曾經找到過“Editor.md”,看之心喜,一直想在Angular中集成下這款markdownpad編輯器玩,在網上也只找到一篇通過指令集成的,雖然可以實現,但還是希望能做成組件形式的,之後看到一篇自定義組件的文章,瞭解到ControlValueAccessor才真正完成這個心願,現在記錄分享與諸公。
ControlValueAccessor
這是自定義表單組件的核心,只有繼承這個接口,纔有被 Angular的formControl識別的資格。
ControlValueAccessor要處理的就是實現 Model -> View,View -> Model 之間的數據綁定,其具體的作用是:
- 把 form 模型中值映射到視圖中
- 當視圖發生變化時,通知 form directives 或 form controls
該接口具體如下,已去掉其中的英文註釋:
export interface ControlValueAccessor { writeValue(obj: any): void; registerOnChange(fn: any): void; registerOnTouched(fn: any): void; setDisabledState?(isDisabled: boolean): void; }
- writeValue:在初始化的時候將formControl的值傳遞給原生表單控件(即,將模型中的新值寫入視圖或 DOM 屬性中);
- registerOnChange:用來獲取原生表單控件的值更新時通知Angular表單控件更新的函數(即,設置當控件接收到 change 事件後,調用的函數)
- registerOnTouched:用來獲取通知用戶正在交互的函數(即,設置當控件接收到 touched 事件後,調用的函數)。
- setDisabledState?(isDisabled: boolean):設置DISABLED狀態時做的執行的方法。即,當控件狀態變成 DISABLED 或從 DISABLED 狀態變化成 ENABLE 狀態時,會調用該函數。該函數會根據參數值,啓用或禁用指定的 DOM 元素。
明確來說,那些原生表單控件都有其對應的ControlValueAccessor,比如: - DefaultValueAccessor - 用於 text 和 textarea 類型的輸入控件 - SelectControlValueAccessor - 用於 select 選擇控件 - CheckboxControlValueAccessor - 用於 checkbox 複選控件
至於原生表單控件和Angular表單控件能夠保持一致的原理,可以看下formControl指令的實現:
// https://github.com/angular/angular/blob/master/packages/forms/src/directives/reactive_directives/form_control_directive.ts export class FormControlDirective extends NgControl implements OnChanges { ... ngOnChanges(changes: SimpleChanges): void { if (this._isControlChanged(changes)) { setUpControl(this.form, this); if (this.control.disabled && this.valueAccessor !.setDisabledState) { this.valueAccessor !.setDisabledState !(true); } this.form.updateValueAndValidity({emitEvent: false}); } if (isPropertyUpdated(changes, this.viewModel)) { _ngModelWarning( 'formControl', FormControlDirective, this, this._ngModelWarningConfig); this.form.setValue(this.model); this.viewModel = this.model; } } ... }
這裏僅列出了部分實現,formControl指令調用了setUpControl函數來實現formControl和ControlValueAccessor之間的交互。
// https://github.com/angular/angular/blob/master/packages/forms/src/directives/shared.ts ... dir.valueAccessor !.writeValue(control.value); ... function setUpViewChangePipeline(control: FormControl, dir: NgControl): void { dir.valueAccessor !.registerOnChange((newValue: any) => { control._pendingValue = newValue; control._pendingChange = true; control._pendingDirty = true; if (control.updateOn === 'change') updateControl(control, dir); }); } ... function setUpModelChangePipeline(control: FormControl, dir: NgControl): void { control.registerOnChange((newValue: any, emitModelEvent: boolean) => { // control -> view dir.valueAccessor !.writeValue(newValue); // control -> ngModel if (emitModelEvent) dir.viewToModelUpdate(newValue); }); } ... function setUpModelChangePipeline(control: FormControl, dir: NgControl): void { control.registerOnChange((newValue: any, emitModelEvent: boolean) => { // control -> view dir.valueAccessor !.writeValue(newValue); // control -> ngModel if (emitModelEvent) dir.viewToModelUpdate(newValue); }); } ...
裏面確實能看到一些似曾相識的方法,但個人能力有限,無法完全看懂,也就只能到這裏了,喜歡深入探究的可以自行探索。
準備工作
經過上面大致瞭解ControlValueAccessor,在正式開始前還需要最後的準備工作:
- 使用npm或者yarn安裝jquery
npm install jquery 或者 yarn add jquery
- 下載Editor.md
- 將需要的css、fonts、images、lib、plugins三個文件夾和editormd.min.js文件放入assets中(其他位置也可,記得配置第3步中對應的angular.json),這裏添加的是精簡資源,也可以把解壓出來的全部放進去,效果如圖:
- 配置angular.json
"styles": [ "src/assets/editorMd/css/editormd.min.css", "node_modules/ng-zorro-antd/src/ng-zorro-antd.min.css", "src/styles.css" ], "scripts": [ "node_modules/jquery/dist/jquery.min.js", "src/assets/editorMd/editormd.min.js" ]
創建EditorMdComponent
該組件肯定要繼承ControlValueAccessor,首先是實現其上面的方法。
writeValue
writeValue(value: string): void { this.value = value; if (this.mdeditor) { this.mdeditor.setMarkdown(this.value); } }
registerOnChange
onChange: Function = () => { }; registerOnChange(fn: any): void { this.onChange = fn; }
registerOnTouched
本示例中實際未用的該方法,主要是registerOnChange。
onTouched: Function = () => { }; registerOnTouched(fn: any): void { this.onTouched = fn; }
setDisabledState
這個也未使用,即便設置也會報mdeditor未知的錯誤,禁用功能需要使用其他方式解決。
setDisabledState?(isDisabled: boolean): void { if (isDisabled) { this.mdeditor.setDisabled(); } else { this.mdeditor.setEnabled(); } }
AfterViewInit
我們需要執行初始化編輯器的操作,故實現了AfterViewInit。
@ViewChild('host') host; // hmtl中添加 #host標識,用於選擇組件模板內的節點 ngAfterViewInit(): void { this.init(); } init() { if (typeof editormd === 'undefined') { console.error('UEditor is missing'); return; } // 檢測配置,若無自定義,則用默認配置。 this.editormdConfig = this.editormdConfig != null ? this.editormdConfig : new EditorConfig(); // 監聽編輯器加載完成事件處理,由於該編輯器的配置特性,只能提前寫好傳入。這裏是用來處理存在默認值時。 this.editormdConfig.onload = () => { if (this.value) { this.mdeditor.setMarkdown(this.value); } }; // 變化監聽處理 this.editormdConfig.onchange = () => { this.updateValue(this.mdeditor.getMarkdown()); }; // 編輯器必須使用<div id></div>的形式,所以只好添加默認id,後期可考慮傳入自定義id this.mdeditor = editormd(this.host.nativeElement.id, this.editormdConfig); // 創建編輯器 } updateValue(value: string) { this.ngZone.run(() => { this.value = value; this.onChange(this.value); // 關鍵代碼 this.onTouched(); this.onValueChange.emit(this.value); this.getHtmlValue.emit({ originalEvent: event, value: this.getHtmlContent() }); }); }
OnDestroy
爲了安全週期,實現了OnDestroy
ngOnDestroy(): void { this.destroy(); } destroy() { if (this.mdeditor) { this.mdeditor.removeListener('ready'); this.mdeditor.removeListener('contentChange'); this.mdeditor.editor.remove(); this.mdeditor.destroy(); this.mdeditor = null; } }
添加自定義驗證功能
註冊自定義驗證器
其中useExisting用來設置驗證函數,可自定義:
const UEDITOR_VALUE_ACCESSOR = { provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => EditorMdComponent), multi: true };
最終代碼
EditorMdComponent
import { Component, ViewChild, Input, AfterViewInit, ElementRef, NgZone, Output, EventEmitter, OnDestroy, forwardRef } from '@angular/core'; import { NG_VALUE_ACCESSOR, ControlValueAccessor } from '@angular/forms'; import { EditorConfig } from './editor-config'; declare var editormd: any; const UEDITOR_VALUE_ACCESSOR = { provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => EditorMdComponent), multi: true }; @Component({ selector: 'qy-editor-md', templateUrl:` <div id="md" #host > </div> `, styleUrls: ['./editor-md.component.scss'], providers: [UEDITOR_VALUE_ACCESSOR] }) export class EditorMdComponent implements AfterViewInit, OnDestroy, ControlValueAccessor { @Input() editormdConfig: EditorConfig; // 配置選項 // tslint:disable-next-line:no-output-on-prefix @Output() onReady = new EventEmitter(); // tslint:disable-next-line:no-output-on-prefix @Output() onValueChange = new EventEmitter(); // tslint:disable-next-line:no-output-on-prefix @Output() onFocus = new EventEmitter(); @Output() getHtmlValue = new EventEmitter(); @ViewChild('host') host; private mdeditor: any; private value: string; onChange: Function = () => { }; onTouched: Function = () => { }; constructor( private el: ElementRef, private ngZone: NgZone ) { } ngAfterViewInit(): void { this.init(); } ngOnDestroy(): void { this.destroy(); } writeValue(value: string): void { this.value = value; console.log('value', value); if (this.mdeditor) { this.mdeditor.setMarkdown(this.value); } } registerOnChange(fn: any): void { this.onChange = fn; } registerOnTouched(fn: any): void { this.onTouched = fn; } setDisabledState?(isDisabled: boolean): void { if (isDisabled) { this.mdeditor.setDisabled(); } else { this.mdeditor.setEnabled(); } } init() { if (typeof editormd === 'undefined') { console.error('UEditor is missing'); return; } this.editormdConfig = this.editormdConfig != null ? this.editormdConfig : new EditorConfig(); this.editormdConfig.onload = () => { if (this.value) { this.mdeditor.setMarkdown(this.value); } }; this.editormdConfig.onchange = () => { this.updateValue(this.mdeditor.getMarkdown()); }; this.mdeditor = editormd(this.host.nativeElement.id, this.editormdConfig); // 創建編輯器 } updateValue(value: string) { this.ngZone.run(() => { this.value = value; this.onChange(this.value); this.onTouched(); this.onValueChange.emit(this.value); this.getHtmlValue.emit({ originalEvent: event, value: this.getHtmlContent() }); }); } destroy() { if (this.mdeditor) { this.mdeditor.removeListener('ready'); this.mdeditor.removeListener('contentChange'); this.mdeditor.editor.remove(); this.mdeditor.destroy(); this.mdeditor = null; } } getMarkContent(): string { return this.mdeditor.getMarkdown(); } getHtmlContent(): string { console.log('this.mdeditor.getHTML() 1', this.mdeditor.getHTML()); return this.mdeditor.getHTML(); } }
EditorConfig
此爲默認編輯器配置。
export class EditorConfig { public width = '100%'; public height = '400'; public path = 'assets/editorMd/lib/'; public codeFold: true; public searchReplace = true; public toolbar = true; public emoji = true; public taskList = true; public tex = true; public readOnly = false; public tocm = true; public watch = true; public previewCodeHighlight = true; public saveHTMLToTextarea = true; public markdown = ''; public flowChart = true; public syncScrolling = true; public sequenceDiagram = true; public imageUpload = true; public imageFormats = ['jpg', 'jpeg', 'gif', 'png', 'bmp', 'webp']; public imageUploadURL = ''; constructor() { } public onload() { } public onchange() { } }
最後記得按照正常組件進行引入和聲明纔可使用哦。
之後就可以在表單組件中可以直接引入了:
<qy-editor-md formControlName="comment" (getHtmlValue)="getHtmlValue($event)" ></qy-editor-md>