import './context-menu-component'; import {html} from "lit"; import {litToHTML} from "../../../../utilities/lit-to-html"; import {type ResultItem} from "./context-menu-component"; import Quill, {Delta} from "quill"; import ZnEditorQuickAction from "./quick-action"; import type {EditorFeatureConfig} from "../../editor.component"; import type ContextMenuComponent from "./context-menu-component"; import type Toolbar from "../toolbar/toolbar"; class ContextMenu { private _quill: Quill; private readonly _toolbarModule: Toolbar; private _component: ContextMenuComponent; private _startIndex = -1; private _keydownHandler = (e: KeyboardEvent) => this.onKeydown(e); private _docClickHandler = (e: MouseEvent) => this.onDocumentClick(e); private _featureConfig: EditorFeatureConfig = {}; constructor(quill: Quill, options: { config: EditorFeatureConfig }) { this._quill = quill; this._toolbarModule = quill.getModule('toolbar') as Toolbar; this._featureConfig = options.config || {}; this.initComponent(); this.attachEvents(); } private initComponent() { this._component = this.createComponent()!; this._quill.container.ownerDocument.body.appendChild(this._component); } private attachEvents() { this._quill.on(Quill.events.TEXT_CHANGE, () => this.updateFromEditor()); this._quill.on(Quill.events.EDITOR_CHANGE, () => this.updateFromEditor()); this._quill.root.addEventListener('keydown', this._keydownHandler); this._component.addEventListener('zn-format-select', (e: Event) => this.onToolbarSelect(e as CustomEvent)); this._quill.on('editor-change', () => this.positionComponent()); this._quill.focus(); } private createComponent() { const tpl = html` `; return litToHTML(tpl); } private onDocumentClick(e: MouseEvent) { const target = e.composedPath ? e.composedPath()[0] as Node : (e.target as Node); if (!target) return; if (!this._component.contains(target) && !this._quill.root.contains(target)) { this.hide(); } } private updateFromEditor() { const info = this.getToolbarQuery(); if (!info) { this.hide(); return; } const {start, formatQuery} = info; this._startIndex = start; try { const q = formatQuery.toLowerCase(); this._component.results = this._getOptions().filter(it => !q || it.label.toLowerCase().includes(q) || it?.format?.toLowerCase().includes(q)); } catch { this._component.results = []; } this._component.query = formatQuery; this.show(); this.positionComponent(); } private positionComponent() { if (!this._component || !this._component.open) return; const range = this._quill.getSelection(); if (!range) return; const bounds = this._quill.getBounds(range.index); if (!bounds) return; const editorBounds = this._quill.container.getBoundingClientRect(); const left = Math.max(0, editorBounds.left + bounds.left); const top = editorBounds.top + bounds.bottom + 4; this._component.setPosition(left, top); } private getToolbarQuery(): { start: number; formatQuery: string } | null { try { const range = this._quill.getSelection(); if (!range) return null; const cursor = range.index; const characterLimit = 50; const textBefore = this._quill.getText(Math.max(0, cursor - characterLimit), Math.min(characterLimit, cursor)); const offset = cursor - Math.max(0, cursor - characterLimit); const uptoCursor = textBefore.slice(0, offset); const cIndex = uptoCursor.lastIndexOf('/'); if (cIndex === -1) return null; const prev = cIndex > 0 ? uptoCursor[cIndex - 1] : ' '; if (prev && /[^\s\n]/.test(prev)) return null; // must start at word boundary const formatQuery = uptoCursor.substring(cIndex + 1); if (/[\s\n]/.test(formatQuery)) return null; // stop at whitespace return {start: cursor - formatQuery.length - 1, formatQuery}; } catch { return null; } } private onKeydown(e: KeyboardEvent) { if (!this._component?.open) return; if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); this.hide(); return; } if (e.key === 'ArrowDown') { e.preventDefault(); e.stopPropagation(); const next = (this._component.getActiveIndex?.() ?? -1) + 1; this._component.setActiveIndex?.(next); return; } if (e.key === 'ArrowUp') { e.preventDefault(); e.stopPropagation(); const prev = (this._component.getActiveIndex?.() ?? 0) - 1; this._component.setActiveIndex?.(prev); return; } if (e.key === 'Enter') { const idx = this._component.getActiveIndex?.(); const results = (this._component.results as ResultItem[]) || []; const item = (typeof idx === 'number' && idx >= 0 && idx < results.length) ? results[idx] : undefined; if (item?.format === 'toolbar' && item?.key) { e.preventDefault(); e.stopPropagation(); this._clickToolbarItem(item.key); return; } if (item?.format) { e.preventDefault(); e.stopPropagation(); this._applySelectedFormat(item.format, item.value); } } } private onToolbarSelect(e: CustomEvent) { const key: string | undefined = e.detail?.key as (string | undefined); if (key) { this._clickToolbarItem(key); return; } const format: string = e.detail?.format || ''; const value: string | boolean | undefined = e.detail?.value as (string | boolean | undefined); if (!format) return; this._applySelectedFormat(format, value); } private _clickToolbarItem(key: string) { this.deleteLastIndex(); this._toolbarModule.trigger?.(key); this.hide(); } private _applySelectedFormat(key: string, value?: string | boolean) { this.deleteLastIndex(); if (key === 'insert' && typeof value === 'string') { const range = this._quill.getSelection(); if (range) { let insertIndex = range.index - 1; this._quill.deleteText(insertIndex, 1, Quill.sources.USER); const prevChar = this._quill.getText(insertIndex - 1, 1); if (prevChar !== ' ' && insertIndex > 0) { this._quill.insertText(insertIndex, ' ', Quill.sources.USER); insertIndex += 1; } const contentDelta = this._quill.clipboard.convert({html: value}); this._quill.updateContents( new Delta().retain(insertIndex).concat(contentDelta), Quill.sources.USER ); setTimeout(() => this._quill.setSelection(insertIndex + contentDelta.length() + 1, 0, Quill.sources.SILENT), 0); } this._quill.focus(); this.hide(); return; } this._toolbarModule.callFormat(key, value); this._quill.focus(); this.hide(); } private deleteLastIndex() { const sel = this._quill.getSelection(); if (sel && this._startIndex >= 0) { const insertIndex = this._startIndex; const length = sel.index - insertIndex; if (length >= 0) { this._quill.deleteText(insertIndex, length, Quill.sources.USER); this._quill.setSelection(insertIndex, 0, Quill.sources.SILENT); } } } private _getOptions(): ResultItem[] { const options: ResultItem[] = []; let orderCounter = 0; // 1) Quick Actions const root = this._quill.container.getRootNode() as ShadowRoot; if (root?.host) { const slot = root.querySelector('slot[name="context-items"]') as HTMLSlotElement | null; const assigned = slot ? slot.assignedElements({flatten: true}) : []; assigned.forEach((quickAction: Element) => { if (!(quickAction instanceof ZnEditorQuickAction)) return; const {label, content, uri, icon, key} = quickAction; let order: number; if (typeof quickAction.order === 'number') { order = quickAction.order!; } else { order = orderCounter++; } if (label && icon) { if (key) { options.push({icon, label, format: 'toolbar', key: key, order}); } else if (uri) { options.push({icon, label, format: 'dialog', value: uri, order}); } else if (content) { options.push({icon, label, format: 'insert', value: content, order}); } } }); } // 2) Built-in Actions (In order they should appear) options.push( {icon: 'format_bold', label: 'Bold', format: 'bold', order: orderCounter++}, {icon: 'format_italic', label: 'Italic', format: 'italic', order: orderCounter++}, {icon: 'format_underlined', label: 'Underline', format: 'underline', order: orderCounter++}, {icon: 'strikethrough_s', label: 'Strikethrough', format: 'strike', order: orderCounter++}, {icon: 'format_quote', label: 'Blockquote', format: 'blockquote', order: orderCounter++}, ); if (this._featureConfig.codeEnabled !== false) { options.push({icon: 'code', label: 'Inline Code', format: 'code', order: orderCounter++}); } if (this._featureConfig.codeBlocksEnabled !== false) { options.push({icon: 'code_blocks', label: 'Code Block', format: 'code-block', order: orderCounter++}); } options.push( {icon: 'format_h1', label: 'Heading 1', format: 'header', value: '1', order: orderCounter++}, {icon: 'format_h2', label: 'Heading 2', format: 'header', value: '2', order: orderCounter++}, {icon: 'match_case', label: 'Normal Text', format: 'header', value: '', order: orderCounter++}, {icon: 'format_list_bulleted', label: 'Bulleted List', format: 'list', value: 'bullet', order: orderCounter++}, {icon: 'format_list_numbered', label: 'Numbered List', format: 'list', value: 'ordered', order: orderCounter++}, {icon: 'checklist', label: 'Checklist', format: 'list', value: 'checked', order: orderCounter++} ); if (this._featureConfig.linksEnabled !== false) { options.push({icon: 'link', label: 'Link', format: 'link', value: true, order: orderCounter++}); } if (this._featureConfig.dividersEnabled !== false) { options.push({icon: 'horizontal_rule', label: 'Divider', format: 'divider', order: orderCounter++}); } if (this._featureConfig.attachmentsEnabled !== false) { options.push({icon: 'attachment', label: 'Attachment', format: 'attachment', order: orderCounter++}); } if (this._featureConfig.imagesEnabled !== false) { options.push({icon: 'image', label: 'Image', format: 'image', order: orderCounter++}); } if (this._featureConfig.videosEnabled !== false) { options.push({icon: 'video_camera_back', label: 'Video', format: 'video', order: orderCounter++}); } if (this._featureConfig.datesEnabled !== false) { options.push({icon: 'calendar_today', label: 'Date', format: 'date', order: orderCounter++}); } options.push({icon: 'format_clear', label: 'Clear Formatting', format: 'clean', order: orderCounter++}); // Sort options by order property options.sort((a, b) => (a.order ?? 0) - (b.order ?? 0)); return options; } private show() { if (!this._component.open) { this._component.show(); this._quill.container.ownerDocument.addEventListener('click', this._docClickHandler); } } private hide() { this._component.hide(); this._startIndex = -1; this._quill.container.ownerDocument.removeEventListener('click', this._docClickHandler); } public isOpen() { return this._component?.open; } } export default ContextMenu;