From 575232ff1c10b6606536a3120665ba1b175aac47 Mon Sep 17 00:00:00 2001 From: benjamin Date: Mon, 27 Apr 2026 22:38:37 +0100 Subject: [PATCH 01/13] Afform - fix token replacement if multiple instances of same token --- ext/afform/core/ang/af/afForm.component.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ext/afform/core/ang/af/afForm.component.js b/ext/afform/core/ang/af/afForm.component.js index 2d8436c87a90..2f33442b9010 100644 --- a/ext/afform/core/ang/af/afForm.component.js +++ b/ext/afform/core/ang/af/afForm.component.js @@ -569,7 +569,7 @@ // these tokens matching/replacing functions should match // the serverside implementation in AbstractProcessor::replaceTokens - this.identifyTokens = (message) => message?.match(/\[[a-zA-Z0-9_]+\.[0-9]+\.[^\]]+\]/g); + this.identifyTokens = (message) => new Set(message?.match(/\[[a-zA-Z0-9_]+\.[0-9]+\.[^\]]+\]/g)); this.getTokenValues = (tokens) => { const values = {}; @@ -586,7 +586,7 @@ this.replaceTokens = (message) => { const tokens = this.identifyTokens(message); const tokenValues = this.getTokenValues(tokens); - tokens.forEach((token) => message = message.replace(token, tokenValues[token])); + tokens.forEach((token) => message = message.replaceAll(token, tokenValues[token])); return message; }; From 835c431830dee6342419e9113b2130134ffb9ec7 Mon Sep 17 00:00:00 2001 From: benjamin Date: Tue, 12 May 2026 09:15:13 +0100 Subject: [PATCH 02/13] Afform.replaceTokens - handle gracefully if data isn't loaded yet --- ext/afform/core/ang/af/afForm.component.js | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/ext/afform/core/ang/af/afForm.component.js b/ext/afform/core/ang/af/afForm.component.js index 2f33442b9010..8395c071f232 100644 --- a/ext/afform/core/ang/af/afForm.component.js +++ b/ext/afform/core/ang/af/afForm.component.js @@ -576,8 +576,15 @@ tokens.forEach((token) => { const parts = token.slice(1, -1).split('.'); - values[token] = data[parts[0]][parts[1]].fields[parts.slice(2).join('.')]; - values[token] = (values[token] === undefined) ? '' : values[token]; + const entity = parts[0]; + const index = parts[1]; + const fieldName = parts.slice(2).join('.'); + if (!data || !data[entity] || !data[entity][index] || !data[entity][index].fields || (data[entity][index].fields[fieldName] === undefined) ) { + values[token] = ''; + } + else { + values[token] = data[entity][index].fields[fieldName]; + } }); return values; From 61e81da8fd7e9ac204d6516530d23bb2f2c0f610 Mon Sep 17 00:00:00 2001 From: benjamin Date: Mon, 27 Apr 2026 22:29:47 +0100 Subject: [PATCH 03/13] Afform - allow tokens and markup in label / help_pre / help_post --- .../ang/afGuiEditor/elements/afGuiField.html | 11 +++-- ext/afform/core/ang/af/afField.html | 10 ++-- ext/afform/core/ang/af/afForm.component.js | 1 - ext/afform/core/ang/af/afMarkup.element.js | 47 ++++++++++++++++++ ext/afform/core/ang/af/afToken.element.js | 49 +++++++++++++++++++ 5 files changed, 111 insertions(+), 7 deletions(-) create mode 100644 ext/afform/core/ang/af/afMarkup.element.js create mode 100644 ext/afform/core/ang/af/afToken.element.js diff --git a/ext/afform/admin/ang/afGuiEditor/elements/afGuiField.html b/ext/afform/admin/ang/afGuiEditor/elements/afGuiField.html index 2f022361110f..67b00344993d 100644 --- a/ext/afform/admin/ang/afGuiEditor/elements/afGuiField.html +++ b/ext/afform/admin/ang/afGuiEditor/elements/afGuiField.html @@ -10,14 +10,19 @@ - +
+ + +
{{ getProp('help_pre') }} +
{{ getProp('help_post') }} +
diff --git a/ext/afform/core/ang/af/afField.html b/ext/afform/core/ang/af/afField.html index 6bba2aa641da..0be925dba950 100644 --- a/ext/afform/core/ang/af/afField.html +++ b/ext/afform/core/ang/af/afField.html @@ -1,8 +1,12 @@ -

{{:: $ctrl.defn.help_pre }}

+

+ +

-

{{:: $ctrl.defn.help_post }}

+

+ +

diff --git a/ext/afform/core/ang/af/afForm.component.js b/ext/afform/core/ang/af/afForm.component.js index 8395c071f232..b5fb391fcf3a 100644 --- a/ext/afform/core/ang/af/afForm.component.js +++ b/ext/afform/core/ang/af/afForm.component.js @@ -596,7 +596,6 @@ tokens.forEach((token) => message = message.replaceAll(token, tokenValues[token])); return message; }; - } }); })(angular, CRM.$, CRM._); diff --git a/ext/afform/core/ang/af/afMarkup.element.js b/ext/afform/core/ang/af/afMarkup.element.js new file mode 100644 index 000000000000..882ba15eb277 --- /dev/null +++ b/ext/afform/core/ang/af/afMarkup.element.js @@ -0,0 +1,47 @@ +(function (CRM, angular) { + + + class AfMarkup extends HTMLElement { + + /* jshint ignore:start */ + static observedAttributes = ['markup']; + /* jshint ignore:end */ + + connectedCallback() { + this.afForm = this.closest('af-form'); + if (!this.afForm) { + throw new Error('af-markup should be placed within an af-form'); + } + // setTimeout ensures child elements can access parent af-form during render + setTimeout(() => this.render()); + } + + attributeChangedCallback() { + this.render(); + } + + render() { + let markup = this.markup; + markup = this.replaceTokensWithElements(markup); + this.innerHTML = markup; + } + + get markup() { + return this.getAttribute('markup'); + } + + set markup(content) { + this.setAttribute('markup', content); + } + + replaceTokensWithElements(markup) { + // @see afForm identifyTokens + const tokens = new Set(markup.match(/\[[a-zA-Z0-9_]+\.[0-9]+\.[^\]]+\]/g)); + tokens?.forEach((token) => markup = markup.replaceAll(token, ``)); + return markup; + } + } + + customElements.define('af-markup', AfMarkup); + +})(CRM, angular); diff --git a/ext/afform/core/ang/af/afToken.element.js b/ext/afform/core/ang/af/afToken.element.js new file mode 100644 index 000000000000..2e9be1aafe25 --- /dev/null +++ b/ext/afform/core/ang/af/afToken.element.js @@ -0,0 +1,49 @@ +(function (CRM, angular) { + + + class AfToken extends HTMLElement { + + connectedCallback() { + this.afForm = this.closest('af-form'); + if (!this.afForm) { + throw new Error('af-token should be placed within an af-form'); + } + + this.render(); + this.registerListener(); + } + + disconnectedCallback() { + this.removeListener(); + } + + render() { + this.innerText = this.evaluate(this.expression); + } + + registerListener() { + this.afForm?.addEventListener('change', () => this.render()); + } + + removeListener() { + this.afForm?.removeEventListener('change', () => this.render()); + } + + get expression() { + return this.getAttribute('expression'); + } + + get afFormCtrl() { + return angular.element(this.afForm).controller('afForm'); + } + + evaluate(expression) { + // TODO: support evaluation using Symfony Expression Language + return this.afFormCtrl ? this.afFormCtrl.replaceTokens(expression) : ''; + } + + } + + customElements.define('af-token', AfToken); + +})(CRM, angular); \ No newline at end of file From 369d1574c84cc4063aba4db801c9bfad2ae7ee83 Mon Sep 17 00:00:00 2001 From: benjamin Date: Tue, 12 May 2026 09:14:10 +0100 Subject: [PATCH 04/13] afForm - support tokens in .af-markup divs --- ext/afform/core/ang/af/afForm.component.js | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/ext/afform/core/ang/af/afForm.component.js b/ext/afform/core/ang/af/afForm.component.js index b5fb391fcf3a..31b487295a88 100644 --- a/ext/afform/core/ang/af/afForm.component.js +++ b/ext/afform/core/ang/af/afForm.component.js @@ -33,6 +33,13 @@ $scope.$parent[this.ctrl] = this; $timeout(() => { + // render tokenised markup + $element[0].querySelectorAll('.af-markup:not(af-markup)').forEach((el) => { + const renderer = document.createElement('af-markup'); + renderer.markup = el.innerHTML; + el.replaceChildren(renderer); + }); + ctrl.loadData() .then(setupDraftWatcher); From 7bf386df597b5e326bea57d589e1bb45c403fb1d Mon Sep 17 00:00:00 2001 From: benjamin Date: Tue, 12 May 2026 09:12:26 +0100 Subject: [PATCH 05/13] afGuiEditor - hide label token picker until editing --- ext/afform/admin/ang/afGuiEditor.css | 5 +++++ .../core/org.civicrm.afform_admin-ang/afGuiEditor.css | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/ext/afform/admin/ang/afGuiEditor.css b/ext/afform/admin/ang/afGuiEditor.css index 681f31f984d2..93126aad642d 100644 --- a/ext/afform/admin/ang/afGuiEditor.css +++ b/ext/afform/admin/ang/afGuiEditor.css @@ -713,3 +713,8 @@ body.af-gui-dragging { #bootstrap-theme.af-gui-conditional-dialog .api4-operator { width: 110px; } + +/* hide label token pickers until editing */ +.af-gui-node-title:not(:focus-within) af-gui-token-select[field="label"] { + visibility: hidden; +} diff --git a/ext/riverlea/core/org.civicrm.afform_admin-ang/afGuiEditor.css b/ext/riverlea/core/org.civicrm.afform_admin-ang/afGuiEditor.css index 9c9c6fda7ebb..9d73a77dd66a 100644 --- a/ext/riverlea/core/org.civicrm.afform_admin-ang/afGuiEditor.css +++ b/ext/riverlea/core/org.civicrm.afform_admin-ang/afGuiEditor.css @@ -701,3 +701,8 @@ i.crm-i.af-gui-conditional-dialog-move-icon { background: linear-gradient(to bottom, #f2f2f2 0 var(--crm-l-large), transparent var(--crm-l-large) 100%) no-repeat; border-radius: var(--crm-l-radius); } + +/* hide label token pickers until editing */ +.af-gui-node-title:not(:focus-within) af-gui-token-select[field="label"] { + visibility: hidden; +} From 15ea7392c0e6721c1b66b1f5005d26b6dc2565eb Mon Sep 17 00:00:00 2001 From: benjamin Date: Mon, 11 May 2026 16:48:12 +0100 Subject: [PATCH 06/13] afGuiEditor - move token getter to top level controller --- .../ang/afGuiEditor/afGuiEditor.component.js | 60 +++++++++++++++++ .../admin/ang/afGuiEditor/afGuiTokenSelect.js | 64 +------------------ 2 files changed, 63 insertions(+), 61 deletions(-) diff --git a/ext/afform/admin/ang/afGuiEditor/afGuiEditor.component.js b/ext/afform/admin/ang/afGuiEditor/afGuiEditor.component.js index dacad90bd10e..3d371066d755 100644 --- a/ext/afform/admin/ang/afGuiEditor/afGuiEditor.component.js +++ b/ext/afform/admin/ang/afGuiEditor/afGuiEditor.component.js @@ -804,6 +804,66 @@ return $location.path(newPath); } + this.getTokens = (includeSubmissionTokens = false) => { + const allTokens = []; + this.getEntities().forEach((entity) => { + const entityTokens = []; + const entityMeta = this.meta.entities[entity.type]; + if (entityMeta.submissionTokens && includeSubmissionTokens) { + // Explicitly defined submission tokens e.g. by FormProcessor extension + entityMeta.submissionTokens.forEach((submissionToken) => { + entityTokens.push({ + id: entity.name + '.0.' + submissionToken.token, + text: entity.label + ' ' + submissionToken.label, + description: submissionToken.description ?? '', + }); + }); + } else if (!entityMeta.submissionTokens) { + // Primary key token + // FIXME: not all entities use `id` for primary key + if (includeSubmissionTokens) { + entityTokens.push({ + id: entity.name + '.0.id', + text: ts('%1 ID', {1: entity.label}), + }); + } + // Tokens from entity data values + if (entity.data) { + Object.keys(entity.data).forEach((key) => { + if (entityMeta.fields[key]) { + entityTokens.push({ + id: entity.name + '.0.' + key, + text: entity.label + ' ' + entityMeta.fields[key].label, + }); + } + }); + } + // Tokens from entity fields on the form + this.getEntityFields(entity.name).fields.forEach((field) => { + entityTokens.push({ + id: entity.name + '.0.' + field.name, + text: entity.label + ' ' + field.label, + }); + }); + } + if (entityTokens.length) { + allTokens.push({ + text: entity.label, + children: entityTokens, + }); + } + }); + if (includeSubmissionTokens) { + allTokens.push({ + text: ts('Form'), + children: [ + {id: 'token', text: ts('Submission JWT')}, + ], + }); + } + return allTokens; + }; + } }); diff --git a/ext/afform/admin/ang/afGuiEditor/afGuiTokenSelect.js b/ext/afform/admin/ang/afGuiEditor/afGuiTokenSelect.js index e203b339f8a4..de2f684b50cc 100644 --- a/ext/afform/admin/ang/afGuiEditor/afGuiTokenSelect.js +++ b/ext/afform/admin/ang/afGuiEditor/afGuiTokenSelect.js @@ -49,73 +49,15 @@ } }; - this.getTokens = function() { - const allTokens = []; - ctrl.editor.getEntities().forEach((entity) => { - const entityTokens = []; - const entityMeta = ctrl.editor.meta.entities[entity.type]; - if (entityMeta.submissionTokens && !ctrl.noSubmissionTokens) { - // Explicitly defined submission tokens e.g. by FormProcessor extension - entityMeta.submissionTokens.forEach((submissionToken) => { - entityTokens.push({ - id: entity.name + '.0.' + submissionToken.token, - text: entity.label + ' ' + submissionToken.label, - description: submissionToken.description ?? '', - }); - }); - } else if (!entityMeta.submissionTokens) { - // Primary key token - if (!ctrl.noSubmissionTokens) { - entityTokens.push({ - id: entity.name + '.0.id', - text: ts('%1 ID', {1: entity.label}), - }); - } - // Tokens from entity data values - if (entity.data) { - Object.keys(entity.data).forEach((key) => { - if (entityMeta.fields[key]) { - entityTokens.push({ - id: entity.name + '.0.' + key, - text: entity.label + ' ' + entityMeta.fields[key].label, - }); - } - }); - } - // Tokens from entity fields on the form - ctrl.editor.getEntityFields(entity.name).fields.forEach((field) => { - entityTokens.push({ - id: entity.name + '.0.' + field.name, - text: entity.label + ' ' + field.label, - }); - }); - } - if (entityTokens.length) { - allTokens.push({ - text: entity.label, - children: entityTokens, - }); - } - }); - if (!ctrl.noSubmissionTokens) { - allTokens.push({ - text: ts('Form'), - children: [ - {id: 'token', text: ts('Submission JWT')}, - ], - }); - } - return { - results: allTokens - }; - }; + this.getTokens = () => ({ + results: this.editor.getTokens(!this.noSubmissionTokens), + }); this.tokenSelectSettings = { data: this.getTokens, // The crm-action-menu icon doesn't show without a placeholder placeholder: ' ', }; - } }); From f3fa1291755297cbb7a5726b2fb14de0d63c9f48 Mon Sep 17 00:00:00 2001 From: benjamin Date: Mon, 11 May 2026 16:49:31 +0100 Subject: [PATCH 07/13] add civi-rich-text-input element --- .../core/css/components/_components.css | 6 + js/wysiwyg/crm.wysiwyg.js | 143 ++++++++++++++++++ 2 files changed, 149 insertions(+) diff --git a/ext/riverlea/core/css/components/_components.css b/ext/riverlea/core/css/components/_components.css index 718ed806fbbd..8f204ffd1a60 100644 --- a/ext/riverlea/core/css/components/_components.css +++ b/ext/riverlea/core/css/components/_components.css @@ -813,3 +813,9 @@ div.civicrm-community-messages a.civicrm-community-message-dismiss::before { .crm-container .pcp-page-text { margin-bottom: var(--crm-padding-reg); } + +civi-rich-text-input .rich-text-preview { + /* ensure a clickable target, even if empty */ + min-height: 1rem; + cursor: cell; +} \ No newline at end of file diff --git a/js/wysiwyg/crm.wysiwyg.js b/js/wysiwyg/crm.wysiwyg.js index b18d464d2ad9..76a4ff129ce7 100644 --- a/js/wysiwyg/crm.wysiwyg.js +++ b/js/wysiwyg/crm.wysiwyg.js @@ -2,6 +2,148 @@ (function($, _) { // This defines an interface which by default only handles plain textareas // A wysiwyg implementation can extend this by overriding as many of these functions as needed + + let richTextInputId = 0; + + /** + * A rich text input with a preview mode + * + * + */ + class CiviRichTextInput extends HTMLElement { + + constructor() { + super(); + + // initialise child input + // NOTE: we need to do this here rather than render in order to persist + // the input value across connection/disconnection + this.input = document.createElement('textarea'); + // generate a unique id for the element as required by ckeditor etc + this.input.id = 'civiRichTextInput' + richTextInputId++; + this.input.style.display = 'none'; + } + + connectedCallback() { + this.render(); + } + + render() { + this.innerHTML = ` + +
+ `; + + // reappend the child input + this.append(this.input); + + this.toolbar = this.querySelector('.rich-text-toolbar'); + this.cancelButton = this.querySelector('.rich-text-cancel'); + this.saveButton = this.querySelector('.rich-text-save'); + this.preview = this.querySelector('.rich-text-preview'); + + // add translated text + this.cancelButton.append(ts('Cancel')); + this.saveButton.append(ts('Save')); + this.preview.title = ts('Click to edit'); + + // (re)load any content + this.preview.innerHTML = this.value; + + // open the editor when click/type on preview + this.preview.onclick = (e) => { + e.preventDefault(); + this.openEditor(); + }; + this.preview.onkeystroke = () => { + e.preventDefault(); + this.openEditor(); + }; + // close the editor with the buttons + this.cancelButton.onclick = () => this.closeEditor(); + this.saveButton.onclick = () => this.saveAndCloseEditor(); + + // load token picker if requested + if (this.hasAttribute('token-picker')) { + this.loadTokenPicker(); + } + } + + loadTokenPicker() { + this.tokenPicker = document.createElement('input'); + this.tokenPicker.classList.add('rich-text-token-picker', 'form-control', 'crm-auto-width', 'crm-action-menu', 'fa-code', 'collapsible-optgroups'); + + this.toolbar.prepend(this.tokenPicker); + + this.tokenPicker.onchange = () => { + const token = `[${this.tokenPicker.value}]`; + CRM.wysiwyg.insert(this.input, token); + CRM.$(this.tokenPicker).select2('val', ''); + }; + + CRM.$(this.tokenPicker).crmSelect2({ + data: () => this.getTokens(), + placeholder: ts('Tokens') + }); + } + + openEditor() { + this.setAttribute('editing', true); + CRM.wysiwyg.create(this.input); + this.preview.style.display = 'none'; + this.toolbar.style.display = null; + this.dispatchEvent(new Event('load')); + } + + closeEditor() { + CRM.wysiwyg.destroy(this.input); + this.input.style.display = 'none'; + this.toolbar.style.display = 'none'; + this.preview.style.display = null; + this.removeAttribute('editing'); + } + + saveAndCloseEditor() { + this.value = CRM.wysiwyg.getVal(this.input); + this.closeEditor(); + this.dispatchEvent(new Event('change')); + } + + getTokens() { + if (this.closest('af-gui-editor')) { + const afGuiEditor = angular.element(this.closest('af-gui-editor')).controller('afGuiEditor'); + return { + results: afGuiEditor.getTokens() + }; + } + else { + throw new Error('civi-rich-text-input[token-picker] doesn\'t know how to get available tokens outside of af-gui-editor context yet'); + } + } + + get value() { + return this.input.value; + } + + set value(v) { + this.input.value = v; + CRM.wysiwyg.setVal(this.input, v); + if (this.preview) { + this.preview.innerHTML = v; + } + } + } + + customElements.define('civi-rich-text-input', CiviRichTextInput); + + CRM.wysiwyg = { supportsFileUploads: !!CRM.config.wysisygScriptLocation, create: function(item) { @@ -71,4 +213,5 @@ }); } }; + })(CRM.$, CRM._); From 01a4105bc4f2af4509df4bce8b3bcff8e8ff239e Mon Sep 17 00:00:00 2001 From: benjamin Date: Tue, 12 May 2026 07:23:23 +0100 Subject: [PATCH 08/13] afGuiEditor - use civi-rich-text-input for af-markup, help_pre/post --- ang/crmUtil.ang.php | 5 ++- ang/crmUtil/crmCustomElementModel.js | 21 +++++++++ .../ang/afGuiEditor/elements/afGuiField.html | 6 +-- .../elements/afGuiMarkup-menu.html | 2 +- .../elements/afGuiMarkup.component.js | 44 ++++--------------- .../ang/afGuiEditor/elements/afGuiMarkup.html | 26 +++-------- 6 files changed, 41 insertions(+), 63 deletions(-) create mode 100644 ang/crmUtil/crmCustomElementModel.js diff --git a/ang/crmUtil.ang.php b/ang/crmUtil.ang.php index 502fab5e17d9..092aa0417c2a 100644 --- a/ang/crmUtil.ang.php +++ b/ang/crmUtil.ang.php @@ -2,6 +2,9 @@ // This file declares an Angular module which can be autoloaded return [ 'ext' => 'civicrm', - 'js' => ['ang/crmUtil.js'], + 'js' => [ + 'ang/crmUtil.js', + 'ang/crmUtil/*.js', + ], 'requires' => [], ]; diff --git a/ang/crmUtil/crmCustomElementModel.js b/ang/crmUtil/crmCustomElementModel.js new file mode 100644 index 000000000000..b29ef9e74bac --- /dev/null +++ b/ang/crmUtil/crmCustomElementModel.js @@ -0,0 +1,21 @@ +// https://civicrm.org/licensing +(function (angular, $, _) { + "use strict"; + + /** + * Allows using ng-model with a custom element like civi-rich-text-input + */ + angular.module("crmUtil").directive("crmCustomElementModel", () => ({ + restrict: "A", + require: 'ngModel', + link: function ($scope, $element, attributes, ngModelController) { + // get the native element + const element = $element[0]; + // update element value from angular in render hook + ngModelController.$render = () => element.value = ngModelController.$viewValue; + // update angular value from element when element is changed + element.addEventListener('change', () => ngModelController.$setViewValue(element.value)); + } + })); + +})(angular, CRM.$, CRM._); diff --git a/ext/afform/admin/ang/afGuiEditor/elements/afGuiField.html b/ext/afform/admin/ang/afGuiEditor/elements/afGuiField.html index 67b00344993d..fe738e3e591b 100644 --- a/ext/afform/admin/ang/afGuiEditor/elements/afGuiField.html +++ b/ext/afform/admin/ang/afGuiEditor/elements/afGuiField.html @@ -17,12 +17,10 @@
- {{ getProp('help_pre') }} - +
- {{ getProp('help_post') }} - +
diff --git a/ext/afform/admin/ang/afGuiEditor/elements/afGuiMarkup-menu.html b/ext/afform/admin/ang/afGuiEditor/elements/afGuiMarkup-menu.html index f857b29c2338..c37b06e3f96e 100644 --- a/ext/afform/admin/ang/afGuiEditor/elements/afGuiMarkup-menu.html +++ b/ext/afform/admin/ang/afGuiEditor/elements/afGuiMarkup-menu.html @@ -1,5 +1,5 @@
  • - {{:: ts('Edit content') }} + {{:: ts('Edit content') }}
  • diff --git a/ext/afform/admin/ang/afGuiEditor/elements/afGuiMarkup.component.js b/ext/afform/admin/ang/afGuiEditor/elements/afGuiMarkup.component.js index 9c8492189e10..a81bdb0969ce 100644 --- a/ext/afform/admin/ang/afGuiEditor/elements/afGuiMarkup.component.js +++ b/ext/afform/admin/ang/afGuiEditor/elements/afGuiMarkup.component.js @@ -13,48 +13,20 @@ require: { editor: '^^afGuiEditor', }, - controller: function($scope, $sce, $timeout) { - const ts = $scope.ts = CRM.ts('org.civicrm.afform_admin'), - ctrl = this; + controller: function($element, $scope) { + const ts = $scope.ts = CRM.ts('org.civicrm.afform_admin'); - this.$onInit = function() { - // CRM.wysiwyg doesn't work without a dom id - $scope.id = 'af-markup-editor-' + richtextId++; + this.$onInit = () => { // When creating a new markup container, go straight to edit mode - $timeout(() => { - if (ctrl.node['#markup'] === false) { - $scope.edit(); - } - }); - }; - - $scope.getMarkup = function() { - return $sce.trustAsHtml(ctrl.node['#markup'] || ''); - }; - - $scope.edit = function() { - $('#afGuiEditor').addClass('af-gui-editing-content'); - $scope.editingMarkup = true; - CRM.wysiwyg.create('#' + $scope.id); - CRM.wysiwyg.setVal('#' + $scope.id, ctrl.node['#markup'] || '

    '); + if (this.node['#markup'] === false) { + this.node['#markup'] = ''; + this.edit(); + } }; - $scope.save = function() { - ctrl.node['#markup'] = CRM.wysiwyg.getVal('#' + $scope.id); - $scope.close(); - }; + this.edit = () => $element[0].querySelector('civi-rich-text-input').openEditor(); - $scope.close = function() { - CRM.wysiwyg.destroy('#' + $scope.id); - $('#afGuiEditor').removeClass('af-gui-editing-content'); - // If a newly-added wysiwyg was canceled, just remove it - if (ctrl.node['#markup'] === false) { - $scope.container.removeElement(ctrl.node); - } else { - $scope.editingMarkup = false; - } - }; } }); diff --git a/ext/afform/admin/ang/afGuiEditor/elements/afGuiMarkup.html b/ext/afform/admin/ang/afGuiEditor/elements/afGuiMarkup.html index da999ac44d45..ac950d3ba697 100644 --- a/ext/afform/admin/ang/afGuiEditor/elements/afGuiMarkup.html +++ b/ext/afform/admin/ang/afGuiEditor/elements/afGuiMarkup.html @@ -1,5 +1,6 @@
    -
    +
    + {{:: ts('Rich content') }}
    -
    -
    -
    -
    -
    -
    -

    {{:: ts('Editing content') }}

    -
    - - -
    -
    - -
    +
    + +
    \ No newline at end of file From 035d2a7e4f735d8861086cf29089f0fa3ac85918 Mon Sep 17 00:00:00 2001 From: benjamin Date: Tue, 12 May 2026 09:16:31 +0100 Subject: [PATCH 09/13] afGuiEditor - save any open rich text inputs on save --- ext/afform/admin/ang/afGuiEditor/afGuiEditor.component.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/ext/afform/admin/ang/afGuiEditor/afGuiEditor.component.js b/ext/afform/admin/ang/afGuiEditor/afGuiEditor.component.js index 3d371066d755..664b4a58d01c 100644 --- a/ext/afform/admin/ang/afGuiEditor/afGuiEditor.component.js +++ b/ext/afform/admin/ang/afGuiEditor/afGuiEditor.component.js @@ -18,7 +18,7 @@ mode: '@' }, controllerAs: 'editor', - controller: function($scope, crmApi4, crmUiHelp, afGui, $parse, $timeout, $location, $route, $rootScope, formatForSelect2) { + controller: function($scope, $element, crmApi4, crmUiHelp, afGui, $parse, $timeout, $location, $route, $rootScope, formatForSelect2) { const ts = $scope.ts = CRM.ts('org.civicrm.afform_admin'); $scope.hs = crmUiHelp({file: 'CRM/AfformAdmin/afformBuilder'}); @@ -711,6 +711,9 @@ }; $scope.save = function() { + // save and close any open rich text elements + $element[0].querySelectorAll('civi-rich-text-input[editing]').forEach((el) => el.saveAndCloseEditor()); + const afform = JSON.parse(angular.toJson(editor.afform)); // This might be set to undefined by validation afform.server_route = afform.server_route || ''; From 2755b48b02a0dff7952d85d640ae31c1a0f8383b Mon Sep 17 00:00:00 2001 From: benjamin Date: Tue, 12 May 2026 09:44:05 +0100 Subject: [PATCH 10/13] afGuiEditor - use civi-rich-text-input for confirmation message --- ext/afform/admin/ang/afGuiEditor/config-form.html | 10 ++++++++-- ext/riverlea/core/css/components/_components.css | 5 ++++- js/wysiwyg/crm.wysiwyg.js | 2 +- 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/ext/afform/admin/ang/afGuiEditor/config-form.html b/ext/afform/admin/ang/afGuiEditor/config-form.html index 12675f19dd96..591294842f35 100644 --- a/ext/afform/admin/ang/afGuiEditor/config-form.html +++ b/ext/afform/admin/ang/afGuiEditor/config-form.html @@ -226,8 +226,14 @@ - - +

    {{:: ts("This message wil be displayed when the form is submitted.") }}

    diff --git a/ext/riverlea/core/css/components/_components.css b/ext/riverlea/core/css/components/_components.css index 8f204ffd1a60..c9c8ffb8a58d 100644 --- a/ext/riverlea/core/css/components/_components.css +++ b/ext/riverlea/core/css/components/_components.css @@ -818,4 +818,7 @@ civi-rich-text-input .rich-text-preview { /* ensure a clickable target, even if empty */ min-height: 1rem; cursor: cell; -} \ No newline at end of file +} +civi-rich-text-input .rich-text-preview:hover { + outline: 2px dashed var(--crm-c-blue-dark); +} diff --git a/js/wysiwyg/crm.wysiwyg.js b/js/wysiwyg/crm.wysiwyg.js index 76a4ff129ce7..61b879a2c325 100644 --- a/js/wysiwyg/crm.wysiwyg.js +++ b/js/wysiwyg/crm.wysiwyg.js @@ -120,7 +120,7 @@ if (this.closest('af-gui-editor')) { const afGuiEditor = angular.element(this.closest('af-gui-editor')).controller('afGuiEditor'); return { - results: afGuiEditor.getTokens() + results: afGuiEditor.getTokens(this.hasAttribute('include-submission-tokens')) }; } else { From 61bcae8f9889eeed164eff19592b487624f290ab Mon Sep 17 00:00:00 2001 From: benjamin Date: Tue, 12 May 2026 10:38:29 +0100 Subject: [PATCH 11/13] civi-rich-text-input - better empty value handling --- .../afGuiEditor/elements/afGuiMarkup.component.js | 3 +-- js/wysiwyg/crm.wysiwyg.js | 12 ++++++++++-- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/ext/afform/admin/ang/afGuiEditor/elements/afGuiMarkup.component.js b/ext/afform/admin/ang/afGuiEditor/elements/afGuiMarkup.component.js index a81bdb0969ce..22ab144fb18a 100644 --- a/ext/afform/admin/ang/afGuiEditor/elements/afGuiMarkup.component.js +++ b/ext/afform/admin/ang/afGuiEditor/elements/afGuiMarkup.component.js @@ -19,8 +19,7 @@ this.$onInit = () => { // When creating a new markup container, go straight to edit mode - if (this.node['#markup'] === false) { - this.node['#markup'] = ''; + if (!this.node['#markup']) { this.edit(); } }; diff --git a/js/wysiwyg/crm.wysiwyg.js b/js/wysiwyg/crm.wysiwyg.js index 61b879a2c325..78cc610bf543 100644 --- a/js/wysiwyg/crm.wysiwyg.js +++ b/js/wysiwyg/crm.wysiwyg.js @@ -55,7 +55,7 @@ this.preview.title = ts('Click to edit'); // (re)load any content - this.preview.innerHTML = this.value; + this.renderPreview(); // open the editor when click/type on preview this.preview.onclick = (e) => { @@ -76,6 +76,11 @@ } } + renderPreview() { + // if no content set, show an edit icon instead + this.preview.innerHTML = this.value.length ? this.value : ''; + } + loadTokenPicker() { this.tokenPicker = document.createElement('input'); this.tokenPicker.classList.add('rich-text-token-picker', 'form-control', 'crm-auto-width', 'crm-action-menu', 'fa-code', 'collapsible-optgroups'); @@ -133,10 +138,13 @@ } set value(v) { + if (!v) { + v = ''; + } this.input.value = v; CRM.wysiwyg.setVal(this.input, v); if (this.preview) { - this.preview.innerHTML = v; + this.renderPreview(); } } } From f24f3ed1f453047881574355b1678ff6fc3e9532 Mon Sep 17 00:00:00 2001 From: benjamin Date: Tue, 12 May 2026 11:25:02 +0100 Subject: [PATCH 12/13] civi-rich-text-input - use for CRM.wysiwyg.createCollapsed --- js/Common.js | 8 ++++---- js/wysiwyg/crm.wysiwyg.js | 29 +++++++---------------------- 2 files changed, 11 insertions(+), 26 deletions(-) diff --git a/js/Common.js b/js/Common.js index 56a6fd790eed..c9ed565a3dcc 100644 --- a/js/Common.js +++ b/js/Common.js @@ -1297,11 +1297,11 @@ if (!CRM.vars) CRM.vars = {}; $('form[data-warn-changes] :input', e.target).each(function() { $(this).data('crm-initial-value', $(this).is(':checkbox, :radio') ? $(this).prop('checked') : $(this).val()); }); - $('textarea.crm-form-wysiwyg', e.target).each(function() { - if ($(this).hasClass("collapsed")) { - CRM.wysiwyg.createCollapsed(this); + e.target.querySelectorAll('textarea.crm-form-wysiwyg').forEach((el) => { + if (el.classList.contains('collapsed')) { + CRM.wysiwyg.createCollapsed(el); } else { - CRM.wysiwyg.create(this); + CRM.wysiwyg.create(el); } }); // Submit once handlers diff --git a/js/wysiwyg/crm.wysiwyg.js b/js/wysiwyg/crm.wysiwyg.js index 78cc610bf543..78d2a39c7ded 100644 --- a/js/wysiwyg/crm.wysiwyg.js +++ b/js/wysiwyg/crm.wysiwyg.js @@ -197,28 +197,13 @@ CRM.wysiwyg.focus(item); }, // Create a "collapsed" textarea that expands into a wysiwyg when clicked - createCollapsed: function(item) { - $(item) - .hide() - .on('blur', function () { - CRM.wysiwyg.destroy(item); - $(item).hide().next('.replace-plain').show().html($(item).val()); - }) - .on('change', function() { - $(this).next('.replace-plain').html($(this).val()); - }) - .after('
    '); - $(item).next('.replace-plain') - .attr('title', ts('Click to edit')) - .html($(item).val()) - .on('click keypress', function (e) { - // Stop browser from opening clicked links - e.preventDefault(); - $(item).show().next('.replace-plain').hide(); - CRM.wysiwyg.create(item).then(() => { - CRM.wysiwyg.focus(item); - }); - }); + createCollapsed: (item) => { + const customInput = document.createElement('civi-rich-text-input'); + // use the pre-existing textarea (preserve name etc) for the custom element + customInput.input = item; + customInput.input.style.display = 'none'; + // replace the pre-existing textarea with our custom element + item.replaceWith(customInput); } }; From 719a0fe4f8657db141c4cff23fb808e7c5c8c865 Mon Sep 17 00:00:00 2001 From: benjamin Date: Tue, 12 May 2026 11:47:41 +0100 Subject: [PATCH 13/13] civi-rich-text-input - style like a normal input by default override for special context af-gui-container --- ext/riverlea/core/css/components/_components.css | 9 --------- ext/riverlea/core/css/components/_form.css | 10 +++++++++- .../core/org.civicrm.afform_admin-ang/afGuiEditor.css | 9 +++++++++ 3 files changed, 18 insertions(+), 10 deletions(-) diff --git a/ext/riverlea/core/css/components/_components.css b/ext/riverlea/core/css/components/_components.css index c9c8ffb8a58d..718ed806fbbd 100644 --- a/ext/riverlea/core/css/components/_components.css +++ b/ext/riverlea/core/css/components/_components.css @@ -813,12 +813,3 @@ div.civicrm-community-messages a.civicrm-community-message-dismiss::before { .crm-container .pcp-page-text { margin-bottom: var(--crm-padding-reg); } - -civi-rich-text-input .rich-text-preview { - /* ensure a clickable target, even if empty */ - min-height: 1rem; - cursor: cell; -} -civi-rich-text-input .rich-text-preview:hover { - outline: 2px dashed var(--crm-c-blue-dark); -} diff --git a/ext/riverlea/core/css/components/_form.css b/ext/riverlea/core/css/components/_form.css index f064f04569fa..95f306e87722 100644 --- a/ext/riverlea/core/css/components/_form.css +++ b/ext/riverlea/core/css/components/_form.css @@ -144,7 +144,8 @@ select.crm-form-multiselect, select.crm-form-select, .crm-container .ui-widget input, .crm-container select, -.ui-datepicker .ui-datepicker-header select { +.ui-datepicker .ui-datepicker-header select, +civi-rich-text-input .rich-text-preview { transition: var(--crm-input-active-transition); font-size: var(--crm-input-font-size); background-color: var(--crm-input-bg-color); @@ -992,3 +993,10 @@ span.crm-select-item-color { .crm-container :is(input,textarea,option):disabled { background-color: color-mix(in srgb, var(--crm-input-bg-color) 90%, var(--crm-ink) 10%); } + +civi-rich-text-input .rich-text-preview { + height: unset; + width: unset; + min-height: var(--crm-input-height); + cursor: cell; +} diff --git a/ext/riverlea/core/org.civicrm.afform_admin-ang/afGuiEditor.css b/ext/riverlea/core/org.civicrm.afform_admin-ang/afGuiEditor.css index 9d73a77dd66a..0d22dc91a269 100644 --- a/ext/riverlea/core/org.civicrm.afform_admin-ang/afGuiEditor.css +++ b/ext/riverlea/core/org.civicrm.afform_admin-ang/afGuiEditor.css @@ -706,3 +706,12 @@ i.crm-i.af-gui-conditional-dialog-move-icon { .af-gui-node-title:not(:focus-within) af-gui-token-select[field="label"] { visibility: hidden; } + +/* suppress input styling on form elements */ +af-gui-container civi-rich-text-input .rich-text-preview { + border: none; + background: none; +} +af-gui-container civi-rich-text-input .rich-text-preview:hover { + outline: 2px dashed var(--crm-c-blue-dark); +}