aboutsummaryrefslogtreecommitdiff
path: root/src/content/presenters
diff options
context:
space:
mode:
Diffstat (limited to 'src/content/presenters')
-rw-r--r--src/content/presenters/ConsoleFramePresenter.ts25
-rw-r--r--src/content/presenters/FindPresenter.ts52
-rw-r--r--src/content/presenters/FocusPresenter.ts25
-rw-r--r--src/content/presenters/FollowPresenter.ts134
-rw-r--r--src/content/presenters/Hint.ts127
-rw-r--r--src/content/presenters/NavigationPresenter.ts98
-rw-r--r--src/content/presenters/ScrollPresenter.ts179
7 files changed, 640 insertions, 0 deletions
diff --git a/src/content/presenters/ConsoleFramePresenter.ts b/src/content/presenters/ConsoleFramePresenter.ts
new file mode 100644
index 0000000..3c7477b
--- /dev/null
+++ b/src/content/presenters/ConsoleFramePresenter.ts
@@ -0,0 +1,25 @@
+export default interface ConsoleFramePresenter {
+ initialize(): void;
+
+ blur(): void;
+
+ // eslint-disable-next-line semi
+}
+
+export class ConsoleFramePresenterImpl implements ConsoleFramePresenter {
+ initialize(): void {
+ let iframe = document.createElement('iframe');
+ iframe.src = browser.runtime.getURL('build/console.html');
+ iframe.id = 'vimvixen-console-frame';
+ iframe.className = 'vimvixen-console-frame';
+ document.body.append(iframe);
+ }
+
+ blur(): void {
+ let ele = document.getElementById('vimvixen-console-frame');
+ if (!ele) {
+ throw new Error('console frame not created');
+ }
+ ele.blur();
+ }
+}
diff --git a/src/content/presenters/FindPresenter.ts b/src/content/presenters/FindPresenter.ts
new file mode 100644
index 0000000..d9bc835
--- /dev/null
+++ b/src/content/presenters/FindPresenter.ts
@@ -0,0 +1,52 @@
+
+export default interface FindPresenter {
+ find(keyword: string, backwards: boolean): boolean;
+
+ clearSelection(): void;
+
+ // eslint-disable-next-line semi
+}
+
+// window.find(aString, aCaseSensitive, aBackwards, aWrapAround,
+// aWholeWord, aSearchInFrames);
+//
+// NOTE: window.find is not standard API
+// https://developer.mozilla.org/en-US/docs/Web/API/Window/find
+interface MyWindow extends Window {
+ find(
+ aString: string,
+ aCaseSensitive?: boolean,
+ aBackwards?: boolean,
+ aWrapAround?: boolean,
+ aWholeWord?: boolean,
+ aSearchInFrames?: boolean,
+ aShowDialog?: boolean): boolean;
+}
+
+// eslint-disable-next-line no-var, vars-on-top, init-declarations
+declare var window: MyWindow;
+
+export class FindPresenterImpl implements FindPresenter {
+ find(keyword: string, backwards: boolean): boolean {
+ let caseSensitive = false;
+ let wrapScan = true;
+
+
+ // NOTE: aWholeWord dows not implemented, and aSearchInFrames does not work
+ // because of same origin policy
+ let found = window.find(keyword, caseSensitive, backwards, wrapScan);
+ if (found) {
+ return found;
+ }
+ this.clearSelection();
+
+ return window.find(keyword, caseSensitive, backwards, wrapScan);
+ }
+
+ clearSelection(): void {
+ let sel = window.getSelection();
+ if (sel) {
+ sel.removeAllRanges();
+ }
+ }
+}
diff --git a/src/content/presenters/FocusPresenter.ts b/src/content/presenters/FocusPresenter.ts
new file mode 100644
index 0000000..4cef5bf
--- /dev/null
+++ b/src/content/presenters/FocusPresenter.ts
@@ -0,0 +1,25 @@
+import * as doms from '../../shared/utils/dom';
+
+export default interface FocusPresenter {
+ focusFirstElement(): boolean;
+
+ // eslint-disable-next-line semi
+}
+
+export class FocusPresenterImpl implements FocusPresenter {
+ focusFirstElement(): boolean {
+ let inputTypes = ['email', 'number', 'search', 'tel', 'text', 'url'];
+ let inputSelector = inputTypes.map(type => `input[type=${type}]`).join(',');
+ let targets = window.document.querySelectorAll(inputSelector + ',textarea');
+ let target = Array.from(targets).find(doms.isVisible);
+ if (target instanceof HTMLInputElement) {
+ target.focus();
+ return true;
+ } else if (target instanceof HTMLTextAreaElement) {
+ target.focus();
+ return true;
+ }
+ return false;
+ }
+}
+
diff --git a/src/content/presenters/FollowPresenter.ts b/src/content/presenters/FollowPresenter.ts
new file mode 100644
index 0000000..f0d115c
--- /dev/null
+++ b/src/content/presenters/FollowPresenter.ts
@@ -0,0 +1,134 @@
+import Hint, { InputHint, LinkHint } from './Hint';
+import * as doms from '../../shared/utils/dom';
+
+const TARGET_SELECTOR = [
+ 'a', 'button', 'input', 'textarea', 'area',
+ '[contenteditable=true]', '[contenteditable=""]', '[tabindex]',
+ '[role="button"]', 'summary'
+].join(',');
+
+interface Size {
+ width: number;
+ height: number;
+}
+
+interface Point {
+ x: number;
+ y: number;
+}
+
+const inViewport = (
+ win: Window,
+ element: Element,
+ viewSize: Size,
+ framePosition: Point,
+): boolean => {
+ let {
+ top, left, bottom, right
+ } = doms.viewportRect(element);
+ let doc = win.document;
+ let frameWidth = doc.documentElement.clientWidth;
+ let frameHeight = doc.documentElement.clientHeight;
+
+ if (right < 0 || bottom < 0 || top > frameHeight || left > frameWidth) {
+ // out of frame
+ return false;
+ }
+ if (right + framePosition.x < 0 || bottom + framePosition.y < 0 ||
+ left + framePosition.x > viewSize.width ||
+ top + framePosition.y > viewSize.height) {
+ // out of viewport
+ return false;
+ }
+ return true;
+};
+
+const isAriaHiddenOrAriaDisabled = (win: Window, element: Element): boolean => {
+ if (!element || win.document.documentElement === element) {
+ return false;
+ }
+ for (let attr of ['aria-hidden', 'aria-disabled']) {
+ let value = element.getAttribute(attr);
+ if (value !== null) {
+ let hidden = value.toLowerCase();
+ if (hidden === '' || hidden === 'true') {
+ return true;
+ }
+ }
+ }
+ return isAriaHiddenOrAriaDisabled(win, element.parentElement as Element);
+};
+
+export default interface FollowPresenter {
+ getTargetCount(viewSize: Size, framePosition: Point): number;
+
+ createHints(viewSize: Size, framePosition: Point, tags: string[]): void;
+
+ filterHints(prefix: string): void;
+
+ clearHints(): void;
+
+ getHint(tag: string): Hint | undefined;
+
+ // eslint-disable-next-line semi
+}
+
+export class FollowPresenterImpl implements FollowPresenter {
+ private hints: Hint[]
+
+ constructor() {
+ this.hints = [];
+ }
+
+ getTargetCount(viewSize: Size, framePosition: Point): number {
+ let targets = this.getTargets(viewSize, framePosition);
+ return targets.length;
+ }
+
+ createHints(viewSize: Size, framePosition: Point, tags: string[]): void {
+ let targets = this.getTargets(viewSize, framePosition);
+ let min = Math.min(targets.length, tags.length);
+ for (let i = 0; i < min; ++i) {
+ let target = targets[i];
+ if (target instanceof HTMLAnchorElement ||
+ target instanceof HTMLAreaElement) {
+ this.hints.push(new LinkHint(target, tags[i]));
+ } else {
+ this.hints.push(new InputHint(target, tags[i]));
+ }
+ }
+ }
+
+ filterHints(prefix: string): void {
+ let shown = this.hints.filter(h => h.getTag().startsWith(prefix));
+ let hidden = this.hints.filter(h => !h.getTag().startsWith(prefix));
+
+ shown.forEach(h => h.show());
+ hidden.forEach(h => h.hide());
+ }
+
+ clearHints(): void {
+ this.hints.forEach(h => h.remove());
+ this.hints = [];
+ }
+
+ getHint(tag: string): Hint | undefined {
+ return this.hints.find(h => h.getTag() === tag);
+ }
+
+ private getTargets(viewSize: Size, framePosition: Point): HTMLElement[] {
+ let all = window.document.querySelectorAll(TARGET_SELECTOR);
+ let filtered = Array.prototype.filter.call(all, (element: HTMLElement) => {
+ let style = window.getComputedStyle(element);
+
+ // AREA's 'display' in Browser style is 'none'
+ return (element.tagName === 'AREA' || style.display !== 'none') &&
+ style.visibility !== 'hidden' &&
+ (element as HTMLInputElement).type !== 'hidden' &&
+ element.offsetHeight > 0 &&
+ !isAriaHiddenOrAriaDisabled(window, element) &&
+ inViewport(window, element, viewSize, framePosition);
+ });
+ return filtered;
+ }
+}
diff --git a/src/content/presenters/Hint.ts b/src/content/presenters/Hint.ts
new file mode 100644
index 0000000..60c0f4c
--- /dev/null
+++ b/src/content/presenters/Hint.ts
@@ -0,0 +1,127 @@
+import * as doms from '../../shared/utils/dom';
+
+interface Point {
+ x: number;
+ y: number;
+}
+
+const hintPosition = (element: Element): Point => {
+ let { left, top, right, bottom } = doms.viewportRect(element);
+
+ if (element.tagName !== 'AREA') {
+ return { x: left, y: top };
+ }
+
+ return {
+ x: (left + right) / 2,
+ y: (top + bottom) / 2,
+ };
+};
+
+export default abstract class Hint {
+ private hint: HTMLElement;
+
+ private tag: string;
+
+ constructor(target: HTMLElement, tag: string) {
+ this.tag = tag;
+
+ let doc = target.ownerDocument;
+ if (doc === null) {
+ throw new TypeError('ownerDocument is null');
+ }
+
+ let { x, y } = hintPosition(target);
+ let { scrollX, scrollY } = window;
+
+ let hint = doc.createElement('span');
+ hint.className = 'vimvixen-hint';
+ hint.textContent = tag;
+ hint.style.left = x + scrollX + 'px';
+ hint.style.top = y + scrollY + 'px';
+
+ doc.body.append(hint);
+
+ this.hint = hint;
+ this.show();
+ }
+
+ show(): void {
+ this.hint.style.display = 'inline';
+ }
+
+ hide(): void {
+ this.hint.style.display = 'none';
+ }
+
+ remove(): void {
+ this.hint.remove();
+ }
+
+ getTag(): string {
+ return this.tag;
+ }
+}
+
+export class LinkHint extends Hint {
+ private target: HTMLAnchorElement | HTMLAreaElement;
+
+ constructor(target: HTMLAnchorElement | HTMLAreaElement, tag: string) {
+ super(target, tag);
+
+ this.target = target;
+ }
+
+ getLink(): string {
+ return this.target.href;
+ }
+
+ getLinkTarget(): string | null {
+ return this.target.getAttribute('target');
+ }
+
+ click(): void {
+ this.target.click();
+ }
+}
+
+export class InputHint extends Hint {
+ private target: HTMLElement;
+
+ constructor(target: HTMLElement, tag: string) {
+ super(target, tag);
+
+ this.target = target;
+ }
+
+ activate(): void {
+ let target = this.target;
+ switch (target.tagName.toLowerCase()) {
+ case 'input':
+ switch ((target as HTMLInputElement).type) {
+ case 'file':
+ case 'checkbox':
+ case 'radio':
+ case 'submit':
+ case 'reset':
+ case 'button':
+ case 'image':
+ case 'color':
+ return target.click();
+ default:
+ return target.focus();
+ }
+ case 'textarea':
+ return target.focus();
+ case 'button':
+ case 'summary':
+ return target.click();
+ default:
+ if (doms.isContentEditable(target)) {
+ return target.focus();
+ } else if (target.hasAttribute('tabindex')) {
+ return target.click();
+ }
+ }
+ }
+}
diff --git a/src/content/presenters/NavigationPresenter.ts b/src/content/presenters/NavigationPresenter.ts
new file mode 100644
index 0000000..66110e5
--- /dev/null
+++ b/src/content/presenters/NavigationPresenter.ts
@@ -0,0 +1,98 @@
+export default interface NavigationPresenter {
+ openHistoryPrev(): void;
+
+ openHistoryNext(): void;
+
+ openLinkPrev(): void;
+
+ openLinkNext(): void;
+
+ openParent(): void;
+
+ openRoot(): void;
+
+ // eslint-disable-next-line semi
+}
+
+const REL_PATTERN: {[key: string]: RegExp} = {
+ prev: /^(?:prev(?:ious)?|older)\b|\u2039|\u2190|\xab|\u226a|<</i,
+ next: /^(?:next|newer)\b|\u203a|\u2192|\xbb|\u226b|>>/i,
+};
+
+// Return the last element in the document matching the supplied selector
+// and the optional filter, or null if there are no matches.
+// eslint-disable-next-line func-style
+function selectLast<E extends Element>(
+ selector: string,
+ filter?: (e: E) => boolean,
+): E | null {
+ let nodes = Array.from(
+ window.document.querySelectorAll(selector) as NodeListOf<E>
+ );
+
+ if (filter) {
+ nodes = nodes.filter(filter);
+ }
+ return nodes.length ? nodes[nodes.length - 1] : null;
+}
+
+export class NavigationPresenterImpl implements NavigationPresenter {
+ openHistoryPrev(): void {
+ window.history.back();
+ }
+
+ openHistoryNext(): void {
+ window.history.forward();
+ }
+
+ openLinkPrev(): void {
+ this.linkRel('prev');
+ }
+
+ openLinkNext(): void {
+ this.linkRel('next');
+ }
+
+ openParent(): void {
+ const loc = window.location;
+ if (loc.hash !== '') {
+ loc.hash = '';
+ return;
+ } else if (loc.search !== '') {
+ loc.search = '';
+ return;
+ }
+
+ const basenamePattern = /\/[^/]+$/;
+ const lastDirPattern = /\/[^/]+\/$/;
+ if (basenamePattern.test(loc.pathname)) {
+ loc.pathname = loc.pathname.replace(basenamePattern, '/');
+ } else if (lastDirPattern.test(loc.pathname)) {
+ loc.pathname = loc.pathname.replace(lastDirPattern, '/');
+ }
+ }
+
+ openRoot(): void {
+ window.location.href = window.location.origin;
+ }
+
+ // Code common to linkPrev and linkNext which navigates to the specified page.
+ private linkRel(rel: 'prev' | 'next'): void {
+ let link = selectLast<HTMLLinkElement>(`link[rel~=${rel}][href]`);
+ if (link) {
+ window.location.href = link.href;
+ return;
+ }
+
+ const pattern = REL_PATTERN[rel];
+
+ let a = selectLast<HTMLAnchorElement>(`a[rel~=${rel}][href]`) ||
+ // `innerText` is much slower than `textContent`, but produces much better
+ // (i.e. less unexpected) results
+ selectLast('a[href]', lnk => pattern.test(lnk.innerText));
+
+ if (a) {
+ a.click();
+ }
+ }
+}
diff --git a/src/content/presenters/ScrollPresenter.ts b/src/content/presenters/ScrollPresenter.ts
new file mode 100644
index 0000000..9286fb0
--- /dev/null
+++ b/src/content/presenters/ScrollPresenter.ts
@@ -0,0 +1,179 @@
+import * as doms from '../../shared/utils/dom';
+
+const SCROLL_DELTA_X = 64;
+const SCROLL_DELTA_Y = 64;
+
+// dirty way to store scrolling state on globally
+let scrolling = false;
+let lastTimeoutId: number | null = null;
+
+const isScrollableStyle = (element: Element): boolean => {
+ let { overflowX, overflowY } = window.getComputedStyle(element);
+ return !(overflowX !== 'scroll' && overflowX !== 'auto' &&
+ overflowY !== 'scroll' && overflowY !== 'auto');
+};
+
+const isOverflowed = (element: Element): boolean => {
+ return element.scrollWidth > element.clientWidth ||
+ element.scrollHeight > element.clientHeight;
+};
+
+// Find a visiable and scrollable element by depth-first search. Currently
+// this method is called by each scrolling, and the returned value of this
+// method is not cached. That does not cause performance issue because in the
+// most pages, the window is root element i,e, documentElement.
+const findScrollable = (element: Element): Element | null => {
+ if (isScrollableStyle(element) && isOverflowed(element)) {
+ return element;
+ }
+
+ let children = Array.from(element.children).filter(doms.isVisible);
+ for (let child of children) {
+ let scrollable = findScrollable(child);
+ if (scrollable) {
+ return scrollable;
+ }
+ }
+ return null;
+};
+
+const scrollTarget = () => {
+ if (isOverflowed(window.document.documentElement)) {
+ return window.document.documentElement;
+ }
+ if (isOverflowed(window.document.body)) {
+ return window.document.body;
+ }
+ let target = findScrollable(window.document.documentElement);
+ if (target) {
+ return target;
+ }
+ return window.document.documentElement;
+};
+
+const resetScrolling = () => {
+ scrolling = false;
+};
+
+class Scroller {
+ private element: Element;
+
+ private smooth: boolean;
+
+ constructor(element: Element, smooth: boolean) {
+ this.element = element;
+ this.smooth = smooth;
+ }
+
+ scrollTo(x: number, y: number): void {
+ if (!this.smooth) {
+ this.element.scrollTo(x, y);
+ return;
+ }
+ this.element.scrollTo({
+ left: x,
+ top: y,
+ behavior: 'smooth',
+ });
+ this.prepareReset();
+ }
+
+ scrollBy(x: number, y: number): void {
+ let left = this.element.scrollLeft + x;
+ let top = this.element.scrollTop + y;
+ this.scrollTo(left, top);
+ }
+
+ prepareReset(): void {
+ scrolling = true;
+ if (lastTimeoutId) {
+ clearTimeout(lastTimeoutId);
+ lastTimeoutId = null;
+ }
+ lastTimeoutId = setTimeout(resetScrolling, 100);
+ }
+}
+
+export type Point = { x: number, y: number };
+
+export default interface ScrollPresenter {
+ getScroll(): Point;
+ scrollVertically(amount: number, smooth: boolean): void;
+ scrollHorizonally(amount: number, smooth: boolean): void;
+ scrollPages(amount: number, smooth: boolean): void;
+ scrollTo(x: number, y: number, smooth: boolean): void;
+ scrollToTop(smooth: boolean): void;
+ scrollToBottom(smooth: boolean): void;
+ scrollToHome(smooth: boolean): void;
+ scrollToEnd(smooth: boolean): void;
+
+ // eslint-disable-next-line semi
+}
+
+export class ScrollPresenterImpl {
+ getScroll(): Point {
+ let target = scrollTarget();
+ return { x: target.scrollLeft, y: target.scrollTop };
+ }
+
+ scrollVertically(count: number, smooth: boolean): void {
+ let target = scrollTarget();
+ let delta = SCROLL_DELTA_Y * count;
+ if (scrolling) {
+ delta = SCROLL_DELTA_Y * count * 4;
+ }
+ new Scroller(target, smooth).scrollBy(0, delta);
+ }
+
+ scrollHorizonally(count: number, smooth: boolean): void {
+ let target = scrollTarget();
+ let delta = SCROLL_DELTA_X * count;
+ if (scrolling) {
+ delta = SCROLL_DELTA_X * count * 4;
+ }
+ new Scroller(target, smooth).scrollBy(delta, 0);
+ }
+
+ scrollPages(count: number, smooth: boolean): void {
+ let target = scrollTarget();
+ let height = target.clientHeight;
+ let delta = height * count;
+ if (scrolling) {
+ delta = height * count;
+ }
+ new Scroller(target, smooth).scrollBy(0, delta);
+ }
+
+ scrollTo(x: number, y: number, smooth: boolean): void {
+ let target = scrollTarget();
+ new Scroller(target, smooth).scrollTo(x, y);
+ }
+
+ scrollToTop(smooth: boolean): void {
+ let target = scrollTarget();
+ let x = target.scrollLeft;
+ let y = 0;
+ new Scroller(target, smooth).scrollTo(x, y);
+ }
+
+ scrollToBottom(smooth: boolean): void {
+ let target = scrollTarget();
+ let x = target.scrollLeft;
+ let y = target.scrollHeight;
+ new Scroller(target, smooth).scrollTo(x, y);
+ }
+
+ scrollToHome(smooth: boolean): void {
+ let target = scrollTarget();
+ let x = 0;
+ let y = target.scrollTop;
+ new Scroller(target, smooth).scrollTo(x, y);
+ }
+
+ scrollToEnd(smooth: boolean): void {
+ let target = scrollTarget();
+ let x = target.scrollWidth;
+ let y = target.scrollTop;
+ new Scroller(target, smooth).scrollTo(x, y);
+ }
+}