import addEventListener = require("add-dom-event-listener"); import EventEmitter = require("events"); import isAbsoluteUrl = require("is-absolute-url"); import sameOrigin = require("same-origin"); import url = require("url"); const { parse, resolve } = url, HISTORY_LIMIT = 1024 * 8; export type IHandler = ( url: url.UrlWithParsedQuery ) => Promise; export type ISetHandler = (prevHandler: IHandler) => IHandler; type IPromise = typeof Promise; export interface IOptions { html5Mode?: boolean; handler?: IHandler; Promise?: IPromise; } export const urlEqual = ( a: url.UrlWithParsedQuery, b: url.UrlWithParsedQuery ): boolean => a.pathname === b.pathname && a.search === b.search && a.hash === b.hash; export const getUrlPathname = (url: url.UrlWithParsedQuery): string => url.pathname + (url.search || "") + (url.hash || ""); const which = (event: MouseEvent): number => { event = event || window.event || {}; return event.which === void 0 || event.which === null ? +event.button : +event.which; }; export class Location extends EventEmitter { init: () => Promise; set: (path: string, force?: boolean) => Promise; setOnly: (path: string) => void; back: () => void; forward: () => void; get: () => url.UrlWithParsedQuery; history: () => number; setHtml5Mode: (mode: boolean) => void; setHandler: (setHandlerFn: ISetHandler) => void; remove: () => void; constructor(window: Window, options: IOptions = {}) { super(); const windowLocation = window.location, windowHistory = window.history || null, Promise: IPromise = options.Promise || (global.Promise as IPromise); interface IInternalLocation { initiated: boolean; html5Mode: boolean; historyIndex: number; history: url.UrlWithParsedQuery[]; handler: IHandler; origin: string; url: url.UrlWithParsedQuery; } const defaultHandler: IHandler = (url: url.UrlWithParsedQuery) => Promise.resolve(url); const internalLocation = ((): IInternalLocation => { const origin = windowLocation.origin || windowLocation.protocol + "//" + windowLocation.hostname + (windowLocation.port ? ":" + windowLocation.port : ""), url = !!options.html5Mode ? parse(windowLocation.href || "", true) : parse(origin + (windowLocation.hash || "#/").slice(1), true); return { initiated: false, html5Mode: !!options.html5Mode, historyIndex: 0, history: [url], handler: options.handler || defaultHandler, url, origin }; })(); if (windowHistory === null) { internalLocation.html5Mode = false; } const pushHistory = (url: url.UrlWithParsedQuery): void => { if (!internalLocation.html5Mode) { const history = internalLocation.history; if (internalLocation.historyIndex < history.length - 1) { history.length = internalLocation.historyIndex + 1; } history.push(url); internalLocation.historyIndex += 1; if (history.length === HISTORY_LIMIT) { history.shift(); } } }; const createUrl = (path: string = ""): url.UrlWithParsedQuery => parse(resolve(internalLocation.origin, path), true); const pushState = (url: url.UrlWithParsedQuery): void => { if (internalLocation.html5Mode) { windowHistory.pushState( url, url.href || url.pathname || "", url.href || url.pathname || "" ); } else { windowLocation.hash = getUrlPathname(url); } }; const replaceState = (url: url.UrlWithParsedQuery): void => { if (internalLocation.html5Mode) { windowHistory.replaceState( url, url.href || url.pathname || "", url.href || url.pathname || "" ); } else { windowLocation.hash = getUrlPathname(url); } }; const backState = (): void => { const history = internalLocation.history; if (history.length >= 1) { let index = internalLocation.historyIndex; if (index > 0) { index -= 1; internalLocation.historyIndex = index; if (internalLocation.html5Mode) { windowHistory.back(); } else { windowLocation.hash = getUrlPathname(history[index]); } } } }; const forwardState = (): void => { const history = internalLocation.history, length = history.length; if (history.length >= 1) { let index = internalLocation.historyIndex; if (index < length - 1) { index += 1; internalLocation.historyIndex = index; if (internalLocation.html5Mode) { windowHistory.forward(); } else { windowLocation.hash = getUrlPathname(history[index]); } } } }; const onHashChange = (event: Event): boolean | undefined => { if (!internalLocation.html5Mode) { setUrl(createUrl((windowLocation.hash || "#/").slice(1) || ""), false); } else { event.preventDefault(); return false; } }; const onPopState = (event: any) => { event.preventDefault(); if (event.nativeEvent && event.nativeEvent.state) { setUrl(parse(event.nativeEvent.state.href, true), false); } return undefined; }; const onClick = (event: MouseEvent): boolean | undefined => { // ignore if not normal left click if ( which(event) !== 1 || event.metaKey || event.ctrlKey || event.shiftKey || event.defaultPrevented ) { return; } // find a a tag in the target's parents let el = event.target as Element; while (el && el.nodeName !== "A") { el = el.parentNode as Element; } // ignore if not a tag or is download or external if ( !el || "A" !== el.nodeName || el.getAttribute("download") || el.getAttribute("rel") === "external" ) { return; } const href = (el as HTMLAnchorElement).href; let link: string = el.getAttribute("href") || href || ""; if (!link || (el as HTMLAnchorElement).target) { return; } link = link[0] === "#" ? link.slice(1) : link; if (link && (link.indexOf("mailto:") > -1 || link.indexOf("tel:") > -1)) { return; } if ( (href && !sameOrigin(internalLocation.origin, href)) || (isAbsoluteUrl(link) && !sameOrigin(internalLocation.origin, link)) ) { return; } event.preventDefault(); setUrl(createUrl(link)); return false; }; const setUrl = ( url: url.UrlWithParsedQuery, history: boolean = true, force: boolean = false ): Promise => { if (force || !urlEqual(url, internalLocation.url)) { this.emit("set", url); return internalLocation.handler(url).then( () => { internalLocation.url = url; if (history) { pushHistory(url); } pushState(url); this.emit("set-success", url); return url; }, (url: url.UrlWithParsedQuery | Error | null) => { this.emit("set-error", url); if (url instanceof Error) { throw url; } else if (url && url.pathname) { return setUrl(url); } else { return setUrl(internalLocation.url); } } ); } else { return Promise.resolve(internalLocation.url); } }; const removeOnClick = addEventListener( window as EventTarget, window.ontouchstart ? "touchstart" : "click", onClick ), removeOnPopState = addEventListener( window as EventTarget, "popstate", onPopState ), removeOnHashChange = addEventListener( window as EventTarget, "hashchange", onHashChange ); this.remove = (): void => { removeOnClick.remove(); removeOnPopState.remove(); removeOnHashChange.remove(); this.emit("remove", internalLocation.url); }; this.init = () => { if (!internalLocation.initiated) { internalLocation.initiated = true; this.emit("init", internalLocation.url); return setUrl(internalLocation.url, true, true); } else { return Promise.resolve(internalLocation.url); } }; this.setHandler = (setHandlerFn: ISetHandler): void => { internalLocation.handler = setHandlerFn(internalLocation.handler); }; this.set = ( path: string, force?: boolean ): Promise => { return setUrl(createUrl(path), true, force); }; this.setOnly = (path: string): void => { const url = createUrl(path); internalLocation.url = url; replaceState(internalLocation.url); this.emit("set-only", url); }; this.back = (): void => { this.emit("back"); return backState(); }; this.forward = (): void => { this.emit("forward"); return forwardState(); }; this.get = (): url.UrlWithParsedQuery => internalLocation.url; this.history = (): number => internalLocation.html5Mode ? windowHistory.length : internalLocation.history.length; this.setHtml5Mode = (html5Mode: boolean): void => { internalLocation.html5Mode = html5Mode; }; } } export const createLocation = (window: Window, options: IOptions = {}) => new Location(window, options);