feat: Context menu for Nunjucks tag[INS-4273] (#7828)

* Initial check-in for tag context menu
* Remove useless codes
This commit is contained in:
Kent Wang
2024-08-12 17:49:16 +08:00
committed by GitHub
parent 6a8d7cdda6
commit 9fc1567bce
5 changed files with 186 additions and 51 deletions

View File

@@ -2,7 +2,7 @@ import type { IpcMainEvent, IpcMainInvokeEvent, MenuItemConstructorOptions, Open
import { app, BrowserWindow, clipboard, dialog, ipcMain, Menu, shell } from 'electron';
import { fnOrString } from '../../common/misc';
import type { NunjucksParsedTagArg } from '../../templating/utils';
import { extractNunjucksTagFromCoords, type NunjucksParsedTagArg, type NunjucksTagContextMenuAction } from '../../templating/utils';
import { localTemplateTags } from '../../ui/components/templating/local-template-tags';
import { invariant } from '../../utils/invariant';
@@ -111,50 +111,78 @@ const getTemplateValue = (arg: NunjucksParsedTagArg) => {
return arg.defaultValue;
};
export function registerElectronHandlers() {
ipcMainOn('show-context-menu', (event, options) => {
ipcMainOn('show-context-menu', (event, options: { key: string; nunjucksTag: ReturnType<typeof extractNunjucksTagFromCoords> }) => {
const { key, nunjucksTag } = options;
const sendNunjuckTagContextMsg = (type: NunjucksTagContextMenuAction) => {
event.sender.send('context-menu-command', { key, nunjucksTag: { ...nunjucksTag, type } });
};
try {
const template: MenuItemConstructorOptions[] = [
{
role: 'cut',
},
{
role: 'copy',
},
{
role: 'paste',
},
{ type: 'separator' },
...localTemplateTags
// sort alphabetically
.sort((a, b) => fnOrString(a.templateTag.displayName).localeCompare(fnOrString(b.templateTag.displayName)))
.map(l => {
const actions = l.templateTag.args?.[0];
const additionalArgs = l.templateTag.args?.slice(1);
const hasSubmenu = actions?.options?.length;
return {
label: fnOrString(l.templateTag.displayName),
...(!hasSubmenu ?
{
const baseTemplate: MenuItemConstructorOptions[] = nunjucksTag ?
[
{
label: 'Edit',
click: () => sendNunjuckTagContextMsg('edit'),
},
{
label: 'Copy',
click: () => {
clipboard.writeText(nunjucksTag.template);
},
},
{
label: 'Cut',
click: () => {
clipboard.writeText(nunjucksTag.template);
sendNunjuckTagContextMsg('delete');
},
},
{
label: 'Delete',
click: () => sendNunjuckTagContextMsg('delete'),
},
{ type: 'separator' },
] :
[
{
role: 'cut',
},
{
role: 'copy',
},
{
role: 'paste',
},
{ type: 'separator' },
];
const localTemplate: MenuItemConstructorOptions[] = localTemplateTags
// sort alphabetically
.sort((a, b) => fnOrString(a.templateTag.displayName).localeCompare(fnOrString(b.templateTag.displayName)))
.map(l => {
const actions = l.templateTag.args?.[0];
const additionalArgs = l.templateTag.args?.slice(1);
const hasSubmenu = actions?.options?.length;
return {
label: fnOrString(l.templateTag.displayName),
...(!hasSubmenu ?
{
click: () => {
const tag = `{% ${l.templateTag.name} ${l.templateTag.args?.map(getTemplateValue).join(', ')} %}`;
event.sender.send('context-menu-command', { key, tag });
},
} :
{
submenu: actions?.options?.map(action => ({
label: fnOrString(action.displayName),
click: () => {
const tag = `{% ${l.templateTag.name} ${l.templateTag.args?.map(getTemplateValue).join(', ')} %}`;
event.sender.send('context-menu-command', { key: options.key, tag });
const additionalTagFields = additionalArgs.length ? ', ' + additionalArgs.map(getTemplateValue).join(', ') : '';
const tag = `{% ${l.templateTag.name} '${action.value}'${additionalTagFields} %}`;
event.sender.send('context-menu-command', { key, tag });
},
} :
{
submenu: actions?.options?.map(action => ({
label: fnOrString(action.displayName),
click: () => {
const additionalTagFields = additionalArgs.length ? ', ' + additionalArgs.map(getTemplateValue).join(', ') : '';
const tag = `{% ${l.templateTag.name} '${action.value}'${additionalTagFields} %}`;
event.sender.send('context-menu-command', { key: options.key, tag });
},
})),
}),
};
}),
];
const menu = Menu.buildFromTemplate(template);
})),
}),
};
});
const menu = Menu.buildFromTemplate([...baseTemplate, ...localTemplate]);
const win = BrowserWindow.fromWebContents(event.sender);
invariant(win, 'expected window');
menu.popup({ window: win });

View File

@@ -1,4 +1,5 @@
import * as Sentry from '@sentry/electron/main';
import type { MarkerRange } from 'codemirror';
import { app, BrowserWindow, type IpcRendererEvent, shell } from 'electron';
import fs from 'fs';
@@ -37,7 +38,7 @@ export interface RendererToMainBridgeAPI {
curl: CurlBridgeAPI;
trackSegmentEvent: (options: { event: string; properties?: Record<string, unknown> }) => void;
trackPageView: (options: { name: string }) => void;
showContextMenu: (options: { key: string }) => void;
showContextMenu: (options: { key: string; nunjucksTag?: { template: string; range: MarkerRange } }) => void;
database: {
caCertificate: {
create: (options: { parentId: string; path: string }) => Promise<string>;

View File

@@ -1,3 +1,5 @@
import type { EditorFromTextArea, MarkerRange } from 'codemirror';
import type { DisplayName, PluginArgumentEnumOption, PluginTemplateTagActionContext } from './extensions';
import objectPath from './third_party/objectPath';
@@ -36,6 +38,8 @@ export interface NunjucksParsedTag {
disablePreview?: (arg0: NunjucksParsedTagArg[]) => boolean;
}
export type NunjucksTagContextMenuAction = 'edit' | 'delete';
interface Key {
name: string;
value: any;
@@ -284,3 +288,28 @@ export function extractVariableKey(text: string = '', line: number, column: numb
const res = errorText?.match(regexVariable);
return res?.[1] || '';
}
export function extractNunjucksTagFromCoords(
coordinates: { left: number; top: number },
cm: React.MutableRefObject<EditorFromTextArea | null>
): { range: MarkerRange; template: string } | void {
if (cm && cm.current) {
const { left, top } = coordinates;
// get position from left and right position
const textMarkerPos = cm.current.coordsChar({ left, top });
// get textMarker from position
const textMarker = cm.current?.getDoc().findMarksAt(textMarkerPos)[0];
if (textMarker) {
const range = textMarker.find() as MarkerRange;
return {
range,
// @ts-expect-error __template shoule be property of nunjucks tag markText
template: textMarker.__template || '',
};
}
}
}
export interface nunjucksTagContextMenuOptions extends Exclude<ReturnType<typeof extractNunjucksTagFromCoords>, void> {
type: NunjucksTagContextMenuAction;
}

View File

@@ -16,7 +16,7 @@ import { DEBOUNCE_MILLIS, isMac } from '../../../common/constants';
import * as misc from '../../../common/misc';
import type { KeyCombination } from '../../../common/settings';
import { getTagDefinitions } from '../../../templating/index';
import type { NunjucksParsedTag } from '../../../templating/utils';
import { extractNunjucksTagFromCoords, type NunjucksParsedTag, type nunjucksTagContextMenuOptions } from '../../../templating/utils';
import { jsonPrettify } from '../../../utils/prettify/json';
import { queryXPath } from '../../../utils/xpath/query';
import { useGatedNunjucks } from '../../context/nunjucks/use-gated-nunjucks';
@@ -26,6 +26,7 @@ import { Icon } from '../icon';
import { createKeybindingsHandler, useDocBodyKeyboardShortcuts } from '../keydown-binder';
import { FilterHelpModal } from '../modals/filter-help-modal';
import { showModal } from '../modals/index';
import { NunjucksModal } from '../modals/nunjucks-modal';
import { isKeyCombinationInRegistry } from '../settings/shortcuts';
import { normalizeIrregularWhitespace } from './normalizeIrregularWhitespace';
const TAB_SIZE = 4;
@@ -521,8 +522,34 @@ export const CodeEditor = memo(forwardRef<CodeEditorHandle, CodeEditorProps>(({
}
};
useEffect(() => {
const unsubscribe = window.main.on('context-menu-command', (_, { key, tag }) =>
id === key && codeMirror.current?.replaceSelection(tag));
const unsubscribe = window.main.on('context-menu-command', (_, { key, tag, nunjucksTag }) => {
if (id === key) {
if (nunjucksTag) {
const { type, template, range } = nunjucksTag as nunjucksTagContextMenuOptions;
switch (type) {
case 'edit':
showModal(NunjucksModal, {
template: template,
onDone: (template: string | null) => {
const { from, to } = range;
codeMirror.current?.replaceRange(template!, from, to);
},
});
return;
case 'delete':
const { from, to } = range;
codeMirror.current?.replaceRange('', from, to);
return;
default:
return;
};
} else {
codeMirror.current?.replaceSelection(tag);
}
}
});
return () => {
unsubscribe();
};
@@ -577,7 +604,18 @@ export const CodeEditor = memo(forwardRef<CodeEditorHandle, CodeEditorProps>(({
return;
}
event.preventDefault();
window.main.showContextMenu({ key: id });
const target = event.target as HTMLElement;
// right click on nunjucks tag
if (target?.classList?.contains('nunjucks-tag')) {
const { clientX, clientY } = event;
const nunjucksTag = extractNunjucksTagFromCoords({ left: clientX, top: clientY }, codeMirror);
if (nunjucksTag) {
// show context menu for nunjucks tag
window.main.showContextMenu({ key: id, nunjucksTag });
}
} else {
window.main.showContextMenu({ key: id });
}
}}
>
<div

View File

@@ -10,10 +10,12 @@ import { DEBOUNCE_MILLIS, isMac } from '../../../common/constants';
import * as misc from '../../../common/misc';
import type { KeyCombination } from '../../../common/settings';
import { getTagDefinitions } from '../../../templating/index';
import type { NunjucksParsedTag } from '../../../templating/utils';
import { extractNunjucksTagFromCoords, type NunjucksParsedTag, type nunjucksTagContextMenuOptions } from '../../../templating/utils';
import { useNunjucks } from '../../context/nunjucks/use-nunjucks';
import { useEditorRefresh } from '../../hooks/use-editor-refresh';
import { useRootLoaderData } from '../../routes/root';
import { showModal } from '../modals';
import { NunjucksModal } from '../modals/nunjucks-modal';
import { isKeyCombinationInRegistry } from '../settings/shortcuts';
export interface OneLineEditorProps {
defaultValue: string;
@@ -225,8 +227,34 @@ export const OneLineEditor = forwardRef<OneLineEditorHandle, OneLineEditorProps>
}, [onChange]);
useEffect(() => {
const unsubscribe = window.main.on('context-menu-command', (_, { key, tag }) =>
id === key && codeMirror.current?.replaceSelection(tag));
const unsubscribe = window.main.on('context-menu-command', (_, { key, tag, nunjucksTag }) => {
if (id === key) {
if (nunjucksTag) {
const { type, template, range } = nunjucksTag as nunjucksTagContextMenuOptions;
switch (type) {
case 'edit':
showModal(NunjucksModal, {
template: template,
onDone: (template: string | null) => {
const { from, to } = range;
codeMirror.current?.replaceRange(template!, from, to);
},
});
return;
case 'delete':
const { from, to } = range;
codeMirror.current?.replaceRange('', from, to);
return;
default:
return;
};
} else {
codeMirror.current?.replaceSelection(tag);
}
}
});
return () => {
unsubscribe();
};
@@ -255,7 +283,18 @@ export const OneLineEditor = forwardRef<OneLineEditorHandle, OneLineEditorProps>
return;
}
event.preventDefault();
window.main.showContextMenu({ key: id });
const target = event.target as HTMLElement;
// right click on nunjucks tag
if (target?.classList?.contains('nunjucks-tag')) {
const { clientX, clientY } = event;
const nunjucksTag = extractNunjucksTagFromCoords({ left: clientX, top: clientY }, codeMirror);
if (nunjucksTag) {
// show context menu for nunjucks tag
window.main.showContextMenu({ key: id, nunjucksTag });
}
} else {
window.main.showContextMenu({ key: id });
}
}}
>
<div className="editor__container input editor--single-line">