Add variable highlighting widgets

This commit is contained in:
Gregory Schier
2023-02-26 15:06:14 -08:00
parent 38e8ef6535
commit 5658da34a2
11 changed files with 403 additions and 68 deletions

View File

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

View File

@@ -11,10 +11,22 @@
top: 0;
bottom: 0;
font-size: 0.9rem;
font-family: monospace;
}
.cm-editor .cm-tooltip {
color: gray;
}
.cm-editor .placeholder-widget {
background-color: hsl(var(--color-blue-400));
padding: 0.05em 0.3em;
border-radius: 0.2em;
color: white;
cursor: pointer;
}
.cm-editor .cm-scroller {
border: 1px solid hsl(var(--color-gray-50));
border-radius: var(--border-radius-lg);
background-color: hsl(var(--color-gray-50));
}
@@ -24,7 +36,7 @@
}
.cm-editor.cm-focused .cm-scroller {
border-color: hsl(var(--color-blue-400)/0.4);
box-shadow: 0 0 0 1px hsl(var(--color-blue-400)/0.4);
}
.cm-editor .cm-line {
@@ -77,10 +89,6 @@
color: hsl(var(--color-gray-300));
}
.cm-editor .cm-content {
padding-top: 0.4rem;
}
.cm-editor .cm-foldPlaceholder {
background-color: hsl(var(--color-gray-100));
border: 1px solid hsl(var(--color-gray-200));
@@ -102,7 +110,7 @@
}
.cm-editor .cm-cursor {
border-left: 2px solid red;
border-left: 2px solid hsl(var(--color-gray-900));
}
.cm-editor .cm-selectionBackground {
@@ -112,3 +120,16 @@
.cm-editor.cm-focused .cm-selectionBackground {
background-color: hsl(var(--color-gray-200));
}
/* --> Add padding to container. For some reason, using padding on both adds an extra
* 1px offset so we need to use a combination of padding and margin.
*/
.cm-editor .cm-gutters {
padding-top: 0.2em;
}
.cm-editor .cm-content {
margin-top: 0.2em;
}
/* <-- */

View File

@@ -6,14 +6,15 @@ import { EditorState } from '@codemirror/state';
interface Props {
contentType: string;
useTemplating?: boolean;
defaultValue?: string | null;
onChange?: (value: string) => void;
}
export default function Editor({ contentType, defaultValue, onChange }: Props) {
export default function Editor({ contentType, useTemplating, defaultValue, onChange }: Props) {
const ref = useRef<HTMLDivElement>(null);
const extensions = useMemo(() => {
const ext = syntaxExtension(contentType);
const ext = syntaxExtension({ contentType, useTemplating });
return [
...baseExtensions,
...(ext ? [ext] : []),
@@ -28,13 +29,18 @@ export default function Editor({ contentType, defaultValue, onChange }: Props) {
useEffect(() => {
if (ref.current === null) return;
const view = new EditorView({
state: EditorState.create({
doc: defaultValue ?? '',
extensions: extensions,
}),
parent: ref.current,
});
let view: EditorView;
try {
view = new EditorView({
state: EditorState.create({
doc: defaultValue ?? '',
extensions: extensions,
}),
parent: ref.current,
});
} catch (e) {
console.log(e);
}
return () => view?.destroy();
}, [ref.current]);

View File

@@ -1,13 +1,18 @@
import { parser as twigParser } from './twig/twig';
import {
bracketMatching,
defaultHighlightStyle,
foldGutter,
foldInside,
foldKeymap,
foldNodeProp,
HighlightStyle,
indentNodeProp,
indentOnInput,
LanguageSupport,
LRLanguage,
syntaxHighlighting,
} from '@codemirror/language';
import { lintKeymap } from '@codemirror/lint';
import {
crosshairCursor,
drawSelection,
@@ -19,6 +24,12 @@ import {
lineNumbers,
rectangularSelection,
} from '@codemirror/view';
import { html } from '@codemirror/lang-html';
import { parseMixed } from '@lezer/common';
import { EditorState } from '@codemirror/state';
import { json } from '@codemirror/lang-json';
import { javascript } from '@codemirror/lang-javascript';
import { tags as t } from '@lezer/highlight';
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands';
import { highlightSelectionMatches, searchKeymap } from '@codemirror/search';
import {
@@ -27,35 +38,86 @@ import {
closeBracketsKeymap,
completionKeymap,
} from '@codemirror/autocomplete';
import { lintKeymap } from '@codemirror/lint';
import { EditorState } from '@codemirror/state';
import { json } from '@codemirror/lang-json';
import { javascript } from '@codemirror/lang-javascript';
import { html } from '@codemirror/lang-html';
import { tags } from '@lezer/highlight';
import { placeholders } from './widgets';
export const myHighlightStyle = HighlightStyle.define([
{
tag: [tags.documentMeta, tags.blockComment, tags.lineComment, tags.docComment, tags.comment],
tag: [t.documentMeta, t.blockComment, t.lineComment, t.docComment, t.comment],
color: '#757b93',
},
{ tag: tags.name, color: '#4699de' },
{ tag: tags.variableName, color: '#31c434' },
{ tag: tags.bool, color: '#e864f6' },
{ tag: tags.attributeName, color: '#8f68ff' },
{ tag: tags.attributeValue, color: '#ff964b' },
{ tag: [tags.keyword, tags.string], color: '#e8b045' },
{ tag: tags.comment, color: '#cec4cc', fontStyle: 'italic' },
{ tag: [t.name], color: '#4699de' },
{ tag: [t.variableName], color: '#31c434' },
{ tag: [t.bool], color: '#e864f6' },
{ tag: [t.attributeName], color: '#8f68ff' },
{ tag: [t.attributeValue], color: '#ff964b' },
{ tag: [t.string], color: '#e8b045' },
{ tag: [t.keyword, t.meta], color: '#45e8a4' },
{ tag: [t.comment], color: '#cec4cc', fontStyle: 'italic' },
]);
const syntaxExtensions: Record<string, LanguageSupport> = {
'application/json': json(),
'application/javascript': javascript(),
'text/html': html(),
// export const defaultHighlightStyle = HighlightStyle.define([
// { tag: t.meta, color: '#404740' },
// { tag: t.link, textDecoration: 'underline' },
// { tag: t.heading, textDecoration: 'underline', fontWeight: 'bold' },
// { tag: t.emphasis, fontStyle: 'italic' },
// { tag: t.strong, fontWeight: 'bold' },
// { tag: t.strikethrough, textDecoration: 'line-through' },
// { tag: t.keyword, color: '#708' },
// { tag: [t.atom, t.bool, t.url, t.contentSeparator, t.labelName], color: '#219' },
// { tag: [t.literal, t.inserted], color: '#164' },
// { tag: [t.string, t.deleted], color: '#a11' },
// { tag: [t.regexp, t.escape, t.special(t.string)], color: '#e40' },
// { tag: t.definition(t.variableName), color: '#00f' },
// { tag: t.local(t.variableName), color: '#30a' },
// { tag: [t.typeName, t.namespace], color: '#085' },
// { tag: t.className, color: '#167' },
// { tag: [t.special(t.variableName), t.macroName], color: '#256' },
// { tag: t.definition(t.propertyName), color: '#00c' },
// { tag: t.comment, color: '#940' },
// { tag: t.invalid, color: '#f00' },
// ]);
const syntaxExtensions: Record<string, { base: LanguageSupport; ext: any[] }> = {
'application/json': { base: json(), ext: [] },
'application/javascript': { base: javascript(), ext: [] },
'text/html': { base: html(), ext: [] },
};
export function syntaxExtension(contentType: string): LanguageSupport | undefined {
return syntaxExtensions[contentType];
export function syntaxExtension({
contentType,
useTemplating,
}: {
contentType: string;
useTemplating?: boolean;
}) {
const { base, ext } = syntaxExtensions[contentType] ?? { base: json(), ext: [] };
if (!useTemplating) {
return [base];
}
const mixedTwigParser = twigParser.configure({
props: [
// Add basic folding/indent metadata
foldNodeProp.add({ Conditional: foldInside }),
indentNodeProp.add({
Conditional: (cx) => {
const closed = /^\s*\{% endif/.test(cx.textAfter);
return cx.lineIndent(cx.node.from) + (closed ? 0 : cx.unit);
},
}),
],
wrap: parseMixed((node) => {
return node.type.isTop
? {
parser: base.language.parser,
overlay: (node) => node.type.name == 'Text',
}
: null;
}),
});
const twigLanguage = LRLanguage.define({ parser: mixedTwigParser });
return [twigLanguage, placeholders, base.support, ...ext];
}
export const baseExtensions = [
@@ -78,7 +140,6 @@ export const baseExtensions = [
dropCursor(),
EditorState.allowMultipleSelections.of(true),
indentOnInput(),
syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
bracketMatching(),
closeBrackets(),
autocompletion(),

View File

@@ -0,0 +1,7 @@
import { styleTags, tags as t } from '@lezer/highlight';
export const twigHighlight = styleTags({
'if endif': t.controlKeyword,
'{{ }} {% %}': t.meta,
DirectiveContent: t.variableName,
});

View File

@@ -0,0 +1,28 @@
// Very crude grammar for a subset of Twig templating syntax
@top Template { (directive | Text)* }
directive {
// Insert |
// Conditional { ConditionalOpen (directive | Text)* ConditionalClose }
Insert
}
@skip {space} {
Insert { "{{" DirectiveContent "}}" }
// ConditionalOpen { "{%" kw<"if"> DirectiveContent "%}" }
// ConditionalClose { "{%" kw<"endif"> "%}" }
}
kw<word> { @specialize[@name={word}]<Identifier, word> }
@tokens {
Identifier { @asciiLetter+ }
Text { ![{] Text? | "{" (@eof | ![%{] Text?) }
space { @whitespace+ }
DirectiveContent { ![%}] DirectiveContent? | $[%}] (@eof | ![}] DirectiveContent?) }
@precedence { space DirectiveContent }
"{{" "}}" // "{%" "%}"
}
@external propSource twigHighlight from "./twig-highlight.ts"

View File

@@ -0,0 +1,6 @@
// This file was generated by lezer-generator. You probably shouldn't edit it.
export const
Template = 1,
Insert = 2,
DirectiveContent = 4,
Text = 6

View File

@@ -0,0 +1,19 @@
// This file was generated by lezer-generator. You probably shouldn't edit it.
import { LRParser } from '@lezer/lr';
import { twigHighlight } from './twig-highlight';
export const parser = LRParser.deserialize({
version: 14,
states: "zQVOPOOO_QQO'#C^OOOO'#Cc'#CcQVOPOOOdQQO,58xOOOO-E6a-E6aOOOO1G.d1G.d",
stateData: 'l~OYOS~ORPOUQO~OSSO~OTUO~OYS~',
goto: 'cWPPXPPPP]TQORQRORTR',
nodeNames: '⚠ Template Insert {{ DirectiveContent }} Text',
maxTerm: 10,
propSources: [twigHighlight],
skippedNodes: [0],
repeatNodeCount: 1,
tokenData:
",gRRmOX!|X^'u^p!|pq'uqu!|uv#pv#o!|#o#p)y#p#q!|#q#r+_#r#y!|#y#z'u#z$f!|$f$g'u$g#BY!|#BY#BZ'u#BZ$IS!|$IS$I_'u$I_$I|!|$I|$JO'u$JO$JT!|$JT$JU'u$JU$KV!|$KV$KW'u$KW&FU!|&FU&FV'u&FV;'S!|;'S;=`&f<%lO!|R#TXUPSQOu!|uv#pv#o!|#o#p$b#p#q!|#q#r#p#r;'S!|;'S;=`&f<%lO!|R#uXUPO#o!|#o#p$b#p#q!|#q#r&q#r;'S!|;'S;=`&f<%l~!|~O!|~~&aR$gZSQOu!|uv%Yv#o!|#o#p%o#p#q!|#q#r#p#r;'S!|;'S;=`&f<%l~!|~O!|~~&lQ%]UO#q%o#r;'S%o;'S;=`&Z<%l~%o~O%o~~&aQ%tVSQOu%ouv%Yv#q%o#q#r%Y#r;'S%o;'S;=`&Z<%lO%oQ&^P;=`<%l%oQ&fOSQR&iP;=`<%l!|P&qOUPP&vTUPO#o&q#o#p'V#p;'S&q;'S;=`'o<%lO&qP'YVOu&qv#o&q#p;'S&q;'S;=`'o<%l~&q~O&q~~&lP'rP;=`<%l&qR(OmUPYQSQOX!|X^'u^p!|pq'uqu!|uv#pv#o!|#o#p$b#p#q!|#q#r#p#r#y!|#y#z'u#z$f!|$f$g'u$g#BY!|#BY#BZ'u#BZ$IS!|$IS$I_'u$I_$I|!|$I|$JO'u$JO$JT!|$JT$JU'u$JU$KV!|$KV$KW'u$KW&FU!|&FU&FV'u&FV;'S!|;'S;=`&f<%lO!|R*OZSQOu!|uv%Yv#o!|#o#p*q#p#q!|#q#r#p#r;'S!|;'S;=`&f<%l~!|~O!|~~&lR*xVRPSQOu%ouv%Yv#q%o#q#r%Y#r;'S%o;'S;=`&Z<%lO%oR+dXUPO#o!|#o#p$b#p#q!|#q#r,P#r;'S!|;'S;=`&f<%l~!|~O!|~~&aR,WTTQUPO#o&q#o#p'V#p;'S&q;'S;=`'o<%lO&q",
tokenizers: [0, 1],
topRules: { Template: [0, 1] },
tokenPrec: 25,
});

View File

@@ -0,0 +1,84 @@
import {
Decoration,
DecorationSet,
EditorView,
MatchDecorator,
ViewPlugin,
ViewUpdate,
WidgetType,
} from '@codemirror/view';
class PlaceholderWidget extends WidgetType {
constructor(readonly name: string) {
super();
}
eq(other: PlaceholderWidget) {
return this.name == other.name;
}
toDOM() {
const elt = document.createElement('span');
elt.className = 'placeholder-widget';
elt.textContent = this.name;
return elt;
}
ignoreEvent() {
return false;
}
}
/**
* This is a custom MatchDecorator that will not decorate a match if the selection is inside it
*/
class BetterMatchDecorator extends MatchDecorator {
updateDeco(update: ViewUpdate, deco: DecorationSet): DecorationSet {
if (!update.startState.selection.eq(update.state.selection)) {
return super.createDeco(update.view);
} else {
return super.updateDeco(update, deco);
}
}
}
const placeholderMatcher = new BetterMatchDecorator({
regexp: /\{\{\s*([^}\s]+)\s*}}/g,
decoration(match, view, matchStartPos) {
const matchEndPos = matchStartPos + match[0].length - 1;
// Don't decorate if the cursor is inside the match
for (const r of view.state.selection.ranges) {
if (r.from > matchStartPos && r.to <= matchEndPos) return null;
}
const groupMatch = match[1];
if (groupMatch == null) {
// Should never happen, but make TS happy
console.warn('Group match was empty', match);
return null;
}
return Decoration.replace({
inclusive: true,
widget: new PlaceholderWidget(groupMatch),
});
},
});
export const placeholders = ViewPlugin.fromClass(
class {
placeholders: DecorationSet;
constructor(view: EditorView) {
this.placeholders = placeholderMatcher.createDeco(view);
}
update(update: ViewUpdate) {
console.log('VIEW UPDATE', update);
this.placeholders = placeholderMatcher.updateDeco(update, this.placeholders);
}
},
{
decorations: (instance) => instance.placeholders,
provide: (plugin) =>
EditorView.atomicRanges.of((view) => {
return view.plugin(plugin)?.placeholders || Decoration.none;
}),
},
);