/** * Angular 디렉티브 — `[aerizEditor]` / `[aerizViewer]`. * * 이 파일은 **소스 배포**다. Angular 라이브러리는 소비자의 Angular 버전에 맞춰 * Ivy 로 컴파일돼야 하고, 미리 빌드한 산출물은 버전 창이 좁아진다. 소스로 * 두면 소비자의 Angular 컴파일러가 직접 컴파일하므로 Angular 14 ~ 최신까지 * 같은 파일 하나로 동작한다. 설정 방법은 `angular/README.md` 참고. * * `@angular/core` / `@angular/forms` 는 소비자 앱의 것을 그대로 쓴다. */ import { Directive, ElementRef, EventEmitter, Input, NgZone, OnChanges, OnDestroy, OnInit, Output, forwardRef, } from '@angular/core'; import { NG_VALUE_ACCESSOR } from '@angular/forms'; import type { ControlValueAccessor } from '@angular/forms'; import { createEditorController, createViewerController } from '@aeriz/wysiwyg/adapter'; import type { AerizEditorController, AerizEditorProps, AerizViewerController, AerizViewerProps, } from '@aeriz/wysiwyg/adapter'; import type { AerizEditor, AerizViewer, BlockSnapshot, EditorState, ToolbarConfig, } from '@aeriz/wysiwyg'; type Theme = 'light' | 'dark'; type Locale = 'en' | 'ko' | 'ja' | 'zh'; type HtmlStyles = 'inline' | 'class'; type UploadFn = (file: File) => Promise; /** * 에디터 디렉티브. * * 세 가지 바인딩 방식을 모두 지원한다. * - 단방향: `
` * - 양방향: `
` * - 폼: `
` / `formControlName="body"` * * 에디터 내부 리스너(selectionchange 등) 는 매우 자주 발화하므로 인스턴스를 * Angular zone **밖에서** 만든다. 값이 실제로 바뀔 때만 zone 안으로 돌아와 * 변경 감지를 한 번 돌린다. */ @Directive({ selector: '[aerizEditor]', standalone: true, providers: [ { provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => AerizEditorDirective), multi: true, }, ], }) export class AerizEditorDirective implements OnInit, OnChanges, OnDestroy, ControlValueAccessor { /** 본문 HTML. `[(value)]` 로 양방향 바인딩할 수 있다. */ @Input() value?: string; @Input() placeholder?: string; @Input() toolbar?: ToolbarConfig | false; @Input() theme?: Theme; @Input() locale?: Locale; /** `getHTML()` · `(valueChange)` 출력 전략. 기본 `'inline'`. */ @Input() htmlStyles?: HtmlStyles; @Input() imageUploadUrl?: string; @Input() fileUploadUrl?: string; /** 직접 업로드 핸들러. 설정 시 `imageUploadUrl` 보다 우선한다. */ @Input() imageUpload?: UploadFn; @Input() fileUpload?: UploadFn; @Output() valueChange = new EventEmitter(); @Output() stateChange = new EventEmitter(); @Output() ready = new EventEmitter(); private controller: AerizEditorController | null = null; /** 에디터가 들고 있는 값. ngModel / [value] 양쪽의 단일 출처. */ private current = ''; private onChangeFn: (value: string) => void = () => undefined; private onTouchedFn: () => void = () => undefined; constructor( private readonly host: ElementRef, private readonly zone: NgZone, ) {} /** 코어 인스턴스. 명령형 API 접근 경로. */ get editor(): AerizEditor | null { return this.controller?.editor ?? null; } ngOnInit(): void { if (this.value !== undefined) this.current = this.value; this.zone.runOutsideAngular(() => { this.controller = createEditorController( this.host.nativeElement, this.buildProps(), ); }); } ngOnChanges(): void { if (!this.controller) return; if (this.value !== undefined) this.current = this.value; this.zone.runOutsideAngular(() => { this.controller?.update(this.buildProps()); }); } ngOnDestroy(): void { this.controller?.destroy(); this.controller = null; } // ─── ControlValueAccessor ──────────────────────────────────────────── writeValue(value: string | null): void { this.current = value ?? ''; this.zone.runOutsideAngular(() => { this.controller?.update(this.buildProps()); }); } registerOnChange(fn: (value: string) => void): void { this.onChangeFn = fn; } registerOnTouched(fn: () => void): void { this.onTouchedFn = fn; } setDisabledState(isDisabled: boolean): void { // 코어에 읽기 전용 모드가 없어 본문 편집 가능 여부만 직접 토글한다. const content = this.controller?.editor.content; if (content) content.contentEditable = isDisabled ? 'false' : 'true'; } // ─── 내부 ──────────────────────────────────────────────────────────── private buildProps(): AerizEditorProps { const props: AerizEditorProps = { value: this.current, onChange: (html: string) => { this.current = html; // 편집이 실제로 값을 바꿨을 때만 zone 으로 복귀한다. this.zone.run(() => { this.onChangeFn(html); this.onTouchedFn(); this.valueChange.emit(html); }); }, onStateChange: (state: EditorState) => { this.zone.run(() => this.stateChange.emit(state)); }, onReady: (editor: AerizEditor) => { this.zone.run(() => this.ready.emit(editor)); }, }; if (this.placeholder !== undefined) props.placeholder = this.placeholder; if (this.toolbar !== undefined) props.toolbar = this.toolbar; if (this.theme !== undefined) props.theme = this.theme; if (this.locale !== undefined) props.locale = this.locale; if (this.htmlStyles !== undefined) props.htmlStyles = this.htmlStyles; if (this.imageUploadUrl !== undefined) props.imageUploadUrl = this.imageUploadUrl; if (this.fileUploadUrl !== undefined) props.fileUploadUrl = this.fileUploadUrl; if (this.imageUpload !== undefined) props.onImageUpload = this.imageUpload; if (this.fileUpload !== undefined) props.onFileUpload = this.fileUpload; return props; } } /** 읽기 전용 뷰어 디렉티브. `blocks` 가 있으면 `html` 보다 우선한다. */ @Directive({ selector: '[aerizViewer]', standalone: true, }) export class AerizViewerDirective implements OnInit, OnChanges, OnDestroy { @Input() blocks?: BlockSnapshot[]; @Input() html?: string; @Input() theme?: Theme; /** `getHTML()` 출력 전략. 기본 `'inline'`. */ @Input() htmlStyles?: HtmlStyles; @Output() ready = new EventEmitter(); private controller: AerizViewerController | null = null; constructor( private readonly host: ElementRef, private readonly zone: NgZone, ) {} get viewer(): AerizViewer | null { return this.controller?.viewer ?? null; } ngOnInit(): void { this.zone.runOutsideAngular(() => { this.controller = createViewerController( this.host.nativeElement, this.buildProps(), ); }); } ngOnChanges(): void { if (!this.controller) return; this.zone.runOutsideAngular(() => { this.controller?.update(this.buildProps()); }); } ngOnDestroy(): void { this.controller?.destroy(); this.controller = null; } private buildProps(): AerizViewerProps { const props: AerizViewerProps = { onReady: (viewer: AerizViewer) => { this.zone.run(() => this.ready.emit(viewer)); }, }; if (this.blocks !== undefined) props.blocks = this.blocks; if (this.html !== undefined) props.html = this.html; if (this.theme !== undefined) props.theme = this.theme; if (this.htmlStyles !== undefined) props.htmlStyles = this.htmlStyles; return props; } }