Skip to content

Commit

Permalink
feat: add a ruleset editor to the options page
Browse files Browse the repository at this point in the history
fix #168

The color scheme is based on
[hybrid-vim](https://github.com/w0ng/vim-hybrid).

BREAKING CHANGE: regular expressions with flags other than 'i' and 'u'
(e.g. `/example/g`) are now invalid.
  • Loading branch information
iorate committed Dec 31, 2021
1 parent 011cdeb commit 2e2fecb
Show file tree
Hide file tree
Showing 10 changed files with 573 additions and 81 deletions.
8 changes: 8 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,14 @@
}
},
"devDependencies": {
"@codemirror/commands": "^0.19.6",
"@codemirror/gutter": "^0.19.9",
"@codemirror/highlight": "^0.19.6",
"@codemirror/history": "^0.19.1",
"@codemirror/language": "^0.19.7",
"@codemirror/state": "^0.19.6",
"@codemirror/stream-parser": "^0.19.3",
"@codemirror/view": "^0.19.37",
"@mdi/svg": "^6.5.95",
"@types/chrome": "0.0.164",
"@types/dotenv-webpack": "^7.0.3",
Expand Down
191 changes: 191 additions & 0 deletions src/scripts/components/editor.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
import { standardKeymap } from '@codemirror/commands';
import { highlightActiveLineGutter, lineNumbers } from '@codemirror/gutter';
import { HighlightStyle, tags as t } from '@codemirror/highlight';
import { history, historyKeymap } from '@codemirror/history';
import { language } from '@codemirror/language';
import { Compartment, EditorState } from '@codemirror/state';
import { StreamLanguage, StreamParser } from '@codemirror/stream-parser';
import { EditorView, highlightSpecialChars, keymap } from '@codemirror/view';
import React, { useCallback, useEffect, useLayoutEffect, useRef } from 'react';
import { FOCUS_END_CLASS, FOCUS_START_CLASS } from './constants';
import { useTheme } from './theme';

export type EditorProps<ParserState> = {
focusStart?: boolean;
focusEnd?: boolean;
parser?: StreamParser<ParserState>;
height?: string;
readOnly?: boolean;
value?: string;
onChange?: (value: string) => void;
};

export function Editor<ParserState>({
focusStart = false,
focusEnd = false,
parser,
height,
readOnly = false,
value = '',
onChange,
}: EditorProps<ParserState>): JSX.Element {
const view = useRef<EditorView | null>(null);
const highlightStyleCompartment = useRef(new Compartment());
const languageCompartment = useRef(new Compartment());
const readOnlyCompartment = useRef(new Compartment());
const themeCompartment = useRef(new Compartment());
const updateListenerCompartment = useRef(new Compartment());

const parentCallback = useCallback((parent: HTMLDivElement | null) => {
if (parent) {
// mount
view.current = new EditorView({
state: EditorState.create({
doc: value, // Set the initial value to prevent undo from going back to empty
extensions: [
keymap.of([...standardKeymap, ...historyKeymap]),
history(),
lineNumbers(),
highlightActiveLineGutter(),
highlightSpecialChars(),
highlightStyleCompartment.current.of([]),
languageCompartment.current.of([]),
readOnlyCompartment.current.of([]),
themeCompartment.current.of([]),
updateListenerCompartment.current.of([]),
],
}),
parent,
});
} else {
// unmount
view.current?.destroy();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);

useLayoutEffect(() => {
view.current?.contentDOM.classList.toggle(FOCUS_START_CLASS, focusStart);
}, [focusStart]);

useLayoutEffect(() => {
view.current?.contentDOM.classList.toggle(FOCUS_END_CLASS, focusEnd);
}, [focusEnd]);

const theme = useTheme();
useLayoutEffect(() => {
view.current?.dispatch({
effects: highlightStyleCompartment.current.reconfigure(
HighlightStyle.define([
{
tag: t.annotation,
color: theme.editor.annotation,
},
{
tag: t.regexp,
color: theme.editor.regexp,
},
{
tag: t.comment,
color: theme.editor.comment,
},
{
tag: t.invalid,
color: theme.editor.comment,
},
]),
),
});
}, [theme]);

useLayoutEffect(() => {
view.current?.dispatch({
effects: languageCompartment.current.reconfigure(
parser ? language.of(StreamLanguage.define(parser)) : [],
),
});
}, [parser]);

useLayoutEffect(() => {
view.current?.dispatch({
effects: readOnlyCompartment.current.reconfigure(EditorState.readOnly.of(readOnly)),
});
}, [readOnly]);

useLayoutEffect(() => {
view.current?.dispatch({
effects: themeCompartment.current.reconfigure(
EditorView.theme(
{
'&': {
backgroundColor: theme.editor.background,
border: `1px solid ${theme.editor.border}`,
color: theme.editor.text,
height: height ?? 'auto',
},
'&.cm-editor.cm-focused': {
boxShadow: `0 0 0 2px ${theme.focus.shadow}`,
outline: 'none',
},
'.cm-scroller': {
fontFamily:
'ui-monospace,SFMono-Regular,"SF Mono",Menlo,Consolas,"Liberation Mono",monospace',
overflow: 'auto',
},
'.cm-gutters': {
backgroundColor: theme.editor.background,
border: 'none',
color: theme.editor.lineNumber,
},
'.cm-activeLineGutter': {
backgroundColor: 'transparent',
},
'&.cm-focused .cm-activeLineGutter': {
color: theme.editor.activeLineNumber,
},
'.cm-lineNumbers .cm-gutterElement': {
padding: '0 8px',
},
'.cm-content ::selection': {
backgroundColor: theme.editor.selectionBackground,
},
},
{ dark: theme.name === 'dark' },
),
),
});
}, [height, theme]);

useLayoutEffect(() => {
view.current?.dispatch({
effects: updateListenerCompartment.current.reconfigure(
onChange
? EditorView.updateListener.of(viewUpdate => {
if (viewUpdate.docChanged) {
onChange(viewUpdate.state.doc.toString());
}
})
: [],
),
});
}, [onChange]);

useEffect(() => {
if (view.current) {
const currentValue = view.current.state.doc.toString();
if (value !== currentValue) {
view.current.dispatch(
view.current.state.update({
changes: {
from: 0,
to: currentValue.length,
insert: value,
},
}),
);
}
}
}, [value]);

return <div ref={parentCallback} />;
}
35 changes: 35 additions & 0 deletions src/scripts/components/theme.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,17 @@ export type Theme = {
dialog: {
background: string;
};
editor: {
border: string;
background: string;
text: string;
lineNumber: string;
activeLineNumber: string;
selectionBackground: string;
annotation: string;
regexp: string;
comment: string;
};
focus: {
circle: string;
shadow: string;
Expand Down Expand Up @@ -115,6 +126,18 @@ export const darkTheme: Readonly<Theme> = {
dialog: {
background: 'rgb(41, 42, 45)',
},
// [hybrid.vim](https://github.com/w0ng/vim-hybrid)
editor: {
border: 'rgb(95, 99, 104)',
background: 'rgb(29, 31, 33)',
text: 'rgb(197, 200, 198)',
lineNumber: 'rgb(55, 59, 65)',
activeLineNumber: 'rgb(240, 198, 116)',
selectionBackground: 'rgb(55, 59, 65)',
annotation: 'rgb(129, 162, 190)',
regexp: 'rgb(181, 189, 104)',
comment: 'rgb(112, 120, 128)',
},
focus: {
shadow: 'rgba(138, 180, 248, 0.5)',
circle: 'rgba(138, 180, 248, 0.4)',
Expand Down Expand Up @@ -195,6 +218,18 @@ export const lightTheme: Readonly<Theme> = {
dialog: {
background: 'white',
},
// [hybrid.vim](https://github.com/w0ng/vim-hybrid)
editor: {
border: 'rgb(218, 220, 224)',
background: 'rgb(255, 255, 255)',
text: 'rgb(0, 0, 0)',
lineNumber: 'rgb(210, 210, 210)',
activeLineNumber: 'rgb(106, 106, 0)',
selectionBackground: 'rgb(210, 210, 210)',
annotation: 'rgb(0, 0, 106)',
regexp: 'rgb(0, 106, 0)',
comment: 'rgb(106, 106, 106)',
},
focus: {
shadow: 'rgba(26, 115, 232, 0.4)',
circle: 'rgba(26, 115, 232, 0.2)',
Expand Down
14 changes: 6 additions & 8 deletions src/scripts/options/general-section.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import { searchEngineMessageNames } from '../search-engines/message-names';
import { SearchEngineId } from '../types';
import { lines, stringKeys } from '../utilities';
import { useOptionsContext } from './options-context';
import { RulesetEditor } from './ruleset-editor';
import { Select, SelectOption } from './select';
import { SetBooleanItem } from './set-boolean-item';

Expand Down Expand Up @@ -215,21 +216,18 @@ const SetBlacklist: React.VFC = () => {
<Row>
<RowItem expanded>
<LabelWrapper fullWidth>
<ControlLabel for="blacklist">{translate('options_blacklistLabel')}</ControlLabel>
<Label>{translate('options_blacklistLabel')}</Label>
<SubLabel>{expandLinks(translate('options_blacklistHelper'))}</SubLabel>
<SubLabel>{translate('options_blockByTitle')}</SubLabel>
<SubLabel>{translate('options_blacklistExample', '*://*.example.com/*')}</SubLabel>
<SubLabel>{translate('options_blacklistExample', '/example\\.(net|org)/')}</SubLabel>
<SubLabel>{translate('options_blacklistExample', 'title/Example Domain/')}</SubLabel>
</LabelWrapper>
<TextArea
id="blacklist"
rows={10}
spellCheck="false"
<RulesetEditor
height="200px"
value={blacklist}
wrap="off"
onChange={e => {
setBlacklist(e.currentTarget.value);
onChange={value => {
setBlacklist(value);
setBlacklistDirty(true);
}}
/>
Expand Down
62 changes: 62 additions & 0 deletions src/scripts/options/ruleset-editor.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import type { StreamParser } from '@codemirror/stream-parser';
import React from 'react';
import { Editor, EditorProps } from '../components/editor';
import { RE_LINE } from '../ruleset';

type ParserState = {
tokens: (readonly [number, string | null])[];
};

const parser: StreamParser<ParserState> = {
token(stream, state) {
if (stream.sol()) {
const groups = RE_LINE.exec(stream.string)?.groups;
if (!groups) {
stream.skipToEnd();
return 'invalid';
}
state.tokens = [];
if (groups.spaceBeforeRuleOrComment) {
state.tokens.push([groups.spaceBeforeRuleOrComment.length, null]);
}
if (groups.highlight) {
state.tokens.push([groups.highlight.length, 'annotation']);
}
if (groups.spaceAfterHighlight) {
state.tokens.push([groups.spaceAfterHighlight.length, null]);
}
if (groups.matchPattern) {
state.tokens.push([groups.matchPattern.length, null]);
}
if (groups.regularExpression) {
state.tokens.push([groups.regularExpression.length, 'regexp']);
}
if (groups.spaceAfterRule) {
state.tokens.push([groups.spaceAfterRule.length, null]);
}
if (groups.comment) {
state.tokens.push([groups.comment.length, 'lineComment']);
}
}
const token = state.tokens.shift();
if (!token) {
// Something went wrong...
stream.skipToEnd();
return 'invalid';
}
stream.pos += token[0];
return token[1];
},
startState() {
return { tokens: [] };
},
copyState(state) {
return { tokens: [...state.tokens] };
},
};

export type RulesetEditorProps = Omit<EditorProps<ParserState>, 'parser'>;

export const RulesetEditor: React.VFC<RulesetEditorProps> = props => (
<Editor parser={parser} {...props} />
);
10 changes: 4 additions & 6 deletions src/scripts/options/subscription-section.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,14 +32,14 @@ import {
TableHeaderRow,
TableRow,
} from '../components/table';
import { TextArea } from '../components/textarea';
import { useClassName, usePrevious } from '../components/utilities';
import { translate } from '../locales';
import { addMessageListeners, sendMessage } from '../messages';
import { Subscription, SubscriptionId, Subscriptions } from '../types';
import { AltURL, MatchPattern, isErrorResult, numberEntries, numberKeys } from '../utilities';
import { FromNow } from './from-now';
import { useOptionsContext } from './options-context';
import { RulesetEditor } from './ruleset-editor';
import { SetIntervalItem } from './set-interval-item';

const PERMISSION_PASSLIST = [
Expand Down Expand Up @@ -206,13 +206,11 @@ const ShowSubscriptionDialog: React.VFC<{ subscription: Subscription | null } &
<Row>
<RowItem expanded>
{open && (
<TextArea
aria-label={translate('options_showSubscriptionDialog_blacklistLabel')}
className={FOCUS_START_CLASS}
<RulesetEditor
focusStart
height="200px"
readOnly
rows={10}
value={subscription?.blacklist ?? ''}
wrap="off"
/>
)}
</RowItem>
Expand Down
Loading

0 comments on commit 2e2fecb

Please sign in to comment.