初始提交: Gitea 项目代码
This commit is contained in:
@@ -0,0 +1,90 @@
|
||||
import {clippie, type ClippieContent} from 'clippie';
|
||||
import {showTemporaryTooltip} from './tippy.ts';
|
||||
import {sleep} from '../utils.ts';
|
||||
import {svg} from '../svg.ts';
|
||||
import {createElementFromHTML} from '../utils/dom.ts';
|
||||
|
||||
const {copy_success, copy_error} = window.config.i18n;
|
||||
const pendingFeedback = new WeakSet<HTMLElement>();
|
||||
|
||||
/** copy the copiable content to clipboard, return "true" on success, otherwise "false" */
|
||||
export async function copyToClipboard(content: ClippieContent): Promise<boolean> {
|
||||
return await clippie(content);
|
||||
}
|
||||
|
||||
/** Copy `content` to the clipboard. `target` is used to:
|
||||
* - avoid duplicate copy actions (especially when the content will be fetched from an async function)
|
||||
* - provide feedback to end users (its `.octicon-copy` is swapped to show success/fail feedback, or a tooltip if it has none)
|
||||
* When `content` is a function, `target` also shows a spinner while it resolves. */
|
||||
export async function copyToClipboardWithFeedback(target: HTMLElement, content: ClippieContent | (() => Promise<ClippieContent>)) {
|
||||
if (pendingFeedback.has(target)) return;
|
||||
pendingFeedback.add(target);
|
||||
|
||||
let success = false;
|
||||
const feedbackSvg = target.querySelector<SVGElement>('.octicon-copy');
|
||||
|
||||
// prepare copiable "content"
|
||||
try {
|
||||
if (typeof content === 'function') {
|
||||
if (feedbackSvg) target.style.setProperty('--loading-size', `${feedbackSvg.getAttribute('width')!}px`);
|
||||
target.classList.add('is-loading', 'loading-icon-2px');
|
||||
try {
|
||||
content = await content();
|
||||
} finally {
|
||||
target.classList.remove('is-loading', 'loading-icon-2px');
|
||||
target.style.removeProperty('--loading-size');
|
||||
}
|
||||
}
|
||||
success = await copyToClipboard(content);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
|
||||
// show feedback
|
||||
if (feedbackSvg) {
|
||||
const restore = replaceWithFeedbackSvg(feedbackSvg, success);
|
||||
await sleep(1000);
|
||||
restore();
|
||||
} else {
|
||||
showTemporaryTooltip(target, success ? copy_success : copy_error);
|
||||
}
|
||||
|
||||
pendingFeedback.delete(target);
|
||||
}
|
||||
|
||||
function replaceWithFeedbackSvg(origSvg: SVGElement, success: boolean): () => void {
|
||||
const size = Number(origSvg.getAttribute('width')!);
|
||||
const {icon, color} = success ?
|
||||
{icon: 'octicon-check', color: 'tw-text-green'} as const :
|
||||
{icon: 'octicon-x', color: 'tw-text-red'} as const;
|
||||
const newSvg = createElementFromHTML<SVGElement>(svg(icon, size, color));
|
||||
origSvg.replaceWith(newSvg);
|
||||
return () => newSvg.replaceWith(origSvg);
|
||||
}
|
||||
|
||||
// Enable clipboard copy from HTML attributes. These properties are supported:
|
||||
// - data-clipboard-text: Direct text to copy
|
||||
// - data-clipboard-target: Holds a selector for an element. "value" of <input> or <textarea>, or "textContent" of <div> will be copied
|
||||
export function initGlobalCopyToClipboardListener() {
|
||||
document.addEventListener('click', async (e) => {
|
||||
const target = (e.target as HTMLElement).closest<HTMLElement>('[data-clipboard-text], [data-clipboard-target]');
|
||||
if (!target) return;
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
let text = target.getAttribute('data-clipboard-text');
|
||||
if (text === null) {
|
||||
const textSelector = target.getAttribute('data-clipboard-target')!;
|
||||
const textTarget = document.querySelector(textSelector)!;
|
||||
if (textTarget.nodeName === 'INPUT' || textTarget.nodeName === 'TEXTAREA') {
|
||||
text = (textTarget as HTMLInputElement | HTMLTextAreaElement).value;
|
||||
} else if (textTarget.nodeName === 'DIV') {
|
||||
text = textTarget.textContent;
|
||||
} else {
|
||||
throw new Error(`Unsupported element for clipboard target: ${textSelector}`);
|
||||
}
|
||||
}
|
||||
// now, text can not be null
|
||||
await copyToClipboardWithFeedback(target, text);
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
import {createTippy} from '../tippy.ts';
|
||||
import {copyToClipboard} from '../clipboard.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 copyToClipboard(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 copyToClipboard(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});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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('@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;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import {buildLanguageDescriptions, importCodemirror} from './main.ts';
|
||||
|
||||
test('matchFilename — language detection covers extended rules', async () => {
|
||||
const cm = await importCodemirror();
|
||||
const list = buildLanguageDescriptions(cm);
|
||||
const match = (filename: string) =>
|
||||
cm.language.LanguageDescription.matchFilename(list, filename)?.name;
|
||||
|
||||
// Linguist-supplied filenames + extensions
|
||||
expect(match('.bashrc')).toBe('Shell');
|
||||
expect(match('PKGBUILD')).toBe('Shell');
|
||||
expect(match('foo.zsh')).toBe('Shell');
|
||||
expect(match('Cargo.lock')).toBe('TOML');
|
||||
expect(match('Gemfile')).toBe('Ruby');
|
||||
expect(match('foo.gemspec')).toBe('Ruby');
|
||||
expect(match('foo.psgi')).toBe('Perl');
|
||||
expect(match('foo.pyi')).toBe('Python');
|
||||
expect(match('foo.webmanifest')).toBe('JSON');
|
||||
expect(match('foo.tcc')).toBe('C++');
|
||||
|
||||
// Script-side extras (extraFilenames / extraExtensions)
|
||||
expect(match('.editorconfig')).toBe('Properties files');
|
||||
expect(match('foo.conf')).toBe('Properties files');
|
||||
expect(match('Snakefile')).toBe('Python');
|
||||
|
||||
// Custom Gitea entries override language-data
|
||||
expect(match('Containerfile.test')).toBe('Dockerfile');
|
||||
expect(match('Dockerfile.dev')).toBe('Dockerfile');
|
||||
expect(match('Makefile.am')).toBe('Makefile');
|
||||
expect(match('foo.mk')).toBe('Makefile');
|
||||
expect(match('.env.local')).toBe('Dotenv');
|
||||
expect(match('foo.json5')).toBe('JSON5');
|
||||
expect(match('foo.mdown')).toBe('Markdown');
|
||||
|
||||
// Filename regex wins over extension match
|
||||
expect(match('nginx.conf')).toBe('Nginx');
|
||||
|
||||
// .spec routes to RPM Spec via excludeExt redirect
|
||||
expect(match('foo.spec')).toBe('RPM Spec');
|
||||
|
||||
// CM original ownership preserved against Linguist's broader claims (.sql is SQL,
|
||||
// not PLSQL, even though Linguist's PLSQL extension list includes it).
|
||||
expect(match('foo.sql')).toBe('SQL');
|
||||
expect(match('foo.h')).toBe('C');
|
||||
expect(match('foo.mm')).toBe('Objective-C++');
|
||||
|
||||
// Globally ambiguous extensions fall through to plain text
|
||||
expect(match('foo.cgi')).toBeUndefined();
|
||||
expect(match('foo.inc')).toBeUndefined();
|
||||
|
||||
// Smoke: existing language-data entries still resolve
|
||||
expect(match('foo.go')).toBe('Go');
|
||||
expect(match('foo.tsx')).toBe('TSX');
|
||||
});
|
||||
@@ -0,0 +1,373 @@
|
||||
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, LanguageSupport} 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;
|
||||
};
|
||||
};
|
||||
|
||||
type LinguistLanguage = {name: string; extensions: string[]; filenames: string[]};
|
||||
|
||||
export type CodemirrorModules = Awaited<ReturnType<typeof importCodemirror>>;
|
||||
|
||||
export async function importCodemirror() {
|
||||
const [autocomplete, commands, language, languageData, lint, search, state, view, highlight, indentMarkers, vscodeKeymap, linguist] = await Promise.all([
|
||||
import('@codemirror/autocomplete'),
|
||||
import('@codemirror/commands'),
|
||||
import('@codemirror/language'),
|
||||
import('@codemirror/language-data'),
|
||||
import('@codemirror/lint'),
|
||||
import('@codemirror/search'),
|
||||
import('@codemirror/state'),
|
||||
import('@codemirror/view'),
|
||||
import('@lezer/highlight'),
|
||||
import('@replit/codemirror-indentation-markers'),
|
||||
import('@replit/codemirror-vscode-keymap'),
|
||||
import('../../../../assets/codemirror-languages.json'),
|
||||
]);
|
||||
return {autocomplete, commands, language, languageData, lint, search, state, view, highlight, indentMarkers, vscodeKeymap, linguistLanguages: linguist.default as LinguistLanguage[]};
|
||||
}
|
||||
|
||||
const escapeRegex = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const filenameUnion = (filenames: string[]) =>
|
||||
filenames.length ? new RegExp(`^(${filenames.map(escapeRegex).join('|')})$`) : undefined;
|
||||
|
||||
export function buildLanguageDescriptions(cm: CodemirrorModules): LanguageDescription[] {
|
||||
const list: LanguageDescription[] = [
|
||||
...buildBaseLanguages(cm),
|
||||
cm.language.LanguageDescription.of({
|
||||
name: 'Markdown', extensions: ['md', 'markdown', 'mkd', 'mdown', 'mdwn', 'mkdn', 'mkdown'],
|
||||
load: async () => (await import('@codemirror/lang-markdown')).markdown({codeLanguages: list}),
|
||||
}),
|
||||
cm.language.LanguageDescription.of({
|
||||
name: 'Dockerfile', extensions: ['dockerfile', 'containerfile'],
|
||||
filename: /^(Containerfile|Dockerfile)(\..+)?$/i,
|
||||
load: async () => new cm.language.LanguageSupport(cm.language.StreamLanguage.define((await import('@codemirror/legacy-modes/mode/dockerfile')).dockerFile)),
|
||||
}),
|
||||
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', extensions: ['mk', 'mak', 'make'], filename: /^(GNU|BSD)?[Mm]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(),
|
||||
}),
|
||||
];
|
||||
return list;
|
||||
}
|
||||
|
||||
// Languages that the JSON omits because they're constructed manually above.
|
||||
const customNames = new Set(['Dockerfile', 'Markdown']);
|
||||
|
||||
let baseLanguagesCache: LanguageDescription[] | null = null;
|
||||
function buildBaseLanguages(cm: CodemirrorModules): LanguageDescription[] {
|
||||
if (baseLanguagesCache) return baseLanguagesCache;
|
||||
const loadByName = new Map<string, LanguageDescription['load']>(
|
||||
cm.languageData.languages.map((l: LanguageDescription) => [l.name, l.load.bind(l)]),
|
||||
);
|
||||
const overrides = cm.linguistLanguages
|
||||
.filter((l) => loadByName.has(l.name))
|
||||
.map((l) => cm.language.LanguageDescription.of({
|
||||
name: l.name,
|
||||
extensions: l.extensions,
|
||||
filename: filenameUnion(l.filenames),
|
||||
load: loadByName.get(l.name)!,
|
||||
}));
|
||||
const overrideNames = new Set(overrides.map((o) => o.name));
|
||||
const fallback = cm.languageData.languages.filter(
|
||||
(l: LanguageDescription) => !overrideNames.has(l.name) && !customNames.has(l.name),
|
||||
);
|
||||
return baseLanguagesCache = [...overrides, ...fallback];
|
||||
}
|
||||
|
||||
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 = buildLanguageDescriptions(cm);
|
||||
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 the JSON parser is too strict for (comments, trailing commas)
|
||||
const jsoncFilesRegex = /^([jt]sconfig.*|devcontainer)\.json$|\.(jsonc|json5)$/i;
|
||||
|
||||
async function getLinterExtension(cm: CodemirrorModules, filename: string, loadedLang: LanguageSupport | null): Promise<Extension> {
|
||||
if (!loadedLang) return [];
|
||||
const lang = loadedLang.language;
|
||||
// StreamLanguage (legacy modes) don't produce Lezer error nodes
|
||||
if (lang instanceof cm.language.StreamLanguage) return [];
|
||||
if (lang.name === 'json') {
|
||||
return jsoncFilesRegex.test(filename) ? [] : [cm.lint.lintGutter(), await createJsonLinter(cm)];
|
||||
}
|
||||
// markdown's parser emits no error nodes, and nested code-fence overlays aren't traversed
|
||||
if (lang.name === 'markdown') 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, []),
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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];
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import {showInfoToast, showWarningToast, showErrorToast} from './toast.ts';
|
||||
import type {Toast} from './toast.ts';
|
||||
import {registerGlobalInitFunc} from './observer.ts';
|
||||
import {showFomanticModal} from './fomantic/modal.ts';
|
||||
import {createElementFromHTML} from '../utils/dom.ts';
|
||||
import {html} from '../utils/html.ts';
|
||||
import {showGlobalErrorMessage} from './errors.ts';
|
||||
|
||||
type LevelMap = Record<string, (message: string) => Toast | null>;
|
||||
|
||||
function initDevtestPage() {
|
||||
const toastButtons = document.querySelectorAll('.toast-test-button');
|
||||
if (toastButtons.length) {
|
||||
const levelMap: LevelMap = {info: showInfoToast, warning: showWarningToast, error: showErrorToast};
|
||||
for (const el of toastButtons) {
|
||||
el.addEventListener('click', () => {
|
||||
const level = el.getAttribute('data-toast-level')!;
|
||||
const message = el.getAttribute('data-toast-message')!;
|
||||
levelMap[level](message);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const modalButtons = document.querySelector('.modal-buttons');
|
||||
if (modalButtons) {
|
||||
for (const el of document.querySelectorAll('.ui.modal:not([data-skip-button])')) {
|
||||
const btn = createElementFromHTML(html`<button class="ui button">${el.id}</button`);
|
||||
btn.addEventListener('click', () => showFomanticModal(el));
|
||||
modalButtons.append(btn);
|
||||
}
|
||||
}
|
||||
|
||||
const sampleButtons = document.querySelectorAll('#devtest-button-samples button.ui.button');
|
||||
if (sampleButtons.length) {
|
||||
const buttonStyles = document.querySelectorAll<HTMLInputElement>('input[name*="button-style"]');
|
||||
for (const elStyle of buttonStyles) {
|
||||
elStyle.addEventListener('click', () => {
|
||||
for (const btn of sampleButtons) {
|
||||
for (const el of buttonStyles) {
|
||||
if (el.value) btn.classList.toggle(el.value, el.checked);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
const buttonStates = document.querySelectorAll<HTMLInputElement>('input[name*="button-state"]');
|
||||
for (const elState of buttonStates) {
|
||||
elState.addEventListener('click', () => {
|
||||
for (const btn of sampleButtons) {
|
||||
(btn as any)[elState.value] = elState.checked;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function initDevtest() {
|
||||
registerGlobalInitFunc('initDevtestPage', initDevtestPage);
|
||||
registerGlobalInitFunc('initDevtestDetailsErrorMessage', () => {
|
||||
for (let i = 0; i < 2; i++) {
|
||||
showGlobalErrorMessage('showGlobalErrorMessage single message', 'warning');
|
||||
showGlobalErrorMessage('showGlobalErrorMessage message with details', 'error', `detail message 1\nvery lo${'o'.repeat(200)}ng line 2\nline 3`);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import {diffTreeStoreSetViewed, reactiveDiffTreeStore} from './diff-file.ts';
|
||||
|
||||
test('diff-tree', () => {
|
||||
const store = reactiveDiffTreeStore({
|
||||
'TreeRoot': {
|
||||
'FullName': '',
|
||||
'DisplayName': '',
|
||||
'EntryMode': '',
|
||||
'IsViewed': false,
|
||||
'NameHash': '....',
|
||||
'DiffStatus': '',
|
||||
'FileIcon': '',
|
||||
'Children': [
|
||||
{
|
||||
'FullName': 'dir1',
|
||||
'DisplayName': 'dir1',
|
||||
'EntryMode': 'tree',
|
||||
'IsViewed': false,
|
||||
'NameHash': '....',
|
||||
'DiffStatus': '',
|
||||
'FileIcon': '',
|
||||
'Children': [
|
||||
{
|
||||
'FullName': 'dir1/test.txt',
|
||||
'DisplayName': 'test.txt',
|
||||
'DiffStatus': 'added',
|
||||
'NameHash': '....',
|
||||
'EntryMode': '',
|
||||
'IsViewed': false,
|
||||
'FileIcon': '',
|
||||
'Children': null,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
'FullName': 'other.txt',
|
||||
'DisplayName': 'other.txt',
|
||||
'NameHash': '........',
|
||||
'DiffStatus': 'added',
|
||||
'EntryMode': '',
|
||||
'IsViewed': false,
|
||||
'FileIcon': '',
|
||||
'Children': null,
|
||||
},
|
||||
],
|
||||
},
|
||||
}, '', '');
|
||||
diffTreeStoreSetViewed(store, 'dir1/test.txt', true);
|
||||
expect(store.fullNameMap['dir1/test.txt'].IsViewed).toBe(true);
|
||||
expect(store.fullNameMap['dir1'].IsViewed).toBe(true);
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
import {reactive} from 'vue';
|
||||
import type {Reactive} from 'vue';
|
||||
|
||||
const {pageData} = window.config;
|
||||
|
||||
export type DiffStatus = '' | 'added' | 'modified' | 'deleted' | 'renamed' | 'copied' | 'typechange';
|
||||
|
||||
export type DiffTreeEntry = {
|
||||
FullName: string,
|
||||
DisplayName: string,
|
||||
NameHash: string,
|
||||
DiffStatus: DiffStatus,
|
||||
EntryMode: string,
|
||||
IsViewed: boolean,
|
||||
Children: DiffTreeEntry[] | null,
|
||||
FileIcon: string,
|
||||
ParentEntry?: DiffTreeEntry,
|
||||
};
|
||||
|
||||
export type DiffFileTreeData = {
|
||||
TreeRoot: DiffTreeEntry,
|
||||
};
|
||||
|
||||
type DiffFileTree = {
|
||||
folderIcon: string;
|
||||
folderOpenIcon: string;
|
||||
diffFileTree: DiffFileTreeData;
|
||||
fullNameMap: Record<string, DiffTreeEntry>
|
||||
fileTreeIsVisible: boolean;
|
||||
selectedItem: string;
|
||||
};
|
||||
|
||||
let diffTreeStoreReactive: Reactive<DiffFileTree>;
|
||||
export function diffTreeStore() {
|
||||
if (!diffTreeStoreReactive) {
|
||||
diffTreeStoreReactive = reactiveDiffTreeStore(pageData.DiffFileTree!, pageData.FolderIcon!, pageData.FolderOpenIcon!);
|
||||
}
|
||||
return diffTreeStoreReactive;
|
||||
}
|
||||
|
||||
export function diffTreeStoreSetViewed(store: Reactive<DiffFileTree>, fullName: string, viewed: boolean) {
|
||||
const entry = store.fullNameMap[fullName];
|
||||
if (!entry) return;
|
||||
entry.IsViewed = viewed;
|
||||
for (let parent = entry.ParentEntry; parent; parent = parent.ParentEntry) {
|
||||
parent.IsViewed = isEntryViewed(parent);
|
||||
}
|
||||
}
|
||||
|
||||
function fillFullNameMap(map: Record<string, DiffTreeEntry>, entry: DiffTreeEntry) {
|
||||
map[entry.FullName] = entry;
|
||||
if (!entry.Children) return;
|
||||
entry.IsViewed = isEntryViewed(entry);
|
||||
for (const child of entry.Children) {
|
||||
child.ParentEntry = entry;
|
||||
fillFullNameMap(map, child);
|
||||
}
|
||||
}
|
||||
|
||||
export function reactiveDiffTreeStore(data: DiffFileTreeData, folderIcon: string, folderOpenIcon: string): Reactive<DiffFileTree> {
|
||||
const store = reactive({
|
||||
diffFileTree: data,
|
||||
folderIcon,
|
||||
folderOpenIcon,
|
||||
fileTreeIsVisible: false,
|
||||
selectedItem: '',
|
||||
fullNameMap: {},
|
||||
});
|
||||
fillFullNameMap(store.fullNameMap, data.TreeRoot);
|
||||
return store;
|
||||
}
|
||||
|
||||
function isEntryViewed(entry: DiffTreeEntry): boolean {
|
||||
if (entry.Children) {
|
||||
let count = 0;
|
||||
for (const child of entry.Children) {
|
||||
if (child.IsViewed) count++;
|
||||
}
|
||||
return count === entry.Children.length;
|
||||
}
|
||||
return entry.IsViewed;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import {isGiteaError, processWindowErrorEvent, showGlobalErrorMessage} from './errors.ts';
|
||||
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = '<div class="page-content"></div>';
|
||||
});
|
||||
|
||||
test('isGiteaError', () => {
|
||||
expect(isGiteaError('', '')).toBe(true);
|
||||
expect(isGiteaError('moz-extension://abc/content.js', '')).toBe(false);
|
||||
expect(isGiteaError('safari-extension://abc/content.js', '')).toBe(false);
|
||||
expect(isGiteaError('safari-web-extension://abc/content.js', '')).toBe(false);
|
||||
expect(isGiteaError('chrome-extension://abc/content.js', '')).toBe(false);
|
||||
expect(isGiteaError('https://other-site.com/script.js', '')).toBe(false);
|
||||
expect(isGiteaError('http://localhost:3000/some/page', '')).toBe(true);
|
||||
expect(isGiteaError('http://localhost:3000/assets/js/index.abc123.js', '')).toBe(true);
|
||||
expect(isGiteaError('', `Error\n at chrome-extension://abc/content.js:1:1`)).toBe(false);
|
||||
expect(isGiteaError('', `Error\n at https://other-site.com/script.js:1:1`)).toBe(false);
|
||||
expect(isGiteaError('', `Error\n at http://localhost:3000/assets/js/index.abc123.js:1:1`)).toBe(true);
|
||||
expect(isGiteaError('http://localhost:3000/assets/js/index.js', `Error\n at chrome-extension://abc/content.js:1:1`)).toBe(false);
|
||||
});
|
||||
|
||||
test('showGlobalErrorMessage', () => {
|
||||
showGlobalErrorMessage('test msg 1');
|
||||
showGlobalErrorMessage('test msg 2');
|
||||
showGlobalErrorMessage('test msg 1'); // duplicated
|
||||
|
||||
expect(document.body.innerHTML).toContain('>test msg 1 (2)<');
|
||||
expect(document.body.innerHTML).toContain('>test msg 2<');
|
||||
expect(document.querySelectorAll('.js-global-error').length).toEqual(2);
|
||||
});
|
||||
|
||||
test('processWindowErrorEvent renders stack trace in details', () => {
|
||||
const error = new Error('boom');
|
||||
error.stack = `Error: boom\n at fn (${window.location.origin}/assets/js/index.js:1:1)`;
|
||||
processWindowErrorEvent({error, type: 'error'} as ErrorEvent & PromiseRejectionEvent);
|
||||
expect(document.querySelector('.js-global-error summary')!.textContent).toContain('JavaScript error: boom');
|
||||
expect(document.querySelector('.js-global-error pre')!.textContent).toContain('/assets/js/index.js:1:1');
|
||||
});
|
||||
|
||||
test('processWindowErrorEvent falls back to message without stack', () => {
|
||||
processWindowErrorEvent({
|
||||
error: {message: 'script error'}, type: 'error',
|
||||
filename: `${window.location.origin}/assets/js/x.js`, lineno: 5, colno: 10,
|
||||
} as ErrorEvent & PromiseRejectionEvent);
|
||||
const msgText = document.querySelector('.js-global-error .ui.message')!.textContent;
|
||||
expect(msgText).toContain('JavaScript error: script error');
|
||||
expect(msgText).toContain('@ 5:10');
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
// keep this file lightweight, it's imported into IIFE chunk in bootstrap
|
||||
import {html} from '../utils/html.ts';
|
||||
import type {Intent} from '../types.ts';
|
||||
|
||||
/** Extract a message string from an unknown caught value. */
|
||||
export function errorMessage(err: unknown): string {
|
||||
return (err as Error)?.message || String(err);
|
||||
}
|
||||
|
||||
/** Extract a name string from an unknown caught value. */
|
||||
export function errorName(err: unknown): string {
|
||||
return (err as Error)?.name ?? '';
|
||||
}
|
||||
|
||||
export function showGlobalErrorMessage(msg: string, msgType: Intent = 'error', details?: string) {
|
||||
const parentContainer = document.querySelector('.page-content') ?? document.body;
|
||||
if (!parentContainer) {
|
||||
alert(`${msgType}: ${msg}`);
|
||||
return;
|
||||
}
|
||||
// compact the message to a data attribute to avoid too many duplicated messages
|
||||
const msgCompact = `${msgType}-${msg.trim()}`.replace(/[^-\w\u{80}-\u{10FFFF}]+/gu, '');
|
||||
let msgContainer = parentContainer.querySelector<HTMLDivElement>(`.js-global-error[data-global-error-msg-compact="${msgCompact}"]`);
|
||||
if (!msgContainer) {
|
||||
const el = document.createElement('div');
|
||||
el.innerHTML = html`<div class="ui container js-global-error tw-my-[--page-spacing]"><details class="ui ${msgType} message"><summary></summary></details></div>`;
|
||||
msgContainer = el.childNodes[0] as HTMLDivElement;
|
||||
}
|
||||
|
||||
// merge duplicated messages into "the message (count)" format
|
||||
const msgCount = Number(msgContainer.getAttribute(`data-global-error-msg-count`)) + 1;
|
||||
msgContainer.setAttribute(`data-global-error-msg-compact`, msgCompact);
|
||||
msgContainer.setAttribute(`data-global-error-msg-count`, msgCount.toString());
|
||||
|
||||
const msgElem = msgContainer.querySelector('details')!;
|
||||
const msgSummary = msgElem.querySelector('summary')!;
|
||||
msgSummary.textContent = msg + (msgCount > 1 ? ` (${msgCount})` : '');
|
||||
if (details) {
|
||||
let msgDetailsPre = msgElem.querySelector('pre');
|
||||
if (!msgDetailsPre) msgDetailsPre = document.createElement('pre');
|
||||
msgDetailsPre.textContent = details;
|
||||
msgElem.append(msgDetailsPre);
|
||||
}
|
||||
parentContainer.prepend(msgContainer);
|
||||
}
|
||||
|
||||
// Detect whether an error originated from Gitea's own scripts, not from
|
||||
// browser extensions or other external scripts.
|
||||
const extensionRe = /(chrome|moz|safari(-web)?)-extension:\/\//;
|
||||
export function isGiteaError(filename: string, stack: string): boolean {
|
||||
if (extensionRe.test(filename) || extensionRe.test(stack)) return false;
|
||||
const assetBaseUrl = new URL(`${window.config.assetUrlPrefix}/`, window.location.origin).href;
|
||||
if (filename && !filename.startsWith(assetBaseUrl) && !filename.startsWith(window.location.origin)) return false;
|
||||
if (stack && !stack.includes(assetBaseUrl)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
export function processWindowErrorEvent({error, reason, message, type, filename, lineno, colno}: ErrorEvent & PromiseRejectionEvent) {
|
||||
const err = error ?? reason;
|
||||
// `error` and `reason` are not guaranteed to be errors. If the value is falsy, it is likely a
|
||||
// non-critical event from the browser. We log them but don't show them to users. Examples:
|
||||
// - https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver#observation_errors
|
||||
// - https://github.com/mozilla-mobile/firefox-ios/issues/10817
|
||||
// - https://github.com/go-gitea/gitea/issues/20240
|
||||
if (!err) {
|
||||
if (message) console.error(new Error(message));
|
||||
if (window.config.runModeIsProd) return;
|
||||
}
|
||||
|
||||
// Filter out errors from browser extensions or other non-Gitea scripts.
|
||||
if (!isGiteaError(filename ?? '', err?.stack ?? '')) return;
|
||||
|
||||
const renderedType = type === 'unhandledrejection' ? 'promise rejection' : type;
|
||||
let msg = err?.message ?? message;
|
||||
if (!err?.stack && lineno) msg += ` (${filename} @ ${lineno}:${colno})`;
|
||||
const dot = msg.endsWith('.') ? '' : '.';
|
||||
showGlobalErrorMessage(`JavaScript ${renderedType}: ${msg}${dot} Open browser console to see more details.`, 'error', err?.stack);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import {GET, POST, PATCH, PUT, DELETE} from './fetch.ts';
|
||||
|
||||
// tests here are only to satisfy the linter for unused functions
|
||||
test('exports', () => {
|
||||
expect(GET).toBeTruthy();
|
||||
expect(POST).toBeTruthy();
|
||||
expect(PATCH).toBeTruthy();
|
||||
expect(PUT).toBeTruthy();
|
||||
expect(DELETE).toBeTruthy();
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import {isObject} from '../utils.ts';
|
||||
import type {RequestOpts} from '../types.ts';
|
||||
|
||||
// fetch wrapper, use below method name functions and the `data` option to pass in data
|
||||
// which will automatically set an appropriate headers. For JSON content, only object
|
||||
// and array types are currently supported.
|
||||
export function request(url: string, {method = 'GET', data, headers = {}, ...other}: RequestOpts = {}): Promise<Response> {
|
||||
let body: string | FormData | URLSearchParams | undefined;
|
||||
let contentType: string | undefined;
|
||||
if (data instanceof FormData || data instanceof URLSearchParams) {
|
||||
body = data;
|
||||
} else if (isObject(data) || Array.isArray(data)) {
|
||||
contentType = 'application/json';
|
||||
body = JSON.stringify(data);
|
||||
}
|
||||
|
||||
headers = new Headers(headers);
|
||||
if (!headers.has('content-type') && contentType) {
|
||||
headers.set('content-type', contentType);
|
||||
}
|
||||
return fetch(url, { // eslint-disable-line no-restricted-globals
|
||||
method,
|
||||
headers,
|
||||
...other,
|
||||
...(body && {body}),
|
||||
});
|
||||
}
|
||||
|
||||
export const GET = (url: string, opts?: RequestOpts) => request(url, {method: 'GET', ...opts});
|
||||
export const POST = (url: string, opts?: RequestOpts) => request(url, {method: 'POST', ...opts});
|
||||
export const PATCH = (url: string, opts?: RequestOpts) => request(url, {method: 'PATCH', ...opts});
|
||||
export const PUT = (url: string, opts?: RequestOpts) => request(url, {method: 'PUT', ...opts});
|
||||
export const DELETE = (url: string, opts?: RequestOpts) => request(url, {method: 'DELETE', ...opts});
|
||||
@@ -0,0 +1,28 @@
|
||||
import {initAriaDropdownPatch} from './fomantic/dropdown.ts';
|
||||
import {initAriaModalPatch} from './fomantic/modal.ts';
|
||||
import {initFomanticTransition} from './fomantic/transition.ts';
|
||||
import {initFomanticDimmer} from './fomantic/dimmer.ts';
|
||||
import {svg} from '../svg.ts';
|
||||
|
||||
export const fomanticMobileScreen = window.matchMedia('only screen and (max-width: 767.98px)');
|
||||
|
||||
export function initGiteaFomantic() {
|
||||
// our extensions
|
||||
$.fn.fomanticExt = {};
|
||||
// By default, use "exact match" for full text search
|
||||
$.fn.dropdown.settings.fullTextSearch = 'exact';
|
||||
// Do not use "cursor: pointer" for dropdown labels
|
||||
$.fn.dropdown.settings.className.label += ' tw-cursor-default';
|
||||
// Always use Gitea's SVG icons
|
||||
$.fn.dropdown.settings.templates.label = function(_value: any, text: any, preserveHTML: any, className: Record<string, string>) {
|
||||
const escape = $.fn.dropdown.settings.templates.escape;
|
||||
return escape(text, preserveHTML) + svg('octicon-x', 16, `${className.delete} icon`);
|
||||
};
|
||||
|
||||
initFomanticTransition();
|
||||
initFomanticDimmer();
|
||||
|
||||
// Use the patches to improve accessibility, these patches are designed to be as independent as possible, make it easy to modify or remove in the future.
|
||||
initAriaDropdownPatch();
|
||||
initAriaModalPatch();
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
# Background
|
||||
|
||||
This document is used as aria/accessibility(a11y) reference for future developers.
|
||||
|
||||
There are a lot of a11y problems in the Fomantic UI library. Files in
|
||||
`web_src/js/modules/fomantic/` are used as a workaround to make the UI more accessible.
|
||||
|
||||
The aria-related code is designed to avoid touching the official Fomantic UI library,
|
||||
and to be as independent as possible, so it can be easily modified/removed in the future.
|
||||
|
||||
To test the aria/accessibility with screen readers, developers can use the following steps:
|
||||
|
||||
* On macOS, you can use VoiceOver.
|
||||
* Press `Command + F5` to turn on VoiceOver.
|
||||
* Try to operate the UI with keyboard-only.
|
||||
* Use Tab/Shift+Tab to switch focus between elements.
|
||||
* Arrow keys (Option+Up/Down) to navigate between menu/combobox items (only aria-active, not really focused).
|
||||
* Press Enter to trigger the aria-active element.
|
||||
* On Android, you can use TalkBack.
|
||||
* Go to Settings -> Accessibility -> TalkBack, turn it on.
|
||||
* Long-press or press+swipe to switch the aria-active element (not really focused).
|
||||
* Double-tap means old single-tap on the aria-active element.
|
||||
* Double-finger swipe means old single-finger swipe.
|
||||
* TODO: on Windows, on Linux, on iOS
|
||||
|
||||
# Known Problems
|
||||
|
||||
* Tested with Apple VoiceOver: If a dropdown menu/combobox is opened by mouse click, then arrow keys don't work.
|
||||
But if the dropdown is opened by keyboard Tab, then arrow keys work, and from then on, the keys almost work with mouse click too.
|
||||
The clue: when the dropdown is only opened by mouse click, VoiceOver doesn't send 'keydown' events of arrow keys to the DOM,
|
||||
VoiceOver expects to use arrow keys to navigate between some elements, but it couldn't.
|
||||
Users could use Option+ArrowKeys to navigate between menu/combobox items or selection labels if the menu/combobox is opened by mouse click.
|
||||
|
||||
# Checkbox
|
||||
|
||||
## Accessibility-friendly Checkbox
|
||||
|
||||
The ideal checkboxes should be:
|
||||
|
||||
```html
|
||||
<label><input type="checkbox"> ... </label>
|
||||
```
|
||||
|
||||
However, the templates still have the Fomantic-style HTML layout:
|
||||
|
||||
```html
|
||||
<div class="ui checkbox">
|
||||
<input type="checkbox">
|
||||
<label>...</label>
|
||||
</div>
|
||||
```
|
||||
|
||||
We call `initAriaLabels` to link the `input` and `label` which makes clicking the
|
||||
label etc. work. There is still a problem: These checkboxes are not friendly to screen readers,
|
||||
so we add IDs to all the Fomantic UI checkboxes automatically by JS. If the `label` part is empty,
|
||||
then the checkbox needs to get the `aria-label` attribute manually.
|
||||
|
||||
# Fomantic Dropdown
|
||||
|
||||
Fomantic Dropdown is designed to be used for many purposes:
|
||||
|
||||
* Menu (the profile menu in navbar, the language menu in footer)
|
||||
* Popup (the branch/tag panel, the review box)
|
||||
* Simple `<select>` , used in many forms
|
||||
* Searchable option-list with static items (used in many forms)
|
||||
* Searchable option-list with dynamic items (ajax)
|
||||
* Searchable multiple selection option-list with dynamic items: the repo topic setting
|
||||
* More complex usages, like the Issue Label selector
|
||||
|
||||
Fomantic Dropdown requires that the focus must be on its primary element.
|
||||
If the focus changes, it hides or panics.
|
||||
|
||||
At the moment, the aria-related code only tries to partially resolve the a11y problems for dropdowns with items.
|
||||
|
||||
There are different solutions:
|
||||
|
||||
* combobox + listbox + option:
|
||||
* https://www.w3.org/WAI/ARIA/apg/patterns/combobox/
|
||||
* A combobox is an input widget with an associated popup that enables users to select a value for the combobox from
|
||||
a collection of possible values. In some implementations, the popup presents allowed values, while in other implementations,
|
||||
the popup presents suggested values, and users may either select one of the suggestions or type a value.
|
||||
* menu + menuitem:
|
||||
* https://www.w3.org/WAI/ARIA/apg/patterns/menubar/
|
||||
* A menu is a widget that offers a list of choices to the user, such as a set of actions or functions.
|
||||
|
||||
The current approach is: detect if the dropdown has an input,
|
||||
if yes, it works like a combobox, otherwise it works like a menu.
|
||||
Multiple selection dropdown is not well-supported yet, it needs more work.
|
||||
|
||||
Some important pages for dropdown testing:
|
||||
|
||||
* Home(dashboard) page, the "Create Repo" / "Profile" / "Language" menu.
|
||||
* Create New Repo page, a lot of dropdowns as combobox.
|
||||
* Collaborators page, the "permission" dropdown (the old behavior was not quite good, it just works).
|
||||
|
||||
```html
|
||||
<!-- read-only dropdown -->
|
||||
<div class="ui dropdown"> <!-- focused here, then it's not perfect to use aria-activedescendant to point to the menu item -->
|
||||
<input type="hidden" ...>
|
||||
<div class="text">Default</div>
|
||||
<div class="menu" tabindex="-1"> <!-- "transition hidden|visible" classes will be added by $.dropdown() and when the dropdown is working -->
|
||||
<div class="item active selected">Default</div>
|
||||
<div class="item">...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- search input dropdown -->
|
||||
<div class="ui dropdown">
|
||||
<input type="hidden" ...>
|
||||
<input class="search" autocomplete="off" tabindex="0"> <!-- focused here -->
|
||||
<div class="text"></div>
|
||||
<div class="menu" tabindex="-1"> <!-- "transition hidden|visible" classes will be added by $.dropdown() and when the dropdown is working -->
|
||||
<div class="item selected">...</div>
|
||||
<div class="item">...</div>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
@@ -0,0 +1,47 @@
|
||||
import {generateElemId, queryElems} from '../../utils/dom.ts';
|
||||
|
||||
function linkLabelAndInput(label: Element, input: Element) {
|
||||
const labelFor = label.getAttribute('for');
|
||||
const inputId = input.getAttribute('id');
|
||||
|
||||
if (inputId && !labelFor) { // missing "for"
|
||||
label.setAttribute('for', inputId);
|
||||
} else if (!inputId && !labelFor) { // missing both "id" and "for"
|
||||
const id = generateElemId('_aria_label_input_');
|
||||
input.setAttribute('id', id);
|
||||
label.setAttribute('for', id);
|
||||
}
|
||||
}
|
||||
|
||||
function patchLabels(parent: ParentNode, containerSelector: string, labelSelector: string, inputSelector: string, marker: string) {
|
||||
// Sample layout for this function:
|
||||
// <div parent>
|
||||
// <div container><label/><input/></div>
|
||||
// <div container><label/><input/></div>
|
||||
// </div>
|
||||
//
|
||||
// OR the parent is also the container:
|
||||
// <div parent container><label/><input/></div>
|
||||
|
||||
const patchLabelContainer = (container: Element) => {
|
||||
if (container.hasAttribute(marker)) return;
|
||||
const label = container.querySelector(labelSelector);
|
||||
const input = container.querySelector(inputSelector);
|
||||
if (!label || !input) return;
|
||||
linkLabelAndInput(label, input);
|
||||
container.setAttribute(marker, 'true');
|
||||
};
|
||||
queryElems(parent, containerSelector, patchLabelContainer);
|
||||
if (parent instanceof Element && parent.matches(containerSelector)) patchLabelContainer(parent);
|
||||
}
|
||||
|
||||
// link labels and inputs in `.ui.checkbox` and `.ui.form .field` so labels are clickable and accessible
|
||||
export function initAriaLabels(container: ParentNode) {
|
||||
patchLabels(container, '.ui.checkbox', 'label', 'input', 'data-checkbox-patched');
|
||||
patchLabels(container, '.ui.form .field', ':scope > label', ':scope > input, :scope > select', 'data-field-patched');
|
||||
}
|
||||
|
||||
export function fomanticQuery(s: string | Element | NodeListOf<Element>): ReturnType<typeof $> {
|
||||
// intentionally make it only work for query selector, it isn't used for creating HTML elements (for safety)
|
||||
return typeof s === 'string' ? $(document).find(s) : $(s);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import {queryElemChildren} from '../../utils/dom.ts';
|
||||
|
||||
export function initFomanticDimmer() {
|
||||
// stand-in for removed dimmer module
|
||||
$.fn.dimmer = function (this: any, arg0: string, arg1: any) {
|
||||
if (arg0 === 'add content') {
|
||||
const $el = arg1;
|
||||
const existingDimmer = document.querySelector('body > .ui.dimmer');
|
||||
if (existingDimmer) {
|
||||
queryElemChildren(existingDimmer, '*', (el) => el.classList.add('hidden'));
|
||||
this._dimmer = existingDimmer;
|
||||
} else {
|
||||
this._dimmer = document.createElement('div');
|
||||
this._dimmer.classList.add('ui', 'dimmer');
|
||||
document.body.append(this._dimmer);
|
||||
}
|
||||
this._dimmer.append($el[0]);
|
||||
} else if (arg0 === 'get dimmer') {
|
||||
return $(this._dimmer);
|
||||
} else if (arg0 === 'show') {
|
||||
this._dimmer.classList.add('active');
|
||||
document.body.classList.add('tw-overflow-hidden');
|
||||
} else if (arg0 === 'hide') {
|
||||
const cb = arg1;
|
||||
this._dimmer.classList.remove('active');
|
||||
document.body.classList.remove('tw-overflow-hidden');
|
||||
cb();
|
||||
}
|
||||
return this;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import {createElementFromHTML} from '../../utils/dom.ts';
|
||||
import {hideScopedEmptyDividers} from './dropdown.ts';
|
||||
|
||||
test('hideScopedEmptyDividers-simple', () => {
|
||||
const container = createElementFromHTML(`<div>
|
||||
<div class="divider"></div>
|
||||
<div class="item">a</div>
|
||||
<div class="divider"></div>
|
||||
<div class="divider"></div>
|
||||
<div class="divider"></div>
|
||||
<div class="item">b</div>
|
||||
<div class="divider"></div>
|
||||
</div>`);
|
||||
hideScopedEmptyDividers(container);
|
||||
expect(container.innerHTML).toEqual(`
|
||||
<div class="divider hidden transition"></div>
|
||||
<div class="item">a</div>
|
||||
<div class="divider hidden transition"></div>
|
||||
<div class="divider hidden transition"></div>
|
||||
<div class="divider"></div>
|
||||
<div class="item">b</div>
|
||||
<div class="divider hidden transition"></div>
|
||||
`);
|
||||
});
|
||||
|
||||
test('hideScopedEmptyDividers-items-all-filtered', () => {
|
||||
const container = createElementFromHTML(`<div>
|
||||
<div class="any"></div>
|
||||
<div class="divider"></div>
|
||||
<div class="item filtered">a</div>
|
||||
<div class="item filtered">b</div>
|
||||
<div class="divider"></div>
|
||||
<div class="any"></div>
|
||||
</div>`);
|
||||
hideScopedEmptyDividers(container);
|
||||
expect(container.innerHTML).toEqual(`
|
||||
<div class="any"></div>
|
||||
<div class="divider hidden transition"></div>
|
||||
<div class="item filtered">a</div>
|
||||
<div class="item filtered">b</div>
|
||||
<div class="divider"></div>
|
||||
<div class="any"></div>
|
||||
`);
|
||||
});
|
||||
|
||||
test('hideScopedEmptyDividers-hide-last', () => {
|
||||
const container = createElementFromHTML(`<div>
|
||||
<div class="item">a</div>
|
||||
<div class="divider" data-scope="b"></div>
|
||||
<div class="item tw-hidden" data-scope="b">b</div>
|
||||
</div>`);
|
||||
hideScopedEmptyDividers(container);
|
||||
expect(container.innerHTML).toEqual(`
|
||||
<div class="item">a</div>
|
||||
<div class="divider hidden transition" data-scope="b"></div>
|
||||
<div class="item tw-hidden" data-scope="b">b</div>
|
||||
`);
|
||||
});
|
||||
|
||||
test('hideScopedEmptyDividers-scoped-items', () => {
|
||||
const container = createElementFromHTML(`<div>
|
||||
<div class="item" data-scope="">a</div>
|
||||
<div class="divider" data-scope="b"></div>
|
||||
<div class="item tw-hidden" data-scope="b">b</div>
|
||||
<div class="divider" data-scope=""></div>
|
||||
<div class="item" data-scope="">c</div>
|
||||
</div>`);
|
||||
hideScopedEmptyDividers(container);
|
||||
expect(container.innerHTML).toEqual(`
|
||||
<div class="item" data-scope="">a</div>
|
||||
<div class="divider hidden transition" data-scope="b"></div>
|
||||
<div class="item tw-hidden" data-scope="b">b</div>
|
||||
<div class="divider hidden transition" data-scope=""></div>
|
||||
<div class="item" data-scope="">c</div>
|
||||
`);
|
||||
});
|
||||
@@ -0,0 +1,365 @@
|
||||
import type {FomanticInitFunction} from '../../types.ts';
|
||||
import {generateElemId, queryElems} from '../../utils/dom.ts';
|
||||
|
||||
const ariaPatchKey = '_giteaAriaPatchDropdown';
|
||||
const fomanticDropdownFn = $.fn.dropdown;
|
||||
|
||||
// use our own `$().dropdown` function to patch Fomantic's dropdown module
|
||||
export function initAriaDropdownPatch() {
|
||||
if ($.fn.dropdown === ariaDropdownFn) throw new Error('initAriaDropdownPatch could only be called once');
|
||||
$.fn.dropdown = ariaDropdownFn;
|
||||
$.fn.fomanticExt.onResponseKeepSelectedItem = onResponseKeepSelectedItem;
|
||||
$.fn.fomanticExt.onDropdownAfterFiltered = onDropdownAfterFiltered;
|
||||
(ariaDropdownFn as FomanticInitFunction).settings = fomanticDropdownFn.settings;
|
||||
}
|
||||
|
||||
// the patched `$.fn.dropdown` function, it passes the arguments to Fomantic's `$.fn.dropdown` function, and:
|
||||
// * it does the one-time element event attaching on the first call
|
||||
// * it delegates the module internal functions like `onLabelCreate` to the patched functions to add more features.
|
||||
function ariaDropdownFn(this: any, ...args: Parameters<FomanticInitFunction>) {
|
||||
const ret = fomanticDropdownFn.apply(this, args);
|
||||
|
||||
for (let el of this) {
|
||||
// dropdown will replace '<select class="ui dropdown"/>' to '<div class="ui dropdown"><select (hidden)></select><div class="menu">...</div></div>'
|
||||
// so we need to correctly find the closest '.ui.dropdown' element, it is the real fomantic dropdown module.
|
||||
el = el.closest('.ui.dropdown');
|
||||
if (!el[ariaPatchKey]) {
|
||||
// the elements don't belong to the dropdown "module" and won't be reset
|
||||
// so we only need to initialize them once.
|
||||
attachInitElements(el);
|
||||
}
|
||||
|
||||
// if the `$().dropdown()` is called without arguments, or it has non-string (object) argument,
|
||||
// it means that such call will reset the dropdown "module" including internal settings,
|
||||
// then we need to re-delegate the callbacks.
|
||||
const $dropdown = $(el);
|
||||
const dropdownModule = $dropdown.data('module-dropdown');
|
||||
if (!dropdownModule.giteaDelegated) {
|
||||
dropdownModule.giteaDelegated = true;
|
||||
delegateDropdownModule($dropdown);
|
||||
}
|
||||
}
|
||||
return ret;
|
||||
}
|
||||
|
||||
// make the item has role=option/menuitem, add an id if there wasn't one yet, make items as non-focusable
|
||||
// the elements inside the dropdown menu item should not be focusable, the focus should always be on the dropdown primary element.
|
||||
function updateMenuItem(dropdown: HTMLElement, item: HTMLElement) {
|
||||
if (!item.id) item.id = generateElemId('_aria_dropdown_item_');
|
||||
item.setAttribute('role', (dropdown as any)[ariaPatchKey].listItemRole);
|
||||
item.setAttribute('tabindex', '-1');
|
||||
for (const el of item.querySelectorAll('a, input, button')) el.setAttribute('tabindex', '-1');
|
||||
}
|
||||
/**
|
||||
* make the label item and its "delete icon" have correct aria attributes
|
||||
* @param {HTMLElement} label
|
||||
*/
|
||||
function updateSelectionLabel(label: HTMLElement) {
|
||||
// the "label" is like this: "<a|div class="ui label" data-value="1">the-label-name <i|svg class="delete icon"/></a>"
|
||||
if (!label.id) {
|
||||
label.id = generateElemId('_aria_dropdown_label_');
|
||||
}
|
||||
label.tabIndex = -1;
|
||||
|
||||
const deleteIcon = label.querySelector('.delete.icon');
|
||||
if (deleteIcon) {
|
||||
deleteIcon.setAttribute('aria-hidden', 'false');
|
||||
deleteIcon.setAttribute('aria-label', window.config.i18n.remove_label_str.replace('%s', label.getAttribute('data-value')!));
|
||||
deleteIcon.setAttribute('role', 'button');
|
||||
}
|
||||
}
|
||||
|
||||
function onDropdownAfterFiltered(this: any) {
|
||||
const $dropdown = $(this).closest('.ui.dropdown'); // "this" can be the "ui dropdown" or "<select>"
|
||||
const hideEmptyDividers = $dropdown.dropdown('setting', 'hideDividers') === 'empty';
|
||||
const itemsMenu = $dropdown[0].querySelector('.scrolling.menu') || $dropdown[0].querySelector('.menu');
|
||||
if (hideEmptyDividers && itemsMenu) hideScopedEmptyDividers(itemsMenu);
|
||||
}
|
||||
|
||||
// delegate the dropdown's template functions and callback functions to add aria attributes.
|
||||
function delegateDropdownModule($dropdown: any) {
|
||||
const dropdownCall = fomanticDropdownFn.bind($dropdown);
|
||||
|
||||
// the "template" functions are used for dynamic creation (eg: AJAX)
|
||||
const dropdownTemplates = {...dropdownCall('setting', 'templates'), t: performance.now()};
|
||||
const dropdownTemplatesMenuOld = dropdownTemplates.menu;
|
||||
dropdownTemplates.menu = function(response: any, fields: any, preserveHTML: any, className: Record<string, string>) {
|
||||
// when the dropdown menu items are loaded from AJAX requests, the items are created dynamically
|
||||
const menuItems = dropdownTemplatesMenuOld(response, fields, preserveHTML, className);
|
||||
const div = document.createElement('div');
|
||||
div.innerHTML = menuItems;
|
||||
const $wrapper = $(div);
|
||||
const $items = $wrapper.find('> .item');
|
||||
$items.each((_, item) => updateMenuItem($dropdown[0], item));
|
||||
$dropdown[0][ariaPatchKey].deferredRefreshAriaActiveItem();
|
||||
return $wrapper.html();
|
||||
};
|
||||
dropdownCall('setting', 'templates', dropdownTemplates);
|
||||
|
||||
// the `onLabelCreate` is used to add necessary aria attributes for dynamically created selection labels
|
||||
const dropdownOnLabelCreateOld = dropdownCall('setting', 'onLabelCreate');
|
||||
dropdownCall('setting', 'onLabelCreate', function(this: any, value: any, text: string) {
|
||||
const $label = dropdownOnLabelCreateOld.call(this, value, text);
|
||||
updateSelectionLabel($label[0]);
|
||||
return $label;
|
||||
});
|
||||
|
||||
const oldSet = dropdownCall('internal', 'set');
|
||||
const oldSetDirection = oldSet.direction;
|
||||
oldSet.direction = function($menu: any) {
|
||||
oldSetDirection.call(this, $menu);
|
||||
const classNames = dropdownCall('setting', 'className');
|
||||
$menu = $menu || $dropdown.find('> .menu');
|
||||
const elMenu = $menu[0];
|
||||
// detect whether the menu is outside the viewport, and adjust the position
|
||||
// there is a bug in fomantic's builtin `direction` function, in some cases (when the menu width is only a little larger) it wrongly opens the menu at right and triggers the scrollbar.
|
||||
elMenu.classList.add(classNames.loading);
|
||||
if (elMenu.getBoundingClientRect().right > document.documentElement.clientWidth) {
|
||||
elMenu.classList.add(classNames.leftward);
|
||||
}
|
||||
elMenu.classList.remove(classNames.loading);
|
||||
};
|
||||
}
|
||||
|
||||
// for static dropdown elements (generated by server-side template), prepare them with necessary aria attributes
|
||||
function attachStaticElements(dropdown: HTMLElement, focusable: HTMLElement, menu: HTMLElement) {
|
||||
// prepare static dropdown menu list popup
|
||||
if (!menu.id) {
|
||||
menu.id = generateElemId('_aria_dropdown_menu_');
|
||||
}
|
||||
|
||||
$(menu).find('> .item').each((_, item) => updateMenuItem(dropdown, item));
|
||||
|
||||
// this role could only be changed after its content is ready, otherwise some browsers+readers (like Chrome+AppleVoice) crash
|
||||
menu.setAttribute('role', (dropdown as any)[ariaPatchKey].listPopupRole);
|
||||
|
||||
// prepare selection label items
|
||||
for (const label of dropdown.querySelectorAll<HTMLElement>('.ui.label')) {
|
||||
updateSelectionLabel(label);
|
||||
}
|
||||
|
||||
// make the primary element (focusable) aria-friendly
|
||||
focusable.setAttribute('role', focusable.getAttribute('role') ?? (dropdown as any)[ariaPatchKey].focusableRole);
|
||||
focusable.setAttribute('aria-haspopup', (dropdown as any)[ariaPatchKey].listPopupRole);
|
||||
focusable.setAttribute('aria-controls', menu.id);
|
||||
focusable.setAttribute('aria-expanded', 'false');
|
||||
|
||||
// use tooltip's content as aria-label if there is no aria-label
|
||||
const tooltipContent = dropdown.getAttribute('data-tooltip-content');
|
||||
if (tooltipContent && !dropdown.getAttribute('aria-label')) {
|
||||
dropdown.setAttribute('aria-label', tooltipContent);
|
||||
}
|
||||
}
|
||||
|
||||
function attachInitElements(dropdown: HTMLElement) {
|
||||
(dropdown as any)[ariaPatchKey] = {};
|
||||
|
||||
// Dropdown has 2 different focusing behaviors
|
||||
// * with search input: the input is focused, and it works with aria-activedescendant pointing another sibling element.
|
||||
// * without search input (but the readonly text), the dropdown itself is focused. then the aria-activedescendant points to the element inside dropdown
|
||||
// Some desktop screen readers may change the focus, but dropdown requires that the focus must be on its primary element, then they don't work well.
|
||||
|
||||
// Expected user interactions for dropdown with aria support:
|
||||
// * user can use Tab to focus in the dropdown, then the dropdown menu (list) will be shown
|
||||
// * user presses Tab on the focused dropdown to move focus to next sibling focusable element (but not the menu item)
|
||||
// * user can use arrow key Up/Down to navigate between menu items
|
||||
// * when user presses Enter:
|
||||
// - if the menu item is clickable (eg: <a>), then trigger the click event
|
||||
// - otherwise, the dropdown control (low-level code) handles the Enter event, hides the dropdown menu
|
||||
|
||||
// TODO: multiple selection is only partially supported. Check and test them one by one in the future.
|
||||
|
||||
const textSearch = dropdown.querySelector<HTMLElement>('input.search');
|
||||
const focusable = textSearch || dropdown; // the primary element for focus, see comment above
|
||||
if (!focusable) return;
|
||||
|
||||
// as a combobox, the input should not have autocomplete by default
|
||||
if (textSearch && !textSearch.getAttribute('autocomplete')) {
|
||||
textSearch.setAttribute('autocomplete', 'off');
|
||||
}
|
||||
|
||||
let menu = $(dropdown).find('> .menu')[0];
|
||||
if (!menu) {
|
||||
// some "multiple selection" dropdowns don't have a static menu element in HTML, we need to pre-create it to make it have correct aria attributes
|
||||
menu = document.createElement('div');
|
||||
menu.classList.add('menu');
|
||||
dropdown.append(menu);
|
||||
}
|
||||
|
||||
// There are 2 possible solutions about the role: combobox or menu.
|
||||
// The idea is that if there is an input, then it's a combobox, otherwise it's a menu.
|
||||
// Since #19861 we have prepared the "combobox" solution, but didn't get enough time to put it into practice and test before.
|
||||
const isComboBox = dropdown.querySelectorAll('input').length > 0;
|
||||
|
||||
(dropdown as any)[ariaPatchKey].focusableRole = isComboBox ? 'combobox' : 'menu';
|
||||
(dropdown as any)[ariaPatchKey].listPopupRole = isComboBox ? 'listbox' : '';
|
||||
(dropdown as any)[ariaPatchKey].listItemRole = isComboBox ? 'option' : 'menuitem';
|
||||
|
||||
attachDomEvents(dropdown, focusable, menu);
|
||||
attachStaticElements(dropdown, focusable, menu);
|
||||
}
|
||||
|
||||
function attachDomEvents(dropdown: HTMLElement, focusable: HTMLElement, menu: HTMLElement) {
|
||||
// when showing, it has class: ".animating.in"
|
||||
// when hiding, it has class: ".visible.animating.out"
|
||||
const isMenuVisible = () => (menu.classList.contains('visible') && !menu.classList.contains('out')) || menu.classList.contains('in');
|
||||
|
||||
// update aria attributes according to current active/selected item
|
||||
const refreshAriaActiveItem = () => {
|
||||
const menuVisible = isMenuVisible();
|
||||
focusable.setAttribute('aria-expanded', menuVisible ? 'true' : 'false');
|
||||
|
||||
// if there is an active item, use it (the user is navigating between items)
|
||||
// otherwise use the "selected" for combobox (for the last selected item)
|
||||
const active = $(menu).find('> .item.active, > .item.selected')[0];
|
||||
if (!active) return;
|
||||
// if the popup is visible and has an active/selected item, use its id as aria-activedescendant
|
||||
if (menuVisible) {
|
||||
focusable.setAttribute('aria-activedescendant', active.id);
|
||||
} else if ((dropdown as any)[ariaPatchKey].listPopupRole === 'menu') {
|
||||
// for menu, when the popup is hidden, no need to keep the aria-activedescendant, and clear the active/selected item
|
||||
focusable.removeAttribute('aria-activedescendant');
|
||||
active.classList.remove('active', 'selected');
|
||||
}
|
||||
};
|
||||
|
||||
dropdown.addEventListener('keydown', (e: KeyboardEvent) => {
|
||||
if (e.isComposing) return;
|
||||
// here it must use keydown event before dropdown's keyup handler, otherwise there is no Enter event in our keyup handler
|
||||
if (e.key === 'Enter') {
|
||||
const elItem = menu.querySelector<HTMLElement>(':scope > .item.selected, .menu > .item.selected');
|
||||
// if the selected item is clickable, then trigger the click event.
|
||||
// we can not click any item without check, because Fomantic code might also handle the Enter event. that would result in double click.
|
||||
if (elItem?.matches('a, .js-aria-clickable') && !elItem.matches('.tw-hidden, .filtered')) {
|
||||
e.preventDefault();
|
||||
elItem.click();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// use setTimeout to run the refreshAria in next tick (to make sure the Fomantic UI code has finished its work)
|
||||
// do not return any value, jQuery has return-value related behaviors.
|
||||
// when the popup is hiding, it's better to have a small "delay", because there is a Fomantic UI animation
|
||||
// without the delay for hiding, the UI will be somewhat laggy and sometimes may get stuck in the animation.
|
||||
const deferredRefreshAriaActiveItem = (delay = 0) => { setTimeout(refreshAriaActiveItem, delay) };
|
||||
(dropdown as any)[ariaPatchKey].deferredRefreshAriaActiveItem = deferredRefreshAriaActiveItem;
|
||||
dropdown.addEventListener('keyup', (e) => { if (e.key.startsWith('Arrow')) deferredRefreshAriaActiveItem(); });
|
||||
|
||||
// if the dropdown has been opened by focus, do not trigger the next click event again.
|
||||
// otherwise the dropdown will be closed immediately, especially on Android with TalkBack
|
||||
// * desktop event sequence: mousedown -> focus -> mouseup -> click
|
||||
// * mobile event sequence: focus -> mousedown -> mouseup -> click
|
||||
// Fomantic may stop propagation of blur event, use capture to make sure we can still get the event
|
||||
let ignoreClickPreEvents = 0, ignoreClickPreVisible = 0;
|
||||
dropdown.addEventListener('mousedown', () => {
|
||||
ignoreClickPreVisible += isMenuVisible() ? 1 : 0;
|
||||
ignoreClickPreEvents++;
|
||||
}, true);
|
||||
dropdown.addEventListener('focus', () => {
|
||||
ignoreClickPreVisible += isMenuVisible() ? 1 : 0;
|
||||
ignoreClickPreEvents++;
|
||||
deferredRefreshAriaActiveItem();
|
||||
}, true);
|
||||
dropdown.addEventListener('blur', () => {
|
||||
ignoreClickPreVisible = ignoreClickPreEvents = 0;
|
||||
deferredRefreshAriaActiveItem(100);
|
||||
}, true);
|
||||
dropdown.addEventListener('mouseup', () => {
|
||||
setTimeout(() => {
|
||||
ignoreClickPreVisible = ignoreClickPreEvents = 0;
|
||||
deferredRefreshAriaActiveItem(100);
|
||||
}, 0);
|
||||
}, true);
|
||||
dropdown.addEventListener('click', (e: MouseEvent) => {
|
||||
if (isMenuVisible() &&
|
||||
ignoreClickPreVisible !== 2 && // dropdown is switch from invisible to visible
|
||||
ignoreClickPreEvents === 2 // the click event is related to mousedown+focus
|
||||
) {
|
||||
e.stopPropagation(); // if the dropdown menu has been opened by focus, do not trigger the next click event again
|
||||
}
|
||||
ignoreClickPreEvents = ignoreClickPreVisible = 0;
|
||||
}, true);
|
||||
}
|
||||
|
||||
// Although Fomantic Dropdown supports "hideDividers", it doesn't really work with our "scoped dividers"
|
||||
// At the moment, "label dropdown items" use scopes, a sample case is:
|
||||
// * a-label
|
||||
// * divider
|
||||
// * scope/1
|
||||
// * scope/2
|
||||
// * divider
|
||||
// * z-label
|
||||
// when the "scope/*" are filtered out, we'd like to see "a-label" and "z-label" without the divider.
|
||||
export function hideScopedEmptyDividers(container: Element) {
|
||||
const visibleItems: Element[] = [];
|
||||
const curScopeVisibleItems: Element[] = [];
|
||||
let curScope: string = '', lastVisibleScope: string = '';
|
||||
const isDivider = (item: Element) => item.classList.contains('divider');
|
||||
const isScopedDivider = (item: Element) => isDivider(item) && item.hasAttribute('data-scope');
|
||||
const hideDivider = (item: Element) => item.classList.add('hidden', 'transition'); // dropdown has its own classes to hide items
|
||||
const showDivider = (item: Element) => item.classList.remove('hidden', 'transition');
|
||||
const isHidden = (item: Element) => item.classList.contains('hidden') || item.classList.contains('filtered') || item.classList.contains('tw-hidden');
|
||||
const handleScopeSwitch = (itemScope: string) => {
|
||||
if (curScopeVisibleItems.length === 1 && isScopedDivider(curScopeVisibleItems[0])) {
|
||||
hideDivider(curScopeVisibleItems[0]);
|
||||
} else if (curScopeVisibleItems.length) {
|
||||
if (isScopedDivider(curScopeVisibleItems[0]) && lastVisibleScope === curScope) {
|
||||
hideDivider(curScopeVisibleItems[0]);
|
||||
curScopeVisibleItems.shift();
|
||||
}
|
||||
visibleItems.push(...curScopeVisibleItems);
|
||||
lastVisibleScope = curScope;
|
||||
}
|
||||
curScope = itemScope;
|
||||
curScopeVisibleItems.length = 0;
|
||||
};
|
||||
|
||||
// reset hidden dividers
|
||||
queryElems(container, '.divider', showDivider);
|
||||
|
||||
// hide the scope dividers if the scope items are empty
|
||||
for (const item of container.children) {
|
||||
const itemScope = item.getAttribute('data-scope') || '';
|
||||
if (itemScope !== curScope) {
|
||||
handleScopeSwitch(itemScope);
|
||||
}
|
||||
if (!isHidden(item)) {
|
||||
curScopeVisibleItems.push(item);
|
||||
}
|
||||
}
|
||||
handleScopeSwitch('');
|
||||
|
||||
// hide all leading and trailing dividers
|
||||
while (visibleItems.length) {
|
||||
if (!isDivider(visibleItems[0])) break;
|
||||
hideDivider(visibleItems[0]);
|
||||
visibleItems.shift();
|
||||
}
|
||||
while (visibleItems.length) {
|
||||
if (!isDivider(visibleItems[visibleItems.length - 1])) break;
|
||||
hideDivider(visibleItems[visibleItems.length - 1]);
|
||||
visibleItems.pop();
|
||||
}
|
||||
// hide all duplicate dividers, hide current divider if next sibling is still divider
|
||||
// no need to update "visibleItems" array since this is the last loop
|
||||
for (let i = 0; i < visibleItems.length - 1; i++) {
|
||||
if (!visibleItems[i].matches('.divider')) continue;
|
||||
if (visibleItems[i + 1].matches('.divider')) hideDivider(visibleItems[i]);
|
||||
}
|
||||
}
|
||||
|
||||
function onResponseKeepSelectedItem(dropdown: typeof $ | HTMLElement, selectedValue: string) {
|
||||
// There is a bug in fomantic dropdown when using "apiSettings" to fetch data
|
||||
// * when there is a selected item, the dropdown insists on hiding the selected one from the list:
|
||||
// * in the "filter" function: ('[data-value="'+value+'"]').addClass(className.filtered)
|
||||
//
|
||||
// When user selects one item, and click the dropdown again,
|
||||
// then the dropdown only shows other items and will select another (wrong) one.
|
||||
// It can't be easily fix by using setTimeout(patch, 0) in `onResponse` because the `onResponse` is called before another `setTimeout(..., timeLeft)`
|
||||
// Fortunately, the "timeLeft" is controlled by "loadingDuration" which is always zero at the moment, so we can use `setTimeout(..., 10)`
|
||||
const elDropdown = (dropdown instanceof HTMLElement) ? dropdown : (dropdown as any)[0];
|
||||
setTimeout(() => {
|
||||
queryElems(elDropdown, `.menu .item[data-value="${CSS.escape(selectedValue)}"].filtered`, (el) => el.classList.remove('filtered'));
|
||||
$(elDropdown).dropdown('set selected', selectedValue ?? '');
|
||||
}, 10);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import type {FomanticInitFunction} from '../../types.ts';
|
||||
import {queryElems} from '../../utils/dom.ts';
|
||||
import {hideToastsFrom} from '../toast.ts';
|
||||
|
||||
type ModalOpts = {
|
||||
closable?: boolean;
|
||||
onApprove?: (this: HTMLElement) => boolean | void;
|
||||
onShow?: (this: HTMLElement) => void | Promise<void>;
|
||||
onHide?: (this: HTMLElement) => void;
|
||||
onHidden?: (this: HTMLElement) => void;
|
||||
};
|
||||
|
||||
// thin wrapper around Fomantic's jQuery modal plugin so callers don't have to touch jQuery or fomanticQuery
|
||||
export function showFomanticModal(el: Element | null, opts: ModalOpts = {}) {
|
||||
if (!el) return;
|
||||
const $el = $(el);
|
||||
if (Object.keys(opts).length) $el.modal(opts);
|
||||
$el.modal('show');
|
||||
}
|
||||
|
||||
export function hideFomanticModal(el: Element | null) {
|
||||
if (!el) return;
|
||||
$(el).modal('hide');
|
||||
}
|
||||
|
||||
const fomanticModalFn = $.fn.modal;
|
||||
|
||||
// use our own `$.fn.modal` to patch Fomantic's modal module
|
||||
export function initAriaModalPatch() {
|
||||
if ($.fn.modal === ariaModalFn) throw new Error('initAriaModalPatch could only be called once');
|
||||
$.fn.modal = ariaModalFn;
|
||||
(ariaModalFn as FomanticInitFunction).settings = fomanticModalFn.settings;
|
||||
$.fn.fomanticExt.onModalBeforeHidden = onModalBeforeHidden;
|
||||
$.fn.modal.settings.onApprove = onModalApproveDefault;
|
||||
}
|
||||
|
||||
// the patched `$.fn.modal` modal function
|
||||
// * it does the one-time attaching on the first call
|
||||
function ariaModalFn(this: any, ...args: Parameters<FomanticInitFunction>) {
|
||||
const ret = fomanticModalFn.apply(this, args);
|
||||
if (args[0] === 'show' || args[0]?.autoShow) {
|
||||
for (const el of this) {
|
||||
// If there is a form in the modal, there might be a "cancel" button before "ok" button (all buttons are "type=submit" by default).
|
||||
// In such case, the "Enter" key will trigger the "cancel" button instead of "ok" button, then the dialog will be closed.
|
||||
// It breaks the user experience - the "Enter" key should confirm the dialog and submit the form.
|
||||
// So, all "cancel" buttons without "[type]" must be marked as "type=button".
|
||||
for (const button of el.querySelectorAll('form button.cancel:not([type])')) {
|
||||
button.setAttribute('type', 'button');
|
||||
}
|
||||
}
|
||||
}
|
||||
return ret;
|
||||
}
|
||||
|
||||
function onModalBeforeHidden(this: any) {
|
||||
const $modal = $(this);
|
||||
const elModal = $modal[0];
|
||||
hideToastsFrom(elModal.closest('.ui.dimmer') ?? document.body);
|
||||
|
||||
// reset the form after the modal is hidden, after other modal events and handlers (e.g. "onApprove", form submit)
|
||||
setTimeout(() => {
|
||||
queryElems(elModal, 'form', (form: HTMLFormElement) => form.reset());
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function onModalApproveDefault(this: any) {
|
||||
const $modal = $(this);
|
||||
const selectors = $modal.modal('setting', 'selector');
|
||||
const elModal = $modal[0];
|
||||
const elApprove = elModal.querySelector(selectors.approve);
|
||||
const elForm = elApprove?.closest('form');
|
||||
if (!elForm) return true; // no form, just allow closing the modal
|
||||
|
||||
// "form-fetch-action" can handle network errors gracefully,
|
||||
// so keep the modal dialog to make users can re-submit the form if anything wrong happens.
|
||||
if (elForm.matches('.form-fetch-action')) return false;
|
||||
|
||||
// There is an abuse for the "modal" + "form" combination, the "Approve" button is a traditional form submit button in the form.
|
||||
// Then "approve" and "submit" occur at the same time, the modal will be closed immediately before the form is submitted.
|
||||
// So here we prevent the modal from closing automatically by returning false, add the "is-loading" class to the form element.
|
||||
elForm.classList.add('is-loading');
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import {queryElemSiblings} from '../../utils/dom.ts';
|
||||
|
||||
export function initTabSwitcher(tabItemContainer: Element) {
|
||||
// Clicking a `.item[data-tab]` menu item activates the matching `.ui.tab[data-tab=...]` panel
|
||||
// This design is from Fomantic UI, and it has problems like :
|
||||
// * The panel selector is global, callers should make sure the "data-tab" values don't conflict on the same page
|
||||
const tabItems = tabItemContainer.querySelectorAll('.item[data-tab]');
|
||||
for (const elItem of tabItems) {
|
||||
const tabName = elItem.getAttribute('data-tab')!;
|
||||
elItem.addEventListener('click', () => {
|
||||
const elPanel = document.querySelector(`.ui.tab[data-tab="${tabName}"]`)!;
|
||||
queryElemSiblings(elPanel, '.ui.tab', (el) => el.classList.remove('active'));
|
||||
queryElemSiblings(elItem, '.item[data-tab]', (el) => el.classList.remove('active'));
|
||||
elItem.classList.add('active');
|
||||
elPanel.classList.add('active');
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
export function initFomanticTransition() {
|
||||
const transitionNopBehaviors = new Set([
|
||||
'clear queue', 'stop', 'stop all', 'destroy',
|
||||
'force repaint', 'repaint', 'reset',
|
||||
'looping', 'remove looping', 'disable', 'enable',
|
||||
'set duration', 'save conditions', 'restore conditions',
|
||||
]);
|
||||
// stand-in for removed transition module
|
||||
$.fn.transition = function (arg0: any, arg1: any, arg2: any) {
|
||||
if (arg0 === 'is supported') return true;
|
||||
if (arg0 === 'is animating') return false;
|
||||
if (arg0 === 'is inward') return false;
|
||||
if (arg0 === 'is outward') return false;
|
||||
|
||||
let argObj: Record<string, any>;
|
||||
if (typeof arg0 === 'string') {
|
||||
// many behaviors are no-op now. https://fomantic-ui.com/modules/transition.html#/usage
|
||||
if (transitionNopBehaviors.has(arg0)) return this;
|
||||
// now, the arg0 is an animation name, the syntax: (animation, duration, complete)
|
||||
argObj = {animation: arg0, ...(arg1 && {duration: arg1}), ...(arg2 && {onComplete: arg2})};
|
||||
} else if (typeof arg0 === 'object') {
|
||||
argObj = arg0;
|
||||
} else {
|
||||
throw new Error(`invalid argument: ${arg0}`);
|
||||
}
|
||||
|
||||
const isAnimationIn = argObj.animation?.startsWith('show') || argObj.animation?.endsWith(' in');
|
||||
const isAnimationOut = argObj.animation?.startsWith('hide') || argObj.animation?.endsWith(' out');
|
||||
this.each((_, el) => {
|
||||
let toShow = isAnimationIn;
|
||||
if (!isAnimationIn && !isAnimationOut) {
|
||||
// If the animation is not in/out, then it must be a toggle animation.
|
||||
// Fomantic uses computed styles to check "visibility", but to avoid unnecessary arguments, here it only checks the class.
|
||||
toShow = this.hasClass('hidden'); // maybe it could also check "!this.hasClass('visible')", leave it to the future until there is a real problem.
|
||||
}
|
||||
argObj.onStart?.call(el);
|
||||
if (toShow) {
|
||||
el.classList.remove('hidden');
|
||||
el.classList.add('visible', 'transition');
|
||||
if (argObj.displayType) el.style.setProperty('display', argObj.displayType, 'important');
|
||||
argObj.onShow?.call(el);
|
||||
} else {
|
||||
el.classList.add('hidden');
|
||||
el.classList.remove('visible'); // don't remove the transition class because the Fomantic animation style is `.hidden.transition`.
|
||||
el.style.removeProperty('display');
|
||||
argObj.onHidden?.call(el);
|
||||
}
|
||||
argObj.onComplete?.call(el);
|
||||
});
|
||||
return this;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
// see "models/actions/status.go", if it needs to be used somewhere else, move it to a shared file like "types/actions.ts"
|
||||
export type ActionsStatus = 'unknown' | 'waiting' | 'running' | 'cancelling' | 'success' | 'failure' | 'cancelled' | 'skipped' | 'blocked';
|
||||
export type ActionsArtifactStatus = 'expired' | 'completed';
|
||||
|
||||
export type ActionsRun = {
|
||||
repoId: number,
|
||||
link: string,
|
||||
viewLink: string,
|
||||
title: string,
|
||||
titleHTML: string,
|
||||
status: ActionsStatus,
|
||||
canCancel: boolean,
|
||||
canApprove: boolean,
|
||||
canRerun: boolean,
|
||||
canRerunFailed: boolean,
|
||||
canDeleteArtifact: boolean,
|
||||
done: boolean,
|
||||
workflowID: string,
|
||||
workflowLink: string,
|
||||
isSchedule: boolean,
|
||||
runAttempt: number,
|
||||
attempts: Array<ActionsRunAttempt>,
|
||||
duration: string,
|
||||
triggeredAt: number,
|
||||
triggerEvent: string,
|
||||
jobs: Array<ActionsJob>,
|
||||
commit: {
|
||||
localeCommit: string,
|
||||
localePushedBy: string,
|
||||
shortSHA: string,
|
||||
link: string,
|
||||
pusher: {
|
||||
displayName: string,
|
||||
link: string,
|
||||
},
|
||||
branch: {
|
||||
name: string,
|
||||
link: string,
|
||||
isDeleted: boolean,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export type ActionsRunAttempt = {
|
||||
attempt: number;
|
||||
status: ActionsStatus;
|
||||
done: boolean;
|
||||
link: string;
|
||||
current: boolean;
|
||||
latest: boolean;
|
||||
triggeredAt: number;
|
||||
triggerUserName: string;
|
||||
triggerUserLink: string;
|
||||
};
|
||||
|
||||
export type ActionsJob = {
|
||||
id: number;
|
||||
link: string;
|
||||
jobId: string;
|
||||
name: string;
|
||||
status: ActionsStatus;
|
||||
canRerun: boolean;
|
||||
needs?: string[];
|
||||
duration: string;
|
||||
|
||||
isReusableCaller: boolean;
|
||||
parentJobID: number; // 0 for top-level jobs.
|
||||
callUses?: string;
|
||||
};
|
||||
|
||||
export type ActionsArtifact = {
|
||||
name: string;
|
||||
size: number;
|
||||
status: ActionsArtifactStatus;
|
||||
expiresUnix: number;
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
import {trN} from './i18n.ts';
|
||||
import {getCurrentLocale} from '../utils.ts';
|
||||
|
||||
vi.mock('../utils.ts', () => ({getCurrentLocale: vi.fn()}));
|
||||
|
||||
test('trN', () => {
|
||||
vi.mocked(getCurrentLocale).mockReturnValue('en-US');
|
||||
expect(trN(0, '%d job', '%d jobs')).toEqual('0 jobs');
|
||||
expect(trN(1, '%d job', '%d jobs')).toEqual('1 job');
|
||||
expect(trN(2, '%d job', '%d jobs')).toEqual('2 jobs');
|
||||
expect(trN(1000, '%d job', '%d jobs')).toEqual('1000 jobs');
|
||||
// languages without a distinct singular always use the plural form
|
||||
vi.mocked(getCurrentLocale).mockReturnValue('zh-CN');
|
||||
expect(trN(1, '%d job', '%d jobs')).toEqual('1 jobs');
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
import {getCurrentLocale} from '../utils.ts';
|
||||
|
||||
/** frontend `Locale.TrN`: pick the `_1` or `_n` form for `count` and interpolate `%d` */
|
||||
export function trN(count: number, form1: string, formN: string): string {
|
||||
const form = new Intl.PluralRules(getCurrentLocale()).select(count) === 'one' ? form1 : formN;
|
||||
return form.replace('%d', String(count));
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
export class InitPerformanceTracer {
|
||||
results: {name: string, dur: number}[] = [];
|
||||
recordCall(name: string, func: () => void) {
|
||||
const start = performance.now();
|
||||
func();
|
||||
this.results.push({name, dur: performance.now() - start});
|
||||
}
|
||||
printResults() {
|
||||
this.results = this.results.sort((a, b) => b.dur - a.dur);
|
||||
for (let i = 0; i < 20 && i < this.results.length; i++) {
|
||||
console.info(`performance trace: ${this.results[i].name} ${this.results[i].dur.toFixed(3)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function callInitFunctions(functions: (() => any)[]): InitPerformanceTracer | null {
|
||||
// Start performance trace by accessing a URL by "https://localhost/?_ui_performance_trace=1" or "https://localhost/?key=value&_ui_performance_trace=1"
|
||||
// It is a quick check, no side effect so no need to do slow URL parsing.
|
||||
const perfTracer = !window.location.search.includes('_ui_performance_trace=1') ? null : new InitPerformanceTracer();
|
||||
if (perfTracer) {
|
||||
for (const func of functions) perfTracer.recordCall(func.name, func);
|
||||
} else {
|
||||
for (const func of functions) func();
|
||||
}
|
||||
return perfTracer;
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import {isDocumentFragmentOrElementNode} from '../utils/dom.ts';
|
||||
import type {Promisable} from '../types.ts';
|
||||
import type {InitPerformanceTracer} from './init.ts';
|
||||
import {initAriaLabels} from './fomantic/base.ts';
|
||||
|
||||
let globalSelectorObserverInited = false;
|
||||
|
||||
type SelectorHandler<T extends Element> = {selector: string, handler: (el: T) => void};
|
||||
const selectorHandlers: SelectorHandler<Element>[] = [];
|
||||
|
||||
type GlobalEventFunc<T extends HTMLElement, E extends Event> = (el: T, e: E) => Promisable<void>;
|
||||
const globalEventFuncs: Record<string, GlobalEventFunc<HTMLElement, Event>> = {};
|
||||
|
||||
type GlobalInitFunc<T extends Element> = (el: T) => Promisable<void>;
|
||||
const globalInitFuncs: Record<string, GlobalInitFunc<Element>> = {};
|
||||
|
||||
// It handles the global events for all `<div data-global-click="onSomeElemClick"></div>` elements.
|
||||
export function registerGlobalEventFunc<T extends HTMLElement, E extends Event>(event: string, name: string, func: GlobalEventFunc<T, E>) {
|
||||
globalEventFuncs[`${event}:${name}`] = func as GlobalEventFunc<HTMLElement, Event>;
|
||||
}
|
||||
|
||||
// It handles the global init functions by a selector, for example:
|
||||
// > registerGlobalSelectorObserver('.ui.dropdown:not(.custom)', (el) => { initDropdown(el, ...) });
|
||||
// ATTENTION: For most cases, it's recommended to use registerGlobalInitFunc instead,
|
||||
// Because this selector-based approach is less efficient and less maintainable.
|
||||
// But if there are already a lot of elements on many pages, this selector-based approach is more convenient for exiting code.
|
||||
export function registerGlobalSelectorFunc<T extends Element>(selector: string, handler: (el: T) => void) {
|
||||
selectorHandlers.push({selector, handler: handler as (el: Element) => void});
|
||||
// Then initAddedElementObserver will call this handler for all existing elements after all handlers are added.
|
||||
// This approach makes the init stage only need to do one "querySelectorAll".
|
||||
if (!globalSelectorObserverInited) return;
|
||||
for (const el of document.querySelectorAll<T>(selector)) {
|
||||
handler(el);
|
||||
}
|
||||
}
|
||||
|
||||
// It handles the global init functions for all `<div data-global-int="initSomeElem"></div>` elements.
|
||||
export function registerGlobalInitFunc<T extends HTMLElement>(name: string, handler: GlobalInitFunc<T>) {
|
||||
globalInitFuncs[name] = handler as GlobalInitFunc<Element>;
|
||||
// The "global init" functions are managed internally and called by callGlobalInitFunc
|
||||
// They must be ready before initGlobalSelectorObserver is called.
|
||||
if (globalSelectorObserverInited) throw new Error('registerGlobalInitFunc() must be called before initGlobalSelectorObserver()');
|
||||
}
|
||||
|
||||
function callGlobalInitFunc(el: Element) {
|
||||
// TODO: GLOBAL-INIT-MULTIPLE-FUNCTIONS: maybe in the future we need to extend it to support multiple functions, for example: `data-global-init="func1 func2 func3"`
|
||||
const initFunc = el.getAttribute('data-global-init')!;
|
||||
const func = globalInitFuncs[initFunc];
|
||||
if (!func) throw new Error(`Global init function "${initFunc}" not found`);
|
||||
|
||||
// when an element node is removed and added again, it should not be re-initialized again.
|
||||
type GiteaGlobalInitElement = Partial<Element> & {_giteaGlobalInited: boolean};
|
||||
if ((el as GiteaGlobalInitElement)._giteaGlobalInited) return;
|
||||
(el as GiteaGlobalInitElement)._giteaGlobalInited = true;
|
||||
|
||||
func(el);
|
||||
}
|
||||
|
||||
function attachGlobalEvents() {
|
||||
// add global "[data-global-click]" event handler
|
||||
document.addEventListener('click', (e) => {
|
||||
const elem = (e.target as HTMLElement).closest<HTMLElement>('[data-global-click]');
|
||||
if (!elem) return;
|
||||
const funcName = elem.getAttribute('data-global-click');
|
||||
const func = globalEventFuncs[`click:${funcName}`];
|
||||
if (!func) throw new Error(`Global event function "click:${funcName}" not found`);
|
||||
func(elem, e);
|
||||
});
|
||||
}
|
||||
|
||||
export function initGlobalSelectorObserver(perfTracer: InitPerformanceTracer | null): void {
|
||||
if (globalSelectorObserverInited) throw new Error('initGlobalSelectorObserver() already called');
|
||||
globalSelectorObserverInited = true;
|
||||
|
||||
attachGlobalEvents();
|
||||
|
||||
selectorHandlers.push({selector: '[data-global-init]', handler: callGlobalInitFunc});
|
||||
const observer = new MutationObserver((mutationList) => {
|
||||
const len = mutationList.length;
|
||||
for (let i = 0; i < len; i++) {
|
||||
const mutation = mutationList[i];
|
||||
const len = mutation.addedNodes.length;
|
||||
for (let i = 0; i < len; i++) {
|
||||
const addedNode = mutation.addedNodes[i] as ParentNode;
|
||||
if (!isDocumentFragmentOrElementNode(addedNode)) continue;
|
||||
|
||||
initAriaLabels(addedNode);
|
||||
for (const {selector, handler} of selectorHandlers) {
|
||||
if ((addedNode instanceof Element) && addedNode.matches(selector)) {
|
||||
handler(addedNode);
|
||||
}
|
||||
for (const el of addedNode.querySelectorAll?.<HTMLElement>(selector) ?? []) {
|
||||
handler(el);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
initAriaLabels(document);
|
||||
if (perfTracer) {
|
||||
for (const {selector, handler} of selectorHandlers) {
|
||||
perfTracer.recordCall(`initGlobalSelectorObserver ${selector}`, () => {
|
||||
for (const el of document.querySelectorAll<HTMLElement>(selector)) {
|
||||
handler(el);
|
||||
}
|
||||
});
|
||||
}
|
||||
} else {
|
||||
for (const {selector, handler} of selectorHandlers) {
|
||||
for (const el of document.querySelectorAll<HTMLElement>(selector)) {
|
||||
handler(el);
|
||||
}
|
||||
}
|
||||
}
|
||||
observer.observe(document, {subtree: true, childList: true});
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import {debounce} from 'throttle-debounce';
|
||||
import {GET} from './fetch.ts';
|
||||
import {errorName} from './errors.ts';
|
||||
import {html, htmlRaw} from '../utils/html.ts';
|
||||
import {urlQueryEscape} from '../utils/url.ts';
|
||||
|
||||
export type SearchResult = {
|
||||
title: string;
|
||||
description?: string;
|
||||
image?: string;
|
||||
};
|
||||
|
||||
function buildResultHTML(result: SearchResult): string {
|
||||
const img = result.image ? html`<div class="image"><img src="${result.image}" alt=""></div>` : '';
|
||||
const desc = result.description ? html`<div class="description">${result.description}</div>` : '';
|
||||
return html`${htmlRaw(img)}<div class="content"><div class="title">${result.title}</div>${htmlRaw(desc)}</div>`;
|
||||
}
|
||||
|
||||
function buildResultElement(result: SearchResult): HTMLElement {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'result';
|
||||
item.innerHTML = buildResultHTML(result);
|
||||
return item;
|
||||
}
|
||||
|
||||
// single delegated outside-click handler; each attachSearchBox registers a {container, hide} entry
|
||||
const outsideClickBoxes = new Set<{container: HTMLElement; hide: () => void}>();
|
||||
document.addEventListener('click', (event) => {
|
||||
for (const box of outsideClickBoxes) {
|
||||
if (!box.container.contains(event.target as Node)) box.hide();
|
||||
}
|
||||
});
|
||||
|
||||
/** Attach an API-driven autocomplete to `container`. `parse` maps the raw JSON response into the rendered result list. The selected result's title is written to the input on selection. */
|
||||
export function attachSearchBox<T = unknown>(container: HTMLElement, url: string, parse: (raw: T, query: string) => SearchResult[], {minCharacters = 2}: {minCharacters?: number} = {}): void {
|
||||
const input = container.querySelector<HTMLInputElement>('input.prompt') ?? container.querySelector<HTMLInputElement>('input');
|
||||
if (!input) return;
|
||||
|
||||
let resultsEl = container.querySelector<HTMLElement>(':scope > .results');
|
||||
if (!resultsEl) {
|
||||
resultsEl = document.createElement('div');
|
||||
resultsEl.className = 'results';
|
||||
container.append(resultsEl);
|
||||
}
|
||||
const itemResults = new Map<HTMLElement, SearchResult>();
|
||||
let fetchController: AbortController | null = null;
|
||||
|
||||
const hide = () => {
|
||||
fetchController?.abort();
|
||||
resultsEl.style.display = 'none';
|
||||
resultsEl.replaceChildren();
|
||||
itemResults.clear();
|
||||
};
|
||||
|
||||
const render = (results: SearchResult[]) => {
|
||||
if (!results.length) return hide();
|
||||
itemResults.clear();
|
||||
resultsEl.replaceChildren(...results.map((result) => {
|
||||
const item = buildResultElement(result);
|
||||
itemResults.set(item, result);
|
||||
return item;
|
||||
}));
|
||||
resultsEl.style.display = 'block';
|
||||
};
|
||||
|
||||
const select = (item: HTMLElement) => {
|
||||
input.value = itemResults.get(item)!.title;
|
||||
input.dispatchEvent(new Event('change', {bubbles: true}));
|
||||
hide();
|
||||
};
|
||||
|
||||
const search = debounce(200, async (query: string) => {
|
||||
fetchController?.abort();
|
||||
if (query.length < minCharacters) return hide();
|
||||
const ctrl = (fetchController = new AbortController());
|
||||
try {
|
||||
const response = await GET(url.replaceAll('{query}', urlQueryEscape(query)), {signal: ctrl.signal});
|
||||
if (!response.ok) return hide();
|
||||
const results = parse(await response.json(), query);
|
||||
// only render if the fetch wasn't aborted (e.g. by hide()) and the input still matches
|
||||
if (!ctrl.signal.aborted && input.value === query) render(results);
|
||||
} catch (err) {
|
||||
if (errorName(err) !== 'AbortError') hide();
|
||||
}
|
||||
});
|
||||
// cancel + hide ensures a debounced fetch scheduled before any of these can't fire afterwards
|
||||
const dismiss = () => { search.cancel(); hide() };
|
||||
|
||||
input.addEventListener('input', () => search(input.value));
|
||||
input.addEventListener('focus', () => { if (itemResults.size) resultsEl.style.display = 'block'; });
|
||||
input.addEventListener('blur', () => { search.cancel(); setTimeout(hide, 150) }); // hide deferred so a result mousedown can land first
|
||||
input.addEventListener('keydown', (event) => {
|
||||
const resultEls = Array.from(resultsEl.querySelectorAll<HTMLElement>('.result'));
|
||||
if (!resultEls.length) return;
|
||||
const index = resultEls.findIndex((item) => item.classList.contains('active'));
|
||||
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
resultEls[index]?.classList.remove('active');
|
||||
const next = event.key === 'ArrowDown' ? (index + 1) % resultEls.length : index <= 0 ? resultEls.length - 1 : index - 1;
|
||||
resultEls[next].classList.add('active');
|
||||
} else if (event.key === 'Enter' && index >= 0) {
|
||||
event.preventDefault();
|
||||
select(resultEls[index]);
|
||||
} else if (event.key === 'Escape') {
|
||||
dismiss();
|
||||
}
|
||||
});
|
||||
// mousedown fires before input blur so the selection registers before blur-hide kicks in
|
||||
resultsEl.addEventListener('mousedown', (event) => {
|
||||
const target = (event.target as HTMLElement).closest<HTMLElement>('.result');
|
||||
if (!target) return;
|
||||
event.preventDefault();
|
||||
select(target);
|
||||
});
|
||||
outsideClickBoxes.add({container, hide: dismiss});
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import {registerGlobalInitFunc} from './observer.ts';
|
||||
import {hideElem, toggleElem} from '../utils/dom.ts';
|
||||
|
||||
function initShortcutKbd(kbd: HTMLElement) {
|
||||
// Handle initial state: hide the kbd hint if the associated input already has a value
|
||||
// (e.g., from browser autofill or back/forward navigation cache)
|
||||
const elem = elemFromKbd(kbd);
|
||||
if (elem?.value) hideElem(kbd);
|
||||
kbd.setAttribute('aria-hidden', 'true');
|
||||
kbd.setAttribute('aria-keyshortcuts', kbd.getAttribute('data-shortcut-keys')!);
|
||||
}
|
||||
|
||||
function shortcutWrapper(el: HTMLElement): HTMLElement | null {
|
||||
const parent = el.parentElement;
|
||||
return parent?.matches('.global-shortcut-wrapper') ? parent : null;
|
||||
}
|
||||
|
||||
function elemFromKbd(kbd: HTMLElement): HTMLInputElement | HTMLTextAreaElement | null {
|
||||
return shortcutWrapper(kbd)?.querySelector<HTMLInputElement>('input, textarea') || null;
|
||||
}
|
||||
|
||||
function kbdFromElem(input: HTMLElement): HTMLElement | null {
|
||||
return shortcutWrapper(input)?.querySelector<HTMLElement>('kbd') || null;
|
||||
}
|
||||
|
||||
export function initGlobalShortcut() {
|
||||
registerGlobalInitFunc('onGlobalShortcut', initShortcutKbd);
|
||||
|
||||
// A <kbd> element next to an <input> declares a keyboard shortcut for that input.
|
||||
// When the matching key is pressed, the sibling input is focused.
|
||||
// When Escape is pressed inside such an input, the input is cleared and blurred.
|
||||
// The <kbd> element is shown/hidden automatically based on input focus and value.
|
||||
document.addEventListener('keydown', (e: KeyboardEvent) => {
|
||||
// Modifier keys are not supported yet
|
||||
if (e.ctrlKey || e.metaKey || e.altKey) return;
|
||||
|
||||
const target = e.target as HTMLElement;
|
||||
|
||||
// Handle Escape: clear and blur inputs that have an associated keyboard shortcut
|
||||
if (e.key === 'Escape') {
|
||||
const kbd = kbdFromElem(target);
|
||||
if (kbd) {
|
||||
(target as HTMLInputElement).value = '';
|
||||
(target as HTMLInputElement).blur();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't trigger shortcuts when typing in input fields or contenteditable areas
|
||||
if (target.matches('input, textarea, select') || target.isContentEditable) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Find kbd element with matching shortcut (case-insensitive), then focus its sibling input
|
||||
const key = e.key.toLowerCase();
|
||||
// At the moment, only a simple match. In the future, it can be extended to support modifiers and key combinations
|
||||
const kbd = document.querySelector<HTMLElement>(`.global-shortcut-wrapper > kbd[data-shortcut-keys="${CSS.escape(key)}"]`);
|
||||
if (!kbd) return;
|
||||
e.preventDefault();
|
||||
elemFromKbd(kbd)!.focus();
|
||||
});
|
||||
|
||||
// Toggle kbd shortcut hint visibility on input focus/blur
|
||||
document.addEventListener('focusin', (e) => {
|
||||
const kbd = kbdFromElem(e.target as HTMLElement);
|
||||
if (!kbd) return;
|
||||
hideElem(kbd);
|
||||
});
|
||||
|
||||
document.addEventListener('focusout', (e) => {
|
||||
const kbd = kbdFromElem(e.target as HTMLElement);
|
||||
if (!kbd) return;
|
||||
const hasContent = Boolean((e.target as HTMLInputElement).value);
|
||||
toggleElem(kbd, !hasContent);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import type {SortableOptions, SortableEvent} from 'sortablejs';
|
||||
import type SortableType from 'sortablejs';
|
||||
|
||||
export async function createSortable(el: HTMLElement, opts: {handle?: string} & SortableOptions = {}): Promise<SortableType> {
|
||||
// type reassigned because typescript derives the wrong type from this import
|
||||
const {Sortable} = (await import('sortablejs') as unknown as {Sortable: typeof SortableType});
|
||||
|
||||
return new Sortable(el, {
|
||||
animation: 150,
|
||||
ghostClass: 'card-ghost',
|
||||
onChoose: (e: SortableEvent) => {
|
||||
const handle = opts.handle ? e.item.querySelector(opts.handle)! : e.item;
|
||||
handle.classList.add('tw-cursor-grabbing');
|
||||
opts.onChoose?.(e);
|
||||
},
|
||||
onUnchoose: (e: SortableEvent) => {
|
||||
const handle = opts.handle ? e.item.querySelector(opts.handle)! : e.item;
|
||||
handle.classList.remove('tw-cursor-grabbing');
|
||||
opts.onUnchoose?.(e);
|
||||
},
|
||||
...opts,
|
||||
} satisfies SortableOptions);
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
import tippy, {followCursor} from 'tippy.js';
|
||||
import {isDocumentFragmentOrElementNode} from '../utils/dom.ts';
|
||||
import type {Content, Instance, Placement, Props} from 'tippy.js';
|
||||
import {html} from '../utils/html.ts';
|
||||
import {stripTags} from '../utils.ts';
|
||||
|
||||
type TippyOpts = {
|
||||
role?: string,
|
||||
theme?: 'default' | 'tooltip' | 'menu' | 'box-with-header' | 'bare',
|
||||
} & Partial<Props>;
|
||||
|
||||
const visibleInstances = new Set<Instance>();
|
||||
const arrowSvg = html`<svg width="16" height="7"><path d="m0 7 8-7 8 7Z" class="tippy-svg-arrow-outer"/><path d="m0 8 8-7 8 7Z" class="tippy-svg-arrow-inner"/></svg>`;
|
||||
|
||||
export function createTippy(target: Element, opts: TippyOpts = {}): Instance {
|
||||
// the callback functions should be destructured from opts,
|
||||
// because we should use our own wrapper functions to handle them, do not let the user override them
|
||||
const {onHide, onShow, onDestroy, role, theme, arrow, ...other} = opts;
|
||||
|
||||
const instance: Instance = tippy(target, {
|
||||
appendTo: document.body,
|
||||
animation: false,
|
||||
allowHTML: false,
|
||||
hideOnClick: false,
|
||||
interactiveBorder: 20,
|
||||
ignoreAttributes: true,
|
||||
maxWidth: 500, // increase over default 350px
|
||||
onHide: (instance: Instance) => {
|
||||
visibleInstances.delete(instance);
|
||||
return onHide?.(instance);
|
||||
},
|
||||
onDestroy: (instance: Instance) => {
|
||||
visibleInstances.delete(instance);
|
||||
return onDestroy?.(instance);
|
||||
},
|
||||
onShow: (instance: Instance) => {
|
||||
// hide other tooltip instances so only one tooltip shows at a time
|
||||
for (const visibleInstance of visibleInstances) {
|
||||
if (visibleInstance.props.role === 'tooltip') {
|
||||
visibleInstance.hide();
|
||||
}
|
||||
}
|
||||
visibleInstances.add(instance);
|
||||
target.setAttribute('aria-controls', instance.popper.id);
|
||||
return onShow?.(instance);
|
||||
},
|
||||
arrow: arrow ?? (theme === 'bare' ? false : arrowSvg),
|
||||
// HTML role attribute, ideally the default role would be "popover" but it does not exist
|
||||
role: role || 'menu',
|
||||
// CSS theme, either "default", "tooltip", "menu", "box-with-header" or "bare"
|
||||
theme: theme || role || 'default',
|
||||
offset: [0, arrow ? 10 : 6],
|
||||
plugins: [followCursor],
|
||||
...other,
|
||||
} satisfies Partial<Props>);
|
||||
|
||||
if (instance.props.role === 'menu') {
|
||||
target.setAttribute('aria-haspopup', 'true');
|
||||
}
|
||||
|
||||
return instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach a tooltip tippy to the given target element.
|
||||
* If the target element already has a tooltip tippy attached, the tooltip will be updated with the new content.
|
||||
* If the target element has no content, then no tooltip will be attached, and it returns null.
|
||||
*
|
||||
* Note: "tooltip" doesn't equal to "tippy". "tooltip" means a auto-popup content, it just uses tippy as the implementation.
|
||||
*/
|
||||
function attachTooltip(target: Element, content: Content | null = null): Instance | null {
|
||||
switchTitleToTooltip(target);
|
||||
|
||||
content = content ?? target.getAttribute('data-tooltip-content');
|
||||
if (!content) return null;
|
||||
|
||||
// when element has a clipboard target, we update the tooltip after copy
|
||||
// in which case it is undesirable to automatically hide it on click as
|
||||
// it would momentarily flash the tooltip out and in.
|
||||
const hasClipboardTarget = target.hasAttribute('data-clipboard-target');
|
||||
const hideOnClick = !hasClipboardTarget;
|
||||
|
||||
const props: TippyOpts = {
|
||||
content,
|
||||
delay: 100,
|
||||
role: 'tooltip',
|
||||
theme: 'tooltip',
|
||||
hideOnClick,
|
||||
allowHTML: target.getAttribute('data-tooltip-render') === 'html',
|
||||
placement: target.getAttribute('data-tooltip-placement') as Placement || 'top-start',
|
||||
followCursor: target.getAttribute('data-tooltip-follow-cursor') as Props['followCursor'] || false,
|
||||
...(target.getAttribute('data-tooltip-interactive') === 'true' ? {interactive: true, aria: {content: 'describedby', expanded: false}} : {}),
|
||||
};
|
||||
|
||||
if (!target._tippy) {
|
||||
createTippy(target, props);
|
||||
} else {
|
||||
target._tippy.setProps(props);
|
||||
}
|
||||
return target._tippy;
|
||||
}
|
||||
|
||||
function switchTitleToTooltip(target: Element): void {
|
||||
const title = target.getAttribute('title');
|
||||
if (title) {
|
||||
target.setAttribute('data-tooltip-content', title);
|
||||
target.setAttribute('aria-label', title);
|
||||
target.setAttribute('title', '');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creating tooltip tippy instance is expensive, so we only create it when the user hovers over the element
|
||||
* According to https://www.w3.org/TR/DOM-Level-3-Events/#events-mouseevent-event-order , mouseover event is fired before mouseenter event
|
||||
* Some browsers like PaleMoon don't support "addEventListener('mouseenter', capture)"
|
||||
* The tippy by default uses "mouseenter" event to show, so we use "mouseover" event to switch to tippy
|
||||
*/
|
||||
function lazyTooltipOnMouseHover(this: HTMLElement, e: Event): void {
|
||||
(e.target as HTMLElement).removeEventListener('mouseover', lazyTooltipOnMouseHover, true);
|
||||
attachTooltip(this);
|
||||
}
|
||||
|
||||
// Activate the tooltip for current element.
|
||||
// If the element has no aria-label, use the tooltip content as aria-label.
|
||||
function attachLazyTooltip(el: HTMLElement): void {
|
||||
el.addEventListener('mouseover', lazyTooltipOnMouseHover, {capture: true});
|
||||
|
||||
// meanwhile, if the element has no aria-label, use the tooltip content as aria-label
|
||||
if (!el.hasAttribute('aria-label')) {
|
||||
const content = el.getAttribute('data-tooltip-content');
|
||||
if (content) {
|
||||
const isHtml = el.getAttribute('data-tooltip-render') === 'html';
|
||||
let ariaLabelValue = content;
|
||||
if (isHtml) ariaLabelValue = stripTags(content).replace(/\s+/g, ' ').trim();
|
||||
el.setAttribute('aria-label', ariaLabelValue);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Activate the tooltip for all children elements.
|
||||
function attachChildrenLazyTooltip(target: HTMLElement): void {
|
||||
for (const el of target.querySelectorAll<HTMLElement>('[data-tooltip-content]')) {
|
||||
attachLazyTooltip(el);
|
||||
}
|
||||
}
|
||||
|
||||
export function initGlobalTooltips(): void {
|
||||
// use MutationObserver to detect new "data-tooltip-content" elements added to the DOM, or attributes changed
|
||||
const observerConnect = (observer: MutationObserver) => observer.observe(document, {
|
||||
subtree: true,
|
||||
childList: true,
|
||||
attributeFilter: ['data-tooltip-content'],
|
||||
});
|
||||
const observer = new MutationObserver((mutationList, observer) => {
|
||||
const pending = observer.takeRecords();
|
||||
observer.disconnect();
|
||||
for (const mutation of [...mutationList, ...pending]) {
|
||||
if (mutation.type === 'childList') {
|
||||
// mainly for Vue components and AJAX rendered elements
|
||||
for (const el of mutation.addedNodes as NodeListOf<HTMLElement>) {
|
||||
if (!isDocumentFragmentOrElementNode(el)) continue;
|
||||
attachChildrenLazyTooltip(el);
|
||||
if (el.hasAttribute('data-tooltip-content')) {
|
||||
attachLazyTooltip(el);
|
||||
}
|
||||
}
|
||||
} else if (mutation.type === 'attributes') {
|
||||
attachTooltip(mutation.target as Element);
|
||||
}
|
||||
}
|
||||
observerConnect(observer);
|
||||
});
|
||||
observerConnect(observer);
|
||||
|
||||
attachChildrenLazyTooltip(document.documentElement);
|
||||
}
|
||||
|
||||
export function showTemporaryTooltip(target: Element, content: Content): void {
|
||||
// if the target is inside a dropdown or tippy popup, the menu will be hidden soon
|
||||
// so display the tooltip on the "aria-controls" element or dropdown instead
|
||||
let refClientRect: DOMRect | undefined;
|
||||
const popupTippyId = target.closest(`[data-tippy-root]`)?.id;
|
||||
if (popupTippyId) {
|
||||
// for example, the "Copy Permalink" button in the "File View" page for the selected lines
|
||||
target = document.body;
|
||||
refClientRect = document.querySelector(`[aria-controls="${CSS.escape(popupTippyId)}"]`)?.getBoundingClientRect();
|
||||
refClientRect = refClientRect ?? new DOMRect(0, 0, 0, 0); // fallback to empty rect if not found, tippy doesn't accept null
|
||||
} else {
|
||||
// for example, the "Copy Link" button in the issue header dropdown menu
|
||||
target = target.closest('.ui.dropdown') ?? target;
|
||||
refClientRect = target.getBoundingClientRect();
|
||||
}
|
||||
const tooltipTippy = target._tippy ?? attachTooltip(target, content);
|
||||
tooltipTippy.setContent(content);
|
||||
tooltipTippy.setProps({getReferenceClientRect: () => refClientRect});
|
||||
if (!tooltipTippy.state.isShown) tooltipTippy.show();
|
||||
|
||||
tooltipTippy.setProps({
|
||||
onHidden: (tippy) => {
|
||||
// reset the default tooltip content, if no default, then this temporary tooltip could be destroyed
|
||||
if (!attachTooltip(target)) {
|
||||
tippy.destroy();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// on elements where the tooltip is re-located like "Copy Link" inside fomantic dropdowns, tippy.js gets
|
||||
// no `mouseout` event and the tooltip stays visible, hide it with timeout.
|
||||
if (!popupTippyId) {
|
||||
setTimeout(() => {
|
||||
if (tooltipTippy.state.isVisible) tooltipTippy.hide();
|
||||
}, 1500);
|
||||
}
|
||||
}
|
||||
|
||||
export function getAttachedTippyInstance(el: Element): Instance | null {
|
||||
return el._tippy ?? null;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import {showInfoToast, showErrorToast, showWarningToast} from './toast.ts';
|
||||
|
||||
test('showInfoToast', async () => {
|
||||
showInfoToast('success 😀', {duration: -1});
|
||||
expect(document.querySelector('.toastify')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('showWarningToast', async () => {
|
||||
showWarningToast('warning 😐', {duration: -1});
|
||||
expect(document.querySelector('.toastify')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('showErrorToast', async () => {
|
||||
showErrorToast('error 🙁', {duration: -1});
|
||||
expect(document.querySelector('.toastify')).toBeTruthy();
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
import {htmlEscape} from '../utils/html.ts';
|
||||
import {svg} from '../svg.ts';
|
||||
import {animateOnce, queryElems, showElem} from '../utils/dom.ts';
|
||||
import Toastify from 'toastify-js'; // don't use "async import", because when network error occurs, the "async import" also fails and nothing is shown
|
||||
import type {Intent} from '../types.ts';
|
||||
import type {SvgName} from '../svg.ts';
|
||||
import type {Options} from 'toastify-js';
|
||||
import type StartToastifyInstance from 'toastify-js';
|
||||
|
||||
export type Toast = ReturnType<typeof StartToastifyInstance>;
|
||||
|
||||
type ToastLevels = {
|
||||
[intent in Intent]: {
|
||||
icon: SvgName,
|
||||
background: string,
|
||||
duration: number,
|
||||
}
|
||||
};
|
||||
|
||||
const levels: ToastLevels = {
|
||||
info: {
|
||||
icon: 'octicon-check',
|
||||
background: 'var(--color-green)',
|
||||
duration: 2500,
|
||||
},
|
||||
warning: {
|
||||
icon: 'gitea-exclamation',
|
||||
background: 'var(--color-orange)',
|
||||
duration: -1, // requires dismissal to hide
|
||||
},
|
||||
error: {
|
||||
icon: 'gitea-exclamation',
|
||||
background: 'var(--color-red)',
|
||||
duration: -1, // requires dismissal to hide
|
||||
},
|
||||
};
|
||||
|
||||
type ToastOpts = {
|
||||
useHtmlBody?: boolean,
|
||||
preventDuplicates?: boolean | string,
|
||||
} & Options;
|
||||
|
||||
type ToastifyElement = HTMLElement & {_giteaToastifyInstance?: Toast};
|
||||
|
||||
/** See https://github.com/apvarun/toastify-js#api for options */
|
||||
function showToast(message: string, level: Intent, {gravity, position, duration, useHtmlBody, preventDuplicates = true, ...other}: ToastOpts = {}): Toast | null {
|
||||
const body = useHtmlBody ? message : htmlEscape(message);
|
||||
const parent = document.querySelector('.ui.dimmer.active') ?? document.body;
|
||||
const duplicateKey = preventDuplicates ? (preventDuplicates === true ? `${level}-${body}` : preventDuplicates) : '';
|
||||
|
||||
// prevent showing duplicate toasts with the same level and message, and give visual feedback for end users
|
||||
if (preventDuplicates) {
|
||||
const toastEl = parent.querySelector(`:scope > .toastify.on[data-toast-unique-key="${CSS.escape(duplicateKey)}"]`);
|
||||
if (toastEl) {
|
||||
const toastDupNumEl = toastEl.querySelector('.toast-duplicate-number')!;
|
||||
showElem(toastDupNumEl);
|
||||
toastDupNumEl.textContent = String(Number(toastDupNumEl.textContent) + 1);
|
||||
animateOnce(toastDupNumEl, 'pulse-1p5-200');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const {icon, background, duration: levelDuration} = levels[level ?? 'info'];
|
||||
const toast = Toastify({
|
||||
selector: parent,
|
||||
text: `
|
||||
<div class='toast-icon'>${svg(icon)}</div>
|
||||
<div class='toast-body'><span class="toast-duplicate-number tw-hidden">1</span>${body}</div>
|
||||
<button class='btn toast-close'>${svg('octicon-x')}</button>
|
||||
`,
|
||||
escapeMarkup: false,
|
||||
gravity: gravity ?? 'top',
|
||||
position: position ?? 'center',
|
||||
duration: duration ?? levelDuration,
|
||||
style: {background},
|
||||
...other,
|
||||
});
|
||||
|
||||
toast.showToast();
|
||||
const el = toast.toastElement as ToastifyElement;
|
||||
el.querySelector('.toast-close')!.addEventListener('click', () => toast.hideToast());
|
||||
el.setAttribute('data-toast-unique-key', duplicateKey);
|
||||
el._giteaToastifyInstance = toast;
|
||||
return toast;
|
||||
}
|
||||
|
||||
export function showInfoToast(message: string, opts?: ToastOpts): Toast | null {
|
||||
return showToast(message, 'info', opts);
|
||||
}
|
||||
|
||||
export function showWarningToast(message: string, opts?: ToastOpts): Toast | null {
|
||||
return showToast(message, 'warning', opts);
|
||||
}
|
||||
|
||||
export function showErrorToast(message: string, opts?: ToastOpts): Toast | null {
|
||||
return showToast(message, 'error', opts);
|
||||
}
|
||||
|
||||
function hideToastByElement(el: Element): void {
|
||||
(el as ToastifyElement)?._giteaToastifyInstance?.hideToast();
|
||||
}
|
||||
|
||||
export function hideToastsFrom(parent: Element): void {
|
||||
queryElems(parent, ':scope > .toastify.on', hideToastByElement);
|
||||
}
|
||||
|
||||
export function hideToastsAll(): void {
|
||||
queryElems(document, '.toastify.on', hideToastByElement);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
/* eslint-disable no-restricted-globals */
|
||||
// Some people deploy Gitea under a subpath, so it needs prefix to avoid local storage key conflicts.
|
||||
// And these keys are for user settings only, it also needs a specific prefix,
|
||||
// in case in the future there are other uses of local storage, and/or we need to clear some keys when the quota is exceeded.
|
||||
const itemKeyPrefix = 'gitea:setting:';
|
||||
|
||||
function handleLocalStorageError(e: any) {
|
||||
// in the future, maybe we need to handle quota exceeded errors differently
|
||||
console.error('Error using local storage for user settings', e);
|
||||
}
|
||||
|
||||
function getLocalStorageUserSetting(settingKey: string): string | null {
|
||||
const legacyKey = settingKey;
|
||||
const itemKey = `${itemKeyPrefix}${settingKey}`;
|
||||
try {
|
||||
const legacyValue = localStorage?.getItem(legacyKey) ?? null;
|
||||
const value = localStorage?.getItem(itemKey) ?? null; // avoid undefined
|
||||
if (value !== null && legacyValue !== null) {
|
||||
// if both values exist, remove the legacy one
|
||||
localStorage?.removeItem(legacyKey);
|
||||
} else if (value === null && legacyValue !== null) {
|
||||
// migrate legacy value to new key
|
||||
localStorage?.removeItem(legacyKey);
|
||||
localStorage?.setItem(itemKey, legacyValue);
|
||||
return legacyValue;
|
||||
}
|
||||
return value;
|
||||
} catch (e) {
|
||||
handleLocalStorageError(e);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function setLocalStorageUserSetting(settingKey: string, value: string) {
|
||||
const legacyKey = settingKey;
|
||||
const itemKey = `${itemKeyPrefix}${settingKey}`;
|
||||
try {
|
||||
localStorage?.removeItem(legacyKey);
|
||||
localStorage?.setItem(itemKey, value);
|
||||
} catch (e) {
|
||||
handleLocalStorageError(e);
|
||||
}
|
||||
}
|
||||
|
||||
export const localUserSettings = {
|
||||
getString: (key: string, def: string = ''): string => {
|
||||
return getLocalStorageUserSetting(key) ?? def;
|
||||
},
|
||||
setString: (key: string, value: string) => {
|
||||
setLocalStorageUserSetting(key, value);
|
||||
},
|
||||
getBoolean: (key: string, def: boolean = false): boolean => {
|
||||
return localUserSettings.getString(key, String(def)) === 'true';
|
||||
},
|
||||
setBoolean: (key: string, value: boolean) => {
|
||||
localUserSettings.setString(key, String(value));
|
||||
},
|
||||
getJsonObject: <T extends Record<string, any>>(key: string, def: T): T => {
|
||||
const value = getLocalStorageUserSetting(key);
|
||||
try {
|
||||
const decoded = value !== null ? JSON.parse(value) : null;
|
||||
return {...def, ...decoded};
|
||||
} catch (e) {
|
||||
console.error(`Unable to parse JSON value for local user settings ${key}=${value}`, e);
|
||||
}
|
||||
return def;
|
||||
},
|
||||
setJsonObject: <T extends Record<string, any>>(key: string, value: T) => {
|
||||
localUserSettings.setString(key, JSON.stringify(value));
|
||||
},
|
||||
};
|
||||
|
||||
window.localUserSettings = localUserSettings;
|
||||
@@ -0,0 +1,65 @@
|
||||
const {appSubUrl, sharedWorkerUri} = window.config;
|
||||
|
||||
export class UserEventsSharedWorker {
|
||||
sharedWorker: SharedWorker;
|
||||
|
||||
// options can be either a string (the debug name of the worker) or an object of type WorkerOptions
|
||||
constructor(options?: string | WorkerOptions) {
|
||||
const worker = new SharedWorker(sharedWorkerUri, options);
|
||||
this.sharedWorker = worker;
|
||||
worker.addEventListener('error', (event) => {
|
||||
console.error('worker error', event);
|
||||
});
|
||||
worker.port.addEventListener('messageerror', () => {
|
||||
console.error('unable to deserialize message');
|
||||
});
|
||||
worker.port.postMessage({
|
||||
type: 'start',
|
||||
url: `${window.location.origin}${appSubUrl}/user/events`,
|
||||
});
|
||||
worker.port.addEventListener('error', (e) => {
|
||||
console.error('worker port error', e);
|
||||
});
|
||||
window.addEventListener('beforeunload', () => {
|
||||
// FIXME: this logic is not quite right.
|
||||
// "beforeunload" can be canceled by some actions like "are-you-sure" and the navigation can be cancelled.
|
||||
// In this case: the worker port is incorrectly closed while the page is still there.
|
||||
worker.port.postMessage({type: 'close'});
|
||||
worker.port.close();
|
||||
});
|
||||
}
|
||||
|
||||
addMessageEventListener(listener: (event: MessageEvent) => void) {
|
||||
this.sharedWorker.port.addEventListener('message', (event: MessageEvent) => {
|
||||
if (!event.data || !event.data.type) {
|
||||
console.error('unknown worker message event', event);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.data.type === 'error') {
|
||||
console.error('worker port event error', event.data);
|
||||
} else if (event.data.type === 'logout') {
|
||||
if (event.data.data !== 'here') return;
|
||||
this.sharedWorker.port.postMessage({type: 'close'});
|
||||
this.sharedWorker.port.close();
|
||||
// slightly delay our "logout" for a short while, in case there are other logout requests in-flight.
|
||||
// * if the logout is triggered by a page redirection (e.g.: user clicks "/user/logout")
|
||||
// * "beforeunload" event is triggered, this code path won't execute
|
||||
// * if the logout is triggered by a fetch call
|
||||
// * "beforeunload" event is not triggered until JS does the redirection.
|
||||
// * in this case, the logout fetch call already completes and has sent the "logout" message to the worker
|
||||
// * there can be a data-race between the fetch call's redirection and the "logout" message from the worker
|
||||
// * the fetch call's logout redirection should always win over the worker message, because it might have a custom location
|
||||
setTimeout(() => { window.location.href = `${appSubUrl}/` }, 1000);
|
||||
} else if (event.data.type === 'close') {
|
||||
this.sharedWorker.port.postMessage({type: 'close'});
|
||||
this.sharedWorker.port.close();
|
||||
}
|
||||
listener(event);
|
||||
});
|
||||
}
|
||||
|
||||
startPort() {
|
||||
this.sharedWorker.port.start();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user