@edugouvfr/ngx-dsfr-ext
Version:
NgxDsfrExt est une extension au package @edugouvfr/ngx-dsfr (portage Angular des éléments DSFR)
1,148 lines (1,139 loc) • 91.3 kB
JavaScript
import * as i0 from '@angular/core';
import { Injectable, inject, Component, ViewEncapsulation, CUSTOM_ELEMENTS_SCHEMA, Input, Directive, NgZone, Renderer2, ElementRef, ViewChild, EventEmitter, forwardRef, Output } from '@angular/core';
import * as i2 from '@angular/forms';
import { FormsModule, ReactiveFormsModule, NG_VALUE_ACCESSOR } from '@angular/forms';
import * as i3 from '@edugouvfr/ngx-dsfr';
import { DsfrI18nService, DsfrSeverityConst, DsfrSkiplinksModule, DsfrFormSelectModule, DsfrButtonModule, DsfrI18nPipe, DefaultValueAccessorComponent, Language } from '@edugouvfr/ngx-dsfr';
import * as i1 from '@angular/common';
import { CommonModule } from '@angular/common';
import Quill from 'quill';
import Emitter from 'quill/core/emitter';
import { ReplaySubject } from 'rxjs';
/**
* Constantes des identifiants d'actions de formatage supportés.
*/
var FormatConst;
(function (FormatConst) {
FormatConst["ALIGN"] = "align";
FormatConst["BACKGROUND"] = "background";
FormatConst["BLOCKQUOTE"] = "blockquote";
FormatConst["BOLD"] = "bold";
FormatConst["CLEAN"] = "clean";
FormatConst["CODE_BLOCK"] = "code-block";
FormatConst["COLOR"] = "color";
FormatConst["DIRECTION"] = "direction";
FormatConst["HEADER"] = "header";
FormatConst["IMAGE"] = "image";
FormatConst["INDENT"] = "indent";
FormatConst["ITALIC"] = "italic";
FormatConst["LINK"] = "link";
FormatConst["LIST"] = "list";
FormatConst["SCRIPT"] = "script";
FormatConst["SIZE"] = "size";
FormatConst["STRIKE"] = "strike";
FormatConst["UNDERLINE"] = "underline";
})(FormatConst || (FormatConst = {}));
/**
* Mapping des clés d'internationalisation des libellés des actions de formatage.
*
* Selon le contexte, le libellé sera utilisé comme tooltip, libellé de champ ou transcription accessible.
*/
const FORMAT_I18N_KEYS = {
'align': {
normal: 'editor.controls.align.left',
center: 'editor.controls.align.center',
right: 'editor.controls.align.right',
justify: 'editor.controls.align.justify',
},
'background': 'editor.controls.background',
'blockquote': 'editor.controls.blockquote',
'clean': 'editor.controls.clean',
'bold': 'editor.controls.bold',
'code-block': 'editor.controls.code',
'color': 'editor.controls.color',
'direction': { normal: 'editor.controls.direction.normal', rtl: 'editor.controls.direction.rtl' },
'header': {
'label': 'editor.controls.header.label',
'0': 'editor.controls.header.0',
'1': 'editor.controls.header.1',
'2': 'editor.controls.header.2',
'3': 'editor.controls.header.3',
'4': 'editor.controls.header.4',
'5': 'editor.controls.header.5',
'6': 'editor.controls.header.6',
},
'image': 'editor.controls.image.label',
'indent': { '-1': 'editor.controls.indent.-1', '+1': 'editor.controls.indent.+1' },
'italic': 'editor.controls.italic',
'link': 'editor.controls.link.label',
'list': {
ordered: 'editor.controls.list.ordered',
bullet: 'editor.controls.list.bullet',
unchecked: 'editor.controls.list.checkbox',
},
'script': { sub: 'editor.controls.script.sub', super: 'editor.controls.script.super' },
'size': {
label: 'editor.controls.size.label',
small: 'editor.controls.size.small',
normal: 'editor.controls.size.normal',
large: 'editor.controls.size.large',
huge: 'editor.controls.size.huge',
},
'strike': 'editor.controls.strike',
'underline': 'editor.controls.underline',
};
/**
* Mapping des icônes par défaut correspondant aux actions de formatage supportées.
*
* Note de conception (RPA+KGE) pour l'instant on décide de maintenir les icônes dans une structure à part afin de
* verrouiller le modèle. Si demain on souhaite donner à l'utilisateur la possibilité de surcharger les icônes des
* actions de formatage alors on pourra réintégrer les icônes au modèle en augmentant l'interface `BaseFormatControl`
* d'une propriété `icon` (rétrocompatible car la propriété aura une valeur par défaut dans le modèle par défaut)
*/
const FORMAT_ICON = {
'align': {
normal: 'fr-icon-align-left',
center: 'fr-icon-align-center',
right: 'fr-icon-align-right',
justify: 'fr-icon-align-justify',
},
'background': 'fr-icon-brush-3-line',
'blockquote': 'fr-icon-quote-line',
'clean': 'fr-icon-clean',
'bold': 'fr-icon-bold',
'code-block': 'fr-icon-code-view',
'color': 'fr-icon-font-color',
'direction': 'fr-icon-direction-rtl',
'header': undefined,
'image': 'fr-icon-image-line',
'indent': { '-1': 'fr-icon-indent-decrease', '+1': 'fr-icon-indent-increase' },
'italic': 'fr-icon-italic',
'link': 'fr-icon-link',
'list': { ordered: 'fr-icon-list-ordered', bullet: 'fr-icon-list-unordered', unchecked: 'fr-icon-list-check' },
'script': { sub: 'fr-icon-subscript', super: 'fr-icon-superscript' },
'size': undefined,
'strike': 'fr-icon-strike',
'underline': 'fr-icon-underline',
};
/**
* Les différents mode supportés par les contrôles de formatage à valeur unique.
*/
var ControlMode;
(function (ControlMode) {
/**
* Active/désactive le format.
*/
ControlMode["TOGGLE"] = "toggle";
/**
* Le format dépend de la capture d'un paramètre utilisateur.
*/
ControlMode["PROMPT"] = "prompt";
/**
* Action issue d'une des options d'une action multivaluée érigée en action unique autonome.
* Ex. je veux un bouton permettant de formater en Titre de niveau 1 (H1)
*/
ControlMode["CUSTOM"] = "custom";
/**
* Permet de représenter l'une des options d'une action multivaluée.
*/
ControlMode["VALUE"] = "valued";
/**
* Dénote une action de formatage multivaluée à afficher sous forme de selecteur.
*/
ControlMode["SELECT"] = "select";
/**
* Dénote une action de formatage multivaluée à afficher sous de groupe de bouton exclusif.
*/
ControlMode["RADIO"] = "radio";
/**
* Mode spécial dénotant le contrôle de réinitialisation.
*/
ControlMode["CLEAN"] = "clean";
})(ControlMode || (ControlMode = {}));
/**
* Les valeurs possibles pour le format 'size'.
*/
var SizeValue;
(function (SizeValue) {
SizeValue["SMALL"] = "small";
SizeValue["NORMAL"] = "normal";
SizeValue["LARGE"] = "large";
SizeValue["HUGE"] = "huge";
})(SizeValue || (SizeValue = {}));
/**
* Les valeurs possibles pour le format 'header'.
*/
var HeaderValue;
(function (HeaderValue) {
HeaderValue[HeaderValue["NORMAL"] = 0] = "NORMAL";
HeaderValue[HeaderValue["H1"] = 1] = "H1";
HeaderValue[HeaderValue["H2"] = 2] = "H2";
HeaderValue[HeaderValue["H3"] = 3] = "H3";
HeaderValue[HeaderValue["H4"] = 4] = "H4";
HeaderValue[HeaderValue["H5"] = 5] = "H5";
HeaderValue[HeaderValue["H6"] = 6] = "H6";
})(HeaderValue || (HeaderValue = {}));
/**
* Les valeurs possibles pour le format 'align'.
*/
var AlignValue;
(function (AlignValue) {
AlignValue["LEFT"] = "normal";
AlignValue["CENTER"] = "center";
AlignValue["RIGHT"] = "right";
AlignValue["JUSTIFY"] = "justify";
})(AlignValue || (AlignValue = {}));
/**
* Les valeurs possibles pour le format 'list'.
*/
var ListValue;
(function (ListValue) {
ListValue["ORDERED"] = "ordered";
ListValue["BULLET"] = "bullet";
ListValue["CHECKBOX"] = "unchecked";
})(ListValue || (ListValue = {}));
/**
* Les valeurs possibles pour le format 'script'.
*/
var ScriptValue;
(function (ScriptValue) {
/**
* Subscript.
*/
ScriptValue["SUBSCRIPT"] = "sub";
/**
* Superscript.
*/
ScriptValue["SUPERSCRIPT"] = "super";
})(ScriptValue || (ScriptValue = {}));
/**
* Les valeurs possibles pour le format 'indent'.
*/
var IndentValue;
(function (IndentValue) {
/**
* Decrease indent.
*/
IndentValue["DECREASE"] = "-1";
/**
* Increase indent.
*/
IndentValue["INCREASE"] = "+1";
})(IndentValue || (IndentValue = {}));
/**
* Les valeurs possibles pour le format 'direction'.
*/
var DirectionValue;
(function (DirectionValue) {
/**
* De gauche à droite.
*/
DirectionValue["NORMAL"] = "normal";
/**
* De droite à gauche.
*/
DirectionValue["RTL"] = "rtl";
})(DirectionValue || (DirectionValue = {}));
/**
* Permet de sortir une des options de formatage d'une action multivaluée et d el'ériger en action unique autonome.
*/
//TODO: pas encore opérationnel, à re-travailler notamment sur la fourniture icône, libellés i18n, etc.
class DsfrCustomFormatControl {
/**
* Construit une action de formatage valuée.
*
* @param name le format de cette action
* @param value la valeur spécifique de cette action de formatage
*/
constructor(name, value) {
this.name = name;
this.value = value;
/**
* Le mode d'une action avec valeur prédéfinie est forcément 'valued'.
*/
this.mode = ControlMode.CUSTOM;
}
}
/**
*
*/
class DsfrToggleFormatControl {
constructor(name) {
this.name = name;
/**
* Le mode d'une action avec valeur prédéfinie est forcément 'toggle'.
*/
this.mode = ControlMode.TOGGLE;
}
}
/**
*
*/
class DsfrPromptFormatControl {
constructor(name) {
this.name = name;
/**
* Le mode d'une action avec valeur prédéfinie est forcément 'prompt'.
*/
this.mode = ControlMode.PROMPT;
}
}
/**
* Représente une des actions possibles d'une méta-action multi-valuée.
*/
class DsfrValueFormatControl {
/**
* Construit une action de formatage valuée.
*
* @param value la valeur spécifique de cette action de formatage
*/
constructor(value) {
this.value = value;
/**
* Le mode d'une action avec valeur prédéfinie est forcément 'valued'.
*/
this.mode = ControlMode.VALUE;
}
}
/**
* Représente une action multi-valuée en mode radio.
*/
class RadioFormatControl {
/**
* Construit un control de type radio.
*
* @param values les différentes valeurs possibles pour le control de type radio
*/
constructor(name, values, group) {
this.name = name;
this.values = values;
this.group = group;
this.mode = ControlMode.RADIO;
}
}
/**
* Représente une action multi-valuée en mode select.
*/
class SelectFormatControl {
/**
* Construit un control de type select.
*
* @param values les différentes valeurs possibles pour le control de type select.
*/
constructor(name, values) {
this.name = name;
this.values = values;
this.mode = ControlMode.SELECT;
this.group = false;
}
}
/**
* Options par défaut de la toolbar.
*/
const DEFAULT_TOOLBAR_OPTIONS = [
// niveau de titre
new SelectFormatControl(FormatConst.HEADER, [
new DsfrValueFormatControl(HeaderValue.NORMAL),
new DsfrValueFormatControl(HeaderValue.H1),
new DsfrValueFormatControl(HeaderValue.H2),
new DsfrValueFormatControl(HeaderValue.H3),
new DsfrValueFormatControl(HeaderValue.H4),
new DsfrValueFormatControl(HeaderValue.H5),
new DsfrValueFormatControl(HeaderValue.H6),
]),
// taille du texte
new SelectFormatControl(FormatConst.SIZE, [
new DsfrValueFormatControl(SizeValue.SMALL),
new DsfrValueFormatControl(SizeValue.NORMAL),
new DsfrValueFormatControl(SizeValue.LARGE),
new DsfrValueFormatControl(SizeValue.HUGE),
]),
// mise en forme du texte (gras, italique, etc.)
[
new DsfrToggleFormatControl(FormatConst.BOLD),
new DsfrToggleFormatControl(FormatConst.ITALIC),
new DsfrToggleFormatControl(FormatConst.UNDERLINE),
new DsfrToggleFormatControl(FormatConst.STRIKE),
],
new RadioFormatControl(FormatConst.SCRIPT, [new DsfrValueFormatControl(ScriptValue.SUPERSCRIPT), new DsfrValueFormatControl(ScriptValue.SUBSCRIPT)], true),
// couleurs
[new DsfrPromptFormatControl(FormatConst.COLOR), new DsfrPromptFormatControl(FormatConst.BACKGROUND)],
// mise en forme avancée
[
new DsfrToggleFormatControl(FormatConst.BLOCKQUOTE),
new DsfrToggleFormatControl(FormatConst.CODE_BLOCK),
new DsfrPromptFormatControl(FormatConst.LINK),
new DsfrPromptFormatControl(FormatConst.IMAGE),
],
// listes
new RadioFormatControl(FormatConst.LIST, [
new DsfrValueFormatControl(ListValue.ORDERED),
new DsfrValueFormatControl(ListValue.BULLET),
// new ValueFormatControl(ListValue.CHECKBOX), //RPA: pas proposé par défaut car non standard au sens html
], true),
// indentation des lignes de texte
new RadioFormatControl(FormatConst.INDENT, [new DsfrValueFormatControl(IndentValue.DECREASE), new DsfrValueFormatControl(IndentValue.INCREASE)], true),
// alignement des lignes de texte
new RadioFormatControl(FormatConst.ALIGN, [
new DsfrValueFormatControl(AlignValue.LEFT), // même effet que clean, faut-il le conserver ? ça fait un peu bizarre sans
new DsfrValueFormatControl(AlignValue.CENTER),
new DsfrValueFormatControl(AlignValue.RIGHT),
new DsfrValueFormatControl(AlignValue.JUSTIFY),
], true),
// reset du format
{
name: FormatConst.CLEAN,
mode: ControlMode.CLEAN,
},
];
const MESSAGES_EN = {
editor: {
placeholder: 'Veuillez éditer votre contenu',
controls: {
align: { left: 'Left', center: 'Center', right: 'Right', justify: 'Justify' },
background: `Background color`,
blockquote: 'Quote',
clean: 'Reset format',
bold: 'Bold',
code: 'Code block',
color: 'Text color',
direction: { normal: 'Left to right', rtl: 'Right to left' },
header: {
'label': 'Heading level',
'0': 'Normal',
'1': 'Heading 1',
'2': 'Heading 2',
'3': 'Heading 3',
'4': 'Heading 4',
'5': 'Heading 5',
'6': 'Heading 6',
},
image: { label: 'Image', prompt: `Image path:` },
indent: { '-1': `Decrease indentation`, '+1': `Increase indentation` },
italic: 'Italic',
link: { label: 'Link', prompt: `Link URL:` },
list: { label: 'List', ordered: 'Ordered', bullet: 'Unordered', checkbox: 'Checklist' },
script: { sub: 'Subscript', super: 'Superscript' },
size: { label: 'Text size', small: 'Small', normal: 'Normal', large: 'Large', huge: 'Huge' },
strike: 'Strike',
underline: 'Underline',
},
},
};
// Ordre alphabétique requis
const MESSAGES_FR = {
editor: {
placeholder: 'Veuillez éditer votre contenu',
controls: {
align: {
label: 'Alignement du texte',
left: 'Aligner à gauche',
center: 'Centrer',
right: 'Aligner à droite',
justify: 'Justifier',
},
background: `Couleur du fond`,
blockquote: 'Guillemets',
clean: 'Réinitialiser le formatage',
bold: 'Gras',
code: 'Bloc de code',
color: 'Couleur du texte',
direction: { label: 'Direction', normal: 'De gauche à droite', rtl: 'De droite à gauche' },
header: {
'label': 'Niveau de titre',
'0': 'Normal',
'1': 'Titre 1',
'2': 'Titre 2',
'3': 'Titre 3',
'4': 'Titre 4',
'5': 'Titre 5',
'6': 'Titre 6',
},
image: { label: 'Image', prompt: `Chemin vers l'image :` },
indent: { 'label': 'Indentation', '-1': `Réduire l'indentation`, '+1': `Augmenter l'indentation` },
italic: 'Italique',
link: { label: 'Lien', prompt: `URL du lien :` },
list: { label: 'Liste', ordered: 'Liste numérotée', bullet: 'Liste à puces', checkbox: 'Liste à cocher' },
script: { sub: 'Indice', super: 'Exposant' },
size: { label: 'Taille du texte', small: 'Petit', normal: 'Normal', large: 'Large', huge: 'Énorme' },
strike: 'Barré',
underline: 'Souligné',
},
},
};
/**
* Service permettant de gérer la communication bidirectionnelle entre la toolbar et son éditeur.
*/
class DsfrEditorService {
constructor() {
// RPA: j'utilise des ReplaySubject(1) pour ne pas être parasité par la première émission de la valeur initiale
// du BehaviorSubject, par contre comme pour ce dernier les souscripteurs seront notifiés de la dernière valeur
// émise _à partir du moment où une valeur a été explicitement émise_
// source: https://stackoverflow.com/a/43119768/8737817
this._currentFormattingSubject = new ReplaySubject(1);
this._formattingRequestSubject = new ReplaySubject(1);
this._modelChangeSubject = new ReplaySubject(1);
this._editorChangeSubject = new ReplaySubject(1);
this._focusOnEditorRequestSubject = new ReplaySubject(1);
this.currentFormatting$ = this._currentFormattingSubject.asObservable();
this.formattingRequest$ = this._formattingRequestSubject.asObservable();
this.modelChange$ = this._modelChangeSubject.asObservable();
this.editorChange$ = this._editorChangeSubject.asObservable();
this.focusOnEditorRequest$ = this._focusOnEditorRequestSubject.asObservable();
}
/**
* Permet de positionner la mise en forme de la sélection courante.
*/
setCurrentFormatting(formats) {
this._currentFormattingSubject.next(formats);
}
/**
* Permet de demander une nouvelle mise en forme sur la sélection courante.
*/
requestFormatting(request) {
this._formattingRequestSubject.next(request);
}
/**
* Permet de demander la prise de focus sur l'éditeur.
*/
requestFocusOnEditorContent() {
this._focusOnEditorRequestSubject.next();
}
/**
* Permet de notifier un changement externe sur le modèle HTML.
*/
emitModelChange(value) {
this._modelChangeSubject.next(value);
}
/**
* Permet de notifier un changement interne du contenu de l'éditeur HTML.
*/
emitEditorChange(value) {
this._editorChangeSubject.next(value);
}
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.13", ngImport: i0, type: DsfrEditorService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "18.2.13", ngImport: i0, type: DsfrEditorService }); }
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.13", ngImport: i0, type: DsfrEditorService, decorators: [{
type: Injectable
}], ctorParameters: () => [] });
class EduEditorContentComponent {
constructor() {
/**
* L'identifiant du tag hôte pour le composant éditeur.
*
* Attention : cet input est statique et doit être connu à l'initialisation du composant.
*/
this.editorId = 'editor';
/**
* Active la lecure seule.
*/
this.readOnly = false;
/**
* Dev/debug : permet d'afficher le code HTML interne de l'éditeur (i.e. celui qui sert pour l'affichage WYSIWYG).
*/
this.showEditorHtmlDebugPanel = false;
/**
* Dev/debug : permet d'afficher le code HTML qui sera récupéré en sortie.
*/
this.showSemanticHtmlDebugPanel = false;
/** @internal */
this.i18n = inject(DsfrI18nService);
/**
* Pour pouvoir utiliser DsfrSeverityConst côté template.
*/
this.severityConst = DsfrSeverityConst;
/**
* Options de configuration de l'éditeur Quill.
*/
this._options = {
readOnly: this.readOnly,
modules: {
toolbar: false,
},
placeholder: this.i18n.t('editor.placeholder'),
theme: 'snow',
};
this._subscriptions = [];
this.editorService = inject(DsfrEditorService);
}
ngAfterViewInit() {
this._quill = new Quill('#' + this.editorId, this._options);
this._quill.root.setAttribute('role', 'textbox');
this._quill.root.setAttribute('aria-multiline', 'true');
this._quill.root.setAttribute('spellcheck', 'false');
// fix a11y : par défaut, le key-binding Quill sur la touche Tab empêche de sortir de l'éditeur en utilisant Tab
delete this._quill.getModule('keyboard')?.bindings['Tab'];
if (this.ariaLabelledBy && this.ariaLabelledBy.length) {
this._quill.root.setAttribute('aria-labelledby', this.ariaLabelledBy);
}
if (this.message && this.message.length && this.messagesGroupId && this.messagesGroupId.length) {
this._quill.root.setAttribute('aria-describedby', this.messagesGroupId);
}
// On se met à l'écoute de la sélection utilisateur
this._editorSelectionHandler = this._quill.on(Emitter.events.SELECTION_CHANGE, (range) => this._onEditorSelectionChange(range));
// Détecte le changement de contenu de l'éditeur.
this._editorChangeHandler = this._quill.on(Emitter.events.EDITOR_CHANGE, (eventName, delta, oldContents, source) => this._onEditorChange(eventName, delta, oldContents, source));
this._subscriptions.push(this.editorService.formattingRequest$.subscribe((req) => {
const range = this._quill.getSelection();
if (req === undefined) {
if (range) {
// remove formatting
this._quill.removeFormat(range.index, range.length);
}
}
else if ('image' === req.action) {
// perform special image format
if (range) {
this._quill.uploader.upload(range, req.value);
}
}
else {
// perform standart formatting
this._quill.format(req.action, req.value);
}
this.editorService.setCurrentFormatting(this._getCurrentFormatting());
}));
// Écoute les mises à jour du modèle externe.
this._subscriptions.push(this.editorService.modelChange$.subscribe((newValue) => {
this.setEditorContent(newValue);
}));
// Écoute les demandes de prise de focus
this._subscriptions.push(this.editorService.focusOnEditorRequest$.subscribe(() => {
this._quill.focus();
}));
}
ngOnDestroy() {
this._subscriptions.forEach((s) => s.unsubscribe());
this._quill.off(Emitter.events.SELECTION_CHANGE, this._editorSelectionHandler.off);
this._quill.off(Emitter.events.EDITOR_CHANGE, this._editorChangeHandler.off);
}
ngOnChanges({ ariaLabelledBy, message, messagesGroupId }) {
if (ariaLabelledBy) {
if (this.ariaLabelledBy && this.ariaLabelledBy.length) {
this._quill?.root.setAttribute('aria-labelledby', this.ariaLabelledBy);
}
else {
this._quill?.root.removeAttribute('aria-labelledby');
}
}
if (message || messagesGroupId) {
if (this.message && this.message.length && this.messagesGroupId && this.messagesGroupId.length) {
this._quill?.root.setAttribute('aria-describedby', this.messagesGroupId);
}
else {
this._quill?.root.removeAttribute('aria-describedby');
}
}
}
hasMessage(severity) {
return !this.isStringEmptyOrNull(this.message) && severity === this.messageSeverity;
}
setEditorContent(newValue) {
this._quill.setContents(this._quill.clipboard.convert({ html: newValue }), 'silent');
}
//FIXME: utiliser les utils de la lib ngx-dsfr
isStringEmptyOrNull(s) {
return !s || s.trim() === '';
}
_onEditorSelectionChange(range) {
// RPA: vu avec SAB le 25/09/2024 : on considère que la toolbar reflète l'état courant relatif à la position du
// curseur et/ou la sélection active dans l'éditeur
// si on change d'avis et qu'on on veut conserver l'état de la toolbar correspondant à la dernière position du
// curseur avant une perte de focus de l'éditeur alors il ne faut pas émettre d'event si range==null
this.editorService.setCurrentFormatting(this._getCurrentFormatting());
}
/**
* Permet de se mettre à l'écoute du contenu de l'éditeur afin de reset la toolbar si l'éditeur est vidé.
*/
_onEditorChange(eventName, delta, oldContents, source) {
if (eventName === 'text-change') {
// Note even when Quill is empty, there is still a blank line represented by '\n', so getLength will return 1.
if (this._quill.getLength() === 1) {
this.editorService.setCurrentFormatting(undefined);
}
else {
this.editorService.setCurrentFormatting(this._getCurrentFormatting());
}
// on signale le changement à l'extérieur
this.editorService.emitEditorChange(this._quill.getSemanticHTML());
}
}
_getCurrentFormatting() {
const range = this._quill.getSelection();
return range ? this._quill.getFormat(range.index, range.length) : undefined;
}
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.13", ngImport: i0, type: EduEditorContentComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "18.2.13", type: EduEditorContentComponent, isStandalone: true, selector: "edu-editor-content", inputs: { editorId: "editorId", ariaLabelledBy: "ariaLabelledBy", readOnly: "readOnly", message: "message", messageSeverity: "messageSeverity", messagesGroupId: "messagesGroupId", showEditorHtmlDebugPanel: "showEditorHtmlDebugPanel", showSemanticHtmlDebugPanel: "showSemanticHtmlDebugPanel" }, usesOnChanges: true, ngImport: i0, template: "<div id=\"{{ editorId }}\"></div>\n\n<div class=\"fr-messages-group\" [id]=\"messagesGroupId\" aria-live=\"polite\">\n @if (message) {\n <p\n class=\"fr-message\"\n [ngClass]=\"{\n 'fr-message--error': hasMessage(severityConst.ERROR),\n 'fr-message--valid': hasMessage(severityConst.SUCCESS),\n 'fr-message--info': hasMessage(severityConst.INFO)\n }\">\n {{ message }}\n </p>\n }\n</div>\n\n@if (showEditorHtmlDebugPanel) {\n <div class=\"debug-panel\">\n <p>Editor source</p>\n <code>{{ _quill?.root?.innerHTML }}</code>\n </div>\n}\n\n@if (showSemanticHtmlDebugPanel) {\n <div class=\"debug-panel\">\n <p>HTML output</p>\n <code>{{ _quill?.getSemanticHTML() }}</code>\n </div>\n}\n", styles: ["edu-editor-content .ql-container{font-family:Marianne,sans-serif;margin-top:.4375rem}edu-editor-content .ql-editor ol{list-style-type:unset;padding-inline-start:unset;counter-reset:unset}edu-editor-content .ql-editor ol>li{counter-increment:unset}edu-editor-content .ql-editor ol>li::marker{content:unset;font-size:unset;font-weight:unset}edu-editor-content .debug-panel{margin-top:1rem;border:1px solid lightgray;background-color:#f9f9f9}edu-editor-content .debug-panel>p:first-child{display:inline-block;position:absolute;color:#5c5b5b;background-color:#d3d3d3;border:1px solid lightgray;border-bottom-right-radius:6px;font-size:.8em;font-weight:700;padding:0 .5rem;margin-bottom:0;height:1.6rem;width:6.6rem}edu-editor-content .debug-panel code{display:block;padding:2rem .7rem .7rem;font-family:monospace monospace;color:#505050;font-size:.9em}edu-editor-content .fr-messages-group{margin-top:1rem}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "directive", type: i1.NgClass, selector: "[ngClass]", inputs: ["class", "ngClass"] }], encapsulation: i0.ViewEncapsulation.None }); }
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.13", ngImport: i0, type: EduEditorContentComponent, decorators: [{
type: Component,
args: [{ selector: 'edu-editor-content', encapsulation: ViewEncapsulation.None, standalone: true, imports: [CommonModule], schemas: [CUSTOM_ELEMENTS_SCHEMA], template: "<div id=\"{{ editorId }}\"></div>\n\n<div class=\"fr-messages-group\" [id]=\"messagesGroupId\" aria-live=\"polite\">\n @if (message) {\n <p\n class=\"fr-message\"\n [ngClass]=\"{\n 'fr-message--error': hasMessage(severityConst.ERROR),\n 'fr-message--valid': hasMessage(severityConst.SUCCESS),\n 'fr-message--info': hasMessage(severityConst.INFO)\n }\">\n {{ message }}\n </p>\n }\n</div>\n\n@if (showEditorHtmlDebugPanel) {\n <div class=\"debug-panel\">\n <p>Editor source</p>\n <code>{{ _quill?.root?.innerHTML }}</code>\n </div>\n}\n\n@if (showSemanticHtmlDebugPanel) {\n <div class=\"debug-panel\">\n <p>HTML output</p>\n <code>{{ _quill?.getSemanticHTML() }}</code>\n </div>\n}\n", styles: ["edu-editor-content .ql-container{font-family:Marianne,sans-serif;margin-top:.4375rem}edu-editor-content .ql-editor ol{list-style-type:unset;padding-inline-start:unset;counter-reset:unset}edu-editor-content .ql-editor ol>li{counter-increment:unset}edu-editor-content .ql-editor ol>li::marker{content:unset;font-size:unset;font-weight:unset}edu-editor-content .debug-panel{margin-top:1rem;border:1px solid lightgray;background-color:#f9f9f9}edu-editor-content .debug-panel>p:first-child{display:inline-block;position:absolute;color:#5c5b5b;background-color:#d3d3d3;border:1px solid lightgray;border-bottom-right-radius:6px;font-size:.8em;font-weight:700;padding:0 .5rem;margin-bottom:0;height:1.6rem;width:6.6rem}edu-editor-content .debug-panel code{display:block;padding:2rem .7rem .7rem;font-family:monospace monospace;color:#505050;font-size:.9em}edu-editor-content .fr-messages-group{margin-top:1rem}\n"] }]
}], ctorParameters: () => [], propDecorators: { editorId: [{
type: Input
}], ariaLabelledBy: [{
type: Input
}], readOnly: [{
type: Input
}], message: [{
type: Input
}], messageSeverity: [{
type: Input
}], messagesGroupId: [{
type: Input
}], showEditorHtmlDebugPanel: [{
type: Input
}], showSemanticHtmlDebugPanel: [{
type: Input
}] } });
/**
* Permet de rendre l'élément déonoté par le sélecteur explicitement non-focusable. Cela sert au niveau de la toolbar
* sur les dsfr-bouton et les dsfr-form-select qui embarquent des éléments nativement focusable tels que button et
* select.
*/
//TODO: (RPA) à réfléchir s'il ne faudrait pas plutôt prévoir d'ajouter un input `'unfocusable' permettant de gérer ça
// directement aux niveaux des composants eux-même (?)
class UnfocusableDirective {
constructor(_elementRef, _renderer) {
this._elementRef = _elementRef;
this._renderer = _renderer;
}
// RPA: Je me mets sur AfterViewInit car sur OnInit je n'ai pas encore le select lorsque je requête le DOM
// (pour le bouton par contre cela fonctionnait avec OnInit)
ngAfterViewInit() {
let el = this._elementRef.nativeElement.querySelector(this.selector);
if (el) {
this._renderer.setAttribute(el, 'tabindex', '-1');
}
else {
//FIXME: (RPA) utiliser LoggerService lorsque celui-ci sera exporté depuis ngx-dsfr
console.warn('UnfocusableDirective > aucun élément remontée par le sélecteur', this.selector);
}
}
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.13", ngImport: i0, type: UnfocusableDirective, deps: [{ token: i0.ElementRef }, { token: i0.Renderer2 }], target: i0.ɵɵFactoryTarget.Directive }); }
static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "18.2.13", type: UnfocusableDirective, isStandalone: true, selector: "[unfocusable]", inputs: { selector: ["unfocusable", "selector"] }, ngImport: i0 }); }
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.13", ngImport: i0, type: UnfocusableDirective, decorators: [{
type: Directive,
args: [{
selector: '[unfocusable]',
standalone: true,
}]
}], ctorParameters: () => [{ type: i0.ElementRef }, { type: i0.Renderer2 }], propDecorators: { selector: [{
type: Input,
args: ['unfocusable']
}] } });
class EduEditorToolbarComponent {
constructor() {
/**
* L'identifiant du tag hôte pour le composant éditeur.
*/
this.editorId = 'editor';
/**
* Active la lecure seule.
*/
//TODO: @Input() readOnly = false;
/**
* Options de la toolbar.
*/
this.toolbarOptions = DEFAULT_TOOLBAR_OPTIONS;
/**
* Modèle de présentation spécifique pour les select (header+size).
* RPA: je passe par un modèle de présentation spécifique et je n'utilise pas directement un binding sur
* currentSelectionFormats car j'ai besoin de reflêter l'état de sélection utilisateur sinon voici ce qui ce passe :
* l'utilisateur sélectionne une portion de texte et sélectionnz un niveau de titre H2, puis il place le curseur dans
* un paragraphe quelconque, le select reste sur H2 car la propriété interne 'value' n'est pas mise à jour lors de
* la sélection utilisateur (binding non bi-directionnel). C'est pourquoi il est nécessaire de passer par un modèle
* de présentation dédié à la gestion des select.
*/
this.selectModel = { header: null, size: null };
/**
* Permet de gérer l'affichage d'un outline custom lorsque la toolbar prend le focus.
*/
this.toolbarFocused = false;
this.ngZone = inject(NgZone);
this.renderer2 = inject(Renderer2);
this.elementRef = inject(ElementRef);
this.i18n = inject(DsfrI18nService);
this.editorService = inject(DsfrEditorService);
/**
* Positionne l'image Base64 dans l'editeur.
*/
this._uploadImage = async (event) => {
const target = event.target;
if (target?.files) {
this.performAction('image', target.files);
}
};
}
ngOnInit() {
this._sub = this.editorService.currentFormatting$.subscribe((formatting) => {
this._updateToolbar(formatting);
});
// Hors zone Angular pour des raisons de perf
this.ngZone.runOutsideAngular(() => {
this._unlistenFn = this.renderer2.listen('document', 'click', (e) => {
this.clickOutside(e);
});
});
}
ngOnDestroy() {
this._sub?.unsubscribe();
this._unlistenFn();
}
/**
* Handler sur l'input file associé à une inserion d'image.
*/
//FIXME : erreur dans la console : ParchmentError: [Parchment] Cannot wrap imag
onImageInputFileChange(event) {
this._uploadImage(event);
}
/**
* Permet de conditionner la structure HTML selon le type contrôle.
*/
getButtonType(control) {
let result = 'btn';
if (Array.isArray(control) && control.length) {
result = 'group';
}
else if ('values' in control && control.values && control.values.length) {
switch (control.mode) {
case ControlMode.RADIO:
result = 'radio';
break;
case ControlMode.SELECT:
result = 'select';
break;
default:
break;
}
}
return result;
}
/**
* Retourne l'icône correspondant au format passé en paramètre.
*
* @param format le format à considérer
* @param value la vaaleur à prendre en compte le cas échéant
* @returns la classe css correspondant à l'icône
*/
getIcon(format, value = undefined) {
let icon = FORMAT_ICON[format];
// la valeur peut être falsy d'où le test explicite sur not(undefined)
if (icon && value !== undefined) {
// on doit aller chercher l'icône en utilisant la valeur comme clé
icon = icon[value];
}
return icon;
}
/**
* Retourne la clé du libellé internationalisé.
* @param control le contôle de formatage
*/
getI18nKey(format, value = undefined) {
let key = FORMAT_I18N_KEYS[format];
if (value !== undefined) {
key = key;
key = key[String(value)];
}
return key;
}
/**
* Détermine si l'option de format doit être activée ou pas.
*
* @param format le nom de contrôle de format
* @param value la valeur de l'option considérée
* @returns vrai si le couple format/value fait partie des formats de la sélection courante, faux sinon
*/
isValueActive(format, value) {
// @ts-ignore (RPA) le test sur currentSelectionFormats est effectué à l'intérieur de la fonction isFormatActive
return this.isFormatActive(format) ? this._currentSelectionFormats[format] === value : false;
}
/**
* Retourne la liste des optiosn à affichier au sein du contrôle multivalué (select).
*
* @param control le contrôle multivalué à considéré
*/
getSelectOptions(control) {
return control.values.map((v) => this._buildSelectOption(control.name, v));
}
/**
* Permet d'indiquer si le contrôle de ce format doit être activé au niveau de la barre d'outils.
*
* @param format le format à considérer vis-à-vis des formats de la sélection courante
* @returns vrai si ce format doit être actif, faux sinon
*/
isFormatActive(format) {
return this._currentSelectionFormats ? format in this._currentSelectionFormats : false;
}
/**
* Permet de réagir aux actions utilisateur sur la toolbar.
*/
onToolbarControlPerform(event, format, value) {
event.preventDefault();
this._applyFormat(format, value);
}
/**
* Permet d'écouter la capture d'une couleur par l'utilisateur.
*/
onColorPickerChange(event, action) {
const target = event.target;
const value = target.value ?? undefined;
if (value) {
this.performAction(action, value);
}
}
/**
* Permet d'écouter les changement sur le format "list".
*
* @param action
* @param event
*/
onListSelect(action, event) {
this.performAction(action, event);
}
/**
* Permet de forcer la prise de focus sur le premier élément focusable de la barre d'outil lorsque le conteneur de
* la toolbar reçoit le focus.
*/
onToolbarFocus() {
this.toolbarFocused = true;
if (this.lastActiveControlElement) {
this.lastActiveControlElement.focus();
}
else {
const nextElem = this.elementRef.nativeElement.querySelector('.item-editor, .fr-select');
nextElem?.focus();
}
}
/**
* Permet de gérer la sortie de focus
*/
onToolbarBlur(event) {
// si le focus est pris par un élément de contrôle, on conserve l'outline custom
if (!event.currentTarget?.contains(event.relatedTarget)) {
this.toolbarFocused = false;
}
}
/**
* Permey de se mettre à l'écoute des actions utilisateur sur la barre d'outils.
*/
onToolbarKeydown(event) {
const key = event.key;
switch (key) {
case 'Tab':
event.preventDefault(); // évite la perte de focus asynchrone sur l'éditeur due à la propagation de l'event
this.editorService.requestFocusOnEditorContent();
this.toolbarFocused = false;
break;
case 'ArrowRight':
case 'ArrowLeft':
event.preventDefault(); // évite la prise de focus sur l'éditeur suite propagation event
let focusableElt = key === 'ArrowRight' ? this.findNextSiblingControl() : this.findPrevSiblingControl();
if (focusableElt) {
focusableElt.focus();
this.lastActiveControlElement = focusableElt;
}
break;
default:
break;
}
}
performAction(action, value) {
if (action) {
this.editorService.requestFormatting({ action: action, value: value });
}
else {
this.editorService.requestFormatting(undefined);
}
this.toolbarFocused = false;
}
/**
* Détecte le clic en dehors du composant pour retirer le marqueur de focus custom sur la toolbar.
*/
clickOutside(event) {
// comme on s'est mis à l'écoute de l'event hors Angular pour des raisons de perf, on doit mettre à jour l'input
// en s'exécutant dans la zone Angular
this.ngZone.run(() => {
if (!this.elementRef.nativeElement.contains(event.target)) {
this.toolbarFocused = false;
}
});
}
_buildSelectOption(format, control) {
return { label: this._getOptionLabel(format, control.value), value: control.value };
}
_getOptionLabel(format, value) {
const record = FORMAT_I18N_KEYS[format];
const key = record[String(value)];
return this.i18n.t(key);
}
findNextSiblingControl() {
return this.findSiblingControl('nextElementSibling', () => this.getFirstToolbarControl());
}
findPrevSiblingControl() {
return this.findSiblingControl('previousElementSibling', () => this.getLastToolbarControl());
}
/**
* Recherche l'élément de contrôle focusable adjaçant à l'élément de contrôle actif.
*
* @param siblingProperty La propriété permettant de récupérer le voisin suivant ou précédent (selon)
* @param failoverFunction La fonction permettant de récupérer un élément focusable lorsqu'il n'y plus de suivant
* @returns Une élément HTML sur lequel il sera possible de prendre le focus
*/
findSiblingControl(siblingProperty, failoverFunction) {
let result = undefined;
let activeElt = document.activeElement;
//TODO: imbrication conditionnelle complexe > à mettre sous harnais de tests pour ensuite tenter une simplification
if (activeElt) {
if (activeElt.tagName === 'BUTTON') {
if (activeElt.closest('dsfr-buttons-group')) {
const dsfrButtonsGroup = activeElt.parentElement;
if (dsfrButtonsGroup && dsfrButtonsGroup[siblingProperty])
// directly try to get next sibling
result = dsfrButtonsGroup[siblingProperty];
}
else if (activeElt.closest('span.group')) {
const spanControl = activeElt.closest('span.control');
if (spanControl && spanControl[siblingProperty]) {
// directly try to get parent span to get next sibling
result = spanControl[siblingProperty];
}
}
}
if (result === undefined) {
while (activeElt.parentElement !== null && !activeElt.parentElement.classList.contains('editor-toolbar')) {
activeElt = activeElt.parentElement;
}
if (activeElt.parentElement?.classList.contains('editor-toolbar')) {
result = activeElt[siblingProperty];
}
}
}
return result ? this.getFocusableControl(result, siblingProperty === 'previousElementSibling') : failoverFunction();
}
getFirstToolbarControl() {
return this.getFocusableControl(this.controlsParent.nativeElement);
}
getLastToolbarControl() {
let lastOne = this.controlsParent.nativeElement.lastElementChild;
return this.getFocusableControl(lastOne, true);
}
/**
* Search focusable control (depth first traversal).
*/
getFocusableControl(elt, reverse = false) {
if (elt.tagName === 'BUTTON' || elt.tagName === 'SELECT') {
return elt;
}
else if (elt.children.length !== 0) {
let children = Array.from(elt.children);
if (reverse) {
children = children.reverse();
}
for (const c of children) {
const focusable = this.getFocusableControl(c);
if (focusable) {
return focusable;
}
}
}
return undefined;
}
/**
* Répercute les formats actifs de la sélection courante vers la toolbar.
*/
_updateToolbar(formats) {
// on force l'exécution dans zonejs car l'événement provient de Quill et n'est pas géré par Angular
this.ngZone.run(() => {
this._currentSelectionFormats = formats;
this.selectModel.header = this._getSelectedOption(FormatConst.HEADER);
this.selectModel.size = this._getSelectedOption(FormatConst.SIZE);
});
// on met à jour les états aria-pressed sur les boutons
//TODO: à remplacer par un input ariaPressed sur dsfr-button cf. ngx-dsfr#
const dsfrButtons = this.elementRef.nativeElement.querySelectorAll('dsfr-button');
dsfrButtons.forEach((n) => {
const e = n;
const controlInfo = e.getAttribute('data-testid')?.split('-');
if (controlInfo) {
const pressed = controlInfo.length === 1
? this.isFormatActive(controlInfo[0])
: this.isValueActive(controlInfo[0], controlInfo[1]);
if (pressed) {
e.querySelector('button')?.setAttribute('aria-pressed', 'true');
}
else {
e.querySelector('button')?.removeAttribute('aria-pressed');
}
}
});
}
/**
* Retourne la valeur à sélectionner dans le cas de contôle multivalué (select).
*
* @internal
*/
_getSelectedOption(format) {
const debug = this._currentSelectionFormats && this._currentSelectionFormats[format]
? this._currentSelectionFormats[format]
: null;
return debug;
}
/**
* Supprime toutes les mises en forme sur la sélection courante.
*/
_removeSelectionFormats() {
this.performAction(undefined);
}
/**
* Applique le format défini par l'utilisateur via la toolbar sur la sélection courante de l'éditeur.
*/
_applyFormat(control, value) {
// Cas particulier du clean
if (control.name === FormatConst.CLEAN) {
this._removeSelectionFormats();
}
else {
const baseControl = control;
switch (baseControl.mode) {
case ControlMode.TOGGLE:
// cas d'une toggle action, on détermine la valeur à transmettre à l'éditeur
value = this._currentSelectionFormats && control.name in this._currentSelectionFormats ? false : true;
break;
case ControlMode.RADIO:
// si l'action envoyée est déjà présente dans la sélection il faut envoyer "false" comme valeur
value =
this._currentSelectionFormats &&
control.name in this._currentSelectionFormats &&
this._currentSelectionFormats[control.name] === value
? false
: value;
break;
case ControlMode.PROMPT:
switch (baseControl.name) {
case FormatConst.IMAGE:
//TODO: à remplacer par quelque chose de plus sexy
this._displayPromptElement('#prompt-' + FormatConst.IMAGE);
break;
case FormatConst.LINK:
//TODO: à remplacer par quelque chose de plus sexy
value = window.prompt(this.i18n.t('editor.controls.link.prompt'));
break;
case FormatConst.COLOR:
this._displayPromptElement('#picker-' + FormatConst.COLOR);
break;
case FormatConst.BACKGROUND:
this._displayPromptElement('#picker-' + FormatConst.BACKGROUND);
break;
default:
break;
}
break;
default:
break;
}
// si on a pu déterminer une valeur, on demande l'application du format à l'éditeur
if (value !== undefined) {
this.performAction(control.name, value);
}