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; } } 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"}}

-