@atlaskit/editor-plugin-interactivity
Version:
Interactivity plugin for @atlaskit/editor-core
294 lines (279 loc) • 11.4 kB
JavaScript
import _defineProperty from "@babel/runtime/helpers/defineProperty";
import { getDocument } from '@atlaskit/browser-apis';
import { isExperimentEnabled } from '@atlaskit/platform-feature-experiments/is-experiment-enabled';
import { EditorEventObserver } from './editor-event-observer';
import { InteractionObserver } from './interaction-observer';
import { InteractivitySession } from './interactivity-session';
import { SCHEMA_VERSION } from './bucket-boundaries';
import { LifecycleObserver } from './lifecycle-observer';
import { LongAnimationFrameObserver } from './long-animation-frame-observer';
import { SnapshotScheduler } from './snapshot-scheduler';
/**
* Collects interaction latencies for one editor mount and emits session-to-date snapshots.
*
* Every interaction is counted in `page`, and the ones with the editor again in one of the editor
* groups, which is what tells a slow editor apart from a slow page around it.
*
* `start` and `stop` bound the collecting, which happens once. Within it there can be several
* sessions, because a session covers one document in one mode: a Confluence live page
* navigates and switches between reading and editing without ever remounting the editor, and
* each of those closes the current session and opens the next.
*/
export class InteractivityCollector {
constructor(options) {
_defineProperty(this, "started", false);
_defineProperty(this, "stopped", false);
this.emit = options.emit;
this.getEditorDomSize = options.getEditorDomSize;
this.getNodeSize = options.getNodeSize;
this.getObjectId = options.getObjectId;
this.getSessionMode = options.getSessionMode;
this.interactionObserver = new InteractionObserver(entries => this.recordEntries(entries));
this.longAnimationFrameObserver = isExperimentEnabled('platform_editor_editor_interactivity_slowest') ? new LongAnimationFrameObserver(frames => this.recordFrames(frames)) : undefined;
this.editorEvents = new EditorEventObserver(event => this.recordEditorEvent(event));
this.snapshotScheduler = new SnapshotScheduler(() => this.onTimer());
this.lifecycleObserver = new LifecycleObserver({
onHidden: () => this.onHidden(),
onVisible: () => this.onVisible(),
onPageHide: () => this.onPageHide(),
onPageShow: () => this.onPageShow()
});
this.session = this.createSession(0);
}
/**
* Starts collecting. Calling it again while collecting changes nothing.
*
* @returns whether collecting is running; `false` when the browser cannot support it, or
* when it has already been stopped.
*/
start() {
var _this$longAnimationFr;
// Collecting is over for good once stopped: the session it covered has been reported.
if (this.stopped) {
return false;
}
if (this.started) {
return true;
}
if (!InteractionObserver.isSupported()) {
return false;
}
// Opened again so the session's timings start with the collecting, not with whenever
// this object was constructed.
this.session = this.createSession(0);
this.interactionObserver.start();
(_this$longAnimationFr = this.longAnimationFrameObserver) === null || _this$longAnimationFr === void 0 ? void 0 : _this$longAnimationFr.start();
this.editorEvents.observe(this.editorRoot);
this.snapshotScheduler.start();
this.lifecycleObserver.start();
this.started = true;
return true;
}
/** Reports the session as ended by the editor unmounting, and stops collecting. */
stop() {
var _this$longAnimationFr2;
if (this.stopped) {
return;
}
this.takeSnapshot('unmount');
this.stopped = true;
this.interactionObserver.stop();
(_this$longAnimationFr2 = this.longAnimationFrameObserver) === null || _this$longAnimationFr2 === void 0 ? void 0 : _this$longAnimationFr2.stop();
this.editorEvents.stop();
this.snapshotScheduler.stop();
this.lifecycleObserver.stop();
}
/**
* The element the editor renders itself into is what makes an interaction one of the editor's.
* The session is not tied to it: interactions from before it arrives are counted in `page`.
*/
setEditorRoot(root) {
this.editorRoot = root !== null && root !== void 0 ? root : undefined;
if (this.started && !this.stopped) {
this.editorEvents.observe(this.editorRoot);
}
}
/**
* Rotates the session when the editor is pointed at different content.
*
* An unknown object id is no information, never a change. The provider resolves
* asynchronously after mount, and `contextIdentifierPlugin` resets its state to the
* configured provider on transactions that do not carry a new one, so the id read here goes
* missing for a moment on a document that never changed.
*/
onObjectIdChanged() {
if (this.stopped) {
return;
}
const objectId = this.getObjectId();
if (objectId === undefined || objectId === this.session.objectId) {
return;
}
if (this.session.objectId === undefined) {
this.session.objectId = objectId;
return;
}
this.rotateSession('navigation');
}
/**
* Rotates the session when the editor switches between reading and editing. Interactions
* with a read-only page are a different population from interactions while editing, so one
* session never covers both.
*/
onViewModeChanged() {
if (this.stopped) {
return;
}
const mode = this.getSessionMode();
if (mode === undefined || mode === this.session.mode) {
return;
}
if (this.session.mode === undefined) {
this.session.mode = mode;
return;
}
this.rotateSession('modeChange');
}
/**
* @param startsAfterInteractionId the highest interaction the previous session saw, so
* entries still arriving for it are not counted here as well.
*/
createSession(startsAfterInteractionId) {
var _getDocument;
return new InteractivitySession({
objectId: this.getObjectId(),
mode: this.getSessionMode(),
hidden: ((_getDocument = getDocument()) === null || _getDocument === void 0 ? void 0 : _getDocument.visibilityState) === 'hidden',
startsAfterInteractionId
});
}
/** Reports the current session as ended by `reason` and puts a new one in its place. */
rotateSession(reason) {
this.takeSnapshot(reason);
this.session = this.createSession(this.session.tracker.lastInteractionId);
this.snapshotScheduler.restart();
}
onTimer() {
this.takeSnapshot('timer');
}
onHidden() {
if (this.session.hiddenSince === undefined) {
this.session.hiddenSince = performance.now();
}
this.takeSnapshot('hidden');
}
onVisible() {
if (this.session.hiddenSince !== undefined) {
this.session.hiddenMs += performance.now() - this.session.hiddenSince;
this.session.hiddenSince = undefined;
}
this.session.lifecycleSnapshotEmitted = false;
}
onPageHide() {
this.takeSnapshot('pagehide');
}
/**
* The page came back from the back/forward cache, which never raises a visibility change.
* The session continues, and the signal that suspended it has been reported, so the next
* one is due.
*/
onPageShow() {
this.session.lifecycleSnapshotEmitted = false;
}
/**
* Counts an interaction with the editor, including the ones Event Timing never reports because
* they were faster than its threshold — which is why a count alone moves the session on.
*/
recordEditorEvent(event) {
if (this.stopped) {
return;
}
// An event a script dispatched is no interaction: the browser counts none of them either.
if (!event.isTrusted) {
return;
}
const group = this.session.tracker.recordEditorEvent(event);
if (!group) {
return;
}
// The session names its editor groups after the fields they are reported in.
this.session[group].countTotal();
this.session.revision += 1;
}
/** Long Animation Frames say where the latency of a slow interaction went. */
recordFrames(frames) {
var _this$session$slowest;
if (this.stopped) {
return;
}
if ((_this$session$slowest = this.session.slowest) !== null && _this$session$slowest !== void 0 && _this$session$slowest.trackLongAnimationFrames(frames)) {
this.session.revision += 1;
}
}
recordEntries(entries) {
for (const entry of entries) {
for (const update of this.session.tracker.merge(entry)) {
var _this$session$slowest2;
let groupsChanged = this.session.page.trackInteractionUpdate(update);
if (update.group) {
groupsChanged = this.session[update.group].trackInteractionUpdate(update) || groupsChanged;
}
const slowestChanged = (_this$session$slowest2 = this.session.slowest) === null || _this$session$slowest2 === void 0 ? void 0 : _this$session$slowest2.trackInteractionUpdate(entry, update);
if (groupsChanged || slowestChanged) {
this.session.revision += 1;
}
}
}
}
takeSnapshot(reason) {
var _this$longAnimationFr3, _session$slowest;
if (this.stopped) {
return;
}
const session = this.session;
// Checked before anything is consumed: `visibilitychange` and `pagehide` fire back to
// back on the same transition and it is reported once, and draining first would take
// entries out of the browser's queue only to suppress the snapshot carrying them.
const lifecycleSignal = reason === 'hidden' || reason === 'pagehide';
if (lifecycleSignal && session.lifecycleSnapshotEmitted) {
return;
}
// Must run before the change check: the browser may be holding entries and frames that have
// not reached the observer callbacks yet, and on `pagehide` there is no later chance to
// pick them up. Entries first, so a record the frames answer for exists by then.
this.interactionObserver.drain();
(_this$longAnimationFr3 = this.longAnimationFrameObserver) === null || _this$longAnimationFr3 === void 0 ? void 0 : _this$longAnimationFr3.drain();
if (session.revision === session.emittedRevision) {
return;
}
if (lifecycleSignal) {
session.lifecycleSnapshotEmitted = true;
}
session.seq += 1;
session.emittedRevision = session.revision;
const now = performance.now();
const hiddenMs = session.hiddenMs + (session.hiddenSince === undefined ? 0 : now - session.hiddenSince);
const pageTotalCount = InteractionObserver.readPageInteractionCount() - session.interactionCountAtStart;
const slowest = (_session$slowest = session.slowest) === null || _session$slowest === void 0 ? void 0 : _session$slowest.snapshot();
this.emit({
schema: SCHEMA_VERSION,
interactivitySessionId: session.id,
objectId: session.objectId,
seq: session.seq,
reason,
sessionMode: session.mode,
activeMs: Math.round(Math.max(0, now - session.startedAt - hiddenMs)),
hiddenMs: Math.round(hiddenMs),
nodeSize: this.getNodeSize(),
editorDomSize: this.getEditorDomSize(),
// Only `page` is told its total; each editor group has counted its own.
page: session.page.snapshot(pageTotalCount),
editorTyping: session.editorTyping.snapshot(),
editorPointer: session.editorPointer.snapshot(),
editorOther: session.editorOther.snapshot(),
...(slowest && {
slowest
})
});
}
}