Skip to content

Commit b1959dd

Browse files
committed
Added dynamic component loading
1 parent f785fb5 commit b1959dd

File tree

8 files changed

+170
-39
lines changed

8 files changed

+170
-39
lines changed
Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
<View>
22
<HyperTextLabels name="ner" toName="text">
3-
<Label value="Person"></Label>
4-
<Label value="Organization"></Label>
5-
<Label value="Date"></Label>
3+
<Label value="Paragraph" granularity="paragraph"></Label>
4+
<Label value="Word" granularity="word"></Label>
5+
<Label value="Div" granularity="div"></Label>
66
</HyperTextLabels>
77
<TableText name="text" value="$text"></TableText>
88
</View>

src/tags/control/Label.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ const TagAttrs = types.model({
6060
size: types.optional(types.string, 'medium'),
6161
background: types.optional(customTypes.color, Constants.LABEL_BACKGROUND),
6262
selectedcolor: types.optional(customTypes.color, '#ffffff'),
63-
granularity: types.maybeNull(types.enumeration(['symbol', 'word', 'sentence', 'paragraph'])),
63+
granularity: types.maybeNull(types.enumeration(['symbol', 'word', 'sentence', 'paragraph', 'div'])),
6464
groupcancontain: types.maybeNull(types.string),
6565
// childrencheck: types.optional(types.enumeration(["any", "all"]), "any")
6666
...(isFF(FF_DEV_2128) ? { html: types.maybeNull(types.string) } : {}),
@@ -174,7 +174,7 @@ const Model = types.model({
174174
if (labels.type === 'labels') return true; // universal labels are fine to select
175175
if (labels.type.includes(region.type.replace(/region$/, ''))) return true; // region type is in label type
176176
if (labels.type.includes(region.results[0].type)) return true; // any result type of the region is in label type
177-
177+
178178
return false;
179179
});
180180

src/tags/object/RichText/RichText.styl

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@
8787
border-style: dashed
8888

8989
&::before
90-
content "math"
90+
content "latex found"
9191
font-size: 10px
9292
position: relative
9393

src/tags/object/RichText/domManager.ts

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -92,17 +92,20 @@ class DDTextElement {
9292
elements.forEach(el => {
9393
fragment.appendChild(el.node);
9494
});
95-
parent.replaceChild(dummyReplacer, node);
96-
parent.replaceChild(fragment, dummyReplacer);
97-
95+
if (parent) {
96+
parent.replaceChild(dummyReplacer, node);
97+
parent.replaceChild(fragment, dummyReplacer);
98+
}
9899
return elements;
99100
}
100101

101102
removeNode() {
102103
const { node } = this;
103104
const parent = node.parentNode as Node;
104105

105-
parent.removeChild(node);
106+
if (parent) {
107+
parent.removeChild(node);
108+
}
106109
}
107110

108111
mergeWith(elements: DDTextElement[]) {
@@ -461,6 +464,9 @@ class DomData {
461464
}
462465

463466
findTextBlock(pos: number, avoid: 'start' | 'end' = 'start'): DDDynamicBlock | undefined {
467+
// TODO: issue is that the start/End here only reflects the static start/end of the text block
468+
// this does not represent the actual dynamic data that is happening!
469+
// e.g. 99 should maps to ...
464470
const block = this.elements.find(el => (el instanceof DDDynamicBlock) && el.start <= pos && el.end >= pos && el[avoid] !== pos);
465471

466472
if (isDefined(block)) {
@@ -633,7 +639,17 @@ export default class DomManager {
633639
while (currentNode) {
634640
const isText = currentNode.nodeType === Node.TEXT_NODE;
635641
const isBR = currentNode.nodeName === 'BR';
642+
const isSkipSelect = currentNode.nodeType === Node.ELEMENT_NODE && currentNode.getAttribute('data-skip-select');
636643

644+
if (isSkipSelect) {
645+
const ignoreContainer = currentNode;
646+
647+
// Skip out of the container, to ensure that we don't process anything
648+
// inside it.
649+
while (ignoreContainer.contains(currentNode)) {
650+
currentNode = this.nextStep();
651+
}
652+
}
637653
if (isText) {
638654
domData.addTextElement(currentNode as Text, this.currentPath);
639655
} else if (isBR) {

src/tags/object/RichText/model.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ const WARNING_MESSAGES = {
4949
* @param {string} [highlightColor] - hex string with highlight color, if not provided uses the labels color
5050
* @param {boolean} [showLabels=true] - whether or not to show labels next to the region
5151
* @param {none|base64|base64unicode} [encoding] - decode value from an encoded string
52-
* @param {symbol|word|sentence|paragraph} [granularity] - control region selection granularity
52+
* @param {symbol|word|sentence|paragraph|div} [granularity] - control region selection granularity
5353
*/
5454
const TagAttrs = types.model('RichTextModel', {
5555
value: types.maybeNull(types.string),
@@ -74,7 +74,7 @@ const TagAttrs = types.model('RichTextModel', {
7474

7575
encoding: types.optional(types.enumeration(['none', 'base64', 'base64unicode']), 'none'),
7676

77-
granularity: types.optional(types.enumeration(['symbol', 'word', 'sentence', 'paragraph']), 'symbol'),
77+
granularity: types.optional(types.enumeration(['symbol', 'word', 'sentence', 'paragraph', 'div']), 'symbol'),
7878
});
7979

8080
const Model = types

src/tags/object/RichText/table.js

Lines changed: 52 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -28,14 +28,16 @@ import { HtxRichText } from './view';
2828
// Note: We use a different marker than what is used in Khanmigo.
2929
const MAJX_MARKER = '$';
3030

31-
// Extract math from conversation
31+
// Extract math from conversation, alternate between math and non-math
3232
// Khanmigo uses "\(.*?\)" as the marker for math
33-
// TODO: only extract first match at the moment
34-
const extractMaths = (str) => {
35-
const match = Array.from(str.matchAll(/\\\((.*?)\\\)/g));
36-
37-
if (!match.length) return null;
38-
return match.map((m) => m[1]);
33+
// For example, "What is \(2 + 2\)?" will split into ["What is ", "2 + 2", "?"]
34+
const parseConvoWithMath = (str) => {
35+
// About the capture group: a cool behaviour of str.split is that if there's
36+
// capturing group, the group is captured into the group, which is perfect
37+
// for us!
38+
const mathRegex = /\\\((.*?)\\\)/g;
39+
40+
return str.split(mathRegex);
3941
};
4042

4143
const renderTableValue = (val) => {
@@ -54,44 +56,60 @@ const renderTableValue = (val) => {
5456

5557
const itemClass = cn('richtext', { elem: 'table-item' });
5658
const questionItemClass = cn('richtext', { elem: 'table-item', mod: { qa : 'question' } });
57-
const mathItemClass = cn('richtext', { elem: 'table-item', mod: { context: 'math' } });
58-
const questionMathItemClass = cn('richtext', { elem: 'table-item', mod: { qa : 'question', context: 'math' } });
5959
let hasMath = false;
6060

6161
const rowElems = conversations.map((conversation, index) => {
6262
const question = conversation[0];
6363
const answer = conversation[1];
64-
const mathQuestions = extractMaths(question);
65-
const mathAnswers = extractMaths(answer);
64+
const mathQuestions = parseConvoWithMath(question);
65+
const mathAnswers = parseConvoWithMath(answer);
6666
let mathQuestionComponent = null;
6767
let mathAnswerComponent = null;
6868

69-
const renderAllMathJax = (maths) => (
70-
maths.map((equation, i) => <MathJax key={`eq-${i}`}>{MAJX_MARKER + equation + MAJX_MARKER}</MathJax>)
69+
// Render an alternate list between Math and non-math expressions
70+
const renderAllMathJax = (convoAndMathList) => (
71+
convoAndMathList.map((convo, i) => {
72+
if (i % 2 === 0) {
73+
// Non math
74+
return <span key={`eq=${i}`}>{convo}</span>;
75+
} else {
76+
// So for Math, we need to create a span as we want 2 piece of dom:
77+
// 1. The hidden raw MathJax expression, to allow slot Label to work
78+
// 2. A marked MathJax expression that allows <MathJax/> to render
79+
return (
80+
<span key={`eq-${i}`}>
81+
<span style={{ 'display': 'none' }}>{'\\(' + convo + '\\)'}</span>
82+
<span data-skip-select='1'>{MAJX_MARKER + convo + MAJX_MARKER}</span>
83+
</span>
84+
);
85+
}
86+
})
7187
);
7288

73-
if (mathQuestions) {
89+
if (mathQuestions.length > 1) {
7490
mathQuestionComponent = (
75-
<div className={questionMathItemClass}>
76-
{renderAllMathJax(mathQuestions)}
91+
<div className={questionItemClass}>
92+
<MathJax>{renderAllMathJax(mathQuestions)}</MathJax>
7793
</div>
7894
);
7995
hasMath = true;
96+
} else {
97+
mathQuestionComponent = <div className={questionItemClass}>{question}</div>;
8098
}
81-
if (mathAnswers) {
99+
if (mathAnswers.length > 1) {
82100
mathAnswerComponent = (
83-
<div className={mathItemClass}>
84-
{renderAllMathJax(mathAnswers)}
101+
<div className={itemClass}>
102+
<MathJax>{renderAllMathJax(mathAnswers)}</MathJax>
85103
</div>
86104
);
87105
hasMath = true;
106+
} else {
107+
mathAnswerComponent = <div className={itemClass}>{answer}</div>;
88108
}
89109

90110
return (
91111
<div key={`conversation-${index}`}>
92-
<div className={questionItemClass}>{question}</div>
93112
{mathQuestionComponent}
94-
<div className={itemClass}>{answer}</div>
95113
{mathAnswerComponent}
96114
</div>
97115
);
@@ -113,10 +131,23 @@ const renderTableValue = (val) => {
113131
return <div>{rowElems}</div>;
114132
};
115133

134+
// We need to trigger MathJax typeset after the component is mounted
135+
// See https://docs.mathjax.org/en/latest/advanced/typeset.html
136+
const triggerMathJaxTypeset = () => {
137+
setTimeout(() => {
138+
// TODO: this is a hacky way to trigger MathJax typeset
139+
// The official way is to useContext(MathJaxBaseContext) but we are not
140+
// composing the component here.
141+
window?.MathJax?.typeset();
142+
});
143+
};
144+
116145
export const TableText = () => (
117146
HtxRichText({
118147
isText: false,
119148
valueToComponent: renderTableValue,
149+
// TODO: do we need this to re-render?
150+
// didMountCallback: triggerMathJaxTypeset,
120151
alwaysInline: true,
121152
})
122153
);

src/tags/object/RichText/view.js

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -237,7 +237,7 @@ class RichTextPieceView extends Component {
237237
*/
238238
_determineRegion(element) {
239239
const spanSelector = isFF(FF_LSDV_4620_3) ? this._regionVisibleSpanSelector : this._regionSpanSelector;
240-
240+
241241
if (matchesSelector(element, spanSelector)) {
242242
const span = element.tagName === 'SPAN' && (!isFF(FF_LSDV_4620_3) || element.matches(spanSelector)) ? element : element.closest(spanSelector);
243243
const { item } = this.props;
@@ -247,7 +247,7 @@ class RichTextPieceView extends Component {
247247
}
248248

249249
componentDidMount() {
250-
const { item, alwaysInline } = this.props;
250+
const { item, alwaysInline, didMountCallback } = this.props;
251251

252252
if (!isFF(FF_LSDV_4620_3)) {
253253
item.setNeedsUpdateCallbacks(
@@ -256,6 +256,10 @@ class RichTextPieceView extends Component {
256256
);
257257
}
258258

259+
if (didMountCallback) {
260+
didMountCallback(item);
261+
}
262+
259263
if (!(alwaysInline || item.inline)) {
260264
this.dispose = observe(item, '_isReady', this.updateLoadingVisibility, true);
261265
}
@@ -484,9 +488,12 @@ const storeInjector = inject('store');
484488
const RPTV = storeInjector(observer(RichTextPieceView));
485489

486490
export const HtxRichText = (
487-
{ isText = false, valueToComponent = null, alwaysInline = false } = {},
491+
{ isText = false, valueToComponent = null, alwaysInline = false, didMountCallback = null } = {},
488492
) => {
489493
return storeInjector(observer(props => {
490-
return <RPTV {...props} isText={isText} valueToComponent={valueToComponent} alwaysInline={alwaysInline}/>;
494+
return (
495+
<RPTV {...props} isText={isText} alwaysInline={alwaysInline}
496+
valueToComponent={valueToComponent} didMountCallback={didMountCallback} />
497+
);
491498
}));
492499
};

src/utils/selection-tools.js

Lines changed: 79 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { clamp, isDefined } from './utilities';
22
import { FF_LSDV_4620_3, isFF } from './feature-flags';
3+
import { first, last } from 'strman';
34

45
export const isTextNode = node => node && node.nodeType === Node.TEXT_NODE;
56

@@ -170,6 +171,61 @@ const closestBoundarySelection = (selection, boundary) => {
170171
return selection;
171172
};
172173

174+
/**
175+
* Modify selection to be a boundary to be of parent element with tagName
176+
*/
177+
const changeBoundaryToElement = (selection, tagName) => {
178+
const {
179+
startOffset,
180+
startContainer,
181+
endOffset,
182+
endContainer,
183+
firstSymbol,
184+
prevSymbol,
185+
lastSymbol,
186+
nextSymbol,
187+
} = destructSelection(selection);
188+
189+
// find parent of startContainer with tagName
190+
const upperCaseTagName = tagName.toUpperCase();
191+
let parent = startContainer;
192+
193+
while (parent && parent.tagName !== upperCaseTagName) {
194+
parent = parent.parentNode;
195+
}
196+
if (!parent) {
197+
return;
198+
}
199+
// Find the first textChild and last, and extent the selections
200+
// See doc at: https://developer.mozilla.org/en-US/docs/Web/API/
201+
// Note we cannot simply do selectAllChilren, as the selection object
202+
// expects multiple range with each range to be a text node.
203+
// selection.selectAllChildren(parent);
204+
const walker = parent.ownerDocument.createTreeWalker(parent, NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT);
205+
let firstTextChild = null;
206+
let lastTextChild = null;
207+
let currentNode = walker.nextNode();
208+
let skipContainer = null;
209+
210+
while (currentNode) {
211+
// check if we are in a skip element - if so skip elements within it.
212+
if (currentNode.nodeType === Node.ELEMENT_NODE && currentNode.getAttribute('data-skip-select')) {
213+
skipContainer = currentNode;
214+
while (skipContainer.contains(currentNode)) {
215+
currentNode = walker.nextNode();
216+
}
217+
continue;
218+
}
219+
220+
if (firstTextChild === null) firstTextChild = currentNode;
221+
lastTextChild = currentNode;
222+
223+
currentNode = walker.nextNode();
224+
}
225+
selection.setPosition(firstTextChild);
226+
selection.extend(lastTextChild, lastTextChild.length);
227+
};
228+
173229
const boundarySelection = (selection, boundary) => {
174230
const wordBoundary = boundary !== 'symbol';
175231
const {
@@ -262,13 +318,16 @@ const applyTextGranularity = (selection, granularity) => {
262318
case 'paragraph':
263319
boundarySelection(selection, 'paragraphboundary');
264320
return;
321+
case 'div':
322+
changeBoundaryToElement(selection, 'div');
323+
return;
265324
case 'charater':
266325
case 'symbol':
267326
default:
268327
return;
269328
}
270-
} catch {
271-
console.warn('Probably, you\'re using browser that doesn\'t support granularity.');
329+
} catch (e) {
330+
console.warn('Probably, you\'re using browser that doesn\'t support granularity.', e);
272331
}
273332
};
274333

@@ -711,6 +770,24 @@ const findGlobalOffset = (node, position, root) => {
711770
const isText = currentNode.nodeType === Node.TEXT_NODE;
712771
const isBR = currentNode.nodeName === 'BR';
713772

773+
// if the current node have skip_select attribute, we should skip it
774+
const isSkipSelect = currentNode.nodeType === Node.ELEMENT_NODE && currentNode.getAttribute('data-skip-select');
775+
776+
// Skip MathJax generated nodes, jump to next node (i.e. lastChild's next)
777+
if (isSkipSelect) {
778+
const ignoreContainer = currentNode;
779+
780+
// Keep checking the next of lastChild is not part of the container
781+
// Note this will end if currentNode = null, which is what we want.
782+
while (ignoreContainer.contains(currentNode)) {
783+
currentNode = walker.nextNode();
784+
// Note: the nodeReached can be within the ignore container, so we need
785+
// to check here.
786+
nodeReached = nodeReached || node === currentNode;
787+
}
788+
continue;
789+
}
790+
714791
// Stop iteration
715792
// Break if we passed target node and current node
716793
// is not target, nor child of a target

0 commit comments

Comments
 (0)