diff --git a/.changeset/lovely-insects-shout.md b/.changeset/lovely-insects-shout.md new file mode 100644 index 0000000..4d88519 --- /dev/null +++ b/.changeset/lovely-insects-shout.md @@ -0,0 +1,6 @@ +--- +"@tapsioss/client-socket-manager": patch +--- + +Preserve the scroll position of channels and logs section inside the DevTool. + \ No newline at end of file diff --git a/packages/core/src/devtool/ScrollPreservor.test.ts b/packages/core/src/devtool/ScrollPreservor.test.ts new file mode 100644 index 0000000..b913e9d --- /dev/null +++ b/packages/core/src/devtool/ScrollPreservor.test.ts @@ -0,0 +1,119 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { ScrollPreservor } from "./ScrollPreservor.ts"; + +describe("ScrollPreservor", () => { + let element: HTMLElement; + let scrollPreservor: ScrollPreservor; + + beforeEach(() => { + // Set up a mock HTML element for testing + element = document.createElement("div"); + Object.defineProperty(element, "scrollTop", { writable: true, value: 0 }); + scrollPreservor = new ScrollPreservor({ target: element }); + }); + + it("should be able to set and save the scroll position", () => { + const scrollPosition = 100; + + element.scrollTop = scrollPosition; + + scrollPreservor.save(); + + expect(scrollPreservor.savedScrollPosition).toBe(scrollPosition); + }); + + it("should restore the saved scroll position", () => { + const scrollPosition = 50; + + element.scrollTop = scrollPosition; + scrollPreservor.save(); + + // Change the scroll position to ensure it gets restored + element.scrollTop = 0; + scrollPreservor.restore(); + + expect(element.scrollTop).toBe(scrollPosition); + }); + + it("should handle cases where no element is set", () => { + const newScrollPreservor = new ScrollPreservor({}); + + // It should not throw an error and simply do nothing + expect(() => newScrollPreservor.save()).not.toThrow(); + expect(() => newScrollPreservor.restore()).not.toThrow(); + }); + + it("should be able to dynamically set a new element", () => { + const newElement = document.createElement("section"); + + Object.defineProperty(newElement, "scrollTop", { + writable: true, + value: 75, + }); + const scrollPosition = 75; + + // Set the new element and test saving and restoring + scrollPreservor.setTarget(newElement); + scrollPreservor.save(); + + expect(scrollPreservor.savedScrollPosition).toBe(scrollPosition); + + newElement.scrollTop = 0; + scrollPreservor.restore(); + expect(newElement.scrollTop).toBe(scrollPosition); + }); + + it("should handle restoring before saving", () => { + // Ensure that restoring when no position has been saved does not throw an error + // and ideally sets scrollTop to 0 or the initial value. + element.scrollTop = 50; + scrollPreservor.restore(); + + expect(element.scrollTop).toBe(0); + }); + + it("should handle saving with a null element", () => { + const newScrollPreservor = new ScrollPreservor({ target: null }); + + expect(() => newScrollPreservor.save()).not.toThrow(); + }); + + it("should handle restoring with a null element", () => { + const newScrollPreservor = new ScrollPreservor({ target: null }); + + expect(() => newScrollPreservor.restore()).not.toThrow(); + }); + + it("should correctly handle setting a null element", () => { + scrollPreservor.setTarget(null); + expect(() => scrollPreservor.save()).not.toThrow(); + expect(() => scrollPreservor.restore()).not.toThrow(); + }); + + it("should overwrite a previously saved position when a new one is saved", () => { + // Save an initial position + element.scrollTop = 10; + scrollPreservor.save(); + + // Change scroll position and save again + element.scrollTop = 99; + scrollPreservor.save(); + + // Restore and check if it's the latest saved position + element.scrollTop = 0; + scrollPreservor.restore(); + expect(element.scrollTop).toBe(99); + }); + + it("should handle saving and restoring with scrollTop of 0", () => { + // Ensure saving a 0 value works correctly + element.scrollTop = 0; + scrollPreservor.save(); + + // Change scroll position and restore + element.scrollTop = 50; + scrollPreservor.restore(); + + expect(element.scrollTop).toBe(0); + }); +}); diff --git a/packages/core/src/devtool/ScrollPreservor.ts b/packages/core/src/devtool/ScrollPreservor.ts new file mode 100644 index 0000000..e9564e0 --- /dev/null +++ b/packages/core/src/devtool/ScrollPreservor.ts @@ -0,0 +1,52 @@ +export class ScrollPreservor { + private _savedScrollPosition: number = 0; + private _target: HTMLElement | null = null; + + constructor(options?: { target?: HTMLElement | null }) { + const { target } = options ?? {}; + + if (target) { + this._target = target; + } + } + + /** + * Sets the HTML element to monitor. + * @param target The HTML element to monitor. + */ + public setTarget(target: HTMLElement | null): void { + if (!target) { + return; + } + + this._target = target; + } + + public get savedScrollPosition(): number { + return this._savedScrollPosition; + } + + /** + * Saves the current scroll position of the element. + * This method stores the `scrollTop` value of the currently set element. + */ + public save(): void { + if (!this._target) { + return; + } + + this._savedScrollPosition = this._target.scrollTop; + } + + /** + * Restores the saved scroll position to the element. + * This method sets the `scrollTop` value of the element to the previously saved position. + */ + public restore(): void { + if (!this._target) { + return; + } + + this._target.scrollTop = this._savedScrollPosition; + } +} diff --git a/packages/core/src/devtool/devtool.test.ts b/packages/core/src/devtool/devtool.test.ts index 003ee7f..38f8f6e 100644 --- a/packages/core/src/devtool/devtool.test.ts +++ b/packages/core/src/devtool/devtool.test.ts @@ -123,4 +123,36 @@ describe("Devtool", () => { document.querySelectorAll(`.${DEVTOOL_LOGS_SECTION_ID}-item`), ).toHaveLength(LOG_CAPACITY); }); + + it("should preserve scroll position when updating", () => { + // Populate logs to make the log section scrollable + for (let i = 0; i < LOG_CAPACITY; i++) { + devtool.update(state => { + state.logs.enqueue({ + type: LogType.CONNECTION_ERROR, + detail: `log-${i}`, + date: new Date(), + }); + }); + } + + const logSection = devtool.getDevtoolLogSectionElement()!; + const scrollPosition = 50; + + logSection.scrollTop = scrollPosition; + expect(logSection.scrollTop).toBe(scrollPosition); + + // Update the devtool and check if the scroll position is preserved + devtool.update(state => { + state.logs.enqueue({ + type: LogType.CONNECTION_ERROR, + detail: "new log", + date: new Date(), + }); + }); + + const newLogSection = devtool.getDevtoolLogSectionElement()!; + + expect(newLogSection.scrollTop).toBe(scrollPosition); + }); }); diff --git a/packages/core/src/devtool/devtool.ts b/packages/core/src/devtool/devtool.ts index b65701b..aef1ca0 100644 --- a/packages/core/src/devtool/devtool.ts +++ b/packages/core/src/devtool/devtool.ts @@ -17,6 +17,7 @@ import { StatusColorMap, } from "./constants.ts"; import { FixedQueue } from "./FixedQueue.ts"; +import { ScrollPreservor } from "./ScrollPreservor.ts"; import { type DevtoolState, type Log } from "./types.ts"; import { formatDate, @@ -105,6 +106,9 @@ export const getDevtoolSocketIconElement = () => export const getDevtoolCloseIconElement = () => document.getElementById(DEVTOOL_CLOSE_ICON_ID); +const channelsSectionScroll = new ScrollPreservor(); +const logSectionScroll = new ScrollPreservor(); + export const renderChannels = () => { const { channels } = devtool; @@ -250,6 +254,10 @@ export const renderDevtool = () => { }; export const updateInfoSection = () => { + // Save scroll positions before updating the content + logSectionScroll.save(); + channelsSectionScroll.save(); + const infoSection = getDevtoolInfoElement()!; const devtoolInfoStyle = generateInlineStyle({ @@ -275,6 +283,12 @@ export const updateInfoSection = () => { `; + logSectionScroll.setTarget(getDevtoolLogSectionElement()); + channelsSectionScroll.setTarget(getDevtoolChannelsElement()); + + logSectionScroll.restore(); + channelsSectionScroll.restore(); + return infoSection; };