@pie-lib/text-select 1.29.0 → 1.29.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/esm/index.js ADDED
@@ -0,0 +1,1307 @@
1
+ import React from 'react';
2
+ import PropTypes from 'prop-types';
3
+ import Button from '@material-ui/core/Button';
4
+ import { withStyles } from '@material-ui/core/styles';
5
+ import Switch from '@material-ui/core/Switch';
6
+ import FormControlLabel from '@material-ui/core/FormControlLabel';
7
+ import { color } from '@pie-lib/render-ui';
8
+ import classNames from 'classnames';
9
+ import compact from 'lodash/compact';
10
+ import English from '@pie-framework/parse-english';
11
+ import clone from 'lodash/clone';
12
+ import isEqual from 'lodash/isEqual';
13
+ import differenceWith from 'lodash/differenceWith';
14
+ import { noSelect } from '@pie-lib/style-utils';
15
+ import yellow from '@material-ui/core/colors/yellow';
16
+ import green from '@material-ui/core/colors/green';
17
+ import debug from 'debug';
18
+ import Check from '@material-ui/icons/Check';
19
+ import Close from '@material-ui/icons/Close';
20
+ import { renderToString } from 'react-dom/server';
21
+ import Translator from '@pie-lib/translator';
22
+
23
+ function _extends() {
24
+ _extends = Object.assign || function (target) {
25
+ for (var i = 1; i < arguments.length; i++) {
26
+ var source = arguments[i];
27
+
28
+ for (var key in source) {
29
+ if (Object.prototype.hasOwnProperty.call(source, key)) {
30
+ target[key] = source[key];
31
+ }
32
+ }
33
+ }
34
+
35
+ return target;
36
+ };
37
+
38
+ return _extends.apply(this, arguments);
39
+ }
40
+
41
+ class Controls extends React.Component {
42
+ render() {
43
+ const {
44
+ classes,
45
+ onClear,
46
+ onWords,
47
+ onSentences,
48
+ onParagraphs,
49
+ setCorrectMode,
50
+ onToggleCorrectMode
51
+ } = this.props;
52
+ return /*#__PURE__*/React.createElement("div", {
53
+ className: classes.controls
54
+ }, /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement(Button, {
55
+ onClick: onWords,
56
+ className: classes.button,
57
+ size: "small",
58
+ color: "primary",
59
+ disabled: setCorrectMode
60
+ }, "Words"), /*#__PURE__*/React.createElement(Button, {
61
+ onClick: onSentences,
62
+ className: classes.button,
63
+ size: "small",
64
+ color: "primary",
65
+ disabled: setCorrectMode
66
+ }, "Sentences"), /*#__PURE__*/React.createElement(Button, {
67
+ onClick: onParagraphs,
68
+ className: classes.button,
69
+ size: "small",
70
+ color: "primary",
71
+ disabled: setCorrectMode
72
+ }, "Paragraphs"), /*#__PURE__*/React.createElement(Button, {
73
+ className: classes.button,
74
+ size: "small",
75
+ color: "secondary",
76
+ onClick: onClear,
77
+ disabled: setCorrectMode
78
+ }, "Clear")), /*#__PURE__*/React.createElement(FormControlLabel, {
79
+ control: /*#__PURE__*/React.createElement(Switch, {
80
+ classes: {
81
+ checked: classes.checkedThumb,
82
+ bar: classNames({
83
+ [classes.checkedBar]: setCorrectMode
84
+ })
85
+ },
86
+ checked: setCorrectMode,
87
+ onChange: onToggleCorrectMode
88
+ }),
89
+ label: "Set correct answers"
90
+ }));
91
+ }
92
+
93
+ }
94
+ Controls.propTypes = {
95
+ classes: PropTypes.object.isRequired,
96
+ onClear: PropTypes.func.isRequired,
97
+ onWords: PropTypes.func.isRequired,
98
+ onSentences: PropTypes.func.isRequired,
99
+ onParagraphs: PropTypes.func.isRequired,
100
+ setCorrectMode: PropTypes.bool.isRequired,
101
+ onToggleCorrectMode: PropTypes.func.isRequired
102
+ };
103
+ Controls.defaultProps = {};
104
+ var Controls$1 = withStyles(theme => ({
105
+ button: {
106
+ marginRight: theme.spacing.unit
107
+ },
108
+ controls: {
109
+ display: 'flex',
110
+ alignItems: 'center',
111
+ justifyContent: 'space-between'
112
+ },
113
+ checkedThumb: {
114
+ color: `${color.tertiary()} !important`
115
+ },
116
+ checkedBar: {
117
+ backgroundColor: `${color.tertiaryLight()} !important`
118
+ }
119
+ }))(Controls);
120
+
121
+ const g = (str, node) => {
122
+ if (node.children) {
123
+ return node.children.reduce(g, str);
124
+ } else if (node.value) {
125
+ return str + node.value;
126
+ } else {
127
+ return str;
128
+ }
129
+ };
130
+
131
+ const getParagraph = p => g('', p);
132
+
133
+ const getSentence = s => g('', s);
134
+
135
+ const getWord = w => g('', w);
136
+
137
+ const paragraphs = text => {
138
+ const tree = new English().parse(text);
139
+ const out = tree.children.reduce((acc, child) => {
140
+ if (child.type === 'ParagraphNode') {
141
+ const paragraph = {
142
+ text: getParagraph(child),
143
+ start: child.position.start.offset,
144
+ end: child.position.end.offset
145
+ };
146
+ return acc.concat([paragraph]);
147
+ } else {
148
+ return acc;
149
+ }
150
+ }, []);
151
+ return out;
152
+ };
153
+ const handleSentence = (child, acc) => {
154
+ const sentenceChilds = []; // we parse the children of the sentence
155
+
156
+ let newAcc = child.children.reduce(function (acc, child) {
157
+ // if we find a whitespace node that's \n, we end the sentence
158
+ if (child.type === 'WhiteSpaceNode' && child.value === '\n') {
159
+ if (sentenceChilds.length) {
160
+ const firstWord = sentenceChilds[0]; // we create a sentence starting from the first word until the new line
161
+
162
+ const sentence = {
163
+ text: sentenceChilds.map(d => getSentence(d)).join(''),
164
+ start: firstWord.position.start.offset,
165
+ end: child.position.start.offset
166
+ }; // we remove all the elements from the array
167
+
168
+ sentenceChilds.splice(0, sentenceChilds.length);
169
+ return acc.concat([sentence]);
170
+ }
171
+ } else {
172
+ // otherwise we add it to the array that contains the child forming a sentence
173
+ sentenceChilds.push(child);
174
+ }
175
+
176
+ return acc;
177
+ }, acc); // we treat the case when no \n character is found at the end
178
+ // so we create a sentence from the last words or white spaces found
179
+
180
+ if (sentenceChilds.length) {
181
+ const firstWord = sentenceChilds[0];
182
+ const lastWord = sentenceChilds[sentenceChilds.length - 1];
183
+ const sentence = {
184
+ text: sentenceChilds.map(d => getSentence(d)).join(''),
185
+ start: firstWord.position.start.offset,
186
+ end: lastWord.position.end.offset
187
+ };
188
+ newAcc = newAcc.concat([sentence]);
189
+ sentenceChilds.splice(0, sentenceChilds.length);
190
+ }
191
+
192
+ return newAcc;
193
+ };
194
+ const sentences = text => {
195
+ const tree = new English().parse(text);
196
+ const out = tree.children.reduce((acc, child) => {
197
+ if (child.type === 'ParagraphNode') {
198
+ return child.children.reduce((acc, child) => {
199
+ if (child.type === 'SentenceNode') {
200
+ const newAcc = handleSentence(child, acc);
201
+ return newAcc || acc;
202
+ } else {
203
+ return acc;
204
+ }
205
+ }, acc);
206
+ } else {
207
+ return acc;
208
+ }
209
+ }, []);
210
+ return out;
211
+ };
212
+ const words = text => {
213
+ const tree = new English().parse(text);
214
+ const out = tree.children.reduce((acc, child) => {
215
+ if (child.type === 'ParagraphNode') {
216
+ return child.children.reduce((acc, child) => {
217
+ if (child.type === 'SentenceNode') {
218
+ return child.children.reduce((acc, child) => {
219
+ if (child.type === 'WordNode') {
220
+ const node = {
221
+ text: getWord(child),
222
+ start: child.position.start.offset,
223
+ end: child.position.end.offset
224
+ };
225
+ return acc.concat([node]);
226
+ } else {
227
+ return acc;
228
+ }
229
+ }, acc);
230
+ } else {
231
+ return acc;
232
+ }
233
+ }, acc);
234
+ } else {
235
+ return acc;
236
+ }
237
+ }, []);
238
+ return out;
239
+ };
240
+
241
+ class Intersection {
242
+ constructor(results) {
243
+ this.results = results;
244
+ }
245
+
246
+ get hasOverlap() {
247
+ return this.results.filter(r => r.type === 'overlap').length > 0;
248
+ }
249
+
250
+ get surroundedTokens() {
251
+ return this.results.filter(r => r.type === 'within-selection').map(t => t.token);
252
+ }
253
+
254
+ }
255
+ /**
256
+ * get intersection info for the selection in relation to tokens.
257
+ * @param {{start: number, end: number}} selection
258
+ * @param {{start: number, end: number}[]} tokens
259
+ * @return {tokens: [], type: 'overlap|no-overlap|contains'}
260
+ */
261
+
262
+
263
+ const intersection = (selection, tokens) => {
264
+ const {
265
+ start,
266
+ end
267
+ } = selection;
268
+
269
+ const startsWithin = t => start >= t.start && start < t.end;
270
+
271
+ const endsWithin = t => end > t.start && end <= t.end;
272
+
273
+ const mapped = tokens.map(t => {
274
+ if (start === t.start && end === t.end) {
275
+ return {
276
+ token: t,
277
+ type: 'exact-fit'
278
+ };
279
+ } else if (start <= t.start && end >= t.end) {
280
+ return {
281
+ token: t,
282
+ type: 'within-selection'
283
+ };
284
+ } else if (startsWithin(t) || endsWithin(t)) {
285
+ return {
286
+ token: t,
287
+ type: 'overlap'
288
+ };
289
+ }
290
+ });
291
+ return new Intersection(compact(mapped));
292
+ };
293
+ const sort = tokens => {
294
+ if (!Array.isArray(tokens)) {
295
+ return tokens;
296
+ } else {
297
+ const out = clone(tokens);
298
+ out.sort((a, b) => {
299
+ const s = a.start < b.start ? -1 : a.start > b.start ? 1 : 0;
300
+ const e = a.end < b.end ? -1 : a.end > b.end ? 1 : 0;
301
+
302
+ if (s === -1 && e !== -1) {
303
+ throw new Error(`sort does not support intersecting tokens. a: ${a.start}-${a.end}, b: ${b.start}-${b.end}`);
304
+ }
305
+
306
+ return s;
307
+ });
308
+ return out;
309
+ }
310
+ };
311
+ const normalize = (textToNormalize, tokens) => {
312
+ // making sure text provided is a string
313
+ const text = textToNormalize || '';
314
+
315
+ if (!Array.isArray(tokens) || tokens.length === 0) {
316
+ return [{
317
+ text,
318
+ start: 0,
319
+ end: text.length
320
+ }];
321
+ }
322
+
323
+ const out = sort(tokens).reduce((acc, t, index, outer) => {
324
+ let tokens = [];
325
+ const lastIndex = acc.lastIndex;
326
+
327
+ if (t.start === lastIndex) {
328
+ tokens = [{
329
+ text: text.substring(lastIndex, t.end),
330
+ start: lastIndex,
331
+ end: t.end,
332
+ predefined: true,
333
+ correct: t.correct,
334
+ isMissing: t.isMissing
335
+ }];
336
+ } else if (lastIndex < t.start) {
337
+ tokens = [{
338
+ text: text.substring(lastIndex, t.start),
339
+ start: lastIndex,
340
+ end: t.start
341
+ }, {
342
+ text: text.substring(t.start, t.end),
343
+ start: t.start,
344
+ end: t.end,
345
+ predefined: true,
346
+ correct: t.correct,
347
+ isMissing: t.isMissing
348
+ }];
349
+ }
350
+
351
+ if (index === outer.length - 1 && t.end < text.length) {
352
+ const last = {
353
+ text: text.substring(t.end),
354
+ start: t.end,
355
+ end: text.length
356
+ };
357
+ tokens.push(last);
358
+ }
359
+
360
+ return {
361
+ lastIndex: tokens.length ? tokens[tokens.length - 1].end : lastIndex,
362
+ result: acc.result.concat(tokens)
363
+ };
364
+ }, {
365
+ result: [],
366
+ lastIndex: 0
367
+ });
368
+ return out.result;
369
+ };
370
+
371
+ const clearSelection = () => {
372
+ if (document.getSelection) {
373
+ // for all new browsers (IE9+, Chrome, Firefox)
374
+ document.getSelection().removeAllRanges();
375
+ document.getSelection().addRange(document.createRange());
376
+ } else if (window.getSelection) {
377
+ // equals with the document.getSelection (MSDN info)
378
+ if (window.getSelection().removeAllRanges) {
379
+ // for all new browsers (IE9+, Chrome, Firefox)
380
+ window.getSelection().removeAllRanges();
381
+ window.getSelection().addRange(document.createRange());
382
+ } else if (window.getSelection().empty) {
383
+ // Chrome supports this as well
384
+ window.getSelection().empty();
385
+ }
386
+ } else if (document.selection) {
387
+ // IE8-
388
+ document.selection.empty();
389
+ }
390
+ };
391
+ const getCaretCharacterOffsetWithin = element => {
392
+ var caretOffset = 0;
393
+ var doc = element.ownerDocument || element.document;
394
+ var win = doc.defaultView || doc.parentWindow;
395
+ var sel;
396
+
397
+ if (typeof win.getSelection !== 'undefined') {
398
+ sel = win.getSelection();
399
+
400
+ if (sel.rangeCount > 0) {
401
+ var range = win.getSelection().getRangeAt(0);
402
+ var selected = range.toString().length;
403
+ var preCaretRange = range.cloneRange();
404
+ preCaretRange.selectNodeContents(element);
405
+ preCaretRange.setEnd(range.endContainer, range.endOffset);
406
+
407
+ if (selected) {
408
+ caretOffset = preCaretRange.toString().length - selected;
409
+ } else {
410
+ caretOffset = preCaretRange.toString().length;
411
+ }
412
+ }
413
+ } else if ((sel = doc.selection) && sel.type !== 'Control') {
414
+ var textRange = sel.createRange();
415
+ var preCaretTextRange = doc.body.createTextRange();
416
+ preCaretTextRange.moveToElementText(element);
417
+ preCaretTextRange.setEndPoint('EndToEnd', textRange);
418
+ caretOffset = preCaretTextRange.text.length;
419
+ }
420
+
421
+ return caretOffset;
422
+ };
423
+
424
+ const log$2 = debug('@pie-lib:text-select:token-text');
425
+ const Text = withStyles(() => ({
426
+ predefined: {
427
+ cursor: 'pointer',
428
+ backgroundColor: yellow[100],
429
+ border: `dashed 0px ${yellow[700]}`,
430
+ // we need this for nested tokenized elements like paragraphs, where p is inside span
431
+ '& *': {
432
+ cursor: 'pointer',
433
+ backgroundColor: yellow[100],
434
+ border: `dashed 0px ${yellow[700]}`
435
+ }
436
+ },
437
+ correct: {
438
+ backgroundColor: green[500],
439
+ '& *': {
440
+ backgroundColor: green[500]
441
+ }
442
+ }
443
+ }))(({
444
+ text,
445
+ predefined,
446
+ classes,
447
+ onClick,
448
+ correct
449
+ }) => {
450
+ const formattedText = (text || '').replace(/\n/g, '<br>');
451
+
452
+ if (predefined) {
453
+ const className = classNames(classes.predefined, correct && classes.correct);
454
+ return /*#__PURE__*/React.createElement("span", {
455
+ onClick: onClick,
456
+ className: className,
457
+ dangerouslySetInnerHTML: {
458
+ __html: formattedText
459
+ }
460
+ });
461
+ } else {
462
+ return /*#__PURE__*/React.createElement("span", {
463
+ dangerouslySetInnerHTML: {
464
+ __html: formattedText
465
+ }
466
+ });
467
+ }
468
+ });
469
+ const notAllowedCharacters = ['\n', ' ', '\t'];
470
+ class TokenText extends React.Component {
471
+ constructor(...args) {
472
+ super(...args);
473
+
474
+ this.onClick = event => {
475
+ const {
476
+ onSelectToken,
477
+ text,
478
+ tokens
479
+ } = this.props;
480
+ event.preventDefault();
481
+
482
+ if (typeof window === 'undefined') {
483
+ return;
484
+ }
485
+
486
+ const selection = window.getSelection();
487
+ const textSelected = selection.toString();
488
+
489
+ if (textSelected.length > 0 && notAllowedCharacters.indexOf(textSelected) < 0) {
490
+ if (this.root) {
491
+ let offset = getCaretCharacterOffsetWithin(this.root);
492
+ /*
493
+ Since we implemented new line functionality (\n) using <br /> dom elements
494
+ and window.getSelection is not taking that into consideration, the offset might
495
+ be off by a few characters.
496
+ To combat that, we check if the selected text is right at the beginning of the offset.
497
+ If it's not, we add the additional offset in order for that to be accurate
498
+ */
499
+
500
+ const newLineOffset = text.slice(offset).indexOf(textSelected);
501
+ offset += newLineOffset;
502
+
503
+ if (offset !== undefined) {
504
+ const endIndex = offset + textSelected.length;
505
+
506
+ if (endIndex <= text.length) {
507
+ const i = intersection({
508
+ start: offset,
509
+ end: endIndex
510
+ }, tokens);
511
+
512
+ if (i.hasOverlap) {
513
+ log$2('hasOverlap - do nothing');
514
+ clearSelection();
515
+ } else {
516
+ const tokensToRemove = i.surroundedTokens;
517
+ const token = {
518
+ text: textSelected,
519
+ start: offset,
520
+ end: endIndex
521
+ };
522
+ onSelectToken(token, tokensToRemove);
523
+ clearSelection();
524
+ }
525
+ }
526
+ }
527
+ }
528
+ }
529
+ };
530
+ }
531
+
532
+ render() {
533
+ const {
534
+ text,
535
+ tokens,
536
+ className,
537
+ onTokenClick
538
+ } = this.props;
539
+ const normalized = normalize(text, tokens);
540
+ return /*#__PURE__*/React.createElement("div", {
541
+ className: className,
542
+ ref: r => this.root = r,
543
+ onClick: this.onClick
544
+ }, normalized.map((t, index) => {
545
+ return /*#__PURE__*/React.createElement(Text, _extends({
546
+ key: index
547
+ }, t, {
548
+ onClick: () => onTokenClick(t)
549
+ }));
550
+ }));
551
+ }
552
+
553
+ }
554
+ TokenText.propTypes = {
555
+ text: PropTypes.string.isRequired,
556
+ tokens: PropTypes.array.isRequired,
557
+ onTokenClick: PropTypes.func.isRequired,
558
+ onSelectToken: PropTypes.func.isRequired,
559
+ className: PropTypes.string
560
+ };
561
+
562
+ class Tokenizer extends React.Component {
563
+ constructor(props) {
564
+ super(props);
565
+
566
+ this.onChangeHandler = (token, mode) => {
567
+ this.props.onChange(token, mode);
568
+ this.setState({
569
+ mode
570
+ });
571
+ };
572
+
573
+ this.toggleCorrectMode = () => this.setState({
574
+ setCorrectMode: !this.state.setCorrectMode
575
+ });
576
+
577
+ this.clear = () => {
578
+ this.onChangeHandler([], '');
579
+ };
580
+
581
+ this.buildTokens = (type, fn) => {
582
+ const {
583
+ text
584
+ } = this.props;
585
+ const tokens = fn(text);
586
+ this.onChangeHandler(tokens, type);
587
+ };
588
+
589
+ this.selectToken = (newToken, tokensToRemove) => {
590
+ const {
591
+ tokens
592
+ } = this.props;
593
+ const update = differenceWith(clone(tokens), tokensToRemove, isEqual);
594
+ update.push(newToken);
595
+ this.onChangeHandler(update, this.state.mode);
596
+ };
597
+
598
+ this.tokenClick = token => {
599
+ const {
600
+ setCorrectMode
601
+ } = this.state;
602
+
603
+ if (setCorrectMode) {
604
+ this.setCorrect(token);
605
+ } else {
606
+ this.removeToken(token);
607
+ }
608
+ };
609
+
610
+ this.tokenIndex = token => {
611
+ const {
612
+ tokens
613
+ } = this.props;
614
+ return tokens.findIndex(t => {
615
+ return t.text == token.text && t.start == token.start && t.end == token.end;
616
+ });
617
+ };
618
+
619
+ this.setCorrect = token => {
620
+ const {
621
+ tokens
622
+ } = this.props;
623
+ const index = this.tokenIndex(token);
624
+
625
+ if (index !== -1) {
626
+ const t = tokens[index];
627
+ t.correct = !t.correct;
628
+ const update = clone(tokens);
629
+ update.splice(index, 1, t);
630
+ this.onChangeHandler(update, this.state.mode);
631
+ }
632
+ };
633
+
634
+ this.removeToken = token => {
635
+ const {
636
+ tokens
637
+ } = this.props;
638
+ const index = this.tokenIndex(token);
639
+
640
+ if (index !== -1) {
641
+ const update = clone(tokens);
642
+ update.splice(index, 1);
643
+ this.onChangeHandler(update, this.state.mode);
644
+ }
645
+ };
646
+
647
+ this.state = {
648
+ setCorrectMode: false,
649
+ mode: ''
650
+ };
651
+ }
652
+
653
+ render() {
654
+ const {
655
+ text,
656
+ tokens,
657
+ classes,
658
+ className
659
+ } = this.props;
660
+ const {
661
+ setCorrectMode
662
+ } = this.state;
663
+ const tokenClassName = classNames(classes.text, setCorrectMode && classes.noselect);
664
+ const rootName = classNames(classes.tokenizer, className);
665
+ return /*#__PURE__*/React.createElement("div", {
666
+ className: rootName
667
+ }, /*#__PURE__*/React.createElement(Controls$1, {
668
+ onClear: this.clear,
669
+ onWords: () => this.buildTokens('words', words),
670
+ onSentences: () => this.buildTokens('sentence', sentences),
671
+ onParagraphs: () => this.buildTokens('paragraphs', paragraphs),
672
+ setCorrectMode: setCorrectMode,
673
+ onToggleCorrectMode: this.toggleCorrectMode
674
+ }), /*#__PURE__*/React.createElement(TokenText, {
675
+ className: tokenClassName,
676
+ text: text,
677
+ tokens: tokens,
678
+ onTokenClick: this.tokenClick,
679
+ onSelectToken: this.selectToken
680
+ }));
681
+ }
682
+
683
+ }
684
+ Tokenizer.propTypes = {
685
+ text: PropTypes.string.isRequired,
686
+ tokens: PropTypes.arrayOf(PropTypes.shape({
687
+ text: PropTypes.string,
688
+ correct: PropTypes.bool,
689
+ start: PropTypes.number,
690
+ end: PropTypes.number
691
+ })),
692
+ classes: PropTypes.object.isRequired,
693
+ className: PropTypes.string,
694
+ onChange: PropTypes.func.isRequired
695
+ };
696
+ Tokenizer.defaultProps = {};
697
+ var index = withStyles(() => ({
698
+ text: {
699
+ whiteSpace: 'pre-wrap'
700
+ },
701
+ noselect: _extends({}, noSelect())
702
+ }))(Tokenizer);
703
+
704
+ const LINE_HEIGHT_MULTIPLIER = 3.2; // we need a bit more space for correctness indicators
705
+
706
+ const CORRECTNESS_LINE_HEIGHT_MULTIPLIER = 3.4;
707
+ const CORRECTNESS_PADDING = 2;
708
+
709
+ const Wrapper = ({
710
+ useWrapper,
711
+ children,
712
+ classNameContainer,
713
+ iconClass,
714
+ Icon
715
+ }) => useWrapper ? /*#__PURE__*/React.createElement("span", {
716
+ className: classNameContainer
717
+ }, children, /*#__PURE__*/React.createElement(Icon, {
718
+ className: iconClass
719
+ })) : children;
720
+
721
+ Wrapper.propTypes = {
722
+ useWrapper: PropTypes.bool,
723
+ classNameContainer: PropTypes.string,
724
+ iconClass: PropTypes.string,
725
+ Icon: PropTypes.func,
726
+ children: PropTypes.element
727
+ };
728
+ const TokenTypes = {
729
+ text: PropTypes.string,
730
+ selectable: PropTypes.bool
731
+ };
732
+ class Token extends React.Component {
733
+ constructor(...args) {
734
+ super(...args);
735
+
736
+ this.getClassAndIconConfig = () => {
737
+ const {
738
+ selectable,
739
+ selected,
740
+ classes,
741
+ className: classNameProp,
742
+ disabled,
743
+ highlight,
744
+ correct,
745
+ animationsDisabled,
746
+ isMissing
747
+ } = this.props;
748
+ const isTouchEnabled = 'ontouchstart' in window || navigator.maxTouchPoints > 0 || navigator.msMaxTouchPoints > 0;
749
+ const baseClassName = Token.rootClassName;
750
+ let classNameContainer;
751
+ let Icon;
752
+ let iconClass;
753
+
754
+ if (correct === undefined && selected && disabled) {
755
+ return {
756
+ className: classNames(classes.token, classes.selected, classes.disabledBlack)
757
+ };
758
+ }
759
+
760
+ if (correct !== undefined) {
761
+ const isCorrect = correct === true;
762
+ return {
763
+ className: classNames(baseClassName, classes.custom),
764
+ classNameContainer: classNames(isCorrect ? classes.correct : classes.incorrect, classes.commonTokenStyle),
765
+ Icon: isCorrect ? Check : Close,
766
+ iconClass: classNames(classes.correctnessIndicatorIcon, isCorrect ? classes.correctIcon : classes.incorrectIcon)
767
+ };
768
+ }
769
+
770
+ if (isMissing) {
771
+ return {
772
+ className: classNames(baseClassName, classes.custom, classes.missing, classes.commonTokenStyle),
773
+ classNameContainer: classes.commonTokenStyle,
774
+ Icon: Close,
775
+ iconClass: classNames(classes.correctnessIndicatorIcon, classes.incorrectIcon)
776
+ };
777
+ }
778
+
779
+ return {
780
+ className: classNames(baseClassName, classes.token, disabled && classes.disabled, selectable && !disabled && !isTouchEnabled && classes.selectable, selected && !disabled && classes.selected, selected && disabled && classes.disabledAndSelected, highlight && selectable && !disabled && !selected && classes.highlight, animationsDisabled && classes.print, classNameProp),
781
+ classNameContainer,
782
+ Icon,
783
+ iconClass
784
+ };
785
+ };
786
+ }
787
+
788
+ render() {
789
+ const {
790
+ text,
791
+ index,
792
+ correct,
793
+ isMissing
794
+ } = this.props;
795
+ const {
796
+ className,
797
+ classNameContainer,
798
+ Icon,
799
+ iconClass
800
+ } = this.getClassAndIconConfig();
801
+ return /*#__PURE__*/React.createElement(Wrapper, {
802
+ useWrapper: correct !== undefined || isMissing,
803
+ classNameContainer: classNameContainer,
804
+ iconClass: iconClass,
805
+ Icon: Icon
806
+ }, /*#__PURE__*/React.createElement("span", {
807
+ className: className,
808
+ dangerouslySetInnerHTML: {
809
+ __html: (text || '').replace(/\n/g, '<br>')
810
+ },
811
+ "data-indexkey": index
812
+ }));
813
+ }
814
+
815
+ }
816
+ Token.rootClassName = 'tokenRootClass';
817
+ Token.propTypes = _extends({}, TokenTypes, {
818
+ classes: PropTypes.object.isRequired,
819
+ text: PropTypes.string.isRequired,
820
+ className: PropTypes.string,
821
+ disabled: PropTypes.bool,
822
+ highlight: PropTypes.bool,
823
+ correct: PropTypes.bool
824
+ });
825
+ Token.defaultProps = {
826
+ selectable: false,
827
+ text: ''
828
+ };
829
+ var Token$1 = withStyles(theme => {
830
+ return {
831
+ token: {
832
+ cursor: 'pointer',
833
+ textIndent: 0
834
+ },
835
+ disabled: {
836
+ cursor: 'inherit',
837
+ color: color.disabled()
838
+ },
839
+ disabledBlack: {
840
+ cursor: 'inherit'
841
+ },
842
+ disabledAndSelected: {
843
+ backgroundColor: color.blueGrey100()
844
+ },
845
+ selectable: {
846
+ [theme.breakpoints.up(769)]: {
847
+ '&:hover': {
848
+ backgroundColor: color.blueGrey300(),
849
+ color: theme.palette.common.black,
850
+ '& > *': {
851
+ backgroundColor: color.blueGrey300()
852
+ }
853
+ }
854
+ }
855
+ },
856
+ selected: {
857
+ backgroundColor: color.blueGrey100(),
858
+ color: theme.palette.common.black,
859
+ lineHeight: `${theme.spacing.unit * LINE_HEIGHT_MULTIPLIER}px`,
860
+ border: `solid 2px ${color.blueGrey900()}`,
861
+ borderRadius: '4px',
862
+ '& > *': {
863
+ backgroundColor: color.blueGrey100()
864
+ }
865
+ },
866
+ highlight: {
867
+ border: `dashed 2px ${color.blueGrey600()}`,
868
+ borderRadius: '4px',
869
+ lineHeight: `${theme.spacing.unit * LINE_HEIGHT_MULTIPLIER}px`
870
+ },
871
+ print: {
872
+ border: `dashed 2px ${color.blueGrey600()}`,
873
+ borderRadius: '4px',
874
+ lineHeight: `${theme.spacing.unit * LINE_HEIGHT_MULTIPLIER}px`,
875
+ color: color.text()
876
+ },
877
+ custom: {
878
+ display: 'initial'
879
+ },
880
+ commonTokenStyle: {
881
+ position: 'relative',
882
+ borderRadius: '4px',
883
+ color: theme.palette.common.black,
884
+ lineHeight: `${theme.spacing.unit * CORRECTNESS_LINE_HEIGHT_MULTIPLIER + CORRECTNESS_PADDING}px`,
885
+ padding: `${CORRECTNESS_PADDING}px`
886
+ },
887
+ correct: {
888
+ border: `${color.correctTertiary()} solid 2px`
889
+ },
890
+ incorrect: {
891
+ border: `${color.incorrectWithIcon()} solid 2px`
892
+ },
893
+ missing: {
894
+ border: `${color.incorrectWithIcon()} dashed 2px`
895
+ },
896
+ incorrectIcon: {
897
+ backgroundColor: color.incorrectWithIcon()
898
+ },
899
+ correctIcon: {
900
+ backgroundColor: color.correctTertiary()
901
+ },
902
+ correctnessIndicatorIcon: {
903
+ color: color.white(),
904
+ position: 'absolute',
905
+ top: '-8px',
906
+ left: '-8px',
907
+ borderRadius: '50%',
908
+ fontSize: '12px',
909
+ padding: '2px'
910
+ }
911
+ };
912
+ })(Token);
913
+
914
+ const log$1 = debug('@pie-lib:text-select:token-select');
915
+ class TokenSelect extends React.Component {
916
+ constructor(...args) {
917
+ super(...args);
918
+
919
+ this.selectedCount = () => this.props.tokens.filter(t => t.selected).length;
920
+
921
+ this.canSelectMore = selectedCount => {
922
+ const {
923
+ maxNoOfSelections
924
+ } = this.props;
925
+
926
+ if (maxNoOfSelections === 1) {
927
+ return true;
928
+ }
929
+
930
+ log$1('[canSelectMore] maxNoOfSelections: ', maxNoOfSelections, 'selectedCount: ', selectedCount);
931
+ return maxNoOfSelections <= 0 || isFinite(maxNoOfSelections) && selectedCount < maxNoOfSelections;
932
+ };
933
+
934
+ this.toggleToken = event => {
935
+ const {
936
+ target
937
+ } = event;
938
+ const {
939
+ tokens,
940
+ animationsDisabled
941
+ } = this.props;
942
+ const tokensCloned = clone(tokens);
943
+ const targetSpanWrapper = target.closest(`.${Token$1.rootClassName}`);
944
+ const targetedTokenIndex = targetSpanWrapper && targetSpanWrapper.dataset && targetSpanWrapper.dataset.indexkey;
945
+ const t = targetedTokenIndex && tokensCloned[targetedTokenIndex]; // don't toggle if we are in print mode, token correctness is defined or if it's missing
946
+ // (missing means that it was evaluated as correct and not selected)
947
+
948
+ if (t && t.correct === undefined && !animationsDisabled && !t.isMissing) {
949
+ const {
950
+ onChange,
951
+ maxNoOfSelections
952
+ } = this.props;
953
+ const selected = !t.selected;
954
+
955
+ if (maxNoOfSelections === 1 && this.selectedCount() === 1) {
956
+ const selectedToken = (tokens || []).filter(t => t.selected);
957
+ const updatedTokens = tokensCloned.map(token => {
958
+ if (isEqual(token, selectedToken[0])) {
959
+ return _extends({}, token, {
960
+ selected: false
961
+ });
962
+ }
963
+
964
+ return _extends({}, token, {
965
+ selectable: true
966
+ });
967
+ });
968
+
969
+ const update = _extends({}, t, {
970
+ selected: !t.selected
971
+ });
972
+
973
+ updatedTokens.splice(targetedTokenIndex, 1, update);
974
+ onChange(updatedTokens);
975
+ } else {
976
+ if (selected && maxNoOfSelections > 0 && this.selectedCount() >= maxNoOfSelections) {
977
+ log$1('skip toggle max reached');
978
+ return;
979
+ }
980
+
981
+ const update = _extends({}, t, {
982
+ selected: !t.selected
983
+ });
984
+
985
+ tokensCloned.splice(targetedTokenIndex, 1, update);
986
+ onChange(tokensCloned);
987
+ }
988
+ }
989
+ };
990
+
991
+ this.generateTokensInHtml = () => {
992
+ const {
993
+ tokens,
994
+ disabled,
995
+ highlightChoices,
996
+ animationsDisabled
997
+ } = this.props;
998
+ const selectedCount = this.selectedCount();
999
+
1000
+ const isLineBreak = text => text === '\n';
1001
+
1002
+ const isNewParagraph = text => text === '\n\n';
1003
+
1004
+ const reducer = (accumulator, t, index) => {
1005
+ const selectable = t.selected || t.selectable && this.canSelectMore(selectedCount);
1006
+ const showCorrectAnswer = t.correct !== undefined && (t.selectable || t.selected);
1007
+ let finalAcc = accumulator;
1008
+
1009
+ if (isNewParagraph(t.text)) {
1010
+ return finalAcc + '</p><p>';
1011
+ }
1012
+
1013
+ if (isLineBreak(t.text)) {
1014
+ return finalAcc + '<br>';
1015
+ }
1016
+
1017
+ if (selectable && !disabled || showCorrectAnswer || t.selected || t.isMissing || animationsDisabled && t.predefined // if we are in print mode
1018
+ ) {
1019
+ return finalAcc + renderToString( /*#__PURE__*/React.createElement(Token$1, _extends({
1020
+ key: index,
1021
+ disabled: disabled,
1022
+ index: index
1023
+ }, t, {
1024
+ selectable: selectable,
1025
+ highlight: highlightChoices,
1026
+ animationsDisabled: animationsDisabled
1027
+ })));
1028
+ } else {
1029
+ return accumulator + t.text;
1030
+ }
1031
+ };
1032
+
1033
+ const reduceResult = (tokens || []).reduce(reducer, '<p>');
1034
+ return reduceResult + '</p>';
1035
+ };
1036
+ }
1037
+
1038
+ render() {
1039
+ const {
1040
+ classes,
1041
+ className: classNameProp
1042
+ } = this.props;
1043
+ const className = classNames(classes.tokenSelect, classNameProp);
1044
+ const html = this.generateTokensInHtml();
1045
+ return /*#__PURE__*/React.createElement("div", {
1046
+ className: className,
1047
+ dangerouslySetInnerHTML: {
1048
+ __html: html
1049
+ },
1050
+ onClick: this.toggleToken
1051
+ });
1052
+ }
1053
+
1054
+ }
1055
+ TokenSelect.propTypes = {
1056
+ tokens: PropTypes.arrayOf(PropTypes.shape(TokenTypes)).isRequired,
1057
+ className: PropTypes.string,
1058
+ classes: PropTypes.object.isRequired,
1059
+ onChange: PropTypes.func.isRequired,
1060
+ disabled: PropTypes.bool,
1061
+ highlightChoices: PropTypes.bool,
1062
+ animationsDisabled: PropTypes.bool,
1063
+ maxNoOfSelections: PropTypes.number
1064
+ };
1065
+ TokenSelect.defaultProps = {
1066
+ highlightChoices: false,
1067
+ maxNoOfSelections: 0,
1068
+ tokens: []
1069
+ };
1070
+ var TokenSelect$1 = withStyles(() => ({
1071
+ tokenSelect: _extends({
1072
+ backgroundColor: 'none',
1073
+ whiteSpace: 'pre'
1074
+ }, noSelect(), {
1075
+ '& p': {
1076
+ whiteSpace: 'break-spaces'
1077
+ }
1078
+ })
1079
+ }))(TokenSelect); // Re-export TokenTypes for external use
1080
+
1081
+ const log = debug('@pie-lib:text-select');
1082
+ /**
1083
+ * Built on TokenSelect uses build.normalize to build the token set.
1084
+ */
1085
+
1086
+ class TextSelect extends React.Component {
1087
+ constructor(...args) {
1088
+ super(...args);
1089
+
1090
+ this.change = tokens => {
1091
+ const {
1092
+ onChange
1093
+ } = this.props;
1094
+
1095
+ if (!onChange) {
1096
+ return;
1097
+ }
1098
+
1099
+ const out = tokens.filter(t => t.selected).map(t => ({
1100
+ start: t.start,
1101
+ end: t.end
1102
+ }));
1103
+ onChange(out);
1104
+ };
1105
+ }
1106
+
1107
+ render() {
1108
+ const {
1109
+ text,
1110
+ disabled,
1111
+ tokens,
1112
+ selectedTokens,
1113
+ className,
1114
+ highlightChoices,
1115
+ maxNoOfSelections,
1116
+ animationsDisabled
1117
+ } = this.props;
1118
+ const normalized = normalize(text, tokens);
1119
+ log('normalized: ', normalized);
1120
+ const prepped = normalized.map(t => {
1121
+ const selectedIndex = selectedTokens.findIndex(s => {
1122
+ return s.start === t.start && s.end === t.end;
1123
+ });
1124
+ const selected = selectedIndex !== -1;
1125
+ const correct = selected ? t.correct : undefined;
1126
+ const isMissing = t.isMissing;
1127
+ return _extends({}, t, {
1128
+ selectable: !disabled && t.predefined,
1129
+ selected,
1130
+ correct,
1131
+ isMissing
1132
+ });
1133
+ });
1134
+ return /*#__PURE__*/React.createElement(TokenSelect$1, {
1135
+ highlightChoices: !disabled && highlightChoices,
1136
+ className: className,
1137
+ tokens: prepped,
1138
+ disabled: disabled,
1139
+ onChange: this.change,
1140
+ maxNoOfSelections: maxNoOfSelections,
1141
+ animationsDisabled: animationsDisabled
1142
+ });
1143
+ }
1144
+
1145
+ }
1146
+ TextSelect.propTypes = {
1147
+ onChange: PropTypes.func,
1148
+ disabled: PropTypes.bool,
1149
+ tokens: PropTypes.arrayOf(PropTypes.shape(TokenTypes)).isRequired,
1150
+ selectedTokens: PropTypes.arrayOf(PropTypes.shape(TokenTypes)).isRequired,
1151
+ text: PropTypes.string.isRequired,
1152
+ className: PropTypes.string,
1153
+ highlightChoices: PropTypes.bool,
1154
+ animationsDisabled: PropTypes.bool,
1155
+ maxNoOfSelections: PropTypes.number
1156
+ };
1157
+
1158
+ const {
1159
+ translator
1160
+ } = Translator;
1161
+ const Legend = withStyles(theme => ({
1162
+ flexContainer: {
1163
+ display: 'flex',
1164
+ flexDirection: 'row',
1165
+ alignItems: 'center',
1166
+ gap: `${2 * theme.spacing.unit}px`,
1167
+ borderBottom: '1px solid lightgrey',
1168
+ borderTop: '1px solid lightgrey',
1169
+ paddingBottom: theme.spacing.unit,
1170
+ paddingTop: theme.spacing.unit,
1171
+ marginBottom: theme.spacing.unit
1172
+ },
1173
+ key: {
1174
+ fontSize: '14px',
1175
+ fontWeight: 'bold',
1176
+ color: color.black(),
1177
+ marginLeft: theme.spacing.unit
1178
+ },
1179
+ container: {
1180
+ position: 'relative',
1181
+ padding: '4px',
1182
+ fontSize: '14px',
1183
+ borderRadius: '4px'
1184
+ },
1185
+ correct: {
1186
+ border: `${color.correctTertiary()} solid 2px`
1187
+ },
1188
+ incorrect: {
1189
+ border: `${color.incorrectWithIcon()} solid 2px`
1190
+ },
1191
+ missing: {
1192
+ border: `${color.incorrectWithIcon()} dashed 2px`
1193
+ },
1194
+ incorrectIcon: {
1195
+ backgroundColor: color.incorrectWithIcon()
1196
+ },
1197
+ correctIcon: {
1198
+ backgroundColor: color.correctTertiary()
1199
+ },
1200
+ icon: {
1201
+ color: color.white(),
1202
+ position: 'absolute',
1203
+ top: '-8px',
1204
+ left: '-8px',
1205
+ borderRadius: '50%',
1206
+ fontSize: '12px',
1207
+ padding: '2px'
1208
+ }
1209
+ }))(({
1210
+ classes,
1211
+ language,
1212
+ showOnlyCorrect
1213
+ }) => {
1214
+ const legendItems = [{
1215
+ Icon: Check,
1216
+ label: translator.t('selectText.correctAnswerSelected', {
1217
+ lng: language
1218
+ }),
1219
+ containerClass: classNames(classes.correct, classes.container),
1220
+ iconClass: classNames(classes.correctIcon, classes.icon)
1221
+ }, {
1222
+ Icon: Close,
1223
+ label: translator.t('selectText.incorrectSelection', {
1224
+ lng: language
1225
+ }),
1226
+ containerClass: classNames(classes.incorrect, classes.container),
1227
+ iconClass: classNames(classes.incorrectIcon, classes.icon)
1228
+ }, {
1229
+ Icon: Close,
1230
+ label: translator.t('selectText.correctAnswerNotSelected', {
1231
+ lng: language
1232
+ }),
1233
+ containerClass: classNames(classes.missing, classes.container),
1234
+ iconClass: classNames(classes.incorrectIcon, classes.icon)
1235
+ }];
1236
+
1237
+ if (showOnlyCorrect) {
1238
+ legendItems.splice(1, 2);
1239
+ }
1240
+
1241
+ return /*#__PURE__*/React.createElement("div", {
1242
+ className: classes.flexContainer
1243
+ }, /*#__PURE__*/React.createElement("span", {
1244
+ className: classes.key
1245
+ }, translator.t('selectText.key', {
1246
+ lng: language
1247
+ })), legendItems.map(({
1248
+ Icon,
1249
+ label,
1250
+ containerClass,
1251
+ iconClass
1252
+ }, idx) => /*#__PURE__*/React.createElement("div", {
1253
+ key: idx,
1254
+ className: containerClass
1255
+ }, /*#__PURE__*/React.createElement(Icon, {
1256
+ className: iconClass
1257
+ }), /*#__PURE__*/React.createElement("span", null, label))));
1258
+ });
1259
+
1260
+ const createElementFromHTML = (htmlString = '') => {
1261
+ const div = document.createElement('div');
1262
+ div.innerHTML = htmlString.trim();
1263
+ return div;
1264
+ };
1265
+
1266
+ const parseBrs = dom => {
1267
+ const brs = dom.querySelectorAll('br');
1268
+ brs.forEach(br => br.replaceWith('\n'));
1269
+ dom.innerHTML = dom.innerHTML.replace(/\n\n/g, '\n');
1270
+ };
1271
+ const parseParagraph = (paragraph, end) => {
1272
+ if (end) {
1273
+ return paragraph.innerHTML;
1274
+ }
1275
+
1276
+ return `${paragraph.innerHTML}\n\n`;
1277
+ };
1278
+ const parseParagraphs = dom => {
1279
+ const paragraphs = dom.querySelectorAll('p'); // separate variable for easily debugging, if needed
1280
+
1281
+ let str = '';
1282
+ paragraphs.forEach((par, index) => {
1283
+ str += parseParagraph(par, index === paragraphs.length - 1);
1284
+ });
1285
+ return str || null;
1286
+ };
1287
+ const prepareText = text => {
1288
+ let txtDom = createElementFromHTML(text);
1289
+ const allDomElements = Array.from(txtDom.querySelectorAll('*'));
1290
+
1291
+ if (txtDom.querySelectorAll('p').length === 0) {
1292
+ const div = document.createElement('div');
1293
+ div.innerHTML = `<p>${txtDom.innerHTML}</p>`;
1294
+ txtDom = div;
1295
+ } // if no dom elements, we just return the text
1296
+
1297
+
1298
+ if (allDomElements.length === 0) {
1299
+ return text;
1300
+ }
1301
+
1302
+ parseBrs(txtDom);
1303
+ return parseParagraphs(txtDom);
1304
+ };
1305
+
1306
+ export { Legend, TextSelect, Token$1 as Token, TokenSelect$1 as TokenSelect, TokenTypes, index as Tokenizer, prepareText };
1307
+ //# sourceMappingURL=index.js.map