From 5fa862e58db985bbfe5b35d0c934a18363a2dddd Mon Sep 17 00:00:00 2001 From: Ofer Shaal Date: Sun, 16 Apr 2023 01:24:30 +0000 Subject: [PATCH 1/2] feat: efficient resize-controller --- packages/outline-core/index.ts | 1 + .../src/controllers/resize-controller.ts | 183 ++++++++++++++++++ 2 files changed, 184 insertions(+) create mode 100644 packages/outline-core/src/controllers/resize-controller.ts diff --git a/packages/outline-core/index.ts b/packages/outline-core/index.ts index c42c963dc..ec5d85f5c 100644 --- a/packages/outline-core/index.ts +++ b/packages/outline-core/index.ts @@ -1,5 +1,6 @@ export { OutlineElement } from './src/outline-element/outline-element'; +export { ResizeController } from './src/controllers/resize-controller'; export { ContainerSizeController } from './src/controllers/container-size-controller'; export { IsHoverableDeviceController } from './src/controllers/is-hoverable-device-controller'; export { LinkedBlockController } from './src/controllers/linked-block-controller'; diff --git a/packages/outline-core/src/controllers/resize-controller.ts b/packages/outline-core/src/controllers/resize-controller.ts new file mode 100644 index 000000000..87571ee10 --- /dev/null +++ b/packages/outline-core/src/controllers/resize-controller.ts @@ -0,0 +1,183 @@ +import { ReactiveControllerHost, ReactiveController } from 'lit'; + +/** + * Debounces a function + * @template T + * @param {T} func - The function to debounce + * @param {number} delay - The delay in milliseconds + * @param {boolean} [immediate=false] - Whether to execute the function immediately + * @returns {(...args: Parameters) => void} - The debounced function + */ +export const debounce = ) => void>( + func: T, + delay: number, + immediate = false +): ((...args: Parameters) => void) => { + let timeoutId: ReturnType | undefined = undefined; + + return function debounced(...args: Parameters) { + const executeFunc = () => func(...args); + + clearTimeout(timeoutId); + + if (immediate && timeoutId === undefined) { + executeFunc(); + } + + timeoutId = setTimeout(executeFunc, delay); + }; +}; + +export type breakpointsRangeType = { + min: number; + max: number; +}; + +/** + * ResizeController class + * @implements {ReactiveController} + */ +export class ResizeController implements ReactiveController { + host: ReactiveControllerHost & HTMLElement; + resizeObserver: ResizeObserver; + elementToObserve: Element; + options: { + debounce: number; + breakpoints: number[]; + }; + currentComponentWidth: number; + currentBreakpointRange: number; + breakpointsRangeArray: breakpointsRangeType[] = []; + + /** + * Create a constructor that takes a host and options + * @param {ReactiveControllerHost & Element} host - The host element + * @param {{debounce?: number; breakpoints?: number[]}} [options={}] - The options object + */ + constructor( + host: ReactiveControllerHost & HTMLElement, + options: { + debounce?: number; + breakpoints?: number[]; + } = {} + ) { + const defaultOptions = { + debounce: 200, + breakpoints: [768], + }; + + /** + * Remove any undefined variables from options object + */ + const filteredOptionsObject = Object.fromEntries( + Object.entries(options).filter(([_, value]) => value !== undefined) + ); + this.options = { ...defaultOptions, ...filteredOptionsObject }; + + this.host = host; + this.host.addController(this); + + this.initializeBreakpointsRangeType(); + } + + /** + * Initialize the breakpoints range array + * + * The default breakpoints array ([768]) will create this breakpoints range array: + * [{min: 0, max: 767}, {min: 768, max: 100000}] + * + * If custom breakpoints array is provided, (for example [768, 1200, 2000]) this breakpoints range array will be created: + * [{min: 0, max: 767}, {min: 768, max: 1199}, {min: 1200, max: 1999}, {min: 2000, max: 100000}] + * + */ + initializeBreakpointsRangeType() { + // This will allow create an additional breakpoint from the last custom breakpoint to 100000 + this.options.breakpoints?.push(100000); + + let minBreakpoint = 0; + this.options.breakpoints?.forEach(breakpoint => { + const newBreakpointRange = { + min: minBreakpoint, + max: breakpoint - 1, + }; + minBreakpoint = breakpoint; + this.breakpointsRangeArray.push(newBreakpointRange); + }); + } + + /** + * Called when the host element is connected to the DOM + */ + hostConnected() { + if (!this.host.style.display) { + // adding `display: block` to :host of component + this.host.style.setProperty( + 'display', + 'var(--style-added-by-resize-controller, block)' + ); + } + + // Create a new ResizeObserver and pass in the function to be called when the element is resized + this.resizeObserver = new ResizeObserver( + (entries: ResizeObserverEntry[]) => { + // Create a debounced version of the onElementResize function + debounce( + this.onElementResize.bind(this), + this.options.debounce + )(entries); + } + ); + + // Get a reference to the element you want to observe + this.elementToObserve = this.host; + + // Observe the element for size changes + this.resizeObserver.observe(this.elementToObserve); + } + + /** + * Called when the host element is disconnected from the DOM + */ + hostDisconnected() { + this.resizeObserver.disconnect(); + } + + /** + * Called when the element is resized + * @param {ResizeObserverEntry[]} _entries - The ResizeObserverEntry array + */ + onElementResize(_entries: ResizeObserverEntry[]) { + this.currentComponentWidth = _entries[0].contentRect.width; + + // skip if width is not yet set + if (this.currentComponentWidth) { + this.calculateNewBreakpointRange(); + } else if (this.currentComponentWidth === 0) { + // eslint-disable-next-line no-console + console.warn( + `resize-controller: No width detected in <${this.host.localName}>. Please confirm it has display: block` + ); + } + } + + /** + * Calculate the new breakpoint based on the current width + */ + calculateNewBreakpointRange() { + let newBreakpointRange = this.currentBreakpointRange; + + this.breakpointsRangeArray.forEach((breakpoint, index) => { + if ( + this.currentComponentWidth >= breakpoint.min && + this.currentComponentWidth <= breakpoint.max + ) { + newBreakpointRange = index; + } + }); + + if (newBreakpointRange !== this.currentBreakpointRange) { + this.currentBreakpointRange = newBreakpointRange; + this.host.requestUpdate(); + } + } +} From 0de70ff2cda0a5b9e42d660d90d7c7dfcaae7ab4 Mon Sep 17 00:00:00 2001 From: Ofer Shaal Date: Mon, 1 May 2023 16:34:08 +0000 Subject: [PATCH 2/2] feat: separate element to watch vs. el to rerender --- packages/outline-core/src/controllers/resize-controller.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/outline-core/src/controllers/resize-controller.ts b/packages/outline-core/src/controllers/resize-controller.ts index 87571ee10..acfe8f160 100644 --- a/packages/outline-core/src/controllers/resize-controller.ts +++ b/packages/outline-core/src/controllers/resize-controller.ts @@ -44,6 +44,7 @@ export class ResizeController implements ReactiveController { options: { debounce: number; breakpoints: number[]; + elementToRerender: ReactiveControllerHost & HTMLElement; }; currentComponentWidth: number; currentBreakpointRange: number; @@ -59,11 +60,13 @@ export class ResizeController implements ReactiveController { options: { debounce?: number; breakpoints?: number[]; + elementToRerender?: ReactiveControllerHost & HTMLElement; } = {} ) { const defaultOptions = { debounce: 200, breakpoints: [768], + elementToRerender: host, }; /** @@ -177,7 +180,7 @@ export class ResizeController implements ReactiveController { if (newBreakpointRange !== this.currentBreakpointRange) { this.currentBreakpointRange = newBreakpointRange; - this.host.requestUpdate(); + this.options.elementToRerender.requestUpdate(); } } }