UNPKG

@oat-sa/tao-item-runner-qti

Version:
1,164 lines (1,041 loc) 63.6 kB
/* * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU General Public License * as published by the Free Software Foundation; under version 2 * of the License (non-upgradable). * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program; if not, write to the Free Software * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. * * Copyright (c) 2014-2023 (original work) Open Assessment Technlogies SA (under the project TAO-PRODUCT); * */ /** * @author Sam Sipasseuth <sam@taotesting.com> * @author Bertrand Chevrier <bertrand@taotesting.com> */ import $ from 'jquery'; import _ from 'lodash'; import __ from 'i18n'; import features from 'services/features'; import strLimiter from 'util/strLimiter'; import template from 'taoQtiItem/qtiCommonRenderer/tpl/interactions/extendedTextInteraction'; import countTpl from 'taoQtiItem/qtiCommonRenderer/tpl/interactions/constraints/count'; import containerHelper from 'taoQtiItem/qtiCommonRenderer/helpers/container'; import instructionMgr from 'taoQtiItem/qtiCommonRenderer/helpers/instructions/instructionManager'; import ckEditor from 'ckeditor'; import ckConfigurator from 'taoQtiItem/qtiCommonRenderer/helpers/ckConfigurator'; import patternMaskHelper from 'taoQtiItem/qtiCommonRenderer/helpers/patternMask'; import { isSafari } from 'taoQtiItem/qtiCommonRenderer/helpers/userAgent'; import tooltip from 'ui/tooltip'; import converter from 'util/converter'; import loggerFactory from 'core/logger'; import { getIsWritingModeVerticalRl, supportsVerticalFormElement } from 'taoQtiItem/qtiCommonRenderer/helpers/verticalWriting'; /** * Create a logger */ const logger = loggerFactory('taoQtiItem/qtiCommonRenderer/renderers/interactions/ExtendedTextInteraction.js'); const hideXhtmlConstraints = !features.isVisible( 'taoQtiItem/creator/interaction/extendedText/property/xhtmlConstraints' ); const hideXhtmlRecommendations = !features.isVisible( 'taoQtiItem/creator/interaction/extendedText/property/xhtmlRecommendations' ); /** * Init rendering, called after template injected into the DOM * All options are listed in the QTI v2.1 information model: * http://www.imsglobal.org/question/qtiv2p1/imsqti_infov2p1.html#element10296 * * @param {Object} interaction - the extended text interaction model * @returns {Promise} rendering is async */ function render(interaction) { return new Promise(function (resolve, reject) { let $el, expectedLength, minStrings, patternMask, placeholderType, editor; let _styleUpdater, themeLoaded, _getNumStrings; let $container = containerHelper.get(interaction); const multiple = _isMultiple(interaction); const limiter = inputLimiter(interaction); const placeholderText = interaction.attr('placeholderText'); const serial = $container.data('serial'); const getItemLanguage = () => { let itemLang = $container.closest('.qti-item').attr('lang'); let itemLocale = itemLang && itemLang.split('-')[0]; if (!itemLocale) { itemLang = window.document.documentElement.getAttribute('lang'); itemLocale = itemLang && itemLang.split('-')[0]; } return itemLocale; }; const toolbarType = 'extendedText'; const ckOptions = { resize_enabled: true, secure: location.protocol === 'https:', forceCustomDomain: true, language: getItemLanguage() }; if (!multiple) { $el = $container.find('textarea'); if (placeholderText) { $el.attr('placeholder', placeholderText); } if (_getFormat(interaction) === 'xhtml') { if (hideXhtmlConstraints && hideXhtmlRecommendations) { $container.find('.text-counter').hide(); } if (hideXhtmlConstraints) { limiter.enabled = false; } _styleUpdater = function () { let qtiItemStyle, $editorBody, qtiItem; if (editor.document) { qtiItem = $('.qti-item').get(0); qtiItemStyle = qtiItem.currentStyle || window.getComputedStyle(qtiItem); if (editor.document.$ && editor.document.$.body) { $editorBody = $(editor.document.$.body); } else { $editorBody = $(editor.document.getBody().$); } $editorBody.css({ 'background-color': 'transparent', color: qtiItemStyle.color }); } }; themeLoaded = function () { _styleUpdater(); }; editor = _setUpCKEditor(interaction, ckOptions); if (!editor) { reject('Unable to instantiate ckEditor'); } editor.on('instanceReady', function () { _styleUpdater(); //TAO-6409, disable navigation from cke toolbar if (editor.container && editor.container.$) { $(editor.container.$).addClass('no-key-navigation'); } //it seems there's still something done after loaded, so resolved must be defered _.delay(resolve, 300); }); if (editor.status === 'ready' || editor.status === 'loaded') { _.defer(resolve); } editor.on('configLoaded', function () { editor.config = ckConfigurator.getConfig(editor, toolbarType, ckOptions); if (limiter.enabled) { limiter.listenTextInput(); } }); editor.on('change', function () { containerHelper.triggerResponseChangeEvent(interaction, {}); }); $(document).on('themechange.themeloader', themeLoaded); } else { const isVertical = getIsWritingModeVerticalRl($container); if (isVertical) { const textareaSupportsVertical = supportsVerticalFormElement(); if (!textareaSupportsVertical) { $el.addClass('vertical-unsupported'); } } $el.on('keyup.commonRenderer change.commonRenderer', function () { containerHelper.triggerResponseChangeEvent(interaction, {}); }); if (limiter.enabled) { limiter.listenTextInput(); } interaction.safariVerticalRlPatch = _patchSafariVerticalRl($el, serial); resolve(); } //multiple inputs } else { $el = $container.find('input'); minStrings = interaction.attr('minStrings'); expectedLength = interaction.attr('expectedLength'); patternMask = interaction.attr('patternMask'); //setting the checking for minimum number of answers if (minStrings) { //get the number of filled inputs _getNumStrings = function ($element) { let num = 0; $element.each(function () { if ($(this).val() !== '') { num++; } }); return num; }; minStrings = parseInt(minStrings, 10); if (minStrings > 0) { $el.on('blur.commonRenderer', function () { setTimeout(function () { //checking if the user was clicked outside of the input fields //TODO remove notifications in favor of instructions if (!$el.is(':focus') && _getNumStrings($el) < minStrings) { instructionMgr.appendNotification( interaction, `${__('The minimum number of answers is ')} : ${minStrings}`, 'warning' ); } }, 100); }); } } //set the fields width if (expectedLength) { expectedLength = parseInt(expectedLength, 10); if (expectedLength > 0) { $el.each(function () { $(this).css('width', `${expectedLength}em`); }); } } //set the fields pattern mask if (patternMask) { $el.each(function () { _setPattern($(this), patternMask); }); } //set the fields placeholder if (placeholderText) { /** * The type of the fileds placeholder: * multiple - set placeholder for each field * first - set placeholder only for first field * none - dont set placeholder */ placeholderType = 'first'; if (placeholderType === 'multiple') { $el.each(function () { $(this).attr('placeholder', placeholderText); }); } else if (placeholderType === 'first') { $el.first().attr('placeholder', placeholderText); } } resolve(); } }); } /** * Reset the textarea / ckEditor * @param {Object} interaction - the extended text interaction model */ function resetResponse(interaction) { if (_getFormat(interaction) === 'xhtml') { _getCKEditor(interaction).setData(''); } else { containerHelper.get(interaction).find('input, textarea').val(''); if (interaction.safariVerticalRlPatch) { interaction.safariVerticalRlPatch.syncValue(); } } } /** * Set the response to the rendered interaction. * * The response format follows the IMS PCI recommendation : * http://www.imsglobal.org/assessment/pciv1p0cf/imsPCIv1p0cf.html#_Toc353965343 * * Available base types are defined in the QTI v2.1 information model: * http://www.imsglobal.org/question/qtiv2p1/imsqti_infov2p1.html#element10296 * * @param {Object} interaction - the extended text interaction model * @param {object} response */ function setResponse(interaction, response) { const _setMultipleVal = (identifier, value) => { interaction.getContainer().find(`#${identifier}`).val(value); }; const baseType = interaction.getResponseDeclaration().attr('baseType'); if (response.base === null && Object.keys(response).length === 1) { response = { base: { string: '' } }; } if (response.base && typeof response.base[baseType] !== 'undefined') { setText(interaction, response.base[baseType]); } else if (response.list && response.list[baseType]) { for (let i in response.list[baseType]) { const serial = typeof response.list.serial === 'undefined' ? '' : response.list.serial[i]; _setMultipleVal(`${serial}_${i}`, response.list[baseType][i]); } } else { throw new Error('wrong response format in argument.'); } } /** * Return the response of the rendered interaction * * The response format follows the IMS PCI recommendation : * http://www.imsglobal.org/assessment/pciv1p0cf/imsPCIv1p0cf.html#_Toc353965343 * * Available base types are defined in the QTI v2.1 information model: * http://www.imsglobal.org/question/qtiv2p1/imsqti_infov2p1.html#element10296 * * @param {Object} interaction - the extended text interaction model * @returns {object} */ function getResponse(interaction) { const $container = containerHelper.get(interaction); const attributes = interaction.getAttributes(); const responseDeclaration = interaction.getResponseDeclaration(); const baseType = responseDeclaration.attr('baseType'); const numericBase = attributes.base || 10; const multiple = !!( attributes.maxStrings && (responseDeclaration.attr('cardinality') === 'multiple' || responseDeclaration.attr('cardinality') === 'ordered') ); const ret = multiple ? { list: {} } : { base: {} }; let values; let value = ''; if (multiple) { values = []; $container.find('input').each(function (i) { const editorValue = $(this).val(); if (attributes.placeholderText && value === attributes.placeholderText) { values[i] = ''; } else { const convertedValue = converter.convert(editorValue); if (baseType === 'integer') { values[i] = parseInt(convertedValue, numericBase); values[i] = isNaN(values[i]) ? '' : values[i]; } else if (baseType === 'float') { values[i] = parseFloat(convertedValue); values[i] = isNaN(values[i]) ? '' : values[i]; } else if (baseType === 'string') { values[i] = convertedValue; } } }); ret.list[baseType] = values; } else { if (attributes.placeholderText && _getTextareaValue(interaction) === attributes.placeholderText) { value = ''; } else { if (baseType === 'integer') { value = parseInt(converter.convert(_getTextareaValue(interaction)), numericBase); } else if (baseType === 'float') { value = converter.convert(_getTextareaValue(interaction)); } else if (baseType === 'string') { value = converter.convert(_getTextareaValue(interaction, true)); } } ret.base[baseType] = isNaN(value) && typeof value === 'number' ? '' : value; } return ret; } /** * Creates an input limiter object * @param {Object} interaction - the extended text interaction * @returns {Object} the limiter */ function inputLimiter(interaction) { const $container = containerHelper.get(interaction); const expectedLength = interaction.attr('expectedLength'); const expectedLines = interaction.attr('expectedLines'); const patternMask = interaction.attr('patternMask'); const isCke = _getFormat(interaction) === 'xhtml'; let patternRegEx; let $textarea, $charsCounter, $wordsCounter, maxWords, maxLength, $maxLengthCounter, $maxWordsCounter, $expectedLengthCounter; let enabled = false; if (expectedLength || expectedLines || patternMask) { enabled = true; $textarea = $('.text-container', $container); $charsCounter = $('.count-chars', $container); $wordsCounter = $('.count-words', $container); $maxLengthCounter = $('.count-max-length', $container); $maxWordsCounter = $('.count-max-words', $container); $expectedLengthCounter = $('.count-expected-length', $container); if (patternMask !== '') { maxWords = parseInt(patternMaskHelper.parsePattern(patternMask, 'words'), 10); maxLength = parseInt(patternMaskHelper.parsePattern(patternMask, 'chars'), 10); maxWords = _.isNaN(maxWords) ? 0 : maxWords; maxLength = _.isNaN(maxLength) ? 0 : maxLength; if (!maxLength && !maxWords) { patternRegEx = new RegExp(patternMask); } $maxLengthCounter.html(maxLength); $maxWordsCounter.text(maxWords); } if (expectedLength || expectedLines) { $expectedLengthCounter.html($expectedLengthCounter.text()); } } const validator = patternMask ? patternMaskHelper.createValidator(patternMask) : null; /** * The limiter instance */ const limiter = { /** * Is the limiter enabled regarding the interaction configuration */ enabled, /** * Listen for text input into the interaction and limit it if necessary */ listenTextInput() { const ignoreKeyCodes = [ 8, // backspace 13, // enter 16, // shift 17, // control 46, // delete 37, // arrow left 38, // arrow up 39, // arrow right 40, // arrow down 35, // home 36, // end // ckeditor specific: 1114177, // home 3342401, // Shift + home 1114181, // end 3342405, // Shift + end 2228232, // Shift + backspace 2228261, // Shift + arrow left 4456485, // Alt + arrow left 2228262, // Shift + arrow up 2228263, // Shift + arrow right 4456487, // Alt + arrow right 2228264, // Shift + arrow down 2228237, // Shift + enter 1114120, // Ctrl + backspace 1114177, // Ctrl + a 1114202, // Ctrl + z 1114200 // Ctrl + x ]; const spaceKeyCodes = [ 32, // space 13, // enter 2228237 // shift + enter in ckEditor ]; let isComposing = false; let hasCompositionJustEnded = false; let valueBeforeComposition = null; let selectionStartBeforeComposition = null; let selectionEndBeforeComposition = null; const acceptKeyCode = keyCode => ignoreKeyCodes.includes(keyCode); const emptyOrSpace = txt => (txt && txt.trim() === '') || /\^s*$/.test(txt); const hasSpace = txt => /\s+/.test(txt); const getCharBefore = (str, pos) => str && str.substring(Math.max(0, pos - 1), pos); const getCharAfter = (str, pos) => str && str.substring(pos, pos + 1); const noSpaceNode = node => node.type === ckEditor.NODE_TEXT || (!node.isBlockBoundary() && node.getName() !== 'br'); const getPreviousNotEmptyNode = range => { let node = range.getPreviousNode(); /** * The previous node isn't always the right one, because it can be an empty <b> tag for example. * So we need to get the previous node until we find a non empty one, but we should not go above body. */ while (node && (node.isEmpty ? node.isEmpty() : node.getText() === '')) { let previousSourceNode = node.getPreviousSourceNode(); let nodeElement = previousSourceNode; if (previousSourceNode && previousSourceNode.type === ckEditor.NODE_TEXT) { nodeElement = previousSourceNode.parentNode || previousSourceNode.$.parentNode; } if ( !nodeElement || !nodeElement.ownerDocument || !nodeElement.ownerDocument.body.contains(nodeElement) ) { return null; } node = previousSourceNode; } return node; }; const getNextNotEmptyNode = range => { let node = range.getNextNode(); while (node && (node.isEmpty ? node.isEmpty() : node.getText() === '')) { let nextSourceNode = node.getNextSourceNode(); let nodeElement = nextSourceNode; if (nextSourceNode && nextSourceNode.type === ckEditor.NODE_TEXT) { nodeElement = nextSourceNode.parentNode || nextSourceNode.$.parentNode; } if ( !nodeElement || !nodeElement.ownerDocument || !nodeElement.ownerDocument.body.contains(nodeElement) ) { return null; } node = nextSourceNode; } return node; }; const cancelEvent = e => { if (e.cancel) { e.cancel(); } else { e.preventDefault(); e.stopImmediatePropagation(); } return false; }; const invalidToolip = tooltip.error($container, __('This is not a valid answer'), { position: 'bottom', trigger: 'manual' }); const patternHandler = function patternHandler(e) { if (isComposing || hasCompositionJustEnded) { // IME composing fires keydown/keyup events hasCompositionJustEnded = false; return; } if (patternRegEx) { let newValue; if (isCke) { newValue = this.getData(); } else { newValue = e.currentTarget.value; } if (!newValue) { return false; } _.debounce(function () { const isValid = validator.isValid(newValue); if (!isValid) { $container.addClass('invalid'); $container.show(); invalidToolip.show(); containerHelper.triggerResponseChangeEvent(interaction); } else { $container.removeClass('invalid'); invalidToolip.dispose(); } }, 400)(); } }; /** * This part works on keyboard input * * @param {Event} e * @returns {boolean} */ const keyLimitHandler = function (e) { if (isComposing) { return; } // Safari on OS X may send a keydown of 229 after compositionend if (e.which !== 229) { hasCompositionJustEnded = false; } const keyCode = e.data ? e.data.keyCode : e.which; let wordsCount = maxWords && this.getWordsCount(); let charsCount = maxLength && this.getCharsCount(); if (maxWords && wordsCount >= maxWords) { let left, right, middle; if (isCke) { const editor = _getCKEditor(interaction); const sel = editor.getSelection(); const range = sel.getRanges()[0]; if (range.startContainer && range.startContainer.type === ckEditor.NODE_TEXT) { left = getCharBefore(range.startContainer.getText(), range.startOffset); } if (!left) { const node = getPreviousNotEmptyNode(range); if (node && noSpaceNode(node)) { const text = node.getText(); left = getCharBefore(text, text && text.length); } else { left = ' '; } } if (range.endContainer && range.endContainer.type === ckEditor.NODE_TEXT) { right = getCharAfter(range.endContainer.getText(), range.endOffset); } if (!right) { const node = getNextNotEmptyNode(range); if (node && noSpaceNode(node)) { right = getCharAfter(node.getText(), 0); } else { right = ' '; } } middle = sel.getSelectedText(); } else { const { selectionStart, selectionEnd, value } = $textarea[0]; left = getCharBefore(value, selectionStart); right = getCharAfter(value, selectionEnd); middle = value.substring(selectionStart, selectionEnd); } // Will prevent the keystroke: // - IF there is a word part before and after the selection, // AND the selection does not contain spaces, // AND the keystroke is either a space or enter // - IF there is no word part before and after the selection, // AND the selection is empty, // AND the keystroke is not from the list of accepted codes, // AND the keystroke is not a space if ( (!emptyOrSpace(left) && !emptyOrSpace(right) && !hasSpace(middle) && spaceKeyCodes.includes(keyCode)) || (emptyOrSpace(left) && emptyOrSpace(right) && !middle && !acceptKeyCode(keyCode) && keyCode !== 32) ) { return cancelEvent(e); } } if (validator) { const currentValue = isCke ? _getCKEditor(interaction).getData() : $textarea[0].value; const isEnterKey = keyCode === 13 || keyCode === 2228237; if (validator.shouldBlockInput(currentValue, keyCode, isEnterKey)) { if (!isCke && !validator.isValid(currentValue)) { const textarea = $textarea[0]; textarea.value = this._truncateToLimit(currentValue, validator); $textarea.trigger('inputlimiter-limited'); textarea.focus(); _.defer(() => this.updateCounter()); } return cancelEvent(e); } } else if (maxLength || maxWords) { charsCount = maxLength && this.getCharsCount(); wordsCount = maxWords && this.getWordsCount(); const isEnterKey = keyCode === 13 || keyCode === 2228237; if (maxLength && charsCount >= maxLength && !acceptKeyCode(keyCode) && !isEnterKey) { // Check if user has selected text that would be replaced let hasSelection = false; if (isCke) { const editor = _getCKEditor(interaction); const selection = editor.getSelection(); const selectedText = selection ? selection.getSelectedText() : ''; hasSelection = selectedText && selectedText.length > 0; } else { hasSelection = $textarea[0].selectionStart !== $textarea[0].selectionEnd; } if (!hasSelection) { return cancelEvent(e); } } if (maxWords && wordsCount >= maxWords && !acceptKeyCode(keyCode)) { return cancelEvent(e); } } _.defer(() => this.updateCounter()); }; /** * This part works on drop or paste * @param {Event} e * @returns {boolean} */ const nonKeyLimitHandler = function (e) { let newValue; if (typeof $(e.target).attr('data-clipboard') === 'string') { newValue = $(e.target).attr('data-clipboard'); } else if (isCke) { // cke has its own object structure newValue = e.data.dataValue; } else { // covers input via paste or drop newValue = e.originalEvent.clipboardData ? e.originalEvent.clipboardData.getData('text') : e.originalEvent.dataTransfer.getData('text') || e.originalEvent.dataTransfer.getData('text/plain') || ''; } // prevent insertion of non-limited data cancelEvent(e); if (!newValue) { return false; } if (validator) { const currentValue = isCke ? _getCKEditor(interaction).getData() : $textarea[0].value; newValue = validator.limitPastedContent(currentValue, newValue); } else { // Legacy logic for old-style limits if (maxWords) { newValue = strLimiter.limitByWordCount(newValue, maxWords - this.getWordsCount()); } else if (maxLength) { newValue = strLimiter.limitByCharCount(newValue, maxLength - this.getCharsCount()); } } // insert the cut-off text if (isCke) { _getCKEditor(interaction).insertHtml(newValue); } else { let elements = containerHelper.get(interaction).find('textarea'); let el = elements[0]; let { selectionStart: start, selectionEnd: end, value: text } = el; elements.val(text.substring(0, start) + newValue + text.substring(end, text.length)); el.focus(); el.selectionStart = start + newValue.length; el.selectionEnd = el.selectionStart; elements.trigger('inputlimiter-limited'); } _.defer(() => this.updateCounter()); }; const handleCompositionStart = function (e) { // Check if we should block composition entirely if (_getFormat(interaction) !== 'xhtml') { const currentValue = $textarea[0].value; const selectionStart = $textarea[0].selectionStart; const selectionEnd = $textarea[0].selectionEnd; const hasSelection = selectionEnd > selectionStart; let shouldBlock = false; if (validator) { if (!validator.isValid(currentValue) && !hasSelection) { shouldBlock = true; } } else if (maxLength) { const currentLength = currentValue.replace(/[\r\n]/g, '').length; if (currentLength >= maxLength && !hasSelection) { shouldBlock = true; } } // If we should block, prevent the composition and cancel the event if (shouldBlock) { e.preventDefault(); $textarea.trigger('inputlimiter-limited'); return false; } // Store the value and selection range before composition starts valueBeforeComposition = currentValue; selectionStartBeforeComposition = selectionStart; selectionEndBeforeComposition = selectionEnd; } isComposing = true; return e; }; const handleCompositionEnd = function (e) { isComposing = false; hasCompositionJustEnded = true; if (_getFormat(interaction) !== 'xhtml') { const currentValue = $textarea[0].value; let shouldRevert = false; let shouldTruncate = false; // Check if there was a selection before composition const hadSelection = selectionStartBeforeComposition !== null && selectionEndBeforeComposition !== null && selectionEndBeforeComposition > selectionStartBeforeComposition; // Check if the composition result exceeds the limit if (validator && !validator.isValid(currentValue)) { // Count chars excluding newlines (matching backend behavior) const originalLength = valueBeforeComposition ? valueBeforeComposition.replace(/[\r\n]/g, '').length : 0; const currentLength = this.getCharsCount(); // If we were at/above limit with no selection, block insertion entirely if (!hadSelection && originalLength >= validator.getLimit() && currentLength > originalLength) { shouldRevert = true; } else { // Otherwise truncate to limit shouldTruncate = true; } } else if (maxLength) { // Count chars excluding newlines (matching backend behavior) const originalLength = valueBeforeComposition ? valueBeforeComposition.replace(/[\r\n]/g, '').length : 0; const currentLength = this.getCharsCount(); // If we were at/above limit with no selection, block any insertion that increases count if (!hadSelection && originalLength >= maxLength && currentLength > originalLength) { shouldRevert = true; } // Otherwise if over limit, truncate to limit else if (currentLength > maxLength) { shouldTruncate = true; } } // Revert: restore previous value (block insertion entirely) if (shouldRevert && valueBeforeComposition !== null) { $textarea[0].value = valueBeforeComposition; // Restore cursor position if (selectionStartBeforeComposition !== null) { $textarea[0].setSelectionRange( selectionStartBeforeComposition, selectionStartBeforeComposition ); } $textarea.trigger('inputlimiter-limited'); _.defer(() => this.updateCounter()); } else if (shouldTruncate) { let truncatedValue; if (validator) { truncatedValue = this._truncateToLimit(currentValue, validator); } else if (maxLength) { let count = 0; let result = ''; for (let i = 0; i < currentValue.length && count < maxLength; i++) { result += currentValue[i]; if (currentValue[i] !== '\n' && currentValue[i] !== '\r') { count++; } } truncatedValue = result; } $textarea[0].value = truncatedValue; // Place cursor at end of truncated content $textarea[0].setSelectionRange(truncatedValue.length, truncatedValue.length); $textarea.trigger('inputlimiter-limited'); _.defer(() => this.updateCounter()); } // Clear stored values valueBeforeComposition = null; selectionStartBeforeComposition = null; selectionEndBeforeComposition = null; } _.defer(() => this.updateCounter()); return e; }; const handleBeforeInput = function (e) { _.defer(() => this.updateCounter()); return e; }; if (isCke) { const editor = _getCKEditor(interaction); if (validator || maxLength) { let previousSnapshot = editor.getSnapshot(); const CKEditorKeyLimit = function () { const range = this.createRange(); const currentContent = editor.getData(); let isValid = true; if (validator) { isValid = validator.isValid(currentContent); } else if (limiter.maxLength) { isValid = limiter.getCharsCount() <= limiter.maxLength; } if (!isValid) { const editable = this.editable(); editable.setData('', true); editable.setData(previousSnapshot, true); range.moveToElementEditablePosition(editable, true); editor.getSelection().selectRanges([range]); _.defer(() => limiter.updateCounter()); } else { previousSnapshot = editor.getSnapshot(); _.defer(() => limiter.updateCounter()); } }; editor.on('instanceReady', function () { const editorInstance = this; const editableElement = editor.editable().$; editableElement.addEventListener('compositionend', function () { CKEditorKeyLimit.call(editorInstance); _.defer(() => limiter.updateCounter()); }); }); editor.on('key', CKEditorKeyLimit); editor.on('blur', CKEditorKeyLimit); } editor.on('key', keyLimitHandler); editor.on('change', evt => { patternHandler(evt); _.defer(() => this.updateCounter()); }); editor.on('paste', nonKeyLimitHandler); // @todo: drop requires cke 4.5 // cke.on('drop', nonKeyLimitHandler); } else { const handleBlur = function (e) { // Skip truncation during IME composition if (isComposing) { return; } if (validator) { const currentValue = $textarea[0].value; if (!validator.isValid(currentValue)) { $textarea[0].value = this._truncateToLimit(currentValue, validator); $textarea.trigger('inputlimiter-limited'); _.defer(() => this.updateCounter()); } } else if (maxLength) { const currentLength = this.getCharsCount(); if (currentLength > maxLength) { const currentValue = $textarea[0].value; $textarea[0].value = currentValue.substring(0, maxLength); $textarea.trigger('inputlimiter-limited'); _.defer(() => this.updateCounter()); } } }; $textarea .on('beforeinput.commonRenderer', handleBeforeInput.bind(this)) .on( 'input.commonRenderer', function () { if (!isComposing && validator) { const currentValue = $textarea[0].value; if (!validator.isValid(currentValue)) { $textarea[0].value = this._truncateToLimit(currentValue, validator); $textarea.trigger('inputlimiter-limited'); } } _.defer(() => this.updateCounter()); }.bind(this) ) .on('compositionstart.commonRenderer', handleCompositionStart) .on('compositionend.commonRenderer', handleCompositionEnd.bind(this)) .on('keyup.commonRenderer', patternHandler) .on('keydown.commonRenderer', keyLimitHandler.bind(this)) .on('paste.commonRenderer drop.commonRenderer', nonKeyLimitHandler.bind(this)) .on('blur.commonRenderer', handleBlur.bind(this)); } }, /** * Get the number of words that are actually written in the response field * Updated to use backend-compatible word counting when patternMask defines word limits * @returns {Number} number of words */ getWordsCount() { const value = _getTextareaValue(interaction) || ''; if (_.isEmpty(value)) { return 0; } if (patternMask && patternMaskHelper.isMaxEntryRestriction(patternMask)) { return value .trim() .split(/[\s.,:;?!&#%\/*+=]+/) .filter(Boolean).length; } return value.trim().replace(/\s+/gi, ' ').split(' ').length; }, /** * Get the number of characters that are actually written in the response field * Updated to NOT count newlines when patternMask is character-based * This matches backend behavior where newlines are removed before validation * @returns {Number} number of characters */ getCharsCount() { const value = _getTextareaValue(interaction) || ''; // For patternMask character limits, don't count newlines // This matches backend behavior where newlines are removed before validation if (patternMask && patternMaskHelper.parsePattern(patternMask, 'chars')) { return value.replace(/[\r\n]/g, '').length; } // Fallback to original logic for backwards compatibility when no patternMask return value.replace(/[\r\n]{1}\xA0[\r\n]{1}/gm, '\r').replace(/[\r\n]+/gm, '').length; }, /** * Update the counter element */ updateCounter() { $charsCounter.html(this.getCharsCount()); $wordsCounter.text(this.getWordsCount()); }, _truncateToLimit(currentValue, validator) { const limit = validator.getLimit(); const currentCount = validator.getCount(currentValue); if (!limit || currentCount <= limit) return currentValue; if (validator.type === 'character') { let truncated = ''; let count = 0; for (let i = 0; i < currentValue.length && count < limit; i++) { truncated += currentValue[i]; if (currentValue[i] !== '\n' && currentValue[i] !== '\r') { count++; } } return truncated; } else if (validator.type === 'word') { const words = currentValue .trim() .split(/[\s.,:;?!&#%\/*+=]+/) .filter(Boolean); return words.slice(0, limit).join(' '); } else { return currentValue.substring(0, limit); } }, maxLength }; return limiter; } /** * In Safari & `writing-mode: vertical-rl`, for *multiline* text: * - when user is typing, browser doesn't always repaint, so user doesn't see what he's typing. * - when user clicks (or key-navigates) inside text, cursor is shown in wrong position. * `textarea.selectionStart/textarea.selectionEnd` are set correctly, just visually cursor is painted in a wrong place. * Create a workaround: * - force browser to repaint on every typed letter, using a trick * - hide native cursor, show custom div as a cursor instead * - position this custom cursor: * - create a shadow div contianing same text as textarea (<textarea>ab</textarea> -> <shadow><span>a</span><span>b</span></shadow>) * - shadow div should also have same size, font, and text-wrapping rules. * - when after click/key-nav browser changes `textarea.selectionStart` to N, use shadow to understand what are the coordinates of the Nth letter of text * - then draw custom cursor at those coordinates. * Unsolved issues: * - when user clicks between lines (in a space left by the difference of line-height & font-size), `textarea.selectionStart` is wrong, cursor jumps randomly * - when user clicks on empty space left in the line, or at empty lines, `textarea.selectionStart` is wrong, cursor jumps randomly * - key-navigation is not intuitive (left/right still jump letters, and up/down - lines, as in horizontal-tb) * @param {JQuery} $textarea * @param {string} serial * @returns {Object|null} if safari & vertical-rl,`{ syncValue: () => void, destroy: () => void }` . Otherwise `null`. */ function _patchSafariVerticalRl($textarea, serial) { if (!getIsWritingModeVerticalRl($textarea) || !isSafari() || !supportsVerticalFormElement()) { return null; } $textarea.addClass('hide-caret'); const $shadow = $('<div>', { class: 'shadow-textarea' }); const $cursor = $('<div>', { class: 'extendedText-shadow-caret' }); $(document.body).append($cursor); $textarea.after($('<div>', { class: 'shadow-container' }).append($shadow)); let repaintIdx = 0; function setShadowString(str) { const shadowString = [...str, ' '] .map(i => { if (i === '\n') { return '<br/>'; } else if (i === ' ') { return `<span>&#x20;</span>`; } else { return `<span>${i}</span>`; } }) .join(''); $shadow.get(0).innerHTML = shadowString; } function forceTextareaRepaint() { if (repaintIdx % 2 === 0) { $textarea.get(0).style.opacity = '99%'; } else { $textarea.get(0).style.opacity = '97%'; } repaintIdx++; } function showHideCursor(show) { if (show && ($cursor.get(0).style.display === 'none' || !$cursor.get(0).style.display)) { $cursor.get(0).style.display = 'block'; } if (!show && ($cursor.get(0).style.display !== 'none' || !$cursor.get(0).style.display)) { $cursor.get(0).style.display = 'none'; } } function positionCursor() { const selectionStart = $textarea.get(0).selectionStart; const selectionEnd = $textarea.get(0).selectionEnd; //range selection seems to be painted fine if (selectionStart === selectionEnd && document.activeElement === $textarea.get(0)) { const shadowLetterEl = $shadow.get(0).children[selectionStart]; const shadowLetterRect = shadowLetterEl.getBoundingClientRect(); const shadowRect = $shadow.get(0).getBoundingClientRect(); const textareaRect = $textarea.get(0).getBoundingClientRect(); const cursorRect = $cursor.get(0).getBoundingClientRect(); const bodyRect = document.body.getBoundingClientRect(); //iPad, when keyboard opened: 'position:fixed' is off because of addressbar height, // so for cursor use 'position:absolute' from 'bo