初始提交: Gitea 项目代码

This commit is contained in:
root
2026-05-30 22:47:36 +08:00
commit f288f76350
6116 changed files with 776822 additions and 0 deletions
+90
View File
@@ -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});
},
});
}
+39
View File
@@ -0,0 +1,39 @@
import type {CodemirrorModules} from './main.ts';
import type {Extension} from '@codemirror/state';
/** Creates a linter for JSON files using `jsonParseLinter` from `@codemirror/lang-json`. */
export async function createJsonLinter(cm: CodemirrorModules): Promise<Extension> {
const {jsonParseLinter} = await import('@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');
});
+373
View File
@@ -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://img.shields.io/npm/v/pkg.svg?style=flat)](https://www.npmjs.org/package/pkg)')).toEqual(['https://img.shields.io/npm/v/pkg.svg?style=flat', 'https://www.npmjs.org/package/pkg']);
});
test('trimUrlPunctuation', () => {
expect(trimUrlPunctuation('https://example.com.')).toEqual('https://example.com');
expect(trimUrlPunctuation('https://example.com,')).toEqual('https://example.com');
expect(trimUrlPunctuation('https://example.com;')).toEqual('https://example.com');
expect(trimUrlPunctuation('https://example.com:')).toEqual('https://example.com');
expect(trimUrlPunctuation("https://example.com'")).toEqual('https://example.com');
expect(trimUrlPunctuation('https://example.com"')).toEqual('https://example.com');
expect(trimUrlPunctuation('https://example.com.,;')).toEqual('https://example.com');
expect(trimUrlPunctuation('https://example.com/path')).toEqual('https://example.com/path');
expect(trimUrlPunctuation('https://example.com/path_(wiki)')).toEqual('https://example.com/path_(wiki)');
expect(trimUrlPunctuation('https://example.com)')).toEqual('https://example.com');
expect(trimUrlPunctuation('https://en.wikipedia.org/wiki/Rust_(lang))')).toEqual('https://en.wikipedia.org/wiki/Rust_(lang)');
});
test('findUrlAtPosition', () => {
const doc = 'visit https://example.com for info';
expect(findUrlAtPosition(doc, 0)).toBeNull();
expect(findUrlAtPosition(doc, 6)).toEqual('https://example.com');
expect(findUrlAtPosition(doc, 15)).toEqual('https://example.com');
expect(findUrlAtPosition(doc, 24)).toEqual('https://example.com');
expect(findUrlAtPosition(doc, 25)).toBeNull();
});
+131
View File
@@ -0,0 +1,131 @@
import type {EditorView, ViewUpdate} from '@codemirror/view';
import type {CodemirrorModules} from './main.ts';
/** Remove trailing whitespace from all lines in the editor. */
export function trimTrailingWhitespaceFromView(view: EditorView): void {
const changes = [];
const doc = view.state.doc;
for (let i = 1; i <= doc.lines; i++) {
const line = doc.line(i);
const trimmed = line.text.replace(/\s+$/, '');
if (trimmed.length < line.text.length) {
changes.push({from: line.from + trimmed.length, to: line.to});
}
}
if (changes.length) view.dispatch({changes});
}
/** Matches URLs, excluding characters that are never valid unencoded in URLs per RFC 3986. */
export const urlRawRegex = /\bhttps?:\/\/[^\s<>[\]]+/gi;
/** Strip trailing punctuation that is likely not part of the URL. */
export function trimUrlPunctuation(url: string): string {
url = url.replace(/[.,;:'"]+$/, '');
// Strip trailing closing parens only if unbalanced (not part of the URL like Wikipedia links)
while (url.endsWith(')') && (url.match(/\(/g) || []).length < (url.match(/\)/g) || []).length) {
url = url.slice(0, -1);
}
return url;
}
/** Find the URL at the given character position in a document string, or null if none. */
export function findUrlAtPosition(doc: string, pos: number): string | null {
for (const match of doc.matchAll(urlRawRegex)) {
const url = trimUrlPunctuation(match[0]);
if (match.index !== undefined && pos >= match.index && pos < match.index + url.length) {
return url;
}
}
return null;
}
// Lezer syntax tree node names for identifier usages and definitions across grammars
const usageNodes = new Set(['VariableName', 'Identifier', 'TypeIdentifier', 'TypeName', 'FieldIdentifier']);
const definitionNodes = new Set(['VariableDefinition', 'DefName', 'Definition', 'TypeDefinition', 'TypeDef']);
export function goToDefinitionAt(cm: CodemirrorModules, view: EditorView, pos: number): boolean {
const tree = cm.language.syntaxTree(view.state);
const node = tree.resolveInner(pos, 1);
if (!node || !usageNodes.has(node.name)) return false;
const name = view.state.doc.sliceString(node.from, node.to);
let target: number | null = null;
tree.iterate({
enter(n): false | void {
if (target !== null) return false;
if (definitionNodes.has(n.name) && n.from !== node.from && view.state.doc.sliceString(n.from, n.to) === name) {
target = n.from;
return false;
}
},
});
if (target === null) return false;
view.dispatch({selection: {anchor: target}, scrollIntoView: true});
return true;
}
/** CodeMirror extension that makes URLs clickable via Ctrl/Cmd+click. */
export function clickableUrls(cm: CodemirrorModules) {
const urlMark = cm.view.Decoration.mark({class: 'cm-url'});
const urlDecorator = new cm.view.MatchDecorator({
regexp: urlRawRegex,
decorate: (add, from, _to, match) => {
const trimmed = trimUrlPunctuation(match[0]);
add(from, from + trimmed.length, urlMark);
},
});
const plugin = cm.view.ViewPlugin.fromClass(class {
decorations: ReturnType<typeof urlDecorator.createDeco>;
constructor(view: EditorView) {
this.decorations = urlDecorator.createDeco(view);
}
update(update: ViewUpdate) {
this.decorations = urlDecorator.updateDeco(update, this.decorations);
}
}, {decorations: (v) => v.decorations});
const handler = cm.view.EditorView.domEventHandlers({
mousedown(event: MouseEvent, view: EditorView) {
if (!event.metaKey && !event.ctrlKey) return false;
const pos = view.posAtCoords({x: event.clientX, y: event.clientY});
if (pos === null) return false;
const line = view.state.doc.lineAt(pos);
const url = findUrlAtPosition(line.text, pos - line.from);
if (url) {
window.open(url, '_blank', 'noopener');
event.preventDefault();
return true;
}
// Fall back to go-to-definition: find the symbol at cursor and jump to its definition
if (goToDefinitionAt(cm, view, pos)) {
event.preventDefault();
return true;
}
return false;
},
});
const modClass = cm.view.ViewPlugin.fromClass(class {
container: Element;
handleKeyDown: (e: KeyboardEvent) => void;
handleKeyUp: (e: KeyboardEvent) => void;
handleBlur: () => void;
constructor(view: EditorView) {
this.container = view.dom.closest('.code-editor-container')!;
this.handleKeyDown = (e) => { if (e.key === 'Meta' || e.key === 'Control') this.container.classList.add('cm-mod-held'); };
this.handleKeyUp = (e) => { if (e.key === 'Meta' || e.key === 'Control') this.container.classList.remove('cm-mod-held'); };
this.handleBlur = () => this.container.classList.remove('cm-mod-held');
document.addEventListener('keydown', this.handleKeyDown);
document.addEventListener('keyup', this.handleKeyUp);
window.addEventListener('blur', this.handleBlur);
}
destroy() {
document.removeEventListener('keydown', this.handleKeyDown);
document.removeEventListener('keyup', this.handleKeyUp);
window.removeEventListener('blur', this.handleBlur);
this.container.classList.remove('cm-mod-held');
}
});
return [plugin, handler, modClass];
}
+64
View File
@@ -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`);
}
});
}
+51
View File
@@ -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);
});
+82
View File
@@ -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;
}
+48
View File
@@ -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');
});
+78
View File
@@ -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);
}
+10
View File
@@ -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();
});
+33
View File
@@ -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});
+28
View File
@@ -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();
}
+117
View File
@@ -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>
```
+47
View File
@@ -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);
}
+31
View File
@@ -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>
`);
});
+365
View File
@@ -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);
}
+83
View File
@@ -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;
}
+18
View File
@@ -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');
});
}
}
+52
View File
@@ -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;
};
}
+76
View File
@@ -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;
};
+15
View File
@@ -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');
});
+7
View File
@@ -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));
}
+26
View File
@@ -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;
}
+117
View File
@@ -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});
}
+116
View File
@@ -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});
}
+76
View File
@@ -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);
});
}
+23
View File
@@ -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);
}
+218
View File
@@ -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;
}
+16
View File
@@ -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();
});
+109
View File
@@ -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);
}
+73
View File
@@ -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;
+65
View File
@@ -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();
}
}