Bin
2025-12-16 9e0b2ba2c317b1a86212f24cbae3195ad1f3dbfa
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
import * as ff from "@humansignal/core/lib/utils/feature-flags/ff";
import { destroy as destroyNode, flow, types } from "mobx-state-tree";
import { createRef } from "react";
import Constants from "../../../core/Constants";
import { customTypes } from "../../../core/CustomTypes";
import { errorBuilder } from "../../../core/DataValidator/ConfigValidator";
import { cloneNode } from "../../../core/Helpers";
import { AnnotationMixin } from "../../../mixins/AnnotationMixin";
import { STATE_CLASS_MODS } from "../../../mixins/HighlightMixin";
import IsReadyMixin from "../../../mixins/IsReadyMixin";
import ProcessAttrsMixin from "../../../mixins/ProcessAttrs";
import RegionsMixin from "../../../mixins/Regions";
import Utils from "../../../utils";
import { parseValue } from "../../../utils/data";
import { FF_SAFE_TEXT, isFF } from "../../../utils/feature-flags";
import { sanitizeHtml } from "../../../utils/html";
import messages from "../../../utils/messages";
import { rangeToGlobalOffset } from "../../../utils/selection-tools";
import { escapeHtml, isValidObjectURL } from "../../../utils/utilities";
import ObjectBase from "../Base";
import DomManager from "./domManager";
 
const WARNING_MESSAGES = {
  dataTypeMistmatch: () => "Do not put text directly in task data if you use valueType=url.",
  badURL: (url) => `URL (${escapeHtml(url)}) is not valid.`,
  secureMode: () => 'In SECURE MODE valueType is set to "url" by default.',
  loadingError: (url, error) => `Loading URL (${url}) unsuccessful: ${error}`,
};
 
/**
 * WARNING: this is not a real doc, that's just a main reference; real docs are in their stub files: HyperText and Text
 *
 * RichText tag shows text or HTML and allows labeling
 * @example
 * <RichText name="text-1" value="$text" granularity="symbol" highlightColor="#ff0000" />
 * @example
 * <Text name="text-1" value="$url" valueType="url" highlightColor="#ff0000" />
 * @example
 * <HyperText name="text-1" value="$html" highlightColor="#ff0000" />
 * @name Text
 * @param {string} name                                   - name of the element
 * @param {string} value                                  - value of the element
 * @param {url|text} [valueType=url|text]                 – source of the data, check (Data retrieval)[https://labelstud.io/guide/tasks.html] page for more inforamtion
 * @param {boolean} [inline=false]                        - whether to embed html directly to LS or use iframe (only HyperText)
 * @param {boolean} [saveTextResult=true]                 – whether or not to save selected text to the serialized data
 * @param {boolean} [selectionEnabled=true]               - enable or disable selection
 * @param {boolean} [clickableLinks=false]                – allow annotator to open resources from links
 * @param {string} [highlightColor]                       - hex string with highlight color, if not provided uses the labels color
 * @param {boolean} [showLabels=true]                     - whether or not to show labels next to the region
 * @param {none|base64|base64unicode} [encoding]          - decode value from an encoded string
 * @param {symbol|word|sentence|paragraph} [granularity]  - control region selection granularity
 */
const TagAttrs = types.model("RichTextModel", {
  value: types.maybeNull(types.string),
 
  /** Defines the type of data to be shown */
  valuetype: types.optional(types.enumeration(["text", "url"]), () => (window.LS_SECURE_MODE ? "url" : "text")),
 
  inline: false,
 
  /** Whether or not to save selected text to the serialized data */
  savetextresult: types.optional(types.enumeration(["none", "no", "yes"]), () =>
    window.LS_SECURE_MODE ? "no" : "none",
  ),
 
  selectionenabled: types.optional(types.boolean, true),
 
  clickablelinks: false,
 
  highlightcolor: types.maybeNull(customTypes.color),
 
  showlabels: types.maybeNull(types.boolean),
 
  encoding: types.optional(types.enumeration(["none", "base64", "base64unicode"]), "none"),
 
  granularity: types.optional(types.enumeration(["symbol", "word", "sentence", "paragraph"]), "symbol"),
});
 
const Model = types
  .model("RichTextModel", {
    type: "richtext",
    _value: types.optional(types.maybeNull(types.string), null),
  })
  .views((self) => ({
    get canResizeSpans() {
      return ff.isActive(ff.FF_ADJUSTABLE_SPANS) && self.type === "text" && !self.isReadOnly();
    },
    get hasStates() {
      const states = self.states();
 
      return states && states.length > 0;
    },
 
    states() {
      return self.annotation.toNames.get(self.name);
    },
 
    activeStates() {
      const states = self.states();
 
      return states ? states.filter((s) => s.isLabeling && s.isSelected) : null;
    },
 
    get isLoaded() {
      return self._isLoaded && self._loadedForAnnotation === self.annotation?.id;
    },
 
    get isReady() {
      return self.isLoaded && self._isReady;
    },
 
    // we are displaying label for either data-label OR data-index
    get styles() {
      return `
      .htx-highlight {
        cursor: pointer;
        border: 1px dashed transparent;
      }
      .htx-highlight[data-index]::after,
      .htx-highlight[data-label]::after {
        padding: 2px 2px;
        font-size: 9.5px;
        font-weight: bold;
        font-family: var(--font-mono);
        vertical-align: super;
        content: attr(data-label);
        line-height: 0;
      }
      .htx-highlight[data-index]:not([data-label])::after {
        content: attr(data-index);
      }
      .htx-highlight.${STATE_CLASS_MODS.highlighted} {
        position: relative;
        cursor: ${Constants.LINKING_MODE_CURSOR};
        border-color: rgb(0, 174, 255);
      }
      .htx-highlight.${STATE_CLASS_MODS.hidden} {
        border: none;
        padding: 0;
        background: transparent !important;
        cursor: inherit;
        // pointer-events: none;
      }
      .htx-highlight.${STATE_CLASS_MODS.hidden}::before,
      .htx-highlight.${STATE_CLASS_MODS.hidden}::after,
      .htx-highlight.${STATE_CLASS_MODS.noLabel}::after {
        display: none;
      }
      `;
    },
    // This is not a real getter as it is dependant on ref which cannot be cached in the right way
    getIframeBodyNode() {
      const mountNode = self.mountNodeRef.current;
      return mountNode?.contentDocument?.body;
    },
    // This is not a real getter as it is dependant on ref which cannot be cached in the right way
    getRootNode() {
      return self.getIframeBodyNode() ?? self.mountNodeRef.current;
    },
  }))
  .volatile(() => ({
    // the only visible iframe/div, that contains rendered value
    mountNodeRef: createRef(),
 
    _isReady: false,
    _isLoaded: false,
    _loadedForAnnotation: null,
  }))
  .actions((self) => {
    let domManager;
 
    return {
      setLoaded(value = true) {
        if (value) self.onLoaded();
 
        self._isLoaded = value;
        self._loadedForAnnotation = self.annotation?.id;
      },
 
      onLoaded() {
        if (self.mountNodeRef.current) {
          domManager = new DomManager(self.mountNodeRef.current);
        }
      },
 
      onDispose() {
        self.regs.forEach((region) => {
          // remove all spans from the visible node, because without cleaning them, the regions won't be updated
          region.clearSpans();
        });
      },
 
      updateValue: flow(function* (store) {
        const valueFromTask = parseValue(self.value, store.task.dataObj);
        const value = yield self.resolveValue(valueFromTask);
 
        if (self.valuetype === "url") {
          const url = value;
 
          if (!isValidObjectURL(url, true)) {
            const message = [WARNING_MESSAGES.badURL(url), WARNING_MESSAGES.dataTypeMistmatch()];
 
            if (window.LS_SECURE_MODE) message.unshift(WARNING_MESSAGES.secureMode());
 
            self.annotationStore.addErrors([errorBuilder.generalError(message.join("<br/>\n"))]);
            self.setRemoteValue("");
            return;
          }
 
          try {
            const response = yield fetch(url);
            const { ok, status, statusText } = response;
 
            if (!ok) throw new Error(`${status} ${statusText}`);
 
            self.setRemoteValue(yield response.text());
          } catch (error) {
            const message = messages.ERR_LOADING_HTTP({ attr: self.value, error: String(error), url });
 
            self.annotationStore.addErrors([errorBuilder.generalError(message)]);
            self.setRemoteValue("");
          }
        } else {
          self.setRemoteValue(value);
        }
      }),
 
      setRemoteValue(val) {
        self.loaded = true;
 
        if (self.encoding === "base64") val = atob(val);
        if (self.encoding === "base64unicode") val = Utils.Checkers.atobUnicode(val);
 
        // clean up the html — remove scripts and iframes
        // nodes count better be the same, so replace them with stubs
        // we should not sanitize text tasks because we already have htmlEscape in view.js
        if (isFF(FF_SAFE_TEXT) && self.type === "text") {
          self._value = String(val);
        } else {
          self._value = sanitizeHtml(String(val));
        }
 
        self._regionsCache.forEach(({ region, annotation }) => {
          region.setText(self._value.substring(region.startOffset, region.endOffset));
          self.regions.push(region);
          annotation.addRegion(region);
        });
 
        self._regionsCache = [];
      },
 
      afterCreate() {
        self._regionsCache = [];
 
        if (self.type === "text") self.inline = true;
 
        // security measure, if valuetype is set to url then LS
        // doesn't save the text into the result, otherwise it does
        // can be aslo directly configured
        if (self.savetextresult === "none") {
          if (self.valuetype === "url") self.savetextresult = "no";
          else if (self.valuetype === "text") self.savetextresult = "yes";
        }
      },
 
      beforeDestroy() {
        domManager?.removeStyles(self.name);
        domManager?.destroy();
        domManager = null;
      },
 
      needsUpdate() {
        if (self.isLoaded === false) return;
 
        self.setReady(false);
 
        const styles = {
          [self.name]: self.styles,
        };
 
        self.regs.forEach((region) => {
          try {
            // will be initialized only once
            region.initRangeAndOffsets();
            region.applyHighlight(true);
            region.updateHighlightedText();
            styles[region.identifier] = region.styles;
          } catch (err) {
            console.error(err);
          }
        });
        self.setStyles(styles);
 
        self.setReady(true);
      },
 
      setStyles(stylesMap) {
        domManager?.setStyles(stylesMap);
      },
      removeStyles(ids) {
        domManager?.removeStyles(ids);
      },
 
      /**
       * Converts global offsets to relative offsets.
       *
       * @param {Object} start - The start global offset in codepoints.
       * @param {Object} end - The end global offset in codepoints.
       * @returns {undefined|{start: string, startOffset: number, end: string, endOffset: number}} - The relative offsets.
       */
      globalOffsetsToRelativeOffsets({ start, end }) {
        return domManager.globalOffsetsToRelativeOffsets(start, end);
      },
 
      /**
       * Calculates relative offsets to global offsets for a given range in the document.
       *
       * @param {Node} start - The starting node of the range.
       * @param {number} startOffset - The offset within the starting node.
       * @param {Node} end - The ending node of the range.
       * @param {number} endOffset - The offset within the ending node.
       * @return {number[]|undefined} - An array containing the calculated global offsets in codepoints in the form of [startGlobalOffset, endGlobalOffset].
       */
      relativeOffsetsToGlobalOffsets(start, startOffset, end, endOffset) {
        return domManager.relativeOffsetsToGlobalOffsets(start, startOffset, end, endOffset);
      },
 
      /**
       * Converts the given range to its global offset.
       *
       * @param {Range} range - The range to convert.
       * @returns {number[]|undefined} - The global offsets of the range in the form of [startGlobalOffset, endGlobalOffset].
       */
      rangeToGlobalOffset(range) {
        return domManager.rangeToGlobalOffset(range);
      },
 
      /**
       * Creates spans in the DOM for a given range of global offsets.
       *
       * @param {Object} offsets - The start and end offsets of the range.
       * @param {number} offsets.start - The starting offset in codepoints.
       * @param {number} offsets.end - The ending offset in codepoints.
       *
       * @returns {Array} - An array of DOM spans created for the range.
       */
      createSpansByGlobalOffsets({ start, end }) {
        return domManager.createSpans(start, end);
      },
 
      /**
       * Removes spans from the given array based on the provided start and end global offsets.
       *
       * @param {Array} spans - The array of spans to be modified.
       * @param {Object} offsets - The start and end global offsets.
       * @param {number} offsets.start - The start global offset in codepoints.
       * @param {number} offsets.end - The end global offset in codepoints.
       * @returns {void} - Nothing is returned.
       */
      removeSpansInGlobalOffsets(spans, { start, end }) {
        return domManager?.removeSpans(spans, start, end);
      },
 
      /**
       * Get text content at the position set by global offsets.
       *
       * @param {Object} offsets - The start and end global offsets.
       * @param {number} offsets.start - The start global offset in codepoints.
       * @param {number} offsets.end - The end global offset in codepoints.
       * @returns {string} - The text content between the start and end offsets.
       */
      getTextFromGlobalOffsets({ start, end }) {
        return domManager.getText(start, end);
      },
 
      setHighlight(region) {
        self.regs.forEach((r) => r.setHighlight(false));
        if (!region) return;
 
        if (region.annotation.isLinkingMode) {
          region.setHighlight(true);
        }
      },
 
      addRegion(range, doubleClickLabel) {
        const states = self.getAvailableStates();
 
        if (states.length === 0) return;
 
        const [control, ...rest] = states;
        const values = doubleClickLabel?.value ?? control.selectedValues();
        const labels = { [control.valueType]: values };
        let restSelectedStates;
        if (!ff.isActive(ff.FF_MULTIPLE_LABELS_REGIONS)) {
          // Clone labels nodes to avoid unselecting them on creating result
          restSelectedStates = rest.map((state) => cloneNode(state));
        }
 
        const area = ff.isActive(ff.FF_MULTIPLE_LABELS_REGIONS)
          ? self.annotation.createResult(range, labels, control, self, false, rest)
          : self.annotation.createResult(range, labels, control, self, false);
        const root = self.getRootNode();
 
        if (!ff.isActive(ff.FF_MULTIPLE_LABELS_REGIONS)) {
          //when user is using two different labels tag to draw a region, the other labels will be added to the region
          restSelectedStates.forEach((state) => {
            area.setValue(state);
            destroyNode(state);
          });
        }
 
        area._range = range._range;
 
        // @TODO: Maybe it could be solved by domManager
        const [soff, eoff] = rangeToGlobalOffset(range._range, root);
 
        area.updateGlobalOffsets(soff, eoff);
 
        if (range.isText) {
          area.updateTextOffsets(soff, eoff);
        } else {
          area.updateXPathsFromGlobalOffsets();
        }
 
        area.applyHighlight();
 
        area.notifyDrawingFinished();
 
        return area;
      },
    };
  });
 
export const RichTextModel = types.compose(
  "RichTextModel",
  ProcessAttrsMixin,
  ObjectBase,
  RegionsMixin,
  AnnotationMixin,
  IsReadyMixin,
  TagAttrs,
  Model,
);