Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
217 changes: 217 additions & 0 deletions frui/src/field/CodeEditor.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
import { ChangeEvent, useEffect, useRef, useState } from 'react';
import { minimalSetup, basicSetup } from 'codemirror';
import { EditorView, lineNumbers } from '@codemirror/view';
import { EditorState, Extension } from '@codemirror/state';
import Input from './Input';
import type { InputProps } from './Input';
import { ExtendsType } from '../types';
import {
LanguageSupport,
LanguageDescription,
} from '@codemirror/language';
import { languages } from '@codemirror/language-data';

/*
* Code Editor Config
*/
export type CodeEditorConfig = {
language: string;
onChange?: Function;
onUpdate?: Function;
};

/**
* Code Editor Props
*/
export type CodeEditorProps = ExtendsType<
InputProps,
{
defaultValue?: string;
extensions?: Extension[];
language?: string;
numbers?: boolean; //might not be needed (there's nothing special about it; could just add to extensions if necessary)
setup?: 'minimal' | 'basic' | 'custom';
value?: string;
}
>;

export function useCodeEditor({
language,
onUpdate,
onChange,
}: CodeEditorConfig) {
const [languageExtension, setLanguageExtension] = useState<
LanguageSupport | undefined
>(undefined);

useEffect(() => {
getLanguageExtension(language).then((extension) => {
setLanguageExtension(extension);
});
}, [language]);

return {
languageExtension,
handlers: {
update: (value: string) => {
onUpdate && onUpdate(value);
},
change: (event: ChangeEvent<HTMLInputElement>) => {
onChange && onChange(event);
},
},
};
}

/**
* Defines CodeMirror options.
*/
function getEditorOptions(
setup: string,
numbers: boolean,
extensions: Extension[]
): Extension {
//@codemirror provides basic and minimal setups
const options: Extension[] = [];
switch (setup) {
case 'minimal':
options.push(minimalSetup);
break;
case 'basic':
options.push(basicSetup);
break;
case 'custom':
options.push();
break;
//not necessary but added for completeness
default:
options.push();
break;
}
//have to add user-defined extensions after the setup
options.push(extensions);

//adds numbers (redundant when using basic setup)
if (numbers) {
options.push(lineNumbers());
}

return options;
}

/**
* Language extension loader
*/
async function getLanguageExtension(
language: string
): Promise<LanguageSupport | undefined> {
const langName = language.toLowerCase();

const lang = languages.find(
(lang: LanguageDescription) =>
lang.name.toLowerCase() === langName ||
lang.alias?.map((a) => a.toLowerCase()).includes(langName)
);

if (lang) {
await lang.load();
const support: LanguageSupport | undefined = lang.support;
return support;
}

return undefined;
}

/**
* Code Editor Component
*/
export default function CodeEditor(props: CodeEditorProps) {
const {
defaultValue,
extensions = [],
language = '',
numbers = false,
onUpdate,
onChange,
setup = 'minimal',
value,
...attributes
} = props;

const [currentValue, setCurrentValue] = useState<string>(value!);
const inputRef = useRef<HTMLInputElement | null>(null);
const editorRef = useRef<HTMLDivElement | null>(null);
const viewRef = useRef<EditorView | null>(null);

const { languageExtension, handlers } = useCodeEditor({
language,
onChange,
onUpdate,
});
const options = getEditorOptions(setup, numbers, extensions);

useEffect(() => {
if (editorRef.current) {
const state = EditorState.create({
doc: value ?? defaultValue ?? '',
extensions: [
options,
languageExtension ?? [],
EditorView.updateListener.of((viewUpdate) => {
// if (onUpdate.docChanged && viewRef.current) {
// const newValue = viewRef.current.state.doc.toString();
// if (value) {
// setCurrentValue(newValue);
// handlers.update(newValue);
// handlers.change(newValue);
// } else {
// //default to uncontrolled
// inputRef.current!.value = newValue;
// }
// }
if (viewUpdate.docChanged && viewRef.current) {
const newValue = viewUpdate.state.doc.toString();
handlers.change({
target: {
...inputRef.current,
value: newValue,
},
} as ChangeEvent<HTMLInputElement>);
handlers.update(newValue);

if (value) {
setCurrentValue(newValue);
} else {
inputRef.current!.value = newValue; //default to uncontrolled
}
}
}),
],
});

viewRef.current = new EditorView({
state,
parent: editorRef.current,
});
}

return () => {
if (viewRef.current) {
viewRef.current.destroy();
}
};
}, [languageExtension]);

return (
<div className={`frui-field-code-editor ${props.className || ''}`}>
<Input
{...attributes}
passRef={inputRef}
type='hidden'
value={currentValue}
defaultValue={defaultValue}
/>
<div className='code-editor-container' ref={editorRef}></div>
</div>
);
}
94 changes: 94 additions & 0 deletions frui/src/format/Code.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { useLanguage } from 'r22n';
import { useEffect, useState } from 'react';
import SyntaxHighlighter from 'react-syntax-highlighter';
import { atomOneDark } from 'react-syntax-highlighter/dist/cjs/styles/hljs';

// copy should reveal the copy button, but onCopy should be defined to modify its behavior
// meanwhile, the presence of onCopy should be enough to show the copy button

export default function Code(props: {
copy?: boolean;
className?: string;
value?: string;
language?: string;
numbers?: boolean;
onCopy?: () => void;
children: string;
syntaxStyle?: { [key: string]: React.CSSProperties };
}) {
const [mounted, setMounted] = useState(false);
const { children, className, copy, onCopy, language, numbers, syntaxStyle } =
props;
const { _ } = useLanguage();

const body = children
.split('\n')
.map((line) => (language === 'bash' ? `$ ${line}` : line))
.join('\n');

//extends the default copy function if an extension is provided
const handleCopy = () => {
if (onCopy) {
onCopy();
}
navigator.clipboard.writeText(children.toString());
};

//only add highlighting when mounted
//necessary because of problems with SSR
useEffect(() => {
setMounted(true);
}, []);

//renders inline code if language is not provided
if (!language) {
return (
<>
<span>&nbsp;</span>
<code className='text-sm text-t2 bg-b1 font-semibold inline-block p-0.5'>
{body}
</code>
<span>&nbsp;</span>
</>
);
}

return (
<div className={`flex text-sm bg-black ${className || ''}`}>
{mounted && (
<SyntaxHighlighter
className='flex-grow !p-4 !bg-transparent'
language={language}
style={syntaxStyle || atomOneDark}
showLineNumbers={numbers}
>
{body}
</SyntaxHighlighter>
)}

{!mounted && (
<pre
className='flex-grow !p-4 !bg-transparent'
style={{
display: 'block',
overflowX: 'auto',
padding: '0.5em',
color: 'rgb(171, 178, 191)',
background: 'rgb(40, 44, 52)',
}}
>
<code style={{ whiteSpace: 'pre' }}>{body}</code>
</pre>
)}

{copy && (
<div
className='text-sm p-4 text-gray-400 cursor-pointer whitespace-nowrap'
onClick={copy && handleCopy}
>
<i className='fas fa-copy'></i> {_('Copy')}
</div>
)}
</div>
);
}
6 changes: 6 additions & 0 deletions web/modules/theme/layouts/components/MainMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,9 @@ const MainMenu: React.FC<{
<Link href="/field/checkbox" className={`${pathname.indexOf('/field/checkbox') === 0 ? 'text-white' : ''} block pl-7 pr-3 py-2 cursor-pointer`}>
<span className="inline-block pl-2">{_('Checkbox')}</span>
</Link>
<Link href="/field/code-editor" className={`${pathname.indexOf('/field/code-editor') === 0 ? 'text-white' : ''} block pl-7 pr-3 py-2 cursor-pointer`}>
<span className="inline-block pl-2">{_('Code Editor')}</span>
</Link>
<Link href="/field/country" className={`${pathname.indexOf('/field/country') === 0 ? 'text-white' : ''} block pl-7 pr-3 py-2 cursor-pointer`}>
<span className="inline-block pl-2">{_('Country')}</span>
</Link>
Expand Down Expand Up @@ -142,6 +145,9 @@ const MainMenu: React.FC<{
<span className="inline-block pl-2">{_('Formats')}</span>
</Link>
<div className={pathname.indexOf('/format') === 0 ? 'block' : 'hidden'}>
<Link href="/format/code" className={`${pathname.indexOf('/format/code') === 0 ? 'text-white' : ''} block pl-7 pr-3 py-2 cursor-pointer`}>
<span className="inline-block pl-2">{_('Code')}</span>
</Link>
<Link href="/format/color" className={`${pathname.indexOf('/format/color') === 0 ? 'text-white' : ''} block pl-7 pr-3 py-2 cursor-pointer`}>
<span className="inline-block pl-2">{_('Color')}</span>
</Link>
Expand Down
4 changes: 2 additions & 2 deletions web/pages/field/checkbox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -321,8 +321,8 @@ export default function Home() {
{_('Autocomplete')}
</Link>
<div className="flex-grow"></div>
<Link className="text-t2" href="/field/country">
{_('Country')}
<Link className="text-t2" href="/field/code-editor">
{_('Code Editor')}
<i className="fas fa-arrow-right ml-2"></i>
</Link>
</div>
Expand Down
Loading