Skip to content

Instantly share code, notes, and snippets.

@roylez
Last active May 29, 2026 03:59
Show Gist options
  • Select an option

  • Save roylez/ff55a382c21f403bcb1a001640e7c19f to your computer and use it in GitHub Desktop.

Select an option

Save roylez/ff55a382c21f403bcb1a001640e7c19f to your computer and use it in GitHub Desktop.
// ==UserScript==
// @name Salesforce Markdown Converter
// @namespace https://gist.github.com/roylez/ff55a382c21f403bcb1a001640e7c19f
// @version 1.7.0
// @description Convert Markdown to HTML in Salesforce Rich Text fields
// @author roylez
// @match https://*.salesforce.com/*
// @match https://*.force.com/*
// @grant none
// @require https://cdn.jsdelivr.net/npm/marked@12.0.0/marked.min.js
// @updateURL https://gist.github.com/roylez/ff55a382c21f403bcb1a001640e7c19f/raw/salesforce-md-converter.user.js
// @downloadURL https://gist.github.com/roylez/ff55a382c21f403bcb1a001640e7c19f/raw/salesforce-md-converter.user.js
// @homepageURL https://gist.github.com/roylez/ff55a382c21f403bcb1a001640e7c19f
// @supportURL https://gist.github.com/roylez/ff55a382c21f403bcb1a001640e7c19f
// ==/UserScript==
(function() {
'use strict';
const BUTTON_ID = 'sf-md-convert-btn';
const CLEAR_BTN_ID = 'sf-md-clear-btn';
const VISIBILITY_BTN_ID = 'sf-md-visibility-btn';
const DATA_ATTR = 'mdConverterAdded';
let isConverting = false;
function createStyles() {
const style = document.createElement('style');
style.textContent = `
/* Markdown Converter Buttons */
.${BUTTON_ID}, .${CLEAR_BTN_ID}, .${VISIBILITY_BTN_ID} {
height: 32px;
padding: 0 12px;
font-size: 12px;
font-weight: 400;
border-radius: 16px;
border: 1px solid transparent;
cursor: pointer;
white-space: nowrap;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
transition: all 0.15s ease;
line-height: 1;
vertical-align: middle;
margin-left: 4px;
}
.${BUTTON_ID} svg, .${CLEAR_BTN_ID} svg, .${VISIBILITY_BTN_ID} svg {
width: 14px;
height: 14px;
flex-shrink: 0;
}
.${BUTTON_ID} span, .${CLEAR_BTN_ID} span, .${VISIBILITY_BTN_ID} span {
font-size: 12px;
font-weight: 600;
}
.${BUTTON_ID} {
background: #0070d2;
color: white;
border-color: #0070d2;
}
.${BUTTON_ID}:hover {
background: #005fb2;
border-color: #005fb2;
}
.${CLEAR_BTN_ID} {
background: #ea001e;
color: white;
border-color: #ea001e;
margin-left: auto;
}
.${CLEAR_BTN_ID}:hover {
background: #c40019;
border-color: #c40019;
}
.${VISIBILITY_BTN_ID} {
background: #32A951;
color: white;
border-color: #32A951;
}
.${VISIBILITY_BTN_ID}:hover {
background: #2B8E44;
border-color: #2B8E44;
}
/* Better paragraph spacing - more compact, higher specificity */
.slds-rich-text-editor__textarea .ql-editor p,
.ql-editor p {
margin-top: 0.3em !important;
margin-bottom: 0.3em !important;
line-height: 1.4 !important;
}
/* Reduce spacing after headings */
.slds-rich-text-editor__textarea .ql-editor h1,
.slds-rich-text-editor__textarea .ql-editor h2,
.slds-rich-text-editor__textarea .ql-editor h3,
.slds-rich-text-editor__textarea .ql-editor h4,
.slds-rich-text-editor__textarea .ql-editor h5,
.slds-rich-text-editor__textarea .ql-editor h6,
.ql-editor h1, .ql-editor h2, .ql-editor h3, .ql-editor h4, .ql-editor h5, .ql-editor h6 {
margin-top: 0.5em !important;
margin-bottom: 0.3em !important;
line-height: 1.3 !important;
}
/* Compact lists */
.slds-rich-text-editor__textarea .ql-editor ul,
.slds-rich-text-editor__textarea .ql-editor ol,
.ql-editor ul, .ql-editor ol {
margin-top: 0.3em !important;
margin-bottom: 0.3em !important;
padding-left: 1.5em !important;
}
.slds-rich-text-editor__textarea .ql-editor li,
.ql-editor li {
margin-top: 0.1em !important;
margin-bottom: 0.1em !important;
}
/* Code blocks - add breathing room but not too much */
.slds-rich-text-editor__textarea .ql-editor pre,
.ql-editor pre {
margin-top: 0.5em !important;
margin-bottom: 0.5em !important;
}
/* Page Header Improvements */
.slds-page-header_record-home {
padding: 1rem 1.5rem !important;
background: linear-gradient(to bottom, #f8f9fa, #ffffff) !important;
border-bottom: 1px solid #e5e5e5 !important;
}
.slds-page-header h1 {
font-size: 1.5rem !important;
font-weight: 700 !important;
line-height: 1.4 !important;
color: #181818 !important;
flex: 1 !important;
min-width: 0 !important;
}
.entityNameTitle {
font-size: 0.75rem !important;
color: #706e6b !important;
text-transform: uppercase !important;
letter-spacing: 0.5px !important;
margin-bottom: 0.25rem !important;
}
.slds-page-header__title {
font-size: 1.125rem !important;
line-height: 1.3 !important;
max-width: none !important;
}
.clip-text-slds-plus {
display: -webkit-box !important;
-webkit-line-clamp: 2 !important;
-webkit-box-orient: vertical !important;
overflow: hidden !important;
}
/* Compact Action Buttons - Only in page header, not toolbar */
.slds-page-header .forceActionsContainer .slds-button,
.slds-page-header .actionsContainer .slds-button {
padding: 0 0.5rem !important;
font-size: 0.75rem !important;
height: 1.75rem !important;
min-width: unset !important;
white-space: nowrap !important;
}
.slds-page-header .forceActionsContainer .slds-button_neutral,
.slds-page-header .actionsContainer .slds-button_neutral {
background: #f3f3f3 !important;
border-color: #d8d8d8 !important;
}
.slds-page-header .forceActionsContainer .slds-button_neutral:hover,
.slds-page-header .actionsContainer .slds-button_neutral:hover {
background: #e8e8e8 !important;
}
/* Hide icons to save space - only in page header */
.slds-page-header .forceActionsContainer .slds-button .slds-button__icon,
.slds-page-header .actionsContainer .slds-button .slds-button__icon {
display: none !important;
}
.forceActionsContainer .slds-button_icon-border-filled,
.actionsContainer .slds-button_icon-border-filled {
padding: 0.5rem !important;
width: 2rem !important;
height: 2rem !important;
}
/* Hide Follow button */
[data-target-selection-name="sfdc:StandardButton.Case.Follow"],
[data-target-selection-name="sfdc:StandardButton.*.Follow"] {
display: none !important;
}
/* Hide Sharing Hierarchy dropdown */
[data-target-reveals="sfdc:StandardButton.Case.RecordShareHierarchy"],
.slds-dropdown-trigger.slds-button_last {
display: none !important;
}
`;
document.head.appendChild(style);
}
function getEditorContent(editor) {
if (editor.value !== undefined) {
return editor.value;
}
return editor.innerText || editor.textContent || '';
}
function setEditorContent(editor, content) {
if (editor.value !== undefined) {
editor.value = content;
return;
}
const container = editor.closest('.ql-container');
if (container) {
const quill = container.__quill;
if (quill) {
// Clear first, then paste to replace content
quill.setText('');
quill.clipboard.dangerouslyPasteHTML(0, content, 'api');
return;
}
}
editor.innerHTML = content;
}
function convertMarkdown(editor) {
if (isConverting) return;
const markdown = getEditorContent(editor).trim();
if (!markdown) return;
isConverting = true;
try {
const html = marked.parse(markdown);
// Remove <p> tags from within <li> - Quill doesn't like block elements in lists
let cleanHtml = html.replace(/<li>\s*<p>/gi, '<li>').replace(/<\/p>\s*<\/li>/gi, '</li>');
// Preserve blank lines - insert <br> between paragraphs with whitespace (blank lines)
cleanHtml = cleanHtml.replace(/<\/p>\s*<p>/gi, '</p><br><p>');
function formatCodeBlock(lang, code) {
// marked adds \n\n after lines, but intentional blank lines become 3+ newlines
// Use temp marker to preserve intentional blank lines
let normalizedCode = code.replace(/\n\n\n+/g, '\u0000\u0000'); // 3+ newlines -> temp marker
normalizedCode = normalizedCode.replace(/\n\n/g, '\n'); // 2 newlines -> 1 (remove artificial)
normalizedCode = normalizedCode.replace(/\u0000\u0000/g, '\n\n'); // restore blank lines
// Remove leading/trailing blank lines
const trimmedCode = normalizedCode.replace(/^\n+/, '').replace(/\n+$/, '');
const lines = trimmedCode.split('\n');
// Build HTML - ALL lines get spans, blank lines included
const linesHtml = lines.map((line, lineIndex) => {
if (line === '') {
// Use <br> for blank lines - no invisible characters
return `<br class="L${lineIndex}"/>`;
}
return `<span class="L${lineIndex}"><span class="pln">${escapeHtml(line)}</span></span>`;
}).join('');
// data-code - use \n as separator (blank lines are empty strings in array)
const codeForData = lines.join('\n');
const langAttr = lang ? `class="language-${lang}"` : '';
return `<pre class="ql-codesnippet quill_widget_wrapper quill_widget_block hasEditEventListener" contenteditable="false" tabindex="-1" data-code="${escapeHtml(codeForData)}"><pre spellcheck="false" data-widget="codeSnippet" class="quill_widget_element"><code ${langAttr}><span class="linenums">${linesHtml}</span></code></pre></pre>`;
}
function formatInlineCode(code) {
// Salesforce inline code styling
return `<code style="background-color: #f6f6f6; padding: 2px 6px; border-radius: 3px; font-family: Consolas, Monaco, monospace; font-size: 0.9em; color: #d73a49; white-space: nowrap;">${escapeHtml(code)}</code>`;
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// Handle code blocks - convert to Salesforce-specific format
cleanHtml = cleanHtml.replace(/<pre><code class="language-([^"]*)">([\s\S]*?)<\/code><\/pre>/gi, (match, lang, code) => formatCodeBlock(lang, code));
cleanHtml = cleanHtml.replace(/<pre><code>([\s\S]*?)<\/code><\/pre>/gi, (match, code) => formatCodeBlock('', code));
setEditorContent(editor, cleanHtml);
} finally {
setTimeout(() => { isConverting = false; }, 100);
}
}
function clearEditor(editor) {
// For Quill editors, properly reset the internal state
const container = editor.closest('.ql-container');
if (container) {
const quill = container.__quill;
if (quill) {
try {
quill.setText('', 'user');
return;
} catch {
// Fall through
}
}
}
setEditorContent(editor, '');
}
function createClearButton(editor) {
const btn = document.createElement('button');
btn.className = CLEAR_BTN_ID;
btn.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/></svg> <span>Clear</span>`;
btn.type = 'button';
btn._editor = editor;
btn.setAttribute('aria-label', 'Clear editor content');
btn.title = 'Clear';
return btn;
}
function createVisibilityButton(editor) {
const btn = document.createElement('button');
btn.className = VISIBILITY_BTN_ID;
btn.id = VISIBILITY_BTN_ID + '-' + Date.now(); // Unique ID for finding
btn.type = 'button';
btn._editor = editor;
btn.setAttribute('aria-label', 'Toggle visibility');
updateVisibilityButtonText(btn);
return btn;
}
function updateVisibilityButtonText(btn) {
const trigger = document.querySelector('.cuf-visibilityMenu');
if (!trigger) return;
const currentText = trigger.textContent.trim();
const isPrivate = currentText.includes('Only') || currentText.includes('Private');
const visibilityText = isPrivate ? 'Private' : 'Public';
// Better icons using lock/unlock metaphor
const lockIcon = isPrivate
? `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0110 0v4"/></svg>`
: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0110 0v4"/><path d="M10 16V4"/><path d="M20 16V4"/><path d="M15 8l-5 5-5-5"/></svg>`;
btn.innerHTML = `${lockIcon} <span>${visibilityText}</span>`;
btn.title = `Toggle to ${isPrivate ? 'Public' : 'Private'}`;
// Update button color based on state
if (isPrivate) {
btn.style.background = '#008080'; // Cyan
btn.style.borderColor = '#008080';
btn.style.color = ''; // Default white text
} else {
btn.style.background = '#FF9800'; // Warning color (orange)
btn.style.borderColor = '#FF9800';
btn.style.color = '#181818'; // Dark text for orange background
}
}
function createConvertButton(editor) {
const btn = document.createElement('button');
btn.className = BUTTON_ID;
btn.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg> <span>Convert</span>`;
btn.type = 'button';
btn._editor = editor;
btn.setAttribute('aria-label', 'Convert Markdown to HTML');
btn.title = 'Convert MD → HTML';
return btn;
}
function handleButtonClick(e) {
const button = e.target.closest(`.${BUTTON_ID}, .${CLEAR_BTN_ID}, .${VISIBILITY_BTN_ID}`);
if (!button) return;
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
if (button.classList.contains(BUTTON_ID)) {
const editor = button._editor;
if (editor) convertMarkdown(editor);
} else if (button.classList.contains(CLEAR_BTN_ID)) {
const editor = button._editor;
if (editor) clearEditor(editor);
} else if (button.classList.contains(VISIBILITY_BTN_ID)) {
toggleVisibility();
}
}
function updateAllVisibilityButtons() {
const buttons = document.querySelectorAll(`.${VISIBILITY_BTN_ID}`);
buttons.forEach(btn => updateVisibilityButtonText(btn));
}
function toggleVisibility() {
// Try to find the visibility dropdown and trigger click
const trigger = document.querySelector('.cuf-visibilityMenu');
if (!trigger) return;
const currentText = trigger.textContent.trim();
const isPrivate = currentText.includes('Only') || currentText.includes('Private');
// Get the popup trigger wrapper
const triggerWrapper = trigger.closest('.uiPopupTrigger');
if (!triggerWrapper) return;
// Click to open menu
trigger.click();
setTimeout(() => {
// Find the popup target that appears
const dropdowns = document.querySelectorAll('.uiPopupTarget');
for (const dropdown of dropdowns) {
const html = dropdown.innerHTML;
// Skip the global actions menu
if (html.includes('Global Actions')) continue;
// This should be the visibility dropdown
const listItems = dropdown.querySelectorAll('ul > li > a, ul > li > span');
for (const item of listItems) {
const itemText = item.textContent.trim();
if (!itemText || itemText === currentText || itemText.length < 2) continue;
if (isPrivate && (itemText.includes('All') || itemText.includes('People'))) {
item.click();
setTimeout(() => updateAllVisibilityButtons(), 300);
return;
}
if (!isPrivate && (itemText.includes('Only') || itemText.includes('Private'))) {
item.click();
setTimeout(() => updateAllVisibilityButtons(), 300);
return;
}
}
}
}, 500);
}
function addButtonToEditor(editor) {
// Re-check: if marked but buttons are gone (SPA recycled DOM), reset
if (editor.dataset[DATA_ATTR]) {
const toolbar = editor.closest('.slds-rich-text-editor, .input-section, .publisherInputContainer')
?.querySelector('.slds-rich-text-editor__toolbar, [role="toolbar"]');
if (toolbar && !toolbar.querySelector(`.${BUTTON_ID}`)) {
delete editor.dataset[DATA_ATTR];
} else {
return;
}
}
// Find the toolbar first (most reliable)
const toolbar = editor.closest('.slds-rich-text-editor, .input-section, .publisherInputContainer')
?.querySelector('.slds-rich-text-editor__toolbar, [role="toolbar"]');
if (toolbar) {
toolbar.appendChild(createConvertButton(editor));
toolbar.appendChild(createVisibilityButton(editor));
toolbar.appendChild(createClearButton(editor));
editor.dataset[DATA_ATTR] = 'true';
// Update button text after a delay when page is fully loaded
setTimeout(() => updateAllVisibilityButtons(), 500);
return;
}
// Fallback: find any container
const container = editor.closest('.slds-rich-text-editor__textarea, .ql-container, [class*="editor"]');
if (container) {
container.appendChild(createConvertButton(editor));
container.appendChild(createVisibilityButton(editor));
container.appendChild(createClearButton(editor));
}
editor.dataset[DATA_ATTR] = 'true';
}
function isActualEditor(node) {
if (node.classList.contains('ql-clipboard') ||
node.classList.contains('ql-tooltip') ||
node.getAttribute('aria-hidden') === 'true') {
return false;
}
return true;
}
function processAddedNode(node) {
if (node.nodeType !== Node.ELEMENT_NODE) return;
if (node.tagName === 'TEXTAREA') {
addButtonToEditor(node);
}
if (node.contentEditable === 'true' || node.getAttribute('contenteditable') === 'true') {
if (isActualEditor(node)) {
addButtonToEditor(node);
}
return;
}
node.querySelectorAll('textarea').forEach(addButtonToEditor);
node.querySelectorAll('[contenteditable="true"]').forEach(el => {
if (isActualEditor(el)) {
addButtonToEditor(el);
}
});
}
function observeEditors() {
const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
if (mutation.type === 'childList') {
for (const node of mutation.addedNodes) {
processAddedNode(node);
}
} else if (mutation.type === 'attributes') {
const node = mutation.target;
if (node.nodeType === Node.ELEMENT_NODE &&
node.getAttribute('contenteditable') === 'true' &&
isActualEditor(node)) {
addButtonToEditor(node);
}
}
}
});
observer.observe(document.body, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ['contenteditable']
});
scanEditors();
}
function renameActionButtons() {
// Removed - not working reliably
}
function isCasePage() {
return /\/lightning\/r\/Case\//.test(location.pathname);
}
function scanEditors() {
document.querySelectorAll('textarea').forEach(addButtonToEditor);
document.querySelectorAll('[contenteditable="true"]').forEach(el => {
if (isActualEditor(el)) {
addButtonToEditor(el);
}
});
}
function observeNavigation() {
let lastUrl = location.href;
function onNavChange() {
if (location.href !== lastUrl) {
lastUrl = location.href;
if (isCasePage()) {
setTimeout(scanEditors, 500);
setTimeout(scanEditors, 1500);
setTimeout(scanEditors, 3000);
}
}
}
// Intercept pushState/replaceState (used by most SPAs)
const origPushState = history.pushState;
const origReplaceState = history.replaceState;
history.pushState = function() {
origPushState.apply(this, arguments);
onNavChange();
};
history.replaceState = function() {
origReplaceState.apply(this, arguments);
onNavChange();
};
// Also listen for popstate (back/forward)
window.addEventListener('popstate', onNavChange);
// Fallback polling for any edge cases
setInterval(onNavChange, 2000);
}
function init() {
createStyles();
document.addEventListener('click', handleButtonClick, true);
document.addEventListener('mousedown', handleButtonClick, true);
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => {
observeEditors();
observeNavigation();
});
} else {
observeEditors();
observeNavigation();
}
}
init();
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment