mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-01-11 20:00:29 +01:00
URL highlighting with inline CM
This commit is contained in:
@@ -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 })}
|
||||
/>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
];
|
||||
|
||||
46
src-web/components/Editor/url/extension.ts
Normal file
46
src-web/components/Editor/url/extension.ts
Normal 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]);
|
||||
}
|
||||
30
src-web/components/Editor/url/url.grammar
Normal file
30
src-web/components/Editor/url/url.grammar
Normal 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 { "&" }
|
||||
}
|
||||
17
src-web/components/Editor/url/url.terms.ts
Normal file
17
src-web/components/Editor/url/url.terms.ts
Normal 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
|
||||
16
src-web/components/Editor/url/url.ts
Normal file
16
src-web/components/Editor/url/url.ts
Normal 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
|
||||
})
|
||||
@@ -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>
|
||||
|
||||
@@ -83,12 +83,14 @@ export function ResponsePane({ requestId, error }: Props) {
|
||||
{response.elapsed}ms •
|
||||
{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
|
||||
|
||||
@@ -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={
|
||||
|
||||
Reference in New Issue
Block a user