diff options
| author | Shin'ya Ueoka <ueokande@i-beam.org> | 2017-10-16 21:48:41 +0900 | 
|---|---|---|
| committer | GitHub <noreply@github.com> | 2017-10-16 21:48:41 +0900 | 
| commit | 33a97a0e8c18c099b23cd50a832242985837edca (patch) | |
| tree | 73a15a1a39b41e0c42ea79a2d521d63865f83f06 /src/content | |
| parent | 7ced514f83a69f557c19c1eb24ad792b3f2ace89 (diff) | |
| parent | cf3a1eaf16d7dd5c71de57901415fb147793aa56 (diff) | |
Merge pull request #61 from ueokande/multi-frame-following
Multi frame following
Diffstat (limited to 'src/content')
| -rw-r--r-- | src/content/actions/operation.js | 6 | ||||
| -rw-r--r-- | src/content/components/common/follow.js | 169 | ||||
| -rw-r--r-- | src/content/components/common/hint.css (renamed from src/content/hint.css) | 0 | ||||
| -rw-r--r-- | src/content/components/common/hint.js (renamed from src/content/hint.js) | 0 | ||||
| -rw-r--r-- | src/content/components/common/index.js | 45 | ||||
| -rw-r--r-- | src/content/components/common/input.js (renamed from src/content/components/content-input.js) | 5 | ||||
| -rw-r--r-- | src/content/components/common/keymapper.js (renamed from src/content/components/keymapper.js) | 3 | ||||
| -rw-r--r-- | src/content/components/follow.js | 175 | ||||
| -rw-r--r-- | src/content/components/frame-content.js | 16 | ||||
| -rw-r--r-- | src/content/components/top-content/follow-controller.js | 131 | ||||
| -rw-r--r-- | src/content/components/top-content/index.js | 32 | ||||
| -rw-r--r-- | src/content/index.js | 61 | 
12 files changed, 422 insertions, 221 deletions
| diff --git a/src/content/actions/operation.js b/src/content/actions/operation.js index 3aa9c1f..81bcc2f 100644 --- a/src/content/actions/operation.js +++ b/src/content/actions/operation.js @@ -3,7 +3,6 @@ import messages from 'shared/messages';  import * as scrolls from 'content/scrolls';  import * as navigates from 'content/navigates';  import * as urls from 'content/urls'; -import * as followActions from 'content/actions/follow';  import * as consoleFrames from 'content/console-frames';  const exec = (operation) => { @@ -23,7 +22,10 @@ const exec = (operation) => {    case operations.SCROLL_END:      return scrolls.scrollEnd(window);    case operations.FOLLOW_START: -    return followActions.enable(operation.newTab); +    return window.top.postMessage(JSON.stringify({ +      type: messages.FOLLOW_START, +      newTab: operation.newTab +    }), '*');    case operations.NAVIGATE_HISTORY_PREV:      return navigates.historyPrev(window);    case operations.NAVIGATE_HISTORY_NEXT: diff --git a/src/content/components/common/follow.js b/src/content/components/common/follow.js new file mode 100644 index 0000000..92d8822 --- /dev/null +++ b/src/content/components/common/follow.js @@ -0,0 +1,169 @@ +import messages from 'shared/messages'; +import Hint from './hint'; + +const TARGET_SELECTOR = [ +  'a', 'button', 'input', 'textarea', +  '[contenteditable=true]', '[contenteditable=""]' +].join(','); + +const inViewport = (win, element, viewSize, framePosition) => { +  let { +    top, left, bottom, right +  } = element.getBoundingClientRect(); +  let doc = win.doc; +  let frameWidth = win.innerWidth || doc.documentElement.clientWidth; +  let frameHeight = win.innerHeight || 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; +}; + +export default class Follow { +  constructor(win, store) { +    this.win = win; +    this.store = store; +    this.newTab = false; +    this.hints = {}; +    this.targets = []; +  } + +  update() { +  } + +  key(key) { +    if (Object.keys(this.hints).length === 0) { +      return false; +    } +    this.win.parent.postMessage(JSON.stringify({ +      type: messages.FOLLOW_KEY_PRESS, +      key, +    }), '*'); +    return true; +  } + +  openLink(element) { +    if (!this.newTab) { +      element.click(); +      return; +    } + +    let href = element.getAttribute('href'); + +    // eslint-disable-next-line no-script-url +    if (!href || href === '#' || href.toLowerCase().startsWith('javascript:')) { +      return; +    } +    return browser.runtime.sendMessage({ +      type: messages.OPEN_URL, +      url: element.href, +      newTab: this.newTab, +    }); +  } + +  countHints(sender, viewSize, framePosition) { +    this.targets = Follow.getTargetElements(this.win, viewSize, framePosition); +    sender.postMessage(JSON.stringify({ +      type: messages.FOLLOW_RESPONSE_COUNT_TARGETS, +      count: this.targets.length, +    }), '*'); +  } + +  createHints(keysArray, newTab) { +    if (keysArray.length !== this.targets.length) { +      throw new Error('illegal hint count'); +    } + +    this.newTab = newTab; +    this.hints = {}; +    for (let i = 0; i < keysArray.length; ++i) { +      let keys = keysArray[i]; +      let hint = new Hint(this.targets[i], keys); +      this.hints[keys] = hint; +    } +  } + +  showHints(keys) { +    Object.keys(this.hints).filter(key => key.startsWith(keys)) +      .forEach(key => this.hints[key].show()); +    Object.keys(this.hints).filter(key => !key.startsWith(keys)) +      .forEach(key => this.hints[key].hide()); +  } + +  removeHints() { +    Object.keys(this.hints).forEach((key) => { +      this.hints[key].remove(); +    }); +    this.hints = {}; +    this.targets = []; +  } + +  activateHints(keys) { +    let hint = this.hints[keys]; +    if (!hint) { +      return; +    } +    let element = hint.target; +    switch (element.tagName.toLowerCase()) { +    case 'a': +      return this.openLink(element, this.newTab); +    case 'input': +      switch (element.type) { +      case 'file': +      case 'checkbox': +      case 'radio': +      case 'submit': +      case 'reset': +      case 'button': +      case 'image': +      case 'color': +        return element.click(); +      default: +        return element.focus(); +      } +    case 'textarea': +      return element.focus(); +    case 'button': +      return element.click(); +    default: +      // it may contenteditable +      return element.focus(); +    } +  } + +  onMessage(message, sender) { +    switch (message.type) { +    case messages.FOLLOW_REQUEST_COUNT_TARGETS: +      return this.countHints(sender, message.viewSize, message.framePosition); +    case messages.FOLLOW_CREATE_HINTS: +      return this.createHints(message.keysArray, message.newTab); +    case messages.FOLLOW_SHOW_HINTS: +      return this.showHints(message.keys); +    case messages.FOLLOW_ACTIVATE: +      return this.activateHints(message.keys); +    case messages.FOLLOW_REMOVE_HINTS: +      return this.removeHints(message.keys); +    } +  } + +  static getTargetElements(win, viewSize, framePosition) { +    let all = win.document.querySelectorAll(TARGET_SELECTOR); +    let filtered = Array.prototype.filter.call(all, (element) => { +      let style = win.getComputedStyle(element); +      return style.display !== 'none' && +        style.visibility !== 'hidden' && +        element.type !== 'hidden' && +        element.offsetHeight > 0 && +        inViewport(win, element, viewSize, framePosition); +    }); +    return filtered; +  } +} diff --git a/src/content/hint.css b/src/content/components/common/hint.css index 119dd21..119dd21 100644 --- a/src/content/hint.css +++ b/src/content/components/common/hint.css diff --git a/src/content/hint.js b/src/content/components/common/hint.js index cc46fd6..cc46fd6 100644 --- a/src/content/hint.js +++ b/src/content/components/common/hint.js diff --git a/src/content/components/common/index.js b/src/content/components/common/index.js new file mode 100644 index 0000000..a05febd --- /dev/null +++ b/src/content/components/common/index.js @@ -0,0 +1,45 @@ +import InputComponent from './input'; +import KeymapperComponent from './keymapper'; +import FollowComponent from './follow'; +import * as inputActions from 'content/actions/input'; +import messages from 'shared/messages'; + +export default class Common { +  constructor(win, store) { +    const follow = new FollowComponent(win, store); +    const input = new InputComponent(win.document.body, store); +    const keymapper = new KeymapperComponent(store); + +    input.onKey((key, ctrl) => follow.key(key, ctrl)); +    input.onKey((key, ctrl) => keymapper.key(key, ctrl)); + +    this.store = store; +    this.children = [ +      follow, +      input, +      keymapper, +    ]; + +    this.reloadSettings(); +  } + +  update() { +    this.children.forEach(c => c.update()); +  } + +  onMessage(message, sender) { +    switch (message) { +    case messages.SETTINGS_CHANGED: +      this.reloadSettings(); +    } +    this.children.forEach(c => c.onMessage(message, sender)); +  } + +  reloadSettings() { +    browser.runtime.sendMessage({ +      type: messages.SETTINGS_QUERY, +    }).then((settings) => { +      this.store.dispatch(inputActions.setKeymaps(settings.keymaps)); +    }); +  } +} diff --git a/src/content/components/content-input.js b/src/content/components/common/input.js index 3e70bbb..8a7f82a 100644 --- a/src/content/components/content-input.js +++ b/src/content/components/common/input.js @@ -1,4 +1,4 @@ -export default class ContentInputComponent { +export default class InputComponent {    constructor(target) {      this.pressed = {};      this.onKeyListeners = []; @@ -69,4 +69,7 @@ export default class ContentInputComponent {            e.target.getAttribute('contenteditable').toLowerCase() === 'true' ||            e.target.getAttribute('contenteditable').toLowerCase() === '');    } + +  onMessage() { +  }  } diff --git a/src/content/components/keymapper.js b/src/content/components/common/keymapper.js index 655c3f2..2a57b28 100644 --- a/src/content/components/keymapper.js +++ b/src/content/components/common/keymapper.js @@ -28,4 +28,7 @@ export default class KeymapperComponent {      this.store.dispatch(inputActions.clearKeys());      return true;    } + +  onMessage() { +  }  } diff --git a/src/content/components/follow.js b/src/content/components/follow.js deleted file mode 100644 index eb453a5..0000000 --- a/src/content/components/follow.js +++ /dev/null @@ -1,175 +0,0 @@ -import * as followActions from 'content/actions/follow'; -import messages from 'shared/messages'; -import Hint from 'content/hint'; -import HintKeyProducer from 'content/hint-key-producer'; - -const DEFAULT_HINT_CHARSET = 'abcdefghijklmnopqrstuvwxyz'; -const TARGET_SELECTOR = [ -  'a', 'button', 'input', 'textarea', -  '[contenteditable=true]', '[contenteditable=""]' -].join(','); - -const inWindow = (window, element) => { -  let { -    top, left, bottom, right -  } = element.getBoundingClientRect(); -  return ( -    top >= 0 && left >= 0 && -    bottom <= (window.innerHeight || document.documentElement.clientHeight) && -    right <= (window.innerWidth || document.documentElement.clientWidth) -  ); -}; - -export default class FollowComponent { -  constructor(wrapper, store) { -    this.wrapper = wrapper; -    this.store = store; -    this.hintElements = {}; -    this.state = {}; -  } - -  update() { -    let prevState = this.state; -    this.state = this.store.getState().follow; -    if (!prevState.enabled && this.state.enabled) { -      this.create(); -    } else if (prevState.enabled && !this.state.enabled) { -      this.remove(); -    } else if (prevState.keys !== this.state.keys) { -      this.updateHints(); -    } -  } - -  key(key) { -    if (!this.state.enabled) { -      return false; -    } - -    switch (key) { -    case 'Enter': -      this.activate(this.hintElements[this.state.keys].target); -      return; -    case 'Escape': -      this.store.dispatch(followActions.disable()); -      return; -    case 'Backspace': -    case 'Delete': -      this.store.dispatch(followActions.backspace()); -      break; -    default: -      if (DEFAULT_HINT_CHARSET.includes(key)) { -        this.store.dispatch(followActions.keyPress(key)); -      } -      break; -    } -    return true; -  } - -  updateHints() { -    let keys = this.state.keys; -    let shown = Object.keys(this.hintElements).filter((key) => { -      return key.startsWith(keys); -    }); -    let hidden = Object.keys(this.hintElements).filter((key) => { -      return !key.startsWith(keys); -    }); -    if (shown.length === 0) { -      this.remove(); -      return; -    } else if (shown.length === 1) { -      this.activate(this.hintElements[keys].target); -      this.store.dispatch(followActions.disable()); -    } - -    shown.forEach((key) => { -      this.hintElements[key].show(); -    }); -    hidden.forEach((key) => { -      this.hintElements[key].hide(); -    }); -  } - -  activate(element) { -    switch (element.tagName.toLowerCase()) { -    case 'a': -      if (this.state.newTab) { -        // getAttribute() to avoid to resolve absolute path -        let href = element.getAttribute('href'); - -        // eslint-disable-next-line no-script-url -        if (!href || href === '#' || href.startsWith('javascript:')) { -          return; -        } -        return browser.runtime.sendMessage({ -          type: messages.OPEN_URL, -          url: element.href, -          newTab: this.state.newTab, -        }); -      } -      if (element.href.startsWith('http://') || -        element.href.startsWith('https://') || -        element.href.startsWith('ftp://')) { -        return browser.runtime.sendMessage({ -          type: messages.OPEN_URL, -          url: element.href, -          newTab: this.state.newTab, -        }); -      } -      return element.click(); -    case 'input': -      switch (element.type) { -      case 'file': -      case 'checkbox': -      case 'radio': -      case 'submit': -      case 'reset': -      case 'button': -      case 'image': -      case 'color': -        return element.click(); -      default: -        return element.focus(); -      } -    case 'textarea': -      return element.focus(); -    case 'button': -      return element.click(); -    default: -      // it may contenteditable -      return element.focus(); -    } -  } - -  create() { -    let doc = this.wrapper.ownerDocument; -    let elements = FollowComponent.getTargetElements(doc); -    let producer = new HintKeyProducer(DEFAULT_HINT_CHARSET); -    let hintElements = {}; -    Array.prototype.forEach.call(elements, (ele) => { -      let keys = producer.produce(); -      let hint = new Hint(ele, keys); -      hintElements[keys] = hint; -    }); -    this.hintElements = hintElements; -  } - -  remove() { -    let hintElements = this.hintElements; -    Object.keys(this.hintElements).forEach((key) => { -      hintElements[key].remove(); -    }); -  } - -  static getTargetElements(doc) { -    let all = doc.querySelectorAll(TARGET_SELECTOR); -    let filtered = Array.prototype.filter.call(all, (element) => { -      let style = window.getComputedStyle(element); -      return style.display !== 'none' && -        style.visibility !== 'hidden' && -        element.type !== 'hidden' && -        element.offsetHeight > 0 && -        inWindow(window, element); -    }); -    return filtered; -  } -} diff --git a/src/content/components/frame-content.js b/src/content/components/frame-content.js new file mode 100644 index 0000000..46786d2 --- /dev/null +++ b/src/content/components/frame-content.js @@ -0,0 +1,16 @@ +import CommonComponent from './common'; + +export default class FrameContent { + +  constructor(win, store) { +    this.children = [new CommonComponent(win, store)]; +  } + +  update() { +    this.children.forEach(c => c.update()); +  } + +  onMessage(message, sender) { +    this.children.forEach(c => c.onMessage(message, sender)); +  } +} diff --git a/src/content/components/top-content/follow-controller.js b/src/content/components/top-content/follow-controller.js new file mode 100644 index 0000000..29f40b3 --- /dev/null +++ b/src/content/components/top-content/follow-controller.js @@ -0,0 +1,131 @@ +import * as followActions from 'content/actions/follow'; +import messages from 'shared/messages'; +import HintKeyProducer from 'content/hint-key-producer'; + +const DEFAULT_HINT_CHARSET = 'abcdefghijklmnopqrstuvwxyz'; + +const broadcastMessage = (win, message) => { +  let json = JSON.stringify(message); +  let frames = [window.self].concat(Array.from(window.frames)); +  frames.forEach(frame => frame.postMessage(json, '*')); +}; + +export default class FollowController { +  constructor(win, store) { +    this.win = win; +    this.store = store; +    this.state = {}; +    this.keys = []; +    this.producer = null; +  } + +  onMessage(message, sender) { +    switch (message.type) { +    case messages.FOLLOW_START: +      return this.store.dispatch(followActions.enable(message.newTab)); +    case messages.FOLLOW_RESPONSE_COUNT_TARGETS: +      return this.create(message.count, sender); +    case messages.FOLLOW_KEY_PRESS: +      return this.keyPress(message.key); +    } +  } + +  update() { +    let prevState = this.state; +    this.state = this.store.getState().follow; + +    if (!prevState.enabled && this.state.enabled) { +      this.count(); +    } else if (prevState.enabled && !this.state.enabled) { +      this.remove(); +    } else if (prevState.keys !== this.state.keys) { +      this.updateHints(); +    } +  } + +  updateHints() { +    let shown = this.keys.filter(key => key.startsWith(this.state.keys)); +    if (shown.length === 1) { +      this.activate(); +      this.store.dispatch(followActions.disable()); +    } + +    broadcastMessage(this.win, { +      type: messages.FOLLOW_SHOW_HINTS, +      keys: this.state.keys, +    }); +  } + +  activate() { +    broadcastMessage(this.win, { +      type: messages.FOLLOW_ACTIVATE, +      keys: this.state.keys, +    }); +  } + +  keyPress(key) { +    switch (key) { +    case 'Enter': +      this.activate(); +      this.store.dispatch(followActions.disable()); +      break; +    case 'Escape': +      this.store.dispatch(followActions.disable()); +      break; +    case 'Backspace': +    case 'Delete': +      this.store.dispatch(followActions.backspace()); +      break; +    default: +      if (DEFAULT_HINT_CHARSET.includes(key)) { +        this.store.dispatch(followActions.keyPress(key)); +      } +      break; +    } +    return true; +  } + +  count() { +    this.producer = new HintKeyProducer(DEFAULT_HINT_CHARSET); +    let doc = this.win.document; +    let viewWidth = this.win.innerWidth || doc.documentElement.clientWidth; +    let viewHeight = this.win.innerHeight || doc.documentElement.clientHeight; +    let frameElements = this.win.document.querySelectorAll('frame,iframe'); + +    this.win.postMessage(JSON.stringify({ +      type: messages.FOLLOW_REQUEST_COUNT_TARGETS, +      viewSize: { width: viewWidth, height: viewHeight }, +      framePosition: { x: 0, y: 0 }, +    }), '*'); +    frameElements.forEach((element) => { +      let { left: frameX, top: frameY } = element.getBoundingClientRect(); +      let message = JSON.stringify({ +        type: messages.FOLLOW_REQUEST_COUNT_TARGETS, +        viewSize: { width: viewWidth, height: viewHeight }, +        framePosition: { x: frameX, y: frameY }, +      }); +      element.contentWindow.postMessage(message, '*'); +    }); +  } + +  create(count, sender) { +    let produced = []; +    for (let i = 0; i < count; ++i) { +      produced.push(this.producer.produce()); +    } +    this.keys = this.keys.concat(produced); + +    sender.postMessage(JSON.stringify({ +      type: messages.FOLLOW_CREATE_HINTS, +      keysArray: produced, +      newTab: this.state.newTab, +    }), '*'); +  } + +  remove() { +    this.keys = []; +    broadcastMessage(this.win, { +      type: messages.FOLLOW_REMOVE_HINTS, +    }); +  } +} diff --git a/src/content/components/top-content/index.js b/src/content/components/top-content/index.js new file mode 100644 index 0000000..a2179da --- /dev/null +++ b/src/content/components/top-content/index.js @@ -0,0 +1,32 @@ +import CommonComponent from '../common'; +import FollowController from './follow-controller'; +import * as consoleFrames from '../../console-frames'; +import messages from 'shared/messages'; + +export default class TopContent { + +  constructor(win, store) { +    this.win = win; +    this.children = [ +      new CommonComponent(win, store), +      new FollowController(win, store), +    ]; + +    // TODO make component +    consoleFrames.initialize(window.document); +  } + +  update() { +    this.children.forEach(c => c.update()); +  } + +  onMessage(message, sender) { +    switch (message.type) { +    case messages.CONSOLE_HIDE_COMMAND: +      this.win.focus(); +      consoleFrames.blur(window.document); +      return Promise.resolve(); +    } +    this.children.forEach(c => c.onMessage(message, sender)); +  } +} diff --git a/src/content/index.js b/src/content/index.js index 64d86bb..e01172d 100644 --- a/src/content/index.js +++ b/src/content/index.js @@ -1,54 +1,29 @@  import './console-frame.scss'; -import * as consoleFrames from './console-frames'; -import * as inputActions from './actions/input';  import { createStore } from 'shared/store'; -import ContentInputComponent from 'content/components/content-input'; -import KeymapperComponent from 'content/components/keymapper'; -import FollowComponent from 'content/components/follow';  import reducers from 'content/reducers'; -import messages from 'shared/messages'; +import TopContentComponent from './components/top-content'; +import FrameContentComponent from './components/frame-content';  const store = createStore(reducers); -const followComponent = new FollowComponent(window.document.body, store); -const contentInputComponent = -  new ContentInputComponent(window.document.body, store); -const keymapperComponent = new KeymapperComponent(store); -contentInputComponent.onKey((key, ctrl) => { -  return followComponent.key(key, ctrl); -}); -contentInputComponent.onKey((key, ctrl) => { -  return keymapperComponent.key(key, ctrl); -}); + +let rootComponent = window.self === window.top +  ? new TopContentComponent(window, store) +  : new FrameContentComponent(window, store); +  store.subscribe(() => { -  try { -    followComponent.update(); -    contentInputComponent.update(); -  } catch (e) { -    console.error(e); -  } +  rootComponent.update();  }); -consoleFrames.initialize(window.document); - -const reloadSettings = () => { -  return browser.runtime.sendMessage({ -    type: messages.SETTINGS_QUERY, -  }).then((settings) => { -    store.dispatch(inputActions.setKeymaps(settings.keymaps)); -  }); -}; +browser.runtime.onMessage.addListener(msg => rootComponent.onMessage(msg)); +rootComponent.update(); -browser.runtime.onMessage.addListener((action) => { -  switch (action.type) { -  case messages.CONSOLE_HIDE_COMMAND: -    window.focus(); -    consoleFrames.blur(window.document); -    return Promise.resolve(); -  case messages.SETTINGS_CHANGED: -    return reloadSettings(); -  default: -    return Promise.resolve(); +window.addEventListener('message', (event) => { +  let message = null; +  try { +    message = JSON.parse(event.data); +  } catch (e) { +    // ignore unexpected message +    return;    } +  rootComponent.onMessage(message, event.source);  }); - -reloadSettings(); | 
