URL highlighting with inline CM

This commit is contained in:
Gregory Schier
2023-02-28 11:26:26 -08:00
parent 74dd4ee979
commit 88c9df4577
11 changed files with 266 additions and 36 deletions

View File

@@ -63,7 +63,7 @@ function App() {
<Editor
key={request.id}
useTemplating
defaultValue={request.body}
defaultValue={request.body ?? undefined}
contentType="application/json"
onChange={(body) => updateRequest.mutate({ body })}
/>

View File

@@ -4,14 +4,21 @@
position: relative;
}
.cm-editor {
.cm-wrapper .cm-editor {
position: absolute !important;
left: 0;
right: 0;
top: 0;
bottom: 0;
font-size: 0.9rem;
font-family: monospace;
}
.cm-editor {
width: 100%;
display: block;
}
.cm-singleline .cm-scroller {
overflow: hidden !important;;
}
.cm-editor .cm-tooltip {
@@ -35,14 +42,22 @@
outline: none !important;
}
.cm-editor.cm-focused .cm-scroller {
.cm-multiline.cm-editor.cm-focused .cm-scroller {
box-shadow: 0 0 0 1px hsl(var(--color-blue-400)/0.4);
}
.cm-editor .cm-line {
color: hsl(var(--color-gray-900));
}
.cm-multiline .cm-editor .cm-line {
padding-left: 1em;
padding-right: 1.5em;
color: hsl(var(--color-gray-900));
}
.cm-singleline .cm-scroller {
display: flex;
align-items: center !important;
}
.cm-editor .cm-gutters {

View File

@@ -1,22 +1,81 @@
import './Editor.css';
import { useEffect, useMemo, useRef } from 'react';
import { HTMLAttributes, useEffect, useMemo, useRef } from 'react';
import { EditorView } from 'codemirror';
import { baseExtensions, syntaxExtension } from './extensions';
import { EditorState } from '@codemirror/state';
import { baseExtensions, multiLineExtensions, syntaxExtension } from './extensions';
import { EditorState, Transaction, EditorSelection } from '@codemirror/state';
import type { TransactionSpec } from '@codemirror/state';
import classnames from 'classnames';
import { autocompletion } from '@codemirror/autocomplete';
interface Props {
interface Props extends Omit<HTMLAttributes<HTMLDivElement>, 'onChange'> {
contentType: string;
useTemplating?: boolean;
defaultValue?: string | null;
onChange?: (value: string) => void;
onSubmit?: () => void;
singleLine?: boolean;
}
export default function Editor({ contentType, useTemplating, defaultValue, onChange }: Props) {
export default function Editor({
contentType,
useTemplating,
defaultValue,
onChange,
onSubmit,
className,
singleLine,
...props
}: Props) {
const ref = useRef<HTMLDivElement>(null);
const extensions = useMemo(() => {
const ext = syntaxExtension({ contentType, useTemplating });
return [
autocompletion(),
...(singleLine
? [
EditorView.domEventHandlers({
keydown: (e) => {
// TODO: Figure out how to not have this mess up autocomplete
if (e.key === 'Enter') {
e.preventDefault();
onSubmit?.();
}
},
}),
EditorState.transactionFilter.of(
(tr: Transaction): TransactionSpec | TransactionSpec[] => {
if (!tr.isUserEvent('input.paste')) {
return tr;
}
console.log('GOT PASTE', tr);
// let addedNewline = false;
const trs: TransactionSpec[] = [];
tr.changes.iterChanges((fromA, toA, fromB, toB, inserted) => {
// console.log('CHANGE', { fromA, toA }, { fromB, toB }, inserted);
let insert = '';
for (const line of inserted) {
insert += line.replace('\n', '');
}
trs.push({
...tr,
selection: undefined,
changes: [{ from: fromB, to: toA, insert }],
});
});
// selection: EditorSelection.create([EditorSelection.cursor(8)], 1),
// console.log('TRS', trs, tr);
trs.push({
selection: EditorSelection.create([EditorSelection.cursor(8)], 1),
});
return trs;
// return addedNewline ? [] : tr;
},
),
]
: []),
...baseExtensions,
...(!singleLine ? [multiLineExtensions] : []),
...(ext ? [ext] : []),
EditorView.updateListener.of((update) => {
if (typeof onChange === 'function') {
@@ -33,7 +92,7 @@ export default function Editor({ contentType, useTemplating, defaultValue, onCha
try {
view = new EditorView({
state: EditorState.create({
doc: defaultValue ?? '',
doc: `${defaultValue ?? ''}`,
extensions: extensions,
}),
parent: ref.current,
@@ -44,5 +103,15 @@ export default function Editor({ contentType, useTemplating, defaultValue, onCha
return () => view?.destroy();
}, [ref.current]);
return <div ref={ref} className="cm-wrapper" />;
return (
<div
ref={ref}
className={classnames(
className,
'cm-wrapper text-base',
singleLine ? 'cm-singleline' : 'cm-multiline',
)}
{...props}
/>
);
}

View File

@@ -40,6 +40,7 @@ import {
completionKeymap,
} from '@codemirror/autocomplete';
import { placeholders } from './widgets';
import { url } from './url/extension';
export const myHighlightStyle = HighlightStyle.define([
{
@@ -84,6 +85,7 @@ const syntaxExtensions: Record<string, { base: LanguageSupport; ext: any[] }> =
'text/html': { base: html(), ext: [] },
'application/xml': { base: xml(), ext: [] },
'text/xml': { base: xml(), ext: [] },
url: { base: url(), ext: [] },
};
export function syntaxExtension({
@@ -125,10 +127,17 @@ export function syntaxExtension({
}
export const baseExtensions = [
lineNumbers(),
highlightActiveLineGutter(),
highlightSpecialChars(),
history(),
drawSelection(),
dropCursor(),
EditorState.allowMultipleSelections.of(true),
syntaxHighlighting(myHighlightStyle),
];
export const multiLineExtensions = [
lineNumbers(),
highlightActiveLineGutter(),
foldGutter({
markerDOM: (open) => {
const el = document.createElement('div');
@@ -141,7 +150,6 @@ export const baseExtensions = [
},
}),
drawSelection(),
dropCursor(),
EditorState.allowMultipleSelections.of(true),
indentOnInput(),
bracketMatching(),
@@ -160,5 +168,4 @@ export const baseExtensions = [
...completionKeymap,
...lintKeymap,
]),
syntaxHighlighting(myHighlightStyle),
];

View File

@@ -0,0 +1,46 @@
import { parser } from './url';
// import { foldNodeProp, foldInside, indentNodeProp } from '@codemirror/language';
import { styleTags, tags as t } from '@lezer/highlight';
import { LanguageSupport, LRLanguage } from '@codemirror/language';
import { completeFromList } from '@codemirror/autocomplete';
const parserWithMetadata = parser.configure({
props: [
styleTags({
ProtocolName: t.comment,
Slashy: t.comment,
Host: t.variableName,
Slash: t.comment,
PathSegment: t.bool,
QueryName: t.variableName,
QueryValue: t.string,
Question: t.comment,
Equal: t.comment,
Amp: t.comment,
}),
// indentNodeProp.add({
// Application: (context) => context.column(context.node.from) + context.unit,
// }),
// foldNodeProp.add({
// Application: foldInside,
// }),
],
});
const urlLanguage = LRLanguage.define({
parser: parserWithMetadata,
languageData: {
// commentTokens: {line: ";"}
},
});
const exampleCompletion = urlLanguage.data.of({
autocomplete: completeFromList([
{ label: 'http://', type: 'keyword' },
{ label: 'https://', type: 'keyword' },
]),
});
export function url() {
return new LanguageSupport(urlLanguage, [exampleCompletion]);
}

View File

@@ -0,0 +1,30 @@
@top url { Protocol Host Path Query }
Protocol {
ProtocolName Slashy
}
Path {
(Slash PathSegment)*
}
Query {
Question (QueryPair)*
}
QueryPair {
Amp? QueryName Equal QueryValue
}
@tokens {
ProtocolName { "http" | "https" }
Host { $[a-zA-Z0-9-_.]+ }
QueryName { $[a-zA-Z0-9-_.]+ }
QueryValue { $[a-zA-Z0-9-_.]+ }
PathSegment { $[a-zA-Z0-9-_.]+ }
Slashy { "://" }
Slash { "/" }
Question { "?" }
Equal { "=" }
Amp { "&" }
}

View File

@@ -0,0 +1,17 @@
// This file was generated by lezer-generator. You probably shouldn't edit it.
export const
url = 1,
Protocol = 2,
ProtocolName = 3,
Slashy = 4,
Host = 5,
Path = 6,
Slash = 7,
PathSegment = 8,
Query = 9,
Question = 10,
QueryPair = 11,
Amp = 12,
QueryName = 13,
Equal = 14,
QueryValue = 15

View File

@@ -0,0 +1,16 @@
// This file was generated by lezer-generator. You probably shouldn't edit it.
import {LRParser} from "@lezer/lr"
export const parser = LRParser.deserialize({
version: 14,
states: "#xOQOPOOOVOPO'#C^O[OQOOOOOO,58x,58xOaOPOOOiOSO'#ClOnOPO'#CbOvOPOOOOOO,59W,59WOOOO-E6j-E6jO{OWO'#CeQOOOOOO!WOPO'#CgO!]OWO'#CgOOOO'#Cm'#CmO!bOWO,59PO!mO`O,59RO!rOPO,59ROOOO-E6k-E6kOOOO1G.m1G.mO!wO`O1G.mOOOO7+$X7+$X",
stateData: "!|~ORPO~OSRO~OTSO~OVTOYUP~OWWO~OVTOYUX~OYYO~O[]O][ObXX~O^`O~O]aO~O[]O][ObXa~O_cO~O^dO~O_eO~O",
goto: "|bPPcPPPfPPiPlPPPPpvRQORVSRZVT^Y_QUSRXUQ_YRb_",
nodeNames: "⚠ url Protocol ProtocolName Slashy Host Path Slash PathSegment Query Question QueryPair Amp QueryName Equal QueryValue",
maxTerm: 18,
skippedNodes: [0],
repeatNodeCount: 2,
tokenData: "'T~R]vwz}!O!P!O!P!P!P!Q!n!Q![!P![!]!s!_!`#U!a!b#Z!c!}!P#R#S!P#T#[!P#[#]#`#]#o!P~!PO[~n![UTQWS]W_`}!O!P!O!P!P!Q![!P!c!}!P#R#S!P#T#o!P~!sOV~~!vP!P!Q!y~!|P!P!Q#P~#UOS~~#ZO^~~#`OY~o#kWTQWS]W_`}!O!P!O!P!P!Q![!P!c!}!P#R#S!P#T#h!P#h#i$T#i#o!Po$`WTQWS]W_`}!O!P!O!P!P!Q![!P!c!}!P#R#S!P#T#h!P#h#i$x#i#o!Po%TWTQWS]W_`}!O!P!O!P!P!Q![!P!c!}!P#R#S!P#T#d!P#d#e%m#e#o!Po%zWTQWSRP]W_`}!O!P!O!P!P!Q![!P!c!}!P#R#S!P#T#g!P#g#h&d#h#o!Po&qUTQWSRP]W_`}!O!P!O!P!P!Q![!P!c!}!P#R#S!P#T#o!P",
tokenizers: [0, 1, 2, 3, 4],
topRules: {"url":[0,1]},
tokenPrec: 0
})

View File

@@ -1,13 +1,17 @@
import { InputHTMLAttributes, ReactNode } from 'react';
import classnames from 'classnames';
import { HStack, VStack } from './Stacks';
import Editor from './Editor/Editor';
interface Props extends Omit<InputHTMLAttributes<HTMLInputElement>, 'size'> {
interface Props extends Omit<InputHTMLAttributes<HTMLInputElement>, 'size' | 'onChange'> {
name: string;
label: string;
hideLabel?: boolean;
labelClassName?: string;
containerClassName?: string;
onChange?: (value: string) => void;
onSubmit?: () => void;
useEditor?: boolean;
leftSlot?: ReactNode;
rightSlot?: ReactNode;
size?: 'sm' | 'md';
@@ -19,10 +23,14 @@ export function Input({
className,
containerClassName,
labelClassName,
onSubmit,
size = 'md',
useEditor,
onChange,
name,
leftSlot,
rightSlot,
defaultValue,
...props
}: Props) {
const id = `input-${name}`;
@@ -49,16 +57,34 @@ export function Input({
>
{label}
</label>
<input
id={id}
className={classnames(
className,
'bg-transparent min-w-0 pl-3 pr-2 h-full w-full focus:outline-none',
leftSlot && '!pl-1',
rightSlot && '!pr-1',
)}
{...props}
/>
{useEditor ? (
<Editor
id={id}
singleLine
contentType="url"
defaultValue={defaultValue}
onChange={onChange}
onSubmit={onSubmit}
className={classnames(
className,
'bg-transparent min-w-0 pl-3 pr-2 h-full w-full focus:outline-none',
leftSlot && '!pl-1',
rightSlot && '!pr-1',
)}
/>
) : (
<input
id={id}
onChange={(e) => onChange?.(e.target.value)}
className={classnames(
className,
'bg-transparent min-w-0 pl-3 pr-2 h-full w-full focus:outline-none',
leftSlot && '!pl-1',
rightSlot && '!pr-1',
)}
{...props}
/>
)}
</VStack>
{rightSlot}
</HStack>

View File

@@ -83,12 +83,14 @@ export function ResponsePane({ requestId, error }: Props) {
{response.elapsed}ms &nbsp;&bull;&nbsp;
{Math.round(response.body.length / 1000)} KB
</div>
<IconButton
icon={viewMode === 'pretty' ? 'eye' : 'code'}
size="sm"
className="ml-auto"
onClick={() => setViewMode((m) => (m === 'pretty' ? 'raw' : 'pretty'))}
/>
{contentType.includes('html') && (
<IconButton
icon={viewMode === 'pretty' ? 'eye' : 'code'}
size="sm"
className="ml-auto"
onClick={() => setViewMode((m) => (m === 'pretty' ? 'raw' : 'pretty'))}
/>
)}
</HStack>
{viewMode === 'pretty' && contentType.includes('html') ? (
<iframe

View File

@@ -23,10 +23,12 @@ export function UrlBar({ sendRequest, loading, onMethodChange, method, onUrlChan
<form onSubmit={handleSendRequest} className="w-full flex items-center">
<Input
hideLabel
useEditor
onSubmit={sendRequest}
name="url"
label="Enter URL"
className="font-mono"
onChange={(e) => onUrlChange(e.currentTarget.value)}
onChange={onUrlChange}
defaultValue={url}
placeholder="Enter a URL..."
leftSlot={