import { Component, ElementRef, EventEmitter, Inject, Input, NgZone, Output, PLATFORM_ID, Renderer2, ViewChild, AfterViewInit, forwardRef, ChangeDetectorRef } from '@angular/core'; import { ControlValueAccessor, Validator, AbstractControl, ValidationErrors, NG_VALUE_ACCESSOR } from '@angular/forms'; import { EditorComponent, NodeSpec, schema, Schema, FontSizeItem } from '@progress/kendo-angular-editor'; import { MsgBoxService } from '../../../services/msg-box.service'; import { DropDownOption } from '../../../entity/dropDownOption'; import { MD2Icon } from '../massive-darkness2.model'; import { first } from 'rxjs/operators'; import { MD2StateService } from '../../../services/MD2/md2-state.service'; import { MD2IconPickerDlgComponent } from './md2-icon-picker-dlg.component'; import { NbDialogService } from '@nebular/theme'; import { DOMParser as ProseMirrorDOMParser } from 'prosemirror-model'; import { DialogService } from '@progress/kendo-angular-dialog'; @Component({ selector: 'md2-html-editor', templateUrl: './md2-html-editor.component.html', styleUrl: './md2-html-editor.component.scss', providers: [ { provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => MD2HtmlEditorComponent), multi: true } ] }) export class MD2HtmlEditorComponent implements ControlValueAccessor, AfterViewInit { @ViewChild(EditorComponent) editor: EditorComponent; @ViewChild('fontSizeDropdown') fontSizeDropdown: any; value: string = ''; disabled: boolean = false; messageTemplateSchema = this.createCustomSchema(); customCssClass = '.MD2Icon{font-family: "Massive Darkness 2", sans-serif !important; font-size: 40px; margin-left:5px} body{font-size: 30px; }'; // Default font size: 30px fontSizeData: FontSizeItem[] = [ { size: 30, text: '30px' }, { size: 8, text: '8px' }, { size: 10, text: '10px' }, { size: 12, text: '12px' }, { size: 14, text: '14px' }, { size: 16, text: '16px' }, { size: 18, text: '18px' }, { size: 20, text: '20px' }, { size: 24, text: '24px' }, { size: 30, text: '30px' }, { size: 36, text: '36px' }, { size: 48, text: '48px' }, { size: 60, text: '60px' }, { size: 72, text: '72px' } ]; defaultFontSize: FontSizeItem = { size: 30, text: '30px' }; // ControlValueAccessor interface private onChangeFn = (value: string) => { }; private onTouchedFn = () => { }; constructor( private msgBoxService: MsgBoxService, private md2StateService: MD2StateService, private dialogService: DialogService, private cdr: ChangeDetectorRef, elementRef: ElementRef, ngZone: NgZone, @Inject(PLATFORM_ID) platformId: Object) { } ngAfterViewInit(): void { // Set default font size after view initialization // The fontSizeDropdown is the EditorFontSizeComponent instance setTimeout(() => { if (this.fontSizeDropdown) { // Access the fontSizeDropDownList component which has the defaultItem property if (this.fontSizeDropdown.fontSizeDropDownList) { this.fontSizeDropdown.fontSizeDropDownList.defaultItem = this.defaultFontSize; } // Also try setting it directly on the component if it has the property if (this.fontSizeDropdown.defaultItem !== undefined) { this.fontSizeDropdown.defaultItem = this.defaultFontSize; } } // Ensure the editor value is set if writeValue was called before view init if (this.editor && this.value && this.editor.value !== this.value) { this.editor.value = this.value; } }, 0); } // ControlValueAccessor implementation writeValue(value: string | null | undefined): void { const newValue = value || ''; // Only update if the value actually changed to avoid unnecessary updates if (this.value !== newValue) { this.value = newValue; // Angular's [value] binding will handle updating the editor // But if editor is already initialized and value is out of sync, ensure sync if (this.editor && this.editor.value !== this.value) { // Use setTimeout to avoid ExpressionChangedAfterItHasBeenCheckedError setTimeout(() => { if (this.editor && this.editor.value !== this.value) { this.editor.value = this.value; } }, 0); } // Trigger change detection to ensure the binding updates if (!this.cdr['destroyed']) { this.cdr.markForCheck(); } } } registerOnChange(fn: (value: string) => void): void { this.onChangeFn = fn; } registerOnTouched(fn: () => void): void { this.onTouchedFn = fn; } setDisabledState(isDisabled: boolean): void { this.disabled = isDisabled; } onChange(value: string): void { // Only update if value actually changed to prevent infinite loops if (this.value !== value) { this.value = value || ''; this.onChangeFn(this.value); } } onTouched(): void { this.onTouchedFn(); } showInsertMD2Icon() { this.dialogService.open({ title: 'Insert MD2 Icon', content: MD2IconPickerDlgComponent, width: '800px', height: 600 }).result.subscribe((html: string) => { if (html && this.editor) { this.insertAfterSelection(html, true); return; // Insert the HTML content at the current cursor position // Use ProseMirror's dispatch method to insert content at cursor const view = this.editor.view; if (view && view.state) { try { // Parse the HTML content const dom = document.createElement('div'); dom.innerHTML = html; // Use ProseMirror's DOMParser to parse HTML // Access it from the view's state const pmState = view.state; // Use the editor's exec method or manual dispatch if ((this.editor as any).exec) { // Try using exec with 'insertHTML' command if available try { (this.editor as any).exec('insertHTML', html); } catch (e) { throw new Error('insertHTML not supported'); } } else { throw new Error('exec method not available'); } } catch (e) { console.error('Error inserting HTML:', e); // Fallback: append to the end const currentValue = this.editor.value || ''; const newValue = currentValue + ' ' + html; this.value = newValue; this.onChange(newValue); } } else { // Fallback: append to the end const currentValue = this.editor.value || ''; const newValue = currentValue + ' ' + html; this.value = newValue; this.onChange(newValue); } } }); } // Text manipulation methods for Kendo Editor /** * Parses HTML string to ProseMirror nodes * @param htmlContent - HTML string to parse * @returns ProseMirror Fragment */ private parseHtmlContent(htmlContent: string): any { if (!this.editor || !this.editor.view) return null; const view = this.editor.view; const element = document.createElement('div'); element.innerHTML = htmlContent; // Use ProseMirror's DOMParser to parse HTML into nodes const parser = ProseMirrorDOMParser.fromSchema(view.state.schema); const slice = parser.parseSlice(element); return slice.content; } /** * Inserts HTML content at the beginning of the editor * @param content - HTML string to insert * @param asHtml - If true, parses as HTML; if false, inserts as plain text */ public insertAtBeginning(content: string, asHtml: boolean = false): void { if (!this.editor || !this.editor.view) return; const view = this.editor.view; const contentNode = asHtml ? this.parseHtmlContent(content) : view.state.schema.text(content); if (contentNode) { const transaction = view.state.tr.insert(0, contentNode); view.dispatch(transaction); this.onChange(this.editor.value); } } /** * Inserts HTML content at the end of the editor * @param content - HTML string to insert * @param asHtml - If true, parses as HTML; if false, inserts as plain text */ public insertAtEnd(content: string, asHtml: boolean = false): void { if (!this.editor || !this.editor.view) return; const view = this.editor.view; const endPos = view.state.doc.content.size; const contentNode = asHtml ? this.parseHtmlContent(content) : view.state.schema.text(content); if (contentNode) { const transaction = view.state.tr.insert(endPos, contentNode); view.dispatch(transaction); this.onChange(this.editor.value); } } /** * Inserts HTML content before the current selection * @param content - HTML string to insert * @param asHtml - If true, parses as HTML; if false, inserts as plain text */ public insertBeforeSelection(content: string, asHtml: boolean = false): void { if (!this.editor || !this.editor.view) return; const view = this.editor.view; const { from } = view.state.selection; const contentNode = asHtml ? this.parseHtmlContent(content) : view.state.schema.text(content); if (contentNode) { const transaction = view.state.tr.insert(from, contentNode); view.dispatch(transaction); this.onChange(this.editor.value); } } /** * Inserts HTML content after the current selection * @param content - HTML string to insert * @param asHtml - If true, parses as HTML; if false, inserts as plain text */ public insertAfterSelection(content: string, asHtml: boolean = false): void { if (!this.editor || !this.editor.view) return; const view = this.editor.view; const { to } = view.state.selection; const contentNode = asHtml ? this.parseHtmlContent(content) : view.state.schema.text(content); if (contentNode) { const transaction = view.state.tr.insert(to, contentNode); view.dispatch(transaction); this.onChange(this.editor.value); } } /** * Replaces the currently selected text with new content * @param content - HTML string to replace selection with * @param asHtml - If true, parses as HTML; if false, inserts as plain text */ public replaceSelectedText(content: string, asHtml: boolean = false): void { if (!this.editor || !this.editor.view) return; const view = this.editor.view; const { from, to } = view.state.selection; const contentNode = asHtml ? this.parseHtmlContent(content) : view.state.schema.text(content); if (!contentNode) return; // If there's no selection, just insert at cursor position if (from === to) { const transaction = view.state.tr.insert(from, contentNode); view.dispatch(transaction); } else { // Replace the selected content const transaction = view.state.tr.replaceWith(from, to, contentNode); view.dispatch(transaction); } this.onChange(this.editor.value); } /** * Gets the currently selected text in the editor * @returns The selected text as a string */ public getSelectedText(): string { if (!this.editor) return ''; return this.editor.selectionText || ''; } public getSelectionTextOrWholeText(): string { if (!this.editor) return ''; // If there's selected text, return it if (this.editor.selectionText && this.editor.selectionText.trim().length > 0) { return this.editor.selectionText; } // Otherwise, get the whole text content from the editor's document if (this.editor.view && this.editor.view.state) { return this.editor.view.state.doc.textContent; } // Fallback: strip HTML tags and return plain text const div = document.createElement('div'); div.innerHTML = this.editor.value; return div.textContent || div.innerText || ''; } /** * Gets the current cursor position or selection range * @returns Object with 'from' and 'to' positions */ public getSelectionRange(): { from: number; to: number } | null { if (!this.editor || !this.editor.view) return null; const { from, to } = this.editor.view.state.selection; return { from, to }; } /** * Inserts HTML content at a specific position * @param content - HTML string to insert * @param position - Position to insert at (0 = beginning) * @param asHtml - If true, parses as HTML; if false, inserts as plain text */ public insertAtPosition(content: string, position: number, asHtml: boolean = false): void { if (!this.editor || !this.editor.view) return; const view = this.editor.view; const maxPos = view.state.doc.content.size; const safePos = Math.min(Math.max(0, position), maxPos); const contentNode = asHtml ? this.parseHtmlContent(content) : view.state.schema.text(content); if (contentNode) { const transaction = view.state.tr.insert(safePos, contentNode); view.dispatch(transaction); this.onChange(this.editor.value); } } /** * Replaces text in a specific range * @param content - HTML string to insert * @param from - Start position * @param to - End position * @param asHtml - If true, parses as HTML; if false, inserts as plain text */ public replaceRange(content: string, from: number, to: number, asHtml: boolean = false): void { if (!this.editor || !this.editor.view) return; const view = this.editor.view; const maxPos = view.state.doc.content.size; const safeFrom = Math.min(Math.max(0, from), maxPos); const safeTo = Math.min(Math.max(safeFrom, to), maxPos); const contentNode = asHtml ? this.parseHtmlContent(content) : view.state.schema.text(content); if (contentNode) { const transaction = view.state.tr.replaceWith(safeFrom, safeTo, contentNode); view.dispatch(transaction); this.onChange(this.editor.value); } } // showVariablePicker() { // this.easyEditorService.openTableMultiPicker(this.variables, this.variableTableSettings, "Please select a variable").pipe(first()).subscribe(result => { // if (result) { // result.forEach(c => { // this.insertAfterSelection(`<${RbjTagNode} class="rbj-tag" ${RbjTagIdAttribute}="${c.name}" ${RbjTagValueAttribute}="${c.name}">${c.name}`, true); // }); // } // }); // } // Private methods private createCustomSchema(): Schema { let nodes = schema.spec.nodes.addBefore("div", "rbjTag", rbjTagNodeSpec); let marks = schema.spec.marks; //marks = marks.addToStart("rbjSpanTag", rbjTagMarkSpec); return new Schema({ nodes: nodes, marks: marks }); } } // Define the custom node specification for the rbj-tag element. export const rbjTagNodeSpec: NodeSpec = { // Define the node attributes for the tag attrs: { "md2-icon": { default: "" }, "class": { default: "" }, // "tag-value": { default: "" }, // "tag-preview": { default: "" } }, // Specify that this node should be treated as an inline element inline: true, // Allow the node to be part of inline content group: "inline", // Make it atomic (non-editable, treated as a single unit) atom: true, // Define how the node should be rendered in the DOM toDOM: (node) => { let md2IconText = node.attrs["md2-icon"] as string; let classValue = node.attrs["class"] as string; if (classValue.includes('dice')) { md2IconText = ''; } // let displayValue = tagPreview == 'true' ? node.attrs["tag-value"] : node.attrs["rbj-tag-id"]; return [ "span", { class: classValue, // "rbj-tag-id": node.attrs["rbj-tag-id"], // "tag-marker": node.attrs["tag-marker"], // "tag-value": node.attrs["tag-value"], // "tag-preview": node.attrs["tag-preview"], contenteditable: "false", //spellcheck: "false", style="font-size: 36px;" style: "display: inline;" }, //node.attrs["tag-marker"] + node.attrs["tag-value"] // Display the tag content directly md2IconText ]; }, // Define how to parse the node from existing DOM elements parseDOM: [ { // Look for span elements with class rbj-tag (higher priority) tag: "span[md2-icon]", priority: 51, // Higher priority to catch before other parsers // Extract attributes from the DOM element getAttrs: (dom) => { const element = dom as HTMLElement; // Must have rbj-tag-id attribute to be valid if (!element.hasAttribute("md2-icon")) { return false; } return { "md2-icon": element.getAttribute("md2-icon") || "", "class": element.className || "", // "tag-preview": element.getAttribute("tag-preview") || "false", // "tag-value": element.getAttribute("tag-value") || element.getAttribute("rbj-tag-id") }; } } ] };