Update to use the Monaco editor (#7)

This commit is contained in:
lucko
2022-01-08 19:10:27 +00:00
committed by GitHub
parent a0b2db024b
commit ed64391a51
18 changed files with 3714 additions and 6792 deletions

View File

@@ -6,8 +6,15 @@ import EditorControls from './EditorControls';
import EditorTextArea from './EditorTextArea';
import themes from '../style/themes';
export default function Editor({ content, setContent, contentType }) {
export default function Editor({
forcedContent,
setForcedContent,
actualContent,
setActualContent,
contentType,
}) {
const [language, setLanguage] = useState('plain');
const [theme, setTheme] = usePreference(
'theme',
'dark',
@@ -37,8 +44,8 @@ export default function Editor({ content, setContent, contentType }) {
<ThemeProvider theme={themes[theme]}>
<EditorGlobalStyle />
<EditorControls
code={content}
setCode={setContent}
actualContent={actualContent}
setForcedContent={setForcedContent}
language={language}
setLanguage={setLanguage}
theme={theme}
@@ -46,8 +53,9 @@ export default function Editor({ content, setContent, contentType }) {
zoom={zoom}
/>
<EditorTextArea
code={content}
setCode={setContent}
forcedContent={forcedContent}
setActualContent={setActualContent}
theme={themes[theme]}
language={language}
fontSize={fontSize}
/>

View File

@@ -10,8 +10,8 @@ import themes from '../style/themes';
import { postUrl } from '../util/constants';
export default function EditorControls({
code,
setCode,
actualContent,
setForcedContent,
language,
setLanguage,
theme,
@@ -23,23 +23,25 @@ export default function EditorControls({
useEffect(() => {
setRecentlySaved(false);
}, [code, language]);
}, [actualContent, language]);
const save = useCallback(() => {
if (!code || recentlySaved) {
if (!actualContent || recentlySaved) {
return;
}
setSaving(true);
saveToBytebin(code, language).then(pasteId => {
saveToBytebin(actualContent, language).then(pasteId => {
setSaving(false);
setRecentlySaved(true);
history.replace({
pathname: pasteId,
});
copy(window.location.href);
document.title = 'paste | ' + pasteId;
if (pasteId) {
history.replace({
pathname: pasteId,
});
copy(window.location.href);
document.title = 'paste | ' + pasteId;
}
});
}, [code, language, recentlySaved]);
}, [actualContent, language, recentlySaved]);
useEffect(() => {
const listener = e => {
@@ -61,7 +63,7 @@ export default function EditorControls({
}, [save, zoom]);
function reset() {
setCode('');
setForcedContent('');
setLanguage('plain');
history.replace({
pathname: '/',
@@ -109,6 +111,7 @@ export default function EditorControls({
const Header = styled.header`
position: fixed;
top: 0;
z-index: 2;
width: 100%;
height: 2em;

View File

@@ -1,178 +0,0 @@
import styled from 'styled-components';
export default function EditorPrismStyle({ children }) {
return <Main>{children}</Main>;
}
const Main = styled.main`
padding-top: 2em;
color: ${props => props.theme.editor.primary};
background: ${props => props.theme.editor.background};
code[class*='language-'],
pre[class*='language-'] {
text-shadow: 0 1px white;
font-family: Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace;
font-size: 1em;
text-align: left;
white-space: pre;
word-spacing: normal;
word-break: normal;
word-wrap: normal;
line-height: 1.5;
-moz-tab-size: 4;
-o-tab-size: 4;
tab-size: 4;
-webkit-hyphens: none;
-moz-hyphens: none;
-ms-hyphens: none;
hyphens: none;
}
pre[class*='language-']::-moz-selection,
pre[class*='language-'] ::-moz-selection,
code[class*='language-']::-moz-selection,
code[class*='language-'] ::-moz-selection {
text-shadow: none;
background: ${props => props.theme.editor.selection};
}
pre[class*='language-']::selection,
pre[class*='language-'] ::selection,
code[class*='language-']::selection,
code[class*='language-'] ::selection {
text-shadow: none;
background: ${props => props.theme.editor.selection};
}
@media print {
code[class*='language-'],
pre[class*='language-'] {
text-shadow: none;
}
}
/* Code blocks */
pre[class*='language-'] {
padding: 1em;
margin: 0.5em 0;
overflow: auto;
}
.token.comment,
.token.prolog,
.token.doctype,
.token.cdata {
color: ${props => props.theme.editor.comment};
}
.token.punctuation {
color: ${props => props.theme.editor.punctuation};
}
.token.annotation {
color: ${props => props.theme.editor.annotation};
}
.token.namespace {
color: ${props => props.theme.editor.namespace};
}
.token.property,
.token.tag {
color: ${props => props.theme.editor.property};
}
.token.tag .punctuation {
color: ${props => props.theme.editor.primary};
}
.token.script > .token.punctuation {
color: ${props => props.theme.editor.punctuation};
}
.token.tag .attr-value {
color: ${props => props.theme.editor.selector};
}
.token.tag .script {
color: ${props => props.theme.editor.primary};
}
.token.boolean {
color: ${props => props.theme.editor.keyword};
}
.token.constant {
color: ${props => props.theme.editor.constant};
}
.token.number,
.token.symbol,
.token.deleted {
color: ${props => props.theme.editor.number};
}
.token.attr-name {
color: ${props => props.theme.editor.function};
}
.token.selector,
.token.string,
.token.char,
.token.builtin,
.token.inserted {
color: ${props => props.theme.editor.selector};
}
.token.operator,
.token.entity,
.token.url,
.language-css .token.string,
.style .token.string {
color: ${props => props.theme.editor.operator};
}
.token.atrule,
.token.attr-value,
.token.keyword {
color: ${props => props.theme.editor.keyword};
}
.token.comment .token.keyword {
color: ${props => props.theme.editor.primary};
}
.token.comment .token.tag,
.token.comment .token.tag > .token.punctuation {
color: ${props => props.theme.editor.commentTag};
}
.token.function {
color: ${props => props.theme.editor.function};
}
.token.class-name {
color: ${props => props.theme.editor.className};
}
.token.regex,
.token.important,
.token.variable {
color: ${props => props.theme.editor.variable};
}
.token.important,
.token.bold {
font-weight: bold;
}
.token.italic {
font-style: italic;
}
.token.entity {
cursor: help;
}
`;

View File

@@ -1,114 +1,127 @@
import { useState, useEffect, useRef } from 'react';
import styled from 'styled-components';
import ReactEditor from 'react-simple-code-editor';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import history from 'history/browser';
import EditorPrismStyle from './EditorPrismStyle';
import { getHighlighter } from '../util/highlighting';
import Editor from '@monaco-editor/react';
import styled from 'styled-components';
import themes, { makeMonacoTheme } from '../style/themes';
export default function EditorTextArea({ code, setCode, language, fontSize }) {
const [isSelected, isSelectionMiddle, toggleSelected] = useSelectedLine();
const highlight = getHighlighter(language);
export default function EditorTextArea({
forcedContent,
setActualContent,
theme,
language,
fontSize,
}) {
const [editor, setEditor] = useState();
const [monaco, setMonaco] = useState();
const [selected, toggleSelected] = useSelectedLine();
const editorAreaRef = useRef();
useLineNumberMagic(editorAreaRef, selected, toggleSelected, editor, monaco);
function highlightWithLineNumbers(input, grammar) {
return highlight(input, grammar)
.split(/\r?\n/)
.map((line, i) => (
<span key={i}>
<LineNumber
lineNo={i + 1}
selected={isSelected(i + 1)}
shouldScroll={isSelectionMiddle(i + 1)}
toggleSelected={toggleSelected}
/>
<span dangerouslySetInnerHTML={{ __html: line }} />
</span>
))
.reduce((acc, curr, idx) => {
if (idx !== 0) {
acc.push('\n');
}
acc.push(curr);
return acc;
}, []);
function beforeMount(monaco) {
for (const [id, theme] of Object.entries(themes)) {
monaco.editor.defineTheme(id, makeMonacoTheme(theme));
}
}
const autoBracketState = useState(null);
const editorRef = useRef();
function keydown(e) {
handleKeydown(e, editorRef.current, autoBracketState);
function onMount(editor, monaco) {
setEditor(editor);
setMonaco(monaco);
editor.focus();
}
const onChange = useCallback(
value => {
setActualContent(value);
},
[setActualContent]
);
return (
<EditorPrismStyle>
<StyledReactEditor
ref={editorRef}
value={code}
onValueChange={setCode}
highlight={highlightWithLineNumbers}
placeholder={'Paste (or type) some code...'}
padding={10}
size={fontSize}
textareaId="code-area"
autoFocus={true}
onKeyDown={keydown}
<EditorArea ref={editorAreaRef}>
<Editor
theme={theme.id}
language={language === 'plain' ? 'plaintext' : language}
options={{
fontFamily: 'JetBrains Mono',
fontSize: fontSize,
fontLigatures: true,
wordWrap: true,
renderLineHighlight: 'none',
detectIndentation: true,
tabSize: 2,
}}
beforeMount={beforeMount}
onMount={onMount}
onChange={onChange}
value={forcedContent}
/>
</EditorPrismStyle>
</EditorArea>
);
}
const LineNumber = ({ lineNo, selected, shouldScroll, toggleSelected }) => {
const autoScroll = useAutoScroll(shouldScroll);
const EditorArea = styled.main`
margin-top: 2.5em;
height: 100%;
function click(e) {
toggleSelected(lineNo, e.shiftKey);
.line-numbers {
cursor: pointer !important;
}
return selected ? (
<HighlightedLineNumber ref={autoScroll} onClick={click}>
{lineNo}
</HighlightedLineNumber>
) : (
<PlainLineNumber ref={autoScroll} onClick={click}>
{lineNo}
</PlainLineNumber>
);
};
const StyledReactEditor = styled(ReactEditor)`
counter-reset: line;
font-size: ${props => props.size}px;
outline: 0;
min-height: calc(100vh - 2rem);
#code-area {
outline: none;
padding-left: 60px !important;
}
pre {
padding-left: 60px !important;
.highlighted-line + div {
color: ${props => props.theme.editor.lineNumberHl};
background-color: ${props => props.theme.editor.lineNumberHlBackground};
font-weight: bold;
}
`;
const PlainLineNumber = styled.span`
position: absolute;
left: 0px;
color: ${props => props.theme.editor.lineNumber};
text-align: right;
width: 40px;
font-weight: 100;
user-select: none;
function useLineNumberMagic(
editorAreaRef,
selected,
toggleSelected,
editor,
monaco
) {
// add an event listener for clicking on line numbers
useEffect(() => {
const node = editorAreaRef.current;
if (!node) {
return;
}
// override parent <pre>
pointer-events: auto;
cursor: pointer;
`;
const handler = click => {
const target = click?.target;
if (target && target.classList.contains('line-numbers')) {
toggleSelected(parseInt(target.textContent), click);
}
};
const HighlightedLineNumber = styled(PlainLineNumber)`
color: ${props => props.theme.editor.lineNumberHl};
background-color: ${props => props.theme.editor.lineNumberHlBackground};
font-weight: bold;
`;
node.addEventListener('click', handler);
return () => node.removeEventListener('click', handler);
}, [editorAreaRef, toggleSelected]);
// apply a 'highlighed' decoration to the selected lines
useEffect(() => {
if (!editor || !monaco) {
return;
}
const range = [];
if (selected[0] !== -1) {
range.push({
range: new monaco.Range(selected[0], 1, selected[1], 1),
options: {
isWholeLine: true,
linesDecorationsClassName: 'highlighted-line',
},
});
}
const decorations = editor.deltaDecorations([], range);
return () => {
editor.deltaDecorations(decorations, []);
};
}, [editor, monaco, selected]);
}
function useSelectedLine() {
// extract highlighted lines from window hash
@@ -116,7 +129,7 @@ function useSelectedLine() {
const hash = window.location.hash;
if (/^#L\d+(-\d+)?$/.test(hash)) {
const [start, end] = hash.substring(2).split('-').map(Number);
return [start, isNaN(end) ? -1 : end];
return [start, isNaN(end) ? start : end];
} else {
return [-1, -1];
}
@@ -127,7 +140,7 @@ function useSelectedLine() {
let hash = '';
if (selected[0] !== -1) {
if (selected[1] !== -1) {
if (selected[1] !== selected[0]) {
const start = Math.min(...selected);
const end = Math.max(...selected);
hash = `#L${start}-${end}`;
@@ -140,171 +153,16 @@ function useSelectedLine() {
}, [selected]);
// toggle the highlighting for a given line
function toggleSelected(lineNo, shift) {
if (selected[0] === lineNo && selected[1] === -1) {
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, -1]);
setSelected([lineNo, lineNo]);
} else {
setSelected([selected[0], lineNo]);
}
}
// should a line be highlighted in the viewer?
function isSelected(lineNo) {
if (selected[0] === -1) {
return false;
}
if (selected[1] === -1) {
return selected[0] === lineNo;
}
return lineNo >= Math.min(...selected) && lineNo <= Math.max(...selected);
}
// is a line in the middle of the selection
function isSelectionMiddle(lineNo) {
if (selected[0] === -1) {
return false;
}
if (selected[1] === -1) {
return selected[0] === lineNo;
}
return (
lineNo === Math.floor((Math.min(...selected) + Math.max(...selected)) / 2)
);
}
return [isSelected, isSelectionMiddle, toggleSelected];
}
function useAutoScroll(shouldScroll) {
const [firstRender, setFirstRender] = useState(true);
const ref = useRef(null);
useEffect(() => {
// only attempt to autoscroll if this is the first render.
if (!firstRender) {
return;
}
setFirstRender(false);
if (shouldScroll) {
ref.current.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}, [shouldScroll, firstRender]);
return ref;
}
const KEYCODE_ENTER = 13;
const KEYCODE_PARENS = 57;
const KEYCODE_PARENS_CLOSE = 48;
const KEYCODE_BRACKETS = 219;
const KEYCODE_BRACKETS_CLOSE = 221;
const KEYCODE_QUOTE = 222;
const KEYCODE_BACK_QUOTE = 192;
function getPair({ keyCode, shiftKey }) {
if (keyCode === KEYCODE_PARENS && shiftKey) {
return ['(', ')'];
} else if (keyCode === KEYCODE_BRACKETS) {
if (shiftKey) {
return ['{', '}'];
} else {
return ['[', ']'];
}
} else if (keyCode === KEYCODE_QUOTE) {
if (shiftKey) {
return ['"', '"'];
} else {
return ["'", "'"];
}
} else if (keyCode === KEYCODE_BACK_QUOTE && !shiftKey) {
return ['`', '`'];
}
return null;
}
function handleKeydown(e, editor, [autoBracket, setAutoBracket]) {
const { value, selectionStart, selectionEnd } = e.target;
if (selectionStart !== selectionEnd) {
return;
}
// If the user types a closing bracket explictly, just jump to after the automatically added one
if (
selectionStart !== 0 &&
autoBracket === e.key &&
(e.keyCode === KEYCODE_BRACKETS_CLOSE || e.keyCode === KEYCODE_PARENS_CLOSE)
) {
e.preventDefault();
editor._applyEdits({
value: value,
selectionStart: selectionStart + 1,
selectionEnd: selectionStart + 1,
});
setAutoBracket(null);
return;
}
// reset auto brackets
setAutoBracket(null);
// When entering an open bracket/quote, add the closing one
const pair = getPair(e);
if (pair) {
// don't add double apostrophes if it looks like a sentence
if (
e.keyCode === KEYCODE_QUOTE &&
!e.shiftKey &&
selectionStart !== 0 &&
/[a-zA-Z]/.test(value.charAt(selectionStart - 1))
) {
return;
}
e.preventDefault();
editor._applyEdits({
value:
value.substring(0, selectionStart) +
pair[0] +
pair[1] +
value.substring(selectionEnd),
selectionStart: selectionStart + 1,
selectionEnd: selectionStart + 1,
});
setAutoBracket(pair[1]);
}
// When pressing enter immediately after an open bracket, automatically add a newline plus extra indent
if (
e.keyCode === KEYCODE_ENTER &&
selectionEnd !== 0 &&
value[selectionEnd - 1] === '{'
) {
const line = editor._getLines(value, selectionStart).pop();
const matches = line.match(/^\s+/);
const existingIndent = matches ? matches[0] : '';
const indent = ' ';
const updatedValue =
value.substring(0, selectionStart) +
'\n' +
existingIndent +
indent +
'\n' +
existingIndent +
value.substring(selectionEnd);
const updatedSelection =
selectionStart + 1 /* newline */ + existingIndent.length + indent.length;
e.preventDefault();
editor._applyEdits({
value: updatedValue,
selectionStart: updatedSelection,
selectionEnd: updatedSelection,
});
}
return [selected, toggleSelected];
}