@oat-sa/tao-item-runner-qti
Version:
TAO QTI Item Runner modules
1,324 lines (1,187 loc) • 51.7 kB
JavaScript
/*
* 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 {
getIsItemWritingModeVerticalRl,
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 = getIsItemWritingModeVerticalRl();
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';
const isVertical = getIsItemWritingModeVerticalRl();
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());
}
}
/**
* 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;
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) {
// cke has its own object structure
newValue = this.getData();
} else {
// covers input
newValue = e.currentTarget.value;
}
if (!newValue) {
return false;
}
_.debounce(function () {
if (!patternRegEx.test(newValue)) {
$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 = 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;
const wordsCount = maxWords && this.getWordsCount();
const 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 (maxLength && charsCount >= maxLength && !acceptKeyCode(keyCode)) {
if (!isCke && charsCount > maxLength) {
const textarea = $textarea[0];
textarea.value = textarea.value.substring(0, maxLength);
$textarea.trigger('inputlimiter-limited');
textarea.focus();
}
return cancelEvent(e);
}
_.defer(() => this.updateCounter());
};
/**
* This part works on drop or paste
* @param {Event} e
* @returns {boolean}
*/
const nonKeyLimitHandler = 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;
}
// limit by word or character count if required
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 = e => {
isComposing = true;
return e;
};
const handleCompositionEnd = e => {
isComposing = false;
hasCompositionJustEnded = true;
// if plain text - then limit input right after composition end event
if (_getFormat(interaction) !== 'xhtml' && maxLength) {
const currentValue = $textarea[0].value;
const currentLength = this.getCharsCount();
if (currentLength > maxLength) {
$textarea[0].value = currentValue.slice(0, maxLength - currentLength);
}
}
_.defer(() => this.updateCounter());
return e;
};
const handleBeforeInput = e => {
_.defer(() => this.updateCounter());
return e;
};
if (isCke) {
const editor = _getCKEditor(interaction);
if (maxLength) {
let previousSnapshot = editor.getSnapshot();
const CKEditorKeyLimit = function () {
const range = this.createRange();
if (limiter.getCharsCount() > limiter.maxLength) {
const editable = this.editable();
editable.setData('', true);
editable.setData(previousSnapshot, true);
range.moveToElementEditablePosition(editable, true);
editor.getSelection().selectRanges([range]);
} else {
previousSnapshot = editor.getSnapshot();
}
_.defer(() => limiter.updateCounter());
};
editor.on('instanceReady', function () {
const self = this;
const editableElement = editor.editable().$;
editableElement.addEventListener('compositionend', function () {
CKEditorKeyLimit.call(self);
_.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 {
$textarea
.on('beforeinput.commonRenderer', handleBeforeInput)
.on('input.commonRenderer', () => {
_.defer(() => this.updateCounter());
})
.on('compositionstart.commonRenderer', handleCompositionStart)
.on('compositionend.commonRenderer', handleCompositionEnd)
.on('keyup.commonRenderer', patternHandler)
.on('keydown.commonRenderer', keyLimitHandler)
.on('paste.commonRenderer drop.commonRenderer', nonKeyLimitHandler);
}
},
/**
* Get the number of words that are actually written in the response field
* @returns {Number} number of words
*/
getWordsCount() {
const value = _getTextareaValue(interaction) || '';
if (_.isEmpty(value)) {
return 0;
}
// leading and trailing white space don't qualify as words
return value.trim().replace(/\s+/gi, ' ').split(' ').length;
},
/**
* Get the number of characters that are actually written in the response field
* @returns {Number} number of characters
*/
getCharsCount() {
const value = _getTextareaValue(interaction) || '';
// remove NO-BREAK SPACE in empty lines added and all new line symbols
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());
},
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 (!getIsItemWritingModeVerticalRl() || !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> </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 'body', and 'bodyRect.top' (='-window.scrollY')
const cursorTop = textareaRect.top + (shadowLetterRect.top - shadowRect.top) - bodyRect.top;
const cursorLeft =
textareaRect.left -
$textarea.get(0).scrollLeft +
(shadowLetterRect.left - shadowRect.left + $shadow.get(0).scrollLeft);
const cursorTopStyle = `${Math.round(cursorTop)}px`;
const cursorLeftStyle = `${Math.round(cursorLeft)}px`;
if ($cursor.get(0).style.top !== cursorTopStyle) {
$cursor.get(0).style.top = cursorTopStyle;
}
if ($cursor.get(0).style.left !== cursorLeftStyle) {
$cursor.get(0).style.left = cursorLeftStyle;
}
//hide if out of bounds (after scroll for example)
if (
cursorLeft > textareaRect.left + textareaRect.width ||
cursorLeft + cursorRect.width < textareaRect.left
) {
showHideCursor(false);
} else {
showHideCursor(true);
}
} else {
showHideCursor(false);
}
}
function syncShadowStyles() {
const textareaStyles = getComputedStyle($textarea.get(0));
const shadowStyles = getComputedStyle($shadow.get(0));
for (const propName of [
'width',
'height',
'padding',
'writing-mode',
'dir',
'line-height',
'font-family',
'font-size',
'word-break',
'white-space',
'overflow-wrap',
'hyphens',
'tab-size'
]) {
const textareaPropVal = textareaStyles.getPropertyValue(propName);
const shadowPropVal = shadowStyles.getPropertyValue(propName);
if (textareaPropVal !== shadowPropVal) {
$shadow.get(0).style[propName] = textareaPropVal;
}
}
}
const debouncedPositionCursor = _.debounce(() => {
if (document.activeElement === $textarea.get(0)) {
requestAnimationFrame(() => {
positionCursor();
});
}
}, 100);
$textarea.on('input', e => {
forceTextareaRepaint();
setShadowString(e.target.value);
positionCursor();
});
$textarea.on('inputlimiter-limited', () => {
forceTextareaRepaint();
setShadowString($textarea.get(0).value);
positionCursor();
});
$textarea.on('selectionchange', () => {
positionCursor();
});
$textarea.on('focus', () => {
syncShadowStyles();
positionCursor();
});
$textarea.on('blur', () => {
showHideCursor(false);
});
$textarea.on('scroll', () => {
requestAnimationFrame(() => {
positionCursor();
});
});
//scroll containers
$('.qti-itemBody, .tao-overflow-y').on(`scroll.exttext-verticalsafari-${serial}`, debouncedPositionCursor);
//document is scrolled on iPad when keyboard opens
$(document).on(`scroll.exttext-verticalsafari-${serial}`, debouncedPositionCursor);
$(window).on(`resize.exttext-verticalsafari-${serial}`, debouncedPositionCursor);
$(document).on(`themeapplied.exttext-verticalsafari-${serial}`, () => {
syncShadowStyles();
positionCursor();
});
syncShadowStyles();
setShadowString($textarea.get(0).value);
const api = {
syncValue: function () {
setShadowString($textarea.get(0).value);
positionCursor();
},
destroy: function () {
$(document).off(`themeapplied.exttext-verticalsafari-${serial}`);
$(window).off(`resize.exttext-verticalsafari-${serial}`);
$('.qti-itemBody, .tao-overflow-y').off(`scroll.exttext-verticalsafari-${serial}`);
$cursor.remove();
}
};
return api;
}
/**
* return the value of the textarea or ckeditor data
* @param {Object} interaction
* @param {Boolean} raw Tells if the returned data does not have to be filtered (i.e. XHTML tags not removed)
* @returns {String} the value
*/
function _getTextareaValue(interaction, raw) {
if (_getFormat(interaction) === 'xhtml') {
return _ckEditorData(interaction, raw);
} else {
return containerHelper.get(interaction).find('textarea').val();
}
}
/**
* Setting the pattern mask for the input, for browsers which doesn't support this feature
* @param {jQuery} $element
* @param {string} pattern
*/
function _setPattern($element, pattern) {
const patt = new RegExp(pattern);
//test when some data is entering in the input field
//@todo plug the validator + tooltip
$element.on('keyup.commonRenderer', function () {
$element.removeClass('field-error');
if (!patt.test($element.val())) {
$element.addClass('field-error');
}
});
}
/**
* Whether or not multiple strings are expected from the candidate to
* compose a valid response.
*
* @param {Object} interaction - the extended text interaction model
* @returns {Boolean} true if a multiple
*/
function _isMultiple(interaction) {
const attributes = interaction.getAttributes();
const response = interaction.getResponseDeclaration();
return !!(
attributes.maxStrings &&
(response.attr('cardinality') === 'multiple' || response.attr('cardinality') === 'ordered')
);
}
/**
* Instantiate CkEditor for the interaction
*
* @param {Object} interaction - the extended text interaction model
* @param {Object} [options= {}] - the CKEditor configuration options
* @returns {Object} the ckEditor instance (or you'll be in trouble
*/
function _setUpCKEditor(interaction, options) {
const $container = containerHelper.get(interaction);
const editor = ckEditor.replace($container.find('.text-container')[0], options || {});
if (editor) {
$container.data('editor', editor.name);
return editor;
}
}
/**
* Destroy CKEditor
*
* @param {Object} interaction - the extended text interaction model
*/
function _destroyCkEditor(interaction) {
const $container = containerHelper.get(interaction);
const name = $container.data('editor');
let editor;
if (name) {
editor = ckEditor.instances[name];
}
if (editor) {
editor.destroy();
$container.removeData('editor');
}
}
/**
* Gets the CKEditor instance
* @param {Object} interaction - the extended text interaction model
* @returns {Object} CKEditor instance
*/
function _getCKEditor(interaction) {
const $container = containerHelper.get(interaction);
const name = $container.data('editor');
return ckEditor.instances[name];
}
/**
* get the text content of the ckEditor ( not the entire html )
* @param {object} interaction the interaction
* @param {Boolean} raw Tells if the returned data does not have to be filtered (i.e. XHTML tags not removed)
* @returns {string} text content of the ckEditor
*/
function _ckEditorData(interaction, raw) {
const editor = _getCKEditor(interaction);
let data = (editor && editor.getData()) || '';
if (!raw) {
data = _stripTags(data);
}
return data;
}
/**
* Remove HTML tags from a string
* @param {String} str
* @returns {String}
*/
function _stripTags(str) {
const tempNode = document.createElement('div');
tempNode.innerHTML = str;
return tempNode.textContent;
}
/**
* Get the interaction format
* @param {Object} interaction - the extended text interaction model
* @returns {String} format in 'plain', 'xhtml', 'preformatted'
*/
function _getFormat(interaction) {
const format = interaction.attr('format');
if (['plain', 'xhtml', 'preformatted'].includes(format)) {
return format;
}
return 'plain';
}
function enable(interaction) {
const $container = containerHelper.get(interaction);
let editor;
$container.find('input, textarea').removeAttr('disabled');
if (_getFormat(interaction) === 'xhtml') {
editor = _getCKEditor(interaction);
if (editor) {
if (editor.status === 'ready') {
editor.setReadOnly(false);
} else {
editor.readOnly = false;
}
}
}
}
function disable(interaction) {
const $container = containerHelper.get(interaction);
let editor;
$container.find('input, textarea').attr('disabled', 'disabled');
if (_getFormat(interaction) === 'xhtml') {
editor = _getCKEditor(interaction);
if (editor) {
if (editor.status === 'ready') {
editor.setReadOnly(true);
} else {
editor.readOnly = true;
}
}
}
}
function clearText(interaction) {
setText(interaction, '');
}
function setText(interaction, text) {
const limiter = inputLimiter(interaction);
if (_getFormat(interaction) === 'xhtml') {
try {
_getCKEditor(interaction).setData(text, function () {
if (limiter.enabled) {
limiter.updateCounter();
}
});
} catch (e) {
logger.warn(`setText error ${e}!`);
}
} else {
containerHelper.get(interaction).find('textarea').val(text);
if (limiter.enabled) {
limiter.updateCounter();
}
if (interaction.safariVerticalRlPatch) {
interaction.safariVerticalRlPatch.syncValue();
}
}
}
/**
* Clean interaction destroy
* @param {Object} interaction
*/
function destroy(interaction) {
const $container = containerHelper.get(interaction);
const $el = $container.find('input, textarea');
if (_getFormat(interaction) === 'xhtml') {
_destroyCkEditor(interaction);
}
//remove event
$el.off('.commonRenderer');
$(document).off('.commonRenderer');
if (interaction.safariVerticalRlPatch) {
interaction.safariVerticalRlPatch.destroy();
}
//remove instructions
instructionMgr.removeInstructions(interaction);
//remove all references to a cache container
containerHelper.reset(interaction);
}
/**
* Set the interaction state. It could be done anytime with any state.
*
* @param {Object} interaction - the interaction instance
* @param {Object} state - the interaction state
*/
function setState(interaction, state) {
if (_.isObject(state)) {
if (state.response) {
try {
interaction.setResponse(state.response);
} catch (e) {
interaction.resetResponse();
throw e;
}
}
}
}
/**
* Get the interaction state.
*
* @param {Object} interaction - the interaction instance
* @returns {Object} the interaction current state
*/
function getState(interaction) {
const state = {};
const response = interaction.getResponse();
if (response) {
state.response = response;
}
return state;
}
/**
* Hydrates the dataset for the interaction with respect to its attributes.
*
* @param {object} interaction - the interaction instance
* @param {object} data - the default data object
* @returns {object} the hydrated data set
*/
function getData(interaction, data) {
const pattern = interaction.attr('patternMask');
const maxWords = parseInt(patternMaskHelper.parsePattern(pattern, 'words'), 10);
const maxLength = parseInt(patternMaskHelper.parsePattern(pattern, 'chars'), 10);
const expectedLines = parseInt(interaction.attr('expectedLines'), 10);
const expectedLength = !isNaN(expectedLines)
? expectedLines * 72
: parseInt(interaction.attr('expectedLength'), 10);
// B