Skip to content

Instantly share code, notes, and snippets.

@DaniilBabanin
Last active May 24, 2024 06:25
Show Gist options
  • Save DaniilBabanin/9a9637b8679e017a194d954488cffb2e to your computer and use it in GitHub Desktop.
Save DaniilBabanin/9a9637b8679e017a194d954488cffb2e to your computer and use it in GitHub Desktop.
Userscript that adds monaco editor ro gitlab textfields
// ==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