@edugouvfr/ngx-dsfr-ext
Version:
NgxDsfrExt est une extension au package @edugouvfr/ngx-dsfr (portage Angular des éléments DSFR)
1,117 lines (1,107 loc) • 106 kB
JavaScript
import * as i0 from '@angular/core';
import { Injectable, inject, Input, CUSTOM_ELEMENTS_SCHEMA, ViewEncapsulation, Component, ElementRef, Directive, Renderer2, NgZone, 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, LangService, DsfrSeverityConst, DsfrI18nPipe, DsfrSkiplinksModule, DsfrFormSelectModule, DsfrButtonModule, 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';
import { FocusMonitor } from '@angular/cdk/a11y';
/**
* 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': {
label: 'editor.controls.align.label',
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': {
label: 'editor.controls.list.label',
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 de l'é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;
}
}
/**
* Action de type interrupteur on/off avec valeur booléenne.
*/
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;
}
}
/**
* Représente un groupe d'actions.
*/
class DsfrGroupFormatControl {
/**
* Construit un control de type radio.
*
* @param values les différentes valeurs possibles pour le control de type radio
*/
constructor(i18nLabelKey, controls) {
this.i18nLabelKey = i18nLabelKey;
this.controls = controls;
this.group = true;
}
}
/**
* Tableau permettant de regrouper des contrôles uniques avec un libellé accessible.
*/
class ControlArray extends Array {
constructor(name, ...items) {
super(...items);
this.name = name;
}
}
/**
* 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 ControlArray('text', 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 ControlArray('palettes', new DsfrPromptFormatControl(FormatConst.COLOR), new DsfrPromptFormatControl(FormatConst.BACKGROUND)),
// mise en forme avancée
new ControlArray('advanced', 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: {
legend: `Editing area`,
hint: `Depending on the actions used, formatting will target either the current selection (e.g., text size or style,
script, colors, link) or the line on which the cursor is positioned (e.g., heading level, quotation, code,
indentation, lists, alignments).`,
announce: {
pre: `The `,
applied: ` formatting has been successfully applied`,
removed: ` formatting has been successfully removed`,
cleaned: 'All formatting has been successfully removed from the selection',
},
placeholder: 'Please edit your content',
controls: {
align: { label: 'Align', left: 'Left', center: 'Center', right: 'Right', justify: 'Justify' },
background: `Background color`,
blockquote: 'Quote',
clean: 'Remove all formatting from the selection',
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: { label: 'Script', sub: 'Subscript', super: 'Superscript' },
size: { label: 'Text size', small: 'Small', normal: 'Normal', large: 'Large', huge: 'Huge' },
strike: 'Strike',
underline: 'Underline',
text: 'Text style',
advanced: 'Advanced styles',
palettes: 'Palettes',
},
},
};
const MESSAGES_FR = {
editor: {
legend: `Zone d'édition`,
hint: `Selon les actions utilisées, la mise en forme ciblera soit la sélection courante (ex. : taille ou style du
texte, script, couleurs, lien) soit la ligne sur laquelle le curseur est positionné (ex. : niveau de titre,
citation, code, indentation, listes, alignements).`,
announce: {
pre: `La mise en forme `,
applied: ` a été appliquée avec succès`,
removed: ` a été supprimée avec succès`,
cleaned: 'Toutes les mises en forme ont été supprimées sur la sélection',
},
placeholder: 'Veuillez éditer votre contenu',
controls: {
align: {
label: 'Alignement',
left: 'Aligner à gauche',
center: 'Centrer',
right: 'Aligner à droite',
justify: 'Justifier',
},
background: `Couleur du fond`,
blockquote: 'Guillemets',
clean: 'Supprimer toutes les mises en forme sur la sélection',
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: { label: 'Script', sub: 'Indice', super: 'Exposant' },
size: { label: 'Taille du texte', small: 'Petit', normal: 'Normal', large: 'Large', huge: 'Énorme' },
strike: 'Barré',
underline: 'Souligné',
text: 'Style du texte',
advanced: 'Styles avancés',
palettes: 'Palettes',
},
},
};
/**
* 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._editorActionPerformedSubject = 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();
this.editorActionPerformed$ = this._editorActionPerformedSubject.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);
}
/**
* Permet de notifier l'application effective d'uen action de formatage dans l'éditeur.
*/
emitEditorActionPerformed(value) {
this._editorActionPerformedSubject.next(value);
}
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.24", ngImport: i0, type: DsfrEditorService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.3.24", ngImport: i0, type: DsfrEditorService }); }
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.24", 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';
/**
* Permet de masquer visuellement la légende en le réservant aux dispositifs de restitution.
*/
this.legendSrOnly = true;
/**
* 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);
/** @internal */
this.langSvc = inject(LangService);
/**
* 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');
this._quill.root.setAttribute('aria-labelledby', this.editorId + '-legend');
// 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.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());
this.editorService.emitEditorActionPerformed(req);
}));
// É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();
}));
// Écoute les changements de langue pour maj le placeholder
this._subscriptions.push(this.langSvc.langChange$.subscribe(() => {
this._quill.root.dataset['placeholder'] = this.i18n.t('editor.placeholder');
}));
}
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({ message, messagesGroupId }) {
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: "20.3.24", ngImport: i0, type: EduEditorContentComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "20.3.24", type: EduEditorContentComponent, isStandalone: true, selector: "edu-editor-content", inputs: { editorId: "editorId", legendSrOnly: "legendSrOnly", readOnly: "readOnly", message: "message", messageSeverity: "messageSeverity", messagesGroupId: "messagesGroupId", showEditorHtmlDebugPanel: "showEditorHtmlDebugPanel", showSemanticHtmlDebugPanel: "showSemanticHtmlDebugPanel" }, usesOnChanges: true, ngImport: i0, template: "<fieldset role=\"presentation\">\n <legend id=\"{{ editorId }}-legend\" [class.fr-sr-only]=\"legendSrOnly\">{{ 'editor.legend' | dsfrI18n }}</legend>\n <div id=\"{{ editorId }}\"></div>\n</fieldset>\n\n<div class=\"fr-messages-group\" [attr.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 fieldset{padding:0 .5rem .5rem;margin:0;border:1px solid var(--border-default-grey)}edu-editor-content fieldset legend{font-size:.75rem;color:var(--text-mention-grey)}edu-editor-content .ql-container{font-family:Marianne,sans-serif;margin-top:.4375rem}edu-editor-content .ql-container.ql-snow{border:none}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"] }, { kind: "pipe", type: DsfrI18nPipe, name: "dsfrI18n" }], encapsulation: i0.ViewEncapsulation.None }); }
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.24", ngImport: i0, type: EduEditorContentComponent, decorators: [{
type: Component,
args: [{ selector: 'edu-editor-content', encapsulation: ViewEncapsulation.None, imports: [CommonModule, DsfrI18nPipe], schemas: [CUSTOM_ELEMENTS_SCHEMA], template: "<fieldset role=\"presentation\">\n <legend id=\"{{ editorId }}-legend\" [class.fr-sr-only]=\"legendSrOnly\">{{ 'editor.legend' | dsfrI18n }}</legend>\n <div id=\"{{ editorId }}\"></div>\n</fieldset>\n\n<div class=\"fr-messages-group\" [attr.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 fieldset{padding:0 .5rem .5rem;margin:0;border:1px solid var(--border-default-grey)}edu-editor-content fieldset legend{font-size:.75rem;color:var(--text-mention-grey)}edu-editor-content .ql-container{font-family:Marianne,sans-serif;margin-top:.4375rem}edu-editor-content .ql-container.ql-snow{border:none}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
}], legendSrOnly: [{
type: Input
}], readOnly: [{
type: Input
}], message: [{
type: Input
}], messageSeverity: [{
type: Input
}], messagesGroupId: [{
type: Input
}], showEditorHtmlDebugPanel: [{
type: Input
}], showSemanticHtmlDebugPanel: [{
type: Input
}] } });
class AddTitleDirective {
constructor() {
/**
* La texte du tooltip.
*/
this.text = '';
/**
* Le sélecteur à utilsier pour identifier l'élément cible sur lequel positionner l'attribut `title`.
*/
this.selector = undefined;
this.host = inject(ElementRef);
}
ngAfterViewInit() {
if (this.text && this.text.length) {
this.applyTitle();
}
}
applyTitle() {
const elem = this.selector ? this.host.nativeElement.querySelector(this.selector) : null;
if (elem) {
elem.setAttribute('title', this.text);
}
}
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.24", ngImport: i0, type: AddTitleDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "20.3.24", type: AddTitleDirective, isStandalone: true, selector: "[addTitle]", inputs: { text: ["addTitle", "text"], selector: ["addTitleSelector", "selector"] }, ngImport: i0 }); }
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.24", ngImport: i0, type: AddTitleDirective, decorators: [{
type: Directive,
args: [{
selector: '[addTitle]',
standalone: true,
}]
}], propDecorators: { text: [{
type: Input,
args: ['addTitle']
}], selector: [{
type: Input,
args: ['addTitleSelector']
}] } });
/**
* 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() {
this._elementRef = inject(ElementRef);
this._renderer = inject(Renderer2);
}
// 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: "20.3.24", ngImport: i0, type: UnfocusableDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "20.3.24", type: UnfocusableDirective, isStandalone: true, selector: "[unfocusable]", inputs: { selector: ["unfocusable", "selector"] }, ngImport: i0 }); }
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.24", ngImport: i0, type: UnfocusableDirective, decorators: [{
type: Directive,
args: [{
selector: '[unfocusable]',
standalone: true,
}]
}], 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 la valeur tabindex de la toolbar.
*/
this.focusable = true;
this._sub = [];
this.ngZone = inject(NgZone);
this.renderer2 = inject(Renderer2);
this.elementRef = inject(ElementRef);
this.i18n = inject(DsfrI18nService);
this.editorService = inject(DsfrEditorService);
this.focusMonitor = inject(FocusMonitor);
this.el = inject(ElementRef);
/**
* 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.push(this.editorService.currentFormatting$.subscribe((formatting) => {
this._updateToolbar(formatting);
}));
// cdk permet de positionner des classes css sur l'hôte indiquant si l'utilisateur utilise la souris ou le clavier
this.focusMonitor.monitor(this.el, true);
// 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.forEach((sub) => sub.unsubscribe());
this._unlistenFn();
}
/**
* Retourne la clé du libellé internationalisé.
* @param control le contôle de formatage
*/
getI18nKey(format, value = undefined) {
let key = FORMAT_I18N_KEYS[format];
// si action multivaluée (radio ou select) il faut aller regarder la valeur pour obtenir la clé i18n
// sauf si la valeur est false ce qui correspond à une suppression et on ne peut pas connaître la valeur exacte
if (value !== undefined && this.isMultiValuedControl(format)) {
// console.log('getI18nKey', value);
key = key;
key = key[typeof value === 'boolean' ? 'label' : String(value)];
}
return key;
}
/**
* Construit la clé i18n correspondant au nom du tableau de contrôles passé en paramètre.
*/
getArrayI18nKey(controlArray) {
return 'editor.controls.' + controlArray.name;
}
/**
* Construit la clé i18n correspondant au groupe de contrôles multivalués passé en paramètre.
*/
getGroupI18nKey(format) {
return 'editor.controls.' + format + '.label';
}
/**
* 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) {
// Tableau de contrôles
if (Array.isArray(control)) {
return control.length ? 'array' : 'btn';
}
// Contrôle multivalué
if ('values' in control && control.values?.length) {
const mapping = {
[ControlMode.RADIO]: 'radio',
[ControlMode.SELECT]: 'select',
};
return mapping[control.mode] ?? 'btn';
}
return 'btn';
}
/**
* 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;
}
/**
* 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(event) {
if (this.lastActiveControlElement) {
this.lastActiveControlElement.focus();
}
else {
const nextElem = this.elementRef.nativeElement.querySelector('.item-editor, .fr-select');
nextElem?.focus();
}
}
/**
* Handler sur la prise de focus d'un élément de la toolbar (gestion du tabindex de la toolbar).
*/
onToolbarFocusIn(event) {
// lorsqu'un élément de la toolbar obtient le focus alors la toolbar ne doit plus être focusable
this.focusable = false;
}
/**
* Handler sur la perte de focus d'un élément de la toolbar (gestion du tabindex de la toolbar).
*/
onToolbarFocusOut(event) {
// lorsqu'un élément de la toolbar perd le focus
// il faut regarder si le focus a été positionné sur un autre élément de la toolbar
// si ce n'est pas le cas la toolbar doit être de nouveau focusable
this.focusable = !this.toolbar.nativeElement.contains(document.activeElement);
}
/**
* Permet de se mettre à l'écoute des actions utilisateur sur la barre d'outils.
*/
onToolbarKeydown(event) {
const key = event.key;
switch (key) {
case 'Tab':
if (!event.shiftKey) {
event.preventDefault(); // évite la perte de focus asynchrone sur l'éditeur due à la propagation de l'event
this.editorService.requestFocusOnEditorContent();
}
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;
}
}
/**
* Retourne vrai si le format correspond à un contrôle d'action multivaluée, faux sinon.
*
* @param format le descripteur de format à considérer
*/
isMultiValuedControl(format) {
let control = this.toolbarOptions.flat(Infinity).find((c) => c.name === format);
return control && control.values && control.values.length;
}
getOptionLabel(format, value) {
const record = FORMAT_I18N_KEYS[format];
const key = record[String(value)];
return this.i18n.t(key);
}
performAction(action, value) {
if (action) {
this.editorService.requestFormatting({ action: action, value: value });
}
else {
this.editorService.requestFormatting(undefined);
}
}
/**
* 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.focusable = true;
}
});
}
_buildSelectOption(format, control) {
return { label: this.getOptionLabel(format, control.value), value: control.value };
}
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.
*
* Cette fonction est utilisée pour pouvoir déplacer le focus lors de la navigation au clavier.
*
* @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) {