aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorShin'ya Ueoka <ueokande@i-beam.org>2021-06-14 23:14:51 +0900
committerShin'ya Ueoka <ueokande@i-beam.org>2021-07-05 21:32:43 +0900
commit65cf6f0842d8d5933dc13b3767b1baf398d68cd5 (patch)
treedf9a8b139fd98adb79f075ba655d1303bdf3fd1d
parentcaced372415a944c4297157397d0027ba629fff0 (diff)
Implement FindNextOperator
-rw-r--r--src/background/Application.ts10
-rw-r--r--src/background/clients/FindClient.ts30
-rw-r--r--src/background/controllers/FindController.ts11
-rw-r--r--src/background/di.ts6
-rw-r--r--src/background/infrastructures/ContentMessageListener.ts6
-rw-r--r--src/background/operators/impls/FindNextOperator.ts94
-rw-r--r--src/background/operators/impls/FindOperatorFactoryChain.ts49
-rw-r--r--src/background/operators/impls/FindPrevOperator.ts20
-rw-r--r--src/background/operators/impls/OperatorFactoryImpl.ts3
-rw-r--r--src/background/presenters/FramePresenter.ts12
-rw-r--r--src/background/repositories/FindRepository.ts69
-rw-r--r--src/background/usecases/StartFindUseCase.ts57
-rw-r--r--src/console/app/hooks.ts11
-rw-r--r--src/content/Application.ts10
-rw-r--r--src/content/MessageListener.ts7
-rw-r--r--src/content/controllers/FindController.ts19
-rw-r--r--src/content/di.ts2
-rw-r--r--src/content/presenters/FindPresenter.ts23
-rw-r--r--src/content/usecases/FindUseCase.ts22
-rw-r--r--src/shared/messages.ts26
-rw-r--r--test/background/mock/MockFindClient.ts24
-rw-r--r--test/background/mock/MockFindRepository.ts26
-rw-r--r--test/background/operators/impls/FindNextOperator.test.ts90
-rw-r--r--test/background/operators/impls/FindOperatorFactoryChain.ts23
-rw-r--r--test/background/operators/impls/FindPrevOperator.test.ts90
-rw-r--r--test/background/repositories/FindRepository.test.ts37
-rw-r--r--test/background/usecases/FindUseCase.test.ts121
27 files changed, 886 insertions, 12 deletions
diff --git a/src/background/Application.ts b/src/background/Application.ts
index 69fe4a4..2006965 100644
--- a/src/background/Application.ts
+++ b/src/background/Application.ts
@@ -3,6 +3,7 @@ import ContentMessageListener from "./infrastructures/ContentMessageListener";
import SettingController from "./controllers/SettingController";
import VersionController from "./controllers/VersionController";
import SettingRepository from "./repositories/SettingRepository";
+import FindRepositoryImpl from "./repositories/FindRepository";
@injectable()
export default class Application {
@@ -11,12 +12,19 @@ export default class Application {
private settingController: SettingController,
private versionController: VersionController,
@inject("SyncSettingRepository")
- private syncSettingRepository: SettingRepository
+ private syncSettingRepository: SettingRepository,
+ @inject("FindRepository")
+ private readonly findRepository: FindRepositoryImpl
) {}
run() {
this.settingController.reload();
+ browser.tabs.onUpdated.addListener((tabId: number, info) => {
+ if (info.status == "loading") {
+ this.findRepository.deleteLocalState(tabId);
+ }
+ });
browser.runtime.onInstalled.addListener((details) => {
if (details.reason !== "install" && details.reason !== "update") {
return;
diff --git a/src/background/clients/FindClient.ts b/src/background/clients/FindClient.ts
new file mode 100644
index 0000000..863c5ad
--- /dev/null
+++ b/src/background/clients/FindClient.ts
@@ -0,0 +1,30 @@
+import * as messages from "../../shared/messages";
+
+export default interface FindClient {
+ findNext(tabId: number, frameId: number, keyword: string): Promise<boolean>;
+
+ clearSelection(tabId: number, frameId: number): Promise<void>;
+}
+
+export class FindClientImpl implements FindClient {
+ async findNext(
+ tabId: number,
+ frameId: number,
+ keyword: string
+ ): Promise<boolean> {
+ const found = (await browser.tabs.sendMessage(
+ tabId,
+ { type: messages.FIND_NEXT, keyword },
+ { frameId }
+ )) as boolean;
+ return found;
+ }
+
+ clearSelection(tabId: number, frameId: number): Promise<void> {
+ return browser.tabs.sendMessage(
+ tabId,
+ { type: messages.FIND_CLEAR_SELECTION },
+ { frameId }
+ );
+ }
+}
diff --git a/src/background/controllers/FindController.ts b/src/background/controllers/FindController.ts
new file mode 100644
index 0000000..0772866
--- /dev/null
+++ b/src/background/controllers/FindController.ts
@@ -0,0 +1,11 @@
+import { injectable } from "tsyringe";
+import StartFindUseCase from "../usecases/StartFindUseCase";
+
+@injectable()
+export default class FindController {
+ constructor(private startFindUseCase: StartFindUseCase) {}
+
+ startFind(tabId: number, keyword?: string): Promise<void> {
+ return this.startFindUseCase.startFind(tabId, keyword);
+ }
+}
diff --git a/src/background/di.ts b/src/background/di.ts
index a571c7d..e97c4a8 100644
--- a/src/background/di.ts
+++ b/src/background/di.ts
@@ -18,7 +18,10 @@ import { BrowserSettingRepositoryImpl } from "./repositories/BrowserSettingRepos
import { RepeatRepositoryImpl } from "./repositories/RepeatRepository";
import { ZoomPresenterImpl } from "./presenters/ZoomPresenter";
import { WindowPresenterImpl } from "./presenters/WindowPresenter";
+import { FramePresenterImpl } from "./presenters/FramePresenter";
+import { FindClientImpl } from "./clients/FindClient";
import { ConsoleFrameClientImpl } from "./clients/ConsoleFrameClient";
+import { FindRepositoryImpl } from "./repositories/FindRepository";
container.register("LocalSettingRepository", {
useClass: LocalSettingRepository,
@@ -40,6 +43,9 @@ container.register("TabRepository", { useClass: TabRepositoryImpl });
container.register("ZoomPresenter", { useClass: ZoomPresenterImpl });
container.register("TabPresenter", { useClass: TabPresenterImpl });
container.register("WindowPresenter", { useClass: WindowPresenterImpl });
+container.register("FramePresenter", { useClass: FramePresenterImpl });
+container.register("FindRepository", { useClass: FindRepositoryImpl });
+container.register("FindClient", { useClass: FindClientImpl });
container.register("NavigateClient", { useClass: NavigateClientImpl });
container.register("ConsoleClient", { useClass: ConsoleClientImpl });
container.register("ConsoleFrameClient", { useClass: ConsoleFrameClientImpl });
diff --git a/src/background/infrastructures/ContentMessageListener.ts b/src/background/infrastructures/ContentMessageListener.ts
index 6189e7c..ce7ff09 100644
--- a/src/background/infrastructures/ContentMessageListener.ts
+++ b/src/background/infrastructures/ContentMessageListener.ts
@@ -9,6 +9,7 @@ import OperationController from "../controllers/OperationController";
import MarkController from "../controllers/MarkController";
import CompletionController from "../controllers/CompletionController";
import ConsoleController from "../controllers/ConsoleController";
+import FindController from "../controllers/FindController";
@injectable()
export default class ContentMessageListener {
@@ -22,7 +23,8 @@ export default class ContentMessageListener {
private readonly linkController: LinkController,
private readonly operationController: OperationController,
private readonly markController: MarkController,
- private readonly consoleController: ConsoleController
+ private readonly consoleController: ConsoleController,
+ private readonly findController: FindController
) {}
run(): void {
@@ -80,6 +82,8 @@ export default class ContentMessageListener {
return this.completionController.getProperties();
case messages.CONSOLE_ENTER_COMMAND:
return this.onConsoleEnterCommand(message.text);
+ case messages.CONSOLE_ENTER_FIND:
+ return this.findController.startFind(senderTab.id!, message.keyword);
case messages.CONSOLE_RESIZE:
return this.onConsoleResize(
senderTab.id!,
diff --git a/src/background/operators/impls/FindNextOperator.ts b/src/background/operators/impls/FindNextOperator.ts
new file mode 100644
index 0000000..241f71d
--- /dev/null
+++ b/src/background/operators/impls/FindNextOperator.ts
@@ -0,0 +1,94 @@
+import Operator from "../Operator";
+import TabPresenter from "../../presenters/TabPresenter";
+import FindRepository from "../../repositories/FindRepository";
+import FindClient from "../../clients/FindClient";
+import ConsoleClient from "../../infrastructures/ConsoleClient";
+import FramePresenter from "../../presenters/FramePresenter";
+
+export default class FindNextOperator implements Operator {
+ constructor(
+ private readonly tabPresenter: TabPresenter,
+ private readonly findRepository: FindRepository,
+ private readonly findClient: FindClient,
+ private readonly consoleClient: ConsoleClient,
+ private readonly framePresenter: FramePresenter
+ ) {}
+
+ async run(): Promise<void> {
+ const tab = await this.tabPresenter.getCurrent();
+ const tabId = tab?.id;
+ if (tabId == null) {
+ return;
+ }
+
+ const state = await this.findRepository.getLocalState(tabId);
+ if (state) {
+ // Start to find the keyword from the current frame which last found on,
+ // and concat it to end of frame ids to perform a wrap-search
+ //
+ // ,- keyword should be in this frame
+ // |
+ // [100, 101, 0, 100]
+ // |
+ // `- continue from frame id 100
+ //
+ const targetFrameIds = state.frameIds
+ .slice(state.framePos)
+ .concat(
+ state.frameIds.slice(0, state.framePos),
+ state.frameIds[state.framePos]
+ );
+
+ for (let i = 0; i < targetFrameIds.length; ++i) {
+ const found = await this.findClient.findNext(
+ tabId,
+ targetFrameIds[i],
+ state.keyword
+ );
+ if (found) {
+ this.findRepository.setLocalState(tabId, {
+ ...state,
+ framePos: (i + state.framePos) % state.frameIds.length, // save current frame position or first
+ });
+ return;
+ }
+ this.findClient.clearSelection(tabId, targetFrameIds[i]);
+ }
+
+ // The keyword is gone.
+ this.consoleClient.showError(
+ tabId,
+ "Pattern not found: " + state.keyword
+ );
+ return;
+ }
+
+ const keyword = await this.findRepository.getGlobalKeyword();
+ if (keyword) {
+ const frameIds = await this.framePresenter.getAllFrameIds(tabId);
+ for (const frameId of frameIds) {
+ await this.findClient.clearSelection(tabId, frameId);
+ }
+
+ for (let framePos = 0; framePos < frameIds.length; ++framePos) {
+ const found = await this.findClient.findNext(
+ tabId,
+ frameIds[framePos],
+ keyword
+ );
+ if (found) {
+ await this.findRepository.setLocalState(tabId, {
+ frameIds,
+ framePos,
+ keyword,
+ });
+ await this.consoleClient.showInfo(tabId, "Pattern found: " + keyword);
+ return;
+ }
+ }
+ this.consoleClient.showError(tabId, "Pattern not found: " + keyword);
+ return;
+ }
+ await this.consoleClient.showError(tabId, "No previous search keywords");
+ }
+}
diff --git a/src/background/operators/impls/FindOperatorFactoryChain.ts b/src/background/operators/impls/FindOperatorFactoryChain.ts
new file mode 100644
index 0000000..b71f032
--- /dev/null
+++ b/src/background/operators/impls/FindOperatorFactoryChain.ts
@@ -0,0 +1,49 @@
+import { inject, injectable } from "tsyringe";
+import Operator from "../Operator";
+import OperatorFactoryChain from "../OperatorFactoryChain";
+import TabPresenter from "../../presenters/TabPresenter";
+import * as operations from "../../../shared/operations";
+import FindNextOperator from "./FindNextOperator";
+import FindPrevOperator from "./FindPrevOperator";
+import FindRepository from "../../repositories/FindRepository";
+import FindClient from "../../clients/FindClient";
+import FramePresenter from "../../presenters/FramePresenter";
+import ConsoleClient from "../../infrastructures/ConsoleClient";
+
+@injectable()
+export default class FindOperatorFactoryChain implements OperatorFactoryChain {
+ constructor(
+ @inject("TabPresenter")
+ private readonly tabPresenter: TabPresenter,
+ @inject("FindRepository")
+ private readonly findRepository: FindRepository,
+ @inject("FindClient")
+ private readonly findClient: FindClient,
+ @inject("ConsoleClient")
+ private readonly consoleClient: ConsoleClient,
+ @inject("FramePresenter")
+ private readonly framePresenter: FramePresenter
+ ) {}
+
+ create(op: operations.Operation): Operator | null {
+ switch (op.type) {
+ case operations.FIND_NEXT:
+ return new FindNextOperator(
+ this.tabPresenter,
+ this.findRepository,
+ this.findClient,
+ this.consoleClient,
+ this.framePresenter
+ );
+ case operations.FIND_PREV:
+ return new FindPrevOperator(
+ this.tabPresenter,
+ this.findRepository,
+ this.findClient,
+ this.consoleClient,
+ this.framePresenter
+ );
+ }
+ return null;
+ }
+}
diff --git a/src/background/operators/impls/FindPrevOperator.ts b/src/background/operators/impls/FindPrevOperator.ts
new file mode 100644
index 0000000..28238f6
--- /dev/null
+++ b/src/background/operators/impls/FindPrevOperator.ts
@@ -0,0 +1,20 @@
+import Operator from "../Operator";
+import TabPresenter from "../../presenters/TabPresenter";
+import FindRepository from "../../repositories/FindRepository";
+import FindClient from "../../clients/FindClient";
+import ConsoleClient from "../../infrastructures/ConsoleClient";
+import FramePresenter from "../../presenters/FramePresenter";
+
+export default class FindPrevOperator implements Operator {
+ constructor(
+ _tabPresenter: TabPresenter,
+ _findRepository: FindRepository,
+ _findClient: FindClient,
+ _consoleClient: ConsoleClient,
+ _framePresenter: FramePresenter
+ ) {}
+
+ async run(): Promise<void> {
+ throw new Error("not implemented");
+ }
+}
diff --git a/src/background/operators/impls/OperatorFactoryImpl.ts b/src/background/operators/impls/OperatorFactoryImpl.ts
index 34e7bb5..ce87491 100644
--- a/src/background/operators/impls/OperatorFactoryImpl.ts
+++ b/src/background/operators/impls/OperatorFactoryImpl.ts
@@ -8,6 +8,7 @@ import NavigateOperatorFactoryChain from "./NavigateOperatorFactoryChain";
import RepeatOperatorFactoryChain from "./RepeatOperatorFactoryChain";
import TabOperatorFactoryChain from "./TabOperatorFactoryChain";
import ZoomOperatorFactoryChain from "./ZoomOperatorFactoryChain";
+import FindOperatorFactoryChain from "./FindOperatorFactoryChain";
import * as operations from "../../../shared/operations";
@injectable()
@@ -20,6 +21,7 @@ export class OperatorFactoryImpl implements OperatorFactory {
navigateOperatorFactoryChain: NavigateOperatorFactoryChain,
tabOperatorFactoryChain: TabOperatorFactoryChain,
zoomOperatorFactoryChain: ZoomOperatorFactoryChain,
+ findOperatorFactoryChain: FindOperatorFactoryChain,
@inject(delay(() => RepeatOperatorFactoryChain))
repeatOperatorFactoryChain: RepeatOperatorFactoryChain
) {
@@ -30,6 +32,7 @@ export class OperatorFactoryImpl implements OperatorFactory {
repeatOperatorFactoryChain,
tabOperatorFactoryChain,
zoomOperatorFactoryChain,
+ findOperatorFactoryChain,
];
}
diff --git a/src/background/presenters/FramePresenter.ts b/src/background/presenters/FramePresenter.ts
new file mode 100644
index 0000000..c94f8dd
--- /dev/null
+++ b/src/background/presenters/FramePresenter.ts
@@ -0,0 +1,12 @@
+export default interface FramePresenter {
+ getAllFrameIds(tabId: number): Promise<Array<number>>;
+}
+
+export class FramePresenterImpl implements FramePresenter {
+ async getAllFrameIds(tabId: number): Promise<Array<number>> {
+ const frames = await browser.webNavigation.getAllFrames({ tabId: tabId });
+ return frames
+ .filter((f) => !f.url.startsWith("moz-extension://"))
+ .map((f) => f.frameId);
+ }
+}
diff --git a/src/background/repositories/FindRepository.ts b/src/background/repositories/FindRepository.ts
new file mode 100644
index 0000000..46ee390
--- /dev/null
+++ b/src/background/repositories/FindRepository.ts
@@ -0,0 +1,69 @@
+import { injectable } from "tsyringe";
+import MemoryStorage from "../infrastructures/MemoryStorage";
+
+const FIND_GLOBAL_KEYWORD_KEY = "find-global-keyword";
+const FIND_LOCAL_KEYWORD_KEY = "find-local-keyword";
+
+export type FindState = {
+ keyword: string;
+ framePos: number;
+ frameIds: number[];
+};
+
+export default interface FindRepository {
+ getGlobalKeyword(): Promise<string | undefined>;
+
+ setGlobalKeyword(keyword: string): Promise<void>;
+
+ getLocalState(tabId: number): Promise<undefined | FindState>;
+
+ setLocalState(tabId: number, state: FindState): Promise<void>;
+
+ deleteLocalState(tabId: number): Promise<void>;
+}
+
+@injectable()
+export class FindRepositoryImpl implements FindRepository {
+ private cache: MemoryStorage;
+
+ constructor() {
+ this.cache = new MemoryStorage();
+ }
+
+ getGlobalKeyword(): Promise<string | undefined> {
+ return Promise.resolve(this.cache.get(FIND_GLOBAL_KEYWORD_KEY));
+ }
+
+ setGlobalKeyword(keyword: string): Promise<void> {
+ this.cache.set(FIND_GLOBAL_KEYWORD_KEY, keyword);
+ return Promise.resolve();
+ }
+
+ getLocalState(tabId: number): Promise<FindState | undefined> {
+ let states = this.cache.get(FIND_LOCAL_KEYWORD_KEY);
+ if (typeof states === "undefined") {
+ states = {};
+ }
+ return Promise.resolve(states[tabId]);
+ }
+
+ setLocalState(tabId: number, state: FindState): Promise<void> {
+ let states = this.cache.get(FIND_LOCAL_KEYWORD_KEY);
+ if (typeof states === "undefined") {
+ states = {};
+ }
+ states[tabId] = state;
+ this.cache.set(FIND_LOCAL_KEYWORD_KEY, states);
+ return Promise.resolve();
+ }
+
+ deleteLocalState(tabId: number): Promise<void> {
+ const states = this.cache.get(FIND_LOCAL_KEYWORD_KEY);
+ if (typeof states === "undefined") {
+ return Promise.resolve();
+ }
+ delete states[tabId];
+ this.cache.set(FIND_LOCAL_KEYWORD_KEY, states);
+ return Promise.resolve();
+ }
+}
diff --git a/src/background/usecases/StartFindUseCase.ts b/src/background/usecases/StartFindUseCase.ts
new file mode 100644
index 0000000..066d930
--- /dev/null
+++ b/src/background/usecases/StartFindUseCase.ts
@@ -0,0 +1,57 @@
+import { inject, injectable } from "tsyringe";
+import ConsoleClient from "../infrastructures/ConsoleClient";
+import FindRepositoryImpl from "../repositories/FindRepository";
+import FindClient from "../clients/FindClient";
+import FramePresenter from "../presenters/FramePresenter";
+
+@injectable()
+export default class StartFindUseCase {
+ constructor(
+ @inject("FindClient")
+ private readonly findClient: FindClient,
+ @inject("FindRepository")
+ private readonly findRepository: FindRepositoryImpl,
+ @inject("ConsoleClient")
+ private readonly consoleClient: ConsoleClient,
+ @inject("FramePresenter")
+ private readonly framePresenter: FramePresenter
+ ) {}
+
+ async startFind(tabId: number, keyword?: string): Promise<void> {
+ if (typeof keyword === "undefined") {
+ keyword = (await this.findRepository.getLocalState(tabId))?.keyword;
+ }
+ if (typeof keyword === "undefined") {
+ keyword = await this.findRepository.getGlobalKeyword();
+ }
+ if (typeof keyword === "undefined") {
+ await this.consoleClient.showError(tabId, "No previous search keywords");
+ return;
+ }
+
+ this.findRepository.setGlobalKeyword(keyword);
+
+ const frameIds = await this.framePresenter.getAllFrameIds(tabId);
+ for (const frameId of frameIds) {
+ await this.findClient.clearSelection(tabId, frameId);
+ }
+
+ for (let framePos = 0; framePos < frameIds.length; ++framePos) {
+ const found = await this.findClient.findNext(
+ tabId,
+ frameIds[framePos],
+ keyword
+ );
+ if (found) {
+ await this.findRepository.setLocalState(tabId, {
+ frameIds,
+ framePos,
+ keyword,
+ });
+ await this.consoleClient.showInfo(tabId, "Pattern found: " + keyword);
+ return;
+ }
+ }
+ this.consoleClient.showError(tabId, "Pattern not found: " + keyword);
+ }
+}
diff --git a/src/console/app/hooks.ts b/src/console/app/hooks.ts
index eefdea3..00ac05c 100644
--- a/src/console/app/hooks.ts
+++ b/src/console/app/hooks.ts
@@ -103,13 +103,10 @@ export const useExecCommand = () => {
export const useExecFind = () => {
const execFind = React.useCallback((text?: string) => {
- window.top.postMessage(
- JSON.stringify({
- type: messages.CONSOLE_ENTER_FIND,
- text,
- }),
- "*"
- );
+ browser.runtime.sendMessage({
+ type: messages.CONSOLE_ENTER_FIND,
+ keyword: text,
+ });
}, []);
return execFind;
};
diff --git a/src/content/Application.ts b/src/content/Application.ts
index 9ea047c..a12c3c6 100644
--- a/src/content/Application.ts
+++ b/src/content/Application.ts
@@ -13,6 +13,7 @@ import SettingController from "./controllers/SettingController";
import ConsoleFrameController from "./controllers/ConsoleFrameController";
import NavigateController from "./controllers/NavigateController";
import * as messages from "../shared/messages";
+import FindController from "./controllers/FindController";
type Message = messages.Message;
@@ -31,7 +32,8 @@ export default class Application {
private addonEnabledController: AddonEnabledController,
private settingController: SettingController,
private consoleFrameController: ConsoleFrameController,
- private navigateController: NavigateController
+ private navigateController: NavigateController,
+ private findController: FindController
) {}
init(): Promise<void> {
@@ -91,6 +93,12 @@ export default class Application {
return this.navigateController.openLinkPrev(msg);
case messages.CONSOLE_RESIZE:
return this.consoleFrameController.resize(msg);
+ case messages.FIND_NEXT:
+ return this.findController.findNext(msg.keyword);
+ case messages.FIND_PREV:
+ return this.findController.findPrev(msg.keyword);
+ case messages.FIND_CLEAR_SELECTION:
+ return this.findController.clearSelection();
}
if (window.self === window.top) {
diff --git a/src/content/MessageListener.ts b/src/content/MessageListener.ts
index ec6940b..31cc1ae 100644
--- a/src/content/MessageListener.ts
+++ b/src/content/MessageListener.ts
@@ -27,7 +27,12 @@ export default class MessageListener {
) {
browser.runtime.onMessage.addListener(
(msg: any, sender: WebExtMessageSender) => {
- return Promise.resolve(listener(valueOf(msg), sender));
+ try {
+ return Promise.resolve(listener(valueOf(msg), sender));
+ } catch (e) {
+ console.warn(e);
+ return;
+ }
}
);
}
diff --git a/src/content/controllers/FindController.ts b/src/content/controllers/FindController.ts
new file mode 100644
index 0000000..adcdb0d
--- /dev/null
+++ b/src/content/controllers/FindController.ts
@@ -0,0 +1,19 @@
+import { injectable } from "tsyringe";
+import FindUseCase from "../usecases/FindUseCase";
+
+@injectable()
+export default class FindController {
+ constructor(private findUseCase: FindUseCase) {}
+
+ findNext(keyword: string): boolean {
+ return this.findUseCase.findNext(keyword);
+ }
+
+ findPrev(keyword: string): boolean {
+ return this.findUseCase.findPrev(keyword);
+ }
+
+ clearSelection() {
+ return this.findUseCase.clearSelection();
+ }
+}
diff --git a/src/content/di.ts b/src/content/di.ts
index 7a7fb08..4c85e76 100644
--- a/src/content/di.ts
+++ b/src/content/di.ts
@@ -27,6 +27,7 @@ import { TabsClientImpl } from "./client/TabsClient";
import { container } from "tsyringe";
import OperatorFactoryImpl from "./operators/impls/OperatorFactoryImpl";
import { URLRepositoryImpl } from "./operators/impls/URLRepository";
+import { FindPresenterImpl } from "./presenters/FindPresenter";
container.register("FollowMasterClient", {
useValue: new FollowMasterClientImpl(window.top),
@@ -71,6 +72,7 @@ container.register("NavigationPresenter", {
});
container.register("OperationClient", { useClass: OperationClientImpl });
container.register("ScrollPresenter", { useClass: ScrollPresenterImpl });
+container.register("FindPresenter", { useClass: FindPresenterImpl });
container.register("SettingClient", { useClass: SettingClientImpl });
container.register("SettingRepository", { useClass: SettingRepositoryImpl });
container.register("URLRepository", { useClass: URLRepositoryImpl });
diff --git a/src/content/presenters/FindPresenter.ts b/src/content/presenters/FindPresenter.ts
new file mode 100644
index 0000000..569f161
--- /dev/null
+++ b/src/content/presenters/FindPresenter.ts
@@ -0,0 +1,23 @@
+export default interface FindPresenter {
+ find(keyword: string, backwards: boolean): boolean;
+
+ clearSelection(): void;
+}
+
+export class FindPresenterImpl implements FindPresenter {
+ find(keyword: string, backwards: boolean): boolean {
+ const caseSensitive = false;
+ const wrapScan = false;
+
+ // NOTE: aWholeWord dows not implemented, and aSearchInFrames does not work
+ // because of same origin policy
+ return window.find(keyword, caseSensitive, backwards, wrapScan);
+ }
+
+ clearSelection(): void {
+ const sel = window.getSelection();
+ if (sel) {
+ sel.removeAllRanges();
+ }
+ }
+}
diff --git a/src/content/usecases/FindUseCase.ts b/src/content/usecases/FindUseCase.ts
new file mode 100644
index 0000000..9e77124
--- /dev/null
+++ b/src/content/usecases/FindUseCase.ts
@@ -0,0 +1,22 @@
+import { inject, injectable } from "tsyringe";
+import FindPresenter from "../presenters/FindPresenter";
+
+@injectable()
+export default class FindUseCase {
+ constructor(
+ @inject("FindPresenter")
+ private readonly findPresenter: FindPresenter
+ ) {}
+
+ findNext(keyword: string): boolean {
+ return this.findPresenter.find(keyword, false);
+ }
+
+ findPrev(keyword: string): boolean {
+ return this.findPresenter.find(keyword, true);
+ }
+
+ clearSelection() {
+ this.findPresenter.clearSelection();
+ }
+}
diff --git a/src/shared/messages.ts b/src/shared/messages.ts
index 2ad3b48..8b9e598 100644
--- a/src/shared/messages.ts
+++ b/src/shared/messages.ts
@@ -35,6 +35,10 @@ export const MARK_JUMP_GLOBAL = "mark.jump.global";
export const TAB_SCROLL_TO = "tab.scroll.to";
+export const FIND_NEXT = "find.next";
+export const FIND_PREV = "find.prev";
+export const FIND_CLEAR_SELECTION = "find.clear.selection";
+
export const ADDON_ENABLED_QUERY = "addon.enabled.query";
export const ADDON_ENABLED_RESPONSE = "addon.enabled.response";
export const ADDON_TOGGLE_ENABLED = "addon.toggle.enabled";
@@ -68,7 +72,7 @@ export interface ConsoleEnterCommandMessage {
export interface ConsoleEnterFindMessage {
type: typeof CONSOLE_ENTER_FIND;
- text?: string;
+ keyword?: string;
}
export interface ConsoleShowCommandMessage {
@@ -222,6 +226,20 @@ export interface TabScrollToMessage {
y: number;
}
+export interface FindNextMessage {
+ type: typeof FIND_NEXT;
+ keyword: string;
+}
+
+export interface FindPrevMessage {
+ type: typeof FIND_PREV;
+ keyword: string;
+}
+
+export interface FindClearSelection {
+ type: typeof FIND_CLEAR_SELECTION;
+}
+
export interface AddonEnabledQueryMessage {
type: typeof ADDON_ENABLED_QUERY;
}
@@ -299,6 +317,9 @@ export type Message =
| MarkSetGlobalMessage
| MarkJumpGlobalMessage
| TabScrollToMessage
+ | FindNextMessage
+ | FindPrevMessage
+ | FindClearSelection
| AddonEnabledQueryMessage
| AddonEnabledResponseMessage
| AddonToggleEnabledMessage
@@ -333,6 +354,9 @@ export const valueOf = (o: any): Message => {
case MARK_SET_GLOBAL:
case MARK_JUMP_GLOBAL:
case TAB_SCROLL_TO:
+ case FIND_NEXT:
+ case FIND_PREV:
+ case FIND_CLEAR_SELECTION:
case ADDON_ENABLED_QUERY:
case ADDON_ENABLED_RESPONSE:
case ADDON_TOGGLE_ENABLED:
diff --git a/test/background/mock/MockFindClient.ts b/test/background/mock/MockFindClient.ts
new file mode 100644
index 0000000..bd25a27
--- /dev/null
+++ b/test/background/mock/MockFindClient.ts
@@ -0,0 +1,24 @@
+import FindClient, {
+ FindResult,
+} from "../../../src/background/clients/FindClient";
+
+export default class MockFindClient implements FindClient {
+ highlightAll(): Promise<void> {
+ throw new Error("not implemented");
+ }
+
+ removeHighlights(): Promise<void> {
+ throw new Error("not implemented");
+ }
+
+ selectKeyword(
+ _tabId: number,
+ _rangeData: browser.find.RangeData
+ ): Promise<void> {
+ throw new Error("not implemented");
+ }
+
+ startFind(_keyword: string): Promise<FindResult> {
+ throw new Error("not implemented");
+ }
+}
diff --git a/test/background/mock/MockFindRepository.ts b/test/background/mock/MockFindRepository.ts
new file mode 100644
index 0000000..af552c8
--- /dev/null
+++ b/test/background/mock/MockFindRepository.ts
@@ -0,0 +1,26 @@
+import FindRepository, {
+ FindState,
+} from "../../../src/background/repositories/FindRepository";
+
+export default class MockFindRepository implements FindRepository {
+ private globalKeyword: string | undefined;
+ private localStates: { [tabId: number]: FindState } = {};
+
+ getGlobalKeyword(): Promise<string | undefined> {
+ return Promise.resolve(this.globalKeyword);
+ }
+
+ setGlobalKeyword(keyword: string): Promise<void> {
+ this.globalKeyword = keyword;
+ return Promise.resolve();
+ }
+
+ getLocalState(tabId: number): Promise<FindState | undefined> {
+ return Promise.resolve(this.localStates[tabId]);
+ }
+
+ setLocalState(tabId: number, state: FindState): Promise<void> {
+ this.localStates[tabId] = state;
+ return Promise.resolve();
+ }
+}
diff --git a/test/background/operators/impls/FindNextOperator.test.ts b/test/background/operators/impls/FindNextOperator.test.ts
new file mode 100644
index 0000000..20208ae
--- /dev/null
+++ b/test/background/operators/impls/FindNextOperator.test.ts
@@ -0,0 +1,90 @@
+import sinon from "sinon";
+import MockTabPresenter from "../../mock/MockTabPresenter";
+import FindNextOperator from "../../../../src/background/operators/impls/FindNextOperator";
+import { FindState } from "../../../../src/background/repositories/FindRepository";
+import MockFindRepository from "../../mock/MockFindRepository";
+import MockFindClient from "../../mock/MockFindClient";
+
+describe("FindNextOperator", () => {
+ describe("#run", () => {
+ it("throws an error on no previous keywords", async () => {
+ const tabPresenter = new MockTabPresenter();
+ const findRepository = new MockFindRepository();
+ const findClient = new MockFindClient();
+ await tabPresenter.create("https://example.com/");
+
+ const sut = new FindNextOperator(
+ tabPresenter,
+ findRepository,
+ findClient
+ );
+ try {
+ await sut.run();
+ } catch (e) {
+ return;
+ }
+ throw new Error("unexpected reach");
+ });
+
+ it("select a next next", async () => {
+ const tabPresenter = new MockTabPresenter();
+ const findRepository = new MockFindRepository();
+ const findClient = new MockFindClient();
+ const currentTab = await tabPresenter.create("https://example.com/");
+
+ const state: FindState = {
+ keyword: "Hello, world",
+ rangeData: [
+ {
+ framePos: 0,
+ startOffset: 0,
+ endOffset: 10,
+ startTextNodePos: 0,
+ endTextNodePos: 0,
+ text: "Hello, world",
+ },
+ {
+ framePos: 1,
+ startOffset: 0,
+ endOffset: 10,
+ startTextNodePos: 1,
+ endTextNodePos: 1,
+ text: "Hello, world",
+ },
+ {
+ framePos: 2,
+ startOffset: 2,
+ endOffset: 10,
+ startTextNodePos: 1,
+ endTextNodePos: 1,
+ text: "Hello, world",
+ },
+ ],
+ highlightPosition: 0,
+ };
+
+ await findRepository.setLocalState(currentTab.id!, state);
+ const mock = sinon.mock(findClient);
+ mock
+ .expects("selectKeyword")
+ .withArgs(currentTab?.id, state.rangeData[1]);
+ mock
+ .expects("selectKeyword")
+ .withArgs(currentTab?.id, state.rangeData[2]);
+ mock
+ .expects("selectKeyword")
+ .withArgs(currentTab?.id, state.rangeData[0]);
+ const sut = new FindNextOperator(
+ tabPresenter,
+ findRepository,
+ findClient
+ );
+
+ await sut.run();
+ await sut.run();
+ await sut.run();
+
+ mock.verify();
+ });
+ });
+});
diff --git a/test/background/operators/impls/FindOperatorFactoryChain.ts b/test/background/operators/impls/FindOperatorFactoryChain.ts
new file mode 100644
index 0000000..0fd234f
--- /dev/null
+++ b/test/background/operators/impls/FindOperatorFactoryChain.ts
@@ -0,0 +1,23 @@
+import "reflect-metadata";
+import { expect } from "chai";
+import TabOperatorFactoryChain from "../../../../src/background/operators/impls/TabOperatorFactoryChain";
+import MockTabPresenter from "../../mock/MockTabPresenter";
+import * as operations from "../../../../src/shared/operations";
+import FindNextOperator from "../../../../src/background/operators/impls/FindNextOperator";
+import FindPrevOperator from "../../../../src/background/operators/impls/FindPrevOperator";
+
+describe("FindOperatorFactoryChain", () => {
+ describe("#create", () => {
+ it("returns a operator for the operation", async () => {
+ const tabPresenter = new MockTabPresenter();
+ const sut = new TabOperatorFactoryChain(tabPresenter);
+
+ expect(sut.create({ type: operations.FIND_NEXT })).to.be.instanceOf(
+ FindNextOperator
+ );
+ expect(sut.create({ type: operations.FIND_PREV })).to.be.instanceOf(
+ FindPrevOperator
+ );
+ });
+ });
+});
diff --git a/test/background/operators/impls/FindPrevOperator.test.ts b/test/background/operators/impls/FindPrevOperator.test.ts
new file mode 100644
index 0000000..409c26d
--- /dev/null
+++ b/test/background/operators/impls/FindPrevOperator.test.ts
@@ -0,0 +1,90 @@
+import sinon from "sinon";
+import MockTabPresenter from "../../mock/MockTabPresenter";
+import FindNextOperator from "../../../../src/background/operators/impls/FindNextOperator";
+import { FindState } from "../../../../src/background/repositories/FindRepository";
+import MockFindRepository from "../../mock/MockFindRepository";
+import MockFindClient from "../../mock/MockFindClient";
+
+describe("FindPrevOperator", () => {
+ describe("#run", () => {
+ it("throws an error on no previous keywords", async () => {
+ const tabPresenter = new MockTabPresenter();
+ const findRepository = new MockFindRepository();
+ const findClient = new MockFindClient();
+ await tabPresenter.create("https://example.com/");
+
+ const sut = new FindNextOperator(
+ tabPresenter,
+ findRepository,
+ findClient
+ );
+ try {
+ await sut.run();
+ } catch (e) {
+ return;
+ }
+ throw new Error("unexpected reach");
+ });
+
+ it("select a next next", async () => {
+ const tabPresenter = new MockTabPresenter();
+ const findRepository = new MockFindRepository();
+ const findClient = new MockFindClient();
+ const currentTab = await tabPresenter.create("https://example.com/");
+
+ const state: FindState = {
+ keyword: "Hello, world",
+ rangeData: [
+ {
+ framePos: 0,
+ startOffset: 0,
+ endOffset: 10,
+ startTextNodePos: 0,
+ endTextNodePos: 0,
+ text: "Hello, world",
+ },
+ {
+ framePos: 1,
+ startOffset: 0,
+ endOffset: 10,
+ startTextNodePos: 1,
+ endTextNodePos: 1,
+ text: "Hello, world",
+ },
+ {
+ framePos: 2,
+ startOffset: 2,
+ endOffset: 10,
+ startTextNodePos: 1,
+ endTextNodePos: 1,
+ text: "Hello, world",
+ },
+ ],
+ highlightPosition: 1,
+ };
+
+ await findRepository.setLocalState(currentTab.id!, state);
+ const mock = sinon.mock(findClient);
+ mock
+ .expects("selectKeyword")
+ .withArgs(currentTab?.id, state.rangeData[0]);
+ mock
+ .expects("selectKeyword")
+ .withArgs(currentTab?.id, state.rangeData[2]);
+ mock
+ .expects("selectKeyword")
+ .withArgs(currentTab?.id, state.rangeData[1]);
+ const sut = new FindNextOperator(
+ tabPresenter,
+ findRepository,
+ findClient
+ );
+
+ await sut.run();
+ await sut.run();
+ await sut.run();
+
+ mock.verify();
+ });
+ });
+});
diff --git a/test/background/repositories/FindRepository.test.ts b/test/background/repositories/FindRepository.test.ts
new file mode 100644
index 0000000..ecb0fed
--- /dev/null
+++ b/test/background/repositories/FindRepository.test.ts
@@ -0,0 +1,37 @@
+import { expect } from "chai";
+import { FindRepositoryImpl } from "../../../src/background/repositories/FindRepository";
+
+describe("background/repositories/FindRepositoryImpl", () => {
+ let sut: FindRepositoryImpl;
+
+ beforeEach(() => {
+ sut = new FindRepositoryImpl();
+ });
+
+ describe("global keyword", () => {
+ it("get and set a keyword", async () => {
+ expect(await sut.getGlobalKeyword()).to.be.undefined;
+
+ await sut.setGlobalKeyword("Hello, world");
+
+ const keyword = await sut.getGlobalKeyword();
+ expect(keyword).to.equal("Hello, world");
+ });
+ });
+
+ describe("local state", () => {
+ it("get and set a keyword", async () => {
+ expect(await sut.getLocalState(10)).to.be.undefined;
+
+ await sut.setLocalState(10, {
+ keyword: "Hello, world",
+ frameId: 20,
+ });
+
+ const state = await sut.getLocalState(10);
+ expect(state?.keyword).to.equal("Hello, world");
+
+ expect(await sut.getLocalState(20)).to.be.undefined;
+ });
+ });
+});
diff --git a/test/background/usecases/FindUseCase.test.ts b/test/background/usecases/FindUseCase.test.ts
new file mode 100644
index 0000000..eef211b
--- /dev/null
+++ b/test/background/usecases/FindUseCase.test.ts
@@ -0,0 +1,121 @@
+import "reflect-metadata";
+import sinon from "sinon";
+import FindClient from "../../../src/background/clients/FindClient";
+import StartFindUseCase from "../../../src/background/usecases/StartFindUseCase";
+import FindRepository from "../../../src/background/repositories/FindRepository";
+import { expect } from "chai";
+import MockFindClient from "../mock/MockFindClient";
+import MockFindRepository from "../mock/MockFindRepository";
+
+describe("FindUseCase", () => {
+ let findClient: FindClient;
+ let findRepository: FindRepository;
+ let sut: StartFindUseCase;
+
+ const rangeData = (count: number): browser.find.RangeData[] => {
+ const data = {
+ text: "Hello, world",
+ framePos: 0,
+ startTextNodePos: 0,
+ endTextNodePos: 0,
+ startOffset: 0,
+ endOffset: 0,
+ };
+ return Array(count).fill(data);
+ };
+
+ beforeEach(() => {
+ findClient = new MockFindClient();
+ findRepository = new MockFindRepository();
+ sut = new StartFindUseCase(findClient, findRepository);
+ });
+
+ describe("startFind", function () {
+ context("with a search keyword", () => {
+ it("starts find and store last used keyword", async () => {
+ const startFind = sinon
+ .stub(findClient, "startFind")
+ .returns(Promise.resolve({ count: 10, rangeData: rangeData(10) }));
+ const highlightAll = sinon
+ .mock(findClient)
+ .expects("highlightAll")
+ .once();
+ const selectKeyword = sinon
+ .mock(findClient)
+ .expects("selectKeyword")
+ .once();
+
+ await sut.startFind(10, "Hello, world");
+
+ expect(startFind.calledWith("Hello, world")).to.be.true;
+ expect(await findRepository.getGlobalKeyword()).to.equals(
+ "Hello, world"
+ );
+ expect((await findRepository.getLocalState(10))?.keyword).to.equal(
+ "Hello, world"
+ );
+ highlightAll.verify();
+ selectKeyword.verify();
+ });
+
+ it("throws an error if no matched", (done) => {
+ sinon
+ .stub(findClient, "startFind")
+ .returns(Promise.resolve({ count: 0, rangeData: [] }));
+
+ sut.startFind(10, "Hello, world").catch((e) => {
+ expect(e).instanceof(Error);
+ done();
+ });
+ });
+ });
+
+ context("without a search keyword", () => {
+ it("starts find with last used keyword in the tab", async () => {
+ const startFind = sinon
+ .stub(findClient, "startFind")
+ .returns(Promise.resolve({ count: 10, rangeData: rangeData(10) }));
+ await findRepository.setLocalState(10, {
+ keyword: "Hello, world",
+ rangeData: rangeData(10),
+ highlightPosition: 0,
+ });
+ const highlightAll = sinon
+ .mock(findClient)
+ .expects("highlightAll")
+ .once();
+ const selectKeyword = sinon
+ .mock(findClient)
+ .expects("selectKeyword")
+ .once();
+
+ await sut.startFind(10, undefined);
+
+ expect(startFind.calledWith("Hello, world")).to.be.true;
+ highlightAll.verify();
+ selectKeyword.verify();
+ });
+
+ it("starts find with last used keyword in global", async () => {
+ const startFind = sinon
+ .stub(findClient, "startFind")
+ .returns(Promise.resolve({ count: 10, rangeData: rangeData(10) }));
+ await findRepository.setGlobalKeyword("Hello, world");
+ const highlightAll = sinon
+ .mock(findClient)
+ .expects("highlightAll")
+ .once();
+ const selectKeyword = sinon
+ .mock(findClient)
+ .expects("selectKeyword")
+ .once();
+
+ await sut.startFind(10, undefined);
+
+ expect(startFind.calledWith("Hello, world")).to.be.true;
+ highlightAll.verify();
+ selectKeyword.verify();
+ });
+ });
+ });
+});