Richtext

Das EditorTemplate Richtext bindet den Quill-Editor an eine string-Eigenschaft. Der Editor schreibt sein Markup vor dem Absenden in ein verstecktes Feld; validiert und übertragen wird dieses Feld, nicht der sichtbare Editor. Jedes Beispiel zeigt in Tabs die cshtml, das ViewModel und den Controller (GET/POST).

Grundfall

Eine string-Eigenschaft mit [UIHint("Richtext")] – mehr braucht es nicht. Der Editor schreibt sein Markup vor dem Absenden in ein verstecktes Feld.

@model RichtextStandardBeispielViewModel

<form asp-area="RichText" asp-controller="Richtext" asp-action="Standard" method="post">
    @Html.EditorFor(m => m.Beschreibung)
    @Html.Fmh(Fmh.Button()
        .Text("Absenden")
        .Size(ButtonSize.Sm)
        .Attributes(new { @class = "mt-2" }))
</form>
using System.ComponentModel.DataAnnotations;

namespace FMH.Komponente.WebUI.Areas.RichText.Models
{
    public class RichtextStandardBeispielViewModel
    {
        /// <summary>
        /// UIHint wählt das EditorTemplate des Pakets FMH.Komponente.RichText. Der Wert ist HTML,
        /// deshalb string, der Editor schreibt sein Markup vor dem Absenden in das versteckte Feld.
        /// </summary>
        [Display(Name = "Beschreibung")]
        [UIHint(UiEditor.RichText.Richtext)]
        public string Beschreibung { get; set; }
    }
}
using FMH.Komponente.Basis.Erweiterungen;
using FMH.Komponente.Basis.Ui.Feedback.Alert;
using FMH.Komponente.WebUI.Areas.RichText.Models;
using Microsoft.AspNetCore.Mvc;

namespace FMH.Komponente.WebUI.Areas.RichText.Controllers.Beispiele
{
    /// <summary>
    /// Ein Controller je EditorTemplate, je Variante ein GET+POST-Paar. Die ModelState-Verzweigung
    /// steht bewusst inline in den POST-Actions, sie ist der Lerninhalt der Beispiele.
    /// </summary>
    [Area("RichText")]
    public class RichtextController : Controller
    {
        [HttpGet]
        public IActionResult OhneLabel()
        {
            return this.View(new RichtextOhneLabelBeispielViewModel());
        }

        [HttpPost]
        [ValidateAntiForgeryToken]
        public IActionResult OhneLabel(RichtextOhneLabelBeispielViewModel modell)
        {
            // Die ausgeblendete Beschriftung ändert nichts an der Übernahme: gebunden und geprüft
            // wird das versteckte Feld, wie in den übrigen Varianten auch.
            var inhalt = string.IsNullOrWhiteSpace(modell.Notiz)
                ? "leer"
                : modell.Notiz;

            this.AddAlert($"Formular übertragen – Notiz: {inhalt}", AlertType.Success, autoHide: true);

            return this.RedirectToAction(nameof(this.OhneLabel));
        }

        [HttpGet]
        public IActionResult Pflicht()
        {
            return this.View(new RichtextPflichtBeispielViewModel());
        }

        [HttpPost]
        [ValidateAntiForgeryToken]
        public IActionResult Pflicht(RichtextPflichtBeispielViewModel modell)
        {
            if (this.ModelState.IsValid)
            {
                // Gültig: Meldung merken und per Redirect auf die GET-Action (PRG verhindert
                // doppeltes Absenden bei erneutem Laden).
                this.AddAlert("Formular ist gültig – Begründung erfasst.", AlertType.Success, autoHide: true);
                return this.RedirectToAction(nameof(this.Pflicht));
            }

            // Ungültig: dieselbe View mit den Validierungsfehlern erneut anzeigen.
            return this.View(modell);
        }

        [HttpGet]
        public IActionResult Standard()
        {
            return this.View(new RichtextStandardBeispielViewModel());
        }

        [HttpPost]
        [ValidateAntiForgeryToken]
        public IActionResult Standard(RichtextStandardBeispielViewModel modell)
        {
            // Ohne Pflichtangabe ist das Formular immer gültig. Die Meldung zeigt das übertragene
            // Markup, damit sichtbar wird, was der Editor liefert und was der Sanitizer davon
            // übrig lässt.
            var inhalt = string.IsNullOrWhiteSpace(modell.Beschreibung)
                ? "leer"
                : modell.Beschreibung;

            this.AddAlert($"Formular übertragen – Inhalt: {inhalt}", AlertType.Success, autoHide: true);

            return this.RedirectToAction(nameof(this.Standard));
        }
    }
}

Darstellung


Das EditorTemplate bindet den Quill-Editor an eine string-Eigenschaft:

[Display(Name = "Beschreibung")]
[UIHint(UiEditor.RichText.Richtext)]
public string Beschreibung { get; set; }

Gerendert wird ein verstecktes Feld mit dem Wert und daneben der Editor. Übertragen und validiert wird das versteckte Feld, nicht der sichtbare Editor: Vor dem Absenden liest die Validierungs-Routine der Basis (jquery-fmh-validation.js) das Markup aus dem Editor, räumt leere Absätze und Quill-Listen auf und schreibt es in das Feld.

Die Werkzeugleiste zeigt Fett, Kursiv, Unterstrichen, Überschriften, Listen, und den Link-Button nur dann, wenn das Tag a erlaubt ist:

@if (HtmlSanitizerRichtextDefauls.AllowedTags.Contains("a"))
{
    <text>['link'],</text>
}

Das ist kein Schönheitsentscheid, sondern verhindert eine stille Enttäuschung: Wäre der Button ohne Freigabe sichtbar, könnte man einen Link setzen, der Sanitizer entfernte ihn beim Absenden zusammen mit seinem Text, und die Eingabe wäre verloren.

Freigegeben wird in der Program.cs der Anwendung, vor der ersten Verwendung:

HtmlSanitizerRichtextDefauls.AddAllowedTags("a");
HtmlSanitizerRichtextDefauls.AddAllowedAttributes("href", "target", "rel");

Ohne Attributfreigabe bliebe das Tag ohne href übrig, also ein Link, der nirgends hinführt.

Beide Seiten arbeiten gegen dieselbe Liste: Serverseitig liest HtmlWhitelistSanitizer über HtmlSanitizerRichtextDefauls.Options(), clientseitig meldet der Endpunkt /api/widgets/HtmlSanitizer/settings dieselben Werte an DOMPurify. Wer die Freigabe ändert, ändert damit Werkzeugleiste, Browser-Bereinigung und Server-Bereinigung in einem Zug.

Erlaubt Wirkung
Voreinstellung ohne a kein Link-Button; ein eingefügter Link wird samt Text entfernt
a ohne Attribute Link-Button erscheint, href wird beim Absenden entfernt
a mit href, target, rel Link bleibt vollständig erhalten

target="_blank" sollte immer mit rel="noopener" einhergehen, die Freigabe des Attributs erzwingt das aber nicht, Quill setzt es von sich aus nicht.

Ohne Beschriftung

Mit [AdditionalMetadata(UiMetadata.Basis.ShowLabel, false)] verschwindet die Beschriftung aus dem Bild. Sie bleibt im Markup, damit Screenreader das Feld weiterhin benennen können.

@model RichtextOhneLabelBeispielViewModel

<form asp-area="RichText" asp-controller="Richtext" asp-action="OhneLabel" method="post">
    <h5>Interne Notiz</h5>
    @Html.EditorFor(m => m.Notiz)
    @Html.Fmh(Fmh.Button()
        .Text("Absenden")
        .Size(ButtonSize.Sm)
        .Attributes(new { @class = "mt-2" }))
</form>
using System.ComponentModel.DataAnnotations;
using FMH.Komponente.Basis.Ui.Validation.DataAnnotation;
using FMH.Komponente.Infrastruktur;

namespace FMH.Komponente.WebUI.Areas.RichText.Models
{
    public class RichtextOhneLabelBeispielViewModel
    {
        /// <summary>
        /// Mit <c>UiMetadata.Basis.ShowLabel</c> auf <c>false</c> bekommt die Beschriftung die Klasse
        /// <c>sr-only</c>: Sie verschwindet aus dem Bild, bleibt aber im Markup und damit für
        /// Screenreader erhalten. Der Text aus <c>[Display]</c> wird deshalb weiterhin gebraucht.
        /// </summary>
        [Display(Name = "Interne Notiz")]
        [UIHint(UiEditor.RichText.Richtext)]
        [AdditionalMetadata(UiMetadata.Basis.ShowLabel, false)]
        public string Notiz { get; set; }
    }
}
using FMH.Komponente.Basis.Erweiterungen;
using FMH.Komponente.Basis.Ui.Feedback.Alert;
using FMH.Komponente.WebUI.Areas.RichText.Models;
using Microsoft.AspNetCore.Mvc;

namespace FMH.Komponente.WebUI.Areas.RichText.Controllers.Beispiele
{
    /// <summary>
    /// Ein Controller je EditorTemplate, je Variante ein GET+POST-Paar. Die ModelState-Verzweigung
    /// steht bewusst inline in den POST-Actions, sie ist der Lerninhalt der Beispiele.
    /// </summary>
    [Area("RichText")]
    public class RichtextController : Controller
    {
        [HttpGet]
        public IActionResult OhneLabel()
        {
            return this.View(new RichtextOhneLabelBeispielViewModel());
        }

        [HttpPost]
        [ValidateAntiForgeryToken]
        public IActionResult OhneLabel(RichtextOhneLabelBeispielViewModel modell)
        {
            // Die ausgeblendete Beschriftung ändert nichts an der Übernahme: gebunden und geprüft
            // wird das versteckte Feld, wie in den übrigen Varianten auch.
            var inhalt = string.IsNullOrWhiteSpace(modell.Notiz)
                ? "leer"
                : modell.Notiz;

            this.AddAlert($"Formular übertragen – Notiz: {inhalt}", AlertType.Success, autoHide: true);

            return this.RedirectToAction(nameof(this.OhneLabel));
        }

        [HttpGet]
        public IActionResult Pflicht()
        {
            return this.View(new RichtextPflichtBeispielViewModel());
        }

        [HttpPost]
        [ValidateAntiForgeryToken]
        public IActionResult Pflicht(RichtextPflichtBeispielViewModel modell)
        {
            if (this.ModelState.IsValid)
            {
                // Gültig: Meldung merken und per Redirect auf die GET-Action (PRG verhindert
                // doppeltes Absenden bei erneutem Laden).
                this.AddAlert("Formular ist gültig – Begründung erfasst.", AlertType.Success, autoHide: true);
                return this.RedirectToAction(nameof(this.Pflicht));
            }

            // Ungültig: dieselbe View mit den Validierungsfehlern erneut anzeigen.
            return this.View(modell);
        }

        [HttpGet]
        public IActionResult Standard()
        {
            return this.View(new RichtextStandardBeispielViewModel());
        }

        [HttpPost]
        [ValidateAntiForgeryToken]
        public IActionResult Standard(RichtextStandardBeispielViewModel modell)
        {
            // Ohne Pflichtangabe ist das Formular immer gültig. Die Meldung zeigt das übertragene
            // Markup, damit sichtbar wird, was der Editor liefert und was der Sanitizer davon
            // übrig lässt.
            var inhalt = string.IsNullOrWhiteSpace(modell.Beschreibung)
                ? "leer"
                : modell.Beschreibung;

            this.AddAlert($"Formular übertragen – Inhalt: {inhalt}", AlertType.Success, autoHide: true);

            return this.RedirectToAction(nameof(this.Standard));
        }
    }
}

Darstellung

Interne Notiz


UiMetadata.Basis.ShowLabel auf false blendet die Beschriftung aus:

[Display(Name = "Interne Notiz")]
[UIHint(UiEditor.RichText.Richtext)]
[AdditionalMetadata(UiMetadata.Basis.ShowLabel, false)]
public string Notiz { get; set; }

Ausgeblendet heisst nicht entfernt: Das Template hängt der Beschriftung die Klasse sr-only an. Sie verschwindet aus dem Bild, bleibt aber im Markup und benennt das Feld weiterhin für Screenreader. Der Text aus [Display] wird also weiterhin gebraucht, auch wenn ihn niemand sieht.

Sinnvoll, wenn der Zusammenhang schon aus der Umgebung hervorgeht, etwa unter einer eigenen Überschrift oder in einer Tabellenzelle. Wo das nicht der Fall ist, fehlt der sehenden Person die Beschriftung ersatzlos.

Was zu beachten ist

  • Die Konstante heisst UiMetadata.Basis.ShowLabel, ihr Wert ist "GroupLabel"; UiMetadata.Basis.ShowGroupLabel ist derselbe Schlüssel unter anderem Namen.
  • Die Metadaten brauchen den registrierten AdditionalMetadataProvider; ohne ihn bleibt die Beschriftung sichtbar, ohne Fehlermeldung.
  • Das EditorTemplate Textarea der Basis wertet diesen Schlüssel nicht aus, dort lässt sich die Beschriftung nicht ausblenden.

Pflichtfeld

Mit [Required] muss Inhalt erfasst sein. Ein Editor, in dem nur ein leerer Absatz steht, gilt als leer – darum kümmert sich die Validierungs-Routine der Basis.

@model RichtextPflichtBeispielViewModel

<form asp-area="RichText" asp-controller="Richtext" asp-action="Pflicht" method="post">
    @Html.EditorFor(m => m.Begruendung)
    @Html.Fmh(Fmh.Button()
        .Text("Absenden")
        .Size(ButtonSize.Sm)
        .Attributes(new { @class = "mt-2" }))
</form>
using System.ComponentModel.DataAnnotations;

namespace FMH.Komponente.WebUI.Areas.RichText.Models
{
    public class RichtextPflichtBeispielViewModel
    {
        /// <summary>
        /// [Required] wirkt auf das versteckte Feld hinter dem Editor. Ein leerer Editor liefert
        /// "&lt;p&gt;&lt;br&gt;&lt;/p&gt;"; die Validierungs-Routine der Basis räumt das vor dem Absenden zu einer
        /// leeren Zeichenkette auf, damit die Pflichtprüfung greift.
        /// </summary>
        [Display(Name = "Begründung")]
        [UIHint(UiEditor.RichText.Richtext)]
        [Required(ErrorMessage = "Bitte eine Begründung erfassen.")]
        public string Begruendung { get; set; }
    }
}
using FMH.Komponente.Basis.Erweiterungen;
using FMH.Komponente.Basis.Ui.Feedback.Alert;
using FMH.Komponente.WebUI.Areas.RichText.Models;
using Microsoft.AspNetCore.Mvc;

namespace FMH.Komponente.WebUI.Areas.RichText.Controllers.Beispiele
{
    /// <summary>
    /// Ein Controller je EditorTemplate, je Variante ein GET+POST-Paar. Die ModelState-Verzweigung
    /// steht bewusst inline in den POST-Actions, sie ist der Lerninhalt der Beispiele.
    /// </summary>
    [Area("RichText")]
    public class RichtextController : Controller
    {
        [HttpGet]
        public IActionResult OhneLabel()
        {
            return this.View(new RichtextOhneLabelBeispielViewModel());
        }

        [HttpPost]
        [ValidateAntiForgeryToken]
        public IActionResult OhneLabel(RichtextOhneLabelBeispielViewModel modell)
        {
            // Die ausgeblendete Beschriftung ändert nichts an der Übernahme: gebunden und geprüft
            // wird das versteckte Feld, wie in den übrigen Varianten auch.
            var inhalt = string.IsNullOrWhiteSpace(modell.Notiz)
                ? "leer"
                : modell.Notiz;

            this.AddAlert($"Formular übertragen – Notiz: {inhalt}", AlertType.Success, autoHide: true);

            return this.RedirectToAction(nameof(this.OhneLabel));
        }

        [HttpGet]
        public IActionResult Pflicht()
        {
            return this.View(new RichtextPflichtBeispielViewModel());
        }

        [HttpPost]
        [ValidateAntiForgeryToken]
        public IActionResult Pflicht(RichtextPflichtBeispielViewModel modell)
        {
            if (this.ModelState.IsValid)
            {
                // Gültig: Meldung merken und per Redirect auf die GET-Action (PRG verhindert
                // doppeltes Absenden bei erneutem Laden).
                this.AddAlert("Formular ist gültig – Begründung erfasst.", AlertType.Success, autoHide: true);
                return this.RedirectToAction(nameof(this.Pflicht));
            }

            // Ungültig: dieselbe View mit den Validierungsfehlern erneut anzeigen.
            return this.View(modell);
        }

        [HttpGet]
        public IActionResult Standard()
        {
            return this.View(new RichtextStandardBeispielViewModel());
        }

        [HttpPost]
        [ValidateAntiForgeryToken]
        public IActionResult Standard(RichtextStandardBeispielViewModel modell)
        {
            // Ohne Pflichtangabe ist das Formular immer gültig. Die Meldung zeigt das übertragene
            // Markup, damit sichtbar wird, was der Editor liefert und was der Sanitizer davon
            // übrig lässt.
            var inhalt = string.IsNullOrWhiteSpace(modell.Beschreibung)
                ? "leer"
                : modell.Beschreibung;

            this.AddAlert($"Formular übertragen – Inhalt: {inhalt}", AlertType.Success, autoHide: true);

            return this.RedirectToAction(nameof(this.Standard));
        }
    }
}

Darstellung


[Required] wirkt auf das versteckte Feld hinter dem Editor:

[Display(Name = "Begründung")]
[UIHint(UiEditor.RichText.Richtext)]
[Required(ErrorMessage = "Bitte eine Begründung erfassen.")]
public string Begruendung { get; set; }

Der entscheidende Punkt ist der leere Editor: Quill liefert dafür nicht eine leere Zeichenkette, sondern <p><br></p>. Ohne Aufräumen wäre das ein Wert und die Pflichtprüfung liefe ins Leere. setQuillElement in jquery-fmh-validation.js fängt genau diesen Fall ab und setzt das Feld auf leer, bevor validiert wird.

Was zu beachten ist

  • Die Beschriftung bekommt über [Display] und die required-Klasse den Stern; die Fehlermeldung erscheint unter dem Editor im invalid-feedback-Bereich.
  • Serverseitig gilt [Required] unabhängig vom Browser erneut, die Aufräumroutine läuft im Browser und ist damit kein Ersatz für die Prüfung am Server.
  • Zusätzliche Längenprüfungen ([StringLength]) zählen Markup mit, nicht den sichtbaren Text. Für eine Begrenzung nach Zeichen des reinen Texts braucht es eine eigene Prüfung.