/**
*
* The Rich Text Field derives from [Component](kiss.ui.Component.html).
* It's a simple componant to edit rich text content:
* - headers (h1, h2, h3)
* - bold, italic, underline
* - color
* - lists (ordered, bullet, check)
* - blockquote
* - code block
* - clear formatting
*
* Encapsulates original Quill inside a KissJS UI component:
* https://quilljs.com
*
* Current version of local Quill: 2.0.2
*
* @param {string} [config.value] - Default value
* @param {string} [config.label]
* @param {boolean} [config.readOnly]
* @param {boolean} [config.disabled]
* @param {boolean} [config.required]
* @param {string|number} [config.labelWidth]
* @param {string|number} [config.fieldWidth]
* @param {string} [config.fieldPadding]
* @param {string} [config.labelPosition] - left | right | top | bottom
* @param {string} [config.labelAlign] - left | right
* @param {number} [config.boxShadow]
* @param {integer} [config.width] - Width in pixels
* @param {integer} [config.height] - Height in pixels
* @param {boolean} [config.useCDN] - Set to true to use the CDN version of Quill. Default is true.
* @returns this
*
* ## Generated markup
* ```
* <a-richtextfield class="a-richtextfield">
* <label class="field-label"></label>
* <div class="field-richtext">
* <!-- Quill editor is here !-->
* </div>
* </a-richtextfield>
* ```
*/
kiss.ux.RichTextField = class RichTextField extends kiss.ui.Component {
/**
* Its a Custom Web Component. Do not use the constructor directly with the **new** keyword.
* Instead, use one of the 2 following methods:
*
* Create the Web Component and call its **init** method:
* ```
* const myRichTextField = document.createElement("a-richtextfield").init(config)
* ```
*
* Or use the shorthand for it:
* ```
* const myRichTextField = createRichTextField({
* label: "My rich text field",
* width: 600,
* labelPosition: "top"
* })
*
* myRichTextField.render()
* ```
*
* Or directly declare the config inside a container component:
* ```
* const myPanel = createPanel({
* title: "My panel",
* items: [
* {
* type: "richTextField",
* label: "My rich text field",
* width: 600,
* labelPosition: "top"
* }
* ]
* })
* myPanel.render()
* ```
*/
constructor() {
super()
}
/**
* Generates a label and a rich text editor inside a div container
*
* @ignore
* @returns {HTMLElement}
*/
init(config = {}) {
super.init(config)
this.useCDN = (config.useCDN === false) ? false : true
this.readOnly = !!config.readOnly
this.disabled = !!config.disabled
this.required = !!config.required
this.innerHTML = `
${ (config.label) ? `<label id="field-label-${this.id}" for="${this.id}" class="field-label">
${ (this.isLocked()) ? this.locker : "" }
${ config.label || "" }
${ (this.isRequired()) ? this.asterisk : "" }
</label>` : "" }
<div id="container-${this.id}" class="field-richtext"></div>
`
// Set properties and styles
this.label = this.querySelector(".field-label")
this.field = this.querySelector(".field-richtext")
this._setProperties(config, [
[
["draggable"],
[this]
],
[
["width", "minWidth", "height", "flex", "display", "margin", "padding"],
[this.style]
],
[
["fieldWidth=width", "maxHeight", "fieldPadding=padding", "boxShadow"],
[this.field.style]
],
[
["fontSize", "labelAlign=textAlign", "labelFlex=flex"],
[this.label?.style]
]
])
// Set the default display mode that will be restored by the show() method
this.displayMode = "flex"
// Manage label and field layout according to label position
this.style.flexFlow = "row"
if (config.label) {
// Label width
if (config.labelWidth) this.setLabelWidth(config.labelWidth)
// Label position
this.config.labelPosition = config.labelPosition || "left"
this.setLabelPosition(config.labelPosition)
}
return this
}
/**
* After render, initialize the "Quill" rich text editor
*
* Note: the focus and blur management is a bit tricky because the Quill editor doesn't not manage it internally.
* For example, the blur event is triggered when the editor is left, but also when the user clicks on the editor toolbar, which is not the expected behavior.
* To fix this, we have to check if the last "blur" event was inside the editor or the toolbar, an cancel the blur event if it was the toolbar.
* On top of this, the "change" event is triggered on every key press, which is not the standard way for a field.
* We circumvent this by triggering the change event only when the editor is left, and by comparing the previous value with the new one.
*
* @ignore
*/
async _afterRender() {
if (window.Quill) {
this._initRichTextField()
} else {
await this._initRichTextEditor()
this._initRichTextField()
}
// Set initial value + eventually bind record
if (this.config.record) {
this._bindRecord(this.config.record)
} else if (this.config.value) {
this.richTextField.clipboard.dangerouslyPasteHTML(this.config.value)
}
// READONLY
if (this.readOnly || this.disabled) {
this.richTextContainer.classList.add("field-richtext-read-only")
this.richTextField.disable()
return
}
// FOCUS
this.isFirstFocus = true
this.richTextField.root.onfocus = () => {
if (!this.isFirstFocus) return
this.isFirstFocus = false
this.previousValue = this.getValue()
this.dispatchEvent(new Event("focus"))
}
// BLUR + GLOBAL CHANGE
this.richTextField.root.onblur = () => {
if (!this._isInsideEditor()) {
this.isFirstFocus = true
this.dispatchEvent(new Event("blur"))
const newValue = this.getValue()
if (this.previousValue == newValue) return
if (this.validate()) {
this.setValue(newValue, true)
}
}
}
// CHANGE
this.richTextField.on("text-change", () => {
this.validate()
})
// EDITOR CHANGE
this.richTextField.on("editor-change", () => {
this._adjustToolbarPosition.call(this)
})
}
/**
* Load the editor library
*
* @private
* @ignore
*/
async _initRichTextEditor() {
if (this.useCDN === false) {
// Local (version 2.0.2)
await kiss.loader.loadScript("../../kissjs/client/ux/richTextField/richTextField_quill")
await kiss.loader.loadStyle("../../kissjs/client/ux/richTextField/richTextField_quill")
} else {
// CDN
await kiss.loader.loadScript("https://cdn.jsdelivr.net/npm/quill@2.0.2/dist/quill")
await kiss.loader.loadStyle("https://cdn.jsdelivr.net/npm/quill@2.0.2/dist/quill.bubble")
}
}
/**
* Initialize the editor
*
* @private
* @ignore
*/
_initRichTextField() {
this.richTextField = new Quill("#container-" + this.id, {
theme: "bubble",
modules: {
toolbar: [
["clean", { "header": 1 }, { "header": 2 }, { "header": 3 }],
["bold", "italic", "underline", {color: []}],
[{ "list": "ordered"}, { "list": "bullet" }, { "list": "check" }],
["blockquote", "code-block"]
]
}
})
this.richTextToolbar = this.querySelector(".ql-toolbar")
this.richTextContainer = this.querySelector(".ql-container")
}
/**
* Bind the field to a record
* (this subscribes the field to react to database changes)
*
* @private
* @ignore
* @param {object} record
* @returns this
*/
_bindRecord(record) {
this.record = record
this.modelId = record.model.id
this.recordId = record.id
// Set initial value
if (record[this.id]) {
this.initialValue = record[this.id]
this.richTextField.clipboard.dangerouslyPasteHTML(this.initialValue)
}
// React to changes on a single record of the binded model
this.subscriptions.push(
subscribe("EVT_DB_UPDATE:" + this.modelId.toUpperCase(), (msgData) => {
if ((msgData.modelId == this.modelId) && (msgData.id == this.recordId)) {
const updates = msgData.data
this._updateField(updates)
}
})
)
// React to changes on multiple records of the binded Model
this.subscriptions.push(
subscribe("EVT_DB_UPDATE_BULK", (msgData) => {
const operations = msgData.data
operations.forEach(operation => {
if ((operation.modelId == this.modelId) && (operation.recordId == this.recordId)) {
const updates = operation.updates
this._updateField(updates)
}
})
})
)
return this
}
/**
* Update the code editor value internally
*
* @private
* @ignore
* @param {*} updates
*/
_updateField(updates) {
if (this.id in updates) {
const newValue = updates[this.id]
if (newValue || (newValue === 0) || (newValue === "")) {
this.richTextField.clipboard.dangerouslyPasteHTML(newValue)
}
}
}
/**
* Set the code
*
* @param {string} newValue
* @param {boolean} [fromBlurEvent] - If true, the update is only performed on binded record, not locally
* @returns this
*/
setValue(newValue, fromBlurEvent) {
if (this.record) {
// If the field is connected to a record, we update the database
this.record.updateFieldDeep(this.id, newValue).then(success => {
// Rollback the initial value if the update failed (ACL)
if (!success) {
this.richTextField.clipboard.dangerouslyPasteHTML(this.initialValue || "")
}
})
} else {
// Otherwise, we just change the field value
if (!fromBlurEvent) {
this.richTextField.clipboard.dangerouslyPasteHTML(newValue)
}
}
return this
}
/**
* Get the field value, which is the HTML content
*
* @returns {string} - The field value
*/
getValue() {
return this.richTextField.getSemanticHTML()
}
/**
* Validate the field value and apply UI style accordingly
*
* @returns {boolean} true is the field is valid, false otherwise
*/
validate() {
this.setValid()
// Exit if field is readOnly
if (this.config.readOnly) return true
// Required
if (this.required && this.isEmpty()) this.setInvalid()
return this.isValid
}
/**
* Give focus to the input field
*
* @returns this
*/
focus() {
this.richTextField.focus()
return this
}
/**
* Unset the focus of the input field
*
* @returns this
*/
blur() {
this.richTextField.blur()
return this
}
/**
* Reset the focus
*/
resetFocus() {
this.blur()
setTimeout(() => this.focus(), 100)
}
/**
* Remove the invalid style
*
* @returns this
*/
setValid() {
this.isValid = true
this.richTextContainer.classList.remove("field-richtext-invalid")
return this
}
/**
* Change the style when the field is invalid
*
* @returns this
*/
setInvalid() {
log("kiss.ui - field.setInvalid - Invalid value for the field: " + this.config.label, 4)
this.isValid = false
this.richTextContainer.classList.add("field-richtext-invalid")
return this
}
/**
* Check if the field is empty
*
* @returns {boolean}
*/
isEmpty() {
const value = this.getValue()
const regex = /^(\s*<p>\s*<\/p>\s*)+$/;
return regex.test(value)
}
/**
* Set the field label
*
* @param {string} newLabel
* @returns this
*/
setLabel(newLabel) {
if (!this.label) return
this.config.label = newLabel
this.label.innerText = newLabel
return this
}
/**
* Get the field label
*
* @returns {string}
*/
getLabel() {
return this?.label?.innerText || ""
}
/**
* Set the field width
*
* @param {*} width
* @returns this
*/
setWidth(width) {
this.config.width = width
this.style.width = this._computeSize("width", width)
return this
}
/**
* Set the color selector field width
*
* @param {*} width
* @returns this
*/
setFieldWidth(width) {
this.config.fieldWidth = width
this.field.style.width = this._computeSize("fieldWidth", width)
return this
}
/**
* Set the label width
*
* @param {*} width
* @returns this
*/
setLabelWidth(width) {
this.config.labelWidth = width
this.label.style.width = this.label.style.maxWidth = this._computeSize("labelWidth", width)
return this
}
/**
* Get the label position
*
* @returns {string} "left" | "right" | "top"
*/
getLabelPosition() {
return this.config.labelPosition
}
/**
* Set label position
*
* @param {string} position - "left" (default) | "right" | "top" | "bottom"
* @returns this
*/
setLabelPosition(position) {
this.config.labelPosition = position
switch (position) {
case "top":
this.style.flexFlow = "column"
this.field.style.order = 1
break
case "bottom":
this.style.flexFlow = "column"
this.field.style.order = -1
break
case "right":
this.style.flexFlow = "row"
this.field.style.order = -1
break
default:
this.style.flexFlow = "row"
this.field.style.order = 1
}
return this
}
/**
* Check if the last blur event was inside the editor
*
* @private
* @ignore
* @returns {boolean}
*/
_isInsideEditor() {
const { x, y } = kiss.screen.mousePosition
// Check if it was inside the editor
const editorRect = this.richTextField.root.getBoundingClientRect()
const isInsideEditor = (
x >= editorRect.left &&
x <= editorRect.right &&
y >= editorRect.top &&
y <= editorRect.bottom
)
if (isInsideEditor) return true
// Check if it was inside the toolbar
const toolbarRect = this.richTextToolbar.getBoundingClientRect()
const isInsideToolbar = (
x >= toolbarRect.left &&
x <= toolbarRect.right &&
y >= toolbarRect.top &&
y <= toolbarRect.bottom
)
if (isInsideToolbar) return true
return false
}
/**
* Adjust the toolbar position to fix default Quill behavior.
* Center it horizontally inside the editor instead of cropping it when it reaches the window border.
*
* @private
* @ignore
*/
_adjustToolbarPosition() {
setTimeout(() => {
const tooltip = document.querySelector('.ql-tooltip')
if (!tooltip) return
const componentBounds = this.getBoundingClientRect()
const tooltipWidth = tooltip.offsetWidth
let left = (componentBounds.width / 2) - (tooltipWidth / 2)
if (left < 0) left = 10
if (left + tooltipWidth > window.innerWidth) left = window.innerWidth - tooltipWidth - 10
tooltip.style.left = left + "px"
}, 5)
}
}
// Create a Custom Element and add a shortcut to create it
customElements.define("a-richtextfield", kiss.ux.RichTextField)
const createRichTextField = (config) => document.createElement("a-richtextfield").init(config)
;
Source