Last active
May 24, 2024 06:25
-
-
Save DaniilBabanin/9a9637b8679e017a194d954488cffb2e to your computer and use it in GitHub Desktop.
Userscript that adds monaco editor ro gitlab textfields
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// ==UserScript== | |
// @name Replace Gitlab textareas with Monaco Editor | |
// @namespace Violentmonkey Scripts | |
// @match https://gitlab.org/* | |
// @grant none | |
// @version 1.0 | |
// @author Daniil Babanin & Dennis Münsterer & CHATGPT4o | |
// @description Replace comment and issue textfields with Monaco Editor and inject custom snippets | |
// @license MIT | |
// ==/UserScript== | |
;(function () { | |
"use strict" | |
var uniqueContextWords = [] | |
const snippetSuggestions = [ | |
// markdown | |
{ | |
label: "header1", | |
documentation: "Header 1", | |
insertText: "# ${1:Header 1}", | |
}, | |
{ | |
label: "header2", | |
documentation: "Header 2", | |
insertText: "## ${1:Header 2}", | |
}, | |
{ | |
label: "header3", | |
documentation: "Header 3", | |
insertText: "### ${1:Header 3}", | |
}, | |
{ | |
label: "bold", | |
documentation: "Bold Text", | |
insertText: "**${1:bold text}**", | |
}, | |
{ | |
label: "italic", | |
documentation: "Italic Text", | |
insertText: "*${1:italic text}*", | |
}, | |
{ | |
label: "strikethrough", | |
documentation: "Strikethrough Text", | |
insertText: "~~${1:strikethrough text}~~", | |
}, | |
{ | |
label: "bullet_list", | |
documentation: "Bullet List", | |
insertText: "- ${1:Item 1}\n- ${2:Item 2}\n- ${3:Item 3}", | |
}, | |
{ | |
label: "numbered_list", | |
documentation: "Numbered List", | |
insertText: "1. ${1:Item 1}\n2. ${2:Item 2}\n3. ${3:Item 3}", | |
}, | |
{ | |
label: "checkbox", | |
documentation: "ToDo", | |
insertText: "- [ ] ${1:task}", | |
}, | |
{ | |
label: "checkbox_done", | |
documentation: "Done/Finished ToDo", | |
insertText: "- [x] ${1:task}", | |
}, | |
{ | |
label: "link", | |
documentation: "Link", | |
insertText: "[${1:link text}](${2:url})", | |
}, | |
{ | |
label: "image", | |
documentation: "Image", | |
insertText: "![${1:alt text}](${2:image source})", | |
}, | |
{ | |
label: "blockquote", | |
documentation: "Blockquote", | |
insertText: "> ${1:Blockquote}", | |
}, | |
{ | |
label: "code", | |
documentation: "Inline Code", | |
insertText: "`${1:code}`", | |
}, | |
{ | |
label: "code_block", | |
documentation: "Code Block", | |
insertText: "```${2}\n${1:code}\n```", | |
}, | |
{ | |
label: "table", | |
documentation: "Table", | |
insertText: | |
"| ${1:Header 1} | ${2:Header 2} | ${3:Header 3} |\n| --- | --- | --- |\n| ${4:Row 1 Col 1} | ${5:Row 1 Col 2} | ${6:Row 1 Col 3} |\n| ${7:Row 2 Col 1} | ${8:Row 2 Col 2} | ${9:Row 2 Col 3} |", | |
}, | |
{ | |
label: "horizontal_rule", | |
documentation: "Horizontal Rule", | |
insertText: "---", | |
}, | |
// Gitlab commands | |
{ | |
label: "/approve", | |
documentation: "Approve the merge request", | |
insertText: "/approve", | |
}, | |
{ | |
label: "/assign", | |
documentation: "Assign a user", | |
insertText: "/assign @${1:username}", | |
}, | |
{ | |
label: "/unassign", | |
documentation: "Unassign a user", | |
insertText: "/unassign @${1:username}", | |
}, | |
{ | |
label: "/close", | |
documentation: "Closes an issue or merge request", | |
insertText: "/close", | |
}, | |
{ | |
label: "/clone", | |
documentation: "Clone an issue or merge request", | |
insertText: "/clone p/${1:path/to/project}", | |
}, | |
{ | |
label: "/reopen", | |
documentation: "Reopens a closed issue or merge request", | |
insertText: "/reopen", | |
}, | |
{ | |
label: "/spend", | |
documentation: "Adds spent time to an issue or merge request", | |
insertText: "/spend ${1:time}", | |
}, | |
{ | |
label: "/spend_yesterday", | |
documentation: "Adds spent time for yesterday's date", | |
insertText: `/spend \${1:time} ${getDayISODate(-1)}`, | |
}, | |
{ | |
label: "/estimate", | |
documentation: "Adds estimated time to an issue or merge request", | |
insertText: "/estimate ${1:time}", | |
}, | |
{ | |
label: "/remove_estimate", | |
documentation: "Removes the time estimate from an issue or merge request", | |
insertText: "/remove_estimate", | |
}, | |
{ | |
label: "/remove_time_spent", | |
documentation: "Removes the time spent from an issue or merge request", | |
insertText: "/remove_time_spent", | |
}, | |
{ | |
label: "/due", | |
documentation: "Sets the due date for an issue", | |
insertText: "/due ${1:YYYY-MM-DD}", | |
}, | |
{ | |
label: "/wip", | |
documentation: "Marks a merge request as work in progress", | |
insertText: "/wip", | |
}, | |
{ | |
label: "/ready", | |
documentation: "Marks a work in progress merge request as ready", | |
insertText: "/ready", | |
}, | |
{ | |
label: "/merge", | |
documentation: "Merges a merge request", | |
insertText: "/merge", | |
}, | |
{ | |
label: "/approve", | |
documentation: "Approves a merge request", | |
insertText: "/approve", | |
}, | |
{ | |
label: "/remove_reviewer", | |
documentation: "Removes a reviewer from a merge request", | |
insertText: "/remove_reviewer @${1:username}", | |
}, | |
{ | |
label: "/target_branch", | |
documentation: "Changes the target branch of a merge request", | |
insertText: "/target_branch ${1:branch_name}", | |
}, | |
{ | |
label: "/label", | |
documentation: "Adds a label to an issue or merge request", | |
insertText: "/label ~${1:label_name}", | |
}, | |
{ | |
label: "/unlabel", | |
documentation: "Removes a label from an issue or merge request", | |
insertText: "/unlabel ~${1:label_name}", | |
}, | |
{ | |
label: "/relabel", | |
documentation: | |
"Replaces all labels of an issue or merge request with the specified labels", | |
insertText: "/relabel ~${1:label_name}", | |
}, | |
{ | |
label: "/subscribe", | |
documentation: | |
"Subscribes to notifications for an issue or merge request", | |
insertText: "/subscribe", | |
}, | |
{ | |
label: "/unsubscribe", | |
documentation: | |
"Unsubscribes from notifications for an issue or merge request", | |
insertText: "/unsubscribe", | |
}, | |
{ | |
label: "/milestone", | |
documentation: "Assigns a milestone to an issue or merge request", | |
insertText: "/milestone %${1:milestone_name}", | |
}, | |
{ | |
label: "/remove_milestone", | |
documentation: "Removes a milestone from an issue or merge request", | |
insertText: "/remove_milestone", | |
}, | |
// Issue templates | |
{ | |
label: "todo-issue", | |
documentation: "New developer issue", | |
insertText: | |
'### Problem\n\n${1:What problem does the proposal solve?}\n\n### Target group\n\n${2:Who will use this function? Does the target group already exist as a role?}\n\n### Details\n\n${3:Examples of use, benefits and/or goals/vision}\n\n### Proposal\n\n${4:How do we solve the problem? Try to travel in time. How do you envision this will be used in the future?}\n\n### Risk & testing\n\n${5:What risk does this change have? What components need to be tested or what tests need to be changed?}\n\n### Measuring success / Definition of Done\n\n- [ ] ${6:What does success look like? How is it measured?}\n\n### Links / References\n\n\n/label ~"To Do"\n${7}', | |
}, | |
{ | |
label: "progress-update", | |
documentation: "Leave a comment with your current development status", | |
insertText: `#### Progress ${getDayISODate()}\n\n- \${1}\n\n\${2:---}\n\n/spend \${3:10}m`, | |
}, | |
] | |
function initializeMonacoEditor(textarea) { | |
// Create a container for Monaco Editor | |
const container = document.createElement("div") | |
container.id = "monaco-container-" + Math.random().toString(36).substr(2, 9) // Unique ID | |
container.style.width = "100%" | |
container.style.height = "300px" | |
container.style.border = "1px solid #ddd" | |
// Add the resize handle | |
const resizeHandle = document.createElement("div") | |
resizeHandle.style.width = "100%" | |
resizeHandle.style.height = "15px" | |
resizeHandle.style.background = "#ddd" | |
resizeHandle.style.cursor = "ns-resize" | |
resizeHandle.style.position = "absolute" | |
resizeHandle.style.bottom = "0" | |
resizeHandle.style.left = "0" | |
container.appendChild(resizeHandle) | |
// Insert the container after the textarea and hide the textarea | |
textarea.parentNode.insertBefore(container, textarea.nextSibling) | |
textarea.style.display = "none" | |
// Load Monaco Editor | |
const script = document.createElement("script") | |
script.src = "https://unpkg.com/monaco-editor@latest/min/vs/loader.js" //@TODO load from own server or locally | |
script.onload = function () { | |
require.config({ | |
paths: { vs: "https://unpkg.com/monaco-editor@latest/min/vs" }, | |
}) | |
window.MonacoEnvironment = { getWorkerUrl: () => proxy } | |
const proxy = URL.createObjectURL( | |
new Blob( | |
[ | |
` | |
self.MonacoEnvironment = { | |
baseUrl: 'https://unpkg.com/monaco-editor@latest/min/' | |
}; | |
importScripts('https://unpkg.com/monaco-editor@latest/min/vs/base/worker/workerMain.js'); | |
`, | |
], | |
{ type: "text/javascript" }, | |
), | |
) | |
require(["vs/editor/editor.main"], function () { | |
const editor = monaco.editor.create( | |
document.getElementById(container.id), | |
{ | |
value: textarea.value, | |
language: "markdown", | |
theme: "vs-dark", | |
}, | |
) | |
// Create a toggle button | |
const toggleButton = document.createElement("button") | |
toggleButton.type = "button" | |
toggleButton.textContent = "Toggle Editor" | |
toggleButton.title = "Cmd/Ctrl+Shift+E" | |
toggleButton.className = | |
"btn gl-font-sm! gl-text-secondary! gl-px-4! btn-default btn-sm gl-button btn-default-tertiary" | |
toggleButton.addEventListener("click", function () { | |
toggleEditorVisibility(container, textarea, editor) | |
}) | |
// remove Markdown help button | |
const element = document.querySelector( | |
'[aria-label="Markdown is supported"]', | |
) | |
if (element) { | |
element.remove() | |
} | |
// Insert the toggle button into the toolbar | |
const toolbar = document.querySelector(".comment-toolbar") | |
if (toolbar) { | |
toolbar.appendChild(toggleButton) | |
} | |
// Sync Monaco Editor content to the textarea | |
editor.onDidChangeModelContent(function () { | |
textarea.value = editor.getValue() | |
textarea.dispatchEvent(new Event("input", { bubbles: true })) | |
}) | |
// Keyboard shortcut for Command+Enter or Ctrl+Enter to submit the form | |
editor.addCommand( | |
monaco.KeyMod.CtrlCmd | monaco.KeyCode.Enter, | |
function () { | |
const commentButton = document.querySelector( | |
'[data-testid="reply-comment-button"]', | |
) | |
if (commentButton) { | |
commentButton.click() | |
editor.setValue("") | |
} else { | |
const submitButton = document.querySelector( | |
".js-comment-submit-button .btn-confirm", | |
) | |
if (submitButton) { | |
submitButton.click() | |
editor.setValue("") | |
} | |
} | |
}, | |
) | |
editor.addCommand( | |
monaco.KeyMod.CtrlCmd | monaco.KeyMod.Shift | monaco.KeyCode.KeyP, | |
function () { | |
// togglePreview(container, textarea, editor) | |
const previewButton = document.querySelector( | |
'button[data-testid="preview-toggle"]', | |
) | |
if (previewButton) { | |
previewButton.focus() | |
} | |
}, | |
) | |
// Keyboard shortcut for Command+Shift+E or Ctrl+Shift+E to toggle the editor visibility | |
window.addEventListener("keydown", function (event) { | |
if ( | |
(event.metaKey || event.ctrlKey) && | |
event.shiftKey && | |
event.key === "e" | |
) { | |
event.preventDefault() | |
toggleEditorVisibility(container, textarea, editor) | |
} | |
if ( | |
(event.metaKey || event.ctrlKey) && | |
event.shiftKey && | |
event.key === "p" | |
) { | |
event.preventDefault() | |
togglePreview(container, textarea, editor) | |
} | |
}) | |
// Resizing logic | |
let isResizing = false | |
let startY, startHeight | |
resizeHandle.addEventListener("mousedown", function (e) { | |
isResizing = true | |
startY = e.clientY | |
startHeight = parseInt( | |
document.defaultView.getComputedStyle(container).height, | |
10, | |
) | |
document.documentElement.addEventListener("mousemove", doDrag, false) | |
document.documentElement.addEventListener("mouseup", stopDrag, false) | |
}) | |
function doDrag(e) { | |
if (!isResizing) return | |
container.style.height = startHeight + e.clientY - startY + "px" | |
editor.layout() | |
} | |
function stopDrag() { | |
isResizing = false | |
document.documentElement.removeEventListener( | |
"mousemove", | |
doDrag, | |
false, | |
) | |
document.documentElement.removeEventListener( | |
"mouseup", | |
stopDrag, | |
false, | |
) | |
} | |
// Create and register snippets | |
let disposeSuggestions | |
editor.onDidChangeModelContent(function () { | |
const fullSnippets = snippetSuggestions.map((suggestion) => ({ | |
...suggestion, | |
kind: suggestion.label.includes("/") | |
? monaco.languages.CompletionItemKind.Keyword | |
: monaco.languages.CompletionItemKind.Snippet, | |
insertTextRules: | |
monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet, | |
sortText: suggestion.label.includes("/") | |
? "a_" + suggestion.label.replace("/", "") | |
: "z_" + suggestion.label, | |
})) | |
const wordProvider = addWordBasedSuggestions( | |
editor, | |
monaco, | |
fullSnippets, | |
) | |
if (disposeSuggestions) { | |
disposeSuggestions() | |
} | |
// Refresh the completion provider to update suggestions | |
const { dispose } = monaco.languages.registerCompletionItemProvider( | |
"markdown", | |
wordProvider, | |
) | |
disposeSuggestions = dispose | |
}) | |
}) | |
} | |
document.body.appendChild(script) | |
} | |
// Wait for the DOM to fully load | |
window.addEventListener( | |
"load", | |
function () { | |
// Check for the textarea elements | |
const textareas = document.querySelectorAll("textarea.note-textarea") | |
textareas.forEach(function (textarea) { | |
initializeMonacoEditor(textarea) | |
}) | |
// Add event listener to the edit buttons | |
const commentEditButtons = document.querySelectorAll( | |
'button[data-testid="note-edit-button"]', | |
) | |
commentEditButtons.forEach(function (button) { | |
button.addEventListener("click", function () { | |
// Wait a bit for the textarea to be rendered | |
setTimeout(function () { | |
const editTextarea = document.querySelector( | |
'textarea[data-testid="reply-field"][id="note_note"]', | |
) | |
if (editTextarea) { | |
initializeMonacoEditor(editTextarea) | |
} | |
}, 500) // Adjust the delay if necessary | |
}) | |
}) | |
const replyButtons = document.querySelectorAll( | |
'button[data-track-label="reply_comment_button"]', | |
) | |
replyButtons.forEach(function (button) { | |
button.addEventListener("click", function () { | |
// Wait a bit for the textarea to be rendered | |
setTimeout(function () { | |
const editTextarea = document.querySelector( | |
'textarea[data-testid="reply-field"][id="note_note"]', | |
) | |
if (editTextarea) { | |
initializeMonacoEditor(editTextarea) | |
} | |
}, 500) // Adjust the delay if necessary | |
}) | |
}) | |
const issueEditButton = document.querySelectorAll( | |
'button[data-testid="edit-button"]', | |
) | |
issueEditButton.forEach(function (button) { | |
button.addEventListener("click", function () { | |
// Wait a bit for the textarea to be rendered | |
setTimeout(function () { | |
const editTextarea = document.querySelector( | |
'textarea[data-testid="markdown-editor-form-field"]', | |
) | |
if (editTextarea) { | |
initializeMonacoEditor(editTextarea) | |
} | |
}, 700) // Adjust the delay if necessary | |
}) | |
}) | |
// Add form submission event listener to clear editor content | |
const form = document.querySelector( | |
"#notes > div.js-comment-form > ul > li > div > div > form", | |
) | |
if (form) { | |
form.addEventListener("submit", function () { | |
const editorContainers = document.querySelectorAll( | |
'div[id^="monaco-container-"]', | |
) | |
editorContainers.forEach(function (container) { | |
const editor = monaco.editor | |
.getModels() | |
.find((model) => model.uri.path === "/" + container.id) | |
if (editor) { | |
editor.setValue("") | |
} | |
}) | |
}) | |
} | |
// Helper function to extract unique words longer than 4 characters | |
function extractUniqueWords(text) { | |
var wordSet = new Set() | |
var words = text.match(/\b\w+\b/g) | |
if (words) { | |
words.forEach(function (word) { | |
if (word.length > 4) { | |
wordSet.add(word) | |
} | |
}) | |
} | |
return Array.from(wordSet) | |
} | |
// Main function to get the text content of the target area | |
function getUniqueWordsFromContent() { | |
var contentElement = document.querySelector(".content-wrapper") | |
if (contentElement) { | |
var contentText = contentElement.textContent || "" | |
var uniqueWords = extractUniqueWords(contentText) | |
return uniqueWords | |
} else { | |
console.warn("Content area not found.") | |
} | |
return [] | |
} | |
uniqueContextWords = getUniqueWordsFromContent() | |
}, | |
false, | |
) | |
function getDayISODate(daysFromToday = 0) { | |
const day = new Date() | |
day.setDate(day.getDate() - daysFromToday) | |
return day.toISOString().split("T")[0] | |
} | |
function clearEditor(editor) { | |
editor.setValue("") | |
} | |
function toggleEditorVisibility(container, textarea, editor) { | |
if (container.style.display === "none") { | |
container.style.display = "block" | |
textarea.style.display = "none" | |
editor.focus() | |
} else { | |
container.style.display = "none" | |
textarea.style.display = "block" | |
textarea.focus() | |
} | |
} | |
function togglePreview(container, textarea, editor) { | |
const previewButton = document.querySelector( | |
'button[data-testid="preview-toggle"]', | |
) | |
if (previewButton) { | |
previewButton.click() | |
if (container.style.display === "none") { | |
editor.focus() | |
} else { | |
textarea.focus() | |
} | |
} | |
} | |
function addWordBasedSuggestions(editor, monaco, snippets) { | |
const wordProvider = { | |
triggerCharacters: ["/"], | |
resolveCompletionItem: function (item) { | |
// Ensure the command suggestions prepend the '/' character | |
if (item.label.startsWith("/")) { | |
item.insertText = item.insertText.slice(1) | |
} | |
return item | |
}, | |
provideCompletionItems: function (model, position) { | |
const text = model.getValue() | |
const currentWord = model.getWordUntilPosition(position) | |
const charBeforeWord = model.getValueInRange({ | |
startLineNumber: position.lineNumber, | |
startColumn: currentWord.startColumn - 1, | |
endLineNumber: position.lineNumber, | |
endColumn: currentWord.startColumn, | |
}) | |
if (charBeforeWord.includes("/")) { | |
return { suggestions: snippets } | |
} | |
const textUntilPosition = model.getValueInRange({ | |
startLineNumber: 1, | |
startColumn: 1, | |
endLineNumber: position.lineNumber, | |
endColumn: 0, | |
}) | |
const textAfterPosition = model.getValueInRange({ | |
startLineNumber: position.lineNumber, | |
startColumn: position.column, | |
endLineNumber: model.getLineCount(), | |
endColumn: model.getLineMaxColumn(model.getLineCount()), | |
}) | |
const wordMatches = (textUntilPosition + " " + textAfterPosition).match( | |
/\b\w+\b/g, | |
) | |
const uniqueWords = Array.from(new Set(wordMatches)).concat( | |
uniqueContextWords, | |
) | |
const suggestions = uniqueWords.map((word) => ({ | |
label: word, | |
kind: monaco.languages.CompletionItemKind.Text, | |
insertText: word, | |
sortText: "z_" + word, | |
})) | |
suggestions.push(...snippets) | |
return { suggestions } | |
}, | |
} | |
return wordProvider | |
} | |
})() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment