Skip to content

Instantly share code, notes, and snippets.

@ffkev
Last active December 15, 2025 09:42
Show Gist options
  • Select an option

  • Save ffkev/4ed5afc282a8bf886d9565008774643b to your computer and use it in GitHub Desktop.

Select an option

Save ffkev/4ed5afc282a8bf886d9565008774643b to your computer and use it in GitHub Desktop.
Changes included in the HTML Editor to support Mulish font.
======================================================================================================
Use the latest test_quill.html as a reference for the full implementation
Create a folder under web/ as web/fonts/ and include all the Mulish fonts for offline access from
the assets/ folder in the Flutter project
======================================================================================================
Refer line 351
// Register Mulish font with Quill
const Font = Quill.import('formats/font');
Font.whitelist = ['sans-serif', 'serif', 'monospace', 'Mulish'];
Quill.register(Font, true);
======================================================================================================
Refer line 494
// dropdown with defaults from theme
[{ 'font': ['sans-serif', 'serif', 'monospace', 'Mulish'] }],
======================================================================================================
Refer line 20
/* Mulish Font Family */
@font-face {
font-family: 'Mulish';
src: url('fonts/Mulish-Regular.ttf') format('truetype');
font-weight: 400;
font-style: normal;
}
@font-face {
font-family: 'Mulish';
src: url('fonts/Mulish-Italic.ttf') format('truetype');
font-weight: 400;
font-style: italic;
}
@font-face {
font-family: 'Mulish';
src: url('fonts/Mulish-Light.ttf') format('truetype');
font-weight: 300;
font-style: normal;
}
@font-face {
font-family: 'Mulish';
src: url('fonts/Mulish-LightItalic.ttf') format('truetype');
font-weight: 300;
font-style: italic;
}
@font-face {
font-family: 'Mulish';
src: url('fonts/Mulish-Medium.ttf') format('truetype');
font-weight: 500;
font-style: normal;
}
@font-face {
font-family: 'Mulish';
src: url('fonts/Mulish-MediumItalic.ttf') format('truetype');
font-weight: 500;
font-style: italic;
}
@font-face {
font-family: 'Mulish';
src: url('fonts/Mulish-SemiBold.ttf') format('truetype');
font-weight: 600;
font-style: normal;
}
@font-face {
font-family: 'Mulish';
src: url('fonts/Mulish-SemiBoldItalic.ttf') format('truetype');
font-weight: 600;
font-style: italic;
}
@font-face {
font-family: 'Mulish';
src: url('fonts/Mulish-Bold.ttf') format('truetype');
font-weight: 700;
font-style: normal;
}
@font-face {
font-family: 'Mulish';
src: url('fonts/Mulish-BoldItalic.ttf') format('truetype');
font-weight: 700;
font-style: italic;
}
@font-face {
font-family: 'Mulish';
src: url('fonts/Mulish-ExtraBold.ttf') format('truetype');
font-weight: 800;
font-style: normal;
}
@font-face {
font-family: 'Mulish';
src: url('fonts/Mulish-ExtraBoldItalic.ttf') format('truetype');
font-weight: 800;
font-style: italic;
}
@font-face {
font-family: 'Mulish';
src: url('fonts/Mulish-Black.ttf') format('truetype');
font-weight: 900;
font-style: normal;
}
@font-face {
font-family: 'Mulish';
src: url('fonts/Mulish-BlackItalic.ttf') format('truetype');
font-weight: 900;
font-style: italic;
}
@font-face {
font-family: 'Mulish';
src: url('fonts/Mulish-ExtraLight.ttf') format('truetype');
font-weight: 200;
font-style: normal;
}
@font-face {
font-family: 'Mulish';
src: url('fonts/Mulish-ExtraLightItalic.ttf') format('truetype');
font-weight: 200;
font-style: italic;
}
======================================================================================================
Refer line 331
/* Mulish font styling for Quill - must be specific to override Quill defaults */
/* Target all possible Quill inline elements with Mulish font class */
.ql-editor .ql-font-Mulish,
.ql-font-Mulish,
.ql-editor span.ql-font-Mulish,
span.ql-font-Mulish,
.ql-editor .ql-font-Mulish *,
.ql-font-Mulish *,
.ql-container .ql-editor .ql-font-Mulish,
.ql-container .ql-editor .ql-font-Mulish p,
.ql-container .ql-editor .ql-font-Mulish span,
.ql-container .ql-editor .ql-font-Mulish div,
.ql-snow .ql-editor .ql-font-Mulish,
.ql-snow .ql-editor span.ql-font-Mulish {
font-family: 'Mulish', sans-serif !important;
}
======================================================================================================
/* Additional rule to ensure font applies to text nodes */ | Refer line 348
.ql-editor [class*="ql-font-Mulish"] {
font-family: 'Mulish', sans-serif !important;
}
======================================================================================================
/* Ensure font dropdown displays Mulish correctly */ | Refer line 336
.ql-picker.ql-font .ql-picker-item[data-value="Mulish"]::before,
.ql-picker.ql-font .ql-picker-label[data-value="Mulish"]::before {
content: 'Mulish';
font-family: 'Mulish', sans-serif;
}
/* Override default Quill font classes to use Mulish as fallback */
.ql-font-sans-serif {
font-family: 'Mulish', sans-serif;
}
/* Ensure all font options in dropdown use proper font rendering */
.ql-picker.ql-font .ql-picker-item {
font-size: 14px;
}
======================================================================================================
====================================================================================================
Include all references to the Mulish font as per the previous implementations in the exported htmlString
====================================================================================================
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<link href="https://cdn.jsdelivr.net/npm/quill@2/dist/quill.snow.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/quill-table-better@1/dist/quill-table-better.css" rel="stylesheet">
<style>
table,
td,
th {
border: 1px solid black;
border-collapse: collapse;
}
.ql-editor ol li {
list-style-type: decimal;
padding-left: 0;
}
.ql-editor ul li {
list-style-type: disc;
padding-left: 0;
margin: 0;
}
.ql-editor ul {
margin: 0;
}
table tbody tr td p {
margin: 0;
padding: 0;
}
p {
margin: 0;
padding: 0;
}
/* Mulish Font Family */
@font-face {
font-family: 'Mulish';
src: url('fonts/Mulish-Regular.ttf') format('truetype');
font-weight: 400;
font-style: normal;
}
@font-face {
font-family: 'Mulish';
src: url('fonts/Mulish-Italic.ttf') format('truetype');
font-weight: 400;
font-style: italic;
}
@font-face {
font-family: 'Mulish';
src: url('fonts/Mulish-Light.ttf') format('truetype');
font-weight: 300;
font-style: normal;
}
@font-face {
font-family: 'Mulish';
src: url('fonts/Mulish-LightItalic.ttf') format('truetype');
font-weight: 300;
font-style: italic;
}
@font-face {
font-family: 'Mulish';
src: url('fonts/Mulish-Medium.ttf') format('truetype');
font-weight: 500;
font-style: normal;
}
@font-face {
font-family: 'Mulish';
src: url('fonts/Mulish-MediumItalic.ttf') format('truetype');
font-weight: 500;
font-style: italic;
}
@font-face {
font-family: 'Mulish';
src: url('fonts/Mulish-SemiBold.ttf') format('truetype');
font-weight: 600;
font-style: normal;
}
@font-face {
font-family: 'Mulish';
src: url('fonts/Mulish-SemiBoldItalic.ttf') format('truetype');
font-weight: 600;
font-style: italic;
}
@font-face {
font-family: 'Mulish';
src: url('fonts/Mulish-Bold.ttf') format('truetype');
font-weight: 700;
font-style: normal;
}
@font-face {
font-family: 'Mulish';
src: url('fonts/Mulish-BoldItalic.ttf') format('truetype');
font-weight: 700;
font-style: italic;
}
@font-face {
font-family: 'Mulish';
src: url('fonts/Mulish-ExtraBold.ttf') format('truetype');
font-weight: 800;
font-style: normal;
}
@font-face {
font-family: 'Mulish';
src: url('fonts/Mulish-ExtraBoldItalic.ttf') format('truetype');
font-weight: 800;
font-style: italic;
}
@font-face {
font-family: 'Mulish';
src: url('fonts/Mulish-Black.ttf') format('truetype');
font-weight: 900;
font-style: normal;
}
@font-face {
font-family: 'Mulish';
src: url('fonts/Mulish-BlackItalic.ttf') format('truetype');
font-weight: 900;
font-style: italic;
}
@font-face {
font-family: 'Mulish';
src: url('fonts/Mulish-ExtraLight.ttf') format('truetype');
font-weight: 200;
font-style: normal;
}
@font-face {
font-family: 'Mulish';
src: url('fonts/Mulish-ExtraLightItalic.ttf') format('truetype');
font-weight: 200;
font-style: italic;
}
/* Mulish font styling for Quill - must be specific to override Quill defaults */
/* Target all possible Quill inline elements with Mulish font class */
.ql-editor .ql-font-Mulish,
.ql-font-Mulish,
.ql-editor span.ql-font-Mulish,
span.ql-font-Mulish,
.ql-editor .ql-font-Mulish *,
.ql-font-Mulish *,
.ql-container .ql-editor .ql-font-Mulish,
.ql-container .ql-editor .ql-font-Mulish p,
.ql-container .ql-editor .ql-font-Mulish span,
.ql-container .ql-editor .ql-font-Mulish div,
.ql-snow .ql-editor .ql-font-Mulish,
.ql-snow .ql-editor span.ql-font-Mulish {
font-family: 'Mulish', sans-serif !important;
}
/* Additional rule to ensure font applies to text nodes */
.ql-editor [class*="ql-font-Mulish"] {
font-family: 'Mulish', sans-serif !important;
}
</style>
</head>
<body>
<div class="ql-editor">
<p><span class="ql-font-sans-serif">asdasdsa&nbsp;</span></p>
<p><span class="ql-font-monospace">asdlsakjdhksj</span></p>
<p><span class="ql-font-Mulish">dfsljfoilksdjflk</span></p>
</div>
</body>
</html>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Quill Editor Test - Table Support</title>
<!-- Quill.js CSS -->
<link href="https://cdn.jsdelivr.net/npm/quill@2/dist/quill.snow.css" rel="stylesheet">
<!-- Quill Better Table CSS -->
<link href="https://cdn.jsdelivr.net/npm/quill-table-better@1/dist/quill-table-better.css" rel="stylesheet">
<style>
/* Mulish Font Family */
@font-face {
font-family: 'Mulish';
src: url('fonts/Mulish-Regular.ttf') format('truetype');
font-weight: 400;
font-style: normal;
}
@font-face {
font-family: 'Mulish';
src: url('fonts/Mulish-Italic.ttf') format('truetype');
font-weight: 400;
font-style: italic;
}
@font-face {
font-family: 'Mulish';
src: url('fonts/Mulish-Light.ttf') format('truetype');
font-weight: 300;
font-style: normal;
}
@font-face {
font-family: 'Mulish';
src: url('fonts/Mulish-LightItalic.ttf') format('truetype');
font-weight: 300;
font-style: italic;
}
@font-face {
font-family: 'Mulish';
src: url('fonts/Mulish-Medium.ttf') format('truetype');
font-weight: 500;
font-style: normal;
}
@font-face {
font-family: 'Mulish';
src: url('fonts/Mulish-MediumItalic.ttf') format('truetype');
font-weight: 500;
font-style: italic;
}
@font-face {
font-family: 'Mulish';
src: url('fonts/Mulish-SemiBold.ttf') format('truetype');
font-weight: 600;
font-style: normal;
}
@font-face {
font-family: 'Mulish';
src: url('fonts/Mulish-SemiBoldItalic.ttf') format('truetype');
font-weight: 600;
font-style: italic;
}
@font-face {
font-family: 'Mulish';
src: url('fonts/Mulish-Bold.ttf') format('truetype');
font-weight: 700;
font-style: normal;
}
@font-face {
font-family: 'Mulish';
src: url('fonts/Mulish-BoldItalic.ttf') format('truetype');
font-weight: 700;
font-style: italic;
}
@font-face {
font-family: 'Mulish';
src: url('fonts/Mulish-ExtraBold.ttf') format('truetype');
font-weight: 800;
font-style: normal;
}
@font-face {
font-family: 'Mulish';
src: url('fonts/Mulish-ExtraBoldItalic.ttf') format('truetype');
font-weight: 800;
font-style: italic;
}
@font-face {
font-family: 'Mulish';
src: url('fonts/Mulish-Black.ttf') format('truetype');
font-weight: 900;
font-style: normal;
}
@font-face {
font-family: 'Mulish';
src: url('fonts/Mulish-BlackItalic.ttf') format('truetype');
font-weight: 900;
font-style: italic;
}
@font-face {
font-family: 'Mulish';
src: url('fonts/Mulish-ExtraLight.ttf') format('truetype');
font-weight: 200;
font-style: normal;
}
@font-face {
font-family: 'Mulish';
src: url('fonts/Mulish-ExtraLightItalic.ttf') format('truetype');
font-weight: 200;
font-style: italic;
}
body {
font-family: Arial, sans-serif;
margin: 0;
padding: 0px;
background-color: transparent;
}
.ql-container.ql-snow {
height: 400px;
}
.container {
max-width: 1200px;
margin: 0 auto;
background: white;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
overflow: hidden;
}
.header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 20px;
text-align: center;
}
.header h1 {
margin: 0;
font-size: 2em;
}
.header p {
margin: 10px 0 0 0;
opacity: 0.9;
}
.toolbar-custom {
background: #f8f9fa;
border-bottom: 1px solid #e9ecef;
padding: 10px 20px;
display: flex;
gap: 10px;
align-items: center;
}
.btn {
background: #007bff;
color: white;
border: none;
padding: 8px 16px;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
transition: background-color 0.2s;
}
.btn:hover {
background: #0056b3;
}
.btn.secondary {
background: #6c757d;
}
.btn.secondary:hover {
background: #545b62;
}
.btn.success {
background: #28a745;
}
.btn.success:hover {
background: #1e7e34;
}
.editor-container {
padding: 20px;
min-height: 400px;
}
.content-preview {
margin-top: 20px;
padding: 20px;
background: #f8f9fa;
border-radius: 4px;
border-left: 4px solid #007bff;
}
.content-preview h3 {
margin-top: 0;
color: #495057;
}
.content-preview pre {
background: white;
padding: 15px;
border-radius: 4px;
border: 1px solid #dee2e6;
overflow-x: auto;
white-space: pre-wrap;
word-wrap: break-word;
}
.stats {
display: flex;
gap: 20px;
margin-top: 10px;
font-size: 14px;
color: #6c757d;
}
.feature-list {
background: #e3f2fd;
padding: 15px;
border-radius: 4px;
margin-bottom: 20px;
}
.feature-list h4 {
margin-top: 0;
color: #1976d2;
}
.feature-list ul {
margin: 10px 0;
padding-left: 20px;
}
.feature-list li {
margin: 5px 0;
}
.table-demo {
background: #fff3cd;
padding: 15px;
border-radius: 4px;
margin-bottom: 20px;
border-left: 4px solid #ffc107;
}
.table-demo h4 {
margin-top: 0;
color: #856404;
}
/* Format Painter button styling */
.ql-format-painter {
position: relative;
}
.ql-format-painter.ql-active {
background-color: #e3f2fd !important;
}
.ql-format-painter.ql-active::after {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(33, 150, 243, 0.2);
border-radius: 2px;
pointer-events: none;
}
/* Format Painter icon (paintbrush SVG) */
.ql-format-painter svg {
display: inline-block;
vertical-align: middle;
width: 18px;
height: 18px;
}
.ql-format-painter::before {
content: '';
}
/* Image resize functionality */
.ql-editor img.ql-image-selected {
outline: 2px solid #007bff !important;
outline-offset: 2px;
cursor: move;
position: relative;
}
.ql-image-resize-handle {
position: fixed;
width: 14px;
height: 14px;
background: #007bff;
border: 2px solid white;
border-radius: 50%;
cursor: nwse-resize;
display: none;
z-index: 10000;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3);
transition: transform 0.2s, background 0.2s;
pointer-events: auto;
}
.ql-image-resize-handle:hover {
transform: scale(1.3);
background: #0056b3;
box-shadow: 0 3px 8px rgba(0, 0, 0, 0.4);
}
.ql-editor {
position: relative;
}
/* Mulish font styling for Quill - must be specific to override Quill defaults */
/* Target all possible Quill inline elements with Mulish font class */
.ql-editor .ql-font-Mulish,
.ql-font-Mulish,
.ql-editor span.ql-font-Mulish,
span.ql-font-Mulish,
.ql-editor .ql-font-Mulish *,
.ql-font-Mulish *,
.ql-container .ql-editor .ql-font-Mulish,
.ql-container .ql-editor .ql-font-Mulish p,
.ql-container .ql-editor .ql-font-Mulish span,
.ql-container .ql-editor .ql-font-Mulish div,
.ql-snow .ql-editor .ql-font-Mulish,
.ql-snow .ql-editor span.ql-font-Mulish {
font-family: 'Mulish', sans-serif !important;
}
/* Additional rule to ensure font applies to text nodes */
.ql-editor [class*="ql-font-Mulish"] {
font-family: 'Mulish', sans-serif !important;
}
/* Ensure font dropdown displays Mulish correctly */
.ql-picker.ql-font .ql-picker-item[data-value="Mulish"]::before,
.ql-picker.ql-font .ql-picker-label[data-value="Mulish"]::before {
content: 'Mulish';
font-family: 'Mulish', sans-serif;
}
/* Ensure all font options in dropdown use proper font rendering */
.ql-picker.ql-font .ql-picker-item {
font-size: 14px;
}
</style>
</head>
<body>
<div id="root"></div>
<br />
<script src="https://cdn.jsdelivr.net/npm/quill@2/dist/quill.js"></script>
<script src="https://cdn.jsdelivr.net/npm/quill-table-better@1/dist/quill-table-better.js"></script>
<script>
Quill.register({
'modules/table-better': QuillTableBetter
}, true);
// Register Mulish font with Quill
const Font = Quill.import('formats/font');
Font.whitelist = ['sans-serif', 'serif', 'monospace', 'Mulish'];
Quill.register(Font, true);
// Verify font registration
console.log('Registered fonts:', Font.whitelist);
// Format Painter custom handler
const FormatPainter = {
storedFormat: null,
isActive: false,
copyFormat() {
const selection = editor.getSelection(true);
if (!selection) {
return false;
}
// Get the format at the selection
const format = editor.getFormat(selection);
// Store the format (filter out attributes we don't want to copy)
this.storedFormat = {};
const attributesToCopy = [
'bold', 'italic', 'underline', 'strike',
'color', 'background',
'font', 'size',
'script', 'link',
'header', 'align',
'blockquote', 'code-block',
'list', 'indent', 'direction'
];
attributesToCopy.forEach(attr => {
if (format[attr] !== undefined && format[attr] !== null && format[attr] !== '') {
this.storedFormat[attr] = format[attr];
}
});
// If we have any format to copy, activate
if (Object.keys(this.storedFormat).length > 0) {
this.isActive = true;
this.updateButtonState();
return true;
}
return false;
},
applyFormat() {
if (!this.storedFormat || !this.isActive) {
return false;
}
const selection = editor.getSelection(true);
if (!selection || selection.length === 0) {
return false;
}
// Apply the stored format to the current selection
Object.keys(this.storedFormat).forEach(attr => {
const value = this.storedFormat[attr];
editor.formatText(selection.index, selection.length, attr, value, Quill.sources.USER);
});
// Deactivate after applying (single-use mode)
this.isActive = false;
this.updateButtonState();
return true;
},
updateButtonState() {
setTimeout(() => {
const button = document.querySelector('.ql-format-painter');
if (button) {
if (this.isActive) {
button.classList.add('ql-active');
button.title = 'Format Painter Active - Select text and click to apply format';
} else {
button.classList.remove('ql-active');
button.title = 'Format Painter - Select formatted text and click to copy format';
}
}
}, 50);
},
handleClick() {
const selection = editor.getSelection(true);
if (this.isActive) {
// Format painter is active - apply format to current selection
if (selection && selection.length > 0) {
this.applyFormat();
} else {
// No selection, deactivate
this.isActive = false;
this.updateButtonState();
}
} else {
// Format painter is not active - copy format from current selection
if (selection && selection.length > 0) {
this.copyFormat();
} else if (selection) {
// No text selected, try to get format at cursor position
const format = editor.getFormat(selection);
if (Object.keys(format).length > 0) {
this.storedFormat = {};
const attributesToCopy = [
'bold', 'italic', 'underline', 'strike',
'color', 'background', 'font', 'size',
'script', 'link', 'header', 'align',
'blockquote', 'code-block', 'list', 'indent', 'direction'
];
attributesToCopy.forEach(attr => {
if (format[attr] !== undefined && format[attr] !== null && format[attr] !== '') {
this.storedFormat[attr] = format[attr];
}
});
if (Object.keys(this.storedFormat).length > 0) {
this.isActive = true;
this.updateButtonState();
}
}
}
}
}
};
const toolbarOptions = [
['bold', 'italic', 'underline', 'strike'], // toggled buttons
['format-painter'], // format painter button
['blockquote', 'code-block'],
['link', 'image', 'video', 'formula'],
[{ 'header': 1 }, { 'header': 2 }], // custom button values
[{ 'list': 'ordered' }, { 'list': 'bullet' }, { 'list': 'check' }],
[{ 'script': 'sub' }, { 'script': 'super' }], // superscript/subscript
[{ 'indent': '-1' }, { 'indent': '+1' }], // outdent/indent
[{ 'direction': 'rtl' }], // text direction
[{ 'size': ['small', false, 'large', 'huge'] }], // custom dropdown
[{ 'header': [1, 2, 3, 4, 5, 6, false] }],
[{ 'color': [] }, { 'background': [] }], // dropdown with defaults from theme
[{ 'font': ['sans-serif', 'serif', 'monospace', 'Mulish'] }],
[{ 'align': [] }],
['clean'], // remove formatting button
['table-better']
];
const options = {
theme: 'snow',
modules: {
toolbar: {
container: toolbarOptions,
handlers: {
'format-painter': function () {
FormatPainter.handleClick();
}
}
},
table: false,
'table-better': {
toolbarTable: true,
menus: ['column', 'row', 'merge', 'table', 'cell', 'wrap', 'copy', 'delete'],
},
keyboard: {
bindings: QuillTableBetter.keyboardBindings
}
}
};
const editor = new Quill('#root', options);
// Add tooltips to all toolbar items
function addTooltipsToToolbar() {
const toolbar = editor.getModule('toolbar');
const toolbarElement = toolbar.container;
// Function to add tooltip to an element
function setTooltip(element, text) {
if (element && !element.title) {
element.title = text;
}
}
// Add tooltips after a short delay to ensure DOM is ready
setTimeout(() => {
// Simple buttons with direct tooltips
const buttonTooltips = {
'ql-bold': 'Bold (Ctrl+B)',
'ql-italic': 'Italic (Ctrl+I)',
'ql-underline': 'Underline (Ctrl+U)',
'ql-strike': 'Strikethrough',
'ql-format-painter': 'Format Painter',
'ql-blockquote': 'Blockquote',
'ql-code-block': 'Code Block',
'ql-link': 'Insert Link',
'ql-image': 'Insert Image',
'ql-video': 'Insert Video',
'ql-formula': 'Insert Formula',
'ql-clean': 'Remove Formatting',
'ql-table-better': 'Insert Table'
};
// Add tooltips to simple buttons
Object.keys(buttonTooltips).forEach(className => {
toolbarElement.querySelectorAll(`button.${className}`).forEach(button => {
setTooltip(button, buttonTooltips[className]);
});
});
// Header buttons
toolbarElement.querySelectorAll('button.ql-header').forEach(button => {
const value = button.getAttribute('value');
if (value === '1') setTooltip(button, 'Heading 1');
else if (value === '2') setTooltip(button, 'Heading 2');
else setTooltip(button, 'Heading');
});
// List buttons
toolbarElement.querySelectorAll('button.ql-list').forEach(button => {
const value = button.getAttribute('value');
if (value === 'ordered') setTooltip(button, 'Numbered List');
else if (value === 'bullet') setTooltip(button, 'Bullet List');
else if (value === 'check') setTooltip(button, 'Check List');
else setTooltip(button, 'List');
});
// Script buttons (subscript/superscript)
toolbarElement.querySelectorAll('button.ql-script').forEach(button => {
const value = button.getAttribute('value');
if (value === 'sub') setTooltip(button, 'Subscript');
else if (value === 'super') setTooltip(button, 'Superscript');
});
// Indent buttons
toolbarElement.querySelectorAll('button.ql-indent').forEach(button => {
const value = button.getAttribute('value');
if (value === '-1') setTooltip(button, 'Decrease Indent');
else if (value === '+1') setTooltip(button, 'Increase Indent');
});
// Direction button
toolbarElement.querySelectorAll('button.ql-direction').forEach(button => {
if (button.getAttribute('value') === 'rtl') {
setTooltip(button, 'Right to Left');
}
});
// Dropdown pickers (size, header, color, background, font, align)
const pickerTooltips = {
'ql-size': 'Text Size',
'ql-header': 'Heading',
'ql-color': 'Text Color',
'ql-background': 'Background Color',
'ql-font': 'Font Family',
'ql-align': 'Text Alignment'
};
Object.keys(pickerTooltips).forEach(pickerClass => {
const picker = toolbarElement.querySelector(`.ql-picker.${pickerClass}`);
if (picker) {
const label = picker.querySelector('.ql-picker-label');
if (label) setTooltip(label, pickerTooltips[pickerClass]);
}
});
// Fallback: add tooltips to any remaining buttons
toolbarElement.querySelectorAll('button').forEach(button => {
if (!button.title) {
const classes = Array.from(button.classList);
const qlClass = classes.find(cls => cls.startsWith('ql-') && cls !== 'ql-picker-label');
if (qlClass && buttonTooltips[qlClass]) {
setTooltip(button, buttonTooltips[qlClass]);
}
}
});
}, 100);
}
// Initialize tooltips after editor is ready
addTooltipsToToolbar();
// Add format painter icon after toolbar is created
setTimeout(() => {
const formatPainterButton = document.querySelector('.ql-format-painter');
if (formatPainterButton && !formatPainterButton.querySelector('svg')) {
// Add paintbrush SVG icon
formatPainterButton.innerHTML = `
<svg viewBox="0 0 18 18">
<path d="M13.5 2.5L15.5 4.5L6.5 13.5L4.5 11.5L13.5 2.5Z" fill="currentColor" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M3.5 15.5L4.5 14.5L5.5 15.5L4.5 16.5L3.5 15.5Z" fill="currentColor" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M2.5 12.5L3.5 13.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
`;
}
}, 150);
let delta = null;
let html = '';
let messageId = 0;
let lastMessageTime = 0;
const MESSAGE_THROTTLE = 100; // milliseconds
// Function to send message to Flutter
function sendMessageToFlutter(message) {
if (window.parent) {
window.parent.postMessage(message, '*');
}
}
// Function to clean HTML by removing selection styles from images
function cleanExportedHTML(html) {
if (!html) return html;
// Create a temporary div to parse and clean the HTML
const tempDiv = document.createElement('div');
tempDiv.innerHTML = html;
// Find all images and remove selection styles
const images = tempDiv.querySelectorAll('img');
images.forEach(img => {
// Remove the selection class
img.classList.remove('ql-image-selected');
// Remove inline outline styles
if (img.style.outline) {
img.style.outline = '';
}
if (img.style.outlineOffset) {
img.style.outlineOffset = '';
}
// If style attribute is now empty, remove it
if (img.style.cssText.trim() === '') {
img.removeAttribute('style');
}
});
// Return the cleaned HTML
return tempDiv.innerHTML;
}
// Function to send JSON data to Flutter
function sendJsonToFlutter(data) {
// Throttle messages to prevent spam
const now = Date.now();
if (now - lastMessageTime < MESSAGE_THROTTLE) {
return;
}
lastMessageTime = now;
// Clean HTML if present before sending
if (data.html) {
data.html = cleanExportedHTML(data.html);
}
if (data.data && data.data.html) {
data.data.html = cleanExportedHTML(data.data.html);
}
// Add unique ID to prevent loops
data.messageId = ++messageId;
data.source = 'iframe';
sendMessageToFlutter(JSON.stringify(data));
}
// Listen for messages from Flutter
window.addEventListener('message', function (event) {
// Only process messages from the parent window (Flutter)
if (event.source !== window.parent) {
return;
}
try {
const data = JSON.parse(event.data);
// Only handle command messages, not response messages
if (data.type === 'command') {
handleFlutterCommand(data);
}
} catch (e) {
// Handle plain string messages
sendMessageToFlutter('Echo: ' + event.data);
}
});
// Handle commands from Flutter
function handleFlutterCommand(data) {
// Prevent processing the same command multiple times
if (data._processed) {
return;
}
data._processed = true;
switch (data.action) {
case 'insertTable':
const rows = data.rows || 3;
const cols = data.cols || 3;
tableModule.insertTable(rows, cols);
sendJsonToFlutter({
type: 'response',
action: 'insertTable',
success: true,
message: `Table inserted: ${rows}x${cols}`,
timestamp: Date.now()
});
break;
case 'getContents':
const content = editor.getContents();
const htmlContent = editor.getSemanticHTML();
sendJsonToFlutter({
type: 'response',
action: 'getContents',
success: true,
data: {
delta: content,
html: htmlContent,
text: editor.getText()
},
timestamp: Date.now()
});
break;
case 'setContents':
if (data.delta) {
editor.setContents(data.delta, Quill.sources.USER);
sendJsonToFlutter({
type: 'response',
action: 'setContents',
success: true,
message: 'Contents set successfully',
timestamp: Date.now()
});
}
break;
case 'setHTML':
if (data.html) {
// Use setTimeout to make this asynchronous and prevent blocking with large HTML
setTimeout(function() {
try {
// Clear existing content first to avoid conflicts
editor.setContents([{ insert: '\n' }], Quill.sources.API);
// For very large HTML, we need to handle it carefully
const htmlString = String(data.html);
// Use dangerouslyPasteHTML with proper error handling
// This method can handle any HTML input, including large content
editor.clipboard.dangerouslyPasteHTML(0, htmlString, Quill.sources.USER);
// Remove the initial newline we added
const length = editor.getLength();
if (length > 1) {
editor.deleteText(0, 1, Quill.sources.API);
}
sendJsonToFlutter({
type: 'response',
action: 'setHTML',
success: true,
message: 'HTML content set successfully',
timestamp: Date.now()
});
} catch (e) {
sendJsonToFlutter({
type: 'response',
action: 'setHTML',
success: false,
message: 'Error setting HTML: ' + (e.message || String(e)),
timestamp: Date.now()
});
}
}, 0);
} else {
sendJsonToFlutter({
type: 'response',
action: 'setHTML',
success: false,
message: 'No HTML content provided',
timestamp: Date.now()
});
}
break;
case 'insertText':
const text = data.text || '';
if (text) {
const selection = editor.getSelection(true);
if (selection) {
// Insert text at the current cursor position
editor.insertText(selection.index, text, Quill.sources.USER);
// Move cursor to after the inserted text
editor.setSelection(selection.index + text.length, Quill.sources.USER);
} else {
// No selection, insert at the end
const length = editor.getLength();
editor.insertText(length - 1, text, Quill.sources.USER);
editor.setSelection(length - 1 + text.length, Quill.sources.USER);
}
sendJsonToFlutter({
type: 'response',
action: 'insertText',
success: true,
message: 'Text inserted successfully',
timestamp: Date.now()
});
}
break;
default:
sendJsonToFlutter({
type: 'response',
action: data.action || 'unknown',
success: false,
message: 'Unknown command',
timestamp: Date.now()
});
}
}
// Send initial message to Flutter when iframe loads
window.addEventListener('load', function () {
});
// Send content change notifications (throttled)
let contentChangeTimeout;
editor.on('text-change', function (delta, oldDelta, source) {
if (source === 'user') {
// Send immediate update with complete content
const completeContent = editor.getContents();
const htmlContent = editor.getSemanticHTML();
const textContent = editor.getText();
sendJsonToFlutter({
type: 'contentChange',
delta: completeContent, // Send complete content, not just the change
html: htmlContent,
text: textContent,
timestamp: Date.now()
});
// Also send a throttled summary for heavy operations
clearTimeout(contentChangeTimeout);
contentChangeTimeout = setTimeout(() => {
}, 1000); // Wait 1 second after last change
}
});
// Handle selection changes to apply format when format painter is active
editor.on('selection-change', function (range, oldRange, source) {
if (FormatPainter.isActive && range && range.length > 0) {
// Format painter is active and user has selected text
// The format will be applied when they click the button again
// Or we can auto-apply on selection change (uncomment below if preferred)
// FormatPainter.applyFormat();
} else if (FormatPainter.isActive && (!range || range.length === 0)) {
// Selection cleared, keep format painter active for next selection
}
});
// Image resize functionality
function initImageResize() {
const editorContainer = editor.container;
const editorElement = editorContainer.querySelector('.ql-editor');
if (!editorElement) {
console.warn('Editor element not found, retrying image resize initialization...');
setTimeout(initImageResize, 200);
return;
}
// Track resize state
let isResizing = false;
let currentImage = null;
let resizeHandle = null;
let startX = 0;
let startY = 0;
let startWidth = 0;
let startHeight = 0;
let aspectRatio = 1;
// Create resize handle
function createResizeHandle() {
if (resizeHandle && resizeHandle.parentNode) {
return resizeHandle;
}
// Remove old handle if it exists but is orphaned
if (resizeHandle && !resizeHandle.parentNode) {
resizeHandle = null;
}
const handle = document.createElement('div');
handle.className = 'ql-image-resize-handle';
// Try appending to body first (for fixed positioning)
// If that doesn't work well, we can fall back to editorElement
try {
document.body.appendChild(handle);
} catch (e) {
// Fallback to editor element if body append fails
editorElement.appendChild(handle);
}
resizeHandle = handle;
return handle;
}
// Position resize handle at bottom-right corner of image
function positionResizeHandle(img, handle) {
if (!img || !handle) return;
const imgRect = img.getBoundingClientRect();
const editorRect = editorElement.getBoundingClientRect();
// Check if handle is in body or editorElement
const isInBody = handle.parentNode === document.body;
if (isInBody) {
// Use fixed positioning relative to viewport
handle.style.position = 'fixed';
handle.style.left = (imgRect.right - 7) + 'px'; // 7px to center the 14px handle
handle.style.top = (imgRect.bottom - 7) + 'px';
} else {
// Use absolute positioning relative to editor
handle.style.position = 'absolute';
const scrollLeft = editorElement.scrollLeft;
const scrollTop = editorElement.scrollTop;
handle.style.left = (imgRect.right - editorRect.left - 7 + scrollLeft) + 'px';
handle.style.top = (imgRect.bottom - editorRect.top - 7 + scrollTop) + 'px';
}
handle.style.right = 'auto';
handle.style.bottom = 'auto';
handle.style.display = 'block';
handle.style.visibility = 'visible';
handle.style.opacity = '1';
console.log('Positioning handle:', {
imgRect: imgRect,
editorRect: editorRect,
isInBody: isInBody,
left: handle.style.left,
top: handle.style.top,
position: handle.style.position,
display: handle.style.display,
visibility: handle.style.visibility
});
}
// Remove resize handle
function removeResizeHandle() {
if (resizeHandle) {
resizeHandle.style.display = 'none';
}
}
// Update image size in Quill delta
function updateImageSize(img, width, height) {
// Update the DOM directly first for immediate visual feedback
img.style.width = width + 'px';
img.style.height = height + 'px';
img.setAttribute('width', width);
img.setAttribute('height', height);
// Try to update through Quill's API
try {
const blot = Quill.find(img);
if (blot) {
const index = editor.getIndex(blot);
if (index !== null) {
// Quill stores images as embeds, so we need to update the embed's attributes
// Try using formatText for width/height (may not work if not registered)
// If that doesn't work, we'll rely on the DOM attributes which will be preserved in HTML output
try {
editor.formatText(index, 1, 'width', width + 'px', Quill.sources.USER);
editor.formatText(index, 1, 'height', height + 'px', Quill.sources.USER);
} catch (e) {
// Width/height might not be registered formats, that's okay
// The DOM attributes will be preserved in the HTML output
console.log('Width/height format not registered, using DOM attributes');
}
}
}
} catch (e) {
// If Quill API fails, DOM updates will still work
console.log('Error updating through Quill API:', e);
}
}
// Handle mouse down on resize handle
function handleResizeStart(e) {
e.preventDefault();
e.stopPropagation();
if (!currentImage) return;
isResizing = true;
startX = e.clientX;
startY = e.clientY;
startWidth = currentImage.offsetWidth || currentImage.width;
startHeight = currentImage.offsetHeight || currentImage.height;
aspectRatio = startWidth / startHeight;
// Prevent text selection during resize
document.body.style.userSelect = 'none';
document.body.style.cursor = 'nwse-resize';
document.addEventListener('mousemove', handleResize);
document.addEventListener('mouseup', handleResizeEnd);
}
// Handle mouse move during resize
function handleResize(e) {
if (!isResizing || !currentImage) return;
const deltaX = e.clientX - startX;
const deltaY = e.clientY - startY;
// Calculate new dimensions maintaining aspect ratio
let newWidth = startWidth + deltaX;
let newHeight = startHeight + deltaY;
// Maintain aspect ratio based on which direction has more movement
if (Math.abs(deltaX) > Math.abs(deltaY)) {
newHeight = newWidth / aspectRatio;
} else {
newWidth = newHeight * aspectRatio;
}
// Set minimum and maximum size constraints
const minSize = 50;
const maxSize = 2000;
newWidth = Math.max(minSize, Math.min(maxSize, newWidth));
newHeight = Math.max(minSize, Math.min(maxSize, newHeight));
// Update image dimensions visually
currentImage.style.width = newWidth + 'px';
currentImage.style.height = newHeight + 'px';
// Update handle position
if (resizeHandle) {
positionResizeHandle(currentImage, resizeHandle);
}
}
// Handle mouse up - finalize resize
function handleResizeEnd(e) {
if (!isResizing || !currentImage) return;
isResizing = false;
// Restore text selection
document.body.style.userSelect = '';
document.body.style.cursor = '';
// Update Quill delta with final dimensions
const finalWidth = Math.round(parseInt(currentImage.style.width) || currentImage.offsetWidth);
const finalHeight = Math.round(parseInt(currentImage.style.height) || currentImage.offsetHeight);
updateImageSize(currentImage, finalWidth, finalHeight);
// Clean up
document.removeEventListener('mousemove', handleResize);
document.removeEventListener('mouseup', handleResizeEnd);
// Content change will be automatically triggered by formatText with Quill.sources.USER
}
// Handle image click - show resize handle
editorElement.addEventListener('click', function (e) {
// Don't interfere if we're resizing
if (isResizing) return;
if (e.target.tagName === 'IMG') {
// Remove previous selection
editorElement.querySelectorAll('img.ql-image-selected').forEach(img => {
img.classList.remove('ql-image-selected');
img.style.outline = '';
img.style.outlineOffset = '';
});
// Select current image
e.target.classList.add('ql-image-selected');
e.target.style.outline = '2px solid #007bff';
e.target.style.outlineOffset = '2px';
currentImage = e.target;
// Create and position resize handle
const handle = createResizeHandle();
// Small delay to ensure image is fully rendered
setTimeout(function () {
positionResizeHandle(e.target, handle);
console.log('Image selected, handle positioned at:', handle.style.left, handle.style.top);
console.log('Handle display:', handle.style.display);
console.log('Handle element:', handle);
}, 10);
// Add event listener to handle if not already added
handle.onmousedown = handleResizeStart;
} else {
// Click outside image - remove selection
editorElement.querySelectorAll('img.ql-image-selected').forEach(img => {
img.classList.remove('ql-image-selected');
img.style.outline = '';
img.style.outlineOffset = '';
});
currentImage = null;
removeResizeHandle();
}
});
// Handle selection change - remove image selection when text is selected
editor.on('selection-change', function (range) {
if (range && range.length > 0) {
// Text is selected, remove image selection
editorElement.querySelectorAll('img.ql-image-selected').forEach(img => {
img.classList.remove('ql-image-selected');
img.style.outline = '';
img.style.outlineOffset = '';
});
currentImage = null;
removeResizeHandle();
}
});
// Update handle position on scroll
editorElement.addEventListener('scroll', function () {
if (currentImage && resizeHandle && resizeHandle.style.display !== 'none') {
positionResizeHandle(currentImage, resizeHandle);
}
});
// Handle window resize
window.addEventListener('resize', function () {
if (currentImage && resizeHandle && resizeHandle.style.display !== 'none') {
positionResizeHandle(currentImage, resizeHandle);
}
});
}
// Initialize image resize after editor is ready
setTimeout(initImageResize, 300);
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment