refactor: migrate infinite scroll feature to a new architecture
This CL introduces a new architecture for the features source code.
Bug: twpowertools:176
Change-Id: I9abc4df2fb67f9bb0c9114aaffc6916d34f1b7ff
diff --git a/src/common/nodeWatcher/NodeWatcher.ts b/src/common/nodeWatcher/NodeWatcher.ts
new file mode 100644
index 0000000..73b9560
--- /dev/null
+++ b/src/common/nodeWatcher/NodeWatcher.ts
@@ -0,0 +1,113 @@
+import {
+ NodeWatcherHandler,
+ NodeMutation,
+ NodeMutationType,
+} from './NodeWatcherHandler';
+
+class NodeWatcher {
+ private handlers: Map<string, NodeWatcherHandler> = new Map();
+ private mutationObserver: MutationObserver;
+
+ constructor() {
+ this.mutationObserver = new MutationObserver(
+ this.mutationCallback.bind(this),
+ );
+ this.start();
+ }
+
+ start(): void {
+ this.mutationObserver.observe(document.body, {
+ childList: true,
+ subtree: true,
+ });
+ }
+
+ pause(): void {
+ this.mutationObserver.disconnect();
+ }
+
+ setHandler(key: string, handler: NodeWatcherHandler): void {
+ this.handlers.set(key, handler);
+ this.performInitialDiscovery(handler);
+ }
+
+ removeHandler(key: string): boolean {
+ return this.handlers.delete(key);
+ }
+
+ private mutationCallback(mutationRecords: MutationRecord[]): void {
+ for (const mutationRecord of mutationRecords) {
+ if (mutationRecord.type !== 'childList') continue;
+ this.handleAddedNodes(mutationRecord);
+ this.handleRemovedNodes(mutationRecord);
+ }
+ }
+
+ private handleAddedNodes(mutationRecord: MutationRecord): void {
+ for (const node of mutationRecord.addedNodes) {
+ this.handleMutatedNode({
+ node,
+ mutationRecord,
+ type: NodeMutationType.NewNode,
+ });
+ }
+ }
+
+ private handleRemovedNodes(mutationRecord: MutationRecord): void {
+ for (const node of mutationRecord.removedNodes) {
+ this.handleMutatedNode({
+ node,
+ mutationRecord,
+ type: NodeMutationType.RemovedNode,
+ });
+ }
+ }
+
+ private performInitialDiscovery(handler: NodeWatcherHandler): void {
+ if (handler.initialDiscoverySelector === undefined) return;
+ const candidateNodes = document.querySelectorAll(
+ handler.initialDiscoverySelector,
+ );
+ for (const candidateNode of candidateNodes) {
+ this.handleMutatedNodeWithHandler(
+ {
+ node: candidateNode,
+ type: NodeMutationType.InitialDiscovery,
+ mutationRecord: null,
+ },
+ handler,
+ );
+ }
+ }
+
+ private handleMutatedNode(nodeMutation: NodeMutation): void {
+ for (const [, handler] of this.handlers) {
+ this.handleMutatedNodeWithHandler(nodeMutation, handler);
+ }
+ }
+
+ private handleMutatedNodeWithHandler(
+ nodeMutation: NodeMutation,
+ handler: NodeWatcherHandler,
+ ): void {
+ if (handler.nodeFilter(nodeMutation)) {
+ handler.onMutatedNode(nodeMutation);
+ }
+ }
+}
+
+export default class NodeWatcherSingleton {
+ private static instance: NodeWatcher;
+
+ /**
+ * @see {@link NodeWatcherSingleton.getInstance}
+ */
+ private constructor() {}
+
+ public static getInstance(): NodeWatcher {
+ if (!NodeWatcherSingleton.instance) {
+ NodeWatcherSingleton.instance = new NodeWatcher();
+ }
+ return NodeWatcherSingleton.instance;
+ }
+}
diff --git a/src/common/nodeWatcher/NodeWatcherHandler.ts b/src/common/nodeWatcher/NodeWatcherHandler.ts
new file mode 100644
index 0000000..b6c3a25
--- /dev/null
+++ b/src/common/nodeWatcher/NodeWatcherHandler.ts
@@ -0,0 +1,52 @@
+export enum NodeMutationType {
+ /**
+ * The node was found during initial discovery.
+ */
+ InitialDiscovery,
+ /**
+ * The node has been added.
+ */
+ NewNode,
+ /**
+ * The node was removed
+ */
+ RemovedNode,
+}
+
+export interface NodeMutation {
+ /**
+ * Node being mutated.
+ */
+ node: Node;
+ /**
+ * Which mutation has occurred to the node.
+ */
+ type: NodeMutationType;
+ /**
+ * MutationRecord from which this node mutation has been extracted. It is null
+ * if the type is {@link NodeMutationType.InitialDiscovery}.
+ */
+ mutationRecord: MutationRecord | null;
+}
+
+export interface NodeWatcherHandler {
+ /**
+ * Only node mutations which pass this filter (it returns true) will be passed
+ * to {@link onMutatedNode}.
+ */
+ nodeFilter: (nodeMutation: NodeMutation) => boolean;
+
+ /**
+ * Optional CSS selector used to discover nodes existing prior to the handler
+ * being established. These matching nodes will be evaluated by
+ * {@link onMutatedNode} if they pass {@link nodeFilter}.
+ *
+ * This is useful when watching an node but it has already been created.
+ */
+ initialDiscoverySelector?: string;
+
+ /**
+ * Function which will be called with each of the filtered node mutations.
+ */
+ onMutatedNode: (nodeMutation: NodeMutation) => void;
+}