diff options
author | Daniel Baumann <daniel@debian.org> | 2024-10-18 20:33:49 +0200 |
---|---|---|
committer | Daniel Baumann <daniel@debian.org> | 2024-12-12 23:57:56 +0100 |
commit | e68b9d00a6e05b3a941f63ffb696f91e554ac5ec (patch) | |
tree | 97775d6c13b0f416af55314eb6a89ef792474615 /web_src/js/features/comp | |
parent | Initial commit. (diff) | |
download | forgejo-e68b9d00a6e05b3a941f63ffb696f91e554ac5ec.tar.xz forgejo-e68b9d00a6e05b3a941f63ffb696f91e554ac5ec.zip |
Adding upstream version 9.0.3.
Signed-off-by: Daniel Baumann <daniel@debian.org>
Diffstat (limited to 'web_src/js/features/comp')
-rw-r--r-- | web_src/js/features/comp/ComboMarkdownEditor.js | 413 | ||||
-rw-r--r-- | web_src/js/features/comp/ConfirmModal.js | 30 | ||||
-rw-r--r-- | web_src/js/features/comp/EasyMDEToolbarActions.js | 152 | ||||
-rw-r--r-- | web_src/js/features/comp/LabelEdit.js | 96 | ||||
-rw-r--r-- | web_src/js/features/comp/Paste.js | 144 | ||||
-rw-r--r-- | web_src/js/features/comp/QuickSubmit.js | 17 | ||||
-rw-r--r-- | web_src/js/features/comp/ReactionSelector.js | 38 | ||||
-rw-r--r-- | web_src/js/features/comp/SearchUserBox.js | 51 | ||||
-rw-r--r-- | web_src/js/features/comp/TextExpander.js | 61 | ||||
-rw-r--r-- | web_src/js/features/comp/WebHookEditor.js | 28 |
10 files changed, 1030 insertions, 0 deletions
diff --git a/web_src/js/features/comp/ComboMarkdownEditor.js b/web_src/js/features/comp/ComboMarkdownEditor.js new file mode 100644 index 0000000..70e92de --- /dev/null +++ b/web_src/js/features/comp/ComboMarkdownEditor.js @@ -0,0 +1,413 @@ +import '@github/markdown-toolbar-element'; +import '@github/text-expander-element'; +import $ from 'jquery'; +import {attachTribute} from '../tribute.js'; +import {hideElem, showElem, autosize, isElemVisible} from '../../utils/dom.js'; +import {initEasyMDEPaste, initTextareaPaste} from './Paste.js'; +import {handleGlobalEnterQuickSubmit} from './QuickSubmit.js'; +import {renderPreviewPanelContent} from '../repo-editor.js'; +import {easyMDEToolbarActions} from './EasyMDEToolbarActions.js'; +import {initTextExpander} from './TextExpander.js'; +import {showErrorToast} from '../../modules/toast.js'; +import {POST} from '../../modules/fetch.js'; + +let elementIdCounter = 0; + +/** + * validate if the given textarea is non-empty. + * @param {HTMLElement} textarea - The textarea element to be validated. + * @returns {boolean} returns true if validation succeeded. + */ +export function validateTextareaNonEmpty(textarea) { + // 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) { + if (isElemVisible(textarea)) { + textarea.required = true; + const form = textarea.closest('form'); + form?.reportValidity(); + } else { + // The alert won't hurt users too much, because we are dropping the EasyMDE and the check only occurs in a few places. + showErrorToast('Require non-empty content'); + } + return false; + } + return true; +} + +class ComboMarkdownEditor { + constructor(container, options = {}) { + container._giteaComboMarkdownEditor = this; + this.options = options; + this.container = container; + } + + async init() { + this.prepareEasyMDEToolbarActions(); + this.setupContainer(); + this.setupTab(); + this.setupDropzone(); + this.setupTextarea(); + + await this.switchToUserPreference(); + } + + applyEditorHeights(el, heights) { + if (!heights) return; + if (heights.minHeight) el.style.minHeight = heights.minHeight; + if (heights.height) el.style.height = heights.height; + if (heights.maxHeight) el.style.maxHeight = heights.maxHeight; + } + + setupContainer() { + initTextExpander(this.container.querySelector('text-expander')); + this.container.addEventListener('ce-editor-content-changed', (e) => this.options?.onContentChanged?.(this, e)); + } + + setupTextarea() { + this.textarea = this.container.querySelector('.markdown-text-editor'); + this.textarea._giteaComboMarkdownEditor = this; + this.textarea.id = `_combo_markdown_editor_${String(elementIdCounter++)}`; + this.textarea.addEventListener('input', (e) => this.options?.onContentChanged?.(this, e)); + this.applyEditorHeights(this.textarea, this.options.editorHeights); + + if (this.textarea.getAttribute('data-disable-autosize') !== 'true') { + this.textareaAutosize = autosize(this.textarea, {viewportMarginBottom: 130}); + } + + this.textareaMarkdownToolbar = this.container.querySelector('markdown-toolbar'); + this.textareaMarkdownToolbar.setAttribute('for', this.textarea.id); + for (const el of this.textareaMarkdownToolbar.querySelectorAll('.markdown-toolbar-button')) { + // upstream bug: The role code is never executed in base MarkdownButtonElement https://github.com/github/markdown-toolbar-element/issues/70 + el.setAttribute('role', 'button'); + // the editor usually is in a form, so the buttons should have "type=button", avoiding conflicting with the form's submit. + if (el.nodeName === 'BUTTON' && !el.getAttribute('type')) el.setAttribute('type', 'button'); + } + this.textareaMarkdownToolbar.querySelector('button[data-md-action="indent"]')?.addEventListener('click', () => { + this.indentSelection(false); + }); + this.textareaMarkdownToolbar.querySelector('button[data-md-action="unindent"]')?.addEventListener('click', () => { + this.indentSelection(true); + }); + + this.textarea.addEventListener('keydown', (e) => { + if (e.shiftKey) { + e.target._shiftDown = true; + } + if (e.key === 'Enter' && !e.shiftKey && !e.ctrlKey && !e.altKey) { + if (!this.breakLine()) return; // Nothing changed, let the default handler work. + this.options?.onContentChanged?.(this, e); + e.preventDefault(); + } + }); + this.textarea.addEventListener('keyup', (e) => { + if (!e.shiftKey) { + e.target._shiftDown = false; + } + }); + + const monospaceButton = this.container.querySelector('.markdown-switch-monospace'); + const monospaceEnabled = localStorage?.getItem('markdown-editor-monospace') === 'true'; + const monospaceText = monospaceButton.getAttribute(monospaceEnabled ? 'data-disable-text' : 'data-enable-text'); + monospaceButton.setAttribute('data-tooltip-content', monospaceText); + monospaceButton.setAttribute('aria-checked', String(monospaceEnabled)); + + monospaceButton?.addEventListener('click', (e) => { + e.preventDefault(); + const enabled = localStorage?.getItem('markdown-editor-monospace') !== 'true'; + localStorage.setItem('markdown-editor-monospace', String(enabled)); + this.textarea.classList.toggle('tw-font-mono', enabled); + const text = monospaceButton.getAttribute(enabled ? 'data-disable-text' : 'data-enable-text'); + monospaceButton.setAttribute('data-tooltip-content', text); + monospaceButton.setAttribute('aria-checked', String(enabled)); + }); + + const easymdeButton = this.container.querySelector('.markdown-switch-easymde'); + easymdeButton?.addEventListener('click', async (e) => { + e.preventDefault(); + this.userPreferredEditor = 'easymde'; + await this.switchToEasyMDE(); + }); + + if (this.dropzone) { + initTextareaPaste(this.textarea, this.dropzone); + } + } + + setupDropzone() { + const dropzoneParentContainer = this.container.getAttribute('data-dropzone-parent-container'); + if (dropzoneParentContainer) { + this.dropzone = this.container.closest(this.container.getAttribute('data-dropzone-parent-container'))?.querySelector('.dropzone'); + } + } + + setupTab() { + const $container = $(this.container); + const tabs = $container[0].querySelectorAll('.tabular.menu > .item'); + + // Fomantic Tab requires the "data-tab" to be globally unique. + // So here it uses our defined "data-tab-for" and "data-tab-panel" to generate the "data-tab" attribute for Fomantic. + const tabEditor = Array.from(tabs).find((tab) => tab.getAttribute('data-tab-for') === 'markdown-writer'); + const tabPreviewer = Array.from(tabs).find((tab) => tab.getAttribute('data-tab-for') === 'markdown-previewer'); + tabEditor.setAttribute('data-tab', `markdown-writer-${elementIdCounter}`); + tabPreviewer.setAttribute('data-tab', `markdown-previewer-${elementIdCounter}`); + const panelEditor = $container[0].querySelector('.ui.tab[data-tab-panel="markdown-writer"]'); + const panelPreviewer = $container[0].querySelector('.ui.tab[data-tab-panel="markdown-previewer"]'); + panelEditor.setAttribute('data-tab', `markdown-writer-${elementIdCounter}`); + panelPreviewer.setAttribute('data-tab', `markdown-previewer-${elementIdCounter}`); + elementIdCounter++; + + tabEditor.addEventListener('click', () => { + requestAnimationFrame(() => { + this.focus(); + }); + }); + + $(tabs).tab(); + + this.previewUrl = tabPreviewer.getAttribute('data-preview-url'); + this.previewContext = tabPreviewer.getAttribute('data-preview-context'); + this.previewMode = this.options.previewMode ?? 'comment'; + this.previewWiki = this.options.previewWiki ?? false; + tabPreviewer.addEventListener('click', async () => { + const formData = new FormData(); + formData.append('mode', this.previewMode); + formData.append('context', this.previewContext); + formData.append('text', this.value()); + formData.append('wiki', this.previewWiki); + const response = await POST(this.previewUrl, {data: formData}); + const data = await response.text(); + renderPreviewPanelContent($(panelPreviewer), data); + }); + } + + prepareEasyMDEToolbarActions() { + this.easyMDEToolbarDefault = [ + 'bold', 'italic', 'strikethrough', '|', 'heading-1', 'heading-2', 'heading-3', + 'heading-bigger', 'heading-smaller', '|', 'code', 'quote', '|', 'gitea-checkbox-empty', + 'gitea-checkbox-checked', '|', 'unordered-list', 'ordered-list', '|', 'link', 'image', + 'table', 'horizontal-rule', '|', 'gitea-switch-to-textarea', + ]; + } + + parseEasyMDEToolbar(EasyMDE, actions) { + this.easyMDEToolbarActions = this.easyMDEToolbarActions || easyMDEToolbarActions(EasyMDE, this); + const processed = []; + for (const action of actions) { + const actionButton = this.easyMDEToolbarActions[action]; + if (!actionButton) throw new Error(`Unknown EasyMDE toolbar action ${action}`); + processed.push(actionButton); + } + return processed; + } + + async switchToUserPreference() { + if (this.userPreferredEditor === 'easymde') { + await this.switchToEasyMDE(); + } else { + this.switchToTextarea(); + } + } + + switchToTextarea() { + if (!this.easyMDE) return; + showElem(this.textareaMarkdownToolbar); + if (this.easyMDE) { + this.easyMDE.toTextArea(); + this.easyMDE = null; + } + } + + async switchToEasyMDE() { + if (this.easyMDE) return; + // EasyMDE's CSS should be loaded via webpack config, otherwise our own styles can not overwrite the default styles. + const {default: EasyMDE} = await import(/* webpackChunkName: "easymde" */'easymde'); + const easyMDEOpt = { + autoDownloadFontAwesome: false, + element: this.textarea, + forceSync: true, + renderingConfig: {singleLineBreaks: false}, + indentWithTabs: false, + tabSize: 4, + spellChecker: false, + inputStyle: 'contenteditable', // nativeSpellcheck requires contenteditable + nativeSpellcheck: true, + ...this.options.easyMDEOptions, + }; + easyMDEOpt.toolbar = this.parseEasyMDEToolbar(EasyMDE, easyMDEOpt.toolbar ?? this.easyMDEToolbarDefault); + + this.easyMDE = new EasyMDE(easyMDEOpt); + this.easyMDE.codemirror.on('change', (...args) => {this.options?.onContentChanged?.(this, ...args)}); + this.easyMDE.codemirror.setOption('extraKeys', { + 'Cmd-Enter': (cm) => handleGlobalEnterQuickSubmit(cm.getTextArea()), + 'Ctrl-Enter': (cm) => handleGlobalEnterQuickSubmit(cm.getTextArea()), + Enter: (cm) => { + const tributeContainer = document.querySelector('.tribute-container'); + if (!tributeContainer || tributeContainer.style.display === 'none') { + cm.execCommand('newlineAndIndent'); + } + }, + Up: (cm) => { + const tributeContainer = document.querySelector('.tribute-container'); + if (!tributeContainer || tributeContainer.style.display === 'none') { + return cm.execCommand('goLineUp'); + } + }, + Down: (cm) => { + const tributeContainer = document.querySelector('.tribute-container'); + if (!tributeContainer || tributeContainer.style.display === 'none') { + return cm.execCommand('goLineDown'); + } + }, + }); + this.applyEditorHeights(this.container.querySelector('.CodeMirror-scroll'), this.options.editorHeights); + await attachTribute(this.easyMDE.codemirror.getInputField(), {mentions: true, emoji: true}); + initEasyMDEPaste(this.easyMDE, this.dropzone); + hideElem(this.textareaMarkdownToolbar); + } + + value(v = undefined) { + if (v === undefined) { + if (this.easyMDE) { + return this.easyMDE.value(); + } + return this.textarea.value; + } + + if (this.easyMDE) { + this.easyMDE.value(v); + } else { + this.textarea.value = v; + } + this.textareaAutosize?.resizeToFit(); + } + + focus() { + if (this.easyMDE) { + this.easyMDE.codemirror.focus(); + } else { + this.textarea.focus(); + } + } + + moveCursorToEnd() { + this.textarea.focus(); + this.textarea.setSelectionRange(this.textarea.value.length, this.textarea.value.length); + if (this.easyMDE) { + this.easyMDE.codemirror.focus(); + this.easyMDE.codemirror.setCursor(this.easyMDE.codemirror.lineCount(), 0); + } + } + + indentSelection(unindent) { + // Indent with 4 spaces, unindent 4 spaces or fewer or a lost tab. + const indentPrefix = ' '; + const unindentRegex = /^( {1,4}|\t)/; + + // Indent all lines that are included in the selection, partially or whole, while preserving the original selection at the end. + const lines = this.textarea.value.split('\n'); + const changedLines = []; + // The current selection or cursor position. + const [start, end] = [this.textarea.selectionStart, this.textarea.selectionEnd]; + // The range containing whole lines that will effectively be replaced. + let [editStart, editEnd] = [start, end]; + // The range that needs to be re-selected to match previous selection. + let [newStart, newEnd] = [start, end]; + // The start and end position of the current line (where end points to the newline or EOF) + let [lineStart, lineEnd] = [0, 0]; + + for (const line of lines) { + lineEnd = lineStart + line.length + 1; + if (lineEnd <= start) { + lineStart = lineEnd; + continue; + } + + const updated = unindent ? line.replace(unindentRegex, '') : indentPrefix + line; + changedLines.push(updated); + const move = updated.length - line.length; + + if (start >= lineStart && start < lineEnd) { + editStart = lineStart; + newStart = Math.max(start + move, lineStart); + } + + newEnd += move; + editEnd = lineEnd - 1; + lineStart = lineEnd; + if (lineStart > end) break; + } + + // Update changed lines whole. + const text = changedLines.join('\n'); + this.textarea.focus(); + this.textarea.setSelectionRange(editStart, editEnd); + if (!document.execCommand('insertText', false, text)) { + // execCommand is deprecated, but setRangeText (and any other direct value modifications) erases the native undo history. + // So only fall back to it if execCommand fails. + this.textarea.setRangeText(text); + } + + // Set selection to (effectively) be the same as before. + this.textarea.setSelectionRange(newStart, Math.max(newStart, newEnd)); + } + + breakLine() { + const [start, end] = [this.textarea.selectionStart, this.textarea.selectionEnd]; + + // Do nothing if a range is selected + if (start !== end) return false; + + const value = this.textarea.value; + // Find the beginning of the current line. + const lineStart = Math.max(0, value.lastIndexOf('\n', start - 1) + 1); + // Find the end and extract the line. + const lineEnd = value.indexOf('\n', start); + const line = value.slice(lineStart, lineEnd < 0 ? value.length : lineEnd); + // Match any whitespace at the start + any repeatable prefix + exactly one space after. + const prefix = line.match(/^\s*((\d+)[.)]\s|[-*+]\s+(\[[ x]\]\s?)?|(>\s+)+)?/); + + // Defer to browser if we can't do anything more useful, or if the cursor is inside the prefix. + if (!prefix || !prefix[0].length || lineStart + prefix[0].length > start) return false; + + // Insert newline + prefix. + let text = `\n${prefix[0]}`; + // Increment a number if present. (perhaps detecting repeating 1. and not doing that then would be a good idea) + const num = text.match(/\d+/); + if (num) text = text.replace(num[0], Number(num[0]) + 1); + text = text.replace('[x]', '[ ]'); + + if (!document.execCommand('insertText', false, text)) { + this.textarea.setRangeText(text); + } + + return true; + } + + get userPreferredEditor() { + return window.localStorage.getItem(`markdown-editor-${this.options.useScene ?? 'default'}`); + } + set userPreferredEditor(s) { + window.localStorage.setItem(`markdown-editor-${this.options.useScene ?? 'default'}`, s); + } +} + +export function getComboMarkdownEditor(el) { + if (el instanceof $) el = el[0]; + return el?._giteaComboMarkdownEditor; +} + +export async function initComboMarkdownEditor(container, options = {}) { + if (container instanceof $) { + if (container.length !== 1) { + throw new Error('initComboMarkdownEditor: container must be a single element'); + } + container = container[0]; + } + if (!container) { + throw new Error('initComboMarkdownEditor: container is null'); + } + const editor = new ComboMarkdownEditor(container, options); + await editor.init(); + return editor; +} diff --git a/web_src/js/features/comp/ConfirmModal.js b/web_src/js/features/comp/ConfirmModal.js new file mode 100644 index 0000000..e64996a --- /dev/null +++ b/web_src/js/features/comp/ConfirmModal.js @@ -0,0 +1,30 @@ +import $ from 'jquery'; +import {svg} from '../../svg.js'; +import {htmlEscape} from 'escape-goat'; + +const {i18n} = window.config; + +export async function confirmModal(opts = {content: '', buttonColor: 'primary'}) { + return new Promise((resolve) => { + const $modal = $(` +<div class="ui g-modal-confirm modal"> + <div class="content">${htmlEscape(opts.content)}</div> + <div class="actions"> + <button class="ui cancel button">${svg('octicon-x')} ${i18n.modal_cancel}</button> + <button class="ui ${opts.buttonColor || 'primary'} ok button">${svg('octicon-check')} ${i18n.modal_confirm}</button> + </div> +</div> +`); + + $modal.appendTo(document.body); + $modal.modal({ + onApprove() { + resolve(true); + }, + onHidden() { + $modal.remove(); + resolve(false); + }, + }).modal('show'); + }); +} diff --git a/web_src/js/features/comp/EasyMDEToolbarActions.js b/web_src/js/features/comp/EasyMDEToolbarActions.js new file mode 100644 index 0000000..35abb87 --- /dev/null +++ b/web_src/js/features/comp/EasyMDEToolbarActions.js @@ -0,0 +1,152 @@ +import {svg} from '../../svg.js'; + +export function easyMDEToolbarActions(EasyMDE, editor) { + const actions = { + '|': '|', + 'heading-1': { + action: EasyMDE.toggleHeading1, + icon: svg('octicon-heading'), + title: 'Heading 1', + }, + 'heading-2': { + action: EasyMDE.toggleHeading2, + icon: svg('octicon-heading'), + title: 'Heading 2', + }, + 'heading-3': { + action: EasyMDE.toggleHeading3, + icon: svg('octicon-heading'), + title: 'Heading 3', + }, + 'heading-smaller': { + action: EasyMDE.toggleHeadingSmaller, + icon: svg('octicon-heading'), + title: 'Decrease Heading', + }, + 'heading-bigger': { + action: EasyMDE.toggleHeadingBigger, + icon: svg('octicon-heading'), + title: 'Increase Heading', + }, + 'bold': { + action: EasyMDE.toggleBold, + icon: svg('octicon-bold'), + title: 'Bold', + }, + 'italic': { + action: EasyMDE.toggleItalic, + icon: svg('octicon-italic'), + title: 'Italic', + }, + 'strikethrough': { + action: EasyMDE.toggleStrikethrough, + icon: svg('octicon-strikethrough'), + title: 'Strikethrough', + }, + 'quote': { + action: EasyMDE.toggleBlockquote, + icon: svg('octicon-quote'), + title: 'Quote', + }, + 'code': { + action: EasyMDE.toggleCodeBlock, + icon: svg('octicon-code'), + title: 'Code', + }, + 'link': { + action: EasyMDE.drawLink, + icon: svg('octicon-link'), + title: 'Link', + }, + 'unordered-list': { + action: EasyMDE.toggleUnorderedList, + icon: svg('octicon-list-unordered'), + title: 'Unordered List', + }, + 'ordered-list': { + action: EasyMDE.toggleOrderedList, + icon: svg('octicon-list-ordered'), + title: 'Ordered List', + }, + 'image': { + action: EasyMDE.drawImage, + icon: svg('octicon-image'), + title: 'Image', + }, + 'table': { + action: EasyMDE.drawTable, + icon: svg('octicon-table'), + title: 'Table', + }, + 'horizontal-rule': { + action: EasyMDE.drawHorizontalRule, + icon: svg('octicon-horizontal-rule'), + title: 'Horizontal Rule', + }, + 'preview': { + action: EasyMDE.togglePreview, + icon: svg('octicon-eye'), + title: 'Preview', + }, + 'fullscreen': { + action: EasyMDE.toggleFullScreen, + icon: svg('octicon-screen-full'), + title: 'Fullscreen', + }, + 'side-by-side': { + action: EasyMDE.toggleSideBySide, + icon: svg('octicon-columns'), + title: 'Side by Side', + }, + + // Forgejo custom actions + 'gitea-checkbox-empty': { + action(e) { + const cm = e.codemirror; + cm.replaceSelection(`\n- [ ] ${cm.getSelection()}`); + cm.focus(); + }, + icon: svg('gitea-empty-checkbox'), + title: 'Add Checkbox (empty)', + }, + 'gitea-checkbox-checked': { + action(e) { + const cm = e.codemirror; + cm.replaceSelection(`\n- [x] ${cm.getSelection()}`); + cm.focus(); + }, + icon: svg('octicon-checkbox'), + title: 'Add Checkbox (checked)', + }, + 'gitea-switch-to-textarea': { + action: () => { + editor.userPreferredEditor = 'textarea'; + editor.switchToTextarea(); + }, + icon: svg('octicon-arrow-switch'), + title: 'Revert to simple textarea', + }, + 'gitea-code-inline': { + action(e) { + const cm = e.codemirror; + const selection = cm.getSelection(); + cm.replaceSelection(`\`${selection}\``); + if (!selection) { + const cursorPos = cm.getCursor(); + cm.setCursor(cursorPos.line, cursorPos.ch - 1); + } + cm.focus(); + }, + icon: svg('octicon-chevron-right'), + title: 'Add Inline Code', + }, + }; + + for (const [key, value] of Object.entries(actions)) { + if (typeof value !== 'string') { + value.name = key; + } + } + + return actions; +} diff --git a/web_src/js/features/comp/LabelEdit.js b/web_src/js/features/comp/LabelEdit.js new file mode 100644 index 0000000..2cc75cc --- /dev/null +++ b/web_src/js/features/comp/LabelEdit.js @@ -0,0 +1,96 @@ +import $ from 'jquery'; + +function isExclusiveScopeName(name) { + return /.*[^/]\/[^/].*/.test(name); +} + +function updateExclusiveLabelEdit(form) { + const nameInput = document.querySelector(`${form} .label-name-input`); + const exclusiveField = document.querySelector(`${form} .label-exclusive-input-field`); + const exclusiveCheckbox = document.querySelector(`${form} .label-exclusive-input`); + const exclusiveWarning = document.querySelector(`${form} .label-exclusive-warning`); + + if (isExclusiveScopeName(nameInput.value)) { + exclusiveField?.classList.remove('muted'); + exclusiveField?.removeAttribute('aria-disabled'); + if (exclusiveCheckbox.checked && exclusiveCheckbox.getAttribute('data-exclusive-warn')) { + exclusiveWarning?.classList.remove('tw-hidden'); + } else { + exclusiveWarning?.classList.add('tw-hidden'); + } + } else { + exclusiveField?.classList.add('muted'); + exclusiveField?.setAttribute('aria-disabled', 'true'); + exclusiveWarning?.classList.add('tw-hidden'); + } +} + +export function initCompLabelEdit(selector) { + if (!$(selector).length) return; + + // Create label + $('.new-label.button').on('click', () => { + updateExclusiveLabelEdit('.new-label'); + $('.new-label.modal').modal({ + onApprove() { + const form = document.querySelector('.new-label.form'); + if (!form.checkValidity()) { + form.reportValidity(); + return false; + } + $('.new-label.form').trigger('submit'); + }, + }).modal('show'); + return false; + }); + + // Edit label + $('.edit-label-button').on('click', function () { + $('#label-modal-id').val($(this).data('id')); + + const $nameInput = $('.edit-label .label-name-input'); + $nameInput.val($(this).data('title')); + + const $isArchivedCheckbox = $('.edit-label .label-is-archived-input'); + $isArchivedCheckbox[0].checked = this.hasAttribute('data-is-archived'); + + const $exclusiveCheckbox = $('.edit-label .label-exclusive-input'); + $exclusiveCheckbox[0].checked = this.hasAttribute('data-exclusive'); + // Warn when label was previously not exclusive and used in issues + $exclusiveCheckbox.data('exclusive-warn', + $(this).data('num-issues') > 0 && + (!this.hasAttribute('data-exclusive') || !isExclusiveScopeName($nameInput.val()))); + updateExclusiveLabelEdit('.edit-label'); + + $('.edit-label .label-desc-input').val(this.getAttribute('data-description')); + + const colorInput = document.querySelector('.edit-label .js-color-picker-input input'); + colorInput.value = this.getAttribute('data-color'); + colorInput.dispatchEvent(new Event('input', {bubbles: true})); + + $('.edit-label.modal').modal({ + onApprove() { + const form = document.querySelector('.edit-label.form'); + if (!form.checkValidity()) { + form.reportValidity(); + return false; + } + $('.edit-label.form').trigger('submit'); + }, + }).modal('show'); + return false; + }); + + $('.new-label .label-name-input').on('input', () => { + updateExclusiveLabelEdit('.new-label'); + }); + $('.new-label .label-exclusive-input').on('change', () => { + updateExclusiveLabelEdit('.new-label'); + }); + $('.edit-label .label-name-input').on('input', () => { + updateExclusiveLabelEdit('.edit-label'); + }); + $('.edit-label .label-exclusive-input').on('change', () => { + updateExclusiveLabelEdit('.edit-label'); + }); +} diff --git a/web_src/js/features/comp/Paste.js b/web_src/js/features/comp/Paste.js new file mode 100644 index 0000000..7e4ecbb --- /dev/null +++ b/web_src/js/features/comp/Paste.js @@ -0,0 +1,144 @@ +import {POST} from '../../modules/fetch.js'; +import {getPastedContent, replaceTextareaSelection} from '../../utils/dom.js'; +import {isUrl} from '../../utils/url.js'; + +async function uploadFile(file, uploadUrl) { + const formData = new FormData(); + formData.append('file', file, file.name); + + const res = await POST(uploadUrl, {data: formData}); + return await res.json(); +} + +function triggerEditorContentChanged(target) { + target.dispatchEvent(new CustomEvent('ce-editor-content-changed', {bubbles: true})); +} + +class TextareaEditor { + constructor(editor) { + this.editor = editor; + } + + insertPlaceholder(value) { + const editor = this.editor; + const startPos = editor.selectionStart; + const endPos = editor.selectionEnd; + editor.value = editor.value.substring(0, startPos) + value + editor.value.substring(endPos); + editor.selectionStart = startPos; + editor.selectionEnd = startPos + value.length; + editor.focus(); + triggerEditorContentChanged(editor); + } + + replacePlaceholder(oldVal, newVal) { + const editor = this.editor; + const startPos = editor.selectionStart; + const endPos = editor.selectionEnd; + if (editor.value.substring(startPos, endPos) === oldVal) { + editor.value = editor.value.substring(0, startPos) + newVal + editor.value.substring(endPos); + editor.selectionEnd = startPos + newVal.length; + } else { + editor.value = editor.value.replace(oldVal, newVal); + editor.selectionEnd -= oldVal.length; + editor.selectionEnd += newVal.length; + } + editor.selectionStart = editor.selectionEnd; + editor.focus(); + triggerEditorContentChanged(editor); + } +} + +class CodeMirrorEditor { + constructor(editor) { + this.editor = editor; + } + + insertPlaceholder(value) { + const editor = this.editor; + const startPoint = editor.getCursor('start'); + const endPoint = editor.getCursor('end'); + editor.replaceSelection(value); + endPoint.ch = startPoint.ch + value.length; + editor.setSelection(startPoint, endPoint); + editor.focus(); + triggerEditorContentChanged(editor.getTextArea()); + } + + replacePlaceholder(oldVal, newVal) { + const editor = this.editor; + const endPoint = editor.getCursor('end'); + if (editor.getSelection() === oldVal) { + editor.replaceSelection(newVal); + } else { + editor.setValue(editor.getValue().replace(oldVal, newVal)); + } + endPoint.ch -= oldVal.length; + endPoint.ch += newVal.length; + editor.setSelection(endPoint, endPoint); + editor.focus(); + triggerEditorContentChanged(editor.getTextArea()); + } +} + +async function handleClipboardImages(editor, dropzone, images, e) { + const uploadUrl = dropzone.getAttribute('data-upload-url'); + const filesContainer = dropzone.querySelector('.files'); + + if (!dropzone || !uploadUrl || !filesContainer || !images.length) return; + + e.preventDefault(); + e.stopPropagation(); + + for (const img of images) { + const name = img.name.slice(0, img.name.lastIndexOf('.')); + + const placeholder = `![${name}](uploading ...)`; + editor.insertPlaceholder(placeholder); + + const {uuid} = await uploadFile(img, uploadUrl); + + const url = `/attachments/${uuid}`; + const text = `![${name}](${url})`; + editor.replacePlaceholder(placeholder, text); + + const input = document.createElement('input'); + input.setAttribute('name', 'files'); + input.setAttribute('type', 'hidden'); + input.setAttribute('id', uuid); + input.value = uuid; + filesContainer.append(input); + } +} + +function handleClipboardText(textarea, text, e) { + // when pasting links over selected text, turn it into [text](link), except when shift key is held + const {value, selectionStart, selectionEnd, _shiftDown} = textarea; + if (_shiftDown) return; + const selectedText = value.substring(selectionStart, selectionEnd); + const trimmedText = text.trim(); + if (selectedText && isUrl(trimmedText) && !isUrl(selectedText)) { + e.stopPropagation(); + e.preventDefault(); + replaceTextareaSelection(textarea, `[${selectedText}](${trimmedText})`); + } +} + +export function initEasyMDEPaste(easyMDE, dropzone) { + easyMDE.codemirror.on('paste', (_, e) => { + const {images} = getPastedContent(e); + if (images.length) { + handleClipboardImages(new CodeMirrorEditor(easyMDE.codemirror), dropzone, images, e); + } + }); +} + +export function initTextareaPaste(textarea, dropzone) { + textarea.addEventListener('paste', (e) => { + const {images, text} = getPastedContent(e); + if (images.length) { + handleClipboardImages(new TextareaEditor(textarea), dropzone, images, e); + } else if (text) { + handleClipboardText(textarea, text, e); + } + }); +} diff --git a/web_src/js/features/comp/QuickSubmit.js b/web_src/js/features/comp/QuickSubmit.js new file mode 100644 index 0000000..e6d7080 --- /dev/null +++ b/web_src/js/features/comp/QuickSubmit.js @@ -0,0 +1,17 @@ +export function handleGlobalEnterQuickSubmit(target) { + const form = target.closest('form'); + if (form) { + if (!form.checkValidity()) { + form.reportValidity(); + return; + } + + // here use the event to trigger the submit event (instead of calling `submit()` method directly) + // otherwise the `areYouSure` handler won't be executed, then there will be an annoying "confirm to leave" dialog + form.dispatchEvent(new SubmitEvent('submit', {bubbles: true, cancelable: true})); + } else { + // if no form, then the editor is for an AJAX request, dispatch an event to the target, let the target's event handler to do the AJAX request. + // the 'ce-' prefix means this is a CustomEvent + target.dispatchEvent(new CustomEvent('ce-quick-submit', {bubbles: true})); + } +} diff --git a/web_src/js/features/comp/ReactionSelector.js b/web_src/js/features/comp/ReactionSelector.js new file mode 100644 index 0000000..fd4601f --- /dev/null +++ b/web_src/js/features/comp/ReactionSelector.js @@ -0,0 +1,38 @@ +import $ from 'jquery'; +import {POST} from '../../modules/fetch.js'; + +export function initCompReactionSelector($parent) { + $parent.find(`.select-reaction .item.reaction, .comment-reaction-button`).on('click', async function (e) { + e.preventDefault(); + + if (this.classList.contains('disabled')) return; + + const actionUrl = this.closest('[data-action-url]')?.getAttribute('data-action-url'); + const reactionContent = this.getAttribute('data-reaction-content'); + const hasReacted = this.closest('.comment')?.querySelector(`.ui.segment.reactions a[data-reaction-content="${reactionContent}"]`)?.getAttribute('data-has-reacted') === 'true'; + + const res = await POST(`${actionUrl}/${hasReacted ? 'unreact' : 'react'}`, { + data: new URLSearchParams({content: reactionContent}), + }); + + const data = await res.json(); + if (data && (data.html || data.empty)) { + const $content = $(this).closest('.content'); + let $react = $content.find('.segment.reactions'); + if ((!data.empty || data.html === '') && $react.length > 0) { + $react.remove(); + } + if (!data.empty) { + const $attachments = $content.find('.segment.bottom:first'); + $react = $(data.html); + if ($attachments.length > 0) { + $react.insertBefore($attachments); + } else { + $react.appendTo($content); + } + $react.find('.dropdown').dropdown(); + initCompReactionSelector($react); + } + } + }); +} diff --git a/web_src/js/features/comp/SearchUserBox.js b/web_src/js/features/comp/SearchUserBox.js new file mode 100644 index 0000000..081c474 --- /dev/null +++ b/web_src/js/features/comp/SearchUserBox.js @@ -0,0 +1,51 @@ +import $ from 'jquery'; +import {htmlEscape} from 'escape-goat'; + +const {appSubUrl} = window.config; +const looksLikeEmailAddressCheck = /^\S+@\S+$/; + +export function initCompSearchUserBox() { + const searchUserBox = document.getElementById('search-user-box'); + if (!searchUserBox) return; + + const $searchUserBox = $(searchUserBox); + const allowEmailInput = searchUserBox.getAttribute('data-allow-email') === 'true'; + const allowEmailDescription = searchUserBox.getAttribute('data-allow-email-description') ?? undefined; + $searchUserBox.search({ + minCharacters: 2, + apiSettings: { + url: `${appSubUrl}/user/search?active=1&q={query}`, + onResponse(response) { + const items = []; + const searchQuery = $searchUserBox.find('input').val(); + const searchQueryUppercase = searchQuery.toUpperCase(); + $.each(response.data, (_i, item) => { + const resultItem = { + title: item.login, + image: item.avatar_url, + }; + if (item.full_name) { + resultItem.description = htmlEscape(item.full_name); + } + if (searchQueryUppercase === item.login.toUpperCase()) { + items.unshift(resultItem); + } else { + items.push(resultItem); + } + }); + + if (allowEmailInput && !items.length && looksLikeEmailAddressCheck.test(searchQuery)) { + const resultItem = { + title: searchQuery, + description: allowEmailDescription, + }; + items.push(resultItem); + } + + return {results: items}; + }, + }, + searchFields: ['login', 'full_name'], + showNoResults: false, + }); +} diff --git a/web_src/js/features/comp/TextExpander.js b/web_src/js/features/comp/TextExpander.js new file mode 100644 index 0000000..128a2dd --- /dev/null +++ b/web_src/js/features/comp/TextExpander.js @@ -0,0 +1,61 @@ +import {matchEmoji, matchMention} from '../../utils/match.js'; +import {emojiString} from '../emoji.js'; + +export function initTextExpander(expander) { + expander?.addEventListener('text-expander-change', ({detail: {key, provide, text}}) => { + if (key === ':') { + const matches = matchEmoji(text); + if (!matches.length) return provide({matched: false}); + + const ul = document.createElement('ul'); + ul.classList.add('suggestions'); + for (const name of matches) { + const emoji = emojiString(name); + const li = document.createElement('li'); + li.setAttribute('role', 'option'); + li.setAttribute('data-value', emoji); + li.textContent = `${emoji} ${name}`; + ul.append(li); + } + + provide({matched: true, fragment: ul}); + } else if (key === '@') { + const matches = matchMention(text); + if (!matches.length) return provide({matched: false}); + + const ul = document.createElement('ul'); + ul.classList.add('suggestions'); + for (const {value, name, fullname, avatar} of matches) { + const li = document.createElement('li'); + li.setAttribute('role', 'option'); + li.setAttribute('data-value', `${key}${value}`); + + const img = document.createElement('img'); + img.src = avatar; + li.append(img); + + const nameSpan = document.createElement('span'); + nameSpan.textContent = name; + li.append(nameSpan); + + if (fullname && fullname.toLowerCase() !== name) { + const fullnameSpan = document.createElement('span'); + fullnameSpan.classList.add('fullname'); + fullnameSpan.textContent = fullname; + li.append(fullnameSpan); + } + + ul.append(li); + } + + provide({matched: true, fragment: ul}); + } + }); + expander?.addEventListener('text-expander-value', ({detail}) => { + if (detail?.item) { + // add a space after @mentions as it's likely the user wants one + const suffix = detail.key === '@' ? ' ' : ''; + detail.value = `${detail.item.getAttribute('data-value')}${suffix}`; + } + }); +} diff --git a/web_src/js/features/comp/WebHookEditor.js b/web_src/js/features/comp/WebHookEditor.js new file mode 100644 index 0000000..522daa1 --- /dev/null +++ b/web_src/js/features/comp/WebHookEditor.js @@ -0,0 +1,28 @@ +import {POST} from '../../modules/fetch.js'; +import {toggleElem} from '../../utils/dom.js'; + +export function initCompWebHookEditor() { + if (!document.querySelectorAll('.new.webhook').length) { + return; + } + + // some webhooks (like Gitea) allow to set the request method (GET/POST), and it would toggle the "Content Type" field + const httpMethodInput = document.getElementById('http_method'); + if (httpMethodInput) { + const updateContentType = function () { + const visible = httpMethodInput.value === 'POST'; + toggleElem(document.getElementById('content_type').closest('.field'), visible); + }; + updateContentType(); + httpMethodInput.addEventListener('change', updateContentType); + } + + // Test delivery + document.getElementById('test-delivery')?.addEventListener('click', async function () { + this.classList.add('is-loading', 'disabled'); + await POST(this.getAttribute('data-link')); + setTimeout(() => { + window.location.href = this.getAttribute('data-redirect'); + }, 5000); + }); +} |