Skip to content
This repository was archived by the owner on Apr 18, 2024. It is now read-only.

Commit e2f0c73

Browse files
authored
fix: DEV-1555: RichText regions initialization (#473)
* First nice but not perfect try; also some comments * Allow to replace history step if needed Good for initialization, when you only need one step in history, but some changes arrived. by @Gondragos * squash * Remove fixRegionsXPath(); this should be on init * use refs from model * fix text; fix xpath; remove useless code * Reset replaceNextUndoState on unfreeze * remove highlightedText, just use a text from model * fixes after review * trying to fix initialization inline elements skips init process because of messed up logic * set correct order of initialization load object mark it as loaded initialize regions mark object is ready also initialize regions from suggestions * Fix history during initialization mobx snapshots are generated after action, so we have to freeze outside of actions, moved this to view/store * try-catch around every region to skip only broken * Detect initial load; simplify logic Add `calculated` hidden field to `globalOffsets` to track initialized state. It should be in the model to avoid reinit on undo/redo. cDU actually useless, we call `needsUpdate()` explicitly on every related step. * Don't use cahcedRange from detached iframe Check that iframe exists (defaultView should be not null) * Fix draft from nowhere; fix errors in console
1 parent 5bd5637 commit e2f0c73

File tree

9 files changed

+225
-210
lines changed

9 files changed

+225
-210
lines changed

src/components/Node/Node.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ const NodeViews = {
1818
RichTextRegionModel: NodeView({
1919
name: "HTML",
2020
icon: IconText,
21-
getContent: node => <span style={{ color: "#5a5a5a" }}>{node.highlightedText}</span>,
21+
getContent: node => <span style={{ color: "#5a5a5a" }}>{node.text}</span>,
2222
}),
2323

2424
ParagraphsRegionModel: NodeView({

src/components/RelationsOverlay/BoundingBox.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ const _detect = region => {
9696
case "paragraphs":
9797
case "timeseriesregion": {
9898
const regionBbox = Geometry.getDOMBBox(region.getRegionElement());
99-
const container = region.parent?.rootNodeRef?.current;
99+
const container = region.parent?.visibleNodeRef?.current;
100100

101101
if (container?.tagName === "IFRAME") {
102102
const iframeBbox = Geometry.getDOMBBox(container, true);

src/core/TimeTraveller.js

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ const TimeTraveller = types
3030
// A way to handle multiple simultaneous freezes from different places
3131
const freezingLockSet = new Set();
3232
let changesDuringFreeze = false;
33+
let replaceNextUndoState = false;
3334

3435
function triggerHandlers() {
3536
updateHandlers.forEach(handler => handler());
@@ -51,11 +52,16 @@ const TimeTraveller = types
5152

5253
unfreeze(key) {
5354
self.safeUnfreeze(key);
54-
if (!self.isFrozen && changesDuringFreeze) {
55-
self.recordNow();
55+
if (!self.isFrozen) {
56+
if (changesDuringFreeze) self.recordNow();
57+
self.setReplaceNextUndoState(false);
5658
}
5759
},
5860

61+
setReplaceNextUndoState(value = true) {
62+
replaceNextUndoState = value;
63+
},
64+
5965
recordNow() {
6066
self.addUndoState(getSnapshot(targetStore));
6167
},
@@ -81,9 +87,10 @@ const TimeTraveller = types
8187
return;
8288
}
8389

84-
self.history.splice(self.undoIdx + 1);
90+
self.history.splice(self.undoIdx + !replaceNextUndoState, self.history.length);
8591
self.history.push(recorder);
8692
self.undoIdx = self.history.length - 1;
93+
replaceNextUndoState = false;
8794
changesDuringFreeze = false;
8895
},
8996

src/mixins/HighlightMixin.js

Lines changed: 11 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -7,34 +7,31 @@ import { isDefined } from "../utils/utilities";
77

88
export const HighlightMixin = types
99
.model()
10-
.volatile(() =>({
11-
_highlightedText: "",
12-
}))
1310
.views(self => ({
1411
get _hasSpans() {
12+
// @todo is it possible that only some spans are connected?
1513
return self._spans ? (
1614
self._spans.every(span => span.isConnected)
1715
) : false;
1816
},
19-
get highlightedText() {
20-
return self.text || self._highlightedText;
21-
},
2217
}))
2318
.actions(self => ({
2419
/**
2520
* Create highlights from the stored `Range`
2621
*/
2722
applyHighlight() {
2823
if (self.parent.isLoaded === false) return;
29-
// Avoid calling this method twice
24+
3025
// spans in iframe disappear on every annotation switch, so check for it
3126
// in iframe spans still isConnected, but window is missing
32-
if (self._hasSpans && self._spans[0]?.ownerDocument?.defaultView) {
33-
console.warn("Spans already created");
27+
const isReallyConnected = Boolean(self._spans?.[0]?.ownerDocument?.defaultView);
28+
29+
// Avoid calling this method twice
30+
if (self._hasSpans && isReallyConnected) {
3431
return;
3532
}
3633

37-
const range = self.rangeFromGlobalOffset();
34+
const range = self.getRangeToHighlight();
3835
const root = self._getRootNode();
3936

4037
// Avoid rendering before view is ready
@@ -47,6 +44,7 @@ export const HighlightMixin = types
4744

4845
const labelColor = self.getLabelColor();
4946
const identifier = guidGenerator(5);
47+
// @todo use label-based stylesheets created only once
5048
const stylesheet = createSpanStylesheet(root.ownerDocument, identifier, labelColor);
5149
const classNames = ["htx-highlight", stylesheet.className];
5250

@@ -70,8 +68,8 @@ export const HighlightMixin = types
7068

7169
updateHighlightedText() {
7270
if (!self.text) {
73-
// Concatinating of spans' innerText is up to 10 times faster, but loses "\n"
74-
const range = self.rangeFromGlobalOffset();
71+
// Concatenating of spans' innerText is up to 10 times faster, but loses "\n"
72+
const range = self.getRangeToHighlight();
7573
const root = self._getRootNode();
7674

7775
if (!range || !root) {
@@ -81,9 +79,8 @@ export const HighlightMixin = types
8179

8280
selection.removeAllRanges();
8381
selection.addRange(range);
84-
self._highlightedText = String(selection);
82+
self.text = String(selection);
8583
selection.removeAllRanges();
86-
8784
}
8885
},
8986

src/regions/RichTextRegion.js

Lines changed: 107 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,21 @@ import Registry from "../core/Registry";
1111
import { AreaMixin } from "../mixins/AreaMixin";
1212
import Utils from "../utils";
1313
import { isDefined } from "../utils/utilities";
14-
import { findRangeNative } from "../utils/selection-tools";
14+
import { findRangeNative, rangeToGlobalOffset } from "../utils/selection-tools";
1515

1616
const GlobalOffsets = types.model("GlobalOffset", {
1717
start: types.number,
1818
end: types.number,
19-
});
19+
// distinguish loaded globalOffsets from user's annotation and internally calculated one;
20+
// we should rely only on calculated offsets to find ranges, see initRangeAndOffsets();
21+
// it should be in the model to avoid reinit on undo/redo.
22+
calculated: false,
23+
}).views(self => ({
24+
get serialized() {
25+
// should never get to serialized result
26+
return { start: self.start, end: self.end };
27+
},
28+
}));
2029

2130
const Model = types
2231
.model("RichTextRegionModel", {
@@ -33,6 +42,7 @@ const Model = types
3342
})
3443
.volatile(() => ({
3544
hideable: true,
45+
cachedRange: null,
3646
}))
3747
.views(self => ({
3848
get parent() {
@@ -77,7 +87,7 @@ const Model = types
7787

7888
Object.assign(res.value, {
7989
...xpathRange,
80-
globalOffsets: self.globalOffsets?.toJSON(),
90+
globalOffsets: self.globalOffsets.serialized,
8191
});
8292
} catch(e) {
8393
// regions may be broken, so they don't have globalOffsets
@@ -88,7 +98,7 @@ const Model = types
8898

8999
if (self.globalOffsets) {
90100
Object.assign(res.value, {
91-
globalOffsets: self.globalOffsets?.toJSON(),
101+
globalOffsets: self.globalOffsets.serialized,
92102
});
93103
}
94104
}
@@ -101,7 +111,8 @@ const Model = types
101111
return res;
102112
},
103113

104-
updateOffsets(startOffset, endOffset) {
114+
// text regions have only start/end, so we should update start/endOffsets with these values
115+
updateTextOffsets(startOffset, endOffset) {
105116
Object.assign(self, { startOffset, endOffset });
106117
},
107118

@@ -112,42 +123,103 @@ const Model = types
112123
});
113124
},
114125

115-
rangeFromGlobalOffset() {
126+
getRangeToHighlight() {
116127
const root = self._getRootNode();
117128

118-
if (self.globalOffsets && isDefined(root)) {
119-
return findRangeNative(self.globalOffsets.start, self.globalOffsets.end, root);
129+
if (!root || !self.globalOffsets) return undefined;
130+
131+
const rangeIsMissing = !self.cachedRange
132+
|| self.cachedRange.collapsed
133+
// if this range is in detached iframe it'll look like a good one, check this
134+
|| !self.cachedRange.startContainer?.ownerDocument?.defaultView;
135+
136+
if (rangeIsMissing) {
137+
const { start, end } = self.globalOffsets;
138+
139+
self.cachedRange = findRangeNative(start, end, root);
120140
}
121141

122-
return self._getRange();
142+
return self.cachedRange;
123143
},
124144

125-
// For external XPath updates
126-
_fixXPaths() {
127-
if (self.isText) return;
145+
/**
146+
* Main method to detect HTML range and its offsets for LSF region
147+
* globalOffsets are used for:
148+
* - internal use (get ranges to highlight quickly)
149+
* - end users convenience
150+
* - for emergencies (xpath invalid)
151+
*/
152+
initRangeAndOffsets() {
153+
if (self.globalOffsets?.calculated) return;
154+
155+
const root = self._getRootNode();
156+
let range;
157+
158+
// 0. Text regions are simple — just get range by offsets
159+
if (self.isText) {
160+
const { startOffset: start, endOffset: end } = self;
161+
162+
self.globalOffsets = { start, end, calculated: true };
163+
self.cachedRange = findRangeNative(start, end, root);
164+
return;
165+
}
166+
167+
// 1. first try to find range by xpath in original document
168+
range = self._getRange({ useOriginalContent: true });
169+
170+
if (range) {
171+
// we need this range in the visible document, so find it by global offsets
172+
const originalRoot = self._getRootNode(true);
173+
const [start, end] = rangeToGlobalOffset(range, originalRoot);
128174

129-
const range = self._getRange(true);
175+
self.globalOffsets = { start, end, calculated: true };
176+
self.cachedRange = findRangeNative(start, end, root);
177+
178+
return;
179+
}
130180

131-
if (range && self.globalOffsets) {
132-
const root = self._getRootNode(true);
181+
// 2. then try to find range on visible document
182+
// that's for old buggy annotations created over dirty document state
183+
range = self._getRange({ useOriginalContent: false });
133184

134-
const rangeFromGlobal = findRangeNative(
135-
self.globalOffsets.start,
136-
self.globalOffsets.end,
137-
root,
138-
);
185+
if (range) {
186+
const [start, end] = rangeToGlobalOffset(range, root);
139187

140-
if (!rangeFromGlobal) return;
188+
self.globalOffsets = { start, end, calculated: true };
189+
self.cachedRange = range;
190+
191+
return;
192+
}
193+
194+
// 3. if xpaths are broken use globalOffsets if given
195+
if (self.globalOffsets && isDefined(root)) {
196+
const { start, end } = self.globalOffsets;
141197

142-
const normedRange = xpath.fromRange(rangeFromGlobal, root);
198+
self.cachedRange = findRangeNative(start, end, root);
143199

144-
if (!isDefined(normedRange)) return;
200+
if (self.cachedRange) {
201+
self._fixXPaths(self.cachedRange, root);
202+
self.globalOffsets.calculated = true;
203+
}
145204

146-
self.start = normedRange.start ?? self.start;
147-
self.end = normedRange.end ?? self.end;
148-
self.startOffset = normedRange.startOffset ?? self.startOffset;
149-
self.endOffset = normedRange.endOffset ?? self.endOffset;
205+
return;
150206
}
207+
208+
// 4. out of options — region is broken
209+
// @todo show error in console and regions list
210+
return undefined;
211+
},
212+
213+
// fix XPaths when we found region by globalOffsets
214+
_fixXPaths(range, root) {
215+
const normedRange = xpath.fromRange(range, root);
216+
217+
if (!isDefined(normedRange)) return;
218+
219+
self.start = normedRange.start;
220+
self.end = normedRange.end;
221+
self.startOffset = normedRange.startOffset;
222+
self.endOffset = normedRange.endOffset;
151223
},
152224

153225
_getRange({ useOriginalContent = false, useCache = true } = {}) {
@@ -168,9 +240,13 @@ const Model = types
168240
},
169241

170242
_getRootNode(originalContent = false) {
171-
const ref = originalContent
172-
? self.parent.originalContentRef
173-
: self.parent.rootNodeRef;
243+
const parent = self.parent;
244+
let ref;
245+
246+
if (originalContent) ref = parent.originalContentRef;
247+
else if (parent.useWorkingNode) ref = parent.workingNodeRef;
248+
else ref = parent.visibleNodeRef;
249+
174250
const node = ref.current;
175251

176252
return node?.contentDocument?.body ?? node;
@@ -183,29 +259,11 @@ const Model = types
183259

184260
const { start, startOffset, end, endOffset } = self;
185261

186-
try {
187-
if (self.isText) {
188-
const { startContainer, endContainer } = Utils.Selection.findRange(startOffset, endOffset, rootNode);
189-
const range = document.createRange();
190-
191-
if (!startContainer || !endContainer) return;
192-
193-
range.setStart(startContainer.node, startContainer.position);
194-
range.setEnd(endContainer.node, endContainer.position);
195-
196-
self.text = range.toString();
197-
198-
return range;
199-
}
200-
} catch (err) {
201-
console.log("can't find range", err);
202-
}
203-
204262
try {
205263
return xpath.toRange(start, startOffset, end, endOffset, rootNode);
206264
} catch (err) {
207265
// actually this happens when regions cannot be located by xpath for some reason
208-
console.log("can't locate xpath", err);
266+
console.warn("can't locate xpath", { start, end }, err);
209267
}
210268

211269
return undefined;

src/stores/AnnotationStore.js

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -841,11 +841,12 @@ const Annotation = types
841841
});
842842
}
843843

844-
if (isFF(FF_DEV_1555)) {
845-
self.updateObjects();
846-
} else {
847-
self.objects.forEach(obj => obj.needsUpdate?.());
848-
}
844+
const { history } = self;
845+
846+
history.freeze("richtext:suggestions");
847+
self.objects.forEach(obj => obj.needsUpdate?.({ suggestions: true }));
848+
history.setReplaceNextUndoState(true);
849+
history.unfreeze("richtext:suggestions");
849850
},
850851

851852
/**
@@ -954,6 +955,7 @@ const Annotation = types
954955
const hasStartEnd = isDefined(value.start) && isDefined(value.end);
955956
const lacksOffsets = !isDefined(value.startOffset) && !isDefined(value.endOffset);
956957

958+
// @todo move this Text regions offsets transform to RichTextRegion
957959
if (hasStartEnd && lacksOffsets) {
958960
return Object.assign({}, value, {
959961
start: "",

0 commit comments

Comments
 (0)