This article will take you through how to Implement a Quill-powered rich text editor for rich text fields in a Forms for Salesforce form so end users can format content (headings, lists, links, alignment) while preserving form submission compatibility. The formatted value is stored as HTML in the Salesforce field and renders as rich text/markdown on the Salesforce record page.
Why this is helpful
- Better authoring experience: Modern toolbar (bold/italic/underline/strike, headings, lists, alignment, links).
- Fewer copy/paste issues: Consistent HTML structure instead of unpredictable pasted formatting.
- Configurable per field: Target only the fields you choose; optional per-field editor height.
- Non-breaking to submissions: Syncs the Quill content back to the original <textarea> so standard submit/validation flows continue to work.
- Programmatic toolbar control: Utility bar (toolbar) options can be changed via code to match your use case.
What the code does (at a glance)
- Loads Quill JS/CSS from a CDN (configurable).
- Injects minimal CSS so the toolbar and editor fit nicely in Formstack layouts.
- Locates target <textarea> elements for the RICH TEXT FIELDS you specify (by fully-qualified API name like Example.Rich_Text_Field__c).
- Replaces each matched <textarea> with a Quill editor, while keeping the original <textarea> hidden and in-sync.
- Hooks into FF_OnAfterRender and FF_OnBeforeSubmit to ensure content synchronization on re-render and submit.
- Optionally observes DOM mutations to re-attach editors if Formstack re-renders the form.
Storage & Rendering behavior
- Storage format: The editor writes HTML into the underlying <textarea>, which is then submitted and saved to the Salesforce field.
- Salesforce display: On record pages, the saved HTML displays as rich text (and can be interpreted as Markdown if your record component supports Markdown). Ensure your page layout or LWC/Record Detail component is configured to render the field’s content as formatted text.
Setup Steps
- Add the script and styles: Place the full code (below) in your form’s custom JavaScript area. No server deployment required.
- List target fields: In RICH_TEXT_FIELDS, add fully-qualified API names of fields you wish to enhance (e.g., Example.Rich_Text_Field__c).
- (Optional) Adjust toolbar: Modify the modules.toolbar array in the Quill initialization to add/remove buttons based on your needs.
- (Optional) Per-field editor height: Set pixel values in FIELD_HEIGHTS for specific fields.
- Publish & test: Open the form, verify the editor loads, enter content, submit, and confirm formatted output is stored in Salesforce and renders on the record page.
Configuration Reference
Required
- RICH_TEXT_FIELDS: Array of fully-qualified API names (e.g., Object.Field__c) to enhance.
- QUILL_JS, QUILL_CSS: CDN URLs (update as needed for version pinning or internal hosting).
Optional
- FIELD_HEIGHTS: { 'Object.Field__c': 200 } to set a min editor height per field.
- Toolbar (utility bar) options: Controlled in the Quill init via modules.toolbar. Add/remove items such as ['bold','italic'], headers { header: 1 }, lists, alignment, link, etc.
Programmatically changing the Utility Bar
You can tailor the editor to the context by modifying modules.toolbar before Quill is instantiated. For example, show headings only for long-form instructions, or remove links for fields that shouldn’t contain external URLs. Conditional logic (e.g., based on record type) can update the toolbar array prior to creating the Quill instance.
Known Limitations & Notes
- HTML output: The stored value is HTML; downstream consumers should be prepared to handle HTML safely.
- Pasted content: Quill sanitizes/normalizes pasted HTML, but edge cases can occur.
- Asset loading: If the CDN is blocked, the editor won’t load. Consider pinning versions or hosting assets internally.
- MutationObserver: Broad observers can be noisy; keep if your form re-renders often, otherwise remove.
- 3rd Party Updates: Quill may make updates that break the code without warning.
Support Policy (as‑is)
This sample is provided as-is. Intellistack Support will not help troubleshoot or implement this code. Use at your own discretion and test thoroughly in a sandbox.
Uninstall / Rollback
- Remove the custom JavaScript and CSS from the form’s theme/custom code area.
- Republish the form; textareas will revert to their default behavior.
Code (copy/paste/integrate into other code)
// ================== CONFIG ==================
const RICH_TEXT_FIELDS = [
// Fully-qualified API names (Object.Field__c)
// Using array allows for dynamic number of fields
'Example.Rich_Text_Test__c',
'Example.Rich_Text_Field__c'
];
const QUILL_JS = "https://cdn.jsdelivr.net/npm/quill/dist/quill.min.js";
const QUILL_CSS = "https://cdn.jsdelivr.net/npm/quill/dist/quill.snow.css";
// Optional per-field height overrides (min-height for editor)
const FIELD_HEIGHTS = {
// 'Example.Rich_Text_Test__c': 160,
};
// ================== LOADER HELPERS ==================
function loadCSS(href){return new Promise((res,rej)=>{const l=document.createElement('link');l.rel='stylesheet';l.href=href;l.onload=res;l.onerror=rej;document.head.appendChild(l);});}
function loadScript(src){return new Promise((res,rej)=>{if([...document.scripts].some(s=>s.src&&s.src.indexOf(src)!==-1))return res();const s=document.createElement('script');s.src=src;s.onload=res;s.onerror=rej;document.body.appendChild(s);});}
function whenQuillReady(){return new Promise(resolve=>{if(window.Quill)return resolve();let t=0,iv=setInterval(()=>{if(window.Quill){clearInterval(iv);resolve();}else if(++t>100){clearInterval(iv);console.warn('Quill not loaded in time');}},50);});}
// Inject minimal CSS so toolbar stacks and editor fills width nicely
(function injectBaseCss(){
const style = document.createElement('style');
style.innerHTML = `
.quill-wrap{display:flex;flex-direction:column;width:100%;min-width:0;}
.quill-wrap .ql-toolbar,.quill-wrap .ql-container{width:100%;max-width:100%;flex:0 0 auto;}
.quill-wrap .ql-container{flex:1 1 auto;min-height:180px;}
.quill-wrap .ql-editor{min-height:140px;}
.slds-form-element__control,.slds-input-has-fixed-addon{min-width:0;}
`;
document.head.appendChild(style);
})();
// ================== CORE ==================
function attachQuillToTextarea(textarea, apiName) {
if (!textarea || textarea.dataset.quillAttached === "1") return;
// Wrapper and editor host
const wrap = document.createElement('div');
wrap.className = 'quill-wrap';
const editorId = 'quill_' + Math.random().toString(36).slice(2);
const editor = document.createElement('div');
editor.id = editorId;
textarea.parentNode.insertBefore(wrap, textarea);
wrap.appendChild(editor);
// Keep submittable
textarea.style.display = 'none';
textarea.disabled = false;
// Init Quill
const quill = new Quill('#' + editorId, {
theme: 'snow',
modules: {
toolbar: [
['bold','italic','underline','strike'],
[{header:1},{header:2}],
[{list:'ordered'},{list:'bullet'}],
[{indent:'-1'},{indent:'+1'}],
[{align:[]}],
['link','clean']
]
}
});
// Per-field height override
if (FIELD_HEIGHTS[apiName]) {
const container = wrap.querySelector('.ql-container');
const editorEl = wrap.querySelector('.ql-editor');
container && (container.style.minHeight = FIELD_HEIGHTS[apiName] + 'px');
editorEl && (editorEl.style.minHeight = (FIELD_HEIGHTS[apiName] - 40) + 'px');
}
// Seed existing HTML
if (textarea.value) quill.root.innerHTML = textarea.value;
// Sync helper + events
function syncToTextarea() {
const html = quill.root.innerHTML;
if (textarea.value !== html) {
textarea.value = html;
textarea.dispatchEvent(new Event('input', { bubbles: true }));
textarea.dispatchEvent(new Event('change', { bubbles: true }));
}
}
quill.on('text-change', syncToTextarea);
const form = textarea.closest('form') || document.querySelector('form');
if (form) form.addEventListener('submit', syncToTextarea, true);
const submitBtn = (form && (form.querySelector('button[type="submit"], input[type="submit"]'))) ||
document.querySelector('button[type="submit"], input[type="submit"]');
if (submitBtn) submitBtn.addEventListener('click', syncToTextarea, true);
// Package hook if present
const prevBefore = window.FF_OnBeforeSubmit;
window.FF_OnBeforeSubmit = function(formEl){
try { syncToTextarea(); } catch(e){}
if (typeof prevBefore === 'function') { try { prevBefore.apply(this, arguments); } catch(e){} }
};
textarea.dataset.quillAttached = "1";
}
function findTextareaForApiName(root, apiName) {
const sels = [
`[data-field-api-name="${apiName}"] textarea`,
`textarea[name*="${apiName}"]`,
`label[for*="${apiName.split('.').pop()}"] ~ textarea`,
`label:has(+ textarea)[for*="${apiName.split('.').pop()}"] + textarea`
];
for (let i=0;i<sels.length;i++){
const el = root.querySelector(sels[i]);
if (el) return el;
}
return null;
}
function attachEditors(root){
RICH_TEXT_FIELDS.forEach(apiName => {
const ta = findTextareaForApiName(root, apiName);
if (ta) attachQuillToTextarea(ta, apiName);
});
}
// ================== HOOKS ==================
(function init(){
Promise.all([loadCSS(QUILL_CSS), loadScript(QUILL_JS)])
.then(whenQuillReady)
.then(function(){
// Wrap Formstack’s render hook
const prevAfter = window.FF_OnAfterRender;
window.FF_OnAfterRender = function(formEl){
if (typeof prevAfter === 'function') { try { prevAfter.apply(this, arguments); } catch(e){} }
attachEditors(formEl || document);
};
// In case form already rendered
attachEditors(document);
// Optional: observe dynamic re-renders to re-attach if nodes get replaced
const obs = new MutationObserver(() => attachEditors(document));
obs.observe(document.body, { childList: true, subtree: true });
})
.catch(err => console.error('Failed to load Quill assets:', err));
})();
Appendix
- Quill Docs: https://quilljs.com/ (for reference; consider pinning versions in production)