485 lines
17 KiB
TypeScript
485 lines
17 KiB
TypeScript
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}</${RbjTagNode}>`, 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")
|
|
};
|
|
}
|
|
}
|
|
]
|
|
}; |