Convert to typescript

This commit is contained in:
Luck
2022-11-06 22:46:13 +00:00
parent e995261d87
commit 042bace2c6
18 changed files with 593 additions and 321 deletions

23
src/components/Button.tsx Normal file
View File

@@ -0,0 +1,23 @@
import styled from 'styled-components';
const Button = styled.div`
cursor: pointer;
height: 100%;
display: flex;
align-items: center;
padding: 0 0.25em;
color: inherit;
text-decoration: none;
:hover {
background: ${props => props.theme.highlight};
}
@media (max-width: 640px) {
span {
display: none;
}
}
`;
export default Button;

View File

@@ -1,11 +1,21 @@
import { useState, useEffect } from 'react';
import { ThemeProvider, createGlobalStyle } from 'styled-components';
import { useEffect, useState } from 'react';
import { isMobile } from 'react-device-detect';
import ls from 'local-storage';
import { ThemeProvider } from 'styled-components';
import usePreference from '../hooks/usePreference';
import themes, { Themes } from '../style/themes';
import EditorControls from './EditorControls';
import EditorGlobalStyle from './EditorGlobalStyle';
import EditorTextArea from './EditorTextArea';
import themes from '../style/themes';
export interface EditorProps {
forcedContent: string;
setForcedContent: (value: string) => void;
actualContent: string;
setActualContent: (value: string) => void;
contentType?: string;
pasteId?: string;
}
export default function Editor({
forcedContent,
@@ -14,16 +24,16 @@ export default function Editor({
setActualContent,
contentType,
pasteId,
}) {
const [language, setLanguage] = useState('plain');
const [readOnly, setReadOnly] = useState(isMobile && pasteId);
}: EditorProps) {
const [language, setLanguage] = useState<string>('plain');
const [readOnly, setReadOnly] = useState<boolean>(isMobile && !!pasteId);
const [theme, setTheme] = usePreference(
const [theme, setTheme] = usePreference<keyof Themes>(
'theme',
'dark',
pref => !!themes[pref]
);
const [fontSize, setFontSize, fontSizeCheck] = usePreference(
const [fontSize, setFontSize, fontSizeCheck] = usePreference<number>(
'fontsize',
16,
pref => pref >= 10 && pref <= 22
@@ -35,7 +45,7 @@ export default function Editor({
}
}, [contentType]);
function zoom(delta) {
function zoom(delta: number) {
const newFontSize = fontSize + delta;
if (fontSizeCheck(newFontSize)) {
setFontSize(newFontSize);
@@ -65,39 +75,8 @@ export default function Editor({
language={language}
fontSize={fontSize}
readOnly={readOnly}
setReadOnly={setReadOnly}
/>
</ThemeProvider>
</>
);
}
const EditorGlobalStyle = createGlobalStyle`
html, body {
color-scheme: ${props => props.theme.lightOrDark};
scrollbar-color: ${props => props.theme.lightOrDark};
background-color: ${props => props.theme.editor.background};
}
`;
// hook used to load "preference" settings from local storage, or fall back to a default value.
function usePreference(id, defaultValue, valid) {
const [value, setValue] = useState(() => {
const pref = ls.get(id);
if (pref && valid(pref)) {
return pref;
} else {
return defaultValue;
}
});
useEffect(() => {
if (value === defaultValue) {
ls.remove(id);
} else {
ls.set(id, value);
}
}, [value, id, defaultValue]);
return [value, setValue, valid];
}

View File

@@ -1,13 +1,25 @@
import { useState, useEffect, useCallback } from 'react';
import styled from 'styled-components';
import { gzip } from 'pako';
import history from 'history/browser';
import copy from 'copy-to-clipboard';
import history from 'history/browser';
import { useCallback, useEffect, useState } from 'react';
import styled from 'styled-components';
import { MenuButton, Button } from './Menu';
import themes, { Themes } from '../style/themes';
import { languages } from '../util/highlighting';
import themes from '../style/themes';
import { postUrl } from '../util/constants';
import { saveToBytebin } from '../util/storage';
import Button from './Button';
import MenuButton from './MenuButton';
export interface EditorControlsProps {
actualContent: string;
setForcedContent: (value: string) => void;
language: string;
setLanguage: (value: string) => void;
readOnly: boolean;
setReadOnly: (value: boolean) => void;
theme: keyof Themes;
setTheme: (value: keyof Themes) => void;
zoom: (delta: number) => void;
}
export default function EditorControls({
actualContent,
@@ -19,9 +31,9 @@ export default function EditorControls({
theme,
setTheme,
zoom,
}) {
const [saving, setSaving] = useState(false);
const [recentlySaved, setRecentlySaved] = useState(false);
}: EditorControlsProps) {
const [saving, setSaving] = useState<boolean>(false);
const [recentlySaved, setRecentlySaved] = useState<boolean>(false);
useEffect(() => {
setRecentlySaved(false);
@@ -46,7 +58,7 @@ export default function EditorControls({
}, [actualContent, language, recentlySaved]);
useEffect(() => {
const listener = e => {
const listener = (e: KeyboardEvent) => {
if (e.ctrlKey || e.metaKey) {
if (e.key === 's' || e.key === 'S') {
e.preventDefault();
@@ -100,7 +112,7 @@ export default function EditorControls({
label="theme"
value={theme}
setValue={setTheme}
ids={Object.keys(themes)}
ids={Object.keys(themes) as (keyof Themes)[]}
/>
<Button
className="optional"
@@ -139,36 +151,3 @@ const Section = styled.div`
}
}
`;
function langaugeToContentType(language) {
if (language === 'json') {
return 'application/json';
} else {
return 'text/' + language;
}
}
async function saveToBytebin(code, language) {
try {
const compressed = gzip(code);
const contentType = langaugeToContentType(language);
const resp = await fetch(postUrl, {
method: 'POST',
headers: {
'Content-Type': contentType,
'Content-Encoding': 'gzip',
'Accept': 'application/json',
},
body: compressed,
});
if (resp.ok) {
const json = await resp.json();
return json.key;
}
} catch (e) {
console.log(e);
}
return null;
}

View File

@@ -0,0 +1,12 @@
import { createGlobalStyle, ThemeProps } from 'styled-components';
import { Theme } from '../style/themes';
const EditorGlobalStyle = createGlobalStyle<ThemeProps<Theme>>`
html, body {
color-scheme: ${props => props.theme.lightOrDark};
scrollbar-color: ${props => props.theme.lightOrDark};
background-color: ${props => props.theme.editor.background};
}
`;
export default EditorGlobalStyle;

View File

@@ -1,8 +1,25 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import Editor, {
BeforeMount,
Monaco,
OnChange,
OnMount,
} from '@monaco-editor/react';
import history from 'history/browser';
import Editor from '@monaco-editor/react';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import styled from 'styled-components';
import themes, { makeMonacoTheme } from '../style/themes';
import themes, { makeMonacoTheme, Theme } from '../style/themes';
import type { editor } from 'monaco-editor';
export interface EditorTextAreaProps {
forcedContent: string;
actualContent: string;
setActualContent: (value: string) => void;
theme: Theme;
language: string;
fontSize: number;
readOnly: boolean;
}
export default function EditorTextArea({
forcedContent,
@@ -12,11 +29,12 @@ export default function EditorTextArea({
language,
fontSize,
readOnly,
}) {
const [editor, setEditor] = useState();
const [monaco, setMonaco] = useState();
}: EditorTextAreaProps) {
const [editor, setEditor] = useState<editor.IStandaloneCodeEditor>();
const [monaco, setMonaco] = useState<Monaco>();
const [selected, toggleSelected] = useSelectedLine();
const editorAreaRef = useRef();
const editorAreaRef = useRef<HTMLDivElement>(null);
useLineNumberMagic(
editorAreaRef,
selected,
@@ -25,9 +43,10 @@ export default function EditorTextArea({
editor,
monaco
);
usePlaceholderText(actualContent, editor, monaco);
function beforeMount(monaco) {
const beforeMount: BeforeMount = monaco => {
for (const [id, theme] of Object.entries(themes)) {
monaco.editor.defineTheme(id, makeMonacoTheme(theme));
}
@@ -36,32 +55,35 @@ export default function EditorTextArea({
noSemanticValidation: true,
noSyntaxValidation: true,
});
}
};
function onMount(editor, monaco) {
const onMount: OnMount = (editor, monaco) => {
editor.addAction({
id: 'search',
label: 'Search with Google',
contextMenuGroupId: '9_cutcopypaste',
contextMenuOrder: 5,
run: (editor) => {
const selection = editor.getSelection()
if (!selection.isEmpty()) {
const query = editor.getModel().getValueInRange(selection);
window.open('https://www.google.com/search?q=' + query, '_blank');
run: editor => {
const selection = editor.getSelection();
if (selection && !selection.isEmpty()) {
const model = editor.getModel();
if (model) {
const query = model.getValueInRange(selection);
window.open('https://www.google.com/search?q=' + query, '_blank');
}
}
}
})
},
});
setEditor(editor);
setMonaco(monaco);
editor.focus();
}
};
const onChange = useCallback(
const onChange: OnChange = useCallback(
value => {
setActualContent(value);
setActualContent(value as string);
},
[setActualContent]
);
@@ -72,8 +94,13 @@ export default function EditorTextArea({
return;
}
editor.getModel().detectIndentation(true, 2);
}, [editor, forcedContent])
const model = editor.getModel();
if (!model) {
return;
}
model.detectIndentation(true, 2);
}, [editor, forcedContent]);
return (
<EditorArea ref={editorAreaRef}>
@@ -84,9 +111,9 @@ export default function EditorTextArea({
fontFamily: 'JetBrains Mono',
fontSize: fontSize,
fontLigatures: true,
wordWrap: true,
wordWrap: 'on',
renderLineHighlight: 'none',
renderValidationDecorations: false,
renderValidationDecorations: 'off',
readOnly,
domReadOnly: readOnly,
}}
@@ -124,13 +151,17 @@ const EditorArea = styled.main`
}
`;
function usePlaceholderText(actualContent, editor, monaco) {
function usePlaceholderText(
actualContent: string,
editor: editor.IStandaloneCodeEditor | undefined,
monaco: Monaco | undefined
) {
useEffect(() => {
if (!editor || !monaco || actualContent) {
return;
}
const decoration = {
range: new monaco.Range(1, 1, 1, 1),
range: new monaco.Range(1, 0, 1, 1),
options: {
after: {
content: '\u200B',
@@ -146,13 +177,63 @@ function usePlaceholderText(actualContent, editor, monaco) {
}, [editor, monaco, actualContent]);
}
type SelectedLine = [number, number];
type ToggleSelectedFunction = (lineNo: number, e: MouseEvent) => void;
function useSelectedLine(): [SelectedLine, ToggleSelectedFunction] {
// extract highlighted lines from window hash
const [selected, setSelected] = useState<[number, number]>(() => {
const hash = window.location.hash;
if (/^#L\d+(-\d+)?$/.test(hash)) {
const [start, end] = hash.substring(2).split('-').map(Number);
return [start, isNaN(end) ? start : end];
} else {
return [-1, -1];
}
});
// update window hash when a new line is highlighted
useEffect(() => {
let hash = '';
if (selected[0] !== -1) {
if (selected[1] !== selected[0]) {
const start = Math.min(...selected);
const end = Math.max(...selected);
hash = `#L${start}-${end}`;
} else {
hash = `#L${selected[0]}`;
}
}
history.replace({ hash });
}, [selected]);
// toggle the highlighting for a given line
const toggleSelected = useCallback(
(lineNo: number, e: MouseEvent) => {
const shift = e.shiftKey;
if (selected[0] === lineNo && selected[1] === lineNo) {
setSelected([-1, -1]);
} else if (selected[0] === -1 || !shift) {
setSelected([lineNo, lineNo]);
} else {
setSelected([selected[0], lineNo]);
}
},
[selected, setSelected]
);
return [selected, toggleSelected];
}
function useLineNumberMagic(
editorAreaRef,
selected,
toggleSelected,
forcedContent,
editor,
monaco
editorAreaRef: React.RefObject<HTMLDivElement>,
selected: SelectedLine,
toggleSelected: ToggleSelectedFunction,
forcedContent: string,
editor: editor.IStandaloneCodeEditor | undefined,
monaco: Monaco | undefined
) {
// add an event listener for clicking on line numbers
useEffect(() => {
@@ -161,9 +242,13 @@ function useLineNumberMagic(
return;
}
const handler = click => {
const target = click?.target;
if (target && target.classList.contains('line-numbers')) {
const handler = (click: MouseEvent) => {
const target = click?.target as HTMLElement;
if (
target &&
target.classList.contains('line-numbers') &&
target.textContent
) {
toggleSelected(parseInt(target.textContent), click);
}
};
@@ -204,47 +289,3 @@ function useLineNumberMagic(
};
}, [editor, monaco, selected, forcedContent]);
}
function useSelectedLine() {
// extract highlighted lines from window hash
const [selected, setSelected] = useState(() => {
const hash = window.location.hash;
if (/^#L\d+(-\d+)?$/.test(hash)) {
const [start, end] = hash.substring(2).split('-').map(Number);
return [start, isNaN(end) ? start : end];
} else {
return [-1, -1];
}
});
// update window hash when a new line is highlighted
useEffect(() => {
let hash = '';
if (selected[0] !== -1) {
if (selected[1] !== selected[0]) {
const start = Math.min(...selected);
const end = Math.max(...selected);
hash = `#L${start}-${end}`;
} else {
hash = `#L${selected[0]}`;
}
}
history.replace({ hash });
}, [selected]);
// toggle the highlighting for a given line
function toggleSelected(lineNo, e) {
const shift = e.shiftKey;
if (selected[0] === lineNo && selected[1] === lineNo) {
setSelected([-1, -1]);
} else if (selected[0] === -1 || !shift) {
setSelected([lineNo, lineNo]);
} else {
setSelected([selected[0], lineNo]);
}
}
return [selected, toggleSelected];
}

View File

@@ -1,25 +1,74 @@
import { useState, useEffect } from 'react';
import React, { useEffect, useState } from 'react';
import styled from 'styled-components';
import Button from './Button';
export const Button = styled.div`
cursor: pointer;
height: 100%;
display: flex;
align-items: center;
padding: 0 0.25em;
color: inherit;
text-decoration: none;
interface MenuButtonProps<T extends string> {
label: string;
ids: T[] | Record<string, T[]>;
value: T;
setValue: (value: T) => void;
}
:hover {
background: ${props => props.theme.highlight};
export default function MenuButton<T extends string>({
label,
ids,
value,
setValue,
}: MenuButtonProps<T>) {
const [open, setOpen] = useState<boolean>(false);
// close the menu when a click is made elsewhere
useEffect(() => {
const listener = (e: MouseEvent) => setOpen(false);
window.addEventListener('click', listener);
return () => window.removeEventListener('click', listener);
}, [setOpen]);
function toggleOpen(e: React.MouseEvent) {
e.stopPropagation();
setOpen(!open);
}
@media (max-width: 640px) {
span {
display: none;
}
function select(e: React.MouseEvent<HTMLLIElement>, id: T) {
e.stopPropagation();
setOpen(false);
setValue(id);
}
`;
function make(ids: T[]) {
return ids.map(id => (
<li
key={id}
className={id === value ? 'selected' : undefined}
onClick={e => select(e, id)}
>
{id}
</li>
));
}
let items;
if (Array.isArray(ids)) {
items = make(ids);
} else {
items = Object.entries(ids).map(([title, values]) =>
[
<li className="title" key={title} onClick={e => e.stopPropagation()}>
[{title}]
</li>,
]
.concat(make(values))
.flat()
);
}
return (
<Button onClick={toggleOpen}>
[<span>{label}: </span>
{value}]{open && <Menu>{items}</Menu>}
</Button>
);
}
const Menu = styled.ul`
position: absolute;
@@ -60,58 +109,3 @@ const Menu = styled.ul`
background-color: ${props => props.theme.secondary};
}
`;
export const MenuButton = ({ label, ids, value, setValue }) => {
const [open, setOpen] = useState(false);
useEffect(() => {
const listener = e => setOpen(false);
window.addEventListener('click', listener);
return () => window.removeEventListener('click', listener);
}, [setOpen]);
function toggleOpen(e) {
e.stopPropagation();
setOpen(!open);
}
function select(e, id) {
e.stopPropagation();
setOpen(false);
setValue(id);
}
function make(ids) {
return ids.map(id => (
<li
key={id}
className={id === value ? 'selected' : undefined}
onClick={e => select(e, id)}
>
{id}
</li>
));
}
let items;
if (Array.isArray(ids)) {
items = make(ids);
} else {
items = Object.entries(ids).map(([title, values]) =>
[
<li className="title" key={title} onClick={e => e.stopPropagation()}>
[{title}]
</li>,
]
.concat(make(values))
.flat()
);
}
return (
<Button onClick={toggleOpen}>
[<span>{label}: </span>
{value}]{open && <Menu>{items}</Menu>}
</Button>
);
};