This commit is contained in:
visortelle 2022-01-15 11:41:35 +01:00
parent 32ecc65849
commit c2fd1a0b27
7 changed files with 191 additions and 132 deletions

View File

@ -4,7 +4,7 @@
"short_name": "Haskell Spotlight",
"description": "Search on Hackage, Hoogle and more soon.",
"homepage_url": "https://github.com/visortelle/hackage-ui",
"version": "0.0.4",
"version": "0.0.5",
"icons": { "192": "images/icon-192.png" },
"content_scripts": [
{
@ -21,7 +21,7 @@
"default_icon": {
"128": "images/icon-192.png"
},
"default_title": "Haskell title",
"default_title": "Haskell Spotlight",
"default_popup": "popup.html"
},
"permissions": [

View File

@ -2,7 +2,7 @@ import * as lib from '@hackage-ui/react-lib';
import styles from './Content.module.css';
import * as s from './Content.module.css';
import haskellLogo from '!!raw-loader!./haskell-monochrome.svg'
import { useCallback, useContext, useEffect, useRef, useState } from 'react';
import { ReactText, Ref, useCallback, useContext, useEffect, useRef, useState } from 'react';
import { ErrorBoundary } from 'react-error-boundary'
import * as k from '../popup/keybindings';
import { applyStyles } from '../styles';
@ -10,79 +10,94 @@ import { applyStyles } from '../styles';
const Content = (props: { rootElement: HTMLElement }) => {
const appContext = useContext(lib.appContext.AppContext);
const stylesContainerRef = useRef(null);
const contentRef = useRef(null);
const [contentEl, setContentEl] = useState<HTMLDivElement>(null);
const [isReady, setIsReady] = useState(false);
const [isShow, setIsShow] = useState(false);
const [explode, setExplode] = useState(false);
const [toggleKB, setToggleKB] = useState<k.KeyBinding | undefined>();
const [showKB, setShowKB] = useState<k.KeyBinding | undefined>();
useEffect(() => {
(async () => {
const keybinding = await k.readKeyBinding('toggleSpotlight');
setToggleKB(() => keybinding);
})()
}, []);
const toggleVisibility = useCallback((event: KeyboardEvent) => {
const kb2: k.KeyBinding = {
code: event.code,
modifiers: {
altKey: event.altKey,
ctrlKey: event.ctrlKey,
metaKey: event.metaKey,
shiftKey: event.shiftKey
}
};
if (k.eqKeyBindings(toggleKB, kb2)) {
event.preventDefault();
setIsShow((isShow) => !isShow);
const preventDefaultKeyBehavior = useCallback((event: KeyboardEvent | React.KeyboardEvent) => {
if (!showKB) {
return;
}
}, [isShow, setIsShow, toggleKB]);
// Prevent global page hotkeys when the search input is in focus.
if (!isShow && k.eqKeyBindings(showKB, k.eventToKeyBinding(event))) {
event.preventDefault();
event.stopPropagation();
}
}, [isShow, showKB]);
const handleDocumentKeydown = useCallback((event: KeyboardEvent) => {
if (!isShow && k.eqKeyBindings(showKB, k.eventToKeyBinding(event))) {
setIsShow(() => true);
}
}, [isShow, setIsShow, showKB]);
// Prevent global page hotkeys when the spotlight popup is in focus.
const handleKeyboardEvents = useCallback((event: KeyboardEvent) => {
if (event.key === 'Escape') {
event.preventDefault();
setIsShow(false);
setIsShow(() => false);
}
event.stopPropagation();
}, []);
if (isShow) {
event.stopPropagation();
}
}, [setIsShow, isShow, showKB]);
const handleClickOutside = useCallback((event: MouseEvent) => {
if (props.rootElement === event.target || props.rootElement.contains(event.target as Node)) {
return;
}
setIsShow(false);
setIsShow(() => false);
}, []);
useEffect(() => {
if (!contentRef.current) {
(async () => {
const keybinding = await k.readKeyBinding('toggleSpotlight');
setShowKB(() => keybinding);
})()
}, []);
useEffect(() => {
const cleanup = () => {
document.removeEventListener('keydown', preventDefaultKeyBehavior);
document.removeEventListener('keyup', preventDefaultKeyBehavior);
}
if (!showKB) {
return;
}
document.addEventListener('keydown', preventDefaultKeyBehavior);
document.addEventListener('keyup', preventDefaultKeyBehavior);
return cleanup;
}, [preventDefaultKeyBehavior, showKB]);
useEffect(() => {
if (!contentEl) {
return;
}
document.addEventListener('mousedown', handleClickOutside);
contentRef.current.addEventListener('keyup', handleKeyboardEvents);
contentRef.current.addEventListener('keydown', handleKeyboardEvents);
contentRef.current.addEventListener('keypress', handleKeyboardEvents);
contentEl.addEventListener('keyup', handleKeyboardEvents);
contentEl.addEventListener('keydown', handleKeyboardEvents);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
contentRef?.current?.removeEventListener('keyup', handleKeyboardEvents)
contentRef?.current?.removeEventListener('keydown', handleKeyboardEvents)
contentRef?.current?.removeEventListener('keypress', handleKeyboardEvents)
contentEl.removeEventListener('keyup', handleKeyboardEvents)
contentEl.removeEventListener('keydown', handleKeyboardEvents)
};
});
}, [contentEl]);
useEffect(() => {
document.addEventListener('keyup', toggleVisibility);
document.addEventListener('keydown', handleDocumentKeydown);
return () => {
document.removeEventListener('keyup', toggleVisibility)
document.removeEventListener('keydown', handleDocumentKeydown)
};
}, [toggleKB]);
}, [showKB]);
useEffect(() => {
let extraStyles = document.createElement('style');
@ -93,7 +108,7 @@ const Content = (props: { rootElement: HTMLElement }) => {
applyStyles(stylesContainerRef.current);
styles.use({ target: stylesContainerRef.current });
setIsReady(true);
setIsReady(() => true);
}, [stylesContainerRef]);
return (
@ -105,7 +120,7 @@ const Content = (props: { rootElement: HTMLElement }) => {
<div>
<div ref={stylesContainerRef}></div>
{isReady && isShow && (
<div ref={contentRef} className={s.content}>
<div ref={setContentEl} className={s.content}>
<div className={`${s.progressIndicator} ${Object.keys(appContext.tasks).length > 0 ? s.progressIndicatorRunning : ''}`}></div>
<a href="https://github.com/visortelle/hackage-ui" target='__blank' className={s.logo} dangerouslySetInnerHTML={{ __html: haskellLogo }}></a>
<div style={{ flex: 1 }}>

View File

@ -31,8 +31,6 @@
justify-content: center;
align-items: center;
transition: var(--transition-short);
background-color: #5e5086;
animation: pulse-mono 800ms infinite ease;
}
.kbdKeys:hover {
@ -46,33 +44,9 @@
.kbdKeysAwaiting {
background-color: #5e5086;
animation: pulse 400ms infinite alternate ease;
transition: var(--transition-short);
}
.kbdKeysCalm {
background-color: #aaa;
animation: none;
}
@keyframes pulse-mono {
0% {
background-color: #bbb;
}
100% {
background-color: #ddd;
}
}
@keyframes pulse {
0% {
background-color: #5e5086;
}
100% {
background-color: #453a62;
}
}
.kbdKey {
margin: 4px;
font-weight: bold;

View File

@ -6,6 +6,15 @@ import * as k from './keybindings';
export default () => {
const [keyBinding, setKeyBinding] = useState<k.KeyBinding | undefined>();
const [tmpKeyBinding, setTmpKeyBinding] = useState<k.KeyBinding>({
code: undefined,
modifiers: {
altKey: false,
ctrlKey: false,
metaKey: false,
shiftKey: false
}
});
const [state, setState] = useState<'awaitingForNewKeyBinding' | 'awaitingForUserInput' | 'keyBindingUpdated'>('awaitingForNewKeyBinding');
useEffect(() => {
@ -18,61 +27,66 @@ export default () => {
})()
}, []);
const handleKeyUp: KeyboardEventHandler = (event) => {
const handleKeyUp: React.KeyboardEventHandler = (event): void => {
event.preventDefault();
event.stopPropagation();
const newKeyBinding = k.eventToKeyBinding(event);
if (!newKeyBinding) {
const isValidKb = k.validateKeyBinding(tmpKeyBinding);
if (!isValidKb) {
return;
}
setKeyBinding(newKeyBinding);
k.writeKeyBinding('toggleSpotlight', keyBinding).then(() => {
setKeyBinding(tmpKeyBinding);
k.writeKeyBinding('toggleSpotlight', tmpKeyBinding).then(() => {
setState('keyBindingUpdated');
});
}
const handleKeyDown: KeyboardEventHandler = (event) => {
const newKeyBinding = k.eventToKeyBinding(event, true);
if (!newKeyBinding) {
const handleKeyDown: React.KeyboardEventHandler = (event): void => {
event.stopPropagation();
event.preventDefault();
if (state !== 'awaitingForUserInput') {
return;
}
setKeyBinding(newKeyBinding);
const kb = k.eventToKeyBinding(event);
setTmpKeyBinding(kb);
}
const kb = state === 'awaitingForUserInput' ? tmpKeyBinding : keyBinding;
return (
<div className={s.popup}>
<div
className={s.popup}
onKeyUp={handleKeyUp}
onKeyDown={handleKeyDown}
>
<div className={s.header}>Haskell Spotlight</div>
<div style={{ display: 'flex', flexDirection: 'column', justifyContent: 'space-between', flex: '1 1' }}>
<div style={{ fontSize: '14px', textAlign: 'center', padding: '0 18px' }}>
{state === 'awaitingForNewKeyBinding' && <span>Click on the area bellow to change hot key.</span>}
{state === 'awaitingForNewKeyBinding' && <span>Click on the area bellow to change hotkey.</span>}
{state === 'awaitingForUserInput' && <span>Press key combination.</span>}
{state === 'keyBindingUpdated' && <span>Hot key has been updated. Close the popup and try it on some web page. Try to refresh the page if nothing happens.</span>}
{state === 'keyBindingUpdated' && <span><br /><strong style={{ fontSize: '32px', color: '#5e5086' }}>Refresh the page to apply new hotkey.</strong></span>}
</div>
{keyBinding && (
{kb && (
<div
className={`${s.kbdKeys} ${state === 'keyBindingUpdated' ? s.kbdKeysCalm : ''} ${state === 'awaitingForUserInput' ? s.kbdKeysAwaiting : ''}`}
className={`${s.kbdKeys} ${state === 'awaitingForUserInput' ? s.kbdKeysAwaiting : ''}`}
onClick={() => {
if (state !== 'awaitingForUserInput') {
if (state === 'awaitingForNewKeyBinding' || state === 'keyBindingUpdated') {
setState('awaitingForUserInput')
} else if (state === 'awaitingForUserInput') {
if (k.validateKeybinding(keyBinding)) {
setState('awaitingForNewKeyBinding');
}
setState('awaitingForNewKeyBinding');
}
}}
onKeyUp={handleKeyUp}
onKeyDown={handleKeyDown}
tabIndex={0}
>
<KbdKey name={keyBinding.code} isActive={keyBinding.code !== undefined} />
<KbdKey name={kb.code} isActive={kb.code !== undefined} />
<div>
<KbdKey name="Alt" isActive={keyBinding.modifiers.altKey} />
<KbdKey name="Ctrl" isActive={keyBinding.modifiers.ctrlKey} />
<KbdKey name="Meta" isActive={keyBinding.modifiers.metaKey} />
<KbdKey name="Shift" isActive={keyBinding.modifiers.shiftKey} />
<KbdKey name="Alt" isActive={kb.modifiers.altKey} />
<KbdKey name="Ctrl" isActive={kb.modifiers.ctrlKey} />
<KbdKey name="Meta" isActive={kb.modifiers.metaKey} />
<KbdKey name="Shift" isActive={kb.modifiers.shiftKey} />
</div>
</div>
)}

View File

@ -31,20 +31,24 @@ export const defaultKeyBindings: Record<KeyBindingId, KeyBinding> = {
}
}
export const eventToKeyBinding = (event: React.KeyboardEvent, ignoreValidation?: boolean): k.KeyBinding => {
const noModifierSpecified = !(event.altKey || event.ctrlKey || event.metaKey || event.shiftKey)
// https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/code/code_values
export const isKeyModifier = (code: string) => {
return code === 'AltLeft' ||
code === 'AltRight' ||
code === 'ControlLeft' ||
code === 'ControlRight' ||
code === 'MetaLeft' ||
code === 'MetaRight' ||
code === 'OSLeft' ||
code === 'OSRight' ||
code === 'ShiftLeft' ||
code === 'ShiftRight'
}
const keyIsModifier =
event.key === 'Alt' ||
event.key === 'Control' ||
event.key === 'Meta' ||
event.key === 'Shift';
export const eventToKeyBinding = (event: KeyboardEvent | React.KeyboardEvent): KeyBinding => {
const keyIsModifier = isKeyModifier(event.code);
if (!ignoreValidation && (noModifierSpecified || keyIsModifier)) {
return undefined;
}
return {
const kb = {
code: keyIsModifier ? undefined : event.code,
modifiers: {
altKey: event.altKey,
@ -53,6 +57,8 @@ export const eventToKeyBinding = (event: React.KeyboardEvent, ignoreValidation?:
shiftKey: event.shiftKey,
}
};
return kb;
}
export const eqKeyBindings = (kb1: KeyBinding, kb2: KeyBinding): boolean => {
@ -66,7 +72,7 @@ export const eqKeyBindings = (kb1: KeyBinding, kb2: KeyBinding): boolean => {
}
export const resetKeyBinding = async (id: KeyBindingId): Promise<void> => {
export const setDefaultKeybinding = async (id: KeyBindingId): Promise<void> => {
const defaultKeyBinding = defaultKeyBindings[id];
if (defaultKeyBinding) {
console.log(`The keybinding ${id} is invalid. We'll reset it to default.`);
@ -74,13 +80,13 @@ export const resetKeyBinding = async (id: KeyBindingId): Promise<void> => {
}
}
export const resetKeyBindings = async (): Promise<void> => {
await Promise.all(Object.keys(defaultKeyBindings).map(id => resetKeyBinding(id)));
export const setDefaultKeyBindings = async (): Promise<void> => {
await Promise.all(Object.keys(defaultKeyBindings).map(id => setDefaultKeybinding(id as KeyBindingId)));
}
// XXX - Very naive validation.
export const validateKeybinding = (kb: KeyBinding): boolean => {
const isValid =
// XXX - Still quite naive validation.
export const validateKeyBinding = (kb: KeyBinding): boolean => {
const schemeIsValid =
(typeof kb.code === 'string') &&
(typeof kb.modifiers === 'object') &&
(typeof kb.modifiers.altKey === 'boolean') &&
@ -88,25 +94,48 @@ export const validateKeybinding = (kb: KeyBinding): boolean => {
(typeof kb.modifiers.metaKey === 'boolean') &&
(typeof kb.modifiers.shiftKey === 'boolean');
return isValid;
if (!schemeIsValid) {
return false;
}
const noModifierSpecified = !(kb.modifiers.altKey || kb.modifiers.ctrlKey || kb.modifiers.metaKey || kb.modifiers.shiftKey);
if (noModifierSpecified) {
return false;
}
const keyIsModifier = isKeyModifier(kb);
if (keyIsModifier) {
return false;
}
return true;
}
const readKeyBindingStr = async (storageKey: KeyBindingId): Promise<string | undefined> => (await browser.storage.local.get([storageKey]))[storageKey];
export const readKeyBinding = async (id: KeyBindingId): Promise<KeyBinding | undefined> => {
const storageKey = kbToStorage(id);
let keybinding: KeyBinding | undefined;
const storageKey = kbToStorage(id) as KeyBindingId;
let serializedKb = await readKeyBindingStr(storageKey);
let kb: KeyBinding;
try {
keybinding = JSON.parse((await browser.storage.local.get(storageKey))[storageKey]);
if (!validateKeybinding(keybinding)) {
await resetKeyBindings();
return (await readKeyBinding(id));
if (!serializedKb) {
await setDefaultKeyBindings();
serializedKb = await readKeyBindingStr(storageKey);
}
kb = JSON.parse(serializedKb);
if (!validateKeyBinding(kb)) {
await setDefaultKeyBindings();
serializedKb = await readKeyBindingStr(storageKey);
}
} catch (err) {
console.log(err);
await resetKeyBinding(id);
} finally {
return keybinding;
await setDefaultKeyBindings();
return await readKeyBinding(id);
}
return kb;
}
export const writeKeyBinding = async (id: KeyBindingId, kb: KeyBinding): Promise<void> => {

View File

@ -20,6 +20,8 @@ const HackageSearchResults = ({ query, apiUrl, asEmbeddedWidget }: HackageSearch
const [searchResults, setSearchResults] = useState<HackageSearchResults>([]);
useEffect(() => {
const abortController = new AbortController();
(async () => {
if (!query) {
return;
@ -38,12 +40,18 @@ const HackageSearchResults = ({ query, apiUrl, asEmbeddedWidget }: HackageSearch
{ headers: { 'Content-Type': 'application/json' } }
)).data;
} catch (err) {
appContext.notifyError(`An error occurred while searching for packages`);
console.log(err);
if (!abortController.signal.aborted) {
appContext.notifyError(`An error occurred while searching for packages`);
console.log(err);
}
} finally {
appContext.finishTask(taskId);
}
if (abortController.signal.aborted) {
return;
}
const searchResults: HackageSearchResults = resData;
if (searchResults.length > 0) {
@ -53,9 +61,13 @@ const HackageSearchResults = ({ query, apiUrl, asEmbeddedWidget }: HackageSearch
setSearchResults(searchResults);
})()
return () => {
abortController.abort();
}
// XXX - don't add appContext to deps here as eslint suggests.
// It may cause infinite recursive calls. Fix it if you know how.
}, [query, appContext.tasks.length]);
}, [query]);
const Link = asEmbeddedWidget ? ExtA : A;
return (

View File

@ -64,6 +64,8 @@ const HoogleSearchResults = ({ query, apiUrl, asEmbeddedWidget }: HoogleSearchRe
}, [globalViewMode, setGlobalViewMode]);
useEffect(() => {
const abortController = new AbortController();
(async () => {
if (!query) {
return;
@ -77,15 +79,24 @@ const HoogleSearchResults = ({ query, apiUrl, asEmbeddedWidget }: HoogleSearchRe
resData = await (await axios.get(
`${apiUrl}?mode=json&format=text&hoogle=${encodeURIComponent(query)}&start=1&count=1000`,
{ headers: { 'Content-Type': 'application/json' } }
{
headers: { 'Content-Type': 'application/json' },
signal: abortController.signal
}
)).data;
} catch (err) {
appContext.notifyError('An error occured during searching on Hoogle');
console.log(err);
if (!abortController.signal.aborted) {
appContext.notifyError('An error occured during searching on Hoogle');
console.log(err);
}
} finally {
appContext.finishTask(taskId);
}
if (abortController.signal.aborted) {
return;
}
const searchResults: HoogleSearchResults = groupBy(deduplicate(resData), 'item');
if (Object.keys(searchResults).length > 0) {
@ -98,6 +109,10 @@ const HoogleSearchResults = ({ query, apiUrl, asEmbeddedWidget }: HoogleSearchRe
setViewModes(zipObject(searchResultsKeys, searchResultsKeys.map(() => globalViewMode)));
})();
return () => {
abortController.abort();
}
// XXX - don't add appContext to deps here as eslint suggests.
// It may cause infinite recursive calls. Fix it if you know how.
}, [query]);