From d77ed0c5ccbc4b5983e974196d52cd992c5e9ab6 Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Tue, 28 Feb 2023 11:26:26 -0800 Subject: [PATCH] URL highlighting with inline CM --- src-web/App.tsx | 2 +- src-web/components/Editor/Editor.css | 25 +++++-- src-web/components/Editor/Editor.tsx | 85 ++++++++++++++++++++-- src-web/components/Editor/extensions.ts | 15 +++- src-web/components/Editor/url/extension.ts | 46 ++++++++++++ src-web/components/Editor/url/url.grammar | 30 ++++++++ src-web/components/Editor/url/url.terms.ts | 17 +++++ src-web/components/Editor/url/url.ts | 16 ++++ src-web/components/Input.tsx | 48 +++++++++--- src-web/components/ResponsePane.tsx | 14 ++-- src-web/components/UrlBar.tsx | 4 +- 11 files changed, 266 insertions(+), 36 deletions(-) create mode 100644 src-web/components/Editor/url/extension.ts create mode 100644 src-web/components/Editor/url/url.grammar create mode 100644 src-web/components/Editor/url/url.terms.ts create mode 100644 src-web/components/Editor/url/url.ts diff --git a/src-web/App.tsx b/src-web/App.tsx index c2e45209..d4c40c9e 100644 --- a/src-web/App.tsx +++ b/src-web/App.tsx @@ -63,7 +63,7 @@ function App() { updateRequest.mutate({ body })} /> diff --git a/src-web/components/Editor/Editor.css b/src-web/components/Editor/Editor.css index 653e39ff..82a653de 100644 --- a/src-web/components/Editor/Editor.css +++ b/src-web/components/Editor/Editor.css @@ -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 { diff --git a/src-web/components/Editor/Editor.tsx b/src-web/components/Editor/Editor.tsx index efb0429a..884ccbea 100644 --- a/src-web/components/Editor/Editor.tsx +++ b/src-web/components/Editor/Editor.tsx @@ -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, '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(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
; + return ( +
+ ); } diff --git a/src-web/components/Editor/extensions.ts b/src-web/components/Editor/extensions.ts index 47127d03..54c2a75b 100644 --- a/src-web/components/Editor/extensions.ts +++ b/src-web/components/Editor/extensions.ts @@ -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 = '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), ]; diff --git a/src-web/components/Editor/url/extension.ts b/src-web/components/Editor/url/extension.ts new file mode 100644 index 00000000..22a9e9dd --- /dev/null +++ b/src-web/components/Editor/url/extension.ts @@ -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]); +} diff --git a/src-web/components/Editor/url/url.grammar b/src-web/components/Editor/url/url.grammar new file mode 100644 index 00000000..ccb50b0f --- /dev/null +++ b/src-web/components/Editor/url/url.grammar @@ -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 { "&" } +} diff --git a/src-web/components/Editor/url/url.terms.ts b/src-web/components/Editor/url/url.terms.ts new file mode 100644 index 00000000..6003bc8e --- /dev/null +++ b/src-web/components/Editor/url/url.terms.ts @@ -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 diff --git a/src-web/components/Editor/url/url.ts b/src-web/components/Editor/url/url.ts new file mode 100644 index 00000000..99aada5d --- /dev/null +++ b/src-web/components/Editor/url/url.ts @@ -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 +}) diff --git a/src-web/components/Input.tsx b/src-web/components/Input.tsx index b6baa4d4..85626c74 100644 --- a/src-web/components/Input.tsx +++ b/src-web/components/Input.tsx @@ -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, 'size'> { +interface Props extends Omit, '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} - + {useEditor ? ( + + ) : ( + 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} + /> + )} {rightSlot} diff --git a/src-web/components/ResponsePane.tsx b/src-web/components/ResponsePane.tsx index eb724527..a29646a9 100644 --- a/src-web/components/ResponsePane.tsx +++ b/src-web/components/ResponsePane.tsx @@ -83,12 +83,14 @@ export function ResponsePane({ requestId, error }: Props) { {response.elapsed}ms  •  {Math.round(response.body.length / 1000)} KB
- setViewMode((m) => (m === 'pretty' ? 'raw' : 'pretty'))} - /> + {contentType.includes('html') && ( + setViewMode((m) => (m === 'pretty' ? 'raw' : 'pretty'))} + /> + )} {viewMode === 'pretty' && contentType.includes('html') ? (