diff options
author | Shin'ya Ueoka <ueokande@i-beam.org> | 2021-04-12 13:09:09 +0000 |
---|---|---|
committer | GitHub <noreply@github.com> | 2021-04-12 13:09:09 +0000 |
commit | d80d0f87b82ba4bd74ed9b2bb7354421a28a11b3 (patch) | |
tree | 691185ad88418d0f44c236d0913cf5c425b29b23 /src | |
parent | ea73c900f66107fd4a5b2f3b05080bcf643c94ea (diff) | |
parent | 8a5bba1da639355a25da8c279a9f1cf0a7300a9f (diff) |
Merge pull request #1098 from ueokande/replace-redux-with-react-hooks
Refactor state management with React Hooks on Console
Diffstat (limited to 'src')
30 files changed, 1238 insertions, 965 deletions
diff --git a/src/console/actions/console.ts b/src/console/actions/console.ts deleted file mode 100644 index 16d33b3..0000000 --- a/src/console/actions/console.ts +++ /dev/null @@ -1,302 +0,0 @@ -import * as messages from "../../shared/messages"; -import * as actions from "./index"; -import { Command } from "../../shared/Command"; -import CompletionClient from "../clients/CompletionClient"; -import SettingClient from "../clients/SettingClient"; -import CompletionType from "../../shared/CompletionType"; -import Completions from "../Completions"; -import TabFlag from "../../shared/TabFlag"; - -const completionClient = new CompletionClient(); -const settingClient = new SettingClient(); - -const commandDocs = { - [Command.Set]: "Set a value of the property", - [Command.Open]: "Open a URL or search by keywords in current tab", - [Command.TabOpen]: "Open a URL or search by keywords in new tab", - [Command.WindowOpen]: "Open a URL or search by keywords in new window", - [Command.Buffer]: "Select tabs by matched keywords", - [Command.BufferDelete]: "Close a certain tab matched by keywords", - [Command.BuffersDelete]: "Close all tabs matched by keywords", - [Command.Quit]: "Close the current tab", - [Command.QuitAll]: "Close all tabs", - [Command.AddBookmark]: "Add current page to bookmarks", - [Command.Help]: "Open Vim Vixen help in new tab", -}; - -const propertyDocs: { [key: string]: string } = { - hintchars: "hint characters on follow mode", - smoothscroll: "smooth scroll", - complete: "which are completed at the open page", - colorscheme: "color scheme of the console", -}; - -const hide = (): actions.ConsoleAction => { - return { - type: actions.CONSOLE_HIDE, - }; -}; - -const showCommand = async (text: string): Promise<actions.ShowCommand> => { - const completionTypes = await completionClient.getCompletionTypes(); - return { - type: actions.CONSOLE_SHOW_COMMAND, - completionTypes, - text, - }; -}; - -const showFind = (): actions.ShowFindAction => { - return { - type: actions.CONSOLE_SHOW_FIND, - }; -}; - -const showError = (text: string): actions.ShowErrorAction => { - return { - type: actions.CONSOLE_SHOW_ERROR, - text: text, - }; -}; - -const showInfo = (text: string): actions.ShowInfoAction => { - return { - type: actions.CONSOLE_SHOW_INFO, - text: text, - }; -}; - -const hideCommand = (): actions.HideCommandAction => { - window.top.postMessage( - JSON.stringify({ - type: messages.CONSOLE_UNFOCUS, - }), - "*" - ); - return { - type: actions.CONSOLE_HIDE_COMMAND, - }; -}; - -const enterCommand = async ( - text: string -): Promise<actions.HideCommandAction> => { - await browser.runtime.sendMessage({ - type: messages.CONSOLE_ENTER_COMMAND, - text, - }); - return hideCommand(); -}; - -const enterFind = (text?: string): actions.HideCommandAction => { - window.top.postMessage( - JSON.stringify({ - type: messages.CONSOLE_ENTER_FIND, - text, - }), - "*" - ); - return hideCommand(); -}; - -const setConsoleText = (consoleText: string): actions.SetConsoleTextAction => { - return { - type: actions.CONSOLE_SET_CONSOLE_TEXT, - consoleText, - }; -}; - -const getCommandCompletions = (text: string): actions.SetCompletionsAction => { - const items = Object.entries(commandDocs) - .filter(([name]) => name.startsWith(text.trimLeft())) - .map(([name, doc]) => ({ - caption: name, - content: name, - url: doc, - })); - const completions = [ - { - name: "Console Command", - items, - }, - ]; - return { - type: actions.CONSOLE_SET_COMPLETIONS, - completions, - completionSource: text, - }; -}; - -const getOpenCompletions = async ( - types: CompletionType[], - original: string, - command: Command, - query: string -): Promise<actions.SetCompletionsAction> => { - const completions: Completions = []; - for (const type of types) { - switch (type) { - case CompletionType.SearchEngines: { - const items = await completionClient.requestSearchEngines(query); - if (items.length === 0) { - break; - } - completions.push({ - name: "Search Engines", - items: items.map((key) => ({ - caption: key.title, - content: command + " " + key.title, - })), - }); - break; - } - case CompletionType.History: { - const items = await completionClient.requestHistory(query); - if (items.length === 0) { - break; - } - completions.push({ - name: "History", - items: items.map((item) => ({ - caption: item.title, - content: command + " " + item.url, - url: item.url, - })), - }); - break; - } - case CompletionType.Bookmarks: { - const items = await completionClient.requestBookmarks(query); - if (items.length === 0) { - break; - } - completions.push({ - name: "Bookmarks", - items: items.map((item) => ({ - caption: item.title, - content: command + " " + item.url, - url: item.url, - })), - }); - break; - } - } - } - - return { - type: actions.CONSOLE_SET_COMPLETIONS, - completions, - completionSource: original, - }; -}; - -const getTabCompletions = async ( - original: string, - command: Command, - query: string, - excludePinned: boolean -): Promise<actions.SetCompletionsAction> => { - const items = await completionClient.requestTabs(query, excludePinned); - let completions: Completions = []; - if (items.length > 0) { - completions = [ - { - name: "Buffers", - items: items.map((item) => ({ - content: command + " " + item.url, - caption: `${item.index}: ${ - item.flag != TabFlag.None ? item.flag : " " - } ${item.title}`, - url: item.url, - icon: item.faviconUrl, - })), - }, - ]; - } - return { - type: actions.CONSOLE_SET_COMPLETIONS, - completions, - completionSource: original, - }; -}; - -const getPropertyCompletions = async ( - original: string, - command: Command, - query: string -): Promise<actions.SetCompletionsAction> => { - const properties = await completionClient.getProperties(); - const items = properties - .map((item) => { - const desc = propertyDocs[item.name] || ""; - if (item.type === "boolean") { - return [ - { - caption: item.name, - content: command + " " + item.name, - url: "Enable " + desc, - }, - { - caption: "no" + item.name, - content: command + " no" + item.name, - url: "Disable " + desc, - }, - ]; - } else { - return [ - { - caption: item.name, - content: command + " " + item.name, - url: "Set " + desc, - }, - ]; - } - }) - .reduce((acc, val) => acc.concat(val), []) - .filter((item) => item.caption.startsWith(query)); - const completions: Completions = [{ name: "Properties", items }]; - return { - type: actions.CONSOLE_SET_COMPLETIONS, - completions, - completionSource: original, - }; -}; - -const completionNext = (): actions.CompletionNextAction => { - return { - type: actions.CONSOLE_COMPLETION_NEXT, - }; -}; - -const completionPrev = (): actions.CompletionPrevAction => { - return { - type: actions.CONSOLE_COMPLETION_PREV, - }; -}; - -const setColorScheme = async (): Promise<actions.SetColorSchemeAction> => { - const scheme = await settingClient.getColorScheme(); - return { - type: actions.CONSOLE_SET_COLORSCHEME, - colorscheme: scheme, - }; -}; - -export { - hide, - showCommand, - showFind, - showError, - showInfo, - hideCommand, - setConsoleText, - enterCommand, - enterFind, - getCommandCompletions, - getOpenCompletions, - getTabCompletions, - getPropertyCompletions, - completionNext, - completionPrev, - setColorScheme, -}; diff --git a/src/console/actions/index.ts b/src/console/actions/index.ts deleted file mode 100644 index 6c1c759..0000000 --- a/src/console/actions/index.ts +++ /dev/null @@ -1,80 +0,0 @@ -import Completions from "../Completions"; -import CompletionType from "../../shared/CompletionType"; -import ColorScheme from "../../shared/ColorScheme"; - -export const CONSOLE_HIDE = "console.hide"; -export const CONSOLE_SHOW_COMMAND = "console.show.command"; -export const CONSOLE_SHOW_ERROR = "console.show.error"; -export const CONSOLE_SHOW_INFO = "console.show.info"; -export const CONSOLE_HIDE_COMMAND = "console.hide.command"; -export const CONSOLE_SET_CONSOLE_TEXT = "console.set.command"; -export const CONSOLE_SET_COMPLETIONS = "console.set.completions"; -export const CONSOLE_COMPLETION_NEXT = "console.completion.next"; -export const CONSOLE_COMPLETION_PREV = "console.completion.prev"; -export const CONSOLE_SHOW_FIND = "console.show.find"; -export const CONSOLE_SET_COLORSCHEME = "console.set.colorscheme"; - -export interface HideAction { - type: typeof CONSOLE_HIDE; -} - -export interface ShowCommand { - type: typeof CONSOLE_SHOW_COMMAND; - text: string; - completionTypes: CompletionType[]; -} - -export interface ShowFindAction { - type: typeof CONSOLE_SHOW_FIND; -} - -export interface ShowErrorAction { - type: typeof CONSOLE_SHOW_ERROR; - text: string; -} - -export interface ShowInfoAction { - type: typeof CONSOLE_SHOW_INFO; - text: string; -} - -export interface HideCommandAction { - type: typeof CONSOLE_HIDE_COMMAND; -} - -export interface SetConsoleTextAction { - type: typeof CONSOLE_SET_CONSOLE_TEXT; - consoleText: string; -} - -export interface SetCompletionsAction { - type: typeof CONSOLE_SET_COMPLETIONS; - completions: Completions; - completionSource: string; -} - -export interface CompletionNextAction { - type: typeof CONSOLE_COMPLETION_NEXT; -} - -export interface CompletionPrevAction { - type: typeof CONSOLE_COMPLETION_PREV; -} - -export interface SetColorSchemeAction { - type: typeof CONSOLE_SET_COLORSCHEME; - colorscheme: ColorScheme; -} - -export type ConsoleAction = - | HideAction - | ShowCommand - | ShowFindAction - | ShowErrorAction - | ShowInfoAction - | HideCommandAction - | SetConsoleTextAction - | SetCompletionsAction - | CompletionNextAction - | CompletionPrevAction - | SetColorSchemeAction; diff --git a/src/console/app/actions.ts b/src/console/app/actions.ts new file mode 100644 index 0000000..5538ae5 --- /dev/null +++ b/src/console/app/actions.ts @@ -0,0 +1,82 @@ +export const SHOW_COMMAND = "show.command"; +export const SHOW_ERROR = "show.error"; +export const SHOW_INFO = "show.info"; +export const HIDE_COMMAND = "hide.command"; +export const SHOW_FIND = "show.find"; +export const HIDE = "hide"; + +export interface HideAction { + type: typeof HIDE; +} + +export interface ShowCommand { + type: typeof SHOW_COMMAND; + text: string; +} + +export interface ShowFindAction { + type: typeof SHOW_FIND; +} + +export interface ShowErrorAction { + type: typeof SHOW_ERROR; + text: string; +} + +export interface ShowInfoAction { + type: typeof SHOW_INFO; + text: string; +} + +export interface HideCommandAction { + type: typeof HIDE_COMMAND; +} + +export type AppAction = + | HideAction + | ShowCommand + | ShowFindAction + | ShowErrorAction + | ShowInfoAction + | HideCommandAction; + +const hide = (): HideAction => { + return { + type: HIDE, + }; +}; + +const showCommand = (text: string): ShowCommand => { + return { + type: SHOW_COMMAND, + text, + }; +}; + +const showFind = (): ShowFindAction => { + return { + type: SHOW_FIND, + }; +}; + +const showError = (text: string): ShowErrorAction => { + return { + type: SHOW_ERROR, + text: text, + }; +}; + +const showInfo = (text: string): ShowInfoAction => { + return { + type: SHOW_INFO, + text: text, + }; +}; + +const hideCommand = (): HideCommandAction => { + return { + type: HIDE_COMMAND, + }; +}; + +export { hide, showCommand, showFind, showError, showInfo, hideCommand }; diff --git a/src/console/app/contexts.ts b/src/console/app/contexts.ts new file mode 100644 index 0000000..7e4f323 --- /dev/null +++ b/src/console/app/contexts.ts @@ -0,0 +1,9 @@ +import React from "react"; +import { State, defaultState } from "./recuer"; +import { AppAction } from "./actions"; + +export const AppStateContext = React.createContext<State>(defaultState); + +export const AppDispatchContext = React.createContext< + (action: AppAction) => void +>(() => {}); diff --git a/src/console/app/hooks.ts b/src/console/app/hooks.ts new file mode 100644 index 0000000..eefdea3 --- /dev/null +++ b/src/console/app/hooks.ts @@ -0,0 +1,115 @@ +import React from "react"; +import * as actions from "./actions"; +import { AppDispatchContext, AppStateContext } from "./contexts"; +import * as messages from "../../shared/messages"; + +export const useHide = () => { + const dispatch = React.useContext(AppDispatchContext); + const hide = React.useCallback(() => { + window.top.postMessage( + JSON.stringify({ + type: messages.CONSOLE_UNFOCUS, + }), + "*" + ); + dispatch(actions.hide()); + }, [dispatch]); + + return hide; +}; + +export const useCommandMode = () => { + const state = React.useContext(AppStateContext); + const dispatch = React.useContext(AppDispatchContext); + + const show = React.useCallback( + (initialInputValue: string) => { + dispatch(actions.showCommand(initialInputValue)); + }, + [dispatch] + ); + + return { + visible: state.mode === "command", + initialInputValue: state.consoleText, + show, + }; +}; + +export const useFindMode = () => { + const state = React.useContext(AppStateContext); + const dispatch = React.useContext(AppDispatchContext); + + const show = React.useCallback(() => { + dispatch(actions.showFind()); + }, [dispatch]); + + return { + visible: state.mode === "find", + show, + }; +}; + +export const useInfoMessage = () => { + const state = React.useContext(AppStateContext); + const dispatch = React.useContext(AppDispatchContext); + + const show = React.useCallback( + (message: string) => { + dispatch(actions.showInfo(message)); + }, + [dispatch] + ); + + return { + visible: state.mode === "info", + message: state.mode === "info" ? state.messageText : "", + show, + }; +}; + +export const useErrorMessage = () => { + const state = React.useContext(AppStateContext); + const dispatch = React.useContext(AppDispatchContext); + + const show = React.useCallback( + (message: string) => { + dispatch(actions.showError(message)); + }, + [dispatch] + ); + + return { + visible: state.mode === "error", + message: state.mode === "error" ? state.messageText : "", + show, + }; +}; + +export const getInitialInputValue = () => { + const state = React.useContext(AppStateContext); + return state.consoleText; +}; + +export const useExecCommand = () => { + const execCommand = React.useCallback((text: string) => { + browser.runtime.sendMessage({ + type: messages.CONSOLE_ENTER_COMMAND, + text, + }); + }, []); + return execCommand; +}; + +export const useExecFind = () => { + const execFind = React.useCallback((text?: string) => { + window.top.postMessage( + JSON.stringify({ + type: messages.CONSOLE_ENTER_FIND, + text, + }), + "*" + ); + }, []); + return execFind; +}; diff --git a/src/console/app/provider.tsx b/src/console/app/provider.tsx new file mode 100644 index 0000000..397f165 --- /dev/null +++ b/src/console/app/provider.tsx @@ -0,0 +1,14 @@ +import React from "react"; +import reducer, { defaultState } from "./recuer"; +import { AppDispatchContext, AppStateContext } from "./contexts"; + +export const AppProvider: React.FC = ({ children }) => { + const [state, dispatch] = React.useReducer(reducer, defaultState); + return ( + <AppStateContext.Provider value={state}> + <AppDispatchContext.Provider value={dispatch}> + {children} + </AppDispatchContext.Provider> + </AppStateContext.Provider> + ); +}; diff --git a/src/console/app/recuer.ts b/src/console/app/recuer.ts new file mode 100644 index 0000000..e3043ee --- /dev/null +++ b/src/console/app/recuer.ts @@ -0,0 +1,52 @@ +import { + HIDE, + HIDE_COMMAND, + SHOW_COMMAND, + SHOW_ERROR, + SHOW_FIND, + SHOW_INFO, + AppAction, +} from "./actions"; + +export interface State { + mode: string; + messageText: string; + consoleText: string; +} + +export const defaultState = { + mode: "", + messageText: "", + consoleText: "", +}; + +// eslint-disable-next-line max-lines-per-function +export default function reducer( + state: State = defaultState, + action: AppAction +): State { + switch (action.type) { + case HIDE: + return { ...state, mode: "" }; + case SHOW_COMMAND: + return { + ...state, + mode: "command", + consoleText: action.text, + }; + case SHOW_FIND: + return { ...state, mode: "find", consoleText: "" }; + case SHOW_ERROR: + return { ...state, mode: "error", messageText: action.text }; + case SHOW_INFO: + return { ...state, mode: "info", messageText: action.text }; + case HIDE_COMMAND: + return { + ...state, + mode: + state.mode === "command" || state.mode === "find" ? "" : state.mode, + }; + default: + return state; + } +} diff --git a/src/console/colorscheme/contexts.tsx b/src/console/colorscheme/contexts.tsx new file mode 100644 index 0000000..e94454b --- /dev/null +++ b/src/console/colorscheme/contexts.tsx @@ -0,0 +1,10 @@ +import React from "react"; +import ColorScheme from "../../shared/ColorScheme"; + +export const ColorSchemeContext = React.createContext<ColorScheme>( + ColorScheme.System +); + +export const ColorSchemeUpdateContext = React.createContext< + (colorscheme: ColorScheme) => void +>(() => {}); diff --git a/src/console/colorscheme/hooks.ts b/src/console/colorscheme/hooks.ts new file mode 100644 index 0000000..c9de754 --- /dev/null +++ b/src/console/colorscheme/hooks.ts @@ -0,0 +1,15 @@ +import React from "react"; +import { ColorSchemeUpdateContext } from "./contexts"; +import SettingClient from "../clients/SettingClient"; + +export const useColorSchemeRefresh = () => { + const update = React.useContext(ColorSchemeUpdateContext); + const settingClient = new SettingClient(); + const refresh = React.useCallback(() => { + settingClient.getColorScheme().then((newScheme) => { + update(newScheme); + }); + }, []); + + return refresh; +}; diff --git a/src/console/colorscheme/providers.tsx b/src/console/colorscheme/providers.tsx new file mode 100644 index 0000000..810c8e0 --- /dev/null +++ b/src/console/colorscheme/providers.tsx @@ -0,0 +1,31 @@ +import React from "react"; +import ColorScheme from "../../shared/ColorScheme"; +import { DarkTheme, LightTheme } from "./theme"; +import { ColorSchemeContext, ColorSchemeUpdateContext } from "./contexts"; +import { ThemeProvider } from "styled-components"; + +export const ColorSchemeProvider: React.FC = ({ children }) => { + const [colorscheme, setColorScheme] = React.useState(ColorScheme.System); + const theme = React.useMemo(() => { + if (colorscheme === ColorScheme.System) { + if ( + window.matchMedia && + window.matchMedia("(prefers-color-scheme: dark)").matches + ) { + return DarkTheme; + } + } else if (colorscheme === ColorScheme.Dark) { + return DarkTheme; + } + return LightTheme; + }, [colorscheme]); + + return ( + <ColorSchemeContext.Provider value={colorscheme}> + <ColorSchemeUpdateContext.Provider value={setColorScheme}> + <ThemeProvider theme={theme}>{children}</ThemeProvider> + </ColorSchemeUpdateContext.Provider> + </ColorSchemeContext.Provider> + ); +}; +export default ColorSchemeProvider; diff --git a/src/console/colorscheme/styled.tsx b/src/console/colorscheme/styled.tsx new file mode 100644 index 0000000..12e10ec --- /dev/null +++ b/src/console/colorscheme/styled.tsx @@ -0,0 +1,6 @@ +import baseStyled, { ThemedStyledInterface } from "styled-components"; +import { ThemeProperties } from "./theme"; + +const styled = baseStyled as ThemedStyledInterface<ThemeProperties>; + +export default styled; diff --git a/src/console/components/Theme.ts b/src/console/colorscheme/theme.ts index dd7baa5..5c17190 100644 --- a/src/console/components/Theme.ts +++ b/src/console/colorscheme/theme.ts @@ -1,6 +1,4 @@ -import baseStyled, { ThemedStyledInterface } from "styled-components"; - -type Theme = { +export type ThemeProperties = { completionTitleBackground: string; completionTitleForeground: string; completionItemBackground: string; @@ -16,7 +14,7 @@ type Theme = { consoleInfoForeground: string; }; -export const LightTheme: Theme = { +export const LightTheme: ThemeProperties = { completionTitleBackground: "lightgray", completionTitleForeground: "#000000", completionItemBackground: "#ffffff", @@ -32,7 +30,7 @@ export const LightTheme: Theme = { consoleInfoForeground: "#018786", }; -export const DarkTheme: Theme = { +export const DarkTheme: ThemeProperties = { completionTitleBackground: "#052027", completionTitleForeground: "white", completionItemBackground: "#2f474f", @@ -47,7 +45,3 @@ export const DarkTheme: Theme = { consoleInfoBackground: "#052027", consoleInfoForeground: "#ffffff", }; - -const styled = baseStyled as ThemedStyledInterface<Theme>; - -export default styled; diff --git a/src/console/completion/actions.ts b/src/console/completion/actions.ts new file mode 100644 index 0000000..59d1a04 --- /dev/null +++ b/src/console/completion/actions.ts @@ -0,0 +1,76 @@ +import CompletionType from "../../shared/CompletionType"; +import Completions from "../Completions"; + +export const INIT_COMPLETIONS = "reset.completions"; +export const SET_COMPLETION_SOURCE = "set.completion.source"; +export const SET_COMPLETIONS = "set.completions"; +export const COMPLETION_NEXT = "completion.next"; +export const COMPLETION_PREV = "completion.prev"; + +export interface InitCompletionAction { + type: typeof INIT_COMPLETIONS; + completionTypes: CompletionType[]; +} + +export interface SetCompletionSourceAction { + type: typeof SET_COMPLETION_SOURCE; + completionSource: string; +} + +export interface SetCompletionsAction { + type: typeof SET_COMPLETIONS; + completions: Completions; +} + +export interface CompletionNextAction { + type: typeof COMPLETION_NEXT; +} + +export interface CompletionPrevAction { + type: typeof COMPLETION_PREV; +} + +export type CompletionAction = + | InitCompletionAction + | SetCompletionSourceAction + | SetCompletionsAction + | CompletionNextAction + | CompletionPrevAction; + +export const initCompletion = ( + completionTypes: CompletionType[] +): InitCompletionAction => { + return { + type: INIT_COMPLETIONS, + completionTypes, + }; +}; +export const setCompletionSource = ( + query: string +): SetCompletionSourceAction => { + return { + type: SET_COMPLETION_SOURCE, + completionSource: query, + }; +}; + +export const setCompletions = ( + completions: Completions +): SetCompletionsAction => { + return { + type: SET_COMPLETIONS, + completions, + }; +}; + +export const selectNext = (): CompletionNextAction => { + return { + type: COMPLETION_NEXT, + }; +}; + +export const selectPrev = (): CompletionPrevAction => { + return { + type: COMPLETION_PREV, + }; +}; diff --git a/src/console/completion/context.ts b/src/console/completion/context.ts new file mode 100644 index 0000000..2fafcf8 --- /dev/null +++ b/src/console/completion/context.ts @@ -0,0 +1,9 @@ +import React from "react"; +import { State, defaultState } from "./reducer"; +import { CompletionAction } from "./actions"; + +export const CompletionStateContext = React.createContext<State>(defaultState); + +export const CompletionDispatchContext = React.createContext< + (action: CompletionAction) => void +>(() => {}); diff --git a/src/console/completion/hooks.ts b/src/console/completion/hooks.ts new file mode 100644 index 0000000..aac431b --- /dev/null +++ b/src/console/completion/hooks.ts @@ -0,0 +1,277 @@ +import React from "react"; +import * as actions from "./actions"; +import { Command } from "../../shared/Command"; +import TabFlag from "../../shared/TabFlag"; +import { CompletionStateContext, CompletionDispatchContext } from "./context"; +import CompletionClient from "../clients/CompletionClient"; +import CommandLineParser, { + CommandLine, + InputPhase, +} from "../commandline/CommandLineParser"; +import { UnknownCommandError } from "../commandline/CommandParser"; +import Completions from "../Completions"; +import CompletionType from "../../shared/CompletionType"; + +const commandDocs = { + [Command.Set]: "Set a value of the property", + [Command.Open]: "Open a URL or search by keywords in current tab", + [Command.TabOpen]: "Open a URL or search by keywords in new tab", + [Command.WindowOpen]: "Open a URL or search by keywords in new window", + [Command.Buffer]: "Select tabs by matched keywords", + [Command.BufferDelete]: "Close a certain tab matched by keywords", + [Command.BuffersDelete]: "Close all tabs matched by keywords", + [Command.Quit]: "Close the current tab", + [Command.QuitAll]: "Close all tabs", + [Command.AddBookmark]: "Add current page to bookmarks", + [Command.Help]: "Open Vim Vixen help in new tab", +}; + +const propertyDocs: { [key: string]: string } = { + hintchars: "hint characters on follow mode", + smoothscroll: "smooth scroll", + complete: "which are completed at the open page", + colorscheme: "color scheme of the console", +}; + +const completionClient = new CompletionClient(); + +const getCommandCompletions = async (query: string): Promise<Completions> => { + const items = Object.entries(commandDocs) + .filter(([name]) => name.startsWith(query)) + .map(([name, doc]) => ({ + caption: name, + content: name, + url: doc, + })); + return [ + { + name: "Console Command", + items, + }, + ]; +}; + +const getOpenCompletions = async ( + command: string, + query: string, + completionTypes: CompletionType[] +): Promise<Completions> => { + const completions: Completions = []; + for (const type of completionTypes) { + switch (type) { + case CompletionType.SearchEngines: { + const items = await completionClient.requestSearchEngines(query); + if (items.length === 0) { + break; + } + completions.push({ + name: "Search Engines", + items: items.map((key) => ({ + caption: key.title, + content: command + " " + key.title, + })), + }); + break; + } + case CompletionType.History: { + const items = await completionClient.requestHistory(query); + if (items.length === 0) { + break; + } + completions.push({ + name: "History", + items: items.map((item) => ({ + caption: item.title, + content: command + " " + item.url, + url: item.url, + })), + }); + break; + } + case CompletionType.Bookmarks: { + const items = await completionClient.requestBookmarks(query); + if (items.length === 0) { + break; + } + completions.push({ + name: "Bookmarks", + items: items.map((item) => ({ + caption: item.title, + content: command + " " + item.url, + url: item.url, + })), + }); + break; + } + } + } + return completions; +}; + +export const getTabCompletions = async ( + command: string, + query: string, + excludePinned: boolean +): Promise<Completions> => { + const items = await completionClient.requestTabs(query, excludePinned); + if (items.length === 0) { + return []; + } + + return [ + { + name: "Buffers", + items: items.map((item) => ({ + content: command + " " + item.url, + caption: `${item.index}: ${ + item.flag != TabFlag.None ? item.flag : " " + } ${item.title}`, + url: item.url, + icon: item.faviconUrl, + })), + }, + ]; +}; + +export const getPropertyCompletions = async ( + command: string, + query: string +): Promise<Completions> => { + const properties = await completionClient.getProperties(); + const items = properties + .map((item) => { + const desc = propertyDocs[item.name] || ""; + if (item.type === "boolean") { + return [ + { + caption: item.name, + content: command + " " + item.name, + url: "Enable " + desc, + }, + { + caption: "no" + item.name, + content: command + " no" + item.name, + url: "Disable " + desc, + }, + ]; + } else { + return [ + { + caption: item.name, + content: command + " " + item.name, + url: "Set " + desc, + }, + ]; + } + }) + .reduce((acc, val) => acc.concat(val), []) + .filter((item) => item.caption.startsWith(query)); + return [{ name: "Properties", items }]; +}; + +export const useCompletions = () => { + const state = React.useContext(CompletionStateContext); + const dispatch = React.useContext(CompletionDispatchContext); + const commandLineParser = React.useMemo(() => new CommandLineParser(), []); + + const updateCompletions = React.useCallback((source: string) => { + dispatch(actions.setCompletionSource(source)); + }, []); + + const initCompletion = React.useCallback((source: string) => { + completionClient.getCompletionTypes().then((completionTypes) => { + dispatch(actions.initCompletion(completionTypes)); + dispatch(actions.setCompletionSource(source)); + }); + }, []); + + React.useEffect(() => { + const text = state.completionSource; + const phase = commandLineParser.inputPhase(text); + if (phase === InputPhase.OnCommand) { + getCommandCompletions(text).then((completions) => + dispatch(actions.setCompletions(completions)) + ); + } else { + let cmd: CommandLine | null = null; + try { + cmd = commandLineParser.parse(text); + } catch (e) { + if (e instanceof UnknownCommandError) { + return; + } + } + switch (cmd?.command) { + case Command.Open: + case Command.TabOpen: + case Command.WindowOpen: + if (!state.completionTypes) { + initCompletion(text); + return; + } + + getOpenCompletions( + cmd.command, + cmd.args, + state.completionTypes + ).then((completions) => + dispatch(actions.setCompletions(completions)) + ); + break; + case Command.Buffer: + getTabCompletions(cmd.command, cmd.args, false).then((completions) => + dispatch(actions.setCompletions(completions)) + ); + break; + case Command.BufferDelete: + case Command.BuffersDelete: + getTabCompletions(cmd.command, cmd.args, true).then((completions) => + dispatch(actions.setCompletions(completions)) + ); + break; + case Command.BufferDeleteForce: + case Command.BuffersDeleteForce: + getTabCompletions(cmd.command, cmd.args, false).then((completions) => + dispatch(actions.setCompletions(completions)) + ); + break; + case Command.Set: + getPropertyCompletions(cmd.command, cmd.args).then((completions) => + dispatch(actions.setCompletions(completions)) + ); + break; + } + } + }, [state.completionSource, state.completionTypes]); + + return { + completions: state.completions, + updateCompletions, + initCompletion, + }; +}; + +export const useSelectCompletion = () => { + const state = React.useContext(CompletionStateContext); + const dispatch = React.useContext(CompletionDispatchContext); + const next = React.useCallback(() => dispatch(actions.selectNext()), [ + dispatch, + ]); + const prev = React.useCallback(() => dispatch(actions.selectPrev()), [ + dispatch, + ]); + const currentValue = React.useMemo(() => { + if (state.select < 0) { + return state.completionSource; + } + const items = state.completions.map((g) => g.items).flat(); + return items[state.select]?.content || ""; + }, [state.completionSource, state.select]); + + return { + select: state.select, + currentValue, + selectNext: next, + selectPrev: prev, + }; +}; diff --git a/src/console/completion/provider.tsx b/src/console/completion/provider.tsx new file mode 100644 index 0000000..c0de250 --- /dev/null +++ b/src/console/completion/provider.tsx @@ -0,0 +1,25 @@ +import reducer, { defaultState } from "./reducer"; +import React from "react"; +import { CompletionDispatchContext, CompletionStateContext } from "./context"; + +interface Props { + initialInputValue: string; +} + +export const CompletionProvider: React.FC<Props> = ({ + initialInputValue, + children, +}) => { + const initialState = { + ...defaultState, + completionSource: initialInputValue, + }; + const [state, dispatch] = React.useReducer(reducer, initialState); + return ( + <CompletionStateContext.Provider value={state}> + <CompletionDispatchContext.Provider value={dispatch}> + {children} + </CompletionDispatchContext.Provider> + </CompletionStateContext.Provider> + ); +}; diff --git a/src/console/completion/reducer.ts b/src/console/completion/reducer.ts new file mode 100644 index 0000000..905451f --- /dev/null +++ b/src/console/completion/reducer.ts @@ -0,0 +1,96 @@ +import Completions from "../Completions"; +import CompletionType from "../../shared/CompletionType"; +import { + INIT_COMPLETIONS, + SET_COMPLETION_SOURCE, + SET_COMPLETIONS, + COMPLETION_NEXT, + COMPLETION_PREV, + CompletionAction, +} from "./actions"; + +export interface State { + completionTypes?: CompletionType[]; + completionSource: string; + completions: Completions; + select: number; +} + +export const defaultState = { + completionTypes: undefined, + completionSource: "", + completions: [], + select: -1, +}; + +const nextSelection = (state: State): number => { + const length = state.completions + .map((g) => g.items.length) + .reduce((x, y) => x + y, 0); + if (length === 0) { + return -1; + } + if (state.select < 0) { + return 0; + } + if (state.select + 1 < length) { + return state.select + 1; + } + return -1; +}; + +const prevSelection = (state: State): number => { + if (state.completions.length === 0) { + return -1; + } + const length = state.completions + .map((g) => g.items.length) + .reduce((x, y) => x + y); + if (state.select < 0) { + return length - 1; + } + return state.select - 1; +}; + +// eslint-disable-next-line max-lines-per-function +export default function reducer( + state: State = defaultState, + action: CompletionAction +): State { + switch (action.type) { + case INIT_COMPLETIONS: + return { + ...state, + completionTypes: action.completionTypes, + completions: [], + select: -1, + }; + case SET_COMPLETION_SOURCE: + return { + ...state, + completionSource: action.completionSource, + select: -1, + }; + case SET_COMPLETIONS: + return { + ...state, + completions: action.completions, + }; + case COMPLETION_NEXT: { + const select = nextSelection(state); + return { + ...state, + select: select, + }; + } + case COMPLETION_PREV: { + const select = prevSelection(state); + return { + ...state, + select: select, + }; + } + default: + return state; + } +} diff --git a/src/console/components/CommandPrompt.tsx b/src/console/components/CommandPrompt.tsx new file mode 100644 index 0000000..1b6281b --- /dev/null +++ b/src/console/components/CommandPrompt.tsx @@ -0,0 +1,116 @@ +import React from "react"; +import Completion from "./console/Completion"; +import Input from "./console//Input"; +import styled from "styled-components"; +import { useCompletions, useSelectCompletion } from "../completion/hooks"; +import useAutoResize from "../hooks/useAutoResize"; +import { CompletionProvider } from "../completion/provider"; +import { useExecCommand, useHide } from "../app/hooks"; + +const COMPLETION_MAX_ITEMS = 33; + +const ConsoleWrapper = styled.div` + border-top: 1px solid gray; +`; + +interface Props { + initialInputValue: string; +} + +const CommandPromptInner: React.FC<Props> = ({ initialInputValue }) => { + const hide = useHide(); + const [inputValue, setInputValue] = React.useState(initialInputValue); + const { completions, updateCompletions } = useCompletions(); + const { + select, + currentValue, + selectNext, + selectPrev, + } = useSelectCompletion(); + const execCommand = useExecCommand(); + + useAutoResize(); + + const onBlur = () => { + hide(); + }; + + const isCancelKey = React.useCallback( + (e: React.KeyboardEvent<HTMLInputElement>) => + e.key === "Escape" || + (e.ctrlKey && e.key === "[") || + (e.ctrlKey && e.key === "c"), + [] + ); + + const isNextKey = React.useCallback( + (e: React.KeyboardEvent<HTMLInputElement>) => + (!e.shiftKey && e.key === "Tab") || (e.ctrlKey && e.key === "n"), + [] + ); + + const isPrevKey = React.useCallback( + (e: React.KeyboardEvent<HTMLInputElement>) => + (e.shiftKey && e.key === "Tab") || (e.ctrlKey && e.key === "p"), + [] + ); + + const isEnterKey = React.useCallback( + (e: React.KeyboardEvent<HTMLInputElement>) => + e.key === "Enter" || (e.ctrlKey && e.key === "m"), + [] + ); + + const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => { + if (isCancelKey(e)) { + hide(); + } else if (isEnterKey(e)) { + const value = (e.target as HTMLInputElement).value; + execCommand(value); + hide(); + } else if (isNextKey(e)) { + selectNext(); + } else if (isPrevKey(e)) { + selectPrev(); + } else { + return; + } + + e.stopPropagation(); + e.preventDefault(); + }; + + const onChange = (e: React.ChangeEvent<HTMLInputElement>) => { + const text = e.target.value; + setInputValue(text); + }; + + React.useEffect(() => { + updateCompletions(inputValue); + }, [inputValue]); + + return ( + <ConsoleWrapper> + <Completion + size={COMPLETION_MAX_ITEMS} + completions={completions} + select={select} + /> + <Input + prompt={":"} + onBlur={onBlur} + onKeyDown={onKeyDown} + onChange={onChange} + value={select == -1 ? inputValue : currentValue} + /> + </ConsoleWrapper> + ); +}; + +const CommandPrompt: React.FC<Props> = ({ initialInputValue }) => ( + <CompletionProvider initialInputValue={initialInputValue}> + <CommandPromptInner initialInputValue={initialInputValue} /> + </CompletionProvider> +); + +export default CommandPrompt; diff --git a/src/console/components/Console.tsx b/src/console/components/Console.tsx index 18a6632..db18fa0 100644 --- a/src/console/components/Console.tsx +++ b/src/console/components/Console.tsx @@ -1,251 +1,39 @@ -import { connect } from "react-redux"; import React from "react"; -import Input from "./console/Input"; -import Completion from "./console/Completion"; -import Message from "./console/Message"; -import * as consoleActions from "../../console/actions/console"; -import { State as AppState } from "../reducers"; -import CommandLineParser, { - InputPhase, -} from "../commandline/CommandLineParser"; -import { Command } from "../../shared/Command"; -import ColorScheme from "../../shared/ColorScheme"; -import { LightTheme, DarkTheme } from "./Theme"; -import styled from "./Theme"; -import { ThemeProvider } from "styled-components"; -import ConsoleFrameClient from "../clients/ConsoleFrameClient"; - -const ConsoleWrapper = styled.div` - border-top: 1px solid gray; -`; - -const COMPLETION_MAX_ITEMS = 33; - -type StateProps = ReturnType<typeof mapStateToProps>; -interface DispatchProps { - dispatch: (action: any) => void; -} -type Props = StateProps & DispatchProps; - -class Console extends React.Component<Props> { - private input: React.RefObject<Input>; - - private commandLineParser: CommandLineParser = new CommandLineParser(); - private consoleFrameClient = new ConsoleFrameClient(); - - constructor(props: Props) { - super(props); - - this.input = React.createRef(); - } - - onBlur() { - if (this.props.mode === "command" || this.props.mode === "find") { - return this.props.dispatch(consoleActions.hideCommand()); - } - } - - doEnter(e: React.KeyboardEvent<HTMLInputElement>) { - e.stopPropagation(); - e.preventDefault(); - - const value = (e.target as HTMLInputElement).value; - if (this.props.mode === "command") { - return this.props.dispatch(consoleActions.enterCommand(value)); - } else if (this.props.mode === "find") { - return this.props.dispatch( - consoleActions.enterFind(value === "" ? undefined : value) - ); - } - } - - selectNext(e: React.KeyboardEvent<HTMLInputElement>) { - this.props.dispatch(consoleActions.completionNext()); - e.stopPropagation(); - e.preventDefault(); - } - - selectPrev(e: React.KeyboardEvent<HTMLInputElement>) { - this.props.dispatch(consoleActions.completionPrev()); - e.stopPropagation(); - e.preventDefault(); - } - - onKeyDown(e: React.KeyboardEvent<HTMLInputElement>) { - switch (e.key) { - case "Escape": - return this.props.dispatch(consoleActions.hideCommand()); - case "Enter": - return this.doEnter(e); - case "Tab": - if (e.shiftKey) { - this.props.dispatch(consoleActions.completionPrev()); - } else { - this.props.dispatch(consoleActions.completionNext()); - } - e.stopPropagation(); - e.preventDefault(); - break; - case "[": - if (e.ctrlKey) { - e.preventDefault(); - return this.props.dispatch(consoleActions.hideCommand()); - } - break; - case "c": - if (e.ctrlKey) { - e.preventDefault(); - return this.props.dispatch(consoleActions.hideCommand()); - } - break; - case "m": - if (e.ctrlKey) { - return this.doEnter(e); - } - break; - case "n": - if (e.ctrlKey) { - this.selectNext(e); - } - break; - case "p": - if (e.ctrlKey) { - this.selectPrev(e); - } - break; - } - } - - onChange(e: React.ChangeEvent<HTMLInputElement>) { - const text = e.target.value; - this.props.dispatch(consoleActions.setConsoleText(text)); - if (this.props.mode !== "command") { - return; - } - this.updateCompletions(text); - } - - componentDidUpdate(prevProps: Props) { - if (prevProps.mode !== "command" && this.props.mode === "command") { - this.updateCompletions(this.props.consoleText); - this.focus(); - } else if (prevProps.mode !== "find" && this.props.mode === "find") { - this.focus(); - } - - const { - scrollWidth: width, - scrollHeight: height, - } = document.getElementById("vimvixen-console")!; - this.consoleFrameClient.resize(width, height); - } - - render() { - let theme = this.props.colorscheme; - if (this.props.colorscheme === ColorScheme.System) { - if ( - window.matchMedia && - window.matchMedia("(prefers-color-scheme: dark)").matches - ) { - theme = ColorScheme.Dark; - } else { - theme = ColorScheme.Light; - } - } - - switch (this.props.mode) { - case "command": - case "find": - return ( - <ThemeProvider - theme={theme === ColorScheme.Dark ? DarkTheme : LightTheme} - > - <ConsoleWrapper> - <Completion - size={COMPLETION_MAX_ITEMS} - completions={this.props.completions} - select={this.props.select} - /> - <Input - ref={this.input} - mode={this.props.mode} - onBlur={this.onBlur.bind(this)} - onKeyDown={this.onKeyDown.bind(this)} - onChange={this.onChange.bind(this)} - value={this.props.consoleText} - /> - </ConsoleWrapper> - </ThemeProvider> - ); - case "info": - case "error": - return ( - <ThemeProvider - theme={theme === ColorScheme.Dark ? DarkTheme : LightTheme} - > - <Message mode={this.props.mode}>{this.props.messageText}</Message> - </ThemeProvider> - ); - default: - return null; - } - } - - async focus() { - this.props.dispatch(consoleActions.setColorScheme()); - - window.focus(); - if (this.input.current) { - this.input.current.focus(); - } - } - - private updateCompletions(text: string) { - const phase = this.commandLineParser.inputPhase(text); - if (phase === InputPhase.OnCommand) { - return this.props.dispatch(consoleActions.getCommandCompletions(text)); - } else { - const cmd = this.commandLineParser.parse(text); - switch (cmd.command) { - case Command.Open: - case Command.TabOpen: - case Command.WindowOpen: - this.props.dispatch( - consoleActions.getOpenCompletions( - this.props.completionTypes, - text, - cmd.command, - cmd.args - ) - ); - break; - case Command.Buffer: - this.props.dispatch( - consoleActions.getTabCompletions(text, cmd.command, cmd.args, false) - ); - break; - case Command.BufferDelete: - case Command.BuffersDelete: - this.props.dispatch( - consoleActions.getTabCompletions(text, cmd.command, cmd.args, true) - ); - break; - case Command.BufferDeleteForce: - case Command.BuffersDeleteForce: - this.props.dispatch( - consoleActions.getTabCompletions(text, cmd.command, cmd.args, false) - ); - break; - case Command.Set: - this.props.dispatch( - consoleActions.getPropertyCompletions(text, cmd.command, cmd.args) - ); - break; - } - } - } -} - -const mapStateToProps = (state: AppState) => ({ ...state }); - -export default connect(mapStateToProps)(Console); +import FindPrompt from "./FindPrompt"; +import CommandPrompt from "./CommandPrompt"; +import InfoMessage from "./InfoMessage"; +import ErrorMessage from "./ErrorMessage"; +import { useColorSchemeRefresh } from "../colorscheme/hooks"; +import { + useCommandMode, + useErrorMessage, + useFindMode, + useInfoMessage, +} from "../app/hooks"; + +const Console: React.FC = () => { + const refreshColorScheme = useColorSchemeRefresh(); + const { visible: visibleCommand, initialInputValue } = useCommandMode(); + const { visible: visibleFind } = useFindMode(); + const { visible: visibleInfo, message: infoMessage } = useInfoMessage(); + const { visible: visibleError, message: errorMessage } = useErrorMessage(); + + React.useEffect(() => { + if (visibleCommand || visibleFind || visibleInfo || visibleError) { + refreshColorScheme(); + } + }, [visibleCommand, visibleFind, visibleInfo, visibleError]); + + if (visibleCommand) { + return <CommandPrompt initialInputValue={initialInputValue} />; + } else if (visibleFind) { + return <FindPrompt />; + } else if (visibleInfo) { + return <InfoMessage>{infoMessage}</InfoMessage>; + } else if (visibleError) { + return <ErrorMessage>{errorMessage}</ErrorMessage>; + } + return null; +}; + +export default Console; diff --git a/src/console/components/ErrorMessage.tsx b/src/console/components/ErrorMessage.tsx new file mode 100644 index 0000000..f8d5ae7 --- /dev/null +++ b/src/console/components/ErrorMessage.tsx @@ -0,0 +1,15 @@ +import React from "react"; +import styled from "../colorscheme/styled"; + +const Wrapper = styled.p` + border-top: 1px solid gray; + background-color: ${({ theme }) => theme.consoleErrorBackground}; + color: ${({ theme }) => theme.consoleErrorForeground}; + font-weight: bold; +`; + +const ErrorMessage: React.FC = ({ children }) => { + return <Wrapper role="alert">{children}</Wrapper>; +}; + +export default ErrorMessage; diff --git a/src/console/components/FindPrompt.tsx b/src/console/components/FindPrompt.tsx new file mode 100644 index 0000000..c437d16 --- /dev/null +++ b/src/console/components/FindPrompt.tsx @@ -0,0 +1,58 @@ +import React from "react"; +import Input from "./console/Input"; +import styled from "styled-components"; +import useAutoResize from "../hooks/useAutoResize"; +import { useExecFind, useHide } from "../app/hooks"; + +const ConsoleWrapper = styled.div` + border-top: 1px solid gray; +`; + +const FindPrompt: React.FC = () => { + const [inputValue, setInputValue] = React.useState(""); + const hide = useHide(); + const execFind = useExecFind(); + + const onBlur = () => { + hide(); + }; + + useAutoResize(); + + const doEnter = (e: React.KeyboardEvent<HTMLInputElement>) => { + e.stopPropagation(); + e.preventDefault(); + + const value = (e.target as HTMLInputElement).value; + execFind(value === "" ? undefined : value); + }; + + const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => { + switch (e.key) { + case "Escape": + hide(); + break; + case "Enter": + doEnter(e); + break; + } + }; + + const onChange = (e: React.ChangeEvent<HTMLInputElement>) => { + setInputValue(e.target.value); + }; + + return ( + <ConsoleWrapper> + <Input + prompt={"/"} + onBlur={onBlur} + onKeyDown={onKeyDown} + onChange={onChange} + value={inputValue} + /> + </ConsoleWrapper> + ); +}; + +export default FindPrompt; diff --git a/src/console/components/InfoMessage.tsx b/src/console/components/InfoMessage.tsx new file mode 100644 index 0000000..ccd9bcf --- /dev/null +++ b/src/console/components/InfoMessage.tsx @@ -0,0 +1,15 @@ +import React from "react"; +import styled from "../colorscheme/styled"; + +const Wrapper = styled.p` + border-top: 1px solid gray; + background-color: ${({ theme }) => theme.consoleInfoBackground}; + color: ${({ theme }) => theme.consoleInfoForeground}; + font-weight: normal; +`; + +const InfoMessage: React.FC = ({ children }) => { + return <Wrapper role="status">{children}</Wrapper>; +}; + +export default InfoMessage; diff --git a/src/console/components/console/Completion.tsx b/src/console/components/console/Completion.tsx index 09ae278..ed271aa 100644 --- a/src/console/components/console/Completion.tsx +++ b/src/console/components/console/Completion.tsx @@ -19,97 +19,85 @@ interface Props { completions: Group[]; } -interface State { - viewOffset: number; - select: number; -} - -class Completion extends React.Component<Props, State> { - constructor(props: Props) { - super(props); - this.state = { viewOffset: 0, select: -1 }; - } +const Completion: React.FC<Props> = ({ select, size, completions }) => { + const [viewOffset, setViewOffset] = React.useState(0); + const [prevSelect, setPrevSelect] = React.useState(-1); - static getDerivedStateFromProps(nextProps: Props, prevState: State) { - if (prevState.select === nextProps.select) { - return null; + React.useEffect(() => { + if (select === prevSelect) { + return; } const viewSelect = (() => { let index = 0; - for (let i = 0; i < nextProps.completions.length; ++i) { + for (let i = 0; i < completions.length; ++i) { ++index; - const g = nextProps.completions[i]; - if (nextProps.select + i + 1 < index + g.items.length) { - return nextProps.select + i + 1; + const g = completions[i]; + if (select + i + 1 < index + g.items.length) { + return select + i + 1; } index += g.items.length; } return -1; })(); - let viewOffset = 0; - if (nextProps.select < 0) { - viewOffset = 0; - } else if (prevState.select < nextProps.select) { - viewOffset = Math.max( - prevState.viewOffset, - viewSelect - nextProps.size + 1 - ); - } else if (prevState.select > nextProps.select) { - viewOffset = Math.min(prevState.viewOffset, viewSelect); - } - return { viewOffset, select: nextProps.select }; - } + const nextViewOffset = (() => { + if (prevSelect < select) { + return Math.max(viewOffset, viewSelect - size + 1); + } else if (prevSelect > select) { + return Math.min(viewOffset, viewSelect); + } + return 0; + })(); + + setPrevSelect(select); + setViewOffset(nextViewOffset); + }, [select]); - render() { - let itemIndex = 0; - let viewIndex = 0; - const groups: Array<JSX.Element> = []; - const viewOffset = this.state.viewOffset; - const viewSize = this.props.size; + let itemIndex = 0; + let viewIndex = 0; + const groups: Array<JSX.Element> = []; - this.props.completions.forEach((group, groupIndex) => { - const items = []; - const title = ( - <CompletionTitle - id={`title-${groupIndex}`} - key={`group-${groupIndex}`} - shown={viewOffset <= viewIndex && viewIndex < viewOffset + viewSize} - title={group.name} + completions.forEach((group, groupIndex) => { + const items = []; + const title = ( + <CompletionTitle + id={`title-${groupIndex}`} + key={`group-${groupIndex}`} + shown={viewOffset <= viewIndex && viewIndex < viewOffset + size} + title={group.name} + /> + ); + ++viewIndex; + for (const item of group.items) { + items.push( + <CompletionItem + shown={viewOffset <= viewIndex && viewIndex < viewOffset + size} + key={`item-${itemIndex}`} + icon={item.icon} + caption={item.caption} + url={item.url} + highlight={itemIndex === select} + aria-selected={itemIndex === select} + role="menuitem" /> ); ++viewIndex; - for (const item of group.items) { - items.push( - <CompletionItem - shown={viewOffset <= viewIndex && viewIndex < viewOffset + viewSize} - key={`item-${itemIndex}`} - icon={item.icon} - caption={item.caption} - url={item.url} - highlight={itemIndex === this.props.select} - aria-selected={itemIndex === this.props.select} - role="menuitem" - /> - ); - ++viewIndex; - ++itemIndex; - } - groups.push( - <div - key={`group-${groupIndex}`} - role="group" - aria-describedby={`title-${groupIndex}`} - > - {title} - <ul>{items}</ul> - </div> - ); - }); + ++itemIndex; + } + groups.push( + <div + key={`group-${groupIndex}`} + role="group" + aria-describedby={`title-${groupIndex}`} + > + {title} + <ul>{items}</ul> + </div> + ); + }); - return <div role="menu">{groups}</div>; - } -} + return <div role="menu">{groups}</div>; +}; export default Completion; diff --git a/src/console/components/console/CompletionItem.tsx b/src/console/components/console/CompletionItem.tsx index 5f2f9f6..2de1375 100644 --- a/src/console/components/console/CompletionItem.tsx +++ b/src/console/components/console/CompletionItem.tsx @@ -1,5 +1,5 @@ import React from "react"; -import styled from "../Theme"; +import styled from "../../colorscheme/styled"; const Container = styled.li<{ shown: boolean; @@ -38,7 +38,7 @@ const Description = styled.span` overflow: hidden; `; -interface Props { +interface Props extends React.HTMLAttributes<HTMLElement> { shown: boolean; highlight: boolean; caption?: string; @@ -46,9 +46,7 @@ interface Props { icon?: string; } -const CompletionItem: React.FC<React.HTMLAttributes<HTMLElement> & Props> = ( - props -) => ( +const CompletionItem: React.FC<Props> = (props) => ( <Container icon={props.icon || ""} aria-labelledby={`completion-item-${props.caption}`} diff --git a/src/console/components/console/CompletionTitle.tsx b/src/console/components/console/CompletionTitle.tsx index ec2fc8b..4018b3f 100644 --- a/src/console/components/console/CompletionTitle.tsx +++ b/src/console/components/console/CompletionTitle.tsx @@ -1,5 +1,5 @@ import React from "react"; -import styled from "../Theme"; +import styled from "../../colorscheme/styled"; const Li = styled.li<{ shown: boolean }>` display: ${({ shown }) => (shown ? "display" : "none")}; @@ -10,13 +10,13 @@ const Li = styled.li<{ shown: boolean }>` padding: 0; `; -interface Props { +interface Props extends React.HTMLAttributes<HTMLElement> { shown: boolean; title: string; } -const CompletionTitle: React.FC<React.HTMLAttributes<HTMLElement> & Props> = ( - props -) => <Li {...props}>{props.title}</Li>; +const CompletionTitle: React.FC<Props> = (props) => ( + <Li {...props}>{props.title}</Li> +); export default CompletionTitle; diff --git a/src/console/components/console/Input.tsx b/src/console/components/console/Input.tsx index 448b096..442bd30 100644 --- a/src/console/components/console/Input.tsx +++ b/src/console/components/console/Input.tsx @@ -1,5 +1,5 @@ import React from "react"; -import styled from "../Theme"; +import styled from "../../colorscheme/styled"; const Container = styled.div` background-color: ${({ theme }) => theme.commandBackground}; @@ -19,49 +19,32 @@ const InputInner = styled.input` `; interface Props { - mode: string; + prompt: string; value: string; onBlur: (e: React.FocusEvent<HTMLInputElement>) => void; onKeyDown: (e: React.KeyboardEvent<HTMLInputElement>) => void; onChange: (e: React.ChangeEvent<HTMLInputElement>) => void; } -class Input extends React.Component<Props> { - private input: React.RefObject<HTMLInputElement>; - - constructor(props: Props) { - super(props); - - this.input = React.createRef(); - } - - focus() { - if (this.input.current) { - this.input.current.focus(); - } - } - - render() { - let prompt = ""; - if (this.props.mode === "command") { - prompt = ":"; - } else if (this.props.mode === "find") { - prompt = "/"; - } - - return ( - <Container> - <Prompt>{prompt}</Prompt> - <InputInner - ref={this.input} - onBlur={this.props.onBlur} - onKeyDown={this.props.onKeyDown} - onChange={this.props.onChange} - value={this.props.value} - /> - </Container> - ); - } -} +const Input: React.FC<Props> = (props) => { + const input = React.useRef<HTMLInputElement>(null); + + React.useEffect(() => { + input?.current?.focus(); + }, []); + + return ( + <Container> + <Prompt>{props.prompt}</Prompt> + <InputInner + ref={input} + onBlur={props.onBlur} + onKeyDown={props.onKeyDown} + onChange={props.onChange} + value={props.value} + /> + </Container> + ); +}; export default Input; diff --git a/src/console/components/console/Message.tsx b/src/console/components/console/Message.tsx deleted file mode 100644 index 73498fd..0000000 --- a/src/console/components/console/Message.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import React from "react"; -import styled from "../Theme"; - -const Error = styled.p` - border-top: 1px solid gray; - background-color: ${({ theme }) => theme.consoleErrorBackground}; - color: ${({ theme }) => theme.consoleErrorForeground}; - font-weight: bold; -`; - -const Info = styled.p` - border-top: 1px solid gray; - background-color: ${({ theme }) => theme.consoleInfoBackground}; - color: ${({ theme }) => theme.consoleInfoForeground}; - font-weight: normal; -`; - -interface Props { - mode: string; - children: string; -} - -const Message: React.FC<Props> = ({ mode, children }) => { - switch (mode) { - case "error": - return <Error role="alert">{children}</Error>; - case "info": - return <Info role="status">{children}</Info>; - } - return null; -}; - -export default Message; diff --git a/src/console/hooks/useAutoResize.ts b/src/console/hooks/useAutoResize.ts new file mode 100644 index 0000000..4253606 --- /dev/null +++ b/src/console/hooks/useAutoResize.ts @@ -0,0 +1,28 @@ +import React from "react"; +import ConsoleFrameClient from "../clients/ConsoleFrameClient"; + +const useAutoResize = () => { + const [prevWidth, setPrevWidth] = React.useState(-1); + const [prevHeight, setPrevHeight] = React.useState(-1); + + const consoleFrameClient = React.useMemo(() => { + return new ConsoleFrameClient(); + }, []); + + React.useLayoutEffect(() => { + const { + scrollWidth: width, + scrollHeight: height, + } = document.getElementById("vimvixen-console")!; + consoleFrameClient.resize(width, height); + + if (width === prevWidth && height === prevHeight) { + return; + } + + setPrevWidth(width); + setPrevHeight(height); + }); +}; + +export default useAutoResize; diff --git a/src/console/index.tsx b/src/console/index.tsx index f9313a0..29fa11f 100644 --- a/src/console/index.tsx +++ b/src/console/index.tsx @@ -1,42 +1,66 @@ import * as messages from "../shared/messages"; -import reducers from "./reducers"; -import { createStore, applyMiddleware } from "redux"; -import promise from "redux-promise"; -import * as consoleActions from "./actions/console"; -import { Provider } from "react-redux"; import Console from "./components/Console"; import "./index.css"; import React from "react"; import ReactDOM from "react-dom"; +import ColorSchemeProvider from "./colorscheme/providers"; +import { AppProvider } from "./app/provider"; +import { + useCommandMode, + useFindMode, + useInfoMessage, + useErrorMessage, + useHide, +} from "./app/hooks"; -const store = createStore(reducers, applyMiddleware(promise)); +const RootComponent: React.FC = () => { + const hide = useHide(); + const { show: showCommand } = useCommandMode(); + const { show: showFind } = useFindMode(); + const { show: showError } = useErrorMessage(); + const { show: showInfo } = useInfoMessage(); -window.addEventListener("DOMContentLoaded", () => { - const wrapper = document.getElementById("vimvixen-console"); - ReactDOM.render( - <Provider store={store}> - <Console></Console> - </Provider>, - wrapper - ); -}); + React.useEffect(() => { + const onMessage = async (message: any): Promise<any> => { + const msg = messages.valueOf(message); + switch (msg.type) { + case messages.CONSOLE_SHOW_COMMAND: + showCommand(msg.command); + break; + case messages.CONSOLE_SHOW_FIND: + showFind(); + break; + case messages.CONSOLE_SHOW_ERROR: + showError(msg.text); + break; + case messages.CONSOLE_SHOW_INFO: + showInfo(msg.text); + break; + case messages.CONSOLE_HIDE: + hide(); + break; + } + }; + + browser.runtime.onMessage.addListener(onMessage); + const port = browser.runtime.connect(undefined, { + name: "vimvixen-console", + }); + port.onMessage.addListener(onMessage); + }, []); -const onMessage = async (message: any): Promise<any> => { - const msg = messages.valueOf(message); - switch (msg.type) { - case messages.CONSOLE_SHOW_COMMAND: - return store.dispatch(await consoleActions.showCommand(msg.command)); - case messages.CONSOLE_SHOW_FIND: - return store.dispatch(consoleActions.showFind()); - case messages.CONSOLE_SHOW_ERROR: - return store.dispatch(consoleActions.showError(msg.text)); - case messages.CONSOLE_SHOW_INFO: - return store.dispatch(consoleActions.showInfo(msg.text)); - case messages.CONSOLE_HIDE: - return store.dispatch(consoleActions.hide()); - } + return <Console />; }; -browser.runtime.onMessage.addListener(onMessage); -const port = browser.runtime.connect(undefined, { name: "vimvixen-console" }); -port.onMessage.addListener(onMessage); +const App: React.FC = () => ( + <AppProvider> + <ColorSchemeProvider> + <RootComponent /> + </ColorSchemeProvider> + </AppProvider> +); + +window.addEventListener("DOMContentLoaded", () => { + const wrapper = document.getElementById("vimvixen-console"); + ReactDOM.render(<App />, wrapper); +}); diff --git a/src/console/reducers/index.ts b/src/console/reducers/index.ts deleted file mode 100644 index 752dfd9..0000000 --- a/src/console/reducers/index.ts +++ /dev/null @@ -1,136 +0,0 @@ -import * as actions from "../actions"; -import Completions from "../Completions"; -import CompletionType from "../../shared/CompletionType"; -import ColorScheme from "../../shared/ColorScheme"; - -export interface State { - mode: string; - messageText: string; - consoleText: string; - completionTypes: CompletionType[]; - completionSource: string; - completions: Completions; - select: number; - viewIndex: number; - colorscheme: ColorScheme; -} - -const defaultState = { - mode: "", - messageText: "", - consoleText: "", - completionTypes: [], - completionSource: "", - completions: [], - select: -1, - viewIndex: 0, - colorscheme: ColorScheme.System, -}; - -const nextSelection = (state: State): number => { - if (state.completions.length === 0) { - return -1; - } - if (state.select < 0) { - return 0; - } - - const length = state.completions - .map((g) => g.items.length) - .reduce((x, y) => x + y); - if (state.select + 1 < length) { - return state.select + 1; - } - return -1; -}; - -const prevSelection = (state: State): number => { - const length = state.completions - .map((g) => g.items.length) - .reduce((x, y) => x + y); - if (state.select < 0) { - return length - 1; - } - return state.select - 1; -}; - -const nextConsoleText = (completions: any[], select: number, defaults: any) => { - if (select < 0) { - return defaults; - } - const items = completions - .map((g) => g.items) - .reduce((g1, g2) => g1.concat(g2)); - return items[select].content; -}; - -// eslint-disable-next-line max-lines-per-function -export default function reducer( - state: State = defaultState, - action: actions.ConsoleAction -): State { - switch (action.type) { - case actions.CONSOLE_HIDE: - return { ...state, mode: "" }; - case actions.CONSOLE_SHOW_COMMAND: - return { - ...state, - mode: "command", - consoleText: action.text, - completionTypes: action.completionTypes, - completions: [], - }; - case actions.CONSOLE_SHOW_FIND: - return { ...state, mode: "find", consoleText: "", completions: [] }; - case actions.CONSOLE_SHOW_ERROR: - return { ...state, mode: "error", messageText: action.text }; - case actions.CONSOLE_SHOW_INFO: - return { ...state, mode: "info", messageText: action.text }; - case actions.CONSOLE_HIDE_COMMAND: - return { - ...state, - mode: - state.mode === "command" || state.mode === "find" ? "" : state.mode, - }; - case actions.CONSOLE_SET_CONSOLE_TEXT: - return { ...state, consoleText: action.consoleText }; - case actions.CONSOLE_SET_COMPLETIONS: - return { - ...state, - completions: action.completions, - completionSource: action.completionSource, - select: -1, - }; - case actions.CONSOLE_COMPLETION_NEXT: { - const select = nextSelection(state); - return { - ...state, - select: select, - consoleText: nextConsoleText( - state.completions, - select, - state.completionSource - ), - }; - } - case actions.CONSOLE_COMPLETION_PREV: { - const select = prevSelection(state); - return { - ...state, - select: select, - consoleText: nextConsoleText( - state.completions, - select, - state.completionSource - ), - }; - } - case actions.CONSOLE_SET_COLORSCHEME: - return { - ...state, - colorscheme: action.colorscheme, - }; - default: - return state; - } -} |