aboutsummaryrefslogtreecommitdiffstats
path: root/web_src/js/features/comp
diff options
context:
space:
mode:
Diffstat (limited to 'web_src/js/features/comp')
-rw-r--r--web_src/js/features/comp/ComboMarkdownEditor.ts41
-rw-r--r--web_src/js/features/comp/ConfirmModal.ts37
-rw-r--r--web_src/js/features/comp/Cropper.ts9
-rw-r--r--web_src/js/features/comp/EditorMarkdown.test.ts190
-rw-r--r--web_src/js/features/comp/EditorMarkdown.ts146
-rw-r--r--web_src/js/features/comp/EditorUpload.test.ts12
-rw-r--r--web_src/js/features/comp/EditorUpload.ts74
-rw-r--r--web_src/js/features/comp/LabelEdit.ts14
-rw-r--r--web_src/js/features/comp/QuickSubmit.ts2
-rw-r--r--web_src/js/features/comp/ReactionSelector.ts48
-rw-r--r--web_src/js/features/comp/SearchUserBox.ts4
-rw-r--r--web_src/js/features/comp/TextExpander.ts62
-rw-r--r--web_src/js/features/comp/WebHookEditor.ts6
13 files changed, 498 insertions, 147 deletions
diff --git a/web_src/js/features/comp/ComboMarkdownEditor.ts b/web_src/js/features/comp/ComboMarkdownEditor.ts
index bba50a1296..d3773a89c4 100644
--- a/web_src/js/features/comp/ComboMarkdownEditor.ts
+++ b/web_src/js/features/comp/ComboMarkdownEditor.ts
@@ -29,10 +29,10 @@ let elementIdCounter = 0;
/**
* validate if the given textarea is non-empty.
- * @param {HTMLElement} textarea - The textarea element to be validated.
+ * @param {HTMLTextAreaElement} textarea - The textarea element to be validated.
* @returns {boolean} returns true if validation succeeded.
*/
-export function validateTextareaNonEmpty(textarea) {
+export function validateTextareaNonEmpty(textarea: HTMLTextAreaElement) {
// When using EasyMDE, the original edit area HTML element is hidden, breaking HTML5 input validation.
// The workaround (https://github.com/sparksuite/simplemde-markdown-editor/issues/324) doesn't work with contenteditable, so we just show an alert.
if (!textarea.value) {
@@ -49,16 +49,25 @@ export function validateTextareaNonEmpty(textarea) {
return true;
}
+type Heights = {
+ minHeight?: string,
+ height?: string,
+ maxHeight?: string,
+};
+
type ComboMarkdownEditorOptions = {
- editorHeights?: {minHeight?: string, height?: string, maxHeight?: string},
+ editorHeights?: Heights,
easyMDEOptions?: EasyMDE.Options,
};
+type ComboMarkdownEditorTextarea = HTMLTextAreaElement & {_giteaComboMarkdownEditor: any};
+type ComboMarkdownEditorContainer = HTMLElement & {_giteaComboMarkdownEditor?: any};
+
export class ComboMarkdownEditor {
static EventEditorContentChanged = EventEditorContentChanged;
static EventUploadStateChanged = EventUploadStateChanged;
- public container : HTMLElement;
+ public container: HTMLElement;
options: ComboMarkdownEditorOptions;
@@ -70,7 +79,7 @@ export class ComboMarkdownEditor {
easyMDEToolbarActions: any;
easyMDEToolbarDefault: any;
- textarea: HTMLTextAreaElement & {_giteaComboMarkdownEditor: any};
+ textarea: ComboMarkdownEditorTextarea;
textareaMarkdownToolbar: HTMLElement;
textareaAutosize: any;
@@ -81,7 +90,7 @@ export class ComboMarkdownEditor {
previewUrl: string;
previewContext: string;
- constructor(container, options:ComboMarkdownEditorOptions = {}) {
+ constructor(container: ComboMarkdownEditorContainer, options:ComboMarkdownEditorOptions = {}) {
if (container._giteaComboMarkdownEditor) throw new Error('ComboMarkdownEditor already initialized');
container._giteaComboMarkdownEditor = this;
this.options = options;
@@ -98,7 +107,7 @@ export class ComboMarkdownEditor {
await this.switchToUserPreference();
}
- applyEditorHeights(el, heights) {
+ applyEditorHeights(el: HTMLElement, heights: Heights) {
if (!heights) return;
if (heights.minHeight) el.style.minHeight = heights.minHeight;
if (heights.height) el.style.height = heights.height;
@@ -283,7 +292,7 @@ export class ComboMarkdownEditor {
];
}
- parseEasyMDEToolbar(easyMde: typeof EasyMDE, actions) {
+ parseEasyMDEToolbar(easyMde: typeof EasyMDE, actions: any) {
this.easyMDEToolbarActions = this.easyMDEToolbarActions || easyMDEToolbarActions(easyMde, this);
const processed = [];
for (const action of actions) {
@@ -332,21 +341,21 @@ export class ComboMarkdownEditor {
this.easyMDE = new EasyMDE(easyMDEOpt);
this.easyMDE.codemirror.on('change', () => triggerEditorContentChanged(this.container));
this.easyMDE.codemirror.setOption('extraKeys', {
- 'Cmd-Enter': (cm) => handleGlobalEnterQuickSubmit(cm.getTextArea()),
- 'Ctrl-Enter': (cm) => handleGlobalEnterQuickSubmit(cm.getTextArea()),
- Enter: (cm) => {
+ 'Cmd-Enter': (cm: any) => handleGlobalEnterQuickSubmit(cm.getTextArea()),
+ 'Ctrl-Enter': (cm: any) => handleGlobalEnterQuickSubmit(cm.getTextArea()),
+ Enter: (cm: any) => {
const tributeContainer = document.querySelector<HTMLElement>('.tribute-container');
if (!tributeContainer || tributeContainer.style.display === 'none') {
cm.execCommand('newlineAndIndent');
}
},
- Up: (cm) => {
+ Up: (cm: any) => {
const tributeContainer = document.querySelector<HTMLElement>('.tribute-container');
if (!tributeContainer || tributeContainer.style.display === 'none') {
return cm.execCommand('goLineUp');
}
},
- Down: (cm) => {
+ Down: (cm: any) => {
const tributeContainer = document.querySelector<HTMLElement>('.tribute-container');
if (!tributeContainer || tributeContainer.style.display === 'none') {
return cm.execCommand('goLineDown');
@@ -354,14 +363,14 @@ export class ComboMarkdownEditor {
},
});
this.applyEditorHeights(this.container.querySelector('.CodeMirror-scroll'), this.options.editorHeights);
- await attachTribute(this.easyMDE.codemirror.getInputField(), {mentions: true, emoji: true});
+ await attachTribute(this.easyMDE.codemirror.getInputField());
if (this.dropzone) {
initEasyMDEPaste(this.easyMDE, this.dropzone);
}
hideElem(this.textareaMarkdownToolbar);
}
- value(v = undefined) {
+ value(v: any = undefined) {
if (v === undefined) {
if (this.easyMDE) {
return this.easyMDE.value();
@@ -402,7 +411,7 @@ export class ComboMarkdownEditor {
}
}
-export function getComboMarkdownEditor(el) {
+export function getComboMarkdownEditor(el: any) {
if (!el) return null;
if (el.length) el = el[0];
return el._giteaComboMarkdownEditor;
diff --git a/web_src/js/features/comp/ConfirmModal.ts b/web_src/js/features/comp/ConfirmModal.ts
index 1ce490ec2e..97a73eace6 100644
--- a/web_src/js/features/comp/ConfirmModal.ts
+++ b/web_src/js/features/comp/ConfirmModal.ts
@@ -1,24 +1,33 @@
import {svg} from '../../svg.ts';
-import {htmlEscape} from 'escape-goat';
+import {html, htmlRaw} from '../../utils/html.ts';
import {createElementFromHTML} from '../../utils/dom.ts';
import {fomanticQuery} from '../../modules/fomantic/base.ts';
const {i18n} = window.config;
-export function confirmModal({header = '', content = '', confirmButtonColor = 'primary'} = {}): Promise<boolean> {
- return new Promise((resolve) => {
- const headerHtml = header ? `<div class="header">${htmlEscape(header)}</div>` : '';
- const modal = createElementFromHTML(`
- <div class="ui g-modal-confirm modal">
- ${headerHtml}
- <div class="content">${htmlEscape(content)}</div>
- <div class="actions">
- <button class="ui cancel button">${svg('octicon-x')} ${htmlEscape(i18n.modal_cancel)}</button>
- <button class="ui ${confirmButtonColor} ok button">${svg('octicon-check')} ${htmlEscape(i18n.modal_confirm)}</button>
- </div>
+type ConfirmModalOptions = {
+ header?: string;
+ content?: string;
+ confirmButtonColor?: 'primary' | 'red' | 'green' | 'blue';
+}
+
+export function createConfirmModal({header = '', content = '', confirmButtonColor = 'primary'}:ConfirmModalOptions = {}): HTMLElement {
+ const headerHtml = header ? html`<div class="header">${header}</div>` : '';
+ return createElementFromHTML(html`
+ <div class="ui g-modal-confirm modal">
+ ${htmlRaw(headerHtml)}
+ <div class="content">${content}</div>
+ <div class="actions">
+ <button class="ui cancel button">${htmlRaw(svg('octicon-x'))} ${i18n.modal_cancel}</button>
+ <button class="ui ${confirmButtonColor} ok button">${htmlRaw(svg('octicon-check'))} ${i18n.modal_confirm}</button>
</div>
- `);
- document.body.append(modal);
+ </div>
+ `.trim());
+}
+
+export function confirmModal(modal: HTMLElement | ConfirmModalOptions): Promise<boolean> {
+ if (!(modal instanceof HTMLElement)) modal = createConfirmModal(modal);
+ return new Promise((resolve) => {
const $modal = fomanticQuery(modal);
$modal.modal({
onApprove() {
diff --git a/web_src/js/features/comp/Cropper.ts b/web_src/js/features/comp/Cropper.ts
index e65dcfbe13..aaa1691152 100644
--- a/web_src/js/features/comp/Cropper.ts
+++ b/web_src/js/features/comp/Cropper.ts
@@ -6,7 +6,7 @@ type CropperOpts = {
fileInput: HTMLInputElement,
}
-export async function initCompCropper({container, fileInput, imageSource}: CropperOpts) {
+async function initCompCropper({container, fileInput, imageSource}: CropperOpts) {
const {default: Cropper} = await import(/* webpackChunkName: "cropperjs" */'cropperjs');
let currentFileName = '';
let currentFileLastModified = 0;
@@ -38,3 +38,10 @@ export async function initCompCropper({container, fileInput, imageSource}: Cropp
}
});
}
+
+export async function initAvatarUploaderWithCropper(fileInput: HTMLInputElement) {
+ const panel = fileInput.nextElementSibling as HTMLElement;
+ if (!panel?.matches('.cropper-panel')) throw new Error('Missing cropper panel for avatar uploader');
+ const imageSource = panel.querySelector<HTMLImageElement>('.cropper-source');
+ await initCompCropper({container: panel, fileInput, imageSource});
+}
diff --git a/web_src/js/features/comp/EditorMarkdown.test.ts b/web_src/js/features/comp/EditorMarkdown.test.ts
index acd496bed6..9f34d77348 100644
--- a/web_src/js/features/comp/EditorMarkdown.test.ts
+++ b/web_src/js/features/comp/EditorMarkdown.test.ts
@@ -1,16 +1,189 @@
-import {initTextareaMarkdown} from './EditorMarkdown.ts';
+import {initTextareaMarkdown, markdownHandleIndention, textareaSplitLines} from './EditorMarkdown.ts';
+
+test('textareaSplitLines', () => {
+ let ret = textareaSplitLines('a\nbc\nd', 0);
+ expect(ret).toEqual({lines: ['a', 'bc', 'd'], lengthBeforePosLine: 0, posLineIndex: 0, inlinePos: 0});
+
+ ret = textareaSplitLines('a\nbc\nd', 1);
+ expect(ret).toEqual({lines: ['a', 'bc', 'd'], lengthBeforePosLine: 0, posLineIndex: 0, inlinePos: 1});
+
+ ret = textareaSplitLines('a\nbc\nd', 2);
+ expect(ret).toEqual({lines: ['a', 'bc', 'd'], lengthBeforePosLine: 2, posLineIndex: 1, inlinePos: 0});
+
+ ret = textareaSplitLines('a\nbc\nd', 3);
+ expect(ret).toEqual({lines: ['a', 'bc', 'd'], lengthBeforePosLine: 2, posLineIndex: 1, inlinePos: 1});
+
+ ret = textareaSplitLines('a\nbc\nd', 4);
+ expect(ret).toEqual({lines: ['a', 'bc', 'd'], lengthBeforePosLine: 2, posLineIndex: 1, inlinePos: 2});
+
+ ret = textareaSplitLines('a\nbc\nd', 5);
+ expect(ret).toEqual({lines: ['a', 'bc', 'd'], lengthBeforePosLine: 5, posLineIndex: 2, inlinePos: 0});
+
+ ret = textareaSplitLines('a\nbc\nd', 6);
+ expect(ret).toEqual({lines: ['a', 'bc', 'd'], lengthBeforePosLine: 5, posLineIndex: 2, inlinePos: 1});
+});
+
+test('markdownHandleIndention', () => {
+ const testInput = (input: string, expected?: string) => {
+ const inputPos = input.indexOf('|');
+ input = input.replace('|', '');
+ const ret = markdownHandleIndention({value: input, selStart: inputPos, selEnd: inputPos});
+ if (expected === null) {
+ expect(ret).toEqual({handled: false});
+ } else {
+ const expectedPos = expected.indexOf('|');
+ expected = expected.replace('|', '');
+ expect(ret).toEqual({
+ handled: true,
+ valueSelection: {value: expected, selStart: expectedPos, selEnd: expectedPos},
+ });
+ }
+ };
+
+ testInput(`
+ a|b
+`, `
+ a
+ |b
+`);
+
+ testInput(`
+1. a
+2. |
+`, `
+1. a
+|
+`);
+
+ testInput(`
+|1. a
+`, null); // let browser handle it
+
+ testInput(`
+1. a
+1. b|c
+`, `
+1. a
+2. b
+3. |c
+`);
+
+ testInput(`
+2. a
+2. b|
+
+1. x
+1. y
+`, `
+1. a
+2. b
+3. |
+
+1. x
+1. y
+`);
+
+ testInput(`
+2. a
+2. b
+
+1. x|
+1. y
+`, `
+2. a
+2. b
+
+1. x
+2. |
+3. y
+`);
+
+ testInput(`
+1. a
+2. b|
+3. c
+`, `
+1. a
+2. b
+3. |
+4. c
+`);
+
+ testInput(`
+1. a
+ 1. b
+ 2. b
+ 3. b
+ 4. b
+1. c|
+`, `
+1. a
+ 1. b
+ 2. b
+ 3. b
+ 4. b
+2. c
+3. |
+`);
+
+ testInput(`
+1. a
+2. a
+3. a
+4. a
+5. a
+6. a
+7. a
+8. a
+9. b|c
+`, `
+1. a
+2. a
+3. a
+4. a
+5. a
+6. a
+7. a
+8. a
+9. b
+10. |c
+`);
+
+ // this is a special case, it's difficult to re-format the parent level at the moment, so leave it to the future
+ testInput(`
+1. a
+ 2. b|
+3. c
+`, `
+1. a
+ 1. b
+ 2. |
+3. c
+`);
+});
test('EditorMarkdown', () => {
const textarea = document.createElement('textarea');
initTextareaMarkdown(textarea);
- const testInput = (value, expected) => {
- textarea.value = value;
- textarea.setSelectionRange(value.length, value.length);
+ type ValueWithCursor = string | {
+ value: string;
+ pos: number;
+ }
+ const testInput = (input: ValueWithCursor, result: ValueWithCursor) => {
+ const intputValue = typeof input === 'string' ? input : input.value;
+ const inputPos = typeof input === 'string' ? intputValue.length : input.pos;
+ textarea.value = intputValue;
+ textarea.setSelectionRange(inputPos, inputPos);
+
const e = new KeyboardEvent('keydown', {key: 'Enter', cancelable: true});
textarea.dispatchEvent(e);
- if (!e.defaultPrevented) textarea.value += '\n';
- expect(textarea.value).toEqual(expected);
+ if (!e.defaultPrevented) textarea.value += '\n'; // simulate default behavior
+
+ const expectedValue = typeof result === 'string' ? result : result.value;
+ const expectedPos = typeof result === 'string' ? expectedValue.length : result.pos;
+ expect(textarea.value).toEqual(expectedValue);
+ expect(textarea.selectionStart).toEqual(expectedPos);
};
testInput('-', '-\n');
@@ -18,10 +191,13 @@ test('EditorMarkdown', () => {
testInput('- ', '');
testInput('1. ', '');
+ testInput({value: '1. \n2. ', pos: 3}, {value: '\n2. ', pos: 0});
testInput('- x', '- x\n- ');
+ testInput('1. foo', '1. foo\n2. ');
+ testInput({value: '1. a\n2. b\n3. c', pos: 4}, {value: '1. a\n2. \n3. b\n4. c', pos: 8});
testInput('- [ ]', '- [ ]\n- ');
testInput('- [ ] foo', '- [ ] foo\n- [ ] ');
testInput('* [x] foo', '* [x] foo\n* [ ] ');
- testInput('1. [x] foo', '1. [x] foo\n1. [ ] ');
+ testInput('1. [x] foo', '1. [x] foo\n2. [ ] ');
});
diff --git a/web_src/js/features/comp/EditorMarkdown.ts b/web_src/js/features/comp/EditorMarkdown.ts
index 2af003ccb0..6e66c15763 100644
--- a/web_src/js/features/comp/EditorMarkdown.ts
+++ b/web_src/js/features/comp/EditorMarkdown.ts
@@ -1,10 +1,10 @@
export const EventEditorContentChanged = 'ce-editor-content-changed';
-export function triggerEditorContentChanged(target) {
+export function triggerEditorContentChanged(target: HTMLElement) {
target.dispatchEvent(new CustomEvent(EventEditorContentChanged, {bubbles: true}));
}
-export function textareaInsertText(textarea, value) {
+export function textareaInsertText(textarea: HTMLTextAreaElement, value: string) {
const startPos = textarea.selectionStart;
const endPos = textarea.selectionEnd;
textarea.value = textarea.value.substring(0, startPos) + value + textarea.value.substring(endPos);
@@ -14,7 +14,13 @@ export function textareaInsertText(textarea, value) {
triggerEditorContentChanged(textarea);
}
-function handleIndentSelection(textarea, e) {
+type TextareaValueSelection = {
+ value: string;
+ selStart: number;
+ selEnd: number;
+}
+
+function handleIndentSelection(textarea: HTMLTextAreaElement, e: KeyboardEvent) {
const selStart = textarea.selectionStart;
const selEnd = textarea.selectionEnd;
if (selEnd === selStart) return; // do not process when no selection
@@ -56,57 +62,135 @@ function handleIndentSelection(textarea, e) {
triggerEditorContentChanged(textarea);
}
-function handleNewline(textarea: HTMLTextAreaElement, e: Event) {
- const selStart = textarea.selectionStart;
- const selEnd = textarea.selectionEnd;
- if (selEnd !== selStart) return; // do not process when there is a selection
+type MarkdownHandleIndentionResult = {
+ handled: boolean;
+ valueSelection?: TextareaValueSelection;
+}
+
+type TextLinesBuffer = {
+ lines: string[];
+ lengthBeforePosLine: number;
+ posLineIndex: number;
+ inlinePos: number
+}
+
+export function textareaSplitLines(value: string, pos: number): TextLinesBuffer {
+ const lines = value.split('\n');
+ let lengthBeforePosLine = 0, inlinePos = 0, posLineIndex = 0;
+ for (; posLineIndex < lines.length; posLineIndex++) {
+ const lineLength = lines[posLineIndex].length + 1;
+ if (lengthBeforePosLine + lineLength > pos) {
+ inlinePos = pos - lengthBeforePosLine;
+ break;
+ }
+ lengthBeforePosLine += lineLength;
+ }
+ return {lines, lengthBeforePosLine, posLineIndex, inlinePos};
+}
+
+function markdownReformatListNumbers(linesBuf: TextLinesBuffer, indention: string) {
+ const reDeeperIndention = new RegExp(`^${indention}\\s+`);
+ const reSameLevel = new RegExp(`^${indention}([0-9]+)\\.`);
+ let firstLineIdx: number;
+ for (firstLineIdx = linesBuf.posLineIndex - 1; firstLineIdx >= 0; firstLineIdx--) {
+ const line = linesBuf.lines[firstLineIdx];
+ if (!reDeeperIndention.test(line) && !reSameLevel.test(line)) break;
+ }
+ firstLineIdx++;
+ let num = 1;
+ for (let i = firstLineIdx; i < linesBuf.lines.length; i++) {
+ const oldLine = linesBuf.lines[i];
+ const sameLevel = reSameLevel.test(oldLine);
+ if (!sameLevel && !reDeeperIndention.test(oldLine)) break;
+ if (sameLevel) {
+ const newLine = `${indention}${num}.${oldLine.replace(reSameLevel, '')}`;
+ linesBuf.lines[i] = newLine;
+ num++;
+ if (linesBuf.posLineIndex === i) {
+ // need to correct the cursor inline position if the line length changes
+ linesBuf.inlinePos += newLine.length - oldLine.length;
+ linesBuf.inlinePos = Math.max(0, linesBuf.inlinePos);
+ linesBuf.inlinePos = Math.min(newLine.length, linesBuf.inlinePos);
+ }
+ }
+ }
+ recalculateLengthBeforeLine(linesBuf);
+}
+
+function recalculateLengthBeforeLine(linesBuf: TextLinesBuffer) {
+ linesBuf.lengthBeforePosLine = 0;
+ for (let i = 0; i < linesBuf.posLineIndex; i++) {
+ linesBuf.lengthBeforePosLine += linesBuf.lines[i].length + 1;
+ }
+}
- const value = textarea.value;
+export function markdownHandleIndention(tvs: TextareaValueSelection): MarkdownHandleIndentionResult {
+ const unhandled: MarkdownHandleIndentionResult = {handled: false};
+ if (tvs.selEnd !== tvs.selStart) return unhandled; // do not process when there is a selection
- // find the current line
- // * if selStart is 0, lastIndexOf(..., -1) is the same as lastIndexOf(..., 0)
- // * if lastIndexOf reruns -1, lineStart is 0 and it is still correct.
- const lineStart = value.lastIndexOf('\n', selStart - 1) + 1;
- let lineEnd = value.indexOf('\n', selStart);
- lineEnd = lineEnd < 0 ? value.length : lineEnd;
- let line = value.slice(lineStart, lineEnd);
- if (!line) return; // if the line is empty, do nothing, let the browser handle it
+ const linesBuf = textareaSplitLines(tvs.value, tvs.selStart);
+ const line = linesBuf.lines[linesBuf.posLineIndex] ?? '';
+ if (!line) return unhandled; // if the line is empty, do nothing, let the browser handle it
// parse the indention
- const indention = /^\s*/.exec(line)[0];
- line = line.slice(indention.length);
+ let lineContent = line;
+ const indention = /^\s*/.exec(lineContent)[0];
+ lineContent = lineContent.slice(indention.length);
+ if (linesBuf.inlinePos <= indention.length) return unhandled; // if cursor is at the indention, do nothing, let the browser handle it
// parse the prefixes: "1. ", "- ", "* ", there could also be " [ ] " or " [x] " for task lists
// there must be a space after the prefix because none of "1.foo" / "-foo" is a list item
- const prefixMatch = /^([0-9]+\.|[-*])(\s\[([ x])\])?\s/.exec(line);
+ const prefixMatch = /^([0-9]+\.|[-*])(\s\[([ x])\])?\s/.exec(lineContent);
let prefix = '';
if (prefixMatch) {
prefix = prefixMatch[0];
- if (lineStart + prefix.length > selStart) prefix = ''; // do not add new line if cursor is at prefix
+ if (prefix.length > linesBuf.inlinePos) prefix = ''; // do not add new line if cursor is at prefix
}
- line = line.slice(prefix.length);
- if (!indention && !prefix) return; // if no indention and no prefix, do nothing, let the browser handle it
+ lineContent = lineContent.slice(prefix.length);
+ if (!indention && !prefix) return unhandled; // if no indention and no prefix, do nothing, let the browser handle it
- e.preventDefault();
- if (!line) {
+ if (!lineContent) {
// clear current line if we only have i.e. '1. ' and the user presses enter again to finish creating a list
- textarea.value = value.slice(0, lineStart) + value.slice(lineEnd);
+ linesBuf.lines[linesBuf.posLineIndex] = '';
+ linesBuf.inlinePos = 0;
} else {
- // start a new line with the same indention and prefix
+ // start a new line with the same indention
let newPrefix = prefix;
- // a simple approach, otherwise it needs to parse the lines after the current line
if (/^\d+\./.test(prefix)) newPrefix = `1. ${newPrefix.slice(newPrefix.indexOf('.') + 2)}`;
newPrefix = newPrefix.replace('[x]', '[ ]');
- const newLine = `\n${indention}${newPrefix}`;
- textarea.value = value.slice(0, selStart) + newLine + value.slice(selEnd);
- textarea.setSelectionRange(selStart + newLine.length, selStart + newLine.length);
+
+ const inlinePos = linesBuf.inlinePos;
+ linesBuf.lines[linesBuf.posLineIndex] = line.substring(0, inlinePos);
+ const newLineLeft = `${indention}${newPrefix}`;
+ const newLine = `${newLineLeft}${line.substring(inlinePos)}`;
+ linesBuf.lines.splice(linesBuf.posLineIndex + 1, 0, newLine);
+ linesBuf.posLineIndex++;
+ linesBuf.inlinePos = newLineLeft.length;
+ recalculateLengthBeforeLine(linesBuf);
}
+
+ markdownReformatListNumbers(linesBuf, indention);
+ const newPos = linesBuf.lengthBeforePosLine + linesBuf.inlinePos;
+ return {handled: true, valueSelection: {value: linesBuf.lines.join('\n'), selStart: newPos, selEnd: newPos}};
+}
+
+function handleNewline(textarea: HTMLTextAreaElement, e: Event) {
+ const ret = markdownHandleIndention({value: textarea.value, selStart: textarea.selectionStart, selEnd: textarea.selectionEnd});
+ if (!ret.handled) return;
+ e.preventDefault();
+ textarea.value = ret.valueSelection.value;
+ textarea.setSelectionRange(ret.valueSelection.selStart, ret.valueSelection.selEnd);
triggerEditorContentChanged(textarea);
}
-export function initTextareaMarkdown(textarea) {
+function isTextExpanderShown(textarea: HTMLElement): boolean {
+ return Boolean(textarea.closest('text-expander')?.querySelector('.suggestions'));
+}
+
+export function initTextareaMarkdown(textarea: HTMLTextAreaElement) {
textarea.addEventListener('keydown', (e) => {
+ if (isTextExpanderShown(textarea)) return;
if (e.key === 'Tab' && !e.ctrlKey && !e.metaKey && !e.altKey) {
// use Tab/Shift-Tab to indent/unindent the selected lines
handleIndentSelection(textarea, e);
diff --git a/web_src/js/features/comp/EditorUpload.test.ts b/web_src/js/features/comp/EditorUpload.test.ts
index 55f3f74389..e6e5f4de13 100644
--- a/web_src/js/features/comp/EditorUpload.test.ts
+++ b/web_src/js/features/comp/EditorUpload.test.ts
@@ -1,4 +1,4 @@
-import {removeAttachmentLinksFromMarkdown} from './EditorUpload.ts';
+import {pasteAsMarkdownLink, removeAttachmentLinksFromMarkdown} from './EditorUpload.ts';
test('removeAttachmentLinksFromMarkdown', () => {
expect(removeAttachmentLinksFromMarkdown('a foo b', 'foo')).toBe('a foo b');
@@ -12,3 +12,13 @@ test('removeAttachmentLinksFromMarkdown', () => {
expect(removeAttachmentLinksFromMarkdown('a <img src="/attachments/foo"> b', 'foo')).toBe('a b');
expect(removeAttachmentLinksFromMarkdown('a <img src="/attachments/foo" width="100"/> b', 'foo')).toBe('a b');
});
+
+test('preparePasteAsMarkdownLink', () => {
+ expect(pasteAsMarkdownLink({value: 'foo', selectionStart: 0, selectionEnd: 0}, 'bar')).toBeNull();
+ expect(pasteAsMarkdownLink({value: 'foo', selectionStart: 0, selectionEnd: 0}, 'https://gitea.com')).toBeNull();
+ expect(pasteAsMarkdownLink({value: 'foo', selectionStart: 0, selectionEnd: 3}, 'bar')).toBeNull();
+ expect(pasteAsMarkdownLink({value: 'foo', selectionStart: 0, selectionEnd: 3}, 'https://gitea.com')).toBe('[foo](https://gitea.com)');
+ expect(pasteAsMarkdownLink({value: '..(url)', selectionStart: 3, selectionEnd: 6}, 'https://gitea.com')).toBe('[url](https://gitea.com)');
+ expect(pasteAsMarkdownLink({value: '[](url)', selectionStart: 3, selectionEnd: 6}, 'https://gitea.com')).toBeNull();
+ expect(pasteAsMarkdownLink({value: 'https://example.com', selectionStart: 0, selectionEnd: 19}, 'https://gitea.com')).toBeNull();
+});
diff --git a/web_src/js/features/comp/EditorUpload.ts b/web_src/js/features/comp/EditorUpload.ts
index 89982747ea..bf78f58daf 100644
--- a/web_src/js/features/comp/EditorUpload.ts
+++ b/web_src/js/features/comp/EditorUpload.ts
@@ -8,43 +8,46 @@ import {
generateMarkdownLinkForAttachment,
} from '../dropzone.ts';
import type CodeMirror from 'codemirror';
+import type EasyMDE from 'easymde';
+import type {DropzoneFile} from 'dropzone';
let uploadIdCounter = 0;
export const EventUploadStateChanged = 'ce-upload-state-changed';
-export function triggerUploadStateChanged(target) {
+export function triggerUploadStateChanged(target: HTMLElement) {
target.dispatchEvent(new CustomEvent(EventUploadStateChanged, {bubbles: true}));
}
-function uploadFile(dropzoneEl, file) {
+function uploadFile(dropzoneEl: HTMLElement, file: File) {
return new Promise((resolve) => {
const curUploadId = uploadIdCounter++;
- file._giteaUploadId = curUploadId;
+ (file as any)._giteaUploadId = curUploadId;
const dropzoneInst = dropzoneEl.dropzone;
- const onUploadDone = ({file}) => {
+ const onUploadDone = ({file}: {file: any}) => {
if (file._giteaUploadId === curUploadId) {
dropzoneInst.off(DropzoneCustomEventUploadDone, onUploadDone);
resolve(file);
}
};
dropzoneInst.on(DropzoneCustomEventUploadDone, onUploadDone);
- dropzoneInst.handleFiles([file]);
+ // FIXME: this is not entirely correct because `file` does not satisfy DropzoneFile (we have abused the Dropzone for long time)
+ dropzoneInst.addFile(file as DropzoneFile);
});
}
class TextareaEditor {
- editor : HTMLTextAreaElement;
+ editor: HTMLTextAreaElement;
- constructor(editor) {
+ constructor(editor: HTMLTextAreaElement) {
this.editor = editor;
}
- insertPlaceholder(value) {
+ insertPlaceholder(value: string) {
textareaInsertText(this.editor, value);
}
- replacePlaceholder(oldVal, newVal) {
+ replacePlaceholder(oldVal: string, newVal: string) {
const editor = this.editor;
const startPos = editor.selectionStart;
const endPos = editor.selectionEnd;
@@ -65,11 +68,11 @@ class TextareaEditor {
class CodeMirrorEditor {
editor: CodeMirror.EditorFromTextArea;
- constructor(editor) {
+ constructor(editor: CodeMirror.EditorFromTextArea) {
this.editor = editor;
}
- insertPlaceholder(value) {
+ insertPlaceholder(value: string) {
const editor = this.editor;
const startPoint = editor.getCursor('start');
const endPoint = editor.getCursor('end');
@@ -80,7 +83,7 @@ class CodeMirrorEditor {
triggerEditorContentChanged(editor.getTextArea());
}
- replacePlaceholder(oldVal, newVal) {
+ replacePlaceholder(oldVal: string, newVal: string) {
const editor = this.editor;
const endPoint = editor.getCursor('end');
if (editor.getSelection() === oldVal) {
@@ -96,7 +99,7 @@ class CodeMirrorEditor {
}
}
-async function handleUploadFiles(editor, dropzoneEl, files, e) {
+async function handleUploadFiles(editor: CodeMirrorEditor | TextareaEditor, dropzoneEl: HTMLElement, files: Array<File> | FileList, e: Event) {
e.preventDefault();
for (const file of files) {
const name = file.name.slice(0, file.name.lastIndexOf('.'));
@@ -109,29 +112,38 @@ async function handleUploadFiles(editor, dropzoneEl, files, e) {
}
}
-export function removeAttachmentLinksFromMarkdown(text, fileUuid) {
+export function removeAttachmentLinksFromMarkdown(text: string, fileUuid: string) {
text = text.replace(new RegExp(`!?\\[([^\\]]+)\\]\\(/?attachments/${fileUuid}\\)`, 'g'), '');
- text = text.replace(new RegExp(`<img[^>]+src="/?attachments/${fileUuid}"[^>]*>`, 'g'), '');
+ text = text.replace(new RegExp(`[<]img[^>]+src="/?attachments/${fileUuid}"[^>]*>`, 'g'), '');
return text;
}
-function handleClipboardText(textarea, e, {text, isShiftDown}) {
+export function pasteAsMarkdownLink(textarea: {value: string, selectionStart: number, selectionEnd: number}, pastedText: string): string | null {
+ const {value, selectionStart, selectionEnd} = textarea;
+ const selectedText = value.substring(selectionStart, selectionEnd);
+ const trimmedText = pastedText.trim();
+ const beforeSelection = value.substring(0, selectionStart);
+ const afterSelection = value.substring(selectionEnd);
+ const isInMarkdownLink = beforeSelection.endsWith('](') && afterSelection.startsWith(')');
+ const asMarkdownLink = selectedText && isUrl(trimmedText) && !isUrl(selectedText) && !isInMarkdownLink;
+ return asMarkdownLink ? `[${selectedText}](${trimmedText})` : null;
+}
+
+function handleClipboardText(textarea: HTMLTextAreaElement, e: ClipboardEvent, pastedText: string, isShiftDown: boolean) {
// pasting with "shift" means "paste as original content" in most applications
if (isShiftDown) return; // let the browser handle it
// when pasting links over selected text, turn it into [text](link)
- const {value, selectionStart, selectionEnd} = textarea;
- const selectedText = value.substring(selectionStart, selectionEnd);
- const trimmedText = text.trim();
- if (selectedText && isUrl(trimmedText) && !isUrl(selectedText)) {
+ const pastedAsMarkdown = pasteAsMarkdownLink(textarea, pastedText);
+ if (pastedAsMarkdown) {
e.preventDefault();
- replaceTextareaSelection(textarea, `[${selectedText}](${trimmedText})`);
+ replaceTextareaSelection(textarea, pastedAsMarkdown);
}
// else, let the browser handle it
}
// extract text and images from "paste" event
-function getPastedContent(e) {
+function getPastedContent(e: ClipboardEvent) {
const images = [];
for (const item of e.clipboardData?.items ?? []) {
if (item.type?.startsWith('image/')) {
@@ -142,8 +154,8 @@ function getPastedContent(e) {
return {text, images};
}
-export function initEasyMDEPaste(easyMDE, dropzoneEl) {
- const editor = new CodeMirrorEditor(easyMDE.codemirror);
+export function initEasyMDEPaste(easyMDE: EasyMDE, dropzoneEl: HTMLElement) {
+ const editor = new CodeMirrorEditor(easyMDE.codemirror as any);
easyMDE.codemirror.on('paste', (_, e) => {
const {images} = getPastedContent(e);
if (!images.length) return;
@@ -160,28 +172,28 @@ export function initEasyMDEPaste(easyMDE, dropzoneEl) {
});
}
-export function initTextareaEvents(textarea, dropzoneEl) {
+export function initTextareaEvents(textarea: HTMLTextAreaElement, dropzoneEl: HTMLElement) {
let isShiftDown = false;
- textarea.addEventListener('keydown', (e) => {
+ textarea.addEventListener('keydown', (e: KeyboardEvent) => {
if (e.shiftKey) isShiftDown = true;
});
- textarea.addEventListener('keyup', (e) => {
+ textarea.addEventListener('keyup', (e: KeyboardEvent) => {
if (!e.shiftKey) isShiftDown = false;
});
- textarea.addEventListener('paste', (e) => {
+ textarea.addEventListener('paste', (e: ClipboardEvent) => {
const {images, text} = getPastedContent(e);
if (images.length && dropzoneEl) {
handleUploadFiles(new TextareaEditor(textarea), dropzoneEl, images, e);
} else if (text) {
- handleClipboardText(textarea, e, {text, isShiftDown});
+ handleClipboardText(textarea, e, text, isShiftDown);
}
});
- textarea.addEventListener('drop', (e) => {
+ textarea.addEventListener('drop', (e: DragEvent) => {
if (!e.dataTransfer.files.length) return;
if (!dropzoneEl) return;
handleUploadFiles(new TextareaEditor(textarea), dropzoneEl, e.dataTransfer.files, e);
});
- dropzoneEl?.dropzone.on(DropzoneCustomEventRemovedFile, ({fileUuid}) => {
+ dropzoneEl?.dropzone.on(DropzoneCustomEventRemovedFile, ({fileUuid}: {fileUuid: string}) => {
const newText = removeAttachmentLinksFromMarkdown(textarea.value, fileUuid);
if (textarea.value !== newText) textarea.value = newText;
});
diff --git a/web_src/js/features/comp/LabelEdit.ts b/web_src/js/features/comp/LabelEdit.ts
index 7bceb636bb..423440129c 100644
--- a/web_src/js/features/comp/LabelEdit.ts
+++ b/web_src/js/features/comp/LabelEdit.ts
@@ -1,5 +1,6 @@
import {toggleElem} from '../../utils/dom.ts';
import {fomanticQuery} from '../../modules/fomantic/base.ts';
+import {submitFormFetchAction} from '../common-fetch-action.ts';
function nameHasScope(name: string): boolean {
return /.*[^/]\/[^/].*/.test(name);
@@ -18,6 +19,8 @@ export function initCompLabelEdit(pageSelector: string) {
const elExclusiveField = elModal.querySelector('.label-exclusive-input-field');
const elExclusiveInput = elModal.querySelector<HTMLInputElement>('.label-exclusive-input');
const elExclusiveWarning = elModal.querySelector('.label-exclusive-warning');
+ const elExclusiveOrderField = elModal.querySelector<HTMLInputElement>('.label-exclusive-order-input-field');
+ const elExclusiveOrderInput = elModal.querySelector<HTMLInputElement>('.label-exclusive-order-input');
const elIsArchivedField = elModal.querySelector('.label-is-archived-input-field');
const elIsArchivedInput = elModal.querySelector<HTMLInputElement>('.label-is-archived-input');
const elDescInput = elModal.querySelector<HTMLInputElement>('.label-desc-input');
@@ -29,6 +32,13 @@ export function initCompLabelEdit(pageSelector: string) {
const showExclusiveWarning = hasScope && elExclusiveInput.checked && elModal.hasAttribute('data-need-warn-exclusive');
toggleElem(elExclusiveWarning, showExclusiveWarning);
if (!hasScope) elExclusiveInput.checked = false;
+ toggleElem(elExclusiveOrderField, elExclusiveInput.checked);
+
+ if (parseInt(elExclusiveOrderInput.value) <= 0) {
+ elExclusiveOrderInput.style.color = 'var(--color-placeholder-text) !important';
+ } else {
+ elExclusiveOrderInput.style.color = null;
+ }
};
const showLabelEditModal = (btn:HTMLElement) => {
@@ -36,6 +46,7 @@ export function initCompLabelEdit(pageSelector: string) {
const form = elModal.querySelector<HTMLFormElement>('form');
elLabelId.value = btn.getAttribute('data-label-id') || '';
elNameInput.value = btn.getAttribute('data-label-name') || '';
+ elExclusiveOrderInput.value = btn.getAttribute('data-label-exclusive-order') || '0';
elIsArchivedInput.checked = btn.getAttribute('data-label-is-archived') === 'true';
elExclusiveInput.checked = btn.getAttribute('data-label-exclusive') === 'true';
elDescInput.value = btn.getAttribute('data-label-description') || '';
@@ -60,7 +71,8 @@ export function initCompLabelEdit(pageSelector: string) {
form.reportValidity();
return false;
}
- form.submit();
+ submitFormFetchAction(form);
+ return false;
},
}).modal('show');
};
diff --git a/web_src/js/features/comp/QuickSubmit.ts b/web_src/js/features/comp/QuickSubmit.ts
index 385acb319f..0a41f69132 100644
--- a/web_src/js/features/comp/QuickSubmit.ts
+++ b/web_src/js/features/comp/QuickSubmit.ts
@@ -1,6 +1,6 @@
import {querySingleVisibleElem} from '../../utils/dom.ts';
-export function handleGlobalEnterQuickSubmit(target) {
+export function handleGlobalEnterQuickSubmit(target: HTMLElement) {
let form = target.closest('form');
if (form) {
if (!form.checkValidity()) {
diff --git a/web_src/js/features/comp/ReactionSelector.ts b/web_src/js/features/comp/ReactionSelector.ts
index e93e3b8377..bb54593f11 100644
--- a/web_src/js/features/comp/ReactionSelector.ts
+++ b/web_src/js/features/comp/ReactionSelector.ts
@@ -1,37 +1,31 @@
import {POST} from '../../modules/fetch.ts';
-import {fomanticQuery} from '../../modules/fomantic/base.ts';
import type {DOMEvent} from '../../utils/dom.ts';
+import {registerGlobalEventFunc} from '../../modules/observer.ts';
-export function initCompReactionSelector(parent: ParentNode = document) {
- for (const container of parent.querySelectorAll<HTMLElement>('.issue-content, .diff-file-body')) {
- container.addEventListener('click', async (e: DOMEvent<MouseEvent>) => {
- // there are 2 places for the "reaction" buttons, one is the top-right reaction menu, one is the bottom of the comment
- const target = e.target.closest('.comment-reaction-button');
- if (!target) return;
- e.preventDefault();
+export function initCompReactionSelector() {
+ registerGlobalEventFunc('click', 'onCommentReactionButtonClick', async (target: HTMLElement, e: DOMEvent<MouseEvent>) => {
+ // there are 2 places for the "reaction" buttons, one is the top-right reaction menu, one is the bottom of the comment
+ e.preventDefault();
- if (target.classList.contains('disabled')) return;
+ if (target.classList.contains('disabled')) return;
- const actionUrl = target.closest('[data-action-url]').getAttribute('data-action-url');
- const reactionContent = target.getAttribute('data-reaction-content');
+ const actionUrl = target.closest('[data-action-url]').getAttribute('data-action-url');
+ const reactionContent = target.getAttribute('data-reaction-content');
- const commentContainer = target.closest('.comment-container');
+ const commentContainer = target.closest('.comment-container');
- const bottomReactions = commentContainer.querySelector('.bottom-reactions'); // may not exist if there is no reaction
- const bottomReactionBtn = bottomReactions?.querySelector(`a[data-reaction-content="${CSS.escape(reactionContent)}"]`);
- const hasReacted = bottomReactionBtn?.getAttribute('data-has-reacted') === 'true';
+ const bottomReactions = commentContainer.querySelector('.bottom-reactions'); // may not exist if there is no reaction
+ const bottomReactionBtn = bottomReactions?.querySelector(`a[data-reaction-content="${CSS.escape(reactionContent)}"]`);
+ const hasReacted = bottomReactionBtn?.getAttribute('data-has-reacted') === 'true';
- const res = await POST(`${actionUrl}/${hasReacted ? 'unreact' : 'react'}`, {
- data: new URLSearchParams({content: reactionContent}),
- });
-
- const data = await res.json();
- bottomReactions?.remove();
- if (data.html) {
- commentContainer.insertAdjacentHTML('beforeend', data.html);
- const bottomReactionsDropdowns = commentContainer.querySelectorAll('.bottom-reactions .dropdown.select-reaction');
- fomanticQuery(bottomReactionsDropdowns).dropdown(); // re-init the dropdown
- }
+ const res = await POST(`${actionUrl}/${hasReacted ? 'unreact' : 'react'}`, {
+ data: new URLSearchParams({content: reactionContent}),
});
- }
+
+ const data = await res.json();
+ bottomReactions?.remove();
+ if (data.html) {
+ commentContainer.insertAdjacentHTML('beforeend', data.html);
+ }
+ });
}
diff --git a/web_src/js/features/comp/SearchUserBox.ts b/web_src/js/features/comp/SearchUserBox.ts
index 2e3b3f83be..4b13a2141f 100644
--- a/web_src/js/features/comp/SearchUserBox.ts
+++ b/web_src/js/features/comp/SearchUserBox.ts
@@ -1,4 +1,4 @@
-import {htmlEscape} from 'escape-goat';
+import {htmlEscape} from '../../utils/html.ts';
import {fomanticQuery} from '../../modules/fomantic/base.ts';
const {appSubUrl} = window.config;
@@ -14,7 +14,7 @@ export function initCompSearchUserBox() {
minCharacters: 2,
apiSettings: {
url: `${appSubUrl}/user/search_candidates?q={query}`,
- onResponse(response) {
+ onResponse(response: any) {
const resultItems = [];
const searchQuery = searchUserBox.querySelector('input').value;
const searchQueryUppercase = searchQuery.toUpperCase();
diff --git a/web_src/js/features/comp/TextExpander.ts b/web_src/js/features/comp/TextExpander.ts
index e0c4abed75..2d79fe5029 100644
--- a/web_src/js/features/comp/TextExpander.ts
+++ b/web_src/js/features/comp/TextExpander.ts
@@ -1,18 +1,25 @@
import {matchEmoji, matchMention, matchIssue} from '../../utils/match.ts';
import {emojiString} from '../emoji.ts';
import {svg} from '../../svg.ts';
-import {parseIssueHref, parseIssueNewHref} from '../../utils.ts';
+import {parseIssueHref, parseRepoOwnerPathInfo} from '../../utils.ts';
import {createElementFromAttrs, createElementFromHTML} from '../../utils/dom.ts';
import {getIssueColor, getIssueIcon} from '../issue.ts';
import {debounce} from 'perfect-debounce';
+import type TextExpanderElement from '@github/text-expander-element';
+import type {TextExpanderChangeEvent, TextExpanderResult} from '@github/text-expander-element';
-const debouncedSuggestIssues = debounce((key: string, text: string) => new Promise<{matched:boolean; fragment?: HTMLElement}>(async (resolve) => {
- let issuePathInfo = parseIssueHref(window.location.href);
- if (!issuePathInfo.ownerName) issuePathInfo = parseIssueNewHref(window.location.href);
- if (!issuePathInfo.ownerName) return resolve({matched: false});
+async function fetchIssueSuggestions(key: string, text: string): Promise<TextExpanderResult> {
+ const issuePathInfo = parseIssueHref(window.location.href);
+ if (!issuePathInfo.ownerName) {
+ const repoOwnerPathInfo = parseRepoOwnerPathInfo(window.location.pathname);
+ issuePathInfo.ownerName = repoOwnerPathInfo.ownerName;
+ issuePathInfo.repoName = repoOwnerPathInfo.repoName;
+ // then no issuePathInfo.indexString here, it is only used to exclude the current issue when "matchIssue"
+ }
+ if (!issuePathInfo.ownerName) return {matched: false};
const matches = await matchIssue(issuePathInfo.ownerName, issuePathInfo.repoName, issuePathInfo.indexString, text);
- if (!matches.length) return resolve({matched: false});
+ if (!matches.length) return {matched: false};
const ul = createElementFromAttrs('ul', {class: 'suggestions'});
for (const issue of matches) {
@@ -24,11 +31,40 @@ const debouncedSuggestIssues = debounce((key: string, text: string) => new Promi
);
ul.append(li);
}
- resolve({matched: true, fragment: ul});
-}), 100);
+ return {matched: true, fragment: ul};
+}
+
+export function initTextExpander(expander: TextExpanderElement) {
+ if (!expander) return;
+
+ const textarea = expander.querySelector<HTMLTextAreaElement>('textarea');
-export function initTextExpander(expander) {
- expander?.addEventListener('text-expander-change', ({detail: {key, provide, text}}) => {
+ // help to fix the text-expander "multiword+promise" bug: do not show the popup when there is no "#" before current line
+ const shouldShowIssueSuggestions = () => {
+ const posVal = textarea.value.substring(0, textarea.selectionStart);
+ const lineStart = posVal.lastIndexOf('\n');
+ const keyStart = posVal.lastIndexOf('#');
+ return keyStart > lineStart;
+ };
+
+ const debouncedIssueSuggestions = debounce(async (key: string, text: string): Promise<TextExpanderResult> => {
+ // https://github.com/github/text-expander-element/issues/71
+ // Upstream bug: when using "multiword+promise", TextExpander will get wrong "key" position.
+ // To reproduce, comment out the "shouldShowIssueSuggestions" check, use the "await sleep" below,
+ // then use content "close #20\nclose #20\nclose #20" (3 lines), keep changing the last line `#20` part from the end (including removing the `#`)
+ // There will be a JS error: Uncaught (in promise) IndexSizeError: Failed to execute 'setStart' on 'Range': The offset 28 is larger than the node's length (27).
+
+ // check the input before the request, to avoid emitting empty query to backend (still related to the upstream bug)
+ if (!shouldShowIssueSuggestions()) return {matched: false};
+ // await sleep(Math.random() * 1000); // help to reproduce the text-expander bug
+ const ret = await fetchIssueSuggestions(key, text);
+ // check the input again to avoid text-expander using incorrect position (upstream bug)
+ if (!shouldShowIssueSuggestions()) return {matched: false};
+ return ret;
+ }, 300); // to match onInputDebounce delay
+
+ expander.addEventListener('text-expander-change', (e: TextExpanderChangeEvent) => {
+ const {key, text, provide} = e.detail;
if (key === ':') {
const matches = matchEmoji(text);
if (!matches.length) return provide({matched: false});
@@ -61,6 +97,7 @@ export function initTextExpander(expander) {
li.append(img);
const nameSpan = document.createElement('span');
+ nameSpan.classList.add('name');
nameSpan.textContent = name;
li.append(nameSpan);
@@ -76,10 +113,11 @@ export function initTextExpander(expander) {
provide({matched: true, fragment: ul});
} else if (key === '#') {
- provide(debouncedSuggestIssues(key, text));
+ provide(debouncedIssueSuggestions(key, text));
}
});
- expander?.addEventListener('text-expander-value', ({detail}) => {
+
+ expander.addEventListener('text-expander-value', ({detail}: Record<string, any>) => {
if (detail?.item) {
// add a space after @mentions and #issue as it's likely the user wants one
const suffix = ['@', '#'].includes(detail.key) ? ' ' : '';
diff --git a/web_src/js/features/comp/WebHookEditor.ts b/web_src/js/features/comp/WebHookEditor.ts
index 203396af80..794b3c99ca 100644
--- a/web_src/js/features/comp/WebHookEditor.ts
+++ b/web_src/js/features/comp/WebHookEditor.ts
@@ -6,7 +6,7 @@ export function initCompWebHookEditor() {
return;
}
- for (const input of document.querySelectorAll('.events.checkbox input')) {
+ for (const input of document.querySelectorAll<HTMLInputElement>('.events.checkbox input')) {
input.addEventListener('change', function () {
if (this.checked) {
showElem('.events.fields');
@@ -14,7 +14,7 @@ export function initCompWebHookEditor() {
});
}
- for (const input of document.querySelectorAll('.non-events.checkbox input')) {
+ for (const input of document.querySelectorAll<HTMLInputElement>('.non-events.checkbox input')) {
input.addEventListener('change', function () {
if (this.checked) {
hideElem('.events.fields');
@@ -34,7 +34,7 @@ export function initCompWebHookEditor() {
}
// Test delivery
- document.querySelector('#test-delivery')?.addEventListener('click', async function () {
+ document.querySelector<HTMLButtonElement>('#test-delivery')?.addEventListener('click', async function () {
this.classList.add('is-loading', 'disabled');
await POST(this.getAttribute('data-link'));
setTimeout(() => {