@antelopejs/dms 0.7.6 → 0.7.8
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/CHANGELOG.md +35 -0
- package/dist/test/unit/interfaces/dms-base/column-display.test.js +15 -0
- package/dist/test/unit/interfaces/dms-base/column-display.test.js.map +1 -1
- package/dist/test/unit/interfaces/dms-base/page-live.test.d.ts +1 -0
- package/dist/test/unit/interfaces/dms-base/page-live.test.js +66 -0
- package/dist/test/unit/interfaces/dms-base/page-live.test.js.map +1 -0
- package/dist/test/unit/interfaces/dms-base/table-view-source-reorder.test.js +16 -0
- package/dist/test/unit/interfaces/dms-base/table-view-source-reorder.test.js.map +1 -1
- package/frontend-vue/layers/dms-core/app/composables/period/usePeriod.ts +23 -3
- package/frontend-vue/layers/dms-core/i18n/locales/core-en-GB.json +2 -1
- package/frontend-vue/layers/dms-core/i18n/locales/core-fr-FR.json +2 -1
- package/frontend-vue/layers/dms-layout/app/build/components/layout/RowSidePanel.vue +134 -0
- package/frontend-vue/layers/dms-layout/app/build/composables/layout/rowSidePanel.ts +129 -0
- package/frontend-vue/layers/dms-layout/app/pages/[...slug].vue +6 -0
- package/frontend-vue/layers/dms-layout/app/types/page.ts +3 -0
- package/frontend-vue/layers/dms-layout/i18n/locales/layout-en-GB.json +5 -1
- package/frontend-vue/layers/dms-layout/i18n/locales/layout-fr-FR.json +5 -1
- package/frontend-vue/layers/dms-ui/app/build/components/form/FormSectionNav.vue +30 -15
- package/frontend-vue/layers/dms-ui/app/build/components/table/Table.vue +44 -8
- package/frontend-vue/layers/dms-ui/app/build/components/table-view/LiveHoldBanner.vue +35 -0
- package/frontend-vue/layers/dms-ui/app/build/composables/actions/rowNavigation.ts +17 -0
- package/frontend-vue/layers/dms-ui/app/build/composables/actions/useActionTargets.ts +107 -12
- package/frontend-vue/layers/dms-ui/app/build/composables/blocks/useRefreshTriggers.ts +26 -11
- package/frontend-vue/layers/dms-ui/app/build/composables/data-types/cellHelpers.ts +24 -0
- package/frontend-vue/layers/dms-ui/app/build/composables/data-types/metricCells.ts +58 -16
- package/frontend-vue/layers/dms-ui/app/build/composables/data-types/registerDefaults.ts +30 -3
- package/frontend-vue/layers/dms-ui/app/build/composables/live/currentPageLive.ts +107 -0
- package/frontend-vue/layers/dms-ui/app/build/composables/live/formatElapsed.ts +25 -0
- package/frontend-vue/layers/dms-ui/app/build/composables/live/pageLiveController.ts +229 -0
- package/frontend-vue/layers/dms-ui/app/build/composables/live/shortcuts.ts +8 -0
- package/frontend-vue/layers/dms-ui/app/build/composables/live/useLiveToggleShortcut.ts +25 -0
- package/frontend-vue/layers/dms-ui/app/build/composables/live/usePageLiveHost.ts +46 -0
- package/frontend-vue/layers/dms-ui/app/build/composables/period/periodQuery.ts +330 -0
- package/frontend-vue/layers/dms-ui/app/build/composables/period/usePeriodLocation.ts +189 -0
- package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableListData.ts +15 -3
- package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableLiveHold.ts +77 -0
- package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableRows.ts +53 -18
- package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableViewConfig.ts +1 -0
- package/frontend-vue/layers/dms-ui/app/build/composables/table-view/utils/recordLink.ts +61 -2
- package/frontend-vue/layers/dms-ui/app/build/utils/rowClickTrigger.ts +73 -0
- package/frontend-vue/layers/dms-ui/app/components/blocks/LiveToggleBlock.vue +92 -0
- package/frontend-vue/layers/dms-ui/app/components/field-row/FieldRow.vue +10 -4
- package/frontend-vue/layers/dms-ui/app/components/period/PeriodSelector.vue +19 -0
- package/frontend-vue/layers/dms-ui/app/components/table-view/TableView.vue +90 -12
- package/frontend-vue/layers/dms-ui/app/composables/form/types/field.ts +1 -1
- package/frontend-vue/layers/dms-ui/app/composables/live/usePageLive.ts +52 -0
- package/frontend-vue/layers/dms-ui/app/composables/table-view/shortcuts/index.ts +8 -0
- package/frontend-vue/layers/dms-ui/app/composables/table-view/shortcuts/tableViewRowPanel.ts +30 -0
- package/frontend-vue/layers/dms-ui/app/composables/table-view/types/action-target.ts +15 -0
- package/frontend-vue/layers/dms-ui/app/composables/table-view/types/config.ts +4 -0
- package/frontend-vue/layers/dms-ui/app/config/shortcuts-registry.ts +5 -0
- package/frontend-vue/layers/dms-ui/app/types/live.ts +8 -0
- package/frontend-vue/layers/dms-ui/app/types/row-action.ts +5 -2
- package/frontend-vue/layers/dms-ui/app/utils/blockRefresh.ts +44 -6
- package/frontend-vue/layers/dms-ui/i18n/locales/ui-en-GB.json +28 -0
- package/frontend-vue/layers/dms-ui/i18n/locales/ui-fr-FR.json +28 -0
- package/frontend-vue/tests/chart-live-pause.test.ts +74 -0
- package/frontend-vue/tests/field-row.test.ts +14 -0
- package/frontend-vue/tests/form-section-nav-groups.test.ts +75 -14
- package/frontend-vue/tests/live-hold-banner.test.ts +37 -0
- package/frontend-vue/tests/live-toggle.test.ts +162 -0
- package/frontend-vue/tests/page-live-gate.test.ts +241 -0
- package/frontend-vue/tests/period-live-pause.test.ts +120 -0
- package/frontend-vue/tests/period-selector-url.test.ts +268 -0
- package/frontend-vue/tests/period-url-query.test.ts +351 -0
- package/frontend-vue/tests/row-side-panel.test.ts +653 -0
- package/frontend-vue/tests/table-cell-tones.test.ts +176 -0
- package/frontend-vue/tests/table-live-hold.test.ts +204 -0
- package/frontend-vue/tests/table-source-period-scope.test.ts +144 -0
- package/frontend-vue/tests/table-view-refresh.test.ts +2 -2
- package/frontend-vue/tests/table-view-sources.test.ts +40 -0
- package/frontend-vue/tests/vue-inventory.test.ts +1 -1
- package/package.json +2 -2
- package/skills/dms/SKILL.md +11 -3
- package/skills/dms-pages/REFERENCE.md +5 -0
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { registerFieldTypes } from "./registerFieldTypes";
|
|
2
|
-
import { defineAsyncComponent, type VNode } from "vue";
|
|
2
|
+
import { defineAsyncComponent, type VNode, type VNodeChild } from "vue";
|
|
3
3
|
import type { DataType } from "#dms-core/app/composables/data-types/useDataType";
|
|
4
4
|
import UAvatar from "@nuxt/ui/components/Avatar.vue";
|
|
5
5
|
import UBadge from "@nuxt/ui/components/Badge.vue";
|
|
@@ -10,6 +10,7 @@ import { mapRelationBeforeState, sameRelationValue } from "./relationValue";
|
|
|
10
10
|
import {
|
|
11
11
|
isSet,
|
|
12
12
|
readRowField,
|
|
13
|
+
readRowTone,
|
|
13
14
|
readSubline,
|
|
14
15
|
type Row,
|
|
15
16
|
stringOf,
|
|
@@ -21,7 +22,7 @@ import { useComposedText } from "../../../../../dms-core/app/composables/transla
|
|
|
21
22
|
import StatusPill, {
|
|
22
23
|
type StatusPillVariant,
|
|
23
24
|
} from "../../../components/status-pill/StatusPill.vue";
|
|
24
|
-
import { isDmsTone } from "../../utils/tone";
|
|
25
|
+
import { DMS_TONE_TEXT, isDmsTone } from "../../utils/tone";
|
|
25
26
|
import IdentityCell from "../../components/table-view/IdentityCell.vue";
|
|
26
27
|
import {
|
|
27
28
|
firstNameOf,
|
|
@@ -738,6 +739,32 @@ const registerCellTypes = (registerDataType: (dataType: DataType) => void) => {
|
|
|
738
739
|
});
|
|
739
740
|
};
|
|
740
741
|
|
|
742
|
+
interface TextOptions {
|
|
743
|
+
/** Row field holding the text's tone. */
|
|
744
|
+
toneField?: string;
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
/**
|
|
748
|
+
* `string`: the value as written, in the tone the row gives (`toneField`) when
|
|
749
|
+
* it gives one; a row without one keeps the bare value, as a string column
|
|
750
|
+
* always drew it.
|
|
751
|
+
*/
|
|
752
|
+
function renderText(
|
|
753
|
+
value: unknown,
|
|
754
|
+
_locale: string,
|
|
755
|
+
options?: unknown,
|
|
756
|
+
row?: Row,
|
|
757
|
+
): unknown {
|
|
758
|
+
const tone = readRowTone(
|
|
759
|
+
row,
|
|
760
|
+
(options as TextOptions | undefined)?.toneField,
|
|
761
|
+
);
|
|
762
|
+
if (!tone) return value;
|
|
763
|
+
// The value stays the child, as the untoned cell returns it: a number or a
|
|
764
|
+
// list renders as it did, never as "[object Object]".
|
|
765
|
+
return h("span", { class: DMS_TONE_TEXT[tone] }, [value as VNodeChild]);
|
|
766
|
+
}
|
|
767
|
+
|
|
741
768
|
const registerPrimitiveTypes = (
|
|
742
769
|
registerDataType: (dataType: DataType) => void,
|
|
743
770
|
) => {
|
|
@@ -747,7 +774,7 @@ const registerPrimitiveTypes = (
|
|
|
747
774
|
});
|
|
748
775
|
registerDataType({
|
|
749
776
|
id: "string",
|
|
750
|
-
formatter: { default:
|
|
777
|
+
formatter: { default: renderText },
|
|
751
778
|
});
|
|
752
779
|
registerDataType({
|
|
753
780
|
id: "price",
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import {
|
|
2
|
+
computed,
|
|
3
|
+
getCurrentScope,
|
|
4
|
+
onScopeDispose,
|
|
5
|
+
shallowRef,
|
|
6
|
+
type ComputedRef,
|
|
7
|
+
} from "vue";
|
|
8
|
+
import type {
|
|
9
|
+
LiveSubscription,
|
|
10
|
+
PageLiveController,
|
|
11
|
+
} from "./pageLiveController";
|
|
12
|
+
|
|
13
|
+
// Client-only state: the page renderer sets it in the browser, where one app
|
|
14
|
+
// shows one page at a time. The server renders no event and no refetch, so it
|
|
15
|
+
// never needs one, and a module-level value there would be shared between
|
|
16
|
+
// requests.
|
|
17
|
+
const currentController = shallowRef<PageLiveController | null>(null);
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The live controller of the page on screen, `null` when the page has no
|
|
21
|
+
* `live` option (or while server-rendering).
|
|
22
|
+
*
|
|
23
|
+
* @internal
|
|
24
|
+
*/
|
|
25
|
+
export function currentPageLive(): PageLiveController | null {
|
|
26
|
+
return currentController.value;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Installs the controller of the page on screen. The page renderer calls it.
|
|
31
|
+
*
|
|
32
|
+
* @internal
|
|
33
|
+
*/
|
|
34
|
+
export function setCurrentPageLive(controller: PageLiveController | null) {
|
|
35
|
+
if (typeof window === "undefined") return;
|
|
36
|
+
currentController.value = controller;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** @internal */
|
|
40
|
+
export interface LiveGate {
|
|
41
|
+
/** An event behind the block arrived: refetch, now or later, or count it. */
|
|
42
|
+
trigger(event?: object): void;
|
|
43
|
+
/** Events the block missed while the page is paused. */
|
|
44
|
+
missed: ComputedRef<number>;
|
|
45
|
+
/** When the block last read through the gate (ms), 0 before or without `live`. */
|
|
46
|
+
lastReadAt: ComputedRef<number>;
|
|
47
|
+
/** Refetches now and forgets what was missed. */
|
|
48
|
+
catchUp(): void;
|
|
49
|
+
/** Records a read the caller makes itself (see LiveSubscription). */
|
|
50
|
+
acknowledge(): void;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
interface BoundSubscription {
|
|
54
|
+
controller: PageLiveController;
|
|
55
|
+
subscription: LiveSubscription;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The gate a block's refetch goes through. On a page without `live` it calls
|
|
60
|
+
* `refresh` at once, as before. Bound lazily, on the first event, to the
|
|
61
|
+
* controller of the page on screen: a block of the page layout sets up before
|
|
62
|
+
* the page installs its controller.
|
|
63
|
+
*
|
|
64
|
+
* @internal
|
|
65
|
+
*/
|
|
66
|
+
export function useLiveGate(refresh: () => void): LiveGate {
|
|
67
|
+
const bound = shallowRef<BoundSubscription | null>(null);
|
|
68
|
+
|
|
69
|
+
const unbind = () => {
|
|
70
|
+
bound.value?.subscription.unsubscribe();
|
|
71
|
+
bound.value = null;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const resolve = (): LiveSubscription | null => {
|
|
75
|
+
const controller = currentController.value;
|
|
76
|
+
if (bound.value?.controller === controller) {
|
|
77
|
+
return bound.value?.subscription ?? null;
|
|
78
|
+
}
|
|
79
|
+
unbind();
|
|
80
|
+
if (!controller) return null;
|
|
81
|
+
bound.value = { controller, subscription: controller.subscribe(refresh) };
|
|
82
|
+
return bound.value.subscription;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
if (getCurrentScope()) onScopeDispose(unbind);
|
|
86
|
+
|
|
87
|
+
const current = () =>
|
|
88
|
+
bound.value?.controller === currentController.value
|
|
89
|
+
? (bound.value?.subscription ?? null)
|
|
90
|
+
: null;
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
trigger: (event) => {
|
|
94
|
+
const subscription = resolve();
|
|
95
|
+
if (subscription) subscription.trigger(event);
|
|
96
|
+
else refresh();
|
|
97
|
+
},
|
|
98
|
+
missed: computed(() => current()?.missed.value ?? 0),
|
|
99
|
+
lastReadAt: computed(() => current()?.lastReadAt.value ?? 0),
|
|
100
|
+
catchUp: () => {
|
|
101
|
+
const subscription = resolve();
|
|
102
|
+
if (subscription) subscription.catchUp();
|
|
103
|
+
else refresh();
|
|
104
|
+
},
|
|
105
|
+
acknowledge: () => resolve()?.acknowledge(),
|
|
106
|
+
};
|
|
107
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
const MS_PER_SECOND = 1000;
|
|
2
|
+
const SECONDS_PER_MINUTE = 60;
|
|
3
|
+
const MINUTES_PER_HOUR = 60;
|
|
4
|
+
|
|
5
|
+
/** The unit an elapsed time reads in: seconds, then minutes, then hours. */
|
|
6
|
+
export type ElapsedUnit = "seconds" | "minutes" | "hours";
|
|
7
|
+
|
|
8
|
+
export interface Elapsed {
|
|
9
|
+
unit: ElapsedUnit;
|
|
10
|
+
count: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* A duration as "N s", "N min" or "N h": the largest unit it fills once,
|
|
15
|
+
* rounded down. A negative duration (a clock set back) reads as 0 s.
|
|
16
|
+
*
|
|
17
|
+
* @internal
|
|
18
|
+
*/
|
|
19
|
+
export function formatElapsed(elapsedMs: number): Elapsed {
|
|
20
|
+
const seconds = Math.max(0, Math.floor(elapsedMs / MS_PER_SECOND));
|
|
21
|
+
if (seconds < SECONDS_PER_MINUTE) return { unit: "seconds", count: seconds };
|
|
22
|
+
const minutes = Math.floor(seconds / SECONDS_PER_MINUTE);
|
|
23
|
+
if (minutes < MINUTES_PER_HOUR) return { unit: "minutes", count: minutes };
|
|
24
|
+
return { unit: "hours", count: Math.floor(minutes / MINUTES_PER_HOUR) };
|
|
25
|
+
}
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import { readonly, ref, type Ref } from "vue";
|
|
2
|
+
import type { PageLiveOptions } from "../../../types/live";
|
|
3
|
+
|
|
4
|
+
const MS_PER_SECOND = 1000;
|
|
5
|
+
const STORAGE_KEY_PREFIX = "dms.live.paused:";
|
|
6
|
+
const PAUSED_VALUE = "1";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* One block or table of a live page: what it reads again, and what it missed
|
|
10
|
+
* while the page was paused.
|
|
11
|
+
*
|
|
12
|
+
* @internal
|
|
13
|
+
*/
|
|
14
|
+
export interface LiveSubscription {
|
|
15
|
+
/**
|
|
16
|
+
* An event behind the block arrived. `event` identifies it, so one event
|
|
17
|
+
* reaching several blocks counts once in the page's `missedCount`.
|
|
18
|
+
*/
|
|
19
|
+
trigger(event?: object): void;
|
|
20
|
+
/** Events the block received while paused and has not read yet. */
|
|
21
|
+
readonly missed: Readonly<Ref<number>>;
|
|
22
|
+
/** When the block last read through the gate (ms since the epoch), 0 before. */
|
|
23
|
+
readonly lastReadAt: Readonly<Ref<number>>;
|
|
24
|
+
/** Reads now, whatever the state, and forgets what was missed. */
|
|
25
|
+
catchUp(): void;
|
|
26
|
+
/**
|
|
27
|
+
* Records a read the block made by itself (one the user asked for): it
|
|
28
|
+
* forgets what was missed, and a waiting trailing read is dropped.
|
|
29
|
+
*/
|
|
30
|
+
acknowledge(): void;
|
|
31
|
+
unsubscribe(): void;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The Direct / Pause state of one page and the gate every refetch of its
|
|
36
|
+
* blocks goes through (see `PageLiveOptions` in interface-dms).
|
|
37
|
+
*
|
|
38
|
+
* @internal
|
|
39
|
+
*/
|
|
40
|
+
export interface PageLiveController {
|
|
41
|
+
readonly pageId: string;
|
|
42
|
+
readonly isPaused: Readonly<Ref<boolean>>;
|
|
43
|
+
/** When the page last read its data (ms since the epoch). */
|
|
44
|
+
readonly lastUpdatedAt: Readonly<Ref<number>>;
|
|
45
|
+
/** Distinct events received since the pause. */
|
|
46
|
+
readonly missedCount: Readonly<Ref<number>>;
|
|
47
|
+
pause(): void;
|
|
48
|
+
resume(): void;
|
|
49
|
+
toggle(): void;
|
|
50
|
+
subscribe(refresh: () => void): LiveSubscription;
|
|
51
|
+
dispose(): void;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
interface SubscriberState {
|
|
55
|
+
refresh: () => void;
|
|
56
|
+
missed: Ref<number>;
|
|
57
|
+
lastRunAt: Ref<number>;
|
|
58
|
+
timer: ReturnType<typeof setTimeout> | null;
|
|
59
|
+
/** The event the trailing call stands for, counted if the page pauses first. */
|
|
60
|
+
pendingEvent: object | undefined;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Browser storage of the paused pages; absent on the server or when denied. */
|
|
64
|
+
function liveStorage(): Storage | undefined {
|
|
65
|
+
try {
|
|
66
|
+
return typeof window === "undefined" ? undefined : window.localStorage;
|
|
67
|
+
} catch {
|
|
68
|
+
return undefined;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function readStoredPause(pageId: string): boolean {
|
|
73
|
+
return liveStorage()?.getItem(STORAGE_KEY_PREFIX + pageId) === PAUSED_VALUE;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function storePause(pageId: string, isPaused: boolean): void {
|
|
77
|
+
const storage = liveStorage();
|
|
78
|
+
if (!storage) return;
|
|
79
|
+
try {
|
|
80
|
+
if (isPaused) storage.setItem(STORAGE_KEY_PREFIX + pageId, PAUSED_VALUE);
|
|
81
|
+
else storage.removeItem(STORAGE_KEY_PREFIX + pageId);
|
|
82
|
+
} catch {
|
|
83
|
+
// A full or locked storage only loses the memory of the choice.
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
class PageLive implements PageLiveController {
|
|
88
|
+
readonly isPaused: Readonly<Ref<boolean>>;
|
|
89
|
+
readonly lastUpdatedAt: Readonly<Ref<number>>;
|
|
90
|
+
readonly missedCount: Readonly<Ref<number>>;
|
|
91
|
+
private readonly paused: Ref<boolean>;
|
|
92
|
+
private readonly updatedAt = ref(Date.now());
|
|
93
|
+
private readonly missedTotal = ref(0);
|
|
94
|
+
private readonly subscribers = new Set<SubscriberState>();
|
|
95
|
+
private countedEvents = new WeakSet<object>();
|
|
96
|
+
private readonly minIntervalMs: number;
|
|
97
|
+
|
|
98
|
+
constructor(
|
|
99
|
+
readonly pageId: string,
|
|
100
|
+
options: PageLiveOptions,
|
|
101
|
+
) {
|
|
102
|
+
this.minIntervalMs = (options.maxRate ?? 0) * MS_PER_SECOND;
|
|
103
|
+
this.paused = ref(readStoredPause(pageId));
|
|
104
|
+
this.isPaused = readonly(this.paused);
|
|
105
|
+
this.lastUpdatedAt = readonly(this.updatedAt);
|
|
106
|
+
this.missedCount = readonly(this.missedTotal);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
pause(): void {
|
|
110
|
+
if (this.paused.value) return;
|
|
111
|
+
this.paused.value = true;
|
|
112
|
+
storePause(this.pageId, true);
|
|
113
|
+
// A trailing read still waiting stands for an event not shown yet.
|
|
114
|
+
for (const state of this.subscribers) {
|
|
115
|
+
if (!state.timer) continue;
|
|
116
|
+
const event = state.pendingEvent;
|
|
117
|
+
clearTimer(state);
|
|
118
|
+
this.countMissed(state, event);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
resume(): void {
|
|
123
|
+
if (!this.paused.value) return;
|
|
124
|
+
this.paused.value = false;
|
|
125
|
+
storePause(this.pageId, false);
|
|
126
|
+
this.missedTotal.value = 0;
|
|
127
|
+
this.countedEvents = new WeakSet();
|
|
128
|
+
this.updatedAt.value = Date.now();
|
|
129
|
+
for (const state of this.subscribers) {
|
|
130
|
+
if (state.missed.value === 0) continue;
|
|
131
|
+
state.missed.value = 0;
|
|
132
|
+
this.run(state);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
toggle(): void {
|
|
137
|
+
if (this.paused.value) this.resume();
|
|
138
|
+
else this.pause();
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
subscribe(refresh: () => void): LiveSubscription {
|
|
142
|
+
const state: SubscriberState = {
|
|
143
|
+
refresh,
|
|
144
|
+
missed: ref(0),
|
|
145
|
+
lastRunAt: ref(0),
|
|
146
|
+
timer: null,
|
|
147
|
+
pendingEvent: undefined,
|
|
148
|
+
};
|
|
149
|
+
this.subscribers.add(state);
|
|
150
|
+
return {
|
|
151
|
+
trigger: (event) => this.trigger(state, event),
|
|
152
|
+
missed: readonly(state.missed),
|
|
153
|
+
lastReadAt: readonly(state.lastRunAt),
|
|
154
|
+
catchUp: () => {
|
|
155
|
+
state.missed.value = 0;
|
|
156
|
+
this.run(state);
|
|
157
|
+
},
|
|
158
|
+
acknowledge: () => {
|
|
159
|
+
state.missed.value = 0;
|
|
160
|
+
this.markRead(state);
|
|
161
|
+
},
|
|
162
|
+
unsubscribe: () => {
|
|
163
|
+
clearTimer(state);
|
|
164
|
+
this.subscribers.delete(state);
|
|
165
|
+
},
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
dispose(): void {
|
|
170
|
+
for (const state of this.subscribers) clearTimer(state);
|
|
171
|
+
this.subscribers.clear();
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
private trigger(state: SubscriberState, event: object | undefined): void {
|
|
175
|
+
if (this.paused.value) this.countMissed(state, event);
|
|
176
|
+
else this.triggerDirect(state, event);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Leading and trailing: the first event of a quiet block reads at once, the
|
|
180
|
+
// ones within `maxRate` of a read collapse into one read when it ends, so
|
|
181
|
+
// the last event of a burst is never lost.
|
|
182
|
+
private triggerDirect(state: SubscriberState, event: object | undefined) {
|
|
183
|
+
if (state.timer) return;
|
|
184
|
+
const wait = state.lastRunAt.value + this.minIntervalMs - Date.now();
|
|
185
|
+
if (wait <= 0) {
|
|
186
|
+
this.run(state);
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
state.pendingEvent = event;
|
|
190
|
+
state.timer = setTimeout(() => this.run(state), wait);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
private run(state: SubscriberState): void {
|
|
194
|
+
this.markRead(state);
|
|
195
|
+
state.refresh();
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
private markRead(state: SubscriberState): void {
|
|
199
|
+
clearTimer(state);
|
|
200
|
+
state.lastRunAt.value = Date.now();
|
|
201
|
+
this.updatedAt.value = state.lastRunAt.value;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
private countMissed(state: SubscriberState, event: object | undefined) {
|
|
205
|
+
state.missed.value += 1;
|
|
206
|
+
if (event && this.countedEvents.has(event)) return;
|
|
207
|
+
if (event) this.countedEvents.add(event);
|
|
208
|
+
this.missedTotal.value += 1;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function clearTimer(state: SubscriberState): void {
|
|
213
|
+
if (state.timer) clearTimeout(state.timer);
|
|
214
|
+
state.timer = null;
|
|
215
|
+
state.pendingEvent = undefined;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* The controller of a page declared with `live`. Starts in the state the
|
|
220
|
+
* browser remembers for `pageId`, Direct otherwise.
|
|
221
|
+
*
|
|
222
|
+
* @internal
|
|
223
|
+
*/
|
|
224
|
+
export function createPageLiveController(
|
|
225
|
+
pageId: string,
|
|
226
|
+
options: PageLiveOptions,
|
|
227
|
+
): PageLiveController {
|
|
228
|
+
return new PageLive(pageId, options);
|
|
229
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** Switches the page between Direct and Pause, while a LiveToggle is on it. */
|
|
2
|
+
export const LIVE_TOGGLE_L_METADATA: ShortcutMetadata = {
|
|
3
|
+
key: ["l"],
|
|
4
|
+
descriptionKey: "$dms.shortcuts.livetoggle.l.description",
|
|
5
|
+
component: "$dms.components.livetoggle",
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export const LIVE_TOGGLE_SHORTCUTS_METADATA = [LIVE_TOGGLE_L_METADATA];
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { onBeforeUnmount, onMounted } from "vue";
|
|
2
|
+
|
|
3
|
+
// The toggles mounted on the page, in mount order: the first one owns the L
|
|
4
|
+
// key, so a page showing two toggles still switches once per press.
|
|
5
|
+
const claims: symbol[] = [];
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Binds L to `toggle` for the first LiveToggle mounted on the page; the next
|
|
9
|
+
* one takes over when it goes.
|
|
10
|
+
*
|
|
11
|
+
* @internal
|
|
12
|
+
*/
|
|
13
|
+
export function useLiveToggleShortcut(toggle: () => void): void {
|
|
14
|
+
const claim = Symbol("live-toggle");
|
|
15
|
+
onMounted(() => claims.push(claim));
|
|
16
|
+
onBeforeUnmount(() => {
|
|
17
|
+
const index = claims.indexOf(claim);
|
|
18
|
+
if (index >= 0) claims.splice(index, 1);
|
|
19
|
+
});
|
|
20
|
+
defineShortcuts({
|
|
21
|
+
l: () => {
|
|
22
|
+
if (claims[0] === claim) toggle();
|
|
23
|
+
},
|
|
24
|
+
});
|
|
25
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { onScopeDispose, watch } from "vue";
|
|
2
|
+
import type { PageLiveOptions } from "../../../types/live";
|
|
3
|
+
import { setCurrentPageLive } from "./currentPageLive";
|
|
4
|
+
import {
|
|
5
|
+
createPageLiveController,
|
|
6
|
+
type PageLiveController,
|
|
7
|
+
} from "./pageLiveController";
|
|
8
|
+
|
|
9
|
+
/** The page the renderer shows: its id and its `live` option. */
|
|
10
|
+
export interface PageLiveSource {
|
|
11
|
+
pageId: string | undefined;
|
|
12
|
+
live: PageLiveOptions | undefined;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Gives the page on screen its live controller while it declares `live`, and
|
|
17
|
+
* a new one when the renderer moves to another page. Browser only.
|
|
18
|
+
*
|
|
19
|
+
* @internal
|
|
20
|
+
*/
|
|
21
|
+
export function usePageLiveHost(source: () => PageLiveSource): void {
|
|
22
|
+
if (typeof window === "undefined") return;
|
|
23
|
+
let controller: PageLiveController | null = null;
|
|
24
|
+
|
|
25
|
+
const install = (next: PageLiveController | null) => {
|
|
26
|
+
controller?.dispose();
|
|
27
|
+
controller = next;
|
|
28
|
+
setCurrentPageLive(next);
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
watch(
|
|
32
|
+
() => {
|
|
33
|
+
const { pageId, live } = source();
|
|
34
|
+
return pageId && live ? JSON.stringify([pageId, live]) : "";
|
|
35
|
+
},
|
|
36
|
+
(key) => {
|
|
37
|
+
const { pageId, live } = source();
|
|
38
|
+
install(
|
|
39
|
+
key && pageId && live ? createPageLiveController(pageId, live) : null,
|
|
40
|
+
);
|
|
41
|
+
},
|
|
42
|
+
{ immediate: true },
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
onScopeDispose(() => install(null));
|
|
46
|
+
}
|