UNPKG

@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
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); }