diff --git a/index.html b/index.html index a109122..95d91de 100644 --- a/index.html +++ b/index.html @@ -292,7 +292,7 @@ npm install driver.js { - driverObj.highlight({ element: ".buttons button:nth-child(5)" }); + driverObj.highlight({}); }, 4000); window.setTimeout(() => { diff --git a/src/config.ts b/src/config.ts index a3788ea..0dd9604 100644 --- a/src/config.ts +++ b/src/config.ts @@ -5,6 +5,7 @@ export type Config = { opacity?: number; stagePadding?: number; stageRadius?: number; + popoverOffset?: number; }; let currentConfig: Config = {}; @@ -17,6 +18,7 @@ export function configure(config: Config = {}) { smoothScroll: false, stagePadding: 10, stageRadius: 5, + popoverOffset: 10, ...config, }; } diff --git a/src/driver.ts b/src/driver.ts index 233339b..021edc6 100644 --- a/src/driver.ts +++ b/src/driver.ts @@ -6,14 +6,13 @@ import { destroyHighlight, highlight } from "./highlight"; import { destroyEmitter, listen } from "./emitter"; import "./style.css"; +import { getState, setState } from "./state"; export type DriveStep = { element?: string | Element; popover?: Popover; }; -let isInitialized = false; - export function driver(options: Config = {}) { configure(options); @@ -26,15 +25,12 @@ export function driver(options: Config = {}) { } function init() { - if (isInitialized) { + if (getState("isInitialized")) { return; } - isInitialized = true; - document.body.classList.add( - "driver-active", - getConfig("animate") ? "driver-fade" : "driver-simple" - ); + setState("isInitialized", true); + document.body.classList.add("driver-active", getConfig("animate") ? "driver-fade" : "driver-simple"); initEvents(); @@ -44,11 +40,9 @@ export function driver(options: Config = {}) { } function destroy() { - isInitialized = false; - document.body.classList.remove( - "driver-active", - getConfig("animate") ? "driver-fade" : "driver-simple" - ); + setState("isInitialized", false); + + document.body.classList.remove("driver-active", "driver-fade", "driver-simple"); destroyEvents(); destroyPopover(); @@ -57,7 +51,6 @@ export function driver(options: Config = {}) { destroyEmitter(); } - // @todo make popover selectable return { drive: (steps: DriveStep[]) => console.log(steps), highlight: (step: DriveStep) => { diff --git a/src/events.ts b/src/events.ts index a2bc80a..3f40511 100644 --- a/src/events.ts +++ b/src/events.ts @@ -1,14 +1,14 @@ import { refreshActiveHighlight } from "./highlight"; import { emit } from "./emitter"; - -let resizeTimeout: number; +import { getState, setState } from "./state"; function requireRefresh() { + const resizeTimeout = getState("resizeTimeout"); if (resizeTimeout) { window.cancelAnimationFrame(resizeTimeout); } - resizeTimeout = window.requestAnimationFrame(refreshActiveHighlight); + setState("resizeTimeout", window.requestAnimationFrame(refreshActiveHighlight)); } function onKeyup(e: KeyboardEvent) { @@ -32,10 +32,7 @@ export function onDriverClick( listener: (pointer: MouseEvent | PointerEvent) => void, shouldPreventDefault?: (target: HTMLElement) => boolean ) { - const listenerWrapper = ( - e: MouseEvent | PointerEvent, - listener?: (pointer: MouseEvent | PointerEvent) => void - ) => { + const listenerWrapper = (e: MouseEvent | PointerEvent, listener?: (pointer: MouseEvent | PointerEvent) => void) => { const target = e.target as HTMLElement; if (!element.contains(target)) { return; diff --git a/src/highlight.ts b/src/highlight.ts index f6f317e..834c036 100644 --- a/src/highlight.ts +++ b/src/highlight.ts @@ -3,10 +3,7 @@ import { refreshStage, trackActiveElement, transitionStage } from "./stage"; import { getConfig } from "./config"; import { repositionPopover, renderPopover, hidePopover } from "./popover"; import { bringInView } from "./utils"; - -let previousHighlight: Element | undefined; -let activeHighlight: Element | undefined; -let currentTransitionCallback: undefined | (() => void); +import { getState, setState } from "./state"; function mountDummyElement(): Element { const existingDummy = document.getElementById("driver-dummy-element"); @@ -38,13 +35,19 @@ export function highlight(step: DriveStep) { elemObj = mountDummyElement(); } - previousHighlight = activeHighlight; - activeHighlight = elemObj; + const previousHighlight = getState("activeHighlight"); - transferHighlight(previousHighlight || elemObj, elemObj); + const transferHighlightFrom = previousHighlight || elemObj; + const transferHighlightTo = elemObj; + + transferHighlight(transferHighlightFrom, transferHighlightTo); + + setState("previousHighlight", transferHighlightFrom); + setState("activeHighlight", transferHighlightTo); } export function refreshActiveHighlight() { + const activeHighlight = getState("activeHighlight"); if (!activeHighlight) { return; } @@ -61,15 +64,17 @@ function transferHighlight(from: Element, to: Element) { // If it's the first time we're highlighting an element, we show // the popover immediately. Otherwise, we wait for the animation // to finish before showing the popover. - const hasDelayedPopover = !from || from !== to; + const hasDelayedPopover = to && (!from || from !== to); hidePopover(); const animate = () => { + const transitionCallback = getState("transitionCallback"); + // This makes sure that the repeated calls to transferHighlight // don't interfere with each other. Only the last call will be // executed. - if (currentTransitionCallback !== animate) { + if (transitionCallback !== animate) { return; } @@ -84,13 +89,13 @@ function transferHighlight(from: Element, to: Element) { renderPopover(to); } - currentTransitionCallback = undefined; + setState("transitionCallback", undefined); } window.requestAnimationFrame(animate); }; - currentTransitionCallback = animate; + setState("transitionCallback", animate); window.requestAnimationFrame(animate); bringInView(to); @@ -103,10 +108,11 @@ function transferHighlight(from: Element, to: Element) { } export function destroyHighlight() { - activeHighlight = undefined; - currentTransitionCallback = undefined; - previousHighlight = undefined; - activeHighlight = undefined; + setState("activeHighlight", undefined); + setState("previousHighlight", undefined); + + setState("transitionCallback", undefined); + document.getElementById("driver-dummy-element")?.remove(); document.querySelectorAll(".driver-active-element").forEach(element => { diff --git a/src/popover.ts b/src/popover.ts index 82fd4fb..845da20 100644 --- a/src/popover.ts +++ b/src/popover.ts @@ -1,11 +1,10 @@ import { bringInView } from "./utils"; import { getConfig } from "./config"; +import { getState, setState } from "./state"; -export type Side = "top" | "right" | "bottom" | "left"; +export type Side = "top" | "right" | "bottom" | "left" | "over"; export type Alignment = "start" | "center" | "end"; -const POPOVER_OFFSET = 10; - export type Popover = { title?: string; description: string; @@ -13,7 +12,7 @@ export type Popover = { align?: Alignment; }; -type PopoverDOM = { +export type PopoverDOM = { wrapper: HTMLElement; arrow: HTMLElement; title: HTMLElement; @@ -25,9 +24,8 @@ type PopoverDOM = { footerButtons: HTMLElement; }; -let popover: PopoverDOM | undefined; - export function hidePopover() { + const popover = getState("popover"); if (!popover) { return; } @@ -36,6 +34,7 @@ export function hidePopover() { } export function renderPopover(element: Element) { + let popover = getState("popover"); if (!popover) { popover = createPopover(); document.body.appendChild(popover.wrapper); @@ -53,6 +52,8 @@ export function renderPopover(element: Element) { const popoverArrow = popover.arrow; popoverArrow.className = "driver-popover-arrow"; + setState("popover", popover); + repositionPopover(element); bringInView(popoverWrapper); } @@ -65,16 +66,19 @@ type PopoverDimensions = { }; function getPopoverDimensions(): PopoverDimensions | undefined { + const popover = getState("popover"); if (!popover?.wrapper) { return; } const boundingClientRect = popover.wrapper.getBoundingClientRect(); + const stagePadding = getConfig("stagePadding") || 0; + const popoverOffset = getConfig("popoverOffset") || 0; return { - width: boundingClientRect.width + stagePadding + POPOVER_OFFSET, - height: boundingClientRect.height + stagePadding + POPOVER_OFFSET, + width: boundingClientRect.width + stagePadding + popoverOffset, + height: boundingClientRect.height + stagePadding + popoverOffset, realWidth: boundingClientRect.width, realHeight: boundingClientRect.height, @@ -171,6 +175,7 @@ function calculateLeftForTopBottom( } export function repositionPopover(element: Element) { + const popover = getState("popover"); if (!popover) { return; } @@ -178,7 +183,7 @@ export function repositionPopover(element: Element) { // @TODO These values will come from the config // Configure the popover positioning const requiredAlignment: Alignment = "start"; - const requiredSide: Side = "left" as Side; + const requiredSide: Side = element.id === "driver-dummy-element" ? "over" : "left" as Side; const popoverPadding = getConfig('stagePadding') || 0; const popoverDimensions = getPopoverDimensions()!; @@ -210,7 +215,15 @@ export function repositionPopover(element: Element) { isLeftOptimal = isTopOptimal = isBottomOptimal = false; } - if (noneOptimal) { + if (requiredSide === "over") { + const leftToSet = window.innerWidth / 2 - popoverDimensions!.realWidth / 2; + const topToSet = window.innerHeight / 2 - popoverDimensions!.realHeight / 2; + + popover.wrapper.style.left = `${leftToSet}px`; + popover.wrapper.style.right = `auto`; + popover.wrapper.style.top = `${topToSet}px`; + popover.wrapper.style.bottom = `auto`; + } else if (noneOptimal) { const leftValue = window.innerWidth / 2 - popoverDimensions?.realWidth! / 2; const bottomValue = 10; @@ -303,6 +316,7 @@ export function repositionPopover(element: Element) { } function renderPopoverArrow(alignment: Alignment, side: Side, element: Element) { + const popover = getState("popover"); if (!popover) { return; } @@ -458,10 +472,11 @@ function createPopover(): PopoverDOM { } export function destroyPopover() { + const popover = getState("popover"); if (!popover) { return; } popover.wrapper.parentElement?.removeChild(popover.wrapper); - popover = undefined; + setState("popover", undefined); } diff --git a/src/stage.ts b/src/stage.ts index 06eeff4..0bdb265 100644 --- a/src/stage.ts +++ b/src/stage.ts @@ -2,6 +2,7 @@ import { easeInOutQuad } from "./utils"; import { onDriverClick } from "./events"; import { emit } from "./emitter"; import { getConfig } from "./config"; +import { getState, setState } from "./state"; export type StageDefinition = { x: number; @@ -10,14 +11,12 @@ export type StageDefinition = { height: number; }; -let activeStagePosition: StageDefinition | undefined; -let stageSvg: SVGSVGElement | undefined; - // This method calculates the animated new position of the // stage (called for each frame by requestAnimationFrame) export function transitionStage(elapsed: number, duration: number, from: Element, to: Element) { - const fromDefinition = activeStagePosition ? activeStagePosition : from.getBoundingClientRect(); + let activeStagePosition = getState("activeStagePosition"); + const fromDefinition = activeStagePosition ? activeStagePosition : from.getBoundingClientRect(); const toDefinition = to.getBoundingClientRect(); const x = easeInOutQuad(elapsed, fromDefinition.x, toDefinition.x - fromDefinition.x, duration); @@ -33,6 +32,7 @@ export function transitionStage(elapsed: number, duration: number, from: Element }; renderStage(activeStagePosition); + setState("activeStagePosition", activeStagePosition); } export function trackActiveElement(element: Element) { @@ -42,17 +42,22 @@ export function trackActiveElement(element: Element) { const definition = element.getBoundingClientRect(); - activeStagePosition = { + const activeStagePosition: StageDefinition = { x: definition.x, y: definition.y, width: definition.width, height: definition.height, }; + setState("activeStagePosition", activeStagePosition); + renderStage(activeStagePosition); } export function refreshStage() { + const activeStagePosition = getState("activeStagePosition"); + const stageSvg = getState("stageSvg"); + if (!activeStagePosition) { return; } @@ -69,7 +74,7 @@ export function refreshStage() { } function mountStage(stagePosition: StageDefinition) { - stageSvg = createStageSvg(stagePosition); + const stageSvg = createStageSvg(stagePosition); document.body.appendChild(stageSvg); onDriverClick(stageSvg, e => { @@ -80,9 +85,13 @@ function mountStage(stagePosition: StageDefinition) { emit("overlayClick"); }); + + setState("stageSvg", stageSvg); } function renderStage(stagePosition: StageDefinition) { + const stageSvg = getState("stageSvg"); + // TODO: cancel rendering if element is not visible if (!stageSvg) { mountStage(stagePosition); @@ -95,7 +104,7 @@ function renderStage(stagePosition: StageDefinition) { throw new Error("no path element found in stage svg"); } - pathElement.setAttribute("d", generateSvgCutoutPathString(stagePosition)); + pathElement.setAttribute("d", generateStageSvgPathString(stagePosition)); } function createStageSvg(stage: StageDefinition): SVGSVGElement { @@ -122,26 +131,26 @@ function createStageSvg(stage: StageDefinition): SVGSVGElement { svg.style.width = "100%"; svg.style.height = "100%"; - const cutoutPath = document.createElementNS("http://www.w3.org/2000/svg", "path"); + const stagePath = document.createElementNS("http://www.w3.org/2000/svg", "path"); - cutoutPath.setAttribute("d", generateSvgCutoutPathString(stage)); + stagePath.setAttribute("d", generateStageSvgPathString(stage)); - cutoutPath.style.fill = "rgb(0,0,0)"; - cutoutPath.style.opacity = `${getConfig("opacity")}`; - cutoutPath.style.pointerEvents = "auto"; - cutoutPath.style.cursor = "auto"; + stagePath.style.fill = "rgb(0,0,0)"; + stagePath.style.opacity = `${getConfig("opacity")}`; + stagePath.style.pointerEvents = "auto"; + stagePath.style.cursor = "auto"; - svg.appendChild(cutoutPath); + svg.appendChild(stagePath); return svg; } -function generateSvgCutoutPathString(stage: StageDefinition) { +function generateStageSvgPathString(stage: StageDefinition) { const windowX = window.innerWidth; const windowY = window.innerHeight; - const stagePadding = getConfig('stagePadding') || 0; - const stageRadius = getConfig('stageRadius') || 0; + const stagePadding = getConfig("stagePadding") || 0; + const stageRadius = getConfig("stageRadius") || 0; const stageWidth = stage.width + stagePadding * 2; const stageHeight = stage.height + stagePadding * 2; @@ -162,10 +171,11 @@ function generateSvgCutoutPathString(stage: StageDefinition) { } export function destroyStage() { + const stageSvg = getState("stageSvg"); if (stageSvg) { stageSvg.remove(); - stageSvg = undefined; + setState("stageSvg", undefined); } - activeStagePosition = undefined; + setState("activeStagePosition", undefined); } diff --git a/src/state.ts b/src/state.ts new file mode 100644 index 0000000..0ac8490 --- /dev/null +++ b/src/state.ts @@ -0,0 +1,28 @@ +import { StageDefinition } from "./stage"; +import { PopoverDOM } from "./popover"; + +export type State = { + isInitialized?: boolean; + resizeTimeout?: number; + + previousHighlight?: Element; + activeHighlight?: Element; + transitionCallback?: () => void; + + activeStagePosition?: StageDefinition; + stageSvg?: SVGSVGElement; + + popover?: PopoverDOM; +}; + +let currentState: State = {}; + +export function setState(key: K, value: State[K]) { + currentState[key] = value; +} + +export function getState(): State; +export function getState(key: K): State[K]; +export function getState(key?: K) { + return key ? currentState[key] : currentState; +} diff --git a/src/style.css b/src/style.css index de976ed..01b0462 100644 --- a/src/style.css +++ b/src/style.css @@ -8,7 +8,8 @@ .driver-active .driver-active-element, .driver-active .driver-active-element *, -.driver-popover, .driver-popover *{ +.driver-popover, +.driver-popover * { pointer-events: auto; } @@ -51,6 +52,10 @@ border: 5px solid #fff; } +.driver-popover-arrow-side-over { + display: none; +} + /** Popover Arrow Sides **/ .driver-popover-arrow-side-left { left: 100%;