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%;