mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-03-30 22:22:02 +02:00
Template Tag Function Editor (#67)

This commit is contained in:
@@ -3,8 +3,12 @@ import type { CompletionContext } from '@codemirror/autocomplete';
|
||||
const openTag = '${[ ';
|
||||
const closeTag = ' ]}';
|
||||
|
||||
interface TwigCompletionOption {
|
||||
export interface TwigCompletionOption {
|
||||
name: string;
|
||||
label: string;
|
||||
type: 'function' | 'variable' | 'unknown';
|
||||
value: string | null;
|
||||
onClick?: (rawTag: string, startPos: number) => void;
|
||||
}
|
||||
|
||||
export interface TwigCompletionConfig {
|
||||
@@ -41,12 +45,16 @@ export function twigCompletion({ options }: TwigCompletionConfig) {
|
||||
from: toMatch.from,
|
||||
options: options
|
||||
.filter((v) => v.name.trim())
|
||||
.map((v) => ({
|
||||
label: toStartOfVariable ? `${openTag}${v.name}${closeTag}` : v.name,
|
||||
apply: `${openTag}${v.name}${closeTag}`,
|
||||
type: 'variable',
|
||||
matchLen: matchLen,
|
||||
}))
|
||||
.map((v) => {
|
||||
const innerLabel = v.type === 'function' ? `${v.name}()` : v.name;
|
||||
const tagSyntax = openTag + innerLabel + closeTag;
|
||||
return {
|
||||
label: innerLabel,
|
||||
apply: tagSyntax,
|
||||
type: v.type === 'variable' ? 'variable' : 'function',
|
||||
matchLen: matchLen,
|
||||
};
|
||||
})
|
||||
// Filter out exact matches
|
||||
.filter((o) => o.label !== toMatch.text),
|
||||
};
|
||||
|
||||
@@ -1,29 +1,57 @@
|
||||
import type { LanguageSupport } from '@codemirror/language';
|
||||
import { LRLanguage } from '@codemirror/language';
|
||||
import { parseMixed } from '@lezer/common';
|
||||
import type { Environment, Workspace } from '@yaakapp/api';
|
||||
import type { EnvironmentVariable } from '@yaakapp/api';
|
||||
import type { TemplateFunction } from '../../../../hooks/useTemplateFunctions';
|
||||
import type { GenericCompletionConfig } from '../genericCompletion';
|
||||
import { genericCompletion } from '../genericCompletion';
|
||||
import { textLanguageName } from '../text/extension';
|
||||
import type { TwigCompletionOption } from './completion';
|
||||
import { twigCompletion } from './completion';
|
||||
import { placeholders } from './placeholder';
|
||||
import { templateTags } from './templateTags';
|
||||
import { parser as twigParser } from './twig';
|
||||
|
||||
export function twig(
|
||||
base: LanguageSupport,
|
||||
environment: Environment | null,
|
||||
workspace: Workspace | null,
|
||||
autocomplete?: GenericCompletionConfig,
|
||||
) {
|
||||
export function twig({
|
||||
base,
|
||||
environmentVariables,
|
||||
templateFunctions,
|
||||
autocomplete,
|
||||
onClickFunction,
|
||||
onClickVariable,
|
||||
}: {
|
||||
base: LanguageSupport;
|
||||
environmentVariables: EnvironmentVariable[];
|
||||
templateFunctions: TemplateFunction[];
|
||||
autocomplete?: GenericCompletionConfig;
|
||||
onClickFunction: (option: TemplateFunction, tagValue: string, startPos: number) => void;
|
||||
onClickVariable: (option: EnvironmentVariable, tagValue: string, startPos: number) => void;
|
||||
}) {
|
||||
const language = mixLanguage(base);
|
||||
const allVariables = [...(workspace?.variables ?? []), ...(environment?.variables ?? [])];
|
||||
const variables = allVariables.filter((v) => v.enabled) ?? [];
|
||||
const completions = twigCompletion({ options: variables });
|
||||
|
||||
const variableOptions: TwigCompletionOption[] =
|
||||
environmentVariables.map((v) => ({
|
||||
...v,
|
||||
type: 'variable',
|
||||
label: v.name,
|
||||
onClick: (rawTag: string, startPos: number) => onClickVariable(v, rawTag, startPos),
|
||||
})) ?? [];
|
||||
const functionOptions: TwigCompletionOption[] =
|
||||
templateFunctions.map((fn) => ({
|
||||
name: fn.name,
|
||||
type: 'function',
|
||||
value: null,
|
||||
label: fn.name + '(' + fn.args.length + ')',
|
||||
onClick: (rawTag: string, startPos: number) => onClickFunction(fn, rawTag, startPos),
|
||||
})) ?? [];
|
||||
|
||||
const options = [...variableOptions, ...functionOptions];
|
||||
|
||||
const completions = twigCompletion({ options });
|
||||
|
||||
return [
|
||||
language,
|
||||
base.support,
|
||||
placeholders(variables),
|
||||
templateTags(options),
|
||||
language.data.of({ autocomplete: completions }),
|
||||
base.language.data.of({ autocomplete: completions }),
|
||||
language.data.of({ autocomplete: genericCompletion(autocomplete) }),
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
import type { DecorationSet, ViewUpdate } from '@codemirror/view';
|
||||
import { Decoration, EditorView, ViewPlugin, WidgetType } from '@codemirror/view';
|
||||
import { BetterMatchDecorator } from '../BetterMatchDecorator';
|
||||
|
||||
class PlaceholderWidget extends WidgetType {
|
||||
constructor(
|
||||
readonly name: string,
|
||||
readonly value: string,
|
||||
readonly exists: boolean,
|
||||
readonly type: 'function' | 'variable' = 'variable',
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
eq(other: PlaceholderWidget) {
|
||||
return this.name == other.name && this.exists == other.exists;
|
||||
}
|
||||
|
||||
toDOM() {
|
||||
const elt = document.createElement('span');
|
||||
elt.className = `x-theme-placeholder placeholder ${
|
||||
!this.exists
|
||||
? 'x-theme-placeholder--danger'
|
||||
: this.type === 'variable'
|
||||
? 'x-theme-placeholder--primary'
|
||||
: 'x-theme-placeholder--info'
|
||||
}`;
|
||||
elt.title = !this.exists ? 'Variable not found in active environment' : this.value ?? '';
|
||||
elt.textContent = this.name;
|
||||
return elt;
|
||||
}
|
||||
|
||||
ignoreEvent() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export const placeholders = function (variables: { name: string; value?: string }[]) {
|
||||
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 Decoration.replace({});
|
||||
}
|
||||
}
|
||||
|
||||
const groupMatch = match[1];
|
||||
if (groupMatch == null) {
|
||||
// Should never happen, but make TS happy
|
||||
console.warn('Group match was empty', match);
|
||||
return Decoration.replace({});
|
||||
}
|
||||
|
||||
const isFunction = groupMatch.includes('(');
|
||||
return Decoration.replace({
|
||||
inclusive: true,
|
||||
widget: new PlaceholderWidget(
|
||||
groupMatch,
|
||||
variables.find((v) => v.name === groupMatch)?.value ?? '',
|
||||
isFunction ? true : variables.some((v) => v.name === groupMatch),
|
||||
isFunction ? 'function' : 'variable',
|
||||
),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return ViewPlugin.fromClass(
|
||||
class {
|
||||
placeholders: DecorationSet;
|
||||
|
||||
constructor(view: EditorView) {
|
||||
this.placeholders = placeholderMatcher.createDeco(view);
|
||||
}
|
||||
|
||||
update(update: ViewUpdate) {
|
||||
this.placeholders = placeholderMatcher.updateDeco(update, this.placeholders);
|
||||
}
|
||||
},
|
||||
{
|
||||
decorations: (instance) => instance.placeholders,
|
||||
provide: (plugin) =>
|
||||
EditorView.atomicRanges.of((view) => {
|
||||
return view.plugin(plugin)?.placeholders || Decoration.none;
|
||||
}),
|
||||
},
|
||||
);
|
||||
};
|
||||
115
src-web/components/core/Editor/twig/templateTags.ts
Normal file
115
src-web/components/core/Editor/twig/templateTags.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import type { DecorationSet, ViewUpdate } from '@codemirror/view';
|
||||
import { Decoration, EditorView, ViewPlugin, WidgetType } from '@codemirror/view';
|
||||
import { truncate } from '../../../../lib/truncate';
|
||||
import { BetterMatchDecorator } from '../BetterMatchDecorator';
|
||||
import type { TwigCompletionOption } from './completion';
|
||||
|
||||
const TAG_TRUNCATE_LEN = 30;
|
||||
|
||||
class TemplateTagWidget extends WidgetType {
|
||||
readonly #clickListenerCallback: () => void;
|
||||
|
||||
constructor(
|
||||
readonly option: TwigCompletionOption,
|
||||
readonly rawTag: string,
|
||||
readonly startPos: number,
|
||||
) {
|
||||
super();
|
||||
this.#clickListenerCallback = () => {
|
||||
this.option.onClick?.(this.rawTag, this.startPos);
|
||||
};
|
||||
}
|
||||
|
||||
eq(other: TemplateTagWidget) {
|
||||
return (
|
||||
this.option.name === other.option.name &&
|
||||
this.option.type === other.option.type &&
|
||||
this.option.value === other.option.value &&
|
||||
this.rawTag === other.rawTag
|
||||
);
|
||||
}
|
||||
|
||||
toDOM() {
|
||||
const elt = document.createElement('span');
|
||||
elt.className = `x-theme-templateTag template-tag ${
|
||||
this.option.type === 'unknown'
|
||||
? 'x-theme-templateTag--danger'
|
||||
: this.option.type === 'variable'
|
||||
? 'x-theme-templateTag--primary'
|
||||
: 'x-theme-templateTag--info'
|
||||
}`;
|
||||
elt.title = this.option.type === 'unknown' ? '__NOT_FOUND__' : this.option.value ?? '';
|
||||
elt.textContent = truncate(
|
||||
this.rawTag.replace('${[', '').replace(']}', '').trim(),
|
||||
TAG_TRUNCATE_LEN,
|
||||
);
|
||||
elt.addEventListener('click', this.#clickListenerCallback);
|
||||
return elt;
|
||||
}
|
||||
|
||||
destroy(dom: HTMLElement) {
|
||||
dom.removeEventListener('click', this.#clickListenerCallback);
|
||||
super.destroy(dom);
|
||||
}
|
||||
|
||||
ignoreEvent() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function templateTags(options: TwigCompletionOption[]) {
|
||||
const templateTagMatcher = new BetterMatchDecorator({
|
||||
regexp: /\$\{\[\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 Decoration.replace({});
|
||||
}
|
||||
}
|
||||
|
||||
const innerTagMatch = match[1];
|
||||
if (innerTagMatch == null) {
|
||||
// Should never happen, but make TS happy
|
||||
console.warn('Group match was empty', match);
|
||||
return Decoration.replace({});
|
||||
}
|
||||
|
||||
// TODO: Replace this hacky match with a proper template parser
|
||||
const name = innerTagMatch.match(/\s*(\w+)[(\s]*/)?.[1] ?? innerTagMatch;
|
||||
|
||||
let option = options.find((v) => v.name === name);
|
||||
if (option == null) {
|
||||
option = { type: 'unknown', name: innerTagMatch, value: null, label: innerTagMatch };
|
||||
}
|
||||
|
||||
return Decoration.replace({
|
||||
inclusive: true,
|
||||
widget: new TemplateTagWidget(option, match[0], matchStartPos),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return ViewPlugin.fromClass(
|
||||
class {
|
||||
decorations: DecorationSet;
|
||||
|
||||
constructor(view: EditorView) {
|
||||
this.decorations = templateTagMatcher.createDeco(view);
|
||||
}
|
||||
|
||||
update(update: ViewUpdate) {
|
||||
this.decorations = templateTagMatcher.updateDeco(update, this.decorations);
|
||||
}
|
||||
},
|
||||
{
|
||||
decorations: (instance) => instance.decorations,
|
||||
provide: (plugin) =>
|
||||
EditorView.atomicRanges.of((view) => {
|
||||
return view.plugin(plugin)?.decorations || Decoration.none;
|
||||
}),
|
||||
},
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user