\ No newline at end of file
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 2d8436c87a90..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);
@@ -569,15 +576,22 @@
// 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 = {};
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;
@@ -586,10 +600,9 @@
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;
};
-
}
});
})(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
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 9c9c6fda7ebb..0d22dc91a269 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,17 @@ 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;
+}
+
+/* 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);
+}
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 b18d464d2ad9..78d2a39c7ded 100644
--- a/js/wysiwyg/crm.wysiwyg.js
+++ b/js/wysiwyg/crm.wysiwyg.js
@@ -2,6 +2,156 @@
(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 = `
+