From dac48edacea6044d1141b4f94c57950560824cc4 Mon Sep 17 00:00:00 2001 From: frostyfan109 Date: Tue, 27 Feb 2024 16:33:43 -0500 Subject: [PATCH] Significantly improved DOM masking. useLocalStorage can now sync across separate helx tabs --- package.json | 1 + src/app.js | 9 +- src/components/layout/layout.js | 4 +- src/contexts/tour-context/context.tsx | 113 +++++++++++++----- src/hooks/use-local-storage.js | 25 +++- src/hooks/use-synthetic-dom-mask.tsx | 161 ++++++++++++++++++-------- src/views/search.js | 12 +- 7 files changed, 228 insertions(+), 97 deletions(-) diff --git a/package.json b/package.json index 4ebcf69f..6118eacc 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "@testing-library/jest-dom": "^5.11.4", "@testing-library/react": "^11.1.0", "@testing-library/user-event": "^12.1.10", + "@types/gatsbyjs__reach-router": "^2.0.4", "@types/node": "^18.7.8", "@types/react": "^18.0.17", "@types/react-dom": "^18.0.6", diff --git a/src/app.js b/src/app.js index d3816c37..2e6d42f5 100644 --- a/src/app.js +++ b/src/app.js @@ -7,6 +7,7 @@ import { useTourContext } from './contexts' import { Layout } from './components/layout' +import { HelxSearch } from './components/search' import { NotFoundView } from './views' const ContextProviders = ({ children }) => { @@ -18,9 +19,11 @@ const ContextProviders = ({ children }) => { - - {children} - + + + {children} + + diff --git a/src/components/layout/layout.js b/src/components/layout/layout.js index 837c5484..6ff91fd0 100644 --- a/src/components/layout/layout.js +++ b/src/components/layout/layout.js @@ -2,7 +2,7 @@ import { Fragment, useState } from 'react' import { Layout as AntLayout, Button, Menu, Grid, Divider } from 'antd' import { LinkOutlined } from '@ant-design/icons' import { useLocation, useNavigate, Link } from '@gatsbyjs/reach-router' -import { useEnvironment, useAnalytics, useWorkspacesAPI } from '../../contexts'; +import { useEnvironment, useAnalytics, useWorkspacesAPI, useTourContext } from '../../contexts'; import { MobileMenu } from './menu'; import { SidePanel } from '../side-panel/side-panel'; import './layout.css'; @@ -13,6 +13,7 @@ const { useBreakpoint } = Grid export const Layout = ({ children }) => { const { helxAppstoreUrl, routes, context, basePath } = useEnvironment() const { api, loading: apiLoading, loggedIn, appstoreContext } = useWorkspacesAPI() + const { tour } = useTourContext() const { analyticsEvents } = useAnalytics() const { md } = useBreakpoint() const baseLinkPath = context.workspaces_enabled === 'true' ? '/helx' : '' @@ -86,6 +87,7 @@ export const Layout = ({ children }) => { )} +
) : ( diff --git a/src/contexts/tour-context/context.tsx b/src/contexts/tour-context/context.tsx index 7fd8c2b3..8ba1988a 100644 --- a/src/contexts/tour-context/context.tsx +++ b/src/contexts/tour-context/context.tsx @@ -2,9 +2,15 @@ import { Fragment, ReactNode, createContext, useContext, useEffect, useMemo, use import { renderToStaticMarkup } from 'react-dom/server' import { useShepherdTour, Tour, ShepherdOptionsWithType } from 'react-shepherd' import { useEnvironment } from '../environment-context' -import { SearchLayout } from '../../components/search' +import { SearchLayout, useHelxSearch } from '../../components/search' +import { SearchView } from '../../views' import { useSyntheticDOMMask } from '../../hooks' import 'shepherd.js/dist/css/shepherd.css' +const { useLocation, useNavigate } = require('@gatsbyjs/reach-router') + +interface ShepherdOptionsWithTypeFixed extends ShepherdOptionsWithType { + when?: any +} export interface ITourContext { tour: any @@ -29,66 +35,111 @@ function setNativeValue(element: any, value: any) { } export const TourProvider = ({ children }: ITourProvider ) => { - const { context } = useEnvironment() as any + const { context, routes, basePath} = useEnvironment() as any + const { layout } = useHelxSearch() as any + const location = useLocation() + const navigate = useNavigate() + + const removeTrailingSlash = (url: string) => url.endsWith("/") ? url.slice(0, url.length - 1) : url + const activeRoutes = useMemo(() => { + if (basePath === undefined) return undefined + return routes.filter((route: any) => ( + removeTrailingSlash(`${removeTrailingSlash(basePath)}${route.path}`) === removeTrailingSlash(location.pathname) + )).flatMap((route: any) => ([ + route, + ...routes.filter((m: any) => m.path === route.parent) + ])).map((route: any) => route.path) + }, [basePath, routes]) + const isSearchActive = useMemo(() => activeRoutes?.some((route) => route.component instanceof SearchView), [activeRoutes]) - // const searchBarDomMask = useSyntheticDOMMask(".search-bar, .search-button, .search-autocomplete-suggestions") + const searchBarDomMask = useSyntheticDOMMask(".search-bar, .search-button") const tourOptions = useMemo(() => ({ defaultStepOptions: { cancelIcon: { enabled: true - } + }, + scrollTo: true, + canClickTarget: true, + classes: "", + highlightClass: "tour-highlighted", + buttons: [ + { + classes: 'shepherd-button-primary', + text: 'Back', + type: 'back' + }, + { + classes: 'shepherd-button-primary', + text: 'Next', + type: 'next' + } + ] }, useModalOverlay: true - }), []); + }), []) - const tourSteps = useMemo(() => ([ + const tourSteps = useMemo<(ShepherdOptionsWithTypeFixed)[]>(() => ([ { - id: 'intro', + id: 'search.intro', attachTo: { - element: ".search-bar", + element: searchBarDomMask.selector!, on: 'bottom' }, - beforeShowPromise: async () => {}, + beforeShowPromise: async () => { + await navigate(basePath) + const input = document.querySelector(".search-bar input") as HTMLInputElement + if (input) input.focus() + }, buttons: [ { classes: 'shepherd-button-secondary', text: 'Exit', type: 'cancel' }, - // { - // classes: 'shepherd-button-primary', - // text: 'Back', - // type: 'back' - // }, { classes: 'shepherd-button-primary', text: 'Next', type: 'next' } ], - classes: 'custom-1', - highlightClass: 'highlight', - scrollTo: false, - cancelIcon: { - enabled: true, - }, - canClickTarget: true, title: `Welcome to ${ context.meta.title }`, text: renderToStaticMarkup(
You can search for biomedical concepts, studies, and variables here.

- Try typing something and press enter. + Try typing something and press enter or click search. +
+ ), + when: { + show: () => { searchBarDomMask.showMask() }, + hide: () => { searchBarDomMask.hideMask() }, + cancel: () => { searchBarDomMask.hideMask() }, + complete: () => { searchBarDomMask.hideMask() } + } + }, + { + id: 'search.concept.intro', + attachTo: { + element: ".result-card", + on: 'right' + }, + beforeShowPromise: async () => {}, + scrollTo: false, + modalOverlayOpeningPadding: 16, + title: `step 2`, + text: renderToStaticMarkup( +
+ step 2
), when: { - show: () => {}, - hide: () => {}, - cancel: () => {}, - complete: () => {} + show: () => { searchBarDomMask.showMask() }, + // hide: () => { searchBarDomMask.hideMask() }, + // cancel: () => { searchBarDomMask.hideMask() }, + // complete: () => { searchBarDomMask.hideMask() } } } - ]), []) + ]), [isSearchActive, searchBarDomMask, basePath, navigate]) const tour = useShepherdTour({ tourOptions, steps: tourSteps }) @@ -96,23 +147,23 @@ export const TourProvider = ({ children }: ITourProvider ) => { let existingSettings = new Map() // Some default UI behaviors are assumed for the tour (e.g. search will bring you to the concept view first) const override = (name: string, newValue: any) => { - console.log("overriding setting", name) + // console.info("overriding setting", name) existingSettings.set(name, localStorage.getItem(name)) localStorage.setItem(name, JSON.stringify(newValue)) } const restore = (name: string) => { - console.log("restoring", name) + // console.info("restoring setting", name) const restoredValue = existingSettings.get(name)! if (restoredValue === null) localStorage.removeItem(name) else localStorage.setItem(name, restoredValue) } const overrideSettings = () => { - console.log("overriding") + // console.log("overriding") override("search_history", []) override("search_layout", SearchLayout.GRID) } const restoreSettings = () => { - console.log("restoring", Array.from(existingSettings.keys()).length, "settings") + // console.log("restoring", Array.from(existingSettings.keys()).length, "settings") Array.from(existingSettings.keys()).forEach((overridedSetting) => { restore(overridedSetting) }) diff --git a/src/hooks/use-local-storage.js b/src/hooks/use-local-storage.js index 76cd4335..46999330 100644 --- a/src/hooks/use-local-storage.js +++ b/src/hooks/use-local-storage.js @@ -17,7 +17,6 @@ if (!window.hasOwnProperty("localStorage2")) { } } window.localStorage.setItem = function() { - console.log("value:", arguments[1]) const cancelled = window.dispatchEvent(new CustomEvent("localStorageModified", { // If arguments undefined, detail: { type: "set", key: arguments[0], value: arguments[1] }, @@ -43,7 +42,14 @@ window.localStorage.clear = function() { !cancelled && window.localStorage2.clear.apply(this, arguments) } -export const useLocalStorage = (key, initialValue) => { +/** + * + * @param {string} key - Name of the key in localStorage to use. + * @param {any} initialValue - Default value (if key is not set), may be any JSON-serializable value + * @param {boolean} [observeOtherPages=false] - If true, watches for external localStorage changes to the value in other tabs/windows. + * @returns + */ +export const useLocalStorage = (key, initialValue, observeOtherPages=true) => { // State to store our value // Pass initial state function to useState so logic is only executed once const [storedValue, setStoredValue] = useState(() => { @@ -78,6 +84,10 @@ export const useLocalStorage = (key, initialValue) => { useEffect(() => { const storageCallback = (e) => { + const newValue = JSON.parse(localStorage.getItem(key)) + setStoredValue(newValue) + } + const modifiedCallback = (e) => { const { type } = e.detail let newValue switch (type) { @@ -98,15 +108,18 @@ export const useLocalStorage = (key, initialValue) => { break } } - console.log("storage callback new value", key, newValue) // Don't double set state, could lead to weird race conditions with other code working with localStorage. setStoredValue(newValue) } - window.addEventListener("localStorageModified", storageCallback) + // Storage changed in another tab/window + if (observeOtherPages) window.addEventListener("storage", storageCallback) + // Storage changed in this tab + window.addEventListener("localStorageModified", modifiedCallback) return () => { - window.removeEventListener("localStorageModified", storageCallback) + window.removeEventListener("storage", storageCallback) + window.removeEventListener("localStorageModified", modifiedCallback) } - }, [key]) + }, [key, observeOtherPages]) return [storedValue, setValue] } \ No newline at end of file diff --git a/src/hooks/use-synthetic-dom-mask.tsx b/src/hooks/use-synthetic-dom-mask.tsx index 75975c56..8477f247 100644 --- a/src/hooks/use-synthetic-dom-mask.tsx +++ b/src/hooks/use-synthetic-dom-mask.tsx @@ -1,48 +1,67 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { v4 as uuid } from 'uuid' -export const useSyntheticDOMMask = (selector: string, boundInterval: number = 10, selectorInterval: number = 1000) => { - // const mask = useRef(document.createElement("div")) +const prepareMaskContainer = (): HTMLDivElement => { + const mask = document.createElement("div") + mask.id = "mask-" + uuid() + mask.style.position = "relative" + mask.style.zIndex = "999999" + mask.style.pointerEvents = "none" + mask.style.borderRadius = "4px" + mask.style.display = "none" + document.body.prepend(mask) + return mask +} + +export const useSyntheticDOMMask = ( + selector: string, + padding: number = 0, + resizeInterval: number = 0, + selectorInterval: number = 10 +) => { + const [mask] = useState(prepareMaskContainer) const [elements, setElements] = useState(undefined) - const [mask, setMask] = useState(undefined) const [elementMasks, setElementMasks] = useState | undefined>(undefined) const [show, setShow] = useState(false) - const maskContainerId = useRef("mask-" + uuid()) - const resize = (element: HTMLElement, bb: DOMRect) => { - element.style.top = (bb.top) + "px" - element.style.left = (bb.left) + "px" - element.style.width = (bb.width) + "px" - element.style.height = (bb.height) + "px" - } + const resize = useCallback((element: HTMLElement, bb: DOMRect) => { + const elBB = element.getBoundingClientRect() + if (elBB.x !== bb.x) element.style.left = (bb.x) + "px" + if (elBB.y !== bb.y) element.style.top = (bb.y) + "px" + if (elBB.width !== bb.width) element.style.width = (bb.width) + "px" + if (elBB.height !== bb.height) element.style.height = (bb.height) + "px" + }, []) - const computeMinimumBounds = (elements: Element[]): DOMRect => { - if (elements.length === 0) throw new Error() + const computeMinimumBounds = useCallback((elements: Element[]): DOMRect => { + if (elements.length === 0) return new DOMRect(0, 0, 0, 0) let [x1, y1, x2, y2] = [Infinity, Infinity, -Infinity, -Infinity] elements.forEach((element) => { const bb = element.getBoundingClientRect() if (bb.width === 0 || bb.height === 0) return - x1 = Math.min(x1, bb.x) - y1 = Math.min(y1, bb.y) - x2 = Math.max(x2, bb.right) - y2 = Math.max(y2, bb.bottom) + x1 = Math.min(x1, bb.x - padding) + y1 = Math.min(y1, bb.y - padding) + x2 = Math.max(x2, bb.right + padding) + y2 = Math.max(y2, bb.bottom + padding) }) return new DOMRect(x1, y1, x2 - x1, y2 - y1) - } + }, [padding]) + // Maintain up-to-date list of DOM elements matching the given selector useEffect(() => { const interval = window.setInterval(() => { const elements = Array.from(document.querySelectorAll(selector)) - const equal = (elems1: Element[], elems2: Element[]): boolean => { - if (elems1.length !== elems2.length) return false - return elems1.every((e1) => { - return elems2.includes(e1) - }) - } setElements((oldElements) => { - if (oldElements === undefined) return elements - if (equal(oldElements, elements)) return oldElements - return elements + // We absolutely don't want to update if query set hasn't changed. + // Can't use JSON comparison because of circular references in DOM nodes. + if (!oldElements) return elements + if (oldElements.length !== elements.length) return elements + const equal = oldElements.every((oldElement, i) => { + const element = elements[i] + return element === oldElement + }) + if (equal) return oldElements + else return elements + }) }, selectorInterval) return () => { @@ -50,53 +69,101 @@ export const useSyntheticDOMMask = (selector: string, boundInterval: number = 10 } }, [selector, selectorInterval]) + // Make sure the mask displays properly and maintain proper position/size useEffect(() => { + const observers: IntersectionObserver[] = [] let interval: number + let cancelled = false if (!mask || !elementMasks) return - if (!show) mask.remove() + if (!show) mask.style.display = "none" else { - document.body.prepend(mask) - interval = window.setInterval(() => { + mask.style.display = "initial" + + const resizeMasks = () => { const elements = Array.from(elementMasks.keys()) + const maskBounds = computeMinimumBounds(elements) elements.forEach((element) => { const elementMask = elementMasks.get(element)! - resize(elementMask, element.getBoundingClientRect()) + const elementBB = element.getBoundingClientRect() + // Mask elements are positioned relative to the mask container + resize(elementMask, new DOMRect( + elementBB.x - maskBounds.x, + elementBB.y - maskBounds.y, + elementBB.width, + elementBB.height + )) }) - resize(mask, computeMinimumBounds(elements)) - }, boundInterval) + resize(mask, maskBounds) + } + + const resizeLoop = () => { + resizeMasks() + if (!cancelled) window.requestAnimationFrame(resizeLoop) + } + resizeLoop() + + Array.from(elementMasks.keys()).forEach((element) => { + const elementMask = elementMasks.get(element)! + elementMask.style.backgroundColor = "red" + }) + + /* + Array.from(elementMasks.keys()).forEach((element) => { + const elementMask = elementMasks.get(element)! + elementMask.style.backgroundColor = "red" + + // For some reason, intersection observers not working on Chrome when root is specified... + // The idea here is that we observe the intersection ratio between the mask and the element, + // and if the ratio ever goes below 1.0 (100% intersection), then we know the element has changed + // position/size and the mask needs to be resized. This would be the most "efficient" solution for us. + + const observer = new IntersectionObserver((entries) => { + entries.forEach((entry) => { + console.log(entry, entry.intersectionRatio) + }) + }, { root: element }) + observer.observe(element) + observers.push(observer) + }) + */ } return () => { - mask.remove() + observers.forEach((observer) => observer.disconnect()) window.clearInterval(interval) + cancelled = true } - }, [mask, elementMasks, show, boundInterval]) + }, [mask, elementMasks, show, resizeInterval, computeMinimumBounds]) + // Generate the mask useEffect(() => { if (!elements) return - const maskContainer = document.createElement("div") - maskContainer.id = maskContainerId.current - maskContainer.style.position = "fixed" - maskContainer.style.zIndex = "999999" - maskContainer.style.pointerEvents = "none" - const masks = new Map() + // Delete old masks. + mask.innerHTML = "" + const newMasks = new Map() elements.forEach((element) => { const maskElement = document.createElement("div") maskElement.style.backgroundColor = "transparent" - maskElement.style.position = "fixed" - maskContainer.appendChild(maskElement) - masks.set(element, maskElement) + maskElement.style.position = "absolute" + mask.appendChild(maskElement) + newMasks.set(element, maskElement) }) - setMask(maskContainer) - setElementMasks(masks) + setElementMasks(newMasks) + }, [mask, elements]) - }, [elements]) + useEffect(() => { + document.body.prepend(mask) + return () => { + mask.remove() + } + }, []) return { showMask: () => setShow(true), hideMask: () => setShow(false), - selector: "#" + maskContainerId.current + selector: "#" + mask.id, + element: mask } as const } \ No newline at end of file diff --git a/src/views/search.js b/src/views/search.js index 1819ff00..b4be76f6 100644 --- a/src/views/search.js +++ b/src/views/search.js @@ -1,5 +1,5 @@ -import { Fragment, useEffect } from 'react' -import { HelxSearch, SearchForm, Results, useHelxSearch } from '../components/search' +import { Fragment } from 'react' +import { Results, useHelxSearch } from '../components/search' import { Typography } from 'antd' import { Breadcrumbs } from '../components/layout' import { useEnvironment } from '../contexts' @@ -7,13 +7,7 @@ import { useTitle } from './' const { Title } = Typography -export const SearchView = () => ( - - - -) - -const ScopedSearchView = () => { +export const SearchView = () => { const { context } = useEnvironment(); const { query } = useHelxSearch()