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) {
+
+ @for (item of toolbarItems; track $index) {
+ @if (item.separator) {
+
+ } @else {
+
+ }
+ }
+
+ }
+
+
+
+
+
+ @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 @@
>
+
+
+
+