Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Use QuillJS as rich text editor #35695

Open
wants to merge 57 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
57 commits
Select commit Hold shift + click to select a range
1177506
Use quill for rich editor
MartinRiese Jan 27, 2025
f1cf6ee
Additional formatting options
MartinRiese Jan 29, 2025
83ca7db
Custom font set for quill
MartinRiese Jan 30, 2025
d94063b
Add image upload functionality to quill
MartinRiese Feb 3, 2025
6c39f61
Move editor knockout binding to shared file
MartinRiese Feb 5, 2025
74f7e26
Because and indent of 2 is prettier?
MartinRiese Feb 5, 2025
ddeffd0
Use global _quill.sccs
MartinRiese Feb 5, 2025
33292db
Round quill editor corners to match style of forms
MartinRiese Feb 5, 2025
552c115
Add missing fonts
MartinRiese Feb 5, 2025
1afde94
Revert "Use global _quill.sccs"
MartinRiese Feb 5, 2025
98f30d0
Add font to font drop down items
MartinRiese Feb 5, 2025
720cc1e
Fix lint issues
MartinRiese Feb 6, 2025
e92c7ff
Use quill handler instead of listening directly
MartinRiese Feb 7, 2025
e130c22
Just use css file
MartinRiese Feb 7, 2025
3d5f51e
Add help link to rich text FF
MartinRiese Feb 10, 2025
bcbe4ef
Merge branch 'master' into riese/quill
MartinRiese Feb 11, 2025
a3b2e24
Fix quill header menu paragraph option
MartinRiese Feb 11, 2025
4580f02
Allow H1 headings
MartinRiese Feb 11, 2025
45df21f
Use quill-delta-to-html to render HTML
MartinRiese Feb 12, 2025
48c29bf
Merge branch 'riese/quill' of github.com:dimagi/commcare-hq into ries…
MartinRiese Feb 12, 2025
c060d26
Merge branch 'master' into riese/quill
orangejenny Feb 12, 2025
8f91064
Fix alignment button
MartinRiese Feb 13, 2025
235f478
Do not need check box list.
MartinRiese Feb 13, 2025
1be6a8b
Merge branch 'riese/quill' of github.com:dimagi/commcare-hq into ries…
MartinRiese Feb 13, 2025
24575ec
Handle error when uploading image
MartinRiese Feb 13, 2025
df4933a
Add title to all buttons in the toolbar
MartinRiese Feb 13, 2025
af7366e
making the linter happy
MartinRiese Feb 13, 2025
b17d802
Eradicate the dust bunnies
MartinRiese Feb 13, 2025
691caa1
Merge branch 'master' into riese/quill
MartinRiese Feb 13, 2025
b8bf7d3
Save whole html as email content
MartinRiese Feb 14, 2025
a9d8a56
Inline styles for emails and add tests
MartinRiese Feb 14, 2025
6f6e423
Don't log
MartinRiese Feb 14, 2025
1876901
Handle case when html does not have a head element
MartinRiese Feb 14, 2025
a0f2642
Allow style and class attributes for tags a and p
MartinRiese Feb 14, 2025
d9eca03
Set ordered list type by level as quill does
MartinRiese Feb 17, 2025
829b207
Add http:// if no schema is given
MartinRiese Feb 17, 2025
d9ee650
Merge branch 'master' into riese/quill
MartinRiese Feb 17, 2025
bc792d2
Merge branch 'master' into riese/quill
MartinRiese Feb 17, 2025
1e3380f
Use updated version of quill-delta-to-html
MartinRiese Feb 18, 2025
07aeb37
Merge branch 'riese/quill' of github.com:dimagi/commcare-hq into ries…
MartinRiese Feb 18, 2025
5e28530
Merge branch 'master' into riese/quill
MartinRiese Feb 18, 2025
fd438dd
Add tests, remove log
MartinRiese Feb 18, 2025
55ee242
Add spinner, better error messages
MartinRiese Feb 18, 2025
4b61abe
use b5 modal
MartinRiese Feb 19, 2025
8b7254b
Add doc strings
MartinRiese Feb 20, 2025
d54b962
Use bootstrap 3 for rich text editor pop up
MartinRiese Feb 20, 2025
7056591
Prettier
MartinRiese Feb 20, 2025
0978566
typo
MartinRiese Feb 20, 2025
500f8b9
Hide whole form group
MartinRiese Feb 20, 2025
94164c8
Add modal for image upload
MartinRiese Feb 20, 2025
252b161
Merge branch 'riese/quill' of github.com:dimagi/commcare-hq into ries…
MartinRiese Feb 20, 2025
8faa281
Clean up email template
MartinRiese Feb 20, 2025
3caedc9
Add extra line
MartinRiese Feb 20, 2025
0d8292d
lint
MartinRiese Feb 20, 2025
f6c6f89
Translate tooltips
MartinRiese Feb 21, 2025
ad3fe8a
Don't close modal until upload is complete
MartinRiese Feb 21, 2025
1a9df7a
Merge branch 'master' into riese/quill
MartinRiese Feb 21, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions Gruntfile.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
/* globals module, process, require */
'use strict';
module.exports = function (grunt) {
var headless = require('mocha-headless-chrome'),
_ = require('lodash'),
Expand Down Expand Up @@ -38,6 +37,7 @@ module.exports = function (grunt) {
'cloudcare/form_entry',
'hqwebapp/bootstrap3',
'hqwebapp/bootstrap5',
'hqwebapp/components',
'case_importer',
];

Expand Down Expand Up @@ -91,15 +91,15 @@ module.exports = function (grunt) {
error && grunt.log.write(error));
}
fs.writeFile(filePath, JSON.stringify(data.coverage), { flag: 'w+' }, error =>
error && grunt.log.write(error)
error && grunt.log.write(error),
);
}
finishedTests.push(currentApp);
runTest(
_.without(queuedTests, currentApp),
taskPromise,
finishedTests,
failures
failures,
);
});
};
Expand Down Expand Up @@ -134,7 +134,7 @@ module.exports = function (grunt) {
var testStatement = "Running tests: " + paths.join(', ');
grunt.log.writeln(testStatement.bold.green);
runTest(paths, this.async());
}
},
);

grunt.registerTask('list', 'Lists all available apps to test', function () {
Expand Down
50 changes: 50 additions & 0 deletions corehq/apps/hqwebapp/static/hqwebapp/js/components/quill.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
.ql-snow .ql-picker.ql-font .ql-picker-label[data-value]::before,
.ql-snow .ql-picker.ql-font .ql-picker-item[data-value]::before {
content: attr(data-value) !important;
}

.ql-editor {
height: 250px;
}

.ql-toolbar {
border-top-left-radius: 3px;
border-top-right-radius: 3px;
}

.ql-container {
border-bottom-left-radius: 3px;
border-bottom-right-radius: 3px;
}

.ql-picker-label {
overflow: hidden;
}

.ql-picker-item[data-value="Ariel"] {
font-family: "Arial", sans-serif;
}

.ql-picker-item[data-value="Courier New"] {
font-family: "Courier New", sans-serif;
}

.ql-picker-item[data-value="Georgia"] {
font-family: "Georgia", serif;
}

.ql-picker-item[data-value="Lucida Sans Unicode"] {
font-family: "Lucida Sans Unicode", sans-serif;
}

.ql-picker-item[data-value="Tahoma"] {
font-family: "Tahoma", sans-serif;
}

.ql-picker-item[data-value="Times New Roman"] {
font-family: "Times New Roman", serif;
}

.ql-picker-item[data-value="Verdana"] {
font-family: "Verdana", sans-serif;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,249 @@
import ko from "knockout";

import "quill/dist/quill.snow.css";
import "hqwebapp/js/components/quill.css";
import Quill from 'quill';
import Toolbar from "quill/modules/toolbar";
import Snow from "quill/themes/snow";
import Bold from "quill/formats/bold";
import Italic from "quill/formats/italic";
import Header from "quill/formats/header";
import {QuillDeltaToHtmlConverter} from 'quill-delta-to-html-upate';
import $ from "jquery";

import initialPageData from "hqwebapp/js/initial_page_data";

Quill.register({
"modules/toolbar": Toolbar,
"themes/snow": Snow,
"formats/bold": Bold,
"formats/italic": Italic,
"formats/header": Header,
});

function imageHandler() {
const self = this;
const $modal = $('#rich-text-image-dialog');
const imageInput = document.getElementById('rich-text-image');
const uploadButton = document.getElementById('rich-text-image-upload');
const uploadProgress = document.getElementById('rich-text-image-upload-in-progress');

const handleImage = async function () {
const file = imageInput.files[0];
if (!file) {
alert(gettext('No File selected'));
return;
}
uploadProgress.classList.remove("d-none");

const uploadUrl = initialPageData.reverse("upload_messaging_image");
let formData = new FormData();

formData.append("upload", file, file.name);
await fetch(uploadUrl, {
method: "POST",
body: formData,
headers: {
"X-CSRFTOKEN": $("#csrfTokenContainer").val(),
},
})
.then(function (response) {
if (!response.ok) {
if (response.status === 400) {
return response.json().then(function (errorJson) {
throw Error(gettext('Failed to upload image: ') + errorJson.error.message);
});
}
throw Error(gettext('Failed to upload image. Please try again.'));
}
return response.json();
})
.then(function (data) {
const Delta = Quill.import("delta");
const selectionRange = self.quill.getSelection(true);
self.quill.updateContents(
new Delta()
.retain(selectionRange.index)
.delete(selectionRange.length)
.insert({
image: data.url,
}, {
alt: file.name,
}),
);
})
.catch(function (error) {
alert(error.message || gettext('Failed to upload image. Please try again.'));
})
.finally(function () {
uploadProgress.classList.add("d-none");
});


imageInput.value = '';
uploadButton.removeEventListener('click', handleImage);
$modal.modal('hide');
};

uploadButton.addEventListener('click', handleImage);
$modal.modal();
}

async function linkHandler(value) {
const self = this;
const linkTextInputGroup = document.getElementById('rich-text-link-text-group');
const linkTextInput = document.getElementById('rich-text-link-text');
const selection = self.quill.getSelection();
if (selection.length === 0) {
linkTextInputGroup.classList.remove("d-none");
} else {
linkTextInputGroup.classList.add("d-none");
}
if (value) {
const $modal = $('#rich-text-link-dialog');
const linkUrlInput = document.getElementById('rich-text-link-url');
const insertButton = document.getElementById('rich-text-link-insert');

const handleInsert = function () {
let href = linkUrlInput.value.trim();
if (!href) {
return;
}

if (!href.match(/^(https?|ftp|mailto):/)) {
href = "https://" + href;
}
if (selection.length === 0) {
const text = linkTextInput.value;
self.quill.insertText(selection.index, text);
self.quill.setSelection({index: selection.index, length: text.length});
self.quill.format('link', href);
self.quill.setSelection({index: selection.index + text.length, length: 0});
} else {
self.quill.format('link', href);
}

linkTextInput.value = '';
linkUrlInput.value = '';
insertButton.removeEventListener('click', handleInsert);
$modal.modal('hide');
};

insertButton.addEventListener('click', handleInsert);
$modal.modal();
}
}

const converterOptions = {
inlineStyles: true,
linkTarget: "",
};

const orderedListTypes = ["1", "a", "i"];

/**
* Update the type of nested ordered lists recursively based on the given level.
*
* @param {HTMLElement} element - The HTML element containing the list.
* @param {number} level - The current level of nesting for the list elements.
*
* @return {void}
*/
function updateListType(element, level) {
element.childNodes.forEach(function (child) {
if (child.tagName && child.tagName.toLowerCase() === 'ol') {
child.type = orderedListTypes[level % orderedListTypes.length];
updateListType(child, level + 1);
} else {
updateListType(child, level);
}
});
}

const parser = new DOMParser();

/**
* Convert quill delta to html
*
* @param {object} delta - Delta representation of the text to be converted to HTML.
* @returns {string} - HTML page converted from the given Delta object, including html
* and body tags
*/
function deltaToHtml(delta) {
// nice for adding more test data
// console.log(JSON.stringify(delta, null, 4));
if (!delta) {
return "";
}
const converter = new QuillDeltaToHtmlConverter(delta.ops, converterOptions);
const body = converter.convert();

const xmlDoc = parser.parseFromString(body, "text/html");
updateListType(xmlDoc, 0);
const html = `<html><body>${xmlDoc.querySelector("body").innerHTML}</body></html>`;
return html;
}

ko.bindingHandlers.richEditor = {
init: function (element, valueAccessor) {
const fontFamilyArr = [
"Arial",
"Courier New",
"Georgia",
"Lucida Sans Unicode",
"Tahoma",
"Times New Roman",
"Trebuchet MS",
"Verdana",
];
let fonts = Quill.import("attributors/style/font");
fonts.whitelist = fontFamilyArr;
Quill.register(fonts, true);

const toolbar = element.parentElement.querySelector("#ql-toolbar");
const editor = new Quill(element, {
modules: {
toolbar: {
container: toolbar,
handlers: {
image: imageHandler,
link: linkHandler,
},
},
},
theme: "snow",
});

const value = ko.utils.unwrapObservable(valueAccessor());
editor.clipboard.dangerouslyPasteHTML(value);

let isSubscriberChange = false;
let isEditorChange = false;

editor.on("text-change", function () {
if (!isSubscriberChange) {
isEditorChange = true;
const html = deltaToHtml(editor.getContents());
valueAccessor()(html);
isEditorChange = false;
}
});

valueAccessor().subscribe(function (value) {
if (!isEditorChange) {
isSubscriberChange = true;
editor.clipboard.dangerouslyPasteHTML(value);
isSubscriberChange = false;
}
});

if (initialPageData.get("read_only_mode")) {
editor.enable(false);
}
},
};

export {
deltaToHtml,
updateListType,
};
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
// This file is for creating porting Bootstrap classes that exist in version 5 but not 3
.d-flex {
display: flex;
display: flex;
}

.d-none {
display: none;
}

.align-items-center {
align-items: center;
align-items: center;
}

.justify-content-between {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import hqMocha from "mocha/js/main";
import "commcarehq";

import "hqwebapp/spec/components/rich_text_spec";

hqMocha.run();
Loading