@netless/app-slide 0.2.106-beta.2 → 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/dist/index.d.ts +10 -2
- package/dist/main.cjs.js +122 -122
- package/dist/main.cjs.js.map +1 -1
- package/dist/main.es.js +175 -65
- package/dist/main.es.js.map +1 -1
- package/dist/main.iife.js +153 -153
- package/dist/main.iife.js.map +1 -1
- package/package.json +2 -2
- package/src/SlideController/index.ts +137 -39
- package/src/index.ts +22 -26
- 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 test/NavigationButtonMode.test.ts test/DocsViewerNavigation.integration.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"
|
|
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",
|
|
@@ -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
|
}
|
package/src/index.ts
CHANGED
|
@@ -94,8 +94,8 @@ export interface AppOptions
|
|
|
94
94
|
resourceMaxRetries?: number;
|
|
95
95
|
onResourceMaxRetries: (url: string, error: Error) => void;
|
|
96
96
|
/**
|
|
97
|
-
*
|
|
98
|
-
*
|
|
97
|
+
* First-render warning threshold in ms. Default: 5_000. New lazy hosts
|
|
98
|
+
* continue awaiting real readiness; legacy/eager hosts resolve on timeout.
|
|
99
99
|
*/
|
|
100
100
|
setupReadyTimeout?: number;
|
|
101
101
|
}
|
|
@@ -289,30 +289,19 @@ const SlideApp: NetlessApp<Attributes, MagixEvents, AppOptions, AppResult> & {
|
|
|
289
289
|
// a lazy-mode-only cache optimization: inactive unless the WindowManager
|
|
290
290
|
// runs with lazySetupInMaximizedMode enabled (read dynamically — lazy can
|
|
291
291
|
// be disabled at runtime, e.g. when forceMaximized is cleared).
|
|
292
|
-
const isBlurFreezeAllowed = (): boolean => {
|
|
293
|
-
const boxStatus = context.getBoxStatus();
|
|
294
|
-
if (boxStatus) return boxStatus !== "normal";
|
|
295
|
-
const boxState = context.getWindowManager()?.boxState;
|
|
296
|
-
return boxState != null && boxState !== "normal";
|
|
297
|
-
};
|
|
298
292
|
sideEffect.add(() =>
|
|
299
|
-
context.emitter.on("focus",
|
|
293
|
+
context.emitter.on("focus", (isFocused: boolean) => {
|
|
300
294
|
if (disposed) return;
|
|
301
|
-
if (!isLazySetupMode()) return;
|
|
302
295
|
const controller = docsViewer?.slideController;
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
if (controller.isFrozen) {
|
|
313
|
-
log("[Slide] focus unfreeze", context.appId);
|
|
314
|
-
await controller.unfreeze();
|
|
315
|
-
}
|
|
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();
|
|
316
305
|
})
|
|
317
306
|
);
|
|
318
307
|
|
|
@@ -346,11 +335,12 @@ const SlideApp: NetlessApp<Attributes, MagixEvents, AppOptions, AppResult> & {
|
|
|
346
335
|
(SlideApp as any).__teardownByContext.set(context, teardown);
|
|
347
336
|
|
|
348
337
|
// Resolve once the SlideController finished its first render (renderEnd).
|
|
349
|
-
//
|
|
350
|
-
//
|
|
338
|
+
// New lazy hosts retain the real completion after the warning threshold.
|
|
339
|
+
// Legacy/eager hosts retain timed readiness; disposal/failure rejects below.
|
|
351
340
|
const waitForFirstRender = (timeoutMs: number): Promise<boolean> =>
|
|
352
341
|
new Promise<boolean>(resolve => {
|
|
353
342
|
let settled = false;
|
|
343
|
+
let timedOut = false;
|
|
354
344
|
const settle = (loaded: boolean) => {
|
|
355
345
|
if (settled) return;
|
|
356
346
|
settled = true;
|
|
@@ -358,7 +348,11 @@ const SlideApp: NetlessApp<Attributes, MagixEvents, AppOptions, AppResult> & {
|
|
|
358
348
|
window.clearInterval(pollTimer);
|
|
359
349
|
resolve(loaded);
|
|
360
350
|
};
|
|
361
|
-
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);
|
|
362
356
|
// Success path resolves straight from readyPromise (no polling lag);
|
|
363
357
|
// the poll only catches teardown and pre-ready render failures.
|
|
364
358
|
slideControllerRef?.readyPromise.then(
|
|
@@ -367,6 +361,7 @@ const SlideApp: NetlessApp<Attributes, MagixEvents, AppOptions, AppResult> & {
|
|
|
367
361
|
);
|
|
368
362
|
const pollTimer = window.setInterval(() => {
|
|
369
363
|
if (disposed || firstRenderFailed) settle(false);
|
|
364
|
+
else if (timedOut && !(context as any).waitForActualSetupReady) settle(false);
|
|
370
365
|
}, 100);
|
|
371
366
|
});
|
|
372
367
|
|
|
@@ -508,6 +503,7 @@ const SlideApp: NetlessApp<Attributes, MagixEvents, AppOptions, AppResult> & {
|
|
|
508
503
|
|
|
509
504
|
const setupReadyTimeout = appOptions?.setupReadyTimeout ?? DEFAULT_SLIDE_SETUP_READY_TIMEOUT;
|
|
510
505
|
return waitForFirstRender(setupReadyTimeout).then(() => {
|
|
506
|
+
if (disposed) throw new Error("[Slide] disposed before first render");
|
|
511
507
|
if (firstRenderFailed) {
|
|
512
508
|
throw new Error("[Slide] first render failed before ready");
|
|
513
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
|
+
}
|