Merge pull request #486 from ueokande/jump-marks

Support jump marks
jh-changes
Shin'ya Ueoka 6 years ago committed by GitHub
commit 8b72aac09a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 15
      src/background/controllers/mark.js
  2. 24
      src/background/domains/global-mark.js
  3. 8
      src/background/infrastructures/content-message-client.js
  4. 14
      src/background/infrastructures/content-message-listener.js
  5. 33
      src/background/repositories/mark.js
  6. 39
      src/background/usecases/mark.js
  7. 6
      src/content/actions/index.js
  8. 46
      src/content/actions/mark.js
  9. 13
      src/content/actions/operation.js
  10. 7
      src/content/components/common/index.js
  11. 74
      src/content/components/common/mark.js
  12. 3
      src/content/components/top-content/index.js
  13. 3
      src/content/reducers/index.js
  14. 25
      src/content/reducers/mark.js
  15. 30
      src/content/scrolls.js
  16. 3
      src/settings/components/form/keymaps-form.jsx
  17. 5
      src/shared/messages.js
  18. 4
      src/shared/operations.js
  19. 2
      src/shared/settings/default.js
  20. 11
      test/background/domains/global-mark.test.js
  21. 2
      test/background/infrastructures/memory-storage.test.js
  22. 26
      test/background/repositories/mark.test.js
  23. 35
      test/content/actions/mark.test.js
  24. 41
      test/content/reducers/mark.test.js

@ -0,0 +1,15 @@
import MarkInteractor from '../usecases/mark';
export default class MarkController {
constructor() {
this.markInteractor = new MarkInteractor();
}
setGlobal(key, x, y) {
this.markInteractor.setGlobal(key, x, y);
}
jumpGlobal(key) {
this.markInteractor.jumpGlobal(key);
}
}

@ -0,0 +1,24 @@
export default class GlobalMark {
constructor(tabId, url, x, y) {
this.tabId0 = tabId;
this.url0 = url;
this.x0 = x;
this.y0 = y;
}
get tabId() {
return this.tabId0;
}
get url() {
return this.url0;
}
get x() {
return this.x0;
}
get y() {
return this.y0;
}
}

@ -22,4 +22,12 @@ export default class ContentMessageClient {
type: messages.ADDON_TOGGLE_ENABLED, type: messages.ADDON_TOGGLE_ENABLED,
}); });
} }
scrollTo(tabId, x, y) {
return browser.tabs.sendMessage(tabId, {
type: messages.TAB_SCROLL_TO,
x,
y,
});
}
} }

@ -5,6 +5,7 @@ import FindController from '../controllers/find';
import AddonEnabledController from '../controllers/addon-enabled'; import AddonEnabledController from '../controllers/addon-enabled';
import LinkController from '../controllers/link'; import LinkController from '../controllers/link';
import OperationController from '../controllers/operation'; import OperationController from '../controllers/operation';
import MarkController from '../controllers/mark';
export default class ContentMessageListener { export default class ContentMessageListener {
constructor() { constructor() {
@ -14,6 +15,7 @@ export default class ContentMessageListener {
this.addonEnabledController = new AddonEnabledController(); this.addonEnabledController = new AddonEnabledController();
this.linkController = new LinkController(); this.linkController = new LinkController();
this.backgroundOperationController = new OperationController(); this.backgroundOperationController = new OperationController();
this.markController = new MarkController();
} }
run() { run() {
@ -59,6 +61,10 @@ export default class ContentMessageListener {
message.newTab, message.url, sender.tab.id, message.background); message.newTab, message.url, sender.tab.id, message.background);
case messages.BACKGROUND_OPERATION: case messages.BACKGROUND_OPERATION:
return this.onBackgroundOperation(message.operation); return this.onBackgroundOperation(message.operation);
case messages.MARK_SET_GLOBAL:
return this.onMarkSetGlobal(message.key, message.x, message.y);
case messages.MARK_JUMP_GLOBAL:
return this.onMarkJumpGlobal(message.key);
} }
} }
@ -102,4 +108,12 @@ export default class ContentMessageListener {
onBackgroundOperation(operation) { onBackgroundOperation(operation) {
return this.backgroundOperationController.exec(operation); return this.backgroundOperationController.exec(operation);
} }
onMarkSetGlobal(key, x, y) {
return this.markController.setGlobal(key, x, y);
}
onMarkJumpGlobal(key) {
return this.markController.jumpGlobal(key);
}
} }

@ -0,0 +1,33 @@
import MemoryStorage from '../infrastructures/memory-storage';
import GlobalMark from 'background/domains/global-mark';
const MARK_KEY = 'mark';
export default class MarkRepository {
constructor() {
this.cache = new MemoryStorage();
}
getMark(key) {
let marks = this.getOrEmptyMarks();
let data = marks[key];
if (!data) {
return Promise.resolve(undefined);
}
let mark = new GlobalMark(data.tabId, data.url, data.x, data.y);
return Promise.resolve(mark);
}
setMark(key, mark) {
let marks = this.getOrEmptyMarks();
marks[key] = { tabId: mark.tabId, url: mark.url, x: mark.x, y: mark.y };
this.cache.set(MARK_KEY, marks);
return Promise.resolve();
}
getOrEmptyMarks() {
return this.cache.get(MARK_KEY) || {};
}
}

@ -0,0 +1,39 @@
import GlobalMark from '../domains/global-mark';
import TabPresenter from '../presenters/tab';
import MarkRepository from '../repositories/mark';
import ConsolePresenter from '../presenters/console';
import ContentMessageClient from '../infrastructures/content-message-client';
export default class MarkInteractor {
constructor() {
this.tabPresenter = new TabPresenter();
this.markRepository = new MarkRepository();
this.consolePresenter = new ConsolePresenter();
this.contentMessageClient = new ContentMessageClient();
}
async setGlobal(key, x, y) {
let tab = await this.tabPresenter.getCurrent();
let mark = new GlobalMark(tab.id, tab.url, x, y);
return this.markRepository.setMark(key, mark);
}
async jumpGlobal(key) {
let current = await this.tabPresenter.getCurrent();
let mark = await this.markRepository.getMark(key);
if (!mark) {
return this.consolePresenter.showError(current.id, 'Mark is not set');
}
return this.contentMessageClient.scrollTo(
mark.tabId, mark.x, mark.y
).then(() => {
return this.tabPresenter.select(mark.tabId);
}).catch(async() => {
let tab = await this.tabPresenter.create(mark.url);
let mark2 = new GlobalMark(tab.id, mark.url, mark.x, mark.y);
return this.markRepository.setMark(key, mark2);
});
}
}

@ -22,4 +22,10 @@ export default {
// Find // Find
FIND_SET_KEYWORD: 'find.set.keyword', FIND_SET_KEYWORD: 'find.set.keyword',
// Mark
MARK_START_SET: 'mark.start.set',
MARK_START_JUMP: 'mark.start.jump',
MARK_CANCEL: 'mark.cancel',
MARK_SET_LOCAL: 'mark.set.local',
}; };

@ -0,0 +1,46 @@
import actions from 'content/actions';
import messages from 'shared/messages';
const startSet = () => {
return { type: actions.MARK_START_SET };
};
const startJump = () => {
return { type: actions.MARK_START_JUMP };
};
const cancel = () => {
return { type: actions.MARK_CANCEL };
};
const setLocal = (key, x, y) => {
return {
type: actions.MARK_SET_LOCAL,
key,
x,
y,
};
};
const setGlobal = (key, x, y) => {
browser.runtime.sendMessage({
type: messages.MARK_SET_GLOBAL,
key,
x,
y,
});
return { type: '' };
};
const jumpGlobal = (key) => {
browser.runtime.sendMessage({
type: messages.MARK_JUMP_GLOBAL,
key,
});
return { type: '' };
};
export {
startSet, startJump, cancel, setLocal,
setGlobal, jumpGlobal,
};

@ -6,6 +6,7 @@ import * as focuses from 'content/focuses';
import * as urls from 'content/urls'; import * as urls from 'content/urls';
import * as consoleFrames from 'content/console-frames'; import * as consoleFrames from 'content/console-frames';
import * as addonActions from './addon'; import * as addonActions from './addon';
import * as markActions from './mark';
import * as properties from 'shared/settings/properties'; import * as properties from 'shared/settings/properties';
// eslint-disable-next-line complexity, max-lines-per-function // eslint-disable-next-line complexity, max-lines-per-function
@ -39,16 +40,16 @@ const exec = (operation, repeat, settings, addonEnabled) => {
scrolls.scrollPages(operation.count, smoothscroll, repeat); scrolls.scrollPages(operation.count, smoothscroll, repeat);
break; break;
case operations.SCROLL_TOP: case operations.SCROLL_TOP:
scrolls.scrollTop(smoothscroll, repeat); scrolls.scrollToTop(smoothscroll);
break; break;
case operations.SCROLL_BOTTOM: case operations.SCROLL_BOTTOM:
scrolls.scrollBottom(smoothscroll, repeat); scrolls.scrollToBottom(smoothscroll);
break; break;
case operations.SCROLL_HOME: case operations.SCROLL_HOME:
scrolls.scrollHome(smoothscroll, repeat); scrolls.scrollToHome(smoothscroll);
break; break;
case operations.SCROLL_END: case operations.SCROLL_END:
scrolls.scrollEnd(smoothscroll, repeat); scrolls.scrollToEnd(smoothscroll);
break; break;
case operations.FOLLOW_START: case operations.FOLLOW_START:
window.top.postMessage(JSON.stringify({ window.top.postMessage(JSON.stringify({
@ -57,6 +58,10 @@ const exec = (operation, repeat, settings, addonEnabled) => {
background: operation.background, background: operation.background,
}), '*'); }), '*');
break; break;
case operations.MARK_SET_PREFIX:
return markActions.startSet();
case operations.MARK_JUMP_PREFIX:
return markActions.startJump();
case operations.NAVIGATE_HISTORY_PREV: case operations.NAVIGATE_HISTORY_PREV:
navigates.historyPrev(window); navigates.historyPrev(window);
break; break;

@ -1,6 +1,7 @@
import InputComponent from './input'; import InputComponent from './input';
import KeymapperComponent from './keymapper';
import FollowComponent from './follow'; import FollowComponent from './follow';
import MarkComponent from './mark';
import KeymapperComponent from './keymapper';
import * as settingActions from 'content/actions/setting'; import * as settingActions from 'content/actions/setting';
import messages from 'shared/messages'; import messages from 'shared/messages';
import * as addonActions from '../../actions/addon'; import * as addonActions from '../../actions/addon';
@ -8,11 +9,13 @@ import * as blacklists from 'shared/blacklists';
export default class Common { export default class Common {
constructor(win, store) { constructor(win, store) {
const follow = new FollowComponent(win, store);
const input = new InputComponent(win.document.body, store); const input = new InputComponent(win.document.body, store);
const follow = new FollowComponent(win, store);
const mark = new MarkComponent(win.document.body, store);
const keymapper = new KeymapperComponent(store); const keymapper = new KeymapperComponent(store);
input.onKey(key => follow.key(key)); input.onKey(key => follow.key(key));
input.onKey(key => mark.key(key));
input.onKey(key => keymapper.key(key)); input.onKey(key => keymapper.key(key));
this.win = win; this.win = win;

@ -0,0 +1,74 @@
import * as markActions from 'content/actions/mark';
import * as scrolls from 'content/scrolls';
import * as consoleFrames from 'content/console-frames';
import * as properties from 'shared/settings/properties';
const cancelKey = (key) => {
return key.key === 'Esc' || key.key === '[' && key.ctrlKey;
};
const globalKey = (key) => {
return (/^[A-Z0-9]$/).test(key);
};
export default class MarkComponent {
constructor(body, store) {
this.body = body;
this.store = store;
}
// eslint-disable-next-line max-statements
key(key) {
let { mark: markStage, setting } = this.store.getState();
let smoothscroll = setting.properties.smoothscroll ||
properties.defaults.smoothscroll;
if (!markStage.setMode && !markStage.jumpMode) {
return false;
}
if (cancelKey(key)) {
this.store.dispatch(markActions.cancel());
return true;
}
if (key.ctrlKey || key.metaKey || key.altKey) {
consoleFrames.postError(window.document, 'Unknown mark');
} else if (globalKey(key.key) && markStage.setMode) {
this.doSetGlobal(key);
} else if (globalKey(key.key) && markStage.jumpMode) {
this.doJumpGlobal(key);
} else if (markStage.setMode) {
this.doSet(key);
} else if (markStage.jumpMode) {
this.doJump(markStage.marks, key, smoothscroll);
}
this.store.dispatch(markActions.cancel());
return true;
}
doSet(key) {
let { x, y } = scrolls.getScroll();
this.store.dispatch(markActions.setLocal(key.key, x, y));
}
doJump(marks, key, smoothscroll) {
if (!marks[key.key]) {
consoleFrames.postError(window.document, 'Mark is not set');
return;
}
let { x, y } = marks[key.key];
scrolls.scrollTo(x, y, smoothscroll);
}
doSetGlobal(key) {
let { x, y } = scrolls.getScroll();
this.store.dispatch(markActions.setGlobal(key.key, x, y));
}
doJumpGlobal(key) {
this.store.dispatch(markActions.jumpGlobal(key.key));
}
}

@ -3,6 +3,7 @@ import FollowController from './follow-controller';
import FindComponent from './find'; import FindComponent from './find';
import * as consoleFrames from '../../console-frames'; import * as consoleFrames from '../../console-frames';
import messages from 'shared/messages'; import messages from 'shared/messages';
import * as scrolls from 'content/scrolls';
export default class TopContent { export default class TopContent {
@ -33,6 +34,8 @@ export default class TopContent {
type: messages.ADDON_ENABLED_RESPONSE, type: messages.ADDON_ENABLED_RESPONSE,
enabled: addonState.enabled, enabled: addonState.enabled,
}); });
case messages.TAB_SCROLL_TO:
return scrolls.scrollTo(message.x, message.y, false);
} }
} }
} }

@ -4,7 +4,8 @@ import find from './find';
import setting from './setting'; import setting from './setting';
import input from './input'; import input from './input';
import followController from './follow-controller'; import followController from './follow-controller';
import mark from './mark';
export default combineReducers({ export default combineReducers({
addon, find, setting, input, followController, addon, find, setting, input, followController, mark,
}); });

@ -0,0 +1,25 @@
import actions from 'content/actions';
const defaultState = {
setMode: false,
jumpMode: false,
marks: {},
};
export default function reducer(state = defaultState, action = {}) {
switch (action.type) {
case actions.MARK_START_SET:
return { ...state, setMode: true };
case actions.MARK_START_JUMP:
return { ...state, jumpMode: true };
case actions.MARK_CANCEL:
return { ...state, setMode: false, jumpMode: false };
case actions.MARK_SET_LOCAL: {
let marks = { ...state.marks };
marks[action.key] = { x: action.x, y: action.y };
return { ...state, setMode: false, marks };
}
default:
return state;
}
}

@ -130,6 +130,11 @@ const scroller = (element, smooth, repeat) => {
return new RoughtScroller(element); return new RoughtScroller(element);
}; };
const getScroll = () => {
let target = scrollTarget();
return { x: target.scrollLeft, y: target.scrollTop };
};
const scrollVertically = (count, smooth, repeat) => { const scrollVertically = (count, smooth, repeat) => {
let target = scrollTarget(); let target = scrollTarget();
let x = target.scrollLeft; let x = target.scrollLeft;
@ -158,35 +163,42 @@ const scrollPages = (count, smooth, repeat) => {
scroller(target, smooth, repeat).scroll(x, y); scroller(target, smooth, repeat).scroll(x, y);
}; };
const scrollTop = (smooth, repeat) => { const scrollTo = (x, y, smooth) => {
let target = scrollTarget();
scroller(target, smooth, false).scroll(x, y);
};
const scrollToTop = (smooth) => {
let target = scrollTarget(); let target = scrollTarget();
let x = target.scrollLeft; let x = target.scrollLeft;
let y = 0; let y = 0;
scroller(target, smooth, repeat).scroll(x, y); scroller(target, smooth, false).scroll(x, y);
}; };
const scrollBottom = (smooth, repeat) => { const scrollToBottom = (smooth) => {
let target = scrollTarget(); let target = scrollTarget();
let x = target.scrollLeft; let x = target.scrollLeft;
let y = target.scrollHeight; let y = target.scrollHeight;
scroller(target, smooth, repeat).scroll(x, y); scroller(target, smooth, false).scroll(x, y);
}; };
const scrollHome = (smooth, repeat) => { const scrollToHome = (smooth) => {
let target = scrollTarget(); let target = scrollTarget();
let x = 0; let x = 0;
let y = target.scrollTop; let y = target.scrollTop;
scroller(target, smooth, repeat).scroll(x, y); scroller(target, smooth, false).scroll(x, y);
}; };
const scrollEnd = (smooth, repeat) => { const scrollToEnd = (smooth) => {
let target = scrollTarget(); let target = scrollTarget();
let x = target.scrollWidth; let x = target.scrollWidth;
let y = target.scrollTop; let y = target.scrollTop;
scroller(target, smooth, repeat).scroll(x, y); scroller(target, smooth, false).scroll(x, y);
}; };
export { export {
getScroll,
scrollVertically, scrollHorizonally, scrollPages, scrollVertically, scrollHorizonally, scrollPages,
scrollTop, scrollBottom, scrollHome, scrollEnd scrollTo,
scrollToTop, scrollToBottom, scrollToHome, scrollToEnd
}; };

@ -16,6 +16,9 @@ const KeyMapFields = [
['scroll.pages?{"count":0.5}', 'Scroll down by half of screen'], ['scroll.pages?{"count":0.5}', 'Scroll down by half of screen'],
['scroll.pages?{"count":-1}', 'Scroll up by a screen'], ['scroll.pages?{"count":-1}', 'Scroll up by a screen'],
['scroll.pages?{"count":1}', 'Scroll down by a screen'], ['scroll.pages?{"count":1}', 'Scroll down by a screen'],
], [
['mark.set.prefix', 'Set mark at current position'],
['mark.jump.prefix', 'Jump to the mark'],
], [ ], [
['tabs.close', 'Close a tab'], ['tabs.close', 'Close a tab'],
['tabs.reopen', 'Reopen closed tab'], ['tabs.reopen', 'Reopen closed tab'],

@ -43,6 +43,11 @@ export default {
FOLLOW_ACTIVATE: 'follow.activate', FOLLOW_ACTIVATE: 'follow.activate',
FOLLOW_KEY_PRESS: 'follow.key.press', FOLLOW_KEY_PRESS: 'follow.key.press',
MARK_SET_GLOBAL: 'mark.set.global',
MARK_JUMP_GLOBAL: 'mark.jump.global',
TAB_SCROLL_TO: 'tab.scroll.to',
FIND_NEXT: 'find.next', FIND_NEXT: 'find.next',
FIND_PREV: 'find.prev', FIND_PREV: 'find.prev',
FIND_GET_KEYWORD: 'find.get.keyword', FIND_GET_KEYWORD: 'find.get.keyword',

@ -69,4 +69,8 @@ export default {
FIND_START: 'find.start', FIND_START: 'find.start',
FIND_NEXT: 'find.next', FIND_NEXT: 'find.next',
FIND_PREV: 'find.prev', FIND_PREV: 'find.prev',
// Mark
MARK_SET_PREFIX: 'mark.set.prefix',
MARK_JUMP_PREFIX: 'mark.jump.prefix',
}; };

@ -42,6 +42,8 @@ export default {
"zz": { "type": "zoom.neutral" }, "zz": { "type": "zoom.neutral" },
"f": { "type": "follow.start", "newTab": false }, "f": { "type": "follow.start", "newTab": false },
"F": { "type": "follow.start", "newTab": true, "background": false }, "F": { "type": "follow.start", "newTab": true, "background": false },
"m": { "type": "mark.set.prefix" },
"'": { "type": "mark.jump.prefix" },
"H": { "type": "navigate.history.prev" }, "H": { "type": "navigate.history.prev" },
"L": { "type": "navigate.history.next" }, "L": { "type": "navigate.history.next" },
"[[": { "type": "navigate.link.prev" }, "[[": { "type": "navigate.link.prev" },

@ -0,0 +1,11 @@
import GlobalMark from 'background/domains/global-mark';
describe('background/domains/global-mark', () => {
describe('constructor and getter', () => {
let mark = new GlobalMark(1, 'http://example.com', 10, 30);
expect(mark.tabId).to.equal(1);
expect(mark.url).to.equal('http://example.com');
expect(mark.x).to.equal(10);
expect(mark.y).to.equal(30);
});
});

@ -1,8 +1,6 @@
import MemoryStorage from 'background/infrastructures/memory-storage'; import MemoryStorage from 'background/infrastructures/memory-storage';
describe("background/infrastructures/memory-storage", () => { describe("background/infrastructures/memory-storage", () => {
let versionRepository;
it('stores values', () => { it('stores values', () => {
let cache = new MemoryStorage(); let cache = new MemoryStorage();
cache.set('number', 123); cache.set('number', 123);

@ -0,0 +1,26 @@
import MarkRepository from 'background/repositories/mark';
import GlobalMark from 'background/domains/global-mark';
describe('background/repositories/mark', () => {
let repository;
beforeEach(() => {
repository = new MarkRepository;
});
it('get and set', async() => {
let mark = new GlobalMark(1, 'http://example.com', 10, 30);
repository.setMark('A', mark);
let got = await repository.getMark('A');
expect(got).to.be.a('object');
expect(got.tabId).to.equal(1);
expect(got.url).to.equal('http://example.com');
expect(got.x).to.equal(10);
expect(got.y).to.equal(30);
got = await repository.getMark('B');
expect(got).to.be.undefined;
});
});

@ -0,0 +1,35 @@
import actions from 'content/actions';
import * as markActions from 'content/actions/mark';
describe('mark actions', () => {
describe('startSet', () => {
it('create MARK_START_SET action', () => {
let action = markActions.startSet();
expect(action.type).to.equal(actions.MARK_START_SET);
});
});
describe('startJump', () => {
it('create MARK_START_JUMP action', () => {
let action = markActions.startJump();
expect(action.type).to.equal(actions.MARK_START_JUMP);
});
});
describe('cancel', () => {
it('create MARK_CANCEL action', () => {
let action = markActions.cancel();
expect(action.type).to.equal(actions.MARK_CANCEL);
});
});
describe('setLocal', () => {
it('create setLocal action', () => {
let action = markActions.setLocal('a', 20, 30);
expect(action.type).to.equal(actions.MARK_SET_LOCAL);
expect(action.key).to.equal('a');
expect(action.x).to.equal(20);
expect(action.y).to.equal(30);
});
});
});

@ -0,0 +1,41 @@
import actions from 'content/actions';
import reducer from 'content/reducers/mark';
describe("mark reducer", () => {
it('return the initial state', () => {
let state = reducer(undefined, {});
expect(state.setMode).to.be.false;
expect(state.jumpMode).to.be.false;
expect(state.marks).to.be.empty;
});
it('starts set mode', () => {
let action = { type: actions.MARK_START_SET };
let state = reducer(undefined, action);
expect(state.setMode).to.be.true;
});
it('starts jump mode', () => {
let action = { type: actions.MARK_START_JUMP };
let state = reducer(undefined, action);
expect(state.jumpMode).to.be.true;
});
it('cancels set and jump mode', () => {
let action = { type: actions.MARK_CANCEL };
let state = reducer({ setMode: true }, action);
expect(state.setMode).to.be.false;
state = reducer({ jumpMode: true }, action);
expect(state.jumpMode).to.be.false;
});
it('stores local mark', () => {
let action = { type: actions.MARK_SET_LOCAL, key: 'a', x: 20, y: 30};
let state = reducer({ setMode: true }, action);
expect(state.setMode).to.be.false;
expect(state.marks['a']).to.be.an('object')
expect(state.marks['a'].x).to.equal(20)
expect(state.marks['a'].y).to.equal(30)
});
});