mirror of
https://github.com/go-gitea/gitea.git
synced 2026-04-02 08:24:18 +01:00
Replace Monaco with CodeMirror (#36764)
- Replace monaco-editor with CodeMirror 6 - Add `--color-syntax-*` CSS variables for all syntax token types, shared by CodeMirror, Chroma and EasyMDE - Consolidate chroma CSS into a single theme-independent file (`modules/chroma.css`) - Syntax colors in the code editor now match the code view and light/dark themes - Code editor is now 12px instead of 14px font size to match code view and GitHub - Use a global style for kbd elements - When editing existing files, focus will be on codemirror instead of filename input. - Keyboard shortcuts are roughtly the same as VSCode - Add a "Find" button, useful for mobile - Add context menu similar to Monaco - Add a command palette (Ctrl/Cmd+Shift+P or F1) or via button - Add clickable URLs via Ctrl/Cmd+click - Add e2e test for the code editor - Remove `window.codeEditors` global - The main missing Monaco features are hover types and semantic rename but these were not fully working because monaco operated only on single files and only for JS/TS/HTML/CSS/JSON. | | Monaco (main) | CodeMirror (cm) | Delta | |---|---|---|---| | **Build time** | 7.8s | 5.3s | **-32%** | | **JS output** | 25 MB | 14 MB | **-44%** | | **CSS output** | 1.2 MB | 1012 KB | **-17%** | | **Total (no maps)** | 23.3 MB | 12.1 MB | **-48%** | Fixes: #36311 Fixes: #14776 Fixes: #12171 <img width="1333" height="555" alt="image" src="https://github.com/user-attachments/assets/f0fe3a28-1ed9-4f22-bf25-2b161501d7ce" /> --------- Signed-off-by: silverwind <me@silverwind.io> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: wxiaoguang <wxiaoguang@gmail.com> Co-authored-by: Giteabot <teabot@gitea.io> Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>
This commit is contained in:
216
web_src/js/modules/codeeditor/command-palette.ts
Normal file
216
web_src/js/modules/codeeditor/command-palette.ts
Normal file
@@ -0,0 +1,216 @@
|
||||
import {isMac, keySymbols} from '../../utils.ts';
|
||||
import {trimTrailingWhitespaceFromView} from './utils.ts';
|
||||
import type {EditorView} from '@codemirror/view';
|
||||
import type {CodemirrorModules} from './main.ts';
|
||||
|
||||
export type PaletteCommand = {
|
||||
label: string;
|
||||
keys: string;
|
||||
run: (view: EditorView) => void;
|
||||
};
|
||||
|
||||
function formatKeys(keys: string): string[][] {
|
||||
return keys.split(' ').map((chord) => chord.split('+').map((k) => keySymbols[k] || k));
|
||||
}
|
||||
|
||||
export function commandPalette(cm: CodemirrorModules) {
|
||||
const commands: PaletteCommand[] = [
|
||||
{label: 'Undo', keys: 'Mod+Z', run: cm.commands.undo},
|
||||
{label: 'Redo', keys: 'Mod+Shift+Z', run: cm.commands.redo},
|
||||
{label: 'Find', keys: 'Mod+F', run: cm.search.openSearchPanel},
|
||||
{label: 'Go to line', keys: 'Mod+Alt+G', run: cm.search.gotoLine},
|
||||
{label: 'Select All', keys: 'Mod+A', run: cm.commands.selectAll},
|
||||
{label: 'Delete Line', keys: 'Mod+Shift+K', run: cm.commands.deleteLine},
|
||||
{label: 'Move Line Up', keys: 'Alt+Up', run: cm.commands.moveLineUp},
|
||||
{label: 'Move Line Down', keys: 'Alt+Down', run: cm.commands.moveLineDown},
|
||||
{label: 'Copy Line Up', keys: 'Shift+Alt+Up', run: cm.commands.copyLineUp},
|
||||
{label: 'Copy Line Down', keys: 'Shift+Alt+Down', run: cm.commands.copyLineDown},
|
||||
{label: 'Toggle Comment', keys: 'Mod+/', run: cm.commands.toggleComment},
|
||||
{label: 'Insert Blank Line', keys: 'Mod+Enter', run: cm.commands.insertBlankLine},
|
||||
{label: 'Add Cursor Above', keys: isMac ? 'Mod+Alt+Up' : 'Ctrl+Alt+Up', run: cm.commands.addCursorAbove},
|
||||
{label: 'Add Cursor Below', keys: isMac ? 'Mod+Alt+Down' : 'Ctrl+Alt+Down', run: cm.commands.addCursorBelow},
|
||||
{label: 'Add Next Occurrence', keys: 'Mod+D', run: cm.search.selectNextOccurrence},
|
||||
{label: 'Go to Matching Bracket', keys: 'Mod+Shift+\\', run: cm.commands.cursorMatchingBracket},
|
||||
{label: 'Indent More', keys: 'Mod+]', run: cm.commands.indentMore},
|
||||
{label: 'Indent Less', keys: 'Mod+[', run: cm.commands.indentLess},
|
||||
{label: 'Fold Code', keys: isMac ? 'Mod+Alt+[' : 'Ctrl+Shift+[', run: cm.language.foldCode},
|
||||
{label: 'Unfold Code', keys: isMac ? 'Mod+Alt+]' : 'Ctrl+Shift+]', run: cm.language.unfoldCode},
|
||||
{label: 'Fold All', keys: 'Ctrl+Alt+[', run: cm.language.foldAll},
|
||||
{label: 'Unfold All', keys: 'Ctrl+Alt+]', run: cm.language.unfoldAll},
|
||||
{label: 'Trigger Autocomplete', keys: 'Ctrl+Space', run: cm.autocomplete.startCompletion},
|
||||
{label: 'Trim Trailing Whitespace', keys: 'Mod+K Mod+X', run: trimTrailingWhitespaceFromView},
|
||||
];
|
||||
|
||||
let overlay: HTMLElement | null = null;
|
||||
let filtered: PaletteCommand[] = [];
|
||||
let selectedIndex = 0;
|
||||
let cleanupClickOutside: (() => void) | null = null;
|
||||
|
||||
function hide(view: EditorView) {
|
||||
if (!overlay) return;
|
||||
cleanupClickOutside?.();
|
||||
cleanupClickOutside = null;
|
||||
overlay.remove();
|
||||
overlay = null;
|
||||
view.focus();
|
||||
}
|
||||
|
||||
function renderList(list: HTMLElement, query: string) {
|
||||
list.textContent = '';
|
||||
if (!filtered.length) {
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'cm-command-palette-empty';
|
||||
empty.textContent = 'No matches';
|
||||
list.append(empty);
|
||||
return;
|
||||
}
|
||||
for (const [index, cmd] of filtered.entries()) {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'cm-command-palette-item';
|
||||
item.setAttribute('role', 'option');
|
||||
item.setAttribute('data-index', String(index));
|
||||
if (index === selectedIndex) item.setAttribute('aria-selected', 'true');
|
||||
|
||||
const label = document.createElement('span');
|
||||
label.className = 'cm-command-palette-label';
|
||||
const matchIndex = query ? cmd.label.toLowerCase().indexOf(query) : -1;
|
||||
if (matchIndex !== -1) {
|
||||
label.append(cmd.label.slice(0, matchIndex));
|
||||
const mark = document.createElement('mark');
|
||||
mark.textContent = cmd.label.slice(matchIndex, matchIndex + query.length);
|
||||
label.append(mark, cmd.label.slice(matchIndex + query.length));
|
||||
} else {
|
||||
label.textContent = cmd.label;
|
||||
}
|
||||
item.append(label);
|
||||
|
||||
if (cmd.keys) {
|
||||
const keysEl = document.createElement('span');
|
||||
keysEl.className = 'cm-command-palette-keys';
|
||||
for (const [chordIndex, chord] of formatKeys(cmd.keys).entries()) {
|
||||
if (chordIndex > 0) keysEl.append('→');
|
||||
for (const k of chord) {
|
||||
const kbd = document.createElement('kbd');
|
||||
kbd.textContent = k;
|
||||
keysEl.append(kbd);
|
||||
}
|
||||
}
|
||||
item.append(keysEl);
|
||||
}
|
||||
list.append(item);
|
||||
}
|
||||
}
|
||||
|
||||
function show(view: EditorView, items?: PaletteCommand[], placeholder?: string) {
|
||||
const container = view.dom.closest('.code-editor-container')!;
|
||||
overlay = document.createElement('div');
|
||||
overlay.className = 'cm-command-palette';
|
||||
|
||||
const input = document.createElement('input');
|
||||
input.className = 'cm-command-palette-input';
|
||||
input.placeholder = placeholder || 'Type a command...';
|
||||
|
||||
const list = document.createElement('div');
|
||||
list.className = 'cm-command-palette-list';
|
||||
list.setAttribute('role', 'listbox');
|
||||
|
||||
const source = items || commands;
|
||||
filtered = source;
|
||||
selectedIndex = 0;
|
||||
|
||||
const updateSelected = () => {
|
||||
list.querySelector('[aria-selected]')?.removeAttribute('aria-selected');
|
||||
const el = list.children[selectedIndex] as HTMLElement | undefined;
|
||||
if (el) {
|
||||
el.setAttribute('aria-selected', 'true');
|
||||
if (el.offsetTop < list.scrollTop) {
|
||||
list.scrollTop = el.offsetTop;
|
||||
} else if (el.offsetTop + el.offsetHeight > list.scrollTop + list.clientHeight) {
|
||||
list.scrollTop = el.offsetTop + el.offsetHeight - list.clientHeight;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const execute = (cmd: PaletteCommand) => {
|
||||
hide(view);
|
||||
cmd.run(view);
|
||||
};
|
||||
|
||||
list.addEventListener('pointerover', (e) => {
|
||||
const item = (e.target as Element).closest<HTMLElement>('.cm-command-palette-item');
|
||||
if (!item) return;
|
||||
selectedIndex = Number(item.getAttribute('data-index'));
|
||||
updateSelected();
|
||||
});
|
||||
|
||||
list.addEventListener('mousedown', (e) => {
|
||||
const item = (e.target as Element).closest<HTMLElement>('.cm-command-palette-item');
|
||||
if (!item) return;
|
||||
e.preventDefault();
|
||||
const cmd = filtered[Number(item.getAttribute('data-index'))];
|
||||
if (cmd) execute(cmd);
|
||||
});
|
||||
|
||||
input.addEventListener('input', () => {
|
||||
const q = input.value.toLowerCase();
|
||||
filtered = q ? source.filter((cmd) => cmd.label.toLowerCase().includes(q)) : source;
|
||||
selectedIndex = 0;
|
||||
renderList(list, q);
|
||||
});
|
||||
|
||||
input.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
selectedIndex = (selectedIndex + 1) % filtered.length;
|
||||
updateSelected();
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
selectedIndex = (selectedIndex - 1 + filtered.length) % filtered.length;
|
||||
updateSelected();
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if (filtered[selectedIndex]) execute(filtered[selectedIndex]);
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
hide(view);
|
||||
}
|
||||
});
|
||||
|
||||
overlay.append(input, list);
|
||||
container.append(overlay);
|
||||
renderList(list, '');
|
||||
input.focus();
|
||||
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
const target = e.target as Element;
|
||||
if (overlay && !overlay.contains(target) && !target.closest('.js-code-command-palette')) {
|
||||
hide(view);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
cleanupClickOutside = () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}
|
||||
|
||||
function showWithItems(view: EditorView, items: PaletteCommand[], placeholder: string) {
|
||||
if (overlay) hide(view);
|
||||
show(view, items, placeholder);
|
||||
}
|
||||
|
||||
function togglePalette(view: EditorView) {
|
||||
if (overlay) {
|
||||
hide(view);
|
||||
} else {
|
||||
show(view);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return {
|
||||
extensions: cm.view.keymap.of([
|
||||
{key: 'Mod-Shift-p', run: togglePalette, preventDefault: true},
|
||||
{key: 'F1', run: togglePalette, preventDefault: true},
|
||||
]),
|
||||
togglePalette,
|
||||
showWithItems,
|
||||
};
|
||||
}
|
||||
249
web_src/js/modules/codeeditor/context-menu.ts
Normal file
249
web_src/js/modules/codeeditor/context-menu.ts
Normal file
@@ -0,0 +1,249 @@
|
||||
import {clippie} from 'clippie';
|
||||
import {createTippy} from '../tippy.ts';
|
||||
import {keySymbols} from '../../utils.ts';
|
||||
import {goToDefinitionAt} from './utils.ts';
|
||||
import type {Instance} from 'tippy.js';
|
||||
import type {EditorView} from '@codemirror/view';
|
||||
import type {CodemirrorModules} from './main.ts';
|
||||
|
||||
type MenuItem = {
|
||||
label: string;
|
||||
keys?: string;
|
||||
disabled?: boolean;
|
||||
run: (view: EditorView) => void | Promise<void>;
|
||||
} | 'separator';
|
||||
|
||||
/** Get the word at cursor, or selected text. Checks adjacent positions when cursor is on a non-word char. */
|
||||
export function getWordAtPosition(view: EditorView, from: number, to: number): string {
|
||||
if (from !== to) return view.state.doc.sliceString(from, to);
|
||||
for (const pos of [from, from - 1, from + 1]) {
|
||||
const range = view.state.wordAt(pos);
|
||||
if (range) return view.state.doc.sliceString(range.from, range.to);
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/** Select all occurrences of the word at cursor for multi-cursor editing. */
|
||||
export function selectAllOccurrences(cm: CodemirrorModules, view: EditorView) {
|
||||
const {from, to} = view.state.selection.main;
|
||||
const word = getWordAtPosition(view, from, to);
|
||||
if (!word) return;
|
||||
const ranges = [];
|
||||
let main = 0;
|
||||
const cursor = new cm.search.SearchCursor(view.state.doc, word);
|
||||
while (!cursor.done) {
|
||||
cursor.next();
|
||||
if (cursor.done) break;
|
||||
if (cursor.value.from <= from && cursor.value.to >= from) main = ranges.length;
|
||||
ranges.push(cm.state.EditorSelection.range(cursor.value.from, cursor.value.to));
|
||||
}
|
||||
if (ranges.length) {
|
||||
view.dispatch({selection: cm.state.EditorSelection.create(ranges, main)});
|
||||
}
|
||||
}
|
||||
|
||||
/** Collect symbol definitions from the Lezer syntax tree. */
|
||||
export function collectSymbols(cm: CodemirrorModules, view: EditorView): {label: string; kind: string; from: number}[] {
|
||||
const tree = cm.language.syntaxTree(view.state);
|
||||
const symbols: {label: string; kind: string; from: number}[] = [];
|
||||
const seen = new Set<number>(); // track by position to avoid O(n²) dedup
|
||||
const addSymbol = (label: string, kind: string, from: number) => {
|
||||
if (!seen.has(from)) {
|
||||
seen.add(from);
|
||||
symbols.push({label, kind, from});
|
||||
}
|
||||
};
|
||||
tree.iterate({
|
||||
enter(node): false | void {
|
||||
if (node.name === 'VariableDefinition' || node.name === 'DefName') {
|
||||
addSymbol(view.state.doc.sliceString(node.from, node.to), 'variable', node.from);
|
||||
} else if (node.name === 'FunctionDeclaration' || node.name === 'FunctionDecl' || node.name === 'ClassDeclaration') {
|
||||
const nameNode = node.node.getChild('VariableDefinition') || node.node.getChild('DefName');
|
||||
if (nameNode) {
|
||||
const kind = node.name === 'ClassDeclaration' ? 'class' : 'function';
|
||||
addSymbol(view.state.doc.sliceString(nameNode.from, nameNode.to), kind, nameNode.from);
|
||||
}
|
||||
return false;
|
||||
} else if (node.name === 'MethodDeclaration' || node.name === 'MethodDecl' || node.name === 'PropertyDefinition') {
|
||||
const nameNode = node.node.getChild('PropertyDefinition') || node.node.getChild('PropertyName') || node.node.getChild('DefName');
|
||||
if (nameNode) {
|
||||
addSymbol(view.state.doc.sliceString(nameNode.from, nameNode.to), node.name === 'PropertyDefinition' ? 'property' : 'method', nameNode.from);
|
||||
}
|
||||
} else if (node.name === 'TypeDecl' || node.name === 'TypeSpec') {
|
||||
const nameNode = node.node.getChild('DefName');
|
||||
if (nameNode) {
|
||||
addSymbol(view.state.doc.sliceString(nameNode.from, nameNode.to), 'type', nameNode.from);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
return symbols;
|
||||
}
|
||||
|
||||
function buildMenuItems(cm: CodemirrorModules, view: EditorView, togglePalette: (view: EditorView) => boolean, goToSymbol: (view: EditorView) => void): MenuItem[] {
|
||||
const {from, to} = view.state.selection.main;
|
||||
const hasSelection = from !== to;
|
||||
// Check if cursor is on a symbol that has a definition
|
||||
const tree = cm.language.syntaxTree(view.state);
|
||||
const nodeAtCursor = tree.resolveInner(from, 1);
|
||||
const hasDefinition = nodeAtCursor?.name === 'VariableName';
|
||||
const hasWord = Boolean(getWordAtPosition(view, from, to));
|
||||
return [
|
||||
{label: 'Go to Definition', keys: 'F12', disabled: !hasDefinition, run: (v) => { goToDefinitionAt(cm, v, v.state.selection.main.from) }},
|
||||
{label: 'Go to Symbol…', keys: 'Mod+Shift+O', run: goToSymbol},
|
||||
{label: 'Change All Occurrences', keys: 'Mod+F2', disabled: !hasWord, run: (v) => selectAllOccurrences(cm, v)},
|
||||
'separator',
|
||||
{label: 'Cut', keys: 'Mod+X', disabled: !hasSelection, run: async (v) => {
|
||||
const {from, to} = v.state.selection.main;
|
||||
if (await clippie(v.state.doc.sliceString(from, to))) {
|
||||
v.dispatch({changes: {from, to}});
|
||||
}
|
||||
}},
|
||||
{label: 'Copy', keys: 'Mod+C', disabled: !hasSelection, run: async (v) => {
|
||||
const {from, to} = v.state.selection.main;
|
||||
await clippie(v.state.doc.sliceString(from, to));
|
||||
}},
|
||||
{label: 'Paste', keys: 'Mod+V', run: async (view) => {
|
||||
try {
|
||||
const text = await navigator.clipboard.readText();
|
||||
view.dispatch(view.state.replaceSelection(text));
|
||||
} catch { /* clipboard permission denied */ }
|
||||
}},
|
||||
'separator',
|
||||
{label: 'Command Palette', keys: 'F1', run: (v) => { togglePalette(v) }},
|
||||
];
|
||||
}
|
||||
|
||||
type MenuResult = {el: HTMLElement; actions: ((() => void) | null)[]};
|
||||
|
||||
function createMenuElement(items: MenuItem[], view: EditorView, onAction: () => void): MenuResult {
|
||||
const menu = document.createElement('div');
|
||||
menu.className = 'cm-context-menu';
|
||||
const actions: ((() => void) | null)[] = [];
|
||||
for (const item of items) {
|
||||
if (item === 'separator') {
|
||||
const sep = document.createElement('div');
|
||||
sep.className = 'cm-context-menu-separator';
|
||||
menu.append(sep);
|
||||
continue;
|
||||
}
|
||||
const row = document.createElement('div');
|
||||
row.className = `item${item.disabled ? ' disabled' : ''}`;
|
||||
if (item.disabled) row.setAttribute('aria-disabled', 'true');
|
||||
const label = document.createElement('span');
|
||||
label.className = 'cm-context-menu-label';
|
||||
label.textContent = item.label;
|
||||
row.append(label);
|
||||
if (item.keys) {
|
||||
const keysEl = document.createElement('span');
|
||||
keysEl.className = 'cm-context-menu-keys';
|
||||
for (const key of item.keys.split('+')) {
|
||||
const kbd = document.createElement('kbd');
|
||||
kbd.textContent = keySymbols[key] || key;
|
||||
keysEl.append(kbd);
|
||||
}
|
||||
row.append(keysEl);
|
||||
}
|
||||
const execute = item.disabled ? null : () => { onAction(); item.run(view) };
|
||||
if (execute) {
|
||||
row.addEventListener('mousedown', (e) => { e.preventDefault(); e.stopPropagation(); execute() });
|
||||
}
|
||||
actions.push(execute);
|
||||
menu.append(row);
|
||||
}
|
||||
return {el: menu, actions};
|
||||
}
|
||||
|
||||
export function contextMenu(cm: CodemirrorModules, togglePalette: (view: EditorView) => boolean, goToSymbol: (view: EditorView) => void) {
|
||||
let instance: Instance | null = null;
|
||||
|
||||
function hideMenu() {
|
||||
if (instance) {
|
||||
instance.destroy();
|
||||
instance = null;
|
||||
}
|
||||
}
|
||||
|
||||
return cm.view.EditorView.domEventHandlers({
|
||||
contextmenu(event: MouseEvent, view: EditorView) {
|
||||
event.preventDefault();
|
||||
hideMenu();
|
||||
|
||||
// Place cursor at right-click position if not inside a selection
|
||||
const pos = view.posAtCoords({x: event.clientX, y: event.clientY});
|
||||
if (pos !== null) {
|
||||
const {from, to} = view.state.selection.main;
|
||||
if (pos < from || pos > to) {
|
||||
view.dispatch({selection: {anchor: pos}});
|
||||
}
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const dismiss = () => {
|
||||
controller.abort();
|
||||
hideMenu();
|
||||
};
|
||||
|
||||
const menuItems = buildMenuItems(cm, view, togglePalette, goToSymbol);
|
||||
const {el: menuEl, actions} = createMenuElement(menuItems, view, dismiss);
|
||||
|
||||
// Create a virtual anchor at mouse position for tippy
|
||||
const anchor = document.createElement('div');
|
||||
anchor.style.position = 'fixed';
|
||||
anchor.style.left = `${event.clientX}px`;
|
||||
anchor.style.top = `${event.clientY}px`;
|
||||
document.body.append(anchor);
|
||||
|
||||
instance = createTippy(anchor, {
|
||||
content: menuEl,
|
||||
theme: 'menu',
|
||||
trigger: 'manual',
|
||||
placement: 'bottom-start',
|
||||
interactive: true,
|
||||
arrow: false,
|
||||
offset: [0, 0],
|
||||
showOnCreate: true,
|
||||
onHidden: () => {
|
||||
anchor.remove();
|
||||
instance = null;
|
||||
},
|
||||
});
|
||||
const rows = menuEl.querySelectorAll<HTMLElement>('.item');
|
||||
let focusIndex = -1;
|
||||
const setFocus = (idx: number) => {
|
||||
focusIndex = idx;
|
||||
for (const [rowIdx, el] of rows.entries()) {
|
||||
el.classList.toggle('active', rowIdx === focusIndex);
|
||||
}
|
||||
};
|
||||
const nextEnabled = (from: number, dir: number) => {
|
||||
for (let step = 1; step <= actions.length; step++) {
|
||||
const idx = (from + dir * step + actions.length) % actions.length;
|
||||
if (actions[idx]) return idx;
|
||||
}
|
||||
return from;
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', (e: MouseEvent) => {
|
||||
if (!menuEl.contains(e.target as Element)) dismiss();
|
||||
}, {signal: controller.signal});
|
||||
document.addEventListener('keydown', (e: KeyboardEvent) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
if (e.key === 'Escape') {
|
||||
dismiss(); view.focus();
|
||||
} else if (e.key === 'ArrowDown') {
|
||||
setFocus(nextEnabled(focusIndex, 1));
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
setFocus(nextEnabled(focusIndex, -1));
|
||||
} else if (e.key === 'Enter' && focusIndex >= 0 && actions[focusIndex]) {
|
||||
actions[focusIndex]!();
|
||||
}
|
||||
}, {signal: controller.signal, capture: true});
|
||||
view.scrollDOM.addEventListener('scroll', dismiss, {signal: controller.signal, once: true});
|
||||
document.addEventListener('scroll', dismiss, {signal: controller.signal, once: true});
|
||||
window.addEventListener('blur', dismiss, {signal: controller.signal});
|
||||
window.addEventListener('resize', dismiss, {signal: controller.signal});
|
||||
},
|
||||
});
|
||||
}
|
||||
39
web_src/js/modules/codeeditor/linter.ts
Normal file
39
web_src/js/modules/codeeditor/linter.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import type {CodemirrorModules} from './main.ts';
|
||||
import type {Extension} from '@codemirror/state';
|
||||
|
||||
/** Creates a linter for JSON files using `jsonParseLinter` from `@codemirror/lang-json`. */
|
||||
export async function createJsonLinter(cm: CodemirrorModules): Promise<Extension> {
|
||||
const {jsonParseLinter} = await import(/* webpackChunkName: "codemirror" */ '@codemirror/lang-json');
|
||||
const baseLinter = jsonParseLinter();
|
||||
return cm.lint.linter((view) => {
|
||||
return baseLinter(view).map((d) => {
|
||||
if (d.from === d.to) {
|
||||
const line = view.state.doc.lineAt(d.from);
|
||||
// expand to end of line content, or at least 1 char
|
||||
d.to = Math.min(Math.max(d.from + 1, line.to), view.state.doc.length);
|
||||
}
|
||||
return d;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** Creates a generic linter that detects Lezer parse-tree error nodes. */
|
||||
export function createSyntaxErrorLinter(cm: CodemirrorModules): Extension {
|
||||
return cm.lint.linter((view) => {
|
||||
const diagnostics: {from: number, to: number, severity: 'error', message: string}[] = [];
|
||||
const tree = cm.language.syntaxTree(view.state);
|
||||
tree.iterate({
|
||||
enter(node) {
|
||||
if (node.type.isError) {
|
||||
diagnostics.push({
|
||||
from: node.from,
|
||||
to: node.to === node.from ? Math.min(node.from + 1, view.state.doc.length) : node.to,
|
||||
severity: 'error',
|
||||
message: 'Syntax error',
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
return diagnostics;
|
||||
});
|
||||
}
|
||||
330
web_src/js/modules/codeeditor/main.ts
Normal file
330
web_src/js/modules/codeeditor/main.ts
Normal file
@@ -0,0 +1,330 @@
|
||||
import {extname} from '../../utils.ts';
|
||||
import {createElementFromHTML, toggleElem} from '../../utils/dom.ts';
|
||||
import {html, htmlRaw} from '../../utils/html.ts';
|
||||
import {svg} from '../../svg.ts';
|
||||
import {commandPalette} from './command-palette.ts';
|
||||
import type {PaletteCommand} from './command-palette.ts';
|
||||
import {contextMenu, collectSymbols, selectAllOccurrences} from './context-menu.ts';
|
||||
import {createJsonLinter, createSyntaxErrorLinter} from './linter.ts';
|
||||
import {clickableUrls, goToDefinitionAt, trimTrailingWhitespaceFromView} from './utils.ts';
|
||||
import type {LanguageDescription} from '@codemirror/language';
|
||||
import type {Compartment, Extension} from '@codemirror/state';
|
||||
import type {EditorView, ViewUpdate} from '@codemirror/view';
|
||||
|
||||
// CodeEditorConfig is also used by backend, defined in "editor_util.go"
|
||||
const codeEditorConfigDefault = {
|
||||
filename: '', // the current filename (base name, not full path), used for language detection
|
||||
autofocus: false, // whether to autofocus the editor on load
|
||||
previewableExtensions: [] as string[], // file extensions that support preview rendering
|
||||
lineWrapExtensions: [] as string[], // file extensions that enable line wrapping by default
|
||||
lineWrap: false, // whether line wrapping is enabled for the current file
|
||||
|
||||
indentStyle: '', // "space" or "tab", from .editorconfig, or empty for not specified (detect from source code)
|
||||
indentSize: 0, // number of spaces per indent level, from .editorconfig, or 0 for not specified (detect from source code)
|
||||
tabWidth: 4, // display width of a tab character, from .editorconfig, defaults to 4
|
||||
trimTrailingWhitespace: false, // whether to trim trailing whitespace on save, from .editorconfig
|
||||
};
|
||||
type CodeEditorConfig = typeof codeEditorConfigDefault;
|
||||
|
||||
export type CodemirrorEditor = {
|
||||
view: EditorView;
|
||||
trimTrailingWhitespace: boolean;
|
||||
togglePalette: (view: EditorView) => boolean;
|
||||
updateFilename: (filename: string) => Promise<void>;
|
||||
languages: LanguageDescription[];
|
||||
compartments: {
|
||||
wordWrap: Compartment;
|
||||
language: Compartment;
|
||||
tabSize: Compartment;
|
||||
indentUnit: Compartment;
|
||||
lint: Compartment;
|
||||
};
|
||||
};
|
||||
|
||||
export type CodemirrorModules = Awaited<ReturnType<typeof importCodemirror>>;
|
||||
|
||||
async function importCodemirror() {
|
||||
const [autocomplete, commands, language, languageData, lint, search, state, view, highlight, indentMarkers, vscodeKeymap] = await Promise.all([
|
||||
import(/* webpackChunkName: "codemirror" */ '@codemirror/autocomplete'),
|
||||
import(/* webpackChunkName: "codemirror" */ '@codemirror/commands'),
|
||||
import(/* webpackChunkName: "codemirror" */ '@codemirror/language'),
|
||||
import(/* webpackChunkName: "codemirror" */ '@codemirror/language-data'),
|
||||
import(/* webpackChunkName: "codemirror" */ '@codemirror/lint'),
|
||||
import(/* webpackChunkName: "codemirror" */ '@codemirror/search'),
|
||||
import(/* webpackChunkName: "codemirror" */ '@codemirror/state'),
|
||||
import(/* webpackChunkName: "codemirror" */ '@codemirror/view'),
|
||||
import(/* webpackChunkName: "codemirror" */ '@lezer/highlight'),
|
||||
import(/* webpackChunkName: "codemirror" */ '@replit/codemirror-indentation-markers'),
|
||||
import(/* webpackChunkName: "codemirror" */ '@replit/codemirror-vscode-keymap'),
|
||||
]);
|
||||
return {autocomplete, commands, language, languageData, lint, search, state, view, highlight, indentMarkers, vscodeKeymap};
|
||||
}
|
||||
|
||||
function togglePreviewDisplay(previewable: boolean): void {
|
||||
// FIXME: here and below, the selector is too broad, it should only query in the editor related scope
|
||||
const previewTab = document.querySelector<HTMLElement>('a[data-tab="preview"]');
|
||||
// the "preview tab" exists for "file code editor", but doesn't exist for "git hook editor"
|
||||
if (!previewTab) return;
|
||||
|
||||
toggleElem(previewTab, previewable);
|
||||
if (previewable) return;
|
||||
|
||||
// If not previewable but the "preview" tab was active (user changes the filename to a non-previewable one),
|
||||
// then the "preview" tab becomes inactive (hidden), so the "write" tab should become active
|
||||
if (previewTab.classList.contains('active')) {
|
||||
const writeTab = document.querySelector<HTMLElement>('a[data-tab="write"]');
|
||||
writeTab!.click();
|
||||
}
|
||||
}
|
||||
|
||||
export async function createCodeEditor(textarea: HTMLTextAreaElement, filenameInput?: HTMLInputElement): Promise<CodemirrorEditor> {
|
||||
const config: CodeEditorConfig = {
|
||||
...codeEditorConfigDefault,
|
||||
...JSON.parse(textarea.getAttribute('data-code-editor-config')!),
|
||||
};
|
||||
const previewableExts = new Set(config.previewableExtensions || []);
|
||||
const lineWrapExts = config.lineWrapExtensions || [];
|
||||
const cm = await importCodemirror();
|
||||
|
||||
const languageDescriptions: LanguageDescription[] = [
|
||||
...cm.languageData.languages.filter((l: LanguageDescription) => l.name !== 'Markdown'),
|
||||
cm.language.LanguageDescription.of({
|
||||
name: 'Markdown', extensions: ['md', 'markdown', 'mkd'],
|
||||
load: async () => (await import('@codemirror/lang-markdown')).markdown({codeLanguages: languageDescriptions}),
|
||||
}),
|
||||
cm.language.LanguageDescription.of({
|
||||
name: 'Elixir', extensions: ['ex', 'exs'],
|
||||
load: async () => (await import('codemirror-lang-elixir')).elixir(),
|
||||
}),
|
||||
cm.language.LanguageDescription.of({
|
||||
name: 'Nix', extensions: ['nix'],
|
||||
load: async () => (await import('@replit/codemirror-lang-nix')).nix(),
|
||||
}),
|
||||
cm.language.LanguageDescription.of({
|
||||
name: 'Svelte', extensions: ['svelte'],
|
||||
load: async () => (await import('@replit/codemirror-lang-svelte')).svelte(),
|
||||
}),
|
||||
cm.language.LanguageDescription.of({
|
||||
name: 'Makefile', filename: /^(GNUm|M|m)akefile$/,
|
||||
load: async () => new cm.language.LanguageSupport(cm.language.StreamLanguage.define((await import('@codemirror/legacy-modes/mode/shell')).shell)),
|
||||
}),
|
||||
cm.language.LanguageDescription.of({
|
||||
name: 'Dotenv', extensions: ['env'], filename: /^\.env(\..*)?$/,
|
||||
load: async () => new cm.language.LanguageSupport(cm.language.StreamLanguage.define((await import('@codemirror/legacy-modes/mode/shell')).shell)),
|
||||
}),
|
||||
cm.language.LanguageDescription.of({
|
||||
name: 'JSON5', extensions: ['json5', 'jsonc'],
|
||||
load: async () => (await import('@codemirror/lang-json')).json(),
|
||||
}),
|
||||
];
|
||||
const matchedLang = cm.language.LanguageDescription.matchFilename(languageDescriptions, config.filename);
|
||||
|
||||
const container = document.createElement('div');
|
||||
container.className = 'code-editor-container';
|
||||
container.setAttribute('data-language', matchedLang?.name.toLowerCase() || '');
|
||||
// Replace the loading placeholder with the editor container in one operation
|
||||
// to avoid a flash where neither element is in the DOM.
|
||||
const loading = textarea.parentNode!.querySelector<HTMLElement>('.editor-loading');
|
||||
if (loading) {
|
||||
loading.replaceWith(container);
|
||||
} else {
|
||||
textarea.parentNode!.append(container);
|
||||
}
|
||||
|
||||
const loadedLang = matchedLang ? await matchedLang.load() : null;
|
||||
const wordWrap = new cm.state.Compartment();
|
||||
const language = new cm.state.Compartment();
|
||||
const tabSize = new cm.state.Compartment();
|
||||
const indentUnitComp = new cm.state.Compartment();
|
||||
const lintComp = new cm.state.Compartment();
|
||||
const palette = commandPalette(cm);
|
||||
|
||||
const goToSymbol = (view: EditorView) => {
|
||||
const symbols = collectSymbols(cm, view);
|
||||
const items: PaletteCommand[] = symbols.map((sym) => ({
|
||||
label: `${sym.label} (${sym.kind})`,
|
||||
keys: '',
|
||||
run: (v: EditorView) => v.dispatch({selection: {anchor: sym.from}, scrollIntoView: true}),
|
||||
}));
|
||||
palette.showWithItems(view, items, 'Go to symbol…');
|
||||
return true;
|
||||
};
|
||||
|
||||
const view = new cm.view.EditorView({
|
||||
doc: textarea.defaultValue, // use defaultValue to prevent browser from restoring form values on refresh
|
||||
parent: container,
|
||||
extensions: [
|
||||
cm.view.lineNumbers(),
|
||||
cm.language.codeFolding({
|
||||
placeholderDOM(_view: EditorView, onclick: (event: Event) => void) {
|
||||
const el = createElementFromHTML(html`<span class="cm-foldPlaceholder">${htmlRaw(svg('octicon-kebab-horizontal', 13))}</span>`);
|
||||
el.addEventListener('click', onclick);
|
||||
return el as unknown as HTMLElement;
|
||||
},
|
||||
}),
|
||||
cm.language.foldGutter({
|
||||
markerDOM(open: boolean) {
|
||||
return createElementFromHTML(svg(open ? 'octicon-chevron-down' : 'octicon-chevron-right', 13));
|
||||
},
|
||||
}),
|
||||
cm.view.highlightActiveLineGutter(),
|
||||
cm.view.highlightSpecialChars(),
|
||||
cm.view.highlightActiveLine(),
|
||||
cm.view.drawSelection(),
|
||||
cm.view.dropCursor(),
|
||||
cm.view.rectangularSelection(),
|
||||
cm.view.crosshairCursor(),
|
||||
cm.view.placeholder(textarea.placeholder),
|
||||
config.trimTrailingWhitespace ? cm.view.highlightTrailingWhitespace() : [],
|
||||
cm.search.search({top: true}),
|
||||
cm.search.highlightSelectionMatches(),
|
||||
cm.view.keymap.of([
|
||||
...cm.vscodeKeymap.vscodeKeymap,
|
||||
...cm.search.searchKeymap,
|
||||
...cm.lint.lintKeymap,
|
||||
cm.commands.indentWithTab,
|
||||
{key: 'Mod-k Mod-x', run: (view) => { trimTrailingWhitespaceFromView(view); return true }, preventDefault: true},
|
||||
{key: 'Mod-Enter', run: cm.commands.insertBlankLine, preventDefault: true},
|
||||
{key: 'Mod-k Mod-k', run: cm.commands.deleteToLineEnd, preventDefault: true},
|
||||
{key: 'Mod-k Mod-Backspace', run: cm.commands.deleteToLineStart, preventDefault: true},
|
||||
]),
|
||||
cm.state.EditorState.allowMultipleSelections.of(true),
|
||||
cm.language.indentOnInput(),
|
||||
cm.language.syntaxHighlighting(cm.highlight.classHighlighter),
|
||||
cm.language.bracketMatching(),
|
||||
indentUnitComp.of(
|
||||
cm.language.indentUnit.of(
|
||||
config.indentStyle === 'tab' ? '\t' : ' '.repeat(config.indentSize || 4),
|
||||
),
|
||||
),
|
||||
cm.autocomplete.closeBrackets(),
|
||||
cm.autocomplete.autocompletion(),
|
||||
cm.state.EditorState.languageData.of(() => [{autocomplete: cm.autocomplete.completeAnyWord}]),
|
||||
cm.indentMarkers.indentationMarkers({
|
||||
colors: {
|
||||
light: 'transparent',
|
||||
dark: 'transparent',
|
||||
activeLight: 'var(--color-secondary-dark-3)',
|
||||
activeDark: 'var(--color-secondary-dark-3)',
|
||||
},
|
||||
}),
|
||||
cm.commands.history(),
|
||||
palette.extensions,
|
||||
cm.view.keymap.of([
|
||||
{key: 'Mod-Shift-o', run: goToSymbol, preventDefault: true},
|
||||
{key: 'Mod-F2', run: (v) => { selectAllOccurrences(cm, v); return true }, preventDefault: true},
|
||||
{key: 'F12', run: (v) => goToDefinitionAt(cm, v, v.state.selection.main.from), preventDefault: true},
|
||||
]),
|
||||
contextMenu(cm, palette.togglePalette, goToSymbol),
|
||||
clickableUrls(cm),
|
||||
tabSize.of(cm.state.EditorState.tabSize.of(config.tabWidth || 4)),
|
||||
wordWrap.of(config.lineWrap ? cm.view.EditorView.lineWrapping : []),
|
||||
language.of(loadedLang ?? []),
|
||||
lintComp.of(await getLinterExtension(cm, config.filename, loadedLang)),
|
||||
cm.view.EditorView.updateListener.of((update: ViewUpdate) => {
|
||||
if (update.docChanged) {
|
||||
textarea.value = update.state.doc.toString();
|
||||
textarea.dispatchEvent(new Event('change')); // needed for jquery-are-you-sure
|
||||
}
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const editor: CodemirrorEditor = {
|
||||
view,
|
||||
trimTrailingWhitespace: config.trimTrailingWhitespace,
|
||||
togglePalette: palette.togglePalette,
|
||||
updateFilename: async (filename: string) => {
|
||||
togglePreviewDisplay(previewableExts.has(extname(filename)));
|
||||
await updateEditorLanguage(cm, editor, filename, lineWrapExts);
|
||||
},
|
||||
languages: languageDescriptions,
|
||||
compartments: {wordWrap, language, tabSize, indentUnit: indentUnitComp, lint: lintComp},
|
||||
};
|
||||
|
||||
const elEditorOptions = textarea.closest('form')!.querySelector('.code-editor-options');
|
||||
if (elEditorOptions) {
|
||||
const indentStyleSelect = elEditorOptions.querySelector<HTMLSelectElement>('.js-indent-style-select')!;
|
||||
const indentSizeSelect = elEditorOptions.querySelector<HTMLSelectElement>('.js-indent-size-select')!;
|
||||
|
||||
const applyIndentSettings = (style: string, size: number) => {
|
||||
view.dispatch({
|
||||
effects: [
|
||||
indentUnitComp.reconfigure(cm.language.indentUnit.of(style === 'tab' ? '\t' : ' '.repeat(size))),
|
||||
tabSize.reconfigure(cm.state.EditorState.tabSize.of(size)),
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
indentStyleSelect.addEventListener('change', () => {
|
||||
applyIndentSettings(indentStyleSelect.value, Number(indentSizeSelect.value) || 4);
|
||||
});
|
||||
|
||||
indentSizeSelect.addEventListener('change', () => {
|
||||
applyIndentSettings(indentStyleSelect.value || 'space', Number(indentSizeSelect.value) || 4);
|
||||
});
|
||||
|
||||
elEditorOptions.querySelector('.js-code-find')!.addEventListener('click', () => {
|
||||
if (cm.search.searchPanelOpen(view.state)) {
|
||||
cm.search.closeSearchPanel(view);
|
||||
} else {
|
||||
cm.search.openSearchPanel(view);
|
||||
}
|
||||
});
|
||||
|
||||
elEditorOptions.querySelector('.js-code-command-palette')!.addEventListener('click', () => {
|
||||
palette.togglePalette(view);
|
||||
});
|
||||
|
||||
elEditorOptions.querySelector<HTMLSelectElement>('.js-line-wrap-select')!.addEventListener('change', (e) => {
|
||||
const target = e.target as HTMLSelectElement;
|
||||
view.dispatch({
|
||||
effects: wordWrap.reconfigure(target.value === 'on' ? cm.view.EditorView.lineWrapping : []),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
togglePreviewDisplay(previewableExts.has(extname(config.filename)));
|
||||
|
||||
if (config.autofocus) {
|
||||
editor.view.focus();
|
||||
} else if (filenameInput) {
|
||||
filenameInput.focus();
|
||||
}
|
||||
|
||||
return editor;
|
||||
}
|
||||
|
||||
// files that are JSONC despite having a .json extension
|
||||
const jsoncFilesRegex = /^([jt]sconfig.*|devcontainer)\.json$/;
|
||||
|
||||
async function getLinterExtension(cm: CodemirrorModules, filename: string, loadedLang: {language: unknown} | null): Promise<Extension> {
|
||||
const ext = extname(filename).toLowerCase();
|
||||
if (ext === '.json' || ext === '.map') {
|
||||
return jsoncFilesRegex.test(filename) ? [] : [cm.lint.lintGutter(), await createJsonLinter(cm)];
|
||||
}
|
||||
// StreamLanguage (legacy modes) don't produce Lezer error nodes
|
||||
if (!loadedLang || loadedLang.language instanceof cm.language.StreamLanguage) return [];
|
||||
return [cm.lint.lintGutter(), createSyntaxErrorLinter(cm)];
|
||||
}
|
||||
|
||||
async function updateEditorLanguage(cm: CodemirrorModules, editor: CodemirrorEditor, filename: string, lineWrapExts: string[]): Promise<void> {
|
||||
const {compartments, view, languages: editorLanguages} = editor;
|
||||
|
||||
const newLanguage = cm.language.LanguageDescription.matchFilename(editorLanguages, filename);
|
||||
const newLoadedLang = newLanguage ? await newLanguage.load() : null;
|
||||
view.dom.closest('.code-editor-container')!.setAttribute('data-language', newLanguage?.name.toLowerCase() || '');
|
||||
view.dispatch(
|
||||
{
|
||||
effects: [
|
||||
compartments.wordWrap.reconfigure(
|
||||
lineWrapExts.includes(extname(filename).toLowerCase()) ? cm.view.EditorView.lineWrapping : [],
|
||||
),
|
||||
compartments.language.reconfigure(newLoadedLang ?? []),
|
||||
compartments.lint.reconfigure(await getLinterExtension(cm, filename, newLoadedLang)),
|
||||
],
|
||||
},
|
||||
// clear stale diagnostics from the previous language on filename change
|
||||
cm.lint.setDiagnostics(view.state, []),
|
||||
);
|
||||
}
|
||||
47
web_src/js/modules/codeeditor/utils.test.ts
Normal file
47
web_src/js/modules/codeeditor/utils.test.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import {findUrlAtPosition, trimUrlPunctuation, urlRawRegex} from './utils.ts';
|
||||
|
||||
function matchUrls(text: string): string[] {
|
||||
return Array.from(text.matchAll(urlRawRegex), (m) => trimUrlPunctuation(m[0]));
|
||||
}
|
||||
|
||||
test('matchUrls', () => {
|
||||
expect(matchUrls('visit https://example.com for info')).toEqual(['https://example.com']);
|
||||
expect(matchUrls('see https://example.com.')).toEqual(['https://example.com']);
|
||||
expect(matchUrls('see https://example.com, and')).toEqual(['https://example.com']);
|
||||
expect(matchUrls('see https://example.com; and')).toEqual(['https://example.com']);
|
||||
expect(matchUrls('(https://example.com)')).toEqual(['https://example.com']);
|
||||
expect(matchUrls('"https://example.com"')).toEqual(['https://example.com']);
|
||||
expect(matchUrls('https://example.com/path?q=1&b=2#hash')).toEqual(['https://example.com/path?q=1&b=2#hash']);
|
||||
expect(matchUrls('https://example.com/path?q=1&b=2#hash.')).toEqual(['https://example.com/path?q=1&b=2#hash']);
|
||||
expect(matchUrls('https://x.co')).toEqual(['https://x.co']);
|
||||
expect(matchUrls('https://example.com/path_(wiki)')).toEqual(['https://example.com/path_(wiki)']);
|
||||
expect(matchUrls('https://en.wikipedia.org/wiki/Rust_(programming_language)')).toEqual(['https://en.wikipedia.org/wiki/Rust_(programming_language)']);
|
||||
expect(matchUrls('(https://en.wikipedia.org/wiki/Rust_(programming_language))')).toEqual(['https://en.wikipedia.org/wiki/Rust_(programming_language)']);
|
||||
expect(matchUrls('http://example.com')).toEqual(['http://example.com']);
|
||||
expect(matchUrls('no url here')).toEqual([]);
|
||||
expect(matchUrls('https://a.com and https://b.com')).toEqual(['https://a.com', 'https://b.com']);
|
||||
expect(matchUrls('[](https://www.npmjs.org/package/pkg)')).toEqual(['https://img.shields.io/npm/v/pkg.svg?style=flat', 'https://www.npmjs.org/package/pkg']);
|
||||
});
|
||||
|
||||
test('trimUrlPunctuation', () => {
|
||||
expect(trimUrlPunctuation('https://example.com.')).toEqual('https://example.com');
|
||||
expect(trimUrlPunctuation('https://example.com,')).toEqual('https://example.com');
|
||||
expect(trimUrlPunctuation('https://example.com;')).toEqual('https://example.com');
|
||||
expect(trimUrlPunctuation('https://example.com:')).toEqual('https://example.com');
|
||||
expect(trimUrlPunctuation("https://example.com'")).toEqual('https://example.com');
|
||||
expect(trimUrlPunctuation('https://example.com"')).toEqual('https://example.com');
|
||||
expect(trimUrlPunctuation('https://example.com.,;')).toEqual('https://example.com');
|
||||
expect(trimUrlPunctuation('https://example.com/path')).toEqual('https://example.com/path');
|
||||
expect(trimUrlPunctuation('https://example.com/path_(wiki)')).toEqual('https://example.com/path_(wiki)');
|
||||
expect(trimUrlPunctuation('https://example.com)')).toEqual('https://example.com');
|
||||
expect(trimUrlPunctuation('https://en.wikipedia.org/wiki/Rust_(lang))')).toEqual('https://en.wikipedia.org/wiki/Rust_(lang)');
|
||||
});
|
||||
|
||||
test('findUrlAtPosition', () => {
|
||||
const doc = 'visit https://example.com for info';
|
||||
expect(findUrlAtPosition(doc, 0)).toBeNull();
|
||||
expect(findUrlAtPosition(doc, 6)).toEqual('https://example.com');
|
||||
expect(findUrlAtPosition(doc, 15)).toEqual('https://example.com');
|
||||
expect(findUrlAtPosition(doc, 24)).toEqual('https://example.com');
|
||||
expect(findUrlAtPosition(doc, 25)).toBeNull();
|
||||
});
|
||||
131
web_src/js/modules/codeeditor/utils.ts
Normal file
131
web_src/js/modules/codeeditor/utils.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import type {EditorView, ViewUpdate} from '@codemirror/view';
|
||||
import type {CodemirrorModules} from './main.ts';
|
||||
|
||||
/** Remove trailing whitespace from all lines in the editor. */
|
||||
export function trimTrailingWhitespaceFromView(view: EditorView): void {
|
||||
const changes = [];
|
||||
const doc = view.state.doc;
|
||||
for (let i = 1; i <= doc.lines; i++) {
|
||||
const line = doc.line(i);
|
||||
const trimmed = line.text.replace(/\s+$/, '');
|
||||
if (trimmed.length < line.text.length) {
|
||||
changes.push({from: line.from + trimmed.length, to: line.to});
|
||||
}
|
||||
}
|
||||
if (changes.length) view.dispatch({changes});
|
||||
}
|
||||
|
||||
/** Matches URLs, excluding characters that are never valid unencoded in URLs per RFC 3986. */
|
||||
export const urlRawRegex = /\bhttps?:\/\/[^\s<>[\]]+/gi;
|
||||
|
||||
/** Strip trailing punctuation that is likely not part of the URL. */
|
||||
export function trimUrlPunctuation(url: string): string {
|
||||
url = url.replace(/[.,;:'"]+$/, '');
|
||||
// Strip trailing closing parens only if unbalanced (not part of the URL like Wikipedia links)
|
||||
while (url.endsWith(')') && (url.match(/\(/g) || []).length < (url.match(/\)/g) || []).length) {
|
||||
url = url.slice(0, -1);
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
/** Find the URL at the given character position in a document string, or null if none. */
|
||||
export function findUrlAtPosition(doc: string, pos: number): string | null {
|
||||
for (const match of doc.matchAll(urlRawRegex)) {
|
||||
const url = trimUrlPunctuation(match[0]);
|
||||
if (match.index !== undefined && pos >= match.index && pos < match.index + url.length) {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Lezer syntax tree node names for identifier usages and definitions across grammars
|
||||
const usageNodes = new Set(['VariableName', 'Identifier', 'TypeIdentifier', 'TypeName', 'FieldIdentifier']);
|
||||
const definitionNodes = new Set(['VariableDefinition', 'DefName', 'Definition', 'TypeDefinition', 'TypeDef']);
|
||||
|
||||
export function goToDefinitionAt(cm: CodemirrorModules, view: EditorView, pos: number): boolean {
|
||||
const tree = cm.language.syntaxTree(view.state);
|
||||
const node = tree.resolveInner(pos, 1);
|
||||
if (!node || !usageNodes.has(node.name)) return false;
|
||||
const name = view.state.doc.sliceString(node.from, node.to);
|
||||
let target: number | null = null;
|
||||
tree.iterate({
|
||||
enter(n): false | void {
|
||||
if (target !== null) return false;
|
||||
if (definitionNodes.has(n.name) && n.from !== node.from && view.state.doc.sliceString(n.from, n.to) === name) {
|
||||
target = n.from;
|
||||
return false;
|
||||
}
|
||||
},
|
||||
});
|
||||
if (target === null) return false;
|
||||
view.dispatch({selection: {anchor: target}, scrollIntoView: true});
|
||||
return true;
|
||||
}
|
||||
|
||||
/** CodeMirror extension that makes URLs clickable via Ctrl/Cmd+click. */
|
||||
export function clickableUrls(cm: CodemirrorModules) {
|
||||
const urlMark = cm.view.Decoration.mark({class: 'cm-url'});
|
||||
const urlDecorator = new cm.view.MatchDecorator({
|
||||
regexp: urlRawRegex,
|
||||
decorate: (add, from, _to, match) => {
|
||||
const trimmed = trimUrlPunctuation(match[0]);
|
||||
add(from, from + trimmed.length, urlMark);
|
||||
},
|
||||
});
|
||||
|
||||
const plugin = cm.view.ViewPlugin.fromClass(class {
|
||||
decorations: ReturnType<typeof urlDecorator.createDeco>;
|
||||
constructor(view: EditorView) {
|
||||
this.decorations = urlDecorator.createDeco(view);
|
||||
}
|
||||
update(update: ViewUpdate) {
|
||||
this.decorations = urlDecorator.updateDeco(update, this.decorations);
|
||||
}
|
||||
}, {decorations: (v) => v.decorations});
|
||||
|
||||
const handler = cm.view.EditorView.domEventHandlers({
|
||||
mousedown(event: MouseEvent, view: EditorView) {
|
||||
if (!event.metaKey && !event.ctrlKey) return false;
|
||||
const pos = view.posAtCoords({x: event.clientX, y: event.clientY});
|
||||
if (pos === null) return false;
|
||||
const line = view.state.doc.lineAt(pos);
|
||||
const url = findUrlAtPosition(line.text, pos - line.from);
|
||||
if (url) {
|
||||
window.open(url, '_blank', 'noopener');
|
||||
event.preventDefault();
|
||||
return true;
|
||||
}
|
||||
// Fall back to go-to-definition: find the symbol at cursor and jump to its definition
|
||||
if (goToDefinitionAt(cm, view, pos)) {
|
||||
event.preventDefault();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
});
|
||||
|
||||
const modClass = cm.view.ViewPlugin.fromClass(class {
|
||||
container: Element;
|
||||
handleKeyDown: (e: KeyboardEvent) => void;
|
||||
handleKeyUp: (e: KeyboardEvent) => void;
|
||||
handleBlur: () => void;
|
||||
constructor(view: EditorView) {
|
||||
this.container = view.dom.closest('.code-editor-container')!;
|
||||
this.handleKeyDown = (e) => { if (e.key === 'Meta' || e.key === 'Control') this.container.classList.add('cm-mod-held'); };
|
||||
this.handleKeyUp = (e) => { if (e.key === 'Meta' || e.key === 'Control') this.container.classList.remove('cm-mod-held'); };
|
||||
this.handleBlur = () => this.container.classList.remove('cm-mod-held');
|
||||
document.addEventListener('keydown', this.handleKeyDown);
|
||||
document.addEventListener('keyup', this.handleKeyUp);
|
||||
window.addEventListener('blur', this.handleBlur);
|
||||
}
|
||||
destroy() {
|
||||
document.removeEventListener('keydown', this.handleKeyDown);
|
||||
document.removeEventListener('keyup', this.handleKeyUp);
|
||||
window.removeEventListener('blur', this.handleBlur);
|
||||
this.container.classList.remove('cm-mod-held');
|
||||
}
|
||||
});
|
||||
|
||||
return [plugin, handler, modClass];
|
||||
}
|
||||
Reference in New Issue
Block a user