@netless/app-slide 0.2.106-beta.1 → 0.2.106-beta.3
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/README-zh.md +11 -0
- package/README.md +11 -0
- package/dist/index.d.ts +19 -4
- package/dist/main.cjs.js +172 -172
- package/dist/main.cjs.js.map +1 -1
- package/dist/main.es.js +322 -153
- package/dist/main.es.js.map +1 -1
- package/dist/main.iife.js +176 -176
- package/dist/main.iife.js.map +1 -1
- package/package.json +3 -3
- package/src/SlideController/index.ts +137 -39
- package/src/SlideDocsViewer/index.ts +44 -18
- package/src/SlideDocsViewer/navigation.ts +33 -0
- package/src/index.ts +45 -39
- package/src/utils/focus-transition.ts +51 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@netless/app-slide",
|
|
3
|
-
"version": "0.2.106-beta.
|
|
3
|
+
"version": "0.2.106-beta.3",
|
|
4
4
|
"main": "dist/main.cjs.js",
|
|
5
5
|
"module": "dist/main.es.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"build": "vite build && npm run types",
|
|
10
10
|
"build:dev": "vite build --mode development && npm run types",
|
|
11
11
|
"cleanup": "rimraf ./dist",
|
|
12
|
-
"test": "rimraf test/.cache && esbuild test/ResizableContainer.test.ts --bundle --platform=node --format=cjs --
|
|
12
|
+
"test": "rimraf test/.cache && esbuild test/ResizableContainer.test.ts test/NavigationButtonMode.test.ts test/DocsViewerNavigation.integration.test.ts test/FocusTransition.test.ts --bundle --platform=node --format=cjs --outdir=test/.cache && node test/.cache/ResizableContainer.test.js && node test/.cache/NavigationButtonMode.test.js && node test/.cache/DocsViewerNavigation.integration.test.js && node test/.cache/FocusTransition.test.js && node test/run-runtime-activity.mjs"
|
|
13
13
|
},
|
|
14
14
|
"files": [
|
|
15
15
|
"src",
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
],
|
|
19
19
|
"devDependencies": {
|
|
20
20
|
"@juggle/resize-observer": "^3.3.1",
|
|
21
|
-
"@netless/slide": "1.4.61
|
|
21
|
+
"@netless/slide": "1.4.61",
|
|
22
22
|
"@types/color-string": "^1.5.2",
|
|
23
23
|
"color-string": "^1.9.1",
|
|
24
24
|
"jspdf": "2.5.1",
|
|
@@ -17,8 +17,9 @@ import { SideEffectManager } from "side-effect-manager";
|
|
|
17
17
|
import { Slide, SLIDE_EVENTS } from "@netless/slide";
|
|
18
18
|
import { clamp } from "../utils/helpers";
|
|
19
19
|
import { cachedGetBgColor } from "../utils/bgcolor";
|
|
20
|
-
import { log, verbose, setRoomLogger } from "../utils/logger";
|
|
20
|
+
import { log, verbose, setRoomLogger, logger } from "../utils/logger";
|
|
21
21
|
import { getRoomTracker } from "../utils/tracker";
|
|
22
|
+
import { createFocusTransitionQueue, shouldSlideRuntimeBeActive } from "../utils/focus-transition";
|
|
22
23
|
export { syncSceneWithSlide, createDocsViewerPages } from "./helpers";
|
|
23
24
|
|
|
24
25
|
export const DefaultUrl = "https://convertcdn.netless.link/dynamicConvert";
|
|
@@ -80,6 +81,7 @@ export class SlideControllerBase {
|
|
|
80
81
|
|
|
81
82
|
protected visible: boolean;
|
|
82
83
|
protected savedIsFrozen: boolean;
|
|
84
|
+
protected focused = true;
|
|
83
85
|
|
|
84
86
|
protected invisibleBehavior: "frozen" | "pause";
|
|
85
87
|
|
|
@@ -132,7 +134,9 @@ export class SlideControllerBase {
|
|
|
132
134
|
log("[Slide] render end", slideIndex);
|
|
133
135
|
} else {
|
|
134
136
|
setTimeout(() => {
|
|
137
|
+
if (this.destroyed) return;
|
|
135
138
|
this.ready = true;
|
|
139
|
+
if (this.isLazySetupMode()) void this.reconcileActivity(true).catch(() => undefined);
|
|
136
140
|
resolve();
|
|
137
141
|
}, 1000);
|
|
138
142
|
}
|
|
@@ -282,6 +286,10 @@ export class SlideControllerBase {
|
|
|
282
286
|
protected pollCount = 0;
|
|
283
287
|
protected pollReadyState = () => {
|
|
284
288
|
if (this.ready) {
|
|
289
|
+
if (this.isLazySetupMode()) {
|
|
290
|
+
void this.reconcileActivity().catch(() => undefined);
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
285
293
|
if (this._toFreeze === 1) {
|
|
286
294
|
this.freeze();
|
|
287
295
|
} else if (this._toFreeze === -1) {
|
|
@@ -378,49 +386,117 @@ export class SlideControllerBase {
|
|
|
378
386
|
};
|
|
379
387
|
|
|
380
388
|
public isFrozen = false;
|
|
389
|
+
private resourceStateUnknown = false;
|
|
390
|
+
private managedActivity = false;
|
|
381
391
|
protected _toFreeze: -1 | 0 | 1 = 0; // -1: unfreeze, 0: no change, 1: freeze
|
|
382
392
|
|
|
383
|
-
public
|
|
393
|
+
public setFocusedState = (focused: boolean): Promise<void> => {
|
|
394
|
+
this.focused = focused;
|
|
395
|
+
return this.reconcileActivity();
|
|
396
|
+
};
|
|
397
|
+
|
|
398
|
+
private resourceTransition = createFocusTransitionQueue(
|
|
399
|
+
undefined,
|
|
400
|
+
async active => {
|
|
401
|
+
if (this.destroyed) return;
|
|
402
|
+
// A queued release can become stale before the engine starts it.
|
|
403
|
+
if (active && this.shouldBeActive()) await this.unfreeze();
|
|
404
|
+
else await this.freeze();
|
|
405
|
+
},
|
|
406
|
+
error => logger.warn("[Slide] resource transition failed", this.context.appId, error)
|
|
407
|
+
);
|
|
408
|
+
|
|
409
|
+
private shouldBeActive = (): boolean => {
|
|
410
|
+
const wm = this.context.getWindowManager() as any;
|
|
411
|
+
const host = (this.context as any).getRuntimeActivity?.();
|
|
412
|
+
const lazy = this.isLazySetupMode();
|
|
413
|
+
// New hosts own one activity snapshot. Mixing it with the delayed UI
|
|
414
|
+
// boxState can acknowledge "frozen" while the host expects activation.
|
|
415
|
+
// Old hosts retain the original local/Attribute checks.
|
|
416
|
+
const minimized =
|
|
417
|
+
lazy && host
|
|
418
|
+
? !host.active
|
|
419
|
+
: wm?.boxState === "minimized" ||
|
|
420
|
+
wm?.attributes?.minimized === true ||
|
|
421
|
+
this.context.getBoxStatus() === "minimized";
|
|
422
|
+
return (
|
|
423
|
+
shouldSlideRuntimeBeActive(
|
|
424
|
+
!lazy || this.focused,
|
|
425
|
+
document.visibilityState !== "hidden",
|
|
426
|
+
minimized
|
|
427
|
+
) &&
|
|
428
|
+
(!lazy || host?.active !== false)
|
|
429
|
+
);
|
|
430
|
+
};
|
|
431
|
+
|
|
432
|
+
public reconcileActivity = (force = false): Promise<void> => {
|
|
433
|
+
if (this.destroyed) return Promise.resolve();
|
|
434
|
+
if (this.isLazySetupMode()) this.managedActivity = true;
|
|
435
|
+
if (!this.managedActivity) return Promise.resolve();
|
|
436
|
+
this.visible = document.visibilityState !== "hidden";
|
|
437
|
+
return this.resourceTransition(this.shouldBeActive(), force);
|
|
438
|
+
};
|
|
439
|
+
|
|
440
|
+
public freeze = async (): Promise<void> => {
|
|
441
|
+
const previousIsFrozen = this.isFrozen;
|
|
442
|
+
const previousToFreeze = this._toFreeze;
|
|
384
443
|
this.isFrozen = true;
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
444
|
+
try {
|
|
445
|
+
if (this.ready) {
|
|
446
|
+
log("[Slide] freeze", this.context.appId);
|
|
447
|
+
if (this.invisibleBehavior === "frozen") {
|
|
448
|
+
await this.slide.frozen();
|
|
449
|
+
} else {
|
|
450
|
+
this.slide.pause();
|
|
451
|
+
}
|
|
389
452
|
} else {
|
|
390
|
-
this.
|
|
453
|
+
this._toFreeze = 1;
|
|
391
454
|
}
|
|
392
|
-
|
|
393
|
-
|
|
455
|
+
this.resourceStateUnknown = false;
|
|
456
|
+
} catch (error) {
|
|
457
|
+
this.resourceStateUnknown = true;
|
|
458
|
+
this.isFrozen = previousIsFrozen;
|
|
459
|
+
this._toFreeze = previousToFreeze;
|
|
460
|
+
throw error;
|
|
394
461
|
}
|
|
395
|
-
return Promise.resolve();
|
|
396
462
|
};
|
|
397
463
|
|
|
398
464
|
public unfreeze = async (): Promise<void> => {
|
|
399
465
|
if (!this.visible) return;
|
|
400
|
-
if (!this.isFrozen && this.ready) return;
|
|
466
|
+
if (!this.isFrozen && this.ready && !this.resourceStateUnknown) return;
|
|
467
|
+
const previousIsFrozen = this.isFrozen;
|
|
468
|
+
const previousToFreeze = this._toFreeze;
|
|
401
469
|
this.isFrozen = false;
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
470
|
+
try {
|
|
471
|
+
if (this.ready) {
|
|
472
|
+
let isNeedSyncState = false;
|
|
473
|
+
log("[Slide] unfreeze", this.context.appId);
|
|
474
|
+
if (this.invisibleBehavior === "frozen") {
|
|
475
|
+
await this.slide.release(() => {
|
|
476
|
+
// release 会重建 player,内部 observer 已关闭,需要按当前 frame 尺寸重绘
|
|
477
|
+
if (!this.isLazySetupMode() || this.shouldBeActive()) this.slide.notifyFrameResize();
|
|
478
|
+
});
|
|
479
|
+
isNeedSyncState = true;
|
|
480
|
+
} else {
|
|
481
|
+
this.slide.resume();
|
|
408
482
|
this.slide.notifyFrameResize();
|
|
409
|
-
});
|
|
410
|
-
isNeedSyncState = true;
|
|
411
|
-
} else {
|
|
412
|
-
this.slide.resume();
|
|
413
|
-
this.slide.notifyFrameResize();
|
|
414
|
-
}
|
|
415
|
-
if (isNeedSyncState) {
|
|
416
|
-
const state = this.context.storage.state.state;
|
|
417
|
-
if (state) {
|
|
418
|
-
log("[Slide] sync storage", JSON.stringify(state));
|
|
419
|
-
this.slide.setSlideState(state);
|
|
420
483
|
}
|
|
484
|
+
if (isNeedSyncState && (!this.isLazySetupMode() || this.shouldBeActive())) {
|
|
485
|
+
const state = this.context.storage.state.state;
|
|
486
|
+
if (state) {
|
|
487
|
+
log("[Slide] sync storage", JSON.stringify(state));
|
|
488
|
+
await this.slide.setSlideState(state);
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
} else {
|
|
492
|
+
this._toFreeze = -1;
|
|
421
493
|
}
|
|
422
|
-
|
|
423
|
-
|
|
494
|
+
this.resourceStateUnknown = false;
|
|
495
|
+
} catch (error) {
|
|
496
|
+
this.resourceStateUnknown = true;
|
|
497
|
+
this.isFrozen = previousIsFrozen;
|
|
498
|
+
this._toFreeze = previousToFreeze;
|
|
499
|
+
throw error;
|
|
424
500
|
}
|
|
425
501
|
};
|
|
426
502
|
|
|
@@ -446,6 +522,10 @@ export class SlideControllerBase {
|
|
|
446
522
|
};
|
|
447
523
|
|
|
448
524
|
protected onAppStateChangeHandler = (state: "normal" | "minimized" | "maximized") => {
|
|
525
|
+
if (this.isLazySetupMode() || this.managedActivity) {
|
|
526
|
+
void this.reconcileActivity().catch(() => undefined);
|
|
527
|
+
return;
|
|
528
|
+
}
|
|
449
529
|
if (state === "minimized") {
|
|
450
530
|
log("[Slide] freeze because app state is minimized");
|
|
451
531
|
this.freeze();
|
|
@@ -457,6 +537,10 @@ export class SlideControllerBase {
|
|
|
457
537
|
status: "normal" | "minimized" | "maximized";
|
|
458
538
|
}) => {
|
|
459
539
|
const { appId, status } = payload;
|
|
540
|
+
if (appId === this.context.appId && (this.isLazySetupMode() || this.managedActivity)) {
|
|
541
|
+
void this.reconcileActivity().catch(() => undefined);
|
|
542
|
+
return;
|
|
543
|
+
}
|
|
460
544
|
if (appId === this.context.appId && status === "minimized") {
|
|
461
545
|
log("[Slide] freeze because app status is minimized");
|
|
462
546
|
this.freeze();
|
|
@@ -479,21 +563,35 @@ export class SlideControllerBase {
|
|
|
479
563
|
}
|
|
480
564
|
};
|
|
481
565
|
|
|
566
|
+
protected isLazySetupMode = (): boolean => {
|
|
567
|
+
try {
|
|
568
|
+
return (this.context.getWindowManager() as any)?.lazySetupInMaximizedMode === true;
|
|
569
|
+
} catch {
|
|
570
|
+
return false;
|
|
571
|
+
}
|
|
572
|
+
};
|
|
573
|
+
|
|
482
574
|
protected onVisibilityChange = async () => {
|
|
575
|
+
this.visible = document.visibilityState === "visible";
|
|
576
|
+
if (this.isLazySetupMode() || this.managedActivity) {
|
|
577
|
+
await this.reconcileActivity().catch(() => undefined);
|
|
578
|
+
return;
|
|
579
|
+
}
|
|
483
580
|
const appStatus = this.getAppStatus();
|
|
581
|
+
if (!this.visible) {
|
|
582
|
+
this.savedIsFrozen = this.isFrozen;
|
|
583
|
+
log("[Slide] freeze because tab becomes invisible");
|
|
584
|
+
if (!this.isFrozen) await this.freeze();
|
|
585
|
+
return;
|
|
586
|
+
}
|
|
587
|
+
|
|
484
588
|
if (appStatus === "minimized") {
|
|
485
589
|
log("[Slide] do nothing because app state is minimized");
|
|
486
590
|
return;
|
|
487
591
|
}
|
|
488
|
-
if (!
|
|
489
|
-
this.savedIsFrozen
|
|
490
|
-
|
|
491
|
-
this.freeze();
|
|
492
|
-
} else {
|
|
493
|
-
if (!this.savedIsFrozen) {
|
|
494
|
-
log("[Slide] unfreeze because tab becomes visible", { savedIsFrozen: this.savedIsFrozen });
|
|
495
|
-
this.unfreeze();
|
|
496
|
-
}
|
|
592
|
+
if (!this.savedIsFrozen) {
|
|
593
|
+
log("[Slide] unfreeze because tab becomes visible", { savedIsFrozen: this.savedIsFrozen });
|
|
594
|
+
await this.unfreeze();
|
|
497
595
|
}
|
|
498
596
|
};
|
|
499
597
|
}
|
|
@@ -12,10 +12,11 @@ import { SideEffectManager } from "side-effect-manager";
|
|
|
12
12
|
import { createDocsViewerPages } from "../SlideController";
|
|
13
13
|
import { DocsViewer, type DocsViewerPage } from "../DocsViewer";
|
|
14
14
|
import { ResizableContainer } from "../DocsViewer/ResizableContainer";
|
|
15
|
+
import { navigateWithNavigationButton, resolveNavigationButtonMode } from "./navigation";
|
|
15
16
|
import { logger } from "../utils/logger";
|
|
16
17
|
import { isEditable } from "../utils/helpers";
|
|
17
18
|
import type { Attributes, MagixEvents } from "../typings";
|
|
18
|
-
import type { AppOptions } from "..";
|
|
19
|
+
import type { AppOptions, NavigationButtonMode } from "..";
|
|
19
20
|
import type { SyncEventQueuePolicy } from "@netless/slide";
|
|
20
21
|
import { ResizeObserver as ResizeObserverPolyfill } from "@juggle/resize-observer";
|
|
21
22
|
|
|
@@ -55,6 +56,7 @@ export interface SlideDocsViewerConfig {
|
|
|
55
56
|
appId: string;
|
|
56
57
|
urlInterrupter?: (url: string) => Promise<string>;
|
|
57
58
|
enableScale?: boolean;
|
|
59
|
+
navigationButtonMode?: NavigationButtonMode;
|
|
58
60
|
onPagesReady?: (pages: DocsViewerPage[]) => void;
|
|
59
61
|
onNavigate?: (index: number, origin?: string) => void;
|
|
60
62
|
}
|
|
@@ -79,6 +81,7 @@ export class SlideDocsViewer {
|
|
|
79
81
|
protected isViewMounted = false;
|
|
80
82
|
protected justSildeReadonly = false;
|
|
81
83
|
protected syncEventQueuePolicy: SyncEventQueuePolicy = "fifo";
|
|
84
|
+
private readonly navigationButtonMode: NavigationButtonMode;
|
|
82
85
|
private enableScale: boolean;
|
|
83
86
|
private boxSizeEventCount = 0;
|
|
84
87
|
private latestBoxSize: BoxSize | undefined;
|
|
@@ -98,6 +101,7 @@ export class SlideDocsViewer {
|
|
|
98
101
|
appId,
|
|
99
102
|
urlInterrupter,
|
|
100
103
|
enableScale,
|
|
104
|
+
navigationButtonMode,
|
|
101
105
|
onPagesReady,
|
|
102
106
|
onNavigate,
|
|
103
107
|
}: SlideDocsViewerConfig) {
|
|
@@ -109,6 +113,7 @@ export class SlideDocsViewer {
|
|
|
109
113
|
this.onNavigate = onNavigate || noop;
|
|
110
114
|
this.baseScenePath = baseScenePath;
|
|
111
115
|
this.enableScale = enableScale ?? false;
|
|
116
|
+
this.navigationButtonMode = resolveNavigationButtonMode(navigationButtonMode);
|
|
112
117
|
this.appId = appId;
|
|
113
118
|
this.viewer = new DocsViewer({
|
|
114
119
|
readonly: box.readonly,
|
|
@@ -298,6 +303,7 @@ export class SlideDocsViewer {
|
|
|
298
303
|
}
|
|
299
304
|
|
|
300
305
|
public mount() {
|
|
306
|
+
this.unmountPromise = undefined;
|
|
301
307
|
this.box.mountContent(this.viewer.$content);
|
|
302
308
|
this.box.mountFooter(this.viewer.$footer);
|
|
303
309
|
|
|
@@ -465,29 +471,42 @@ export class SlideDocsViewer {
|
|
|
465
471
|
return (page > 0 ? page : 1) - 1;
|
|
466
472
|
}
|
|
467
473
|
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
this.
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
474
|
+
protected unmountPromise?: Promise<this>;
|
|
475
|
+
protected destroyPromise?: Promise<void>;
|
|
476
|
+
|
|
477
|
+
public unmount(): Promise<this> {
|
|
478
|
+
if (!this.unmountPromise) {
|
|
479
|
+
this.unmountPromise = Promise.resolve().then(async () => {
|
|
480
|
+
this.offBoxSizeChange?.();
|
|
481
|
+
this.offBoxSizeChange = undefined;
|
|
482
|
+
this.contentResizeObserver?.disconnect();
|
|
483
|
+
this.contentResizeObserver = undefined;
|
|
484
|
+
if (this.slideController) {
|
|
485
|
+
const controller = this.slideController;
|
|
486
|
+
this.slideController = null;
|
|
487
|
+
await controller.destroy();
|
|
488
|
+
}
|
|
489
|
+
this.viewer.unmount();
|
|
490
|
+
this.resizableContainer.destroy(this.box);
|
|
491
|
+
return this;
|
|
492
|
+
});
|
|
477
493
|
}
|
|
478
|
-
this.
|
|
479
|
-
this.resizableContainer.destroy(this.box);
|
|
480
|
-
return this;
|
|
494
|
+
return this.unmountPromise;
|
|
481
495
|
}
|
|
482
496
|
|
|
483
497
|
public setReadonly(readonly: boolean) {
|
|
484
498
|
this.viewer.setReadonly(readonly);
|
|
485
499
|
}
|
|
486
500
|
|
|
487
|
-
public
|
|
488
|
-
this.
|
|
489
|
-
|
|
490
|
-
|
|
501
|
+
public destroy(): Promise<void> {
|
|
502
|
+
if (!this.destroyPromise) {
|
|
503
|
+
this.destroyPromise = Promise.resolve().then(async () => {
|
|
504
|
+
this.sideEffect.flushAll();
|
|
505
|
+
await this.unmount();
|
|
506
|
+
this.viewer.destroy();
|
|
507
|
+
});
|
|
508
|
+
}
|
|
509
|
+
return this.destroyPromise;
|
|
491
510
|
}
|
|
492
511
|
|
|
493
512
|
public toggleClickThrough(tool?: string) {
|
|
@@ -541,7 +560,14 @@ export class SlideDocsViewer {
|
|
|
541
560
|
|
|
542
561
|
protected onNewPageIndex = (index: number, origin?: string) => {
|
|
543
562
|
if (this.slideController) {
|
|
544
|
-
|
|
563
|
+
navigateWithNavigationButton(
|
|
564
|
+
this.navigationButtonMode,
|
|
565
|
+
origin,
|
|
566
|
+
this.viewer.pageIndex,
|
|
567
|
+
index,
|
|
568
|
+
this.slideController,
|
|
569
|
+
this.onNavigate
|
|
570
|
+
);
|
|
545
571
|
}
|
|
546
572
|
};
|
|
547
573
|
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { NavigationButtonMode } from "..";
|
|
2
|
+
|
|
3
|
+
export type NavigationButtonAction = "prevStep" | "nextStep";
|
|
4
|
+
|
|
5
|
+
export function resolveNavigationButtonMode(
|
|
6
|
+
mode: NavigationButtonMode | undefined
|
|
7
|
+
): NavigationButtonMode {
|
|
8
|
+
return mode ?? "page";
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface NavigationController {
|
|
12
|
+
readonly page: number;
|
|
13
|
+
readonly slide: Record<NavigationButtonAction, () => void>;
|
|
14
|
+
jumpToPage(page: number, origin?: string): void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function navigateWithNavigationButton(
|
|
18
|
+
mode: NavigationButtonMode,
|
|
19
|
+
origin: string | undefined,
|
|
20
|
+
currentPageIndex: number,
|
|
21
|
+
targetPageIndex: number,
|
|
22
|
+
controller: NavigationController,
|
|
23
|
+
onNavigate: (page: number, origin?: string) => void
|
|
24
|
+
): void {
|
|
25
|
+
if (mode === "step" && origin === "navigation") {
|
|
26
|
+
const action: NavigationButtonAction =
|
|
27
|
+
targetPageIndex < currentPageIndex ? "prevStep" : "nextStep";
|
|
28
|
+
controller.slide[action]();
|
|
29
|
+
onNavigate(controller.page, origin);
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
controller.jumpToPage(targetPageIndex + 1, origin);
|
|
33
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -43,6 +43,8 @@ export { DefaultUrl, apps, FreezerLength, addHooks, useFreezer, log, logger };
|
|
|
43
43
|
|
|
44
44
|
export { setFreezerLength, getFreezerLength, onCreated, onDestroyed } from "./utils/freezer";
|
|
45
45
|
|
|
46
|
+
export type NavigationButtonMode = "page" | "step";
|
|
47
|
+
|
|
46
48
|
export interface AppOptions
|
|
47
49
|
extends Pick<
|
|
48
50
|
ISlideConfig,
|
|
@@ -86,12 +88,14 @@ export interface AppOptions
|
|
|
86
88
|
invisibleBehavior?: "frozen" | "pause";
|
|
87
89
|
/** just readonly, no operate silder */
|
|
88
90
|
justSildeReadonly?: true;
|
|
91
|
+
/** footer navigation button behavior @default 'page' */
|
|
92
|
+
navigationButtonMode?: NavigationButtonMode;
|
|
89
93
|
enableScale?: boolean;
|
|
90
94
|
resourceMaxRetries?: number;
|
|
91
95
|
onResourceMaxRetries: (url: string, error: Error) => void;
|
|
92
96
|
/**
|
|
93
|
-
*
|
|
94
|
-
*
|
|
97
|
+
* First-render warning threshold in ms. Default: 5_000. New lazy hosts
|
|
98
|
+
* continue awaiting real readiness; legacy/eager hosts resolve on timeout.
|
|
95
99
|
*/
|
|
96
100
|
setupReadyTimeout?: number;
|
|
97
101
|
}
|
|
@@ -235,6 +239,7 @@ const SlideApp: NetlessApp<Attributes, MagixEvents, AppOptions, AppResult> & {
|
|
|
235
239
|
appId: context.appId,
|
|
236
240
|
urlInterrupter: context.getAppOptions()?.urlInterrupter,
|
|
237
241
|
enableScale: context.getAppOptions()?.enableScale ?? false,
|
|
242
|
+
navigationButtonMode: context.getAppOptions()?.navigationButtonMode,
|
|
238
243
|
onPagesReady: ({ length }) => {
|
|
239
244
|
const index = docsViewer?.viewer.pageIndex || 0;
|
|
240
245
|
context.dispatchAppEvent("pageStateChange", { index, length });
|
|
@@ -284,49 +289,43 @@ const SlideApp: NetlessApp<Attributes, MagixEvents, AppOptions, AppResult> & {
|
|
|
284
289
|
// a lazy-mode-only cache optimization: inactive unless the WindowManager
|
|
285
290
|
// runs with lazySetupInMaximizedMode enabled (read dynamically — lazy can
|
|
286
291
|
// be disabled at runtime, e.g. when forceMaximized is cleared).
|
|
287
|
-
const isBlurFreezeAllowed = (): boolean => {
|
|
288
|
-
const boxStatus = context.getBoxStatus();
|
|
289
|
-
if (boxStatus) return boxStatus !== "normal";
|
|
290
|
-
const boxState = context.getWindowManager()?.boxState;
|
|
291
|
-
return boxState != null && boxState !== "normal";
|
|
292
|
-
};
|
|
293
292
|
sideEffect.add(() =>
|
|
294
|
-
context.emitter.on("focus",
|
|
293
|
+
context.emitter.on("focus", (isFocused: boolean) => {
|
|
295
294
|
if (disposed) return;
|
|
296
|
-
if (!isLazySetupMode()) return;
|
|
297
295
|
const controller = docsViewer?.slideController;
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
if (controller.isFrozen) {
|
|
308
|
-
log("[Slide] focus unfreeze", context.appId);
|
|
309
|
-
await controller.unfreeze();
|
|
310
|
-
}
|
|
296
|
+
// Emittery waits for listener return values. Propagate the transition
|
|
297
|
+
// promise so WindowManager commits focus only after resources are
|
|
298
|
+
// frozen/unfrozen and Slide state restoration has completed.
|
|
299
|
+
return controller?.setFocusedState(isFocused);
|
|
300
|
+
})
|
|
301
|
+
);
|
|
302
|
+
sideEffect.add(() =>
|
|
303
|
+
(context.emitter as any).on("runtimeActivity", () => {
|
|
304
|
+
if (!disposed) return docsViewer?.slideController?.reconcileActivity();
|
|
311
305
|
})
|
|
312
306
|
);
|
|
313
307
|
|
|
314
308
|
let disposed = false;
|
|
315
309
|
let offDestroy: (() => void) | undefined;
|
|
316
|
-
|
|
317
|
-
|
|
310
|
+
let teardownPromise: Promise<void> | undefined;
|
|
311
|
+
const teardown = (): Promise<void> => {
|
|
312
|
+
if (teardownPromise) return teardownPromise;
|
|
318
313
|
disposed = true;
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
docsViewer
|
|
328
|
-
|
|
329
|
-
|
|
314
|
+
// Store the promise before cleanup can synchronously trigger another teardown.
|
|
315
|
+
teardownPromise = Promise.resolve().then(async () => {
|
|
316
|
+
const removeDestroy = offDestroy;
|
|
317
|
+
offDestroy = undefined;
|
|
318
|
+
removeDestroy?.();
|
|
319
|
+
log("[Slide] destroy", context.appId);
|
|
320
|
+
if (useFreezer) apps.delete(context.appId);
|
|
321
|
+
sideEffect.flushAll();
|
|
322
|
+
if (docsViewer) {
|
|
323
|
+
const viewer = docsViewer;
|
|
324
|
+
docsViewer = null;
|
|
325
|
+
await viewer.destroy();
|
|
326
|
+
}
|
|
327
|
+
});
|
|
328
|
+
return teardownPromise;
|
|
330
329
|
};
|
|
331
330
|
offDestroy = context.emitter.on("destroy", teardown);
|
|
332
331
|
|
|
@@ -336,11 +335,12 @@ const SlideApp: NetlessApp<Attributes, MagixEvents, AppOptions, AppResult> & {
|
|
|
336
335
|
(SlideApp as any).__teardownByContext.set(context, teardown);
|
|
337
336
|
|
|
338
337
|
// Resolve once the SlideController finished its first render (renderEnd).
|
|
339
|
-
//
|
|
340
|
-
//
|
|
338
|
+
// New lazy hosts retain the real completion after the warning threshold.
|
|
339
|
+
// Legacy/eager hosts retain timed readiness; disposal/failure rejects below.
|
|
341
340
|
const waitForFirstRender = (timeoutMs: number): Promise<boolean> =>
|
|
342
341
|
new Promise<boolean>(resolve => {
|
|
343
342
|
let settled = false;
|
|
343
|
+
let timedOut = false;
|
|
344
344
|
const settle = (loaded: boolean) => {
|
|
345
345
|
if (settled) return;
|
|
346
346
|
settled = true;
|
|
@@ -348,7 +348,11 @@ const SlideApp: NetlessApp<Attributes, MagixEvents, AppOptions, AppResult> & {
|
|
|
348
348
|
window.clearInterval(pollTimer);
|
|
349
349
|
resolve(loaded);
|
|
350
350
|
};
|
|
351
|
-
const timeoutTimer = window.setTimeout(() =>
|
|
351
|
+
const timeoutTimer = window.setTimeout(() => {
|
|
352
|
+
timedOut = true;
|
|
353
|
+
logger.warn("[Slide] first render still pending", context.appId, timeoutMs);
|
|
354
|
+
if (!(context as any).waitForActualSetupReady) settle(false);
|
|
355
|
+
}, timeoutMs);
|
|
352
356
|
// Success path resolves straight from readyPromise (no polling lag);
|
|
353
357
|
// the poll only catches teardown and pre-ready render failures.
|
|
354
358
|
slideControllerRef?.readyPromise.then(
|
|
@@ -357,6 +361,7 @@ const SlideApp: NetlessApp<Attributes, MagixEvents, AppOptions, AppResult> & {
|
|
|
357
361
|
);
|
|
358
362
|
const pollTimer = window.setInterval(() => {
|
|
359
363
|
if (disposed || firstRenderFailed) settle(false);
|
|
364
|
+
else if (timedOut && !(context as any).waitForActualSetupReady) settle(false);
|
|
360
365
|
}, 100);
|
|
361
366
|
});
|
|
362
367
|
|
|
@@ -498,6 +503,7 @@ const SlideApp: NetlessApp<Attributes, MagixEvents, AppOptions, AppResult> & {
|
|
|
498
503
|
|
|
499
504
|
const setupReadyTimeout = appOptions?.setupReadyTimeout ?? DEFAULT_SLIDE_SETUP_READY_TIMEOUT;
|
|
500
505
|
return waitForFirstRender(setupReadyTimeout).then(() => {
|
|
506
|
+
if (disposed) throw new Error("[Slide] disposed before first render");
|
|
501
507
|
if (firstRenderFailed) {
|
|
502
508
|
throw new Error("[Slide] first render failed before ready");
|
|
503
509
|
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
export type FocusTransitionHandler = (isFocused: boolean) => void | Promise<void>;
|
|
2
|
+
|
|
3
|
+
export function shouldSlideRuntimeBeActive(
|
|
4
|
+
isFocused: boolean,
|
|
5
|
+
isVisible: boolean,
|
|
6
|
+
isMinimized: boolean
|
|
7
|
+
): boolean {
|
|
8
|
+
return isFocused && isVisible && !isMinimized;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Drops duplicate focus states and serializes opposite resource transitions.
|
|
13
|
+
* The state tracks accepted events so an in-flight async transition cannot be
|
|
14
|
+
* started twice by duplicate host signals.
|
|
15
|
+
*/
|
|
16
|
+
export function createFocusTransitionQueue(
|
|
17
|
+
initialState: boolean | undefined,
|
|
18
|
+
apply: FocusTransitionHandler,
|
|
19
|
+
onError?: (error: unknown) => void
|
|
20
|
+
): (isFocused: boolean, force?: boolean) => Promise<void> {
|
|
21
|
+
let acceptedState = initialState;
|
|
22
|
+
let revision = 0;
|
|
23
|
+
let queue = Promise.resolve();
|
|
24
|
+
let acceptedPromise = Promise.resolve();
|
|
25
|
+
|
|
26
|
+
return (isFocused, force = false) => {
|
|
27
|
+
// A duplicate signal must observe the same completion barrier. Returning
|
|
28
|
+
// early here would let the host commit focus while freeze/unfreeze is
|
|
29
|
+
// still running.
|
|
30
|
+
if (!force && isFocused === acceptedState) return acceptedPromise;
|
|
31
|
+
acceptedState = isFocused;
|
|
32
|
+
const transitionRevision = ++revision;
|
|
33
|
+
const transition = queue
|
|
34
|
+
.then(() => apply(isFocused))
|
|
35
|
+
.catch(error => {
|
|
36
|
+
// Permit a later same-state signal to retry only when no newer focus
|
|
37
|
+
// transition has superseded the failed request.
|
|
38
|
+
if (revision === transitionRevision) {
|
|
39
|
+
acceptedState = undefined;
|
|
40
|
+
acceptedPromise = Promise.resolve();
|
|
41
|
+
}
|
|
42
|
+
onError?.(error);
|
|
43
|
+
throw error;
|
|
44
|
+
});
|
|
45
|
+
// Keep later opposite transitions runnable after a failure, while the
|
|
46
|
+
// caller still receives the rejection for this exact transition.
|
|
47
|
+
queue = transition.catch(() => undefined);
|
|
48
|
+
acceptedPromise = transition;
|
|
49
|
+
return transition;
|
|
50
|
+
};
|
|
51
|
+
}
|