UNPKG

@kolkov/angular-editor

Version:

A simple native WYSIWYG editor for Angular 20+. Rich Text editor component for Angular.

1,067 lines (1,060 loc) 96.8 kB
import * as i0 from '@angular/core'; import { DOCUMENT, Inject, Injectable, EventEmitter, forwardRef, HostListener, ViewChild, Output, HostBinding, Input, Component, SecurityContext, ContentChild, Attribute, ViewEncapsulation, NgModule } from '@angular/core'; import * as i1 from '@angular/common/http'; import * as i3 from '@angular/forms'; import { NG_VALUE_ACCESSOR, FormsModule, ReactiveFormsModule } from '@angular/forms'; import * as i2 from '@angular/platform-browser'; import * as i1$1 from '@angular/common'; import { CommonModule } from '@angular/common'; class AngularEditorService { http; doc; savedSelection; selectedText; uploadUrl; uploadWithCredentials; constructor(http, doc) { this.http = http; this.doc = doc; } /** * Executed command from editor header buttons exclude toggleEditorMode * @param command string from triggerCommand * @param value */ executeCommand(command, value) { const commands = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'p', 'pre']; if (commands.includes(command)) { this.doc.execCommand('formatBlock', false, command); return; } this.doc.execCommand(command, false, value); } /** * Create URL link * @param url string from UI prompt */ createLink(url) { if (!url.includes('http')) { this.doc.execCommand('createlink', false, url); } else { const newUrl = '<a href="' + url + '" target="_blank">' + this.selectedText + '</a>'; this.insertHtml(newUrl); } } /** * insert color either font or background * * @param color color to be inserted * @param where where the color has to be inserted either text/background */ insertColor(color, where) { const restored = this.restoreSelection(); if (restored) { if (where === 'textColor') { this.doc.execCommand('foreColor', false, color); } else { this.doc.execCommand('hiliteColor', false, color); } } } /** * Set font name * @param fontName string */ setFontName(fontName) { this.doc.execCommand('fontName', false, fontName); } /** * Set font size * @param fontSize string */ setFontSize(fontSize) { this.doc.execCommand('fontSize', false, fontSize); } /** * Create raw HTML * @param html HTML string */ insertHtml(html) { const isHTMLInserted = this.doc.execCommand('insertHTML', false, html); if (!isHTMLInserted) { throw new Error('Unable to perform the operation'); } } /** * save selection when the editor is focussed out */ saveSelection = () => { if (this.doc.getSelection) { const sel = this.doc.getSelection(); if (sel.getRangeAt && sel.rangeCount) { this.savedSelection = sel.getRangeAt(0); this.selectedText = sel.toString(); } } else if (this.doc.getSelection && this.doc.createRange) { this.savedSelection = document.createRange(); } else { this.savedSelection = null; } }; /** * restore selection when the editor is focused in * * saved selection when the editor is focused out */ restoreSelection() { if (this.savedSelection) { if (this.doc.getSelection) { const sel = this.doc.getSelection(); sel.removeAllRanges(); sel.addRange(this.savedSelection); return true; } else if (this.doc.getSelection /*&& this.savedSelection.select*/) { // this.savedSelection.select(); return true; } } else { return false; } } /** * setTimeout used for execute 'saveSelection' method in next event loop iteration */ executeInNextQueueIteration(callbackFn, timeout = 1e2) { setTimeout(callbackFn, timeout); } /** check any selection is made or not */ checkSelection() { const selectedText = this.savedSelection.toString(); if (selectedText.length === 0) { throw new Error('No Selection Made'); } return true; } /** * Upload file to uploadUrl * @param file The file */ uploadImage(file) { const uploadData = new FormData(); uploadData.append('file', file, file.name); return this.http.post(this.uploadUrl, uploadData, { reportProgress: true, observe: 'events', withCredentials: this.uploadWithCredentials, }); } /** * Insert image with Url * @param imageUrl The imageUrl. */ insertImage(imageUrl) { this.doc.execCommand('insertImage', false, imageUrl); } setDefaultParagraphSeparator(separator) { this.doc.execCommand('defaultParagraphSeparator', false, separator); } /** * Apply custom class to selection with enterprise-level HTML structure preservation. * Supports three modes: * - 'inline': Wrap selection in a single element (legacy behavior) * - 'block': Apply class to each block element in selection * - 'auto': Smart detection based on selection span (default) * * @param customClass The custom class configuration */ createCustomClass(customClass) { if (!customClass || !this.savedSelection) { return; } const mode = customClass.mode || 'auto'; const range = this.savedSelection; // Restore selection before applying this.restoreSelection(); if (mode === 'inline') { this.applyClassInline(range, customClass); } else if (mode === 'block') { this.applyClassToBlocks(range, customClass); } else { // Auto mode: detect if selection spans multiple blocks const blocks = this.getBlockElementsInRange(range); if (blocks.length > 1) { this.applyClassToBlocks(range, customClass); } else { this.applyClassInline(range, customClass); } } } /** * Apply class inline by wrapping selection in a single element. * Uses extractContents + insertNode pattern for safety. */ applyClassInline(range, customClass) { const tagName = customClass.tag || 'span'; try { // Create wrapper element const wrapper = this.doc.createElement(tagName); wrapper.className = customClass.class; // Extract contents and wrap (safer than surroundContents) const contents = range.extractContents(); wrapper.appendChild(contents); range.insertNode(wrapper); // Normalize to merge adjacent text nodes if (wrapper.parentNode) { wrapper.parentNode.normalize(); } // Update selection to the new wrapper range.selectNodeContents(wrapper); const sel = this.doc.getSelection(); sel.removeAllRanges(); sel.addRange(range); } catch (e) { // Fallback to legacy method if DOM manipulation fails console.warn('applyClassInline failed, using fallback:', e); this.applyClassInlineFallback(customClass); } } /** * Fallback method for inline class application (legacy behavior). */ applyClassInlineFallback(customClass) { const tagName = customClass.tag || 'span'; const newTag = '<' + tagName + ' class="' + customClass.class + '">' + this.selectedText + '</' + tagName + '>'; this.insertHtml(newTag); } /** * Apply class to each block element in selection. * Preserves HTML structure by adding class to existing elements. */ applyClassToBlocks(range, customClass) { const blocks = this.getBlockElementsInRange(range); if (blocks.length === 0) { // No blocks found, fall back to inline this.applyClassInline(range, customClass); return; } // Apply class to each block element blocks.forEach(block => { // Toggle class: remove if present, add if not if (block.classList.contains(customClass.class)) { block.classList.remove(customClass.class); } else { block.classList.add(customClass.class); } }); // Trigger change detection by moving cursor const sel = this.doc.getSelection(); if (sel && blocks.length > 0) { // Reselect the original range sel.removeAllRanges(); sel.addRange(range); } } /** * Get all block-level elements within a range. * Returns elements that are fully or partially selected. */ getBlockElementsInRange(range) { const blockTags = [ 'P', 'DIV', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'LI', 'BLOCKQUOTE', 'PRE', 'ADDRESS', 'ARTICLE', 'ASIDE', 'FIGCAPTION', 'FIGURE', 'FOOTER', 'HEADER', 'MAIN', 'NAV', 'SECTION' ]; const blocks = []; const seen = new Set(); // Get the common ancestor const container = range.commonAncestorContainer; const root = container.nodeType === Node.ELEMENT_NODE ? container : container.parentElement; if (!root) { return blocks; } // If root itself is a block element and fully contains the range if (blockTags.includes(root.tagName) && this.isNodeFullyInRange(root, range)) { return [root]; } // Find all block elements within the range using TreeWalker const walker = this.doc.createTreeWalker(root, NodeFilter.SHOW_ELEMENT, { acceptNode: (node) => { if (!blockTags.includes(node.tagName)) { return NodeFilter.FILTER_SKIP; } // Check if node intersects with selection if (range.intersectsNode(node)) { return NodeFilter.FILTER_ACCEPT; } return NodeFilter.FILTER_SKIP; } }); let node; while ((node = walker.nextNode())) { const element = node; // Avoid duplicates and nested blocks if (!seen.has(element) && !this.hasAncestorInSet(element, seen)) { seen.add(element); blocks.push(element); } } // If no blocks found, check if we're inside a block if (blocks.length === 0) { let parent = root; while (parent && parent !== this.doc.body) { if (blockTags.includes(parent.tagName)) { blocks.push(parent); break; } parent = parent.parentElement; } } return blocks; } /** * Check if a node is fully contained within a range. */ isNodeFullyInRange(node, range) { const nodeRange = this.doc.createRange(); nodeRange.selectNodeContents(node); return range.compareBoundaryPoints(Range.START_TO_START, nodeRange) <= 0 && range.compareBoundaryPoints(Range.END_TO_END, nodeRange) >= 0; } /** * Check if element has an ancestor in the given set. */ hasAncestorInSet(element, set) { let parent = element.parentElement; while (parent) { if (set.has(parent)) { return true; } parent = parent.parentElement; } return false; } insertVideo(videoUrl) { if (videoUrl.match('www.youtube.com') || videoUrl.match('youtu.be')) { this.insertYouTubeVideoTag(videoUrl); } if (videoUrl.match('vimeo.com')) { this.insertVimeoVideoTag(videoUrl); } } insertYouTubeVideoTag(videoUrl) { // Support both formats: youtube.com/watch?v=ID and youtu.be/ID let id; if (videoUrl.includes('youtu.be/')) { id = videoUrl.split('youtu.be/')[1].split('?')[0]; } else { id = videoUrl.split('v=')[1].split('&')[0]; } const imageUrl = `https://img.youtube.com/vi/${id}/0.jpg`; const thumbnail = ` <div style='position: relative'> <a href='${videoUrl}' target='_blank'> <img src="${imageUrl}" alt="click to watch"/> <img style='position: absolute; left:200px; top:140px' src="https://img.icons8.com/color/96/000000/youtube-play.png"/> </a> </div>`; this.insertHtml(thumbnail); } insertVimeoVideoTag(videoUrl) { const sub = this.http.get(`https://vimeo.com/api/oembed.json?url=${videoUrl}`).subscribe(data => { const imageUrl = data.thumbnail_url_with_play_button; const thumbnail = `<div> <a href='${videoUrl}' target='_blank'> <img src="${imageUrl}" alt="${data.title}"/> </a> </div>`; this.insertHtml(thumbnail); sub.unsubscribe(); }); } nextNode(node) { if (node.hasChildNodes()) { return node.firstChild; } else { while (node && !node.nextSibling) { node = node.parentNode; } if (!node) { return null; } return node.nextSibling; } } getRangeSelectedNodes(range, includePartiallySelectedContainers) { let node = range.startContainer; const endNode = range.endContainer; let rangeNodes = []; // Special case for a range that is contained within a single node if (node === endNode) { rangeNodes = [node]; } else { // Iterate nodes until we hit the end container while (node && node !== endNode) { rangeNodes.push(node = this.nextNode(node)); } // Add partially selected nodes at the start of the range node = range.startContainer; while (node && node !== range.commonAncestorContainer) { rangeNodes.unshift(node); node = node.parentNode; } } // Add ancestors of the range container, if required if (includePartiallySelectedContainers) { node = range.commonAncestorContainer; while (node) { rangeNodes.push(node); node = node.parentNode; } } return rangeNodes; } getSelectedNodes() { const nodes = []; if (this.doc.getSelection) { const sel = this.doc.getSelection(); for (let i = 0, len = sel.rangeCount; i < len; ++i) { nodes.push.apply(nodes, this.getRangeSelectedNodes(sel.getRangeAt(i), true)); } } return nodes; } replaceWithOwnChildren(el) { const parent = el.parentNode; while (el.hasChildNodes()) { parent.insertBefore(el.firstChild, el); } parent.removeChild(el); } removeSelectedElements(tagNames) { const tagNamesArray = tagNames.toLowerCase().split(','); this.getSelectedNodes().forEach((node) => { if (node.nodeType === 1 && tagNamesArray.indexOf(node.tagName.toLowerCase()) > -1) { // Remove the node and replace it with its children this.replaceWithOwnChildren(node); } }); } static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.13", ngImport: i0, type: AngularEditorService, deps: [{ token: i1.HttpClient }, { token: DOCUMENT }], target: i0.ɵɵFactoryTarget.Injectable }); static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.3.13", ngImport: i0, type: AngularEditorService }); } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.13", ngImport: i0, type: AngularEditorService, decorators: [{ type: Injectable }], ctorParameters: () => [{ type: i1.HttpClient }, { type: undefined, decorators: [{ type: Inject, args: [DOCUMENT] }] }] }); const angularEditorConfig = { editable: true, spellcheck: true, height: 'auto', minHeight: '0', maxHeight: 'auto', width: 'auto', minWidth: '0', translate: 'yes', enableToolbar: true, showToolbar: true, placeholder: 'Enter text here...', defaultParagraphSeparator: '', defaultFontName: '', defaultFontSize: '', fonts: [ { class: 'arial', name: 'Arial' }, { class: 'times-new-roman', name: 'Times New Roman' }, { class: 'calibri', name: 'Calibri' }, { class: 'comic-sans-ms', name: 'Comic Sans MS' } ], uploadUrl: 'v1/image', uploadWithCredentials: false, sanitize: true, toolbarPosition: 'top', outline: true, /*toolbarHiddenButtons: [ ['bold', 'italic', 'underline', 'strikeThrough', 'superscript', 'subscript'], ['heading', 'fontName', 'fontSize', 'color'], ['justifyLeft', 'justifyCenter', 'justifyRight', 'justifyFull', 'indent', 'outdent'], ['cut', 'copy', 'delete', 'removeFormat', 'undo', 'redo'], ['paragraph', 'blockquote', 'removeBlockquote', 'horizontalLine', 'orderedList', 'unorderedList'], ['link', 'unlink', 'image', 'video'] ]*/ }; function isDefined(value) { return value !== undefined && value !== null; } class AeSelectComponent { elRef; r; options = []; // eslint-disable-next-line @angular-eslint/no-input-rename isHidden; selectedOption; disabled = false; optionId = 0; get label() { return this.selectedOption && this.selectedOption.hasOwnProperty('label') ? this.selectedOption.label : 'Select'; } opened = false; get value() { return this.selectedOption.value; } hidden = 'inline-block'; // eslint-disable-next-line @angular-eslint/no-output-native, @angular-eslint/no-output-rename changeEvent = new EventEmitter(); labelButton; constructor(elRef, r) { this.elRef = elRef; this.r = r; } ngOnInit() { this.selectedOption = this.options[0]; if (isDefined(this.isHidden) && this.isHidden) { this.hide(); } } hide() { this.hidden = 'none'; } optionSelect(option, event) { //console.log(event.button, event.buttons); if (event.buttons !== 1) { return; } event.preventDefault(); event.stopPropagation(); this.setValue(option.value); this.onChange(this.selectedOption.value); this.changeEvent.emit(this.selectedOption.value); this.onTouched(); this.opened = false; } toggleOpen(event) { // event.stopPropagation(); if (this.disabled) { return; } this.opened = !this.opened; } onClick($event) { if (!this.elRef.nativeElement.contains($event.target)) { this.close(); } } close() { this.opened = false; } get isOpen() { return this.opened; } writeValue(value) { if (!value || typeof value !== 'string') { return; } this.setValue(value); } setValue(value) { let index = 0; const selectedEl = this.options.find((el, i) => { index = i; return el.value === value; }); if (selectedEl) { this.selectedOption = selectedEl; this.optionId = index; } } onChange = () => { }; onTouched = () => { }; registerOnChange(fn) { this.onChange = fn; } registerOnTouched(fn) { this.onTouched = fn; } setDisabledState(isDisabled) { this.labelButton.nativeElement.disabled = isDisabled; const div = this.labelButton.nativeElement; const action = isDisabled ? 'addClass' : 'removeClass'; this.r[action](div, 'disabled'); this.disabled = isDisabled; } handleKeyDown($event) { if (!this.opened) { return; } // console.log($event.key); // if (KeyCode[$event.key]) { switch ($event.key) { case 'ArrowDown': this._handleArrowDown($event); break; case 'ArrowUp': this._handleArrowUp($event); break; case 'Space': this._handleSpace($event); break; case 'Enter': this._handleEnter($event); break; case 'Tab': this._handleTab($event); break; case 'Escape': this.close(); $event.preventDefault(); break; case 'Backspace': this._handleBackspace(); break; } // } else if ($event.key && $event.key.length === 1) { // this._keyPress$.next($event.key.toLocaleLowerCase()); // } } _handleArrowDown($event) { if (this.optionId < this.options.length - 1) { this.optionId++; } } _handleArrowUp($event) { if (this.optionId >= 1) { this.optionId--; } } _handleSpace($event) { } _handleEnter($event) { this.optionSelect(this.options[this.optionId], $event); } _handleTab($event) { } _handleBackspace() { } static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.13", ngImport: i0, type: AeSelectComponent, deps: [{ token: i0.ElementRef }, { token: i0.Renderer2 }], target: i0.ɵɵFactoryTarget.Component }); static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "20.3.13", type: AeSelectComponent, isStandalone: false, selector: "ae-select", inputs: { options: "options", isHidden: ["hidden", "isHidden"] }, outputs: { changeEvent: "change" }, host: { listeners: { "document:click": "onClick($event)", "keydown": "handleKeyDown($event)" }, properties: { "style.display": "this.hidden" } }, providers: [ { provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => AeSelectComponent), multi: true, } ], viewQueries: [{ propertyName: "labelButton", first: true, predicate: ["labelButton"], descendants: true, static: true }], ngImport: i0, template: "<span class=\"ae-picker\" [ngClass]=\"{'ae-expanded':isOpen}\">\n <button [tabIndex]=\"-1\" #labelButton tabindex=\"-1\" type=\"button\" role=\"button\" class=\"ae-picker-label\" (click)=\"toggleOpen($event);\">{{label}}\n <svg>\n <use [attr.href]=\"'assets/ae-icons/icons.svg#sort'\" [attr.xlink:href]=\"'assets/ae-icons/icons.svg#sort'\"></use>\n </svg>\n </button>\n <span class=\"ae-picker-options\">\n <span tabindex=\"-1\" type=\"button\" role=\"button\" class=\"ae-picker-item\"\n *ngFor=\"let item of options; let i = index\"\n [ngClass]=\"{'selected': item.value === value, 'focused': i === optionId}\"\n (mousedown)=\"optionSelect(item, $event)\">\n {{item.label}}\n </span>\n <span class=\"dropdown-item\" *ngIf=\"!options.length\">No items for select</span>\n </span>\n</span>\n", styles: ["a{cursor:pointer}svg{width:100%;height:100%}.ae-picker{color:var(--ae-picker-color, #444);display:inline-block;float:left;width:100%;position:relative;vertical-align:middle}.ae-picker-label{cursor:pointer;display:inline-block;padding-left:8px;padding-right:10px;position:relative;width:100%;line-height:1.8rem;vertical-align:middle;font-size:85%;text-align:left;background-color:var(--ae-picker-label-color, white);min-width:2rem;float:left;border:1px solid #ddd;border-radius:var(--ae-button-radius, 4px);text-overflow:clip;overflow:hidden;white-space:nowrap;height:2rem}.ae-picker-label:before{content:\"\";position:absolute;right:0;top:0;width:20px;height:100%;background:linear-gradient(to right,var(--ae-picker-label-color, white),var(--ae-picker-label-color, white) 100%)}.ae-picker-label:focus{outline:none}.ae-picker-label:hover{cursor:pointer;background-color:#f1f1f1;transition:.2s ease}.ae-picker-label:hover:before{background:linear-gradient(to right,#f5f5f5 100%,#fff)}.ae-picker-label:disabled{background-color:#f5f5f5;pointer-events:none;cursor:not-allowed}.ae-picker-label:disabled:before{background:linear-gradient(to right,#f5f5f5 100%,#fff)}.ae-picker-label svg{position:absolute;right:0;width:1rem}.ae-picker-label svg:not(:root){overflow:hidden}.ae-picker-label svg .ae-stroke{fill:none;stroke:#444;stroke-linecap:round;stroke-linejoin:round;stroke-width:2}.ae-picker-options{background-color:var(--ae-picker-option-bg-color, #fff);display:none;min-width:100%;position:absolute;white-space:nowrap;z-index:3;border:1px solid transparent;box-shadow:#0003 0 2px 8px}.ae-picker-options .ae-picker-item{cursor:pointer;display:block;padding:5px;z-index:3;text-align:left;background-color:transparent;min-width:2rem;border:0 solid #ddd}.ae-picker-options .ae-picker-item.selected{color:#06c;background-color:var(--ae-picker-option-active-bg-color, #fff4c2)}.ae-picker-options .ae-picker-item.focused{background-color:var(--ae-picker-option-focused-bg-color, #fbf9b0)}.ae-picker-options .ae-picker-item:hover{background-color:var(--ae-picker-option-hover-bg-color, #fffa98)}.ae-expanded{display:block;margin-top:-1px;z-index:1}.ae-expanded .ae-picker-label{color:#ccc;z-index:2}.ae-expanded .ae-picker-label svg{color:#ccc;z-index:2}.ae-expanded .ae-picker-label svg .ae-stroke{stroke:#ccc}.ae-expanded .ae-picker-options{display:flex;flex-direction:column;margin-top:-1px;top:100%;z-index:3;border-color:#ccc}\n"], dependencies: [{ kind: "directive", type: i1$1.NgClass, selector: "[ngClass]", inputs: ["class", "ngClass"] }, { kind: "directive", type: i1$1.NgForOf, selector: "[ngFor][ngForOf]", inputs: ["ngForOf", "ngForTrackBy", "ngForTemplate"] }, { kind: "directive", type: i1$1.NgIf, selector: "[ngIf]", inputs: ["ngIf", "ngIfThen", "ngIfElse"] }] }); } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.13", ngImport: i0, type: AeSelectComponent, decorators: [{ type: Component, args: [{ selector: 'ae-select', providers: [ { provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => AeSelectComponent), multi: true, } ], standalone: false, template: "<span class=\"ae-picker\" [ngClass]=\"{'ae-expanded':isOpen}\">\n <button [tabIndex]=\"-1\" #labelButton tabindex=\"-1\" type=\"button\" role=\"button\" class=\"ae-picker-label\" (click)=\"toggleOpen($event);\">{{label}}\n <svg>\n <use [attr.href]=\"'assets/ae-icons/icons.svg#sort'\" [attr.xlink:href]=\"'assets/ae-icons/icons.svg#sort'\"></use>\n </svg>\n </button>\n <span class=\"ae-picker-options\">\n <span tabindex=\"-1\" type=\"button\" role=\"button\" class=\"ae-picker-item\"\n *ngFor=\"let item of options; let i = index\"\n [ngClass]=\"{'selected': item.value === value, 'focused': i === optionId}\"\n (mousedown)=\"optionSelect(item, $event)\">\n {{item.label}}\n </span>\n <span class=\"dropdown-item\" *ngIf=\"!options.length\">No items for select</span>\n </span>\n</span>\n", styles: ["a{cursor:pointer}svg{width:100%;height:100%}.ae-picker{color:var(--ae-picker-color, #444);display:inline-block;float:left;width:100%;position:relative;vertical-align:middle}.ae-picker-label{cursor:pointer;display:inline-block;padding-left:8px;padding-right:10px;position:relative;width:100%;line-height:1.8rem;vertical-align:middle;font-size:85%;text-align:left;background-color:var(--ae-picker-label-color, white);min-width:2rem;float:left;border:1px solid #ddd;border-radius:var(--ae-button-radius, 4px);text-overflow:clip;overflow:hidden;white-space:nowrap;height:2rem}.ae-picker-label:before{content:\"\";position:absolute;right:0;top:0;width:20px;height:100%;background:linear-gradient(to right,var(--ae-picker-label-color, white),var(--ae-picker-label-color, white) 100%)}.ae-picker-label:focus{outline:none}.ae-picker-label:hover{cursor:pointer;background-color:#f1f1f1;transition:.2s ease}.ae-picker-label:hover:before{background:linear-gradient(to right,#f5f5f5 100%,#fff)}.ae-picker-label:disabled{background-color:#f5f5f5;pointer-events:none;cursor:not-allowed}.ae-picker-label:disabled:before{background:linear-gradient(to right,#f5f5f5 100%,#fff)}.ae-picker-label svg{position:absolute;right:0;width:1rem}.ae-picker-label svg:not(:root){overflow:hidden}.ae-picker-label svg .ae-stroke{fill:none;stroke:#444;stroke-linecap:round;stroke-linejoin:round;stroke-width:2}.ae-picker-options{background-color:var(--ae-picker-option-bg-color, #fff);display:none;min-width:100%;position:absolute;white-space:nowrap;z-index:3;border:1px solid transparent;box-shadow:#0003 0 2px 8px}.ae-picker-options .ae-picker-item{cursor:pointer;display:block;padding:5px;z-index:3;text-align:left;background-color:transparent;min-width:2rem;border:0 solid #ddd}.ae-picker-options .ae-picker-item.selected{color:#06c;background-color:var(--ae-picker-option-active-bg-color, #fff4c2)}.ae-picker-options .ae-picker-item.focused{background-color:var(--ae-picker-option-focused-bg-color, #fbf9b0)}.ae-picker-options .ae-picker-item:hover{background-color:var(--ae-picker-option-hover-bg-color, #fffa98)}.ae-expanded{display:block;margin-top:-1px;z-index:1}.ae-expanded .ae-picker-label{color:#ccc;z-index:2}.ae-expanded .ae-picker-label svg{color:#ccc;z-index:2}.ae-expanded .ae-picker-label svg .ae-stroke{stroke:#ccc}.ae-expanded .ae-picker-options{display:flex;flex-direction:column;margin-top:-1px;top:100%;z-index:3;border-color:#ccc}\n"] }] }], ctorParameters: () => [{ type: i0.ElementRef }, { type: i0.Renderer2 }], propDecorators: { options: [{ type: Input }], isHidden: [{ type: Input, args: ['hidden'] }], hidden: [{ type: HostBinding, args: ['style.display'] }], changeEvent: [{ type: Output, args: ['change'] }], labelButton: [{ type: ViewChild, args: ['labelButton', { static: true }] }], onClick: [{ type: HostListener, args: ['document:click', ['$event']] }], handleKeyDown: [{ type: HostListener, args: ['keydown', ['$event']] }] } }); class AeButtonComponent { iconName = ''; constructor() { } static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.13", ngImport: i0, type: AeButtonComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "20.3.13", type: AeButtonComponent, isStandalone: false, selector: "ae-button, button[aeButton]", inputs: { iconName: "iconName" }, host: { properties: { "tabIndex": "-1", "type": "\"button\"" }, classAttribute: "angular-editor-button" }, ngImport: i0, template: "<ng-container *ngIf=\"iconName; else contentTemplate\">\n <svg>\n <use [attr.href]=\"'assets/ae-icons/icons.svg#' + iconName\" [attr.xlink:href]=\"'assets/ae-icons/icons.svg#' + iconName\"></use>\n </svg>\n</ng-container>\n<ng-template #contentTemplate>\n <ng-content></ng-content>\n</ng-template>\n", styles: ["a{cursor:pointer}:host.angular-editor-button{background-color:var(--ae-button-bg-color, white);vertical-align:middle;border:var(--ae-button-border, 1px solid #ddd);border-radius:var(--ae-button-radius, 4px);padding:.4rem;float:left;width:2rem;height:2rem}:host.angular-editor-button svg{width:100%;height:100%}:host.angular-editor-button:hover{cursor:pointer;background-color:var(--ae-button-hover-bg-color, #f1f1f1);transition:.2s ease}:host.angular-editor-button:focus,:host.angular-editor-button.focus{outline:0}:host.angular-editor-button:disabled{background-color:var(--ae-button-disabled-bg-color, #f5f5f5);pointer-events:none;cursor:not-allowed}:host.angular-editor-button:disabled>.color-label{pointer-events:none;cursor:not-allowed}:host.angular-editor-button:disabled>.color-label.foreground :after{background:#555}:host.angular-editor-button:disabled>.color-label.background{background:#555}:host.angular-editor-button.active{background:var(--ae-button-active-bg-color, #fffbd3)}:host.angular-editor-button.active:hover{background-color:var(--ae-button-active-hover-bg-color, #fffaad)}\n"], dependencies: [{ kind: "directive", type: i1$1.NgIf, selector: "[ngIf]", inputs: ["ngIf", "ngIfThen", "ngIfElse"] }] }); } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.13", ngImport: i0, type: AeButtonComponent, decorators: [{ type: Component, args: [{ selector: 'ae-button, button[aeButton]', host: { 'class': 'angular-editor-button', '[tabIndex]': '-1', '[type]': '"button"', }, standalone: false, template: "<ng-container *ngIf=\"iconName; else contentTemplate\">\n <svg>\n <use [attr.href]=\"'assets/ae-icons/icons.svg#' + iconName\" [attr.xlink:href]=\"'assets/ae-icons/icons.svg#' + iconName\"></use>\n </svg>\n</ng-container>\n<ng-template #contentTemplate>\n <ng-content></ng-content>\n</ng-template>\n", styles: ["a{cursor:pointer}:host.angular-editor-button{background-color:var(--ae-button-bg-color, white);vertical-align:middle;border:var(--ae-button-border, 1px solid #ddd);border-radius:var(--ae-button-radius, 4px);padding:.4rem;float:left;width:2rem;height:2rem}:host.angular-editor-button svg{width:100%;height:100%}:host.angular-editor-button:hover{cursor:pointer;background-color:var(--ae-button-hover-bg-color, #f1f1f1);transition:.2s ease}:host.angular-editor-button:focus,:host.angular-editor-button.focus{outline:0}:host.angular-editor-button:disabled{background-color:var(--ae-button-disabled-bg-color, #f5f5f5);pointer-events:none;cursor:not-allowed}:host.angular-editor-button:disabled>.color-label{pointer-events:none;cursor:not-allowed}:host.angular-editor-button:disabled>.color-label.foreground :after{background:#555}:host.angular-editor-button:disabled>.color-label.background{background:#555}:host.angular-editor-button.active{background:var(--ae-button-active-bg-color, #fffbd3)}:host.angular-editor-button.active:hover{background-color:var(--ae-button-active-hover-bg-color, #fffaad)}\n"] }] }], ctorParameters: () => [], propDecorators: { iconName: [{ type: Input }] } }); class AeToolbarSetComponent { constructor() { } static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.13", ngImport: i0, type: AeToolbarSetComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "20.3.13", type: AeToolbarSetComponent, isStandalone: false, selector: "ae-toolbar-set, [aeToolbarSet]", host: { classAttribute: "angular-editor-toolbar-set" }, ngImport: i0, template: "<ng-content></ng-content>\n", styles: ["a{cursor:pointer}:host.angular-editor-toolbar-set{display:flex;gap:1px;width:fit-content;vertical-align:baseline}\n"] }); } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.13", ngImport: i0, type: AeToolbarSetComponent, decorators: [{ type: Component, args: [{ selector: 'ae-toolbar-set, [aeToolbarSet]', host: { 'class': 'angular-editor-toolbar-set' }, standalone: false, template: "<ng-content></ng-content>\n", styles: ["a{cursor:pointer}:host.angular-editor-toolbar-set{display:flex;gap:1px;width:fit-content;vertical-align:baseline}\n"] }] }], ctorParameters: () => [] }); class AeToolbarComponent { r; editorService; er; doc; htmlMode = false; linkSelected = false; block = 'default'; fontName = 'Times New Roman'; fontSize = '3'; foreColour; backColor; headings = [ { label: 'Heading 1', value: 'h1', }, { label: 'Heading 2', value: 'h2', }, { label: 'Heading 3', value: 'h3', }, { label: 'Heading 4', value: 'h4', }, { label: 'Heading 5', value: 'h5', }, { label: 'Heading 6', value: 'h6', }, { label: 'Paragraph', value: 'p', }, { label: 'Predefined', value: 'pre' }, { label: 'Standard', value: 'div' }, { label: 'default', value: 'default' } ]; fontSizes = [ { label: '1', value: '1', }, { label: '2', value: '2', }, { label: '3', value: '3', }, { label: '4', value: '4', }, { label: '5', value: '5', }, { label: '6', value: '6', }, { label: '7', value: '7', } ]; customClassId = '-1'; // eslint-disable-next-line no-underscore-dangle, id-blacklist, id-match _customClasses; customClassList = [{ label: '', value: '' }]; // uploadUrl: string; tagMap = { BLOCKQUOTE: 'indent', A: 'link' }; select = ['H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'P', 'PRE', 'DIV']; buttons = ['bold', 'italic', 'underline', 'strikeThrough', 'subscript', 'superscript', 'justifyLeft', 'justifyCenter', 'justifyRight', 'justifyFull', 'indent', 'outdent', 'insertUnorderedList', 'insertOrderedList', 'link']; id; uploadUrl; upload; showToolbar; fonts = [{ label: '', value: '' }]; set customClasses(classes) { if (classes) { this._customClasses = classes; this.customClassList = this._customClasses.map((x, i) => ({ label: x.name, value: i.toString() })); this.customClassList.unshift({ label: 'Clear Class', value: '-1' }); } } set defaultFontName(value) { if (value) { this.fontName = value; } } set defaultFontSize(value) { if (value) { this.fontSize = value; } } hiddenButtons; execute = new EventEmitter(); myInputFile; get isLinkButtonDisabled() { return this.htmlMode || !Boolean(this.editorService.selectedText); } constructor(r, editorService, er, doc) { this.r = r; this.editorService = editorService; this.er = er; this.doc = doc; } /** * Trigger command from editor header buttons * @param command string from toolbar buttons */ triggerCommand(command) { this.execute.emit(command); } /** * highlight editor buttons when cursor moved or positioning */ triggerButtons() { if (!this.showToolbar) { return; } this.buttons.forEach(e => { const result = this.doc.queryCommandState(e); const elementById = this.doc.getElementById(e + '-' + this.id); if (result) { this.r.addClass(elementById, 'active'); } else { this.r.removeClass(elementById, 'active'); } }); } /** * trigger highlight editor buttons when cursor moved or positioning in block */ triggerBlocks(nodes) { if (!this.showToolbar) { return; } this.linkSelected = nodes.findIndex(x => x.nodeName === 'A') > -1; let found = false; this.select.forEach(y => { const node = nodes.find(x => x.nodeName === y); if (node !== undefined && y === node.nodeName) { if (found === false) { this.block = node.nodeName.toLowerCase(); found = true; } } else if (found === false) { this.block = 'default'; } }); found = false; if (this._customClasses) { this._customClasses.forEach((y, index) => { const node = nodes.find(x => { if (x instanceof Element) { return x.className === y.class; } }); if (node !== undefined) { if (found === false) { this.customClassId = index.toString(); found = true; } } else if (found === false) { this.customClassId = '-1'; } }); } Object.keys(this.tagMap).map(e => { const elementById = this.doc.getElementById(this.tagMap[e] + '-' + this.id); const node = nodes.find(x => x.nodeName === e); if (node !== undefined && e === node.nodeName) { this.r.addClass(elementById, 'active'); } else { this.r.removeClass(elementById, 'active'); } }); this.foreColour = this.doc.queryCommandValue('ForeColor'); this.fontSize = this.doc.queryCommandValue('FontSize'); this.fontName = this.doc.queryCommandValue('FontName').replace(/"/g, ''); this.backColor = this.doc.queryCommandValue('backColor'); } /** * insert URL link */ insertUrl() { let url = 'https:\/\/'; const selection = this.editorService.savedSelection; if (selection && selection.commonAncestorContainer.parentElement.nodeName === 'A') { const parent = selection.commonAncestorContainer.parentElement; // Use getAttribute to preserve relative URLs instead of href which returns absolute URL const href = parent.getAttribute('href'); if (href !== '' && href !== null) { url = href; } } url = prompt('Insert URL link', url); if (url && url !== '' && url !== 'https://') { this.editorService.createLink(url); } } /** * insert Video link */ insertVideo() { this.execute.emit(''); const url = prompt('Insert Video link', `https://`); if (url && url !== '' && url !== `https://`) { this.editorService.insertVideo(url); } } /** insert color */ insertColor(color, where) { this.editorService.insertColor(color, where); this.execute.emit(''); } /** * set font Name/family * @param foreColor string */ setFontName(foreColor) { this.editorService.setFontName(foreColor); this.execute.emit(''); } /** * set font Size * @param fontSize string */ setFontSize(fontSize) { this.editorService.setFontSize(fontSize); this.execute.emit(''); } /** * toggle editor mode (WYSIWYG or SOURCE) * @param m boolean */ setEditorMode(m) { const toggleEditorModeButton = this.doc.getElementById('toggleEditorMode' + '-' + this.id); if (m) { this.r.addClass(toggleEditorModeButton, 'active'); } else { this.r.removeClass(toggleEditorModeButton, 'active'); } this.htmlMode = m; } /** * Upload image when file is selected. */ onFileChanged(event) { const file = event.target.files[0]; if (file.type.includes('image/')) { if (this.upload) { this.upload(file).subscribe((response) => this.watchUploadImage(response, event)); } else if (this.uploadUrl) { this.editorService.uploadImage(file).subscribe((response) => this.watchUploadImage(response, event)); } else { const reader = new FileReader(); reader.onload = (e) => { const fr = e.currentTarget; this.editorService.insertImage(fr.result.toString()); // Reset input value to allow re-uploading the same file event.target.value = null; }; reader.readAsDataURL(file); } } } watchUploadImage(response, event) { const { imageUrl } = response.body; this.editorService.insertImage(imageUrl); event.srcElement.value = null; } /** * Set custom class */ setCustomClass(classId) { if (classId === '-1') { this.execute.emit('clear'); } else { this.editorService.createCustomClass(this._customClasses[+classId]); } } isButtonHidden(name) { if (!name) { return false; } if (!(this.hiddenButtons instanceof Array)) { return false; } let result; for (const arr of this.hiddenButtons) { if (arr instanceof Array) { result = arr.find(item => item === name); } if (result) { break; } } return result !== undefined; } focus() { this.execute.emit('focus'); } static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.13", ngImport: i0, type: AeToolbarComponent, deps: [{ token: i0.Renderer2 }, { token: AngularEditorService }, { token: i0.ElementRef }, { token: DOCUMENT }], target: i0.ɵɵFactoryTarget.Component }); static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "20.3.13", type: AeToolbarComponent, isStandalone: false, selector: "angular-editor-toolbar, ae-toolbar, div[aeToolbar]", inputs: { id: "id", uploadUrl: "uploadUrl", upload: "upload", showToolbar: "showToolbar", fonts: "fonts", customClasses: "customClasses", defaultFontName: "defaultFontName", defaultFontSize: "defaultFontSize", hiddenButtons: "hiddenButtons" }, outputs: { execute: "execute" }, viewQueries: [{ propertyName: "myInputFile", first: true, predicate: ["fileInput"], descendants: true, static: true }], ngImport: i0, template: "<div class=\"angular-editor-toolbar\" *ngIf=\"showToolbar\">\n <div aeToolbarSet>\n <button aeButton title=\"Undo\" iconName=\"undo\" (click)=\"triggerCommand('undo')\" [hidden]=\"isButtonHidden('undo')\">\n </button>\n <button aeButton title=\"Redo\" iconName=\"redo\" (click)=\"triggerCommand('redo')\"\n [hidden]=\"isButtonHidden('redo')\">\n </button>\n </div>\n <div aeToolbarSet>\n <button [id]=\"'bold-'+id\" aeButton title=\"Bold\" iconName=\"bold\" (click)=\"triggerCommand('bold')\"\n [disabled]=\"htmlMode\" [hidden]=\"isButtonHidden('bold')\">\n </button>\n <button [id]=\"'italic-'+id\" aeButton iconName=\"italic\" title=\"Italic\" (click)=\"triggerCommand('italic')\"\n [disabled]=\"htmlMode\" [hidden]=\"isButtonHidden('italic')\">\n </button>\n <button [id]=\"'underline-'+id\" aeButton title=\"Underline\" iconName=\"underline\"\n (click)=\"triggerCommand('underline')\" [disabled]=\"htmlMode\" [hidden]=\"isButtonHidden('underline')\">\n </button>\n <button [id]=\"'strikeThrough-'+id\" aeButton iconName=\"strikeThrough\" title=\"Strikethrough\"\n (click)=\"triggerCommand('strikeThrough')\" [disabled]=\"htmlMode\"\n [hidden]=\"isButtonHidden('strikeThrough')\">\n </button>\n <button [id]=\"'subscript-'+id\" aeButton title=\"Subscript\" iconName=\"subscript\"\n (click)=\"triggerCommand('subscript')\" [disabled]=\"htmlMode\" [hidden]=\"isButtonHidden('subscript')\">\n </button>\n <button [id]=\"'superscript-'+id\" aeButton iconName=\"superscript\" title=\"Superscript\"\n (click)=\"triggerCommand('superscript')\" [disabled]=\"htmlMode\"\n [hidden]=\"isButtonHidden('superscript')\">\n </button>\n </div>\n <div aeToolbarSet>\n <button [id]=\"'justifyLeft-'+id\" aeButton iconName=\"justifyLeft\" title=\"Justify Left\"\n (click)=\"triggerCommand('justifyLeft')\" [disabled]=\"htmlMode\"\n [hidden]=\"isButtonHidden('justifyLeft')\">\n </button>\n <button [id]=\"'justifyCenter-'+id\" aeButton iconName=\"justifyCenter\" title=\"Justify Center\"\n (click)=\"triggerCommand('justifyCenter')\" [disabled]=\"htmlMode\"\n [hidden]=\"isButtonHidden('justifyCenter')\">\n </button>\n <button [id]=\"'justifyRight-'+id\" aeButton iconName=\"justifyRight\" title=\"Justify Right\"\n (click)=\"triggerCommand('justifyRight')\" [disabled]=\"htmlMode\"\n [hidden]=\"isButtonHidden('justifyRight')\">\n </button>\n <button [id]=\"'justifyFull-'+id\" aeButton iconName=\"justifyFull\" title=\"Justify Full\"\n (click)=\"triggerCommand('justifyFull')\" [disabled]=\"htmlMode\"