diff --git a/packages/frontend/app/routes/program-year.js b/packages/frontend/app/routes/program-year.js
index 8f12f823d7..fd137ba33a 100644
--- a/packages/frontend/app/routes/program-year.js
+++ b/packages/frontend/app/routes/program-year.js
@@ -1,6 +1,6 @@
import Route from '@ember/routing/route';
import { service } from '@ember/service';
-import { loadFroalaEditor } from 'ilios-common/utils/load-froala-editor';
+import { loadQuillEditor } from 'ilios-common/utils/load-quill-editor';
export default class ProgramYearRoute extends Route {
@service currentUser;
@@ -34,8 +34,8 @@ export default class ProgramYearRoute extends Route {
async afterModel(programYear) {
const permissionChecker = this.permissionChecker;
this.canUpdate = await permissionChecker.canUpdateProgramYear(programYear);
- //pre load froala so it's available quickly when working in the course
- loadFroalaEditor();
+ // pre-load quill so it's available quickly when working in the course
+ loadQuillEditor();
}
setupController(controller, model) {
diff --git a/packages/frontend/ember-cli-build.js b/packages/frontend/ember-cli-build.js
index 8a6850c505..6a401d5702 100644
--- a/packages/frontend/ember-cli-build.js
+++ b/packages/frontend/ember-cli-build.js
@@ -94,6 +94,14 @@ module.exports = async function (defaults) {
}),
],
},
+ module: {
+ rules: [
+ {
+ test: /\.svg$/,
+ type: 'asset/source', // This will import SVG files as text
+ },
+ ],
+ },
},
},
});
diff --git a/packages/frontend/package.json b/packages/frontend/package.json
index 81992de022..15ef08f63c 100644
--- a/packages/frontend/package.json
+++ b/packages/frontend/package.json
@@ -119,6 +119,7 @@
"prettier": "^3.5.3",
"prettier-plugin-ember-template-tag": "^2.0.6",
"query-string": "^9.1.0",
+ "quill": "^2.0.3",
"qunit": "^2.24.1",
"qunit-dom": "^3.4.0",
"sass": "^1.91.0",
diff --git a/packages/frontend/tests/acceptance/course/session/overview-test.js b/packages/frontend/tests/acceptance/course/session/overview-test.js
index efee058b48..f3c586c0f5 100644
--- a/packages/frontend/tests/acceptance/course/session/overview-test.js
+++ b/packages/frontend/tests/acceptance/course/session/overview-test.js
@@ -642,15 +642,24 @@ module('Acceptance | Session - Overview', function (hooks) {
const newInstructionalNotes = 'some new thing';
await page.visit({ courseId: 1, sessionId: 1 });
- assert.strictEqual(currentRouteName(), 'session.index');
- assert.strictEqual(page.details.overview.instructionalNotes.value, 'instructional note');
+ assert.strictEqual(currentRouteName(), 'session.index', 'route name is correct');
+ assert.strictEqual(
+ page.details.overview.instructionalNotes.value,
+ 'instructional note',
+ 'instructional notes value is correct',
+ );
await page.details.overview.instructionalNotes.edit();
await page.details.overview.instructionalNotes.set(newInstructionalNotes);
await page.details.overview.instructionalNotes.save();
- assert.strictEqual(page.details.overview.instructionalNotes.value, newInstructionalNotes);
+ assert.strictEqual(
+ page.details.overview.instructionalNotes.value,
+ newInstructionalNotes,
+ 'new instructional notes value is correct',
+ );
assert.strictEqual(
this.server.db.sessions[0].instructionalNotes,
`
${newInstructionalNotes}
`,
+ 'instructional notes value in database is correct',
);
});
@@ -668,15 +677,24 @@ module('Acceptance | Session - Overview', function (hooks) {
const newInstructionalNotes = 'some new thing';
await page.visit({ courseId: 1, sessionId: 1 });
- assert.strictEqual(currentRouteName(), 'session.index');
- assert.strictEqual(page.details.overview.instructionalNotes.value, 'Click to edit');
+ assert.strictEqual(currentRouteName(), 'session.index', 'route name is correct');
+ assert.strictEqual(
+ page.details.overview.instructionalNotes.value,
+ 'Click to edit',
+ 'initial instructional notes value is correct',
+ );
await page.details.overview.instructionalNotes.edit();
await page.details.overview.instructionalNotes.set(newInstructionalNotes);
await page.details.overview.instructionalNotes.save();
- assert.strictEqual(page.details.overview.instructionalNotes.value, newInstructionalNotes);
+ assert.strictEqual(
+ page.details.overview.instructionalNotes.value,
+ newInstructionalNotes,
+ 'new instructional notes value is correct',
+ );
assert.strictEqual(
this.server.db.sessions[0].instructionalNotes,
`${newInstructionalNotes}
`,
+ 'instructional notes value in database is correct',
);
});
diff --git a/packages/frontend/tests/pages/components/program-year/objective-list-item.js b/packages/frontend/tests/pages/components/program-year/objective-list-item.js
index cba4ab5246..c018e89a36 100644
--- a/packages/frontend/tests/pages/components/program-year/objective-list-item.js
+++ b/packages/frontend/tests/pages/components/program-year/objective-list-item.js
@@ -1,5 +1,5 @@
import { clickable, create, hasClass, isPresent, isVisible, text } from 'ember-cli-page-object';
-import { pageObjectFillInFroalaEditor, pageObjectFroalaEditorValue } from 'ilios-common';
+import { pageObjectFillInQuillEditor, pageObjectQuillEditorValue } from 'ilios-common';
import meshManager from './manage-objective-descriptors';
import competencyManager from './manage-objective-competency';
import meshDescriptors from './objective-list-item-descriptors';
@@ -14,8 +14,8 @@ const definition = {
description: {
scope: '[data-test-description]',
openEditor: clickable('[data-test-edit]'),
- editorContents: pageObjectFroalaEditorValue('[data-test-html-editor]'),
- edit: pageObjectFillInFroalaEditor('[data-test-html-editor]'),
+ editorContents: pageObjectQuillEditorValue('[data-test-html-editor]'),
+ edit: pageObjectFillInQuillEditor('[data-test-html-editor]'),
save: clickable('.done'),
hasError: isPresent('[data-test-description-validation-error-message]'),
error: text('[data-test-description-validation-error-message]'),
diff --git a/packages/ilios-common/addon-test-support/ilios-common/helpers/froala-editor.js b/packages/ilios-common/addon-test-support/ilios-common/helpers/froala-editor.js
deleted file mode 100644
index 59043892bf..0000000000
--- a/packages/ilios-common/addon-test-support/ilios-common/helpers/froala-editor.js
+++ /dev/null
@@ -1,55 +0,0 @@
-import { findOne } from 'ember-cli-page-object/extend';
-import { loadFroalaEditor } from 'ilios-common/utils/load-froala-editor';
-import { later } from '@ember/runloop';
-
-export async function fillInFroalaEditor(element, html) {
- const editor = await getEditorInstance(element);
- editor.html.set(html);
- editor.undo.saveStep();
-}
-export async function froalaEditorValue(element) {
- const editor = await getEditorInstance(element);
- return editor.html.get();
-}
-
-export function pageObjectFillInFroalaEditor(selector, options = {}) {
- return {
- isDescriptor: true,
-
- get() {
- return async function (html) {
- const element = findOne(this, selector, options);
- return fillInFroalaEditor(element, html);
- };
- },
- };
-}
-
-export function pageObjectFroalaEditorValue(selector, options = {}) {
- return {
- isDescriptor: true,
-
- get() {
- return async function () {
- const element = findOne(this, selector, options);
- return froalaEditorValue(element);
- };
- },
- };
-}
-
-function getEditorInstance(element) {
- return new Promise((resolve) => {
- loadFroalaEditor().then(({ FroalaEditor }) => {
- // eslint-disable-next-line ember/no-runloop
- later(() => {
- const { INSTANCES } = FroalaEditor;
- const ourInstance = INSTANCES.find((instance) => {
- const instanceElement = instance['$oel'][0];
- return instanceElement.id === element.id;
- });
- resolve(ourInstance);
- });
- });
- });
-}
diff --git a/packages/ilios-common/addon-test-support/ilios-common/helpers/quill-editor.js b/packages/ilios-common/addon-test-support/ilios-common/helpers/quill-editor.js
new file mode 100644
index 0000000000..2bb8318d94
--- /dev/null
+++ b/packages/ilios-common/addon-test-support/ilios-common/helpers/quill-editor.js
@@ -0,0 +1,52 @@
+import { findOne } from 'ember-cli-page-object/extend';
+import { loadQuillEditor } from 'ilios-common/utils/load-quill-editor';
+import { later } from '@ember/runloop';
+
+export async function fillInQuillEditor(element, html) {
+ const editor = await getEditorInstance(element);
+ editor.setContents(editor.clipboard.convert({ html }));
+}
+export async function quillEditorValue(element) {
+ const editor = await getEditorInstance(element);
+ // easiest way to get the HTML in an editor, maintain multiple spaces, and make sure it's empty empty, as Quill leaves `
` even if the editor is "empty"
+ // not using editor.getContents() as it returns custom Delta object that doesn't actually have the HTML markup: https://quilljs.com/docs/api#getcontents
+ return editor.root.innerHTML.split(' ').join(' ').replaceAll('
', '');
+}
+
+export function pageObjectFillInQuillEditor(selector, options = {}) {
+ return {
+ isDescriptor: true,
+
+ get() {
+ return async function (html) {
+ const element = findOne(this, selector, options);
+ return fillInQuillEditor(element, html);
+ };
+ },
+ };
+}
+
+export function pageObjectQuillEditorValue(selector, options = {}) {
+ return {
+ isDescriptor: true,
+
+ get() {
+ return async function () {
+ const element = findOne(this, selector, options);
+ return quillEditorValue(element);
+ };
+ },
+ };
+}
+
+function getEditorInstance(element) {
+ return new Promise((resolve) => {
+ loadQuillEditor().then(({ QuillEditor }) => {
+ // eslint-disable-next-line ember/no-runloop
+ later(() => {
+ const ourInstance = QuillEditor.find(document.querySelector(`#${element.id}`));
+ resolve(ourInstance);
+ });
+ });
+ });
+}
diff --git a/packages/ilios-common/addon-test-support/ilios-common/index.js b/packages/ilios-common/addon-test-support/ilios-common/index.js
index 020b2c4f65..2f25a79532 100644
--- a/packages/ilios-common/addon-test-support/ilios-common/index.js
+++ b/packages/ilios-common/addon-test-support/ilios-common/index.js
@@ -3,10 +3,10 @@ export { default as waitForResource } from './helpers/wait-for-resource';
export { freezeDateAt, unfreezeDate } from './helpers/mockdate';
export { flatpickrDatePicker, flatpickrDateValue } from './helpers/flatpickr-date-picker';
export {
- fillInFroalaEditor,
- froalaEditorValue,
- pageObjectFillInFroalaEditor,
- pageObjectFroalaEditorValue,
-} from './helpers/froala-editor';
+ fillInQuillEditor,
+ quillEditorValue,
+ pageObjectFillInQuillEditor,
+ pageObjectQuillEditorValue,
+} from './helpers/quill-editor';
export { getText, getElementText } from './helpers/custom-helpers';
export { hasFocus } from './helpers/has-focus';
diff --git a/packages/ilios-common/addon-test-support/ilios-common/page-objects/components/course/objective-list-item.js b/packages/ilios-common/addon-test-support/ilios-common/page-objects/components/course/objective-list-item.js
index f28cf194ec..7de09f62ae 100644
--- a/packages/ilios-common/addon-test-support/ilios-common/page-objects/components/course/objective-list-item.js
+++ b/packages/ilios-common/addon-test-support/ilios-common/page-objects/components/course/objective-list-item.js
@@ -1,5 +1,5 @@
import { clickable, create, hasClass, isPresent, isVisible, text } from 'ember-cli-page-object';
-import { pageObjectFillInFroalaEditor, pageObjectFroalaEditorValue } from 'ilios-common';
+import { pageObjectFillInQuillEditor, pageObjectQuillEditorValue } from 'ilios-common';
import fadeText from '../fade-text';
import meshManager from './manage-objective-descriptors';
import parentManager from './manage-objective-parents';
@@ -15,8 +15,8 @@ const definition = {
scope: '[data-test-description]',
openEditor: clickable('[data-test-edit]'),
fadeText,
- editorContents: pageObjectFroalaEditorValue('[data-test-html-editor]'),
- edit: pageObjectFillInFroalaEditor('[data-test-html-editor]'),
+ editorContents: pageObjectQuillEditorValue('[data-test-html-editor]'),
+ edit: pageObjectFillInQuillEditor('[data-test-html-editor]'),
save: clickable('.done'),
hasError: isPresent('[data-test-description-validation-error-message]'),
error: text('[data-test-description-validation-error-message]'),
diff --git a/packages/ilios-common/addon-test-support/ilios-common/page-objects/components/detail-learning-materials.js b/packages/ilios-common/addon-test-support/ilios-common/page-objects/components/detail-learning-materials.js
index 30b03915ec..ed9f87ed6e 100644
--- a/packages/ilios-common/addon-test-support/ilios-common/page-objects/components/detail-learning-materials.js
+++ b/packages/ilios-common/addon-test-support/ilios-common/page-objects/components/detail-learning-materials.js
@@ -15,7 +15,7 @@ import newLearningMaterial from './new-learningmaterial';
import datePicker from './date-picker';
import timePicker from './time-picker';
import items from './detail-learning-materials-item';
-import { pageObjectFillInFroalaEditor, pageObjectFroalaEditorValue } from 'ilios-common';
+import { pageObjectFillInQuillEditor, pageObjectQuillEditorValue } from 'ilios-common';
const definition = {
scope: '[data-test-detail-learning-materials]',
@@ -44,8 +44,8 @@ const definition = {
description: {
scope: '.description',
value: text(),
- update: pageObjectFillInFroalaEditor('[data-test-html-editor]'),
- editorValue: pageObjectFroalaEditorValue('[data-test-html-editor]'),
+ update: pageObjectFillInQuillEditor('[data-test-html-editor]'),
+ editorValue: pageObjectQuillEditorValue('[data-test-html-editor]'),
},
copyrightPermission: text('.copyrightpermission'),
copyrightRationale: text('.copyrightrationale'),
@@ -65,8 +65,8 @@ const definition = {
statusValue: value('select', { at: 0 }),
notes: {
scope: '.notes',
- update: pageObjectFillInFroalaEditor('[data-test-html-editor]'),
- value: pageObjectFroalaEditorValue('[data-test-html-editor]'),
+ update: pageObjectFillInQuillEditor('[data-test-html-editor]'),
+ value: pageObjectQuillEditorValue('[data-test-html-editor]'),
},
addStartDate: clickable('[data-test-add-start-date]'),
addEndDate: clickable('[data-test-add-end-date]'),
diff --git a/packages/ilios-common/addon-test-support/ilios-common/page-objects/components/html-editor.js b/packages/ilios-common/addon-test-support/ilios-common/page-objects/components/html-editor.js
new file mode 100644
index 0000000000..253e3a5de2
--- /dev/null
+++ b/packages/ilios-common/addon-test-support/ilios-common/page-objects/components/html-editor.js
@@ -0,0 +1,77 @@
+import {
+ attribute,
+ clickable,
+ collection,
+ create,
+ fillable,
+ focusable,
+ isVisible,
+ isPresent,
+ property,
+ text,
+} from 'ember-cli-page-object';
+
+const definition = {
+ scope: '[data-test-quill-html-editor]',
+ toolbar: {
+ scope: '[data-test-toolbar]',
+ bold: isPresent('[data-test-toolbar-bold]'),
+ italic: isPresent('[data-test-toolbar-italic]'),
+ subscript: isPresent('[data-test-toolbar-subscript]'),
+ superscript: isPresent('[data-test-toolbar-superscript]'),
+ listOrdered: isPresent('[data-test-toolbar-list-ordered]'),
+ listUnordered: isPresent('[data-test-toolbar-list-unordered]'),
+ link: {
+ scope: '[data-test-toolbar-link]',
+ insertLink: clickable(),
+ },
+ undo: {
+ scope: '[data-test-toolbar-undo]',
+ disabled: attribute('disabled'),
+ goBack: clickable(),
+ },
+ redo: {
+ scope: '[data-test-toolbar-redo]',
+ disabled: attribute('disabled'),
+ goForward: clickable(),
+ },
+ },
+ editor: {
+ scope: '[data-test-html-editor]',
+ content: {
+ scope: '.ql-editor',
+ edit: fillable(),
+ focus: focusable(),
+ textContent: text(),
+ htmlContent: property('innerHTML'),
+ linkTooltip: {
+ scope: '.ql-tooltip',
+ openEditor: clickable('.ql-action'),
+ edit: fillable('input'),
+ },
+ },
+ },
+ popup: {
+ scope: '[data-test-insert-link-popup]',
+ errors: collection('.validation-error-message'),
+ activated: isVisible(),
+ form: {
+ url: {
+ scope: '[data-test-url]',
+ edit: fillable(),
+ },
+ text: {
+ scope: '[data-test-text]',
+ edit: fillable(),
+ },
+ linkNewTarget: isPresent('[data-test-link-new-target]'),
+ insert: {
+ scope: '[data-test-submit]',
+ submit: clickable(),
+ },
+ },
+ },
+};
+
+export default definition;
+export const component = create(definition);
diff --git a/packages/ilios-common/addon-test-support/ilios-common/page-objects/components/new-learningmaterial.js b/packages/ilios-common/addon-test-support/ilios-common/page-objects/components/new-learningmaterial.js
index b3a6a1a628..f4b63b014a 100644
--- a/packages/ilios-common/addon-test-support/ilios-common/page-objects/components/new-learningmaterial.js
+++ b/packages/ilios-common/addon-test-support/ilios-common/page-objects/components/new-learningmaterial.js
@@ -1,5 +1,5 @@
import { attribute, clickable, create, fillable, isPresent } from 'ember-cli-page-object';
-import { pageObjectFillInFroalaEditor } from 'ilios-common';
+import { pageObjectFillInQuillEditor } from 'ilios-common';
import userNameInfo from './user-name-info';
const definition = {
@@ -57,7 +57,7 @@ const definition = {
scope: '[data-test-role]',
select: fillable('select'),
},
- description: pageObjectFillInFroalaEditor('[data-test-html-editor]'),
+ description: pageObjectFillInQuillEditor('[data-test-html-editor]'),
copyrightPermission: {
scope: '[data-test-copyright-permission]',
toggle: clickable('input'),
diff --git a/packages/ilios-common/addon-test-support/ilios-common/page-objects/components/new-objective.js b/packages/ilios-common/addon-test-support/ilios-common/page-objects/components/new-objective.js
index ffcedde5e5..2c0ef30441 100644
--- a/packages/ilios-common/addon-test-support/ilios-common/page-objects/components/new-objective.js
+++ b/packages/ilios-common/addon-test-support/ilios-common/page-objects/components/new-objective.js
@@ -1,14 +1,14 @@
import { clickable, create, isPresent, text } from 'ember-cli-page-object';
-import { pageObjectFillInFroalaEditor, pageObjectFroalaEditorValue } from 'ilios-common';
+import { pageObjectFillInQuillEditor, pageObjectQuillEditorValue } from 'ilios-common';
const definition = {
title: text('[data-test-title]'),
scope: '[data-test-new-objective]',
description: {
scope: '[data-test-description]',
- label: text('label'),
- value: pageObjectFroalaEditorValue('[data-test-html-editor]'),
- set: pageObjectFillInFroalaEditor('[data-test-html-editor]'),
+ label: text('[data-test-description-label]'),
+ value: pageObjectQuillEditorValue('[data-test-html-editor]'),
+ set: pageObjectFillInQuillEditor('[data-test-html-editor]'),
hasError: isPresent('[data-test-description-validation-error-message]'),
error: text('[data-test-description-validation-error-message]'),
},
diff --git a/packages/ilios-common/addon-test-support/ilios-common/page-objects/components/objectives.js b/packages/ilios-common/addon-test-support/ilios-common/page-objects/components/objectives.js
index 528e5287f3..a1f728224d 100644
--- a/packages/ilios-common/addon-test-support/ilios-common/page-objects/components/objectives.js
+++ b/packages/ilios-common/addon-test-support/ilios-common/page-objects/components/objectives.js
@@ -1,5 +1,5 @@
import { clickable, collection, isVisible, property, text } from 'ember-cli-page-object';
-import { pageObjectFillInFroalaEditor, pageObjectFroalaEditorValue } from 'ilios-common';
+import { pageObjectFillInQuillEditor, pageObjectQuillEditorValue } from 'ilios-common';
import meshManager from './mesh-manager';
export default {
@@ -8,7 +8,7 @@ export default {
save: clickable('.detail-objectives-actions button.bigadd'),
cancel: clickable('.detail-objectives-actions button.bigcancel'),
newObjective: {
- description: pageObjectFillInFroalaEditor('[data-test-html-editor]'),
+ description: pageObjectFillInQuillEditor('[data-test-html-editor]'),
save: clickable('.done'),
cancel: clickable('.cancel'),
canSave: property('disabled', '.done'),
@@ -19,8 +19,8 @@ export default {
description: {
scope: 'td:eq(0)',
openEditor: clickable('[data-test-edit]'),
- editorContents: pageObjectFroalaEditorValue('[data-test-html-editor]'),
- edit: pageObjectFillInFroalaEditor('[data-test-html-editor]'),
+ editorContents: pageObjectQuillEditorValue('[data-test-html-editor]'),
+ edit: pageObjectFillInQuillEditor('[data-test-html-editor]'),
save: clickable('.done'),
validationError: text('.validation-error-message'),
hasValidationError: isVisible('.validation-error-message'),
diff --git a/packages/ilios-common/addon-test-support/ilios-common/page-objects/components/session/objective-list-item.js b/packages/ilios-common/addon-test-support/ilios-common/page-objects/components/session/objective-list-item.js
index fdcbc8509c..f7e0df3016 100644
--- a/packages/ilios-common/addon-test-support/ilios-common/page-objects/components/session/objective-list-item.js
+++ b/packages/ilios-common/addon-test-support/ilios-common/page-objects/components/session/objective-list-item.js
@@ -1,5 +1,5 @@
import { clickable, create, hasClass, isPresent, isVisible, text } from 'ember-cli-page-object';
-import { pageObjectFillInFroalaEditor, pageObjectFroalaEditorValue } from 'ilios-common';
+import { pageObjectFillInQuillEditor, pageObjectQuillEditorValue } from 'ilios-common';
import fadeText from '../fade-text';
import meshManager from './manage-objective-descriptors';
import parentManager from './manage-objective-parents';
@@ -15,8 +15,8 @@ const definition = {
scope: '[data-test-description]',
openEditor: clickable('[data-test-edit]'),
fadeText,
- editorContents: pageObjectFroalaEditorValue('[data-test-html-editor]'),
- edit: pageObjectFillInFroalaEditor('[data-test-html-editor]'),
+ editorContents: pageObjectQuillEditorValue('[data-test-html-editor]'),
+ edit: pageObjectFillInQuillEditor('[data-test-html-editor]'),
save: clickable('.done'),
hasError: isPresent('[data-test-description-validation-error-message]'),
error: text('[data-test-description-validation-error-message]'),
diff --git a/packages/ilios-common/addon-test-support/ilios-common/page-objects/components/session/overview.js b/packages/ilios-common/addon-test-support/ilios-common/page-objects/components/session/overview.js
index 292f83099b..d75d462717 100644
--- a/packages/ilios-common/addon-test-support/ilios-common/page-objects/components/session/overview.js
+++ b/packages/ilios-common/addon-test-support/ilios-common/page-objects/components/session/overview.js
@@ -7,7 +7,7 @@ import {
property,
text,
} from 'ember-cli-page-object';
-import { pageObjectFillInFroalaEditor } from 'ilios-common';
+import { pageObjectFillInQuillEditor } from 'ilios-common';
import postrequisiteEditor from './postrequisite-editor';
import yesNoToggle from '../toggle-yesno';
import ilm from './ilm';
@@ -42,7 +42,7 @@ const definition = {
scope: '[data-test-description]',
value: text('span', { at: 0 }),
edit: clickable('[data-test-edit]'),
- set: pageObjectFillInFroalaEditor('[data-test-html-editor]'),
+ set: pageObjectFillInQuillEditor('[data-test-html-editor]'),
save: clickable('.done'),
savingIsDisabled: property('disabled', '.done'),
cancel: clickable('.cancel'),
@@ -52,7 +52,7 @@ const definition = {
scope: '[data-test-instructional-notes]',
value: text('span', { at: 0 }),
edit: clickable('[data-test-edit]'),
- set: pageObjectFillInFroalaEditor('[data-test-html-editor]'),
+ set: pageObjectFillInQuillEditor('[data-test-html-editor]'),
save: clickable('.done'),
savingIsDisabled: property('disabled', '.done'),
cancel: clickable('.cancel'),
diff --git a/packages/ilios-common/addon/components/html-editor.gjs b/packages/ilios-common/addon/components/html-editor.gjs
index 0bf1fea195..a20ee172ab 100644
--- a/packages/ilios-common/addon/components/html-editor.gjs
+++ b/packages/ilios-common/addon/components/html-editor.gjs
@@ -1,111 +1,447 @@
import Component from '@glimmer/component';
import { service } from '@ember/service';
import { cached, tracked } from '@glimmer/tracking';
-import { loadFroalaEditor } from 'ilios-common/utils/load-froala-editor';
+import { action } from '@ember/object';
import { guidFor } from '@ember/object/internals';
import { modifier } from 'ember-modifier';
+import { on } from '@ember/modifier';
+import t from 'ember-intl/helpers/t';
+import pick from 'ilios-common/helpers/pick';
+import set from 'ember-set-helper/helpers/set';
+import not from 'ember-truth-helpers/helpers/not';
+import onKey from 'ember-keyboard/modifiers/on-key';
+import { task } from 'ember-concurrency';
+import perform from 'ember-concurrency/helpers/perform';
+import YupValidations from 'ilios-common/classes/yup-validations';
+import { string } from 'yup';
+import YupValidationMessage from 'ilios-common/components/yup-validation-message';
import { TrackedAsyncData } from 'ember-async-data';
+import { loadQuillEditor } from 'ilios-common/utils/load-quill-editor';
+
+const DEFAULT_URL_VALUE = 'https://';
export default class HtmlEditorComponent extends Component {
@service intl;
@tracked editorId = null;
- @tracked loadFinished = false;
+ @tracked popupUrlValue;
+ @tracked popupUrlValueChanged = false;
+ @tracked popupTextValue;
+ @tracked popupLinkNewTarget = false;
+ @tracked editorHasNoRedo = true;
+ @tracked editorHasNoUndo = true;
editor = null;
- defaultButtons = {
- moreText: {
- buttons: ['bold', 'italic', 'subscript', 'superscript', 'formatOL', 'formatUL', 'insertLink'],
- buttonsVisible: 7,
- },
- moreMisc: {
- buttons: ['undo', 'redo', 'html'],
- align: 'right',
- },
- };
-
- constructor() {
- super(...arguments);
- this.editorId = guidFor(this);
- }
-
- @cached
- get loadFroalaData() {
- return new TrackedAsyncData(loadFroalaEditor());
- }
editorInserted = modifier((element, [options]) => {
if (!this.editor) {
- const { FroalaEditor } = this.loadFroalaData.value;
- const component = this;
- // getting the Froala instance inside its constructor callback
- // https://froala.com/wysiwyg-editor/examples/getHTML/
- this.editor = new FroalaEditor(element, options, function () {
- this.html.set(component.args.content);
- if (component.args.autoFocus) {
- this.events.focus();
- }
- component.loadFinished = true;
+ const { QuillEditor } = this.loadQuillData.value;
+ this.editor = new QuillEditor(element, options);
+
+ // create Quill Delta object from saved content so it can be re-added to editor
+ // https://quilljs.com/docs/delta
+ const contentToLoad = this.editor.clipboard.convert({ html: this.args.content });
+ this.editor.setContents(contentToLoad);
+
+ // clear the history stack on load so it doesn't allow you to "undo" existing content
+ this.editor.history.clear();
+
+ if (this.args.autofocus) {
+ this.editor.focus();
+ }
+
+ let typingTimer;
+ const TYPING_TIMEOUT = 500; // like Froala's typingTimer
+
+ this.editor.on('text-change', () => {
+ // in order to make Quill's undo work like Froala
+ // we use a timer and force a history cutoff
+ // but only when a user is done typing
+ // otherwise, Quill will make undo steps after `delay` ms
+ clearTimeout(typingTimer);
+ typingTimer = setTimeout(() => {
+ if (this.editor) {
+ this.editor.history.cutoff();
+ }
+ }, TYPING_TIMEOUT);
+
+ this.editorHasNoRedo = !this.editor.history.stack.redo.length;
+ this.editorHasNoUndo = !this.editor.history.stack.undo.length;
+
+ // get version of content suitable for saving to database
+ // https://quilljs.com/docs/api#getsemantichtml
+ let contentToSave = this.editor.getSemanticHTML();
+ // Quill is turning ' ' into ` `
+ // so we need to massage this to only use the entity when needed
+ const regex = /(?: |\u00A0)+/g;
+
+ const spaceReplacer = (match) => {
+ const count = (match.match(/(?: |\u00A0)/g) || []).length;
+ const pairs = Math.floor(count / 2);
+ const extra = count % 2;
+
+ // Create ' ' for each pair
+ let result = ' '.repeat(pairs);
+
+ // Add an extra space if there is a leftover single
+ if (extra) result += ' ';
+
+ return result;
+ };
+ contentToSave = contentToSave.replace(regex, spaceReplacer);
+ this.args.update(contentToSave);
});
}
return true;
});
+ validations = new YupValidations(this, {
+ popupUrlValue: string().required().trim().max(2000).url(),
+ popupTextValue: string().required(),
+ });
+
+ constructor() {
+ super(...arguments);
+ this.editorId = guidFor(this);
+ }
+
+ @cached
+ get loadQuillData() {
+ return new TrackedAsyncData(loadQuillEditor());
+ }
+
get options() {
return {
- key: 'Kb3A3pE2E2A1E4G4I4oCd2ZSb1XHi1Cb2a1KIWCWMJHXCLSwG1G1B2C1B1C7F6E1E4F4==',
- theme: 'gray',
- attribution: false,
- // workaround, see https://github.com/froala/wysiwyg-editor/issues/4794
- fontFamilyDefaultSelection: 'Font Family',
- fontSizeDefaultSelection: 'Font Size',
- language: this.intl.primaryLocale,
- toolbarInline: false,
- placeholderText: '',
- saveInterval: false,
- pastePlain: true,
- spellcheck: true,
- toolbarButtons: this.defaultButtons,
- toolbarButtonsMD: this.defaultButtons,
- toolbarButtonsSM: this.defaultButtons,
- toolbarButtonsXS: this.defaultButtons,
- quickInsertButtons: false,
- pluginsEnabled: ['lists', 'code_view', 'link'],
- listAdvancedTypes: false,
- shortcutsEnabled: ['bold', 'italic', 'strikeThrough', 'undo', 'redo', 'createLink'],
- events: {
- contentChanged: () => {
- if (!this.isDestroyed && !this.isDestroying) {
- this.args.update(this.editor.html.get());
- }
+ modules: {
+ toolbar: {
+ container: this.toolbarId,
+ handlers: {
+ undo: () => {
+ this.editor.history.undo();
+ },
+ redo: () => {
+ this.editor.history.redo();
+ },
+ link: () => {
+ this.togglePopup();
+ },
+ },
},
- },
- linkList: [
- {
- displayText: 'PubMed',
- href: 'https://www.ncbi.nlm.nih.gov/pubmed/',
- target: '_blank',
+ history: {
+ delay: 100000, // effectively disable so we can do debounce like Froala
},
- ],
- linkEditButtons: ['linkEdit', 'linkRemove'],
+ },
+ theme: 'snow',
};
}
+
+ get popupId() {
+ return `${this.editorId}-popup`;
+ }
+ get popupUrlId() {
+ return `${this.editorId}-popup-link-url`;
+ }
+ get popupTextId() {
+ return `${this.editorId}-popup-link-text`;
+ }
+ get popupLinkNewTargetId() {
+ return `${this.editorId}-popup-new-target`;
+ }
+
+ get toolbarId() {
+ return `#${this.editorId}-toolbar`;
+ }
+ get toolbarLinkPosition() {
+ return document.querySelector(`#${this.editorId}-toolbar .ql-link`).offsetLeft;
+ }
+
+ get bestUrl() {
+ if (this.popupUrlValue || this.popupUrlValueChanged) {
+ return this.popupUrlValue;
+ }
+
+ return DEFAULT_URL_VALUE;
+ }
+
+ @action
+ clearPopupValues() {
+ this.popupUrlValue = '';
+ this.popupTextValue = '';
+ }
+
+ @action
+ async saveOnEnter(event) {
+ // don't send an actual Enter/Return to Quill
+ event.preventDefault();
+
+ await this.addLink.perform();
+ }
+
+ @action
+ onEscapeKey() {
+ const popup = document.querySelector(`#${this.popupId}`);
+ if (popup.classList.contains('ql-active')) {
+ this.togglePopup();
+ }
+ }
+
+ addLink = task(async () => {
+ this.validations.addErrorDisplayForAllFields();
+ const isValid = await this.validations.isValid();
+
+ if (!isValid) {
+ return false;
+ }
+
+ const quill = this.editor;
+ const range = quill.getSelection(true);
+
+ // no text yet, add text and link around it
+ if (!range.length) {
+ quill.insertText(range.index, this.popupTextValue, 'user');
+ }
+
+ quill.setSelection(range.index, this.popupTextValue.length);
+
+ // create URL out of url string to make sure it is parsed correctly
+ if (/^[a-z0-9]+(?:\.[a-z0-9]+)+$/.test(this.popupUrlValue)) {
+ this.popupUrlValue = `http://${this.popupUrlValue}`;
+ }
+
+ const url = new URL(this.popupUrlValue, window.location.href);
+
+ // double check that the url has a protocol (default to http)
+ if (!url.protocol) {
+ url.protocol = 'http://';
+ }
+
+ const attrs = this.popupLinkNewTarget ? { href: url.href, blank: true } : { href: url.href };
+ quill.formatText(range.index, this.popupTextValue.length, 'link', attrs);
+
+ quill.setSelection(range.index + this.popupTextValue.length);
+
+ this.clearPopupValues();
+
+ this.togglePopup();
+ });
+
+ @action
+ togglePopup() {
+ const popup = document.querySelector(`#${this.popupId}`);
+
+ if (!popup.classList.contains('ql-active')) {
+ popup.classList.add('ql-active');
+
+ const editor = document.querySelector(`#${this.editorId}`);
+ popup.style.left = `${this.toolbarLinkPosition}px`;
+ popup.style.top = `${editor.offsetTop - 18}px`;
+
+ const range = this.editor.getSelection(true);
+
+ if (range.length) {
+ this.popupTextValue = this.editor.getText(range.index, range.length);
+ }
+
+ popup.querySelector('input').focus();
+
+ const closePopup = document.addEventListener('click', ({ target }) => {
+ if (!target.closest('.ql-popup') && !target.closest('.ql-link')) {
+ popup.classList.remove('ql-active');
+
+ this.clearPopupValues();
+
+ document.removeEventListener('click', closePopup);
+ }
+ });
+ } else {
+ popup.classList.remove('ql-active');
+ this.editor.focus();
+ }
+ }
+
+ @action
+ selectAllText({ target }) {
+ if (target.value === DEFAULT_URL_VALUE) {
+ target.select();
+ }
+ }
+
+ @action
+ changeURL(value) {
+ this.validations.addErrorDisplayFor('popupUrlValue');
+ value = value.trim();
+ const regex = RegExp('https://http[s]?:');
+ if (regex.test(value)) {
+ value = value.substring(8);
+ }
+ this.popupUrlValue = value;
+ this.popupUrlValueChanged = true;
+ }
+
willDestroy() {
super.willDestroy(...arguments);
if (this.editor) {
- this.editor.destroy();
this.editor = null;
}
}
- {{#if this.loadFroalaData.isResolved}}
+ {{#if this.loadQuillData.isResolved}}
{{/if}}
diff --git a/packages/ilios-common/addon/components/new-objective.gjs b/packages/ilios-common/addon/components/new-objective.gjs
index 48f6b6de63..14f3f86ed9 100644
--- a/packages/ilios-common/addon/components/new-objective.gjs
+++ b/packages/ilios-common/addon/components/new-objective.gjs
@@ -46,7 +46,7 @@ export default class NewObjectiveComponent extends Component {
{{t "general.newObjective"}}