From c4f1c49778c029fc405c4ecd2f89ee6affa067f0 Mon Sep 17 00:00:00 2001 From: Nikita Madeev Date: Wed, 5 Jun 2024 11:24:47 +0300 Subject: [PATCH 1/2] fix: :bug: Consider expanding and collapsing items in total time. --- src/e2e/calculate-total-hashtag-time.spec.ts | 25 ++++++ src/modules/dom-manager/index.ts | 64 +++++++------- src/modules/request-idle-interval/index.ts | 91 ++++++++++++++++++++ src/modules/time-manager/index.ts | 3 +- 4 files changed, 153 insertions(+), 30 deletions(-) create mode 100644 src/modules/request-idle-interval/index.ts diff --git a/src/e2e/calculate-total-hashtag-time.spec.ts b/src/e2e/calculate-total-hashtag-time.spec.ts index e865883..352ddc3 100644 --- a/src/e2e/calculate-total-hashtag-time.spec.ts +++ b/src/e2e/calculate-total-hashtag-time.spec.ts @@ -37,6 +37,31 @@ test.describe('Calculate total hashtag time', () => { } } }); + + // The total time must be recalculated when expanding and collapsing the item containing time. + test('Consider expanding and collapsing an item', async ({ page, testPage }) => { + const toggle = page + .locator('.name', { hasText: 'Swap hashtags on hotkey' }) + .locator('a[data-handbook="expand.toggle"]'); + + // Collapse + await toggle.click(); + + // Waiting for requestIdleInterval + await page.waitForTimeout(1000); + + // Minus 5 hours + await expect(page.locator('#bw-time-counter')).toHaveText('2d 9h 42m 14s'); + + // Expand + await toggle.click(); + + // Waiting for requestIdleInterval + await page.waitForTimeout(1000); + + // Total time should return + await expect(page.locator('#bw-time-counter')).toHaveText('2d 14h 42m 14s'); + }); }); test.describe('Option disabled', () => { diff --git a/src/modules/dom-manager/index.ts b/src/modules/dom-manager/index.ts index 6b264ca..7cf6ed2 100644 --- a/src/modules/dom-manager/index.ts +++ b/src/modules/dom-manager/index.ts @@ -8,7 +8,8 @@ export class DomManager implements IDomManager { private subscribers: ((node: HTMLElement) => void)[] = []; constructor(private logger: ILogger) { - this.observe(); + const observer = new MutationObserver(this.mutationCallback); + observer.observe(document.body, { childList: true, subtree: true }); } public loadingApp(): Promise { @@ -79,34 +80,39 @@ export class DomManager implements IDomManager { }, 1 * 1000); } - private observe() { - const observer = new MutationObserver((mutations) => { - for (const mutation of mutations) { - for (const node of mutation.addedNodes) { - if (!(node instanceof HTMLElement)) continue; - - // Detect app load - if ( - this.resolveLoadingPromise !== null && - (node.matches(`.${PAGE_ELEMENT_CLASS_NAME}`) || - node.querySelector(`.${PAGE_ELEMENT_CLASS_NAME}`)) - ) { - this.resolveLoadingPromise(); - this.resolveLoadingPromise = null; - } - - // Detect any changes on content rows with hashtags - if (node.classList.contains(CONTENT_ROW_ELEMENT_CLASS_NAME)) { - const contentTag = node.querySelector(`.${TAG_ELEMENT_TEXT_CLASS_NAME}`); - - if (contentTag) { - this.subscribers.forEach((c) => c(node)); - } - } - } + private mutationCallback: MutationCallback = ( + mutations: MutationRecord[], + observer: MutationObserver, + ): void => { + for (const mutation of mutations) { + for (const node of mutation.addedNodes) { + this.processingChangedNode(node); } - }); - - observer.observe(document.body, { childList: true, subtree: true }); + } + }; + + private processingChangedNode(node: Node): void { + if (!(node instanceof HTMLElement)) { + return; + } + + // Detect app load + if ( + this.resolveLoadingPromise !== null && + (node.matches(`.${PAGE_ELEMENT_CLASS_NAME}`) || + node.querySelector(`.${PAGE_ELEMENT_CLASS_NAME}`)) + ) { + this.resolveLoadingPromise(); + this.resolveLoadingPromise = null; + } + + // Detect any changes on content rows with hashtags + if (node.classList.contains(CONTENT_ROW_ELEMENT_CLASS_NAME)) { + const contentTag = node.querySelector(`.${TAG_ELEMENT_TEXT_CLASS_NAME}`); + + if (contentTag) { + this.subscribers.forEach((c) => c(node)); + } + } } } diff --git a/src/modules/request-idle-interval/index.ts b/src/modules/request-idle-interval/index.ts new file mode 100644 index 0000000..fb90d05 --- /dev/null +++ b/src/modules/request-idle-interval/index.ts @@ -0,0 +1,91 @@ +type Options = { + interval: number; + timeout?: number; +}; + +type State = { + intervalId?: string | number | NodeJS.Timeout; + requestIdleCallbackId?: number; + isRequestIdleCallbackScheduled: boolean; +}; + +type CancelCallback = () => void; + +/** + * `callback` function is invoked when after `interval` msec and environment is idled. + * `callback` which have a `timeout` specified may be called out-of-order if necessary in order to run them before the timeout elapses. + * @param callback + * @param options + * @return {function} return cancelRequestIdleInterval function + */ +export function requestIdleInterval(callback: () => void, options: Options): CancelCallback { + polyfill(); + + if (options.interval <= options.timeout) { + throw new Error( + `options.timeout should be less than options.interval. Recommended: options.timeout is less than half of options.interval.`, + ); + } + + const state: State = { + isRequestIdleCallbackScheduled: false, + }; + + state.intervalId = setInterval(() => { + // Only schedule the rIC if one has not already been set. + if (state.isRequestIdleCallbackScheduled) { + return; + } + + state.isRequestIdleCallbackScheduled = true; + state.requestIdleCallbackId = requestIdleCallback( + () => { + // Reset the boolean so future rICs can be set. + state.isRequestIdleCallbackScheduled = false; + callback(); + }, + { + timeout: options.timeout, + }, + ); + }, options.interval); + + // Return cancel function + return () => { + if (state.intervalId !== undefined) { + clearInterval(state.intervalId); + } + + if (state.requestIdleCallbackId !== undefined) { + cancelIdleCallback(state.requestIdleCallbackId); + } + }; +} + +// https://developer.chrome.com/blog/using-requestidlecallback +function polyfill(): void { + if ('requestIdleCallback' in window) { + return; + } + + (window as Window).requestIdleCallback = function ( + callback: IdleRequestCallback, + options?: IdleRequestOptions, + ) { + const start = Date.now(); + const intervalId = setTimeout(function () { + callback({ + didTimeout: false, + timeRemaining: function () { + return Math.max(0, 50 - (Date.now() - start)); + }, + }); + }, 1); + + return intervalId as unknown as number; + }; + + (window as Window).cancelIdleCallback = function (id) { + clearTimeout(id); + }; +} diff --git a/src/modules/time-manager/index.ts b/src/modules/time-manager/index.ts index 2dfa6d4..5cccbbf 100644 --- a/src/modules/time-manager/index.ts +++ b/src/modules/time-manager/index.ts @@ -1,3 +1,4 @@ +import { requestIdleInterval } from '../request-idle-interval'; import { formatTime, getTagSeconds } from './utils'; export class TimeManager implements ITimeManager { @@ -17,7 +18,7 @@ export class TimeManager implements ITimeManager { this.renderTotalTime(); this.domManager.subscribe(this.highlightTimeHashtag); - this.domManager.subscribe(this.renderTotalTime); + requestIdleInterval(this.renderTotalTime, { interval: 1000 }); } private createTimeCounterElement() { From 1c4e0f38c9c15969c93064a3732871d169f82b27 Mon Sep 17 00:00:00 2001 From: Nikita Madeev Date: Wed, 5 Jun 2024 11:25:46 +0300 Subject: [PATCH 2/2] chore: :bookmark: Bump version to 2.2.1. --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index fade939..ce28472 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "better-workflowy", - "version": "2.2.0", + "version": "2.2.1", "type": "module", "scripts": { "dev": "vite",