import {type CSSResultGroup, html, type PropertyValues, unsafeCSS} from 'lit'; import {deepQuerySelectorAll} from "../../utilities/query"; import {FormControlController} from '../../internal/form'; import {on} from "../../utilities/on"; import {property, query} from 'lit/decorators.js'; import {trim} from "lodash"; import Attachment from "./modules/attachment/attachment"; import ContextMenu from "./modules/context-menu/context-menu"; import DatePicker from "./modules/date-picker/date-picker"; import Dialog from "./modules/dialog/dialog"; import DragAndDrop from "./modules/drag-drop/drag-drop"; import Emoji from "./modules/emoji/emoji"; import HeadlessEmoji from "./modules/emoji/headless/headless-emoji"; import ImageResize from "./modules/image-resize/image-resize"; import Quill from "quill"; import QuillAI from "./modules/ai"; import TimeTracking from "./modules/time-tracking/time-tracking"; import Toolbar from "./modules/toolbar/toolbar"; import ZincElement from '../../internal/zinc-element'; import ZnTextarea from "../textarea"; import type {OnEvent} from "../../utilities/on"; import type {Range} from "quill"; import type {ZincFormControl} from '../../internal/zinc-element'; import type ToolbarComponent from "./modules/toolbar/toolbar.component"; import styles from './editor.scss'; export interface EditorFeatureConfig { codeBlocksEnabled?: boolean; dividersEnabled?: boolean; linksEnabled?: boolean; attachmentsEnabled?: boolean; imagesEnabled?: boolean; videosEnabled?: boolean; datesEnabled?: boolean; emojisEnabled?: boolean; codeEnabled?: boolean; } /** * @summary Short summary of the component's intended use. * @documentation https://zinc.style/components/editor * @status experimental * @since 1.0 * * @dependency zn-example * * @event zn-event-name - Emitted as an example. * * @slot - The default slot. * @slot example - An example slot. * * @csspart base - The component's base wrapper. * * @cssproperty --example - An example CSS custom property. */ export default class ZnEditor extends ZincElement implements ZincFormControl { static styles: CSSResultGroup = unsafeCSS(styles); private formControlController = new FormControlController(this, {}); @query('#editor') private editor: HTMLElement; @query('#editorHtml') private editorHtml: HTMLTextAreaElement; @query('#toolbar') private toolbar: ToolbarComponent; @property({reflect: true}) id: string; @property() name: string; @property() value: string; @property({attribute: 'interaction-type', type: String}) interactionType: 'ticket' | 'chat' = 'chat'; @property({attribute: 'attachment-url', type: String}) uploadAttachmentUrl: string; /* Feature Permission Attributes */ @property({attribute: 'code-blocks', type: Boolean}) codeBlocksEnabled: boolean = false; @property({attribute: 'dividers', type: Boolean}) dividersEnabled: boolean = false; @property({attribute: 'links', type: Boolean}) linksEnabled: boolean = false; @property({attribute: 'attachments', type: Boolean}) attachmentsEnabled: boolean = false; @property({attribute: 'images', type: Boolean}) imagesEnabled: boolean = false; @property({attribute: 'videos', type: Boolean}) videosEnabled: boolean = false; @property({attribute: 'dates', type: Boolean}) datesEnabled: boolean = false; @property({attribute: 'emojis', type: Boolean}) emojisEnabled: boolean = false; @property({attribute: 'code', type: Boolean}) codeEnabled: boolean = false; @property({attribute: 'ai', type: Boolean}) aiEnabled: boolean = false; @property({attribute: 'ai-path'}) aiPath: string = ''; private quillElement: Quill; private _content: string = ''; private _selectionRange: Range = {index: 0, length: 0}; get validity(): ValidityState { return this.editorHtml.validity; } get validationMessage(): string { return this.editorHtml.validationMessage; } checkValidity(): boolean { return this.editorHtml.checkValidity(); } getForm(): HTMLFormElement | null { return this.formControlController.getForm(); } reportValidity(): boolean { return this.editorHtml.reportValidity(); } setCustomValidity(message: string): void { this.editorHtml.setCustomValidity(message); this.formControlController.updateValidity(); } protected firstUpdated(_changedProperties: PropertyValues) { this.formControlController.updateValidity(); const bindings = this._getQuillKeyboardBindings(); let checkId = this.id if (!checkId) { checkId = "abc" } on(document, 'click', `[editor-id="` + checkId + `"]`, (e: OnEvent) => { this._handleEditorChange(e); }); Quill.register({'modules/toolbar': Toolbar}, true); if (this.datesEnabled) { Quill.register({'modules/datePicker': DatePicker}, true); } if (this.emojisEnabled) { Quill.register({'modules/emoji': Emoji}, true); Quill.register({'modules/headlessEmoji': HeadlessEmoji}, true); } if (this.attachmentsEnabled) { Quill.register({'modules/attachment': Attachment}, true); } Quill.register({'modules/timeTracking': TimeTracking}, true); Quill.register({'modules/dragAndDrop': DragAndDrop}, true); Quill.register({'modules/imageResize': ImageResize}, true); Quill.register({'modules/contextMenu': ContextMenu}, true); Quill.register({'modules/dialog': Dialog}, true); /* AI Modules */ if (this.aiEnabled) { Quill.register('modules/ai', QuillAI as any, true); } // Register a custom HR blot so we can insert an inline horizontal rule block if (this.dividersEnabled) { const BlockEmbed = Quill.import('blots/block/embed') as { new(...args: any[]): any }; class HrBlot extends BlockEmbed { static blotName = 'hr'; static tagName = 'HR'; static className = 'ql-hr'; static create(): HTMLElement { const node = document.createElement('hr'); node.classList.add('ql-hr'); return node; } } Quill.register({'formats/hr': HrBlot}, true); } const startTimeInput = this.getForm()?.querySelector('input[name="startTime"]'); const openTimeInput = this.getForm()?.querySelector('input[name="openTime"]'); const featureConfig: EditorFeatureConfig = { codeBlocksEnabled: this.codeBlocksEnabled, dividersEnabled: this.dividersEnabled, linksEnabled: this.linksEnabled, attachmentsEnabled: this.attachmentsEnabled, imagesEnabled: this.imagesEnabled, videosEnabled: this.videosEnabled, datesEnabled: this.datesEnabled, emojisEnabled: this.emojisEnabled, codeEnabled: this.codeEnabled, }; const quill = new Quill(this.editor, { modules: { toolbar: { container: this.toolbar, config: featureConfig }, contextMenu: { config: featureConfig }, keyboard: { bindings: bindings }, dialog: { editorId: this.id }, ...(this.datesEnabled && { datePicker: {} }), ...(this.emojisEnabled && { emoji: {}, headlessEmoji: {}, }), ...(this.aiEnabled && { ai: { path: this.aiPath } }), timeTracking: { startTimeInput: startTimeInput as HTMLInputElement, openTimeInput: openTimeInput as HTMLInputElement }, ...(this.attachmentsEnabled && { attachment: { attachmentInput: this.getForm()?.querySelector('input[name="attachments"]'), onFileUploaded: () => { window.onbeforeunload = () => null; }, upload: (file: File) => { window.onbeforeunload = () => 'You have unsaved changes. Are you sure you want to leave?'; return new Promise((resolve) => { const fd = new FormData(); fd.append('filename', file.name); fd.append('size', file.size.toString()); fd.append('mimeType', file.type); const xhr = new XMLHttpRequest(); xhr.open('POST', this.uploadAttachmentUrl, true); xhr.onload = () => { if (xhr.status === 200) { const response = JSON.parse(xhr.responseText) as { uploadPath: string; uploadUrl: string; originalFilename: string; }; resolve({path: response.uploadPath, url: response.uploadUrl, filename: response.originalFilename}); } }; xhr.send(fd); }); }, }, }), imageResize: {}, history: {} }, placeholder: 'Compose your reply...', theme: 'snow', bounds: this.editor, }); this.quillElement = quill; // @ts-expect-error getSelection is available it lies. const hasShadowRootSelection = !!(document.createElement('div').attachShadow({mode: 'open'}).getSelection); // Each browser engine has a different implementation for retrieving the Range const getNativeRange = (rootNode: any): any => { try { if (hasShadowRootSelection) { // In Chromium, the shadow root has a getSelection function which returns the range // eslint-disable-next-line @typescript-eslint/no-unsafe-call,@typescript-eslint/no-unsafe-member-access return rootNode?.getSelection()?.getRangeAt(0); } else { const selection = window.getSelection(); if (selection && "getComposedRanges" in selection && typeof selection.getComposedRanges === 'function') { // Webkit range retrieval is done with getComposedRanges (see: https://bugs.webkit.org/show_bug.cgi?id=163921) return Array.isArray(selection.getComposedRanges(rootNode)) ? (selection.getComposedRanges(rootNode) as unknown[])[0] : undefined; } else { // Gecko implements the range API properly in Native Shadow: https://developer.mozilla.org/en-US/docs/Web/API/Selection/getRangeAt return selection?.getRangeAt(0); } } } catch { return null; } } /** * Original implementation uses document.active element which does not work in Native Shadow. * Replace document.activeElement with shadowRoot.activeElement **/ quill.selection.hasFocus = function () { const rootNode = quill.root.getRootNode() as Document | ShadowRoot; return rootNode.activeElement === quill.root; } /** * Original implementation uses document.getSelection which does not work in Native Shadow. * Replace document.getSelection with shadow dom equivalent (different for each browser) **/ quill.selection.getNativeRange = function () { const rootNode = quill.root.getRootNode(); // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const nativeRange = getNativeRange(rootNode); // eslint-disable-next-line @typescript-eslint/no-unsafe-argument return nativeRange ? quill.selection.normalizeNative(nativeRange) : null; }; /** * Original implementation relies on Selection.addRange to programmatically set the range, which does not work * in Webkit with Native Shadow. Selection.addRange works fine in Chromium and Gecko. **/ quill.selection.setNativeRange = function (startNode: Element, startOffset: any) { // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment,prefer-rest-params let endNode: any = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : startNode; // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment,prefer-rest-params let endOffset: any = arguments.length > 3 && arguments[3] !== undefined ? arguments[3] : startOffset; // eslint-disable-next-line prefer-rest-params,@typescript-eslint/no-unsafe-assignment const force: any = arguments.length > 4 && arguments[4] !== undefined ? arguments[4] : false; // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access if (startNode !== null && (quill.selection.root.parentNode === null || startNode.parentNode === null || endNode.parentNode === null)) { return; } const selection = document.getSelection(); if (selection === null) return; if (startNode !== null) { if (!quill.selection.hasFocus()) quill.selection.root.focus(); const native = quill.selection.getNativeRange()?.native; // @ts-ignore if (native === null || force || startNode !== native?.startContainer || startOffset !== native.startOffset || endNode !== native.endContainer || endOffset !== native.endOffset) { if (startNode.tagName === "BR") { // @ts-ignore startOffset = [].indexOf.call(startNode.parentNode.childNodes, startNode); startNode = startNode.parentNode as Element; } // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access if (endNode.tagName === "BR") { // @ts-ignore // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access,@typescript-eslint/no-unsafe-argument endOffset = [].indexOf.call(endNode.parentNode.childNodes, endNode); // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment,@typescript-eslint/no-unsafe-member-access endNode = endNode.parentNode; } // eslint-disable-next-line @typescript-eslint/no-unsafe-argument selection.setBaseAndExtent(startNode, startOffset, endNode, endOffset); } } else { selection.removeAllRanges(); quill.selection.root.blur(); document.body.focus(); } } /** * Subscribe to selection change separately, because emitter in Quill doesn't catch this event in Shadow DOM **/ document.addEventListener('selectionchange', () => { this.quillElement.selection.update(); }); document.addEventListener('zn-editor-update', this._handleTextChange.bind(this)); quill.on('text-change', this._handleTextChange.bind(this)); // Track selection range inside the editor so other modules can access it quill.on(Quill.events.SELECTION_CHANGE, (range: Range, oldRange: Range) => { this._selectionRange = range ?? oldRange; }); const delta = quill.clipboard.convert({html: this.value}); quill.setContents(delta, Quill.sources.SILENT); this.emit('zn-element-added', {detail: {element: this.editor}}); super.firstUpdated(_changedProperties); } private _handleTextChange() { this.value = this.quillElement.getSemanticHTML(); this.editorHtml.value = this.value; this.emit('zn-change'); } private _getQuillKeyboardBindings() { const empty = (value: string) => { const match = value.match(/[^/]/); return match === null; }; // Always add an Enter binding to support emoji selection in all interaction types return { 'enter': { key: 'Enter', shiftKey: false, handler: () => { const emoji = this.quillElement.getModule('headlessEmoji') as HeadlessEmoji; const menu = this.quillElement.getModule('contextMenu') as ContextMenu; const dialog = this.quillElement.getModule('dialog') as Dialog; if (emoji?.isOpen() || menu?.isOpen() || dialog?.isOpen()) { return false; } if (this.interactionType === 'chat') { const form = this.closest('form'); if (form && this.value && this.value.trim().length > 0 && !empty(this.value)) { this.emit('zn-submit', {detail: {value: this.value, element: this}}); form.requestSubmit(); this.quillElement.setText(''); // prevent default Enter (submit completed) return false; } // If we didn't submit, allow default so Enter inserts newline return true; } // For non-chat interactions, allow default behavior (newline) return true; }, } }; } private _handleEditorChange(e: OnEvent) { const target = e.selectedTarget as HTMLElement; if (!target.hasAttribute('editor-mode')) return; const editorMode = target.getAttribute('editor-mode'); if (!editorMode) return; const contentContainer = target.getAttribute('editor-content-id'); if (!contentContainer) return; // Find which element contains the content to insert // Priority: currentTarget > selectedTarget > target > composedPath > querySelector const contentElement: Element | null = (e.currentTarget instanceof Element && e.currentTarget.closest('#' + contentContainer)) || (e.selectedTarget instanceof Element && e.selectedTarget.closest('#' + contentContainer)) || (e.target instanceof Element && e.target.closest('#' + contentContainer)) || ((e.composedPath()[0] as Element)?.closest('#' + contentContainer)) || deepQuerySelectorAll(`#${contentContainer}`, document.documentElement, '')[0]; if (!contentElement) return; let content = contentElement.textContent; if (contentElement instanceof HTMLInputElement || contentElement instanceof ZnTextarea) { content = contentElement.value; } if (!content || content === '') return; this._content = trim(content); if (editorMode === 'replace') { this._replaceTextAtSelection(); } else if (editorMode === 'insert') { this._insertTextAtSelection(); } } private _replaceTextAtSelection() { const range = this.quillElement.getSelection(); if (range) { if (range.length === 0) { this.quillElement.setText(this._content || ''); this.quillElement.setSelection((this._content?.length || 0), 0); } else { this.quillElement.deleteText(range.index, range.length); this.quillElement.insertText(range.index, this._content || ''); this.quillElement.setSelection(range.index + (this._content.length || 0), 0); } } else { this.quillElement.setText(this._content || ''); this.quillElement.setSelection((this._content?.length || 0), 0); } this._closePopups(); } private _insertTextAtSelection() { const range = this.quillElement.getSelection(); const index = range ? range.index : this._selectionRange.index; this.quillElement.insertText(index, this._content || ''); this.quillElement.setSelection(index + (this._content.length || 0), 0); this._closePopups(); } private _closePopups() { this._closeAiPanel(); this._closeDialog(); } private _closeAiPanel() { const aiModule = this.quillElement.getModule('ai') as QuillAI; if (aiModule) { aiModule.resetComponent(); } } private _closeDialog() { const dialogModule = this.quillElement.getModule('dialog') as Dialog; if (dialogModule) { dialogModule.close(); } } render() { return html`
`; } }