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 * * @example * * @example * * @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("
\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, );