diff --git a/frontend/src/app/modules/schema-engine/rich-text-editor/rich-text-editor.component.html b/frontend/src/app/modules/schema-engine/rich-text-editor/rich-text-editor.component.html new file mode 100644 index 0000000000..24f86163eb --- /dev/null +++ b/frontend/src/app/modules/schema-engine/rich-text-editor/rich-text-editor.component.html @@ -0,0 +1,73 @@ +
+ + + @if (!readonly && !isDisabled) { + + } + + +
+ + + @if (showLinkDialog) { + + } + +
diff --git a/frontend/src/app/modules/schema-engine/rich-text-editor/rich-text-editor.component.scss b/frontend/src/app/modules/schema-engine/rich-text-editor/rich-text-editor.component.scss new file mode 100644 index 0000000000..3df80693e3 --- /dev/null +++ b/frontend/src/app/modules/schema-engine/rich-text-editor/rich-text-editor.component.scss @@ -0,0 +1,181 @@ +// ── Rich Text Editor ────────────────────────────────────────────────────────── + +.rte-wrapper { + display: flex; + flex-direction: column; + border: 1px solid var(--guardian-border-color, #d1d5db); + border-radius: 4px; + background: var(--guardian-input-bg, #ffffff); + width: 100%; + transition: border-color 0.15s ease; + + &:focus-within { + border-color: var(--guardian-focus-color, #3b82f6); + box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.15); + } + + &.rte-readonly { + background: var(--guardian-readonly-bg, #f9fafb); + border-color: var(--guardian-border-color, #d1d5db); + + .rte-editor { + cursor: default; + color: var(--guardian-text-color, #374151); + } + } +} + +// ── Toolbar ────────────────────────────────────────────────────────────────── + +.rte-toolbar { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 2px; + padding: 4px 6px; + border-bottom: 1px solid var(--guardian-border-color, #d1d5db); + background: var(--guardian-toolbar-bg, #f3f4f6); + border-radius: 4px 4px 0 0; +} + +.rte-btn { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 28px; + height: 28px; + padding: 0 6px; + border: none; + background: transparent; + border-radius: 3px; + cursor: pointer; + color: var(--guardian-icon-color, #4b5563); + font-size: 13px; + font-weight: 600; + transition: background 0.1s ease, color 0.1s ease; + + &:hover { + background: var(--guardian-btn-hover-bg, #e5e7eb); + color: var(--guardian-primary-color, #1d4ed8); + } + + &:active { + background: var(--guardian-btn-active-bg, #dbeafe); + } + + i { + font-size: 12px; + } +} + +.rte-label { + font-size: 11px; + font-weight: 700; + line-height: 1; +} + +.rte-separator { + display: inline-block; + width: 1px; + height: 20px; + background: var(--guardian-border-color, #d1d5db); + margin: 0 4px; +} + +// ── Editor area ─────────────────────────────────────────────────────────────── + +.rte-editor { + min-height: 80px; + max-height: 400px; + overflow-y: auto; + padding: 8px 12px; + outline: none; + font-size: 14px; + line-height: 1.6; + color: var(--guardian-text-color, #111827); + word-break: break-word; + + // Placeholder via pseudo-element + &.rte-empty::before { + content: attr(data-placeholder); + color: var(--guardian-placeholder-color, #9ca3af); + pointer-events: none; + position: absolute; + // fallback – absolute won't work without relative parent, use float trick + } + + // Resets for editable content + &:focus { outline: none; } + + // Style pasted / user content + b, strong { font-weight: 700; } + i, em { font-style: italic; } + u { text-decoration: underline; } + + h1, h2, h3 { + margin: 0.4em 0 0.2em; + font-weight: 700; + line-height: 1.3; + } + h1 { font-size: 1.5em; } + h2 { font-size: 1.25em; } + h3 { font-size: 1.1em; } + + ul, ol { + margin: 0.3em 0 0.3em 1.6em; + padding: 0; + } + ul { list-style-type: disc; } + ol { list-style-type: decimal; } + + a { + color: var(--guardian-link-color, #2563eb); + text-decoration: underline; + cursor: pointer; + } + + p, div { margin: 0.2em 0; } +} + +// ── Link dialog ─────────────────────────────────────────────────────────────── + +.rte-link-dialog { + position: absolute; + z-index: 1000; + left: 0; + right: 0; + bottom: 100%; + margin-bottom: 4px; + display: flex; + justify-content: flex-start; +} + +.rte-link-dialog-content { + display: flex; + align-items: center; + gap: 8px; + background: #fff; + border: 1px solid var(--guardian-border-color, #d1d5db); + border-radius: 6px; + padding: 8px 12px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12); + flex-wrap: wrap; +} + +.rte-link-label { + font-size: 13px; + font-weight: 600; + color: #374151; + white-space: nowrap; +} + +.rte-link-input { + flex: 1; + min-width: 200px; + font-size: 13px; +} + +.rte-link-actions { + display: flex; + gap: 6px; +} diff --git a/frontend/src/app/modules/schema-engine/rich-text-editor/rich-text-editor.component.spec.ts b/frontend/src/app/modules/schema-engine/rich-text-editor/rich-text-editor.component.spec.ts new file mode 100644 index 0000000000..af345e9c51 --- /dev/null +++ b/frontend/src/app/modules/schema-engine/rich-text-editor/rich-text-editor.component.spec.ts @@ -0,0 +1,157 @@ +import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing'; +import { FormsModule, ReactiveFormsModule, FormControl } from '@angular/forms'; +import { ChangeDetectionStrategy } from '@angular/core'; +import { By } from '@angular/platform-browser'; + +import { RichTextEditorComponent } from './rich-text-editor.component'; + +function createNgModel(value: string = '') { + return { value } as FormControl; +} + +describe('RichTextEditorComponent', () => { + let component: RichTextEditorComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [RichTextEditorComponent], + imports: [FormsModule, ReactiveFormsModule], + }) + .overrideComponent(RichTextEditorComponent, { + set: { changeDetection: ChangeDetectionStrategy.Default }, + }) + .compileComponents(); + + fixture = TestBed.createComponent(RichTextEditorComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should render toolbar buttons when not readonly', () => { + component.readonly = false; + component.isDisabled = false; + fixture.detectChanges(); + const toolbar = fixture.debugElement.query(By.css('.rte-toolbar')); + expect(toolbar).toBeTruthy(); + }); + + it('should hide toolbar in readonly mode', () => { + component.readonly = true; + fixture.detectChanges(); + const toolbar = fixture.debugElement.query(By.css('.rte-toolbar')); + expect(toolbar).toBeNull(); + }); + + it('should implement ControlValueAccessor: writeValue sets editor content', () => { + component.writeValue('Hello'); + fixture.detectChanges(); + const editor = fixture.debugElement.query(By.css('.rte-editor')); + expect(editor.nativeElement.innerHTML).toBe('Hello'); + }); + + it('should treat null writeValue as empty string', () => { + component.writeValue(null as any); + fixture.detectChanges(); + expect(component['_value']).toBe(''); + }); + + it('should call onChange when onInput is triggered', () => { + const changeSpy = jasmine.createSpy('onChange'); + component.registerOnChange(changeSpy); + const editor = fixture.debugElement.query(By.css('.rte-editor')); + editor.nativeElement.innerHTML = 'Test'; + editor.nativeElement.dispatchEvent(new Event('input')); + expect(changeSpy).toHaveBeenCalledWith('Test'); + }); + + it('should call onTouched when editor blurs', () => { + const touchedSpy = jasmine.createSpy('onTouched'); + component.registerOnTouched(touchedSpy); + const editor = fixture.debugElement.query(By.css('.rte-editor')); + editor.nativeElement.dispatchEvent(new Event('blur')); + expect(touchedSpy).toHaveBeenCalled(); + }); + + it('should report isEmpty=true for blank content', () => { + component.writeValue(''); + expect(component.isEmpty).toBeTrue(); + }); + + it('should report isEmpty=true for whitespace-only HTML', () => { + component.writeValue('

'); + expect(component.isEmpty).toBeTrue(); + }); + + it('should report isEmpty=false for content with text', () => { + component.writeValue('

Hello

'); + expect(component.isEmpty).toBeFalse(); + }); + + it('setDisabledState should update isDisabled flag', () => { + component.setDisabledState(true); + expect(component.isDisabled).toBeTrue(); + component.setDisabledState(false); + expect(component.isDisabled).toBeFalse(); + }); + + it('should show link dialog when link command is executed', () => { + component.readonly = false; + fixture.detectChanges(); + const event = new MouseEvent('mousedown'); + spyOn(event, 'preventDefault'); + component.execCommand('link', event); + expect(component.showLinkDialog).toBeTrue(); + }); + + it('should close link dialog on cancelLink()', () => { + component.showLinkDialog = true; + component.linkUrl = 'https://example.com'; + component.cancelLink(); + expect(component.showLinkDialog).toBeFalse(); + expect(component.linkUrl).toBe(''); + }); + + it('should not insert link when URL is empty', () => { + component.showLinkDialog = true; + component.linkUrl = ''; + component.insertLink(); + expect(component.showLinkDialog).toBeFalse(); + }); + + it('should prepend https:// when URL lacks protocol', () => { + // Spy on execCommand to avoid needing a real browser context + const execSpy = spyOn(document, 'execCommand'); + component.showLinkDialog = true; + component.linkUrl = 'example.com'; + component.insertLink(); + expect(execSpy).toHaveBeenCalledWith('createLink', false, 'https://example.com'); + }); + + it('should not prepend https:// when URL already has protocol', () => { + const execSpy = spyOn(document, 'execCommand'); + component.showLinkDialog = true; + component.linkUrl = 'https://example.com'; + component.insertLink(); + expect(execSpy).toHaveBeenCalledWith('createLink', false, 'https://example.com'); + }); + + it('should have all required toolbar actions', () => { + const commands = component.toolbarItems + .filter(t => !t.separator) + .map(t => t.command); + expect(commands).toContain('bold'); + expect(commands).toContain('italic'); + expect(commands).toContain('underline'); + expect(commands).toContain('insertUnorderedList'); + expect(commands).toContain('insertOrderedList'); + expect(commands).toContain('h1'); + expect(commands).toContain('h2'); + expect(commands).toContain('h3'); + expect(commands).toContain('link'); + }); +}); diff --git a/frontend/src/app/modules/schema-engine/rich-text-editor/rich-text-editor.component.ts b/frontend/src/app/modules/schema-engine/rich-text-editor/rich-text-editor.component.ts new file mode 100644 index 0000000000..1cc57051f0 --- /dev/null +++ b/frontend/src/app/modules/schema-engine/rich-text-editor/rich-text-editor.component.ts @@ -0,0 +1,201 @@ +import { + Component, + OnInit, + OnDestroy, + Input, + forwardRef, + ChangeDetectionStrategy, + ChangeDetectorRef, + AfterViewInit, + ElementRef, + ViewChild, +} from '@angular/core'; +import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; + +/** + * A lightweight Rich Text Editor component using the browser's built-in + * `contenteditable` API. No external dependencies required. + * + * Supports: bold, italic, underline, ordered/unordered lists, + * headings (H1-H3), hyperlinks, and plain-text fallback rendering. + * + * Value contract: stores / emits HTML strings. Existing plain-text + * values are displayed as-is without breaking backward compatibility. + */ +@Component({ + selector: 'app-rich-text-editor', + templateUrl: './rich-text-editor.component.html', + styleUrls: ['./rich-text-editor.component.scss'], + standalone: false, + changeDetection: ChangeDetectionStrategy.OnPush, + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => RichTextEditorComponent), + multi: true, + }, + ], +}) +export class RichTextEditorComponent + implements OnInit, OnDestroy, AfterViewInit, ControlValueAccessor +{ + @ViewChild('editor', { static: false }) editorRef!: ElementRef; + + @Input() placeholder: string = 'Enter text here…'; + @Input() readonly: boolean = false; + + public showLinkDialog: boolean = false; + public linkUrl: string = ''; + public isDisabled: boolean = false; + + private _value: string = ''; + private _onChange: (value: string) => void = () => {}; + private _onTouched: () => void = () => {}; + + /** Track saved selection for link insertion */ + private _savedRange: Range | null = null; + + public readonly toolbarItems = [ + { command: 'bold', icon: 'pi pi-bold', title: 'Bold (Ctrl+B)' }, + { command: 'italic', icon: 'pi pi-italic', title: 'Italic (Ctrl+I)' }, + { command: 'underline', icon: 'pi pi-underline', title: 'Underline (Ctrl+U)' }, + { separator: true }, + { command: 'insertUnorderedList', icon: 'pi pi-list', title: 'Bullet list' }, + { command: 'insertOrderedList', icon: 'pi pi-list-check', title: 'Numbered list' }, + { separator: true }, + { command: 'h1', icon: null, label: 'H1', title: 'Heading 1' }, + { command: 'h2', icon: null, label: 'H2', title: 'Heading 2' }, + { command: 'h3', icon: null, label: 'H3', title: 'Heading 3' }, + { separator: true }, + { command: 'link', icon: 'pi pi-link', title: 'Insert link' }, + ]; + + constructor(private cdr: ChangeDetectorRef) {} + + ngOnInit(): void {} + + ngAfterViewInit(): void { + if (this.editorRef) { + this._setEditorContent(this._value); + } + } + + ngOnDestroy(): void {} + + // ── ControlValueAccessor ────────────────────────────────────────────────── + + writeValue(value: string | null): void { + this._value = value ?? ''; + if (this.editorRef) { + this._setEditorContent(this._value); + } + } + + registerOnChange(fn: (value: string) => void): void { + this._onChange = fn; + } + + registerOnTouched(fn: () => void): void { + this._onTouched = fn; + } + + setDisabledState(isDisabled: boolean): void { + this.isDisabled = isDisabled; + this.cdr.markForCheck(); + } + + // ── Editor events ───────────────────────────────────────────────────────── + + onInput(): void { + const html = this.editorRef.nativeElement.innerHTML; + this._value = html; + this._onChange(html); + } + + onBlur(): void { + this._onTouched(); + } + + /** Returns true if the field is empty (no visible text). */ + get isEmpty(): boolean { + if (!this._value) { return true; } + const tmp = document.createElement('div'); + tmp.innerHTML = this._value; + return (tmp.textContent || '').trim() === ''; + } + + // ── Toolbar actions ─────────────────────────────────────────────────────── + + execCommand(command: string, event: MouseEvent): void { + event.preventDefault(); + if (this.readonly || this.isDisabled) { return; } + this.editorRef.nativeElement.focus(); + + if (['h1', 'h2', 'h3'].includes(command)) { + document.execCommand('formatBlock', false, command); + } else if (command === 'link') { + this._savedRange = this._getSelection(); + this.showLinkDialog = true; + this.linkUrl = ''; + this.cdr.markForCheck(); + return; + } else { + document.execCommand(command, false, undefined); + } + this.onInput(); + this.cdr.markForCheck(); + } + + insertLink(): void { + if (!this.linkUrl) { + this.showLinkDialog = false; + return; + } + this.editorRef.nativeElement.focus(); + if (this._savedRange) { + const sel = window.getSelection(); + if (sel) { + sel.removeAllRanges(); + sel.addRange(this._savedRange); + } + } + const url = this.linkUrl.match(/^https?:\/\//) + ? this.linkUrl + : 'https://' + this.linkUrl; + document.execCommand('createLink', false, url); + // Make all links open in new tab + this.editorRef.nativeElement + .querySelectorAll('a') + .forEach((a: HTMLAnchorElement) => a.setAttribute('target', '_blank')); + this.showLinkDialog = false; + this.linkUrl = ''; + this._savedRange = null; + this.onInput(); + this.cdr.markForCheck(); + } + + cancelLink(): void { + this.showLinkDialog = false; + this.linkUrl = ''; + this._savedRange = null; + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + + private _setEditorContent(value: string): void { + const el = this.editorRef?.nativeElement; + if (!el) { return; } + // Only update DOM if value actually changed to avoid cursor jump + if (el.innerHTML !== value) { + el.innerHTML = value; + } + } + + private _getSelection(): Range | null { + const sel = window.getSelection(); + if (sel && sel.rangeCount > 0) { + return sel.getRangeAt(0).cloneRange(); + } + return null; + } +} diff --git a/frontend/src/app/modules/schema-engine/schema-engine.module.ts b/frontend/src/app/modules/schema-engine/schema-engine.module.ts index 91f8274713..670396a5b1 100644 --- a/frontend/src/app/modules/schema-engine/schema-engine.module.ts +++ b/frontend/src/app/modules/schema-engine/schema-engine.module.ts @@ -1,6 +1,6 @@ import { NgModule } from '@angular/core'; import { CommonModule } from '@angular/common'; -import { FormsModule } from '@angular/forms'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { ClipboardModule } from '@angular/cdk/clipboard'; import { CodemirrorModule } from '@ctrl/ngx-codemirror'; import { GeojsonTypeComponent } from './geojson-type/geojson-type.component'; @@ -56,6 +56,7 @@ import { SchemaDeleteWarningDialogComponent } from './schema-delete-warning-dial import { SchemaDeleteDialogComponent } from './schema-delete-dialog/schema-delete-dialog.component'; import { SchemaFormNavigationComponent } from './schema-form-navigation/schema-form-navigation.component'; import { SchemaFormViewNavigationComponent } from './schema-form-view-navigation/schema-form-view-navigation.component'; +import { RichTextEditorComponent } from './rich-text-editor/rich-text-editor.component'; @NgModule({ declarations: [ @@ -89,11 +90,13 @@ import { SchemaFormViewNavigationComponent } from './schema-form-view-navigation TableFieldComponent, TableViewerComponent, SchemaFormNavigationComponent, - SchemaFormViewNavigationComponent + SchemaFormViewNavigationComponent, + RichTextEditorComponent ], imports: [ CommonModule, FormsModule, + ReactiveFormsModule, CommonComponentsModule, MaterialModule, ClipboardModule, diff --git a/frontend/src/app/modules/schema-engine/schema-form-view/schema-form-view.component.html b/frontend/src/app/modules/schema-engine/schema-form-view/schema-form-view.component.html index b21e847995..dd4c2c152c 100644 --- a/frontend/src/app/modules/schema-engine/schema-form-view/schema-form-view.component.html +++ b/frontend/src/app/modules/schema-engine/schema-form-view/schema-form-view.component.html @@ -21,6 +21,10 @@ > +
+
+
+