import { tryReference, types } from "mobx-state-tree"; import Registry from "../core/Registry"; import { AreaMixin } from "../mixins/AreaMixin"; import { HighlightMixin } from "../mixins/HighlightMixin"; import NormalizationMixin from "../mixins/Normalization"; import RegionsMixin from "../mixins/Regions"; import { RichTextModel } from "../tags/object/RichText/model"; import { isDefined } from "../utils/utilities"; const GlobalOffsets = types .model("GlobalOffset", { start: types.number, end: types.number, // distinguish loaded globalOffsets from user's annotation and internally calculated one; // we should rely only on calculated offsets to find ranges, see initRangeAndOffsets(); // it should be in the model to avoid reinit on undo/redo. calculated: false, }) .views((self) => ({ get serialized() { // should never get to serialized result return { start: self.start, end: self.end }; }, })); const Model = types .model("RichTextRegionModel", { type: "richtextregion", object: types.late(() => types.reference(RichTextModel)), startOffset: types.integer, endOffset: types.integer, start: types.string, end: types.string, text: types.maybeNull(types.string), isText: types.optional(types.boolean, false), globalOffsets: types.maybeNull(GlobalOffsets), }) .volatile(() => ({ hideable: true, })) .views((self) => ({ get parent() { return tryReference(() => self.object); }, getRegionElement() { return self._spans?.[0]; }, get displayValue() { return self.text; }, })) .actions((self) => ({ beforeDestroy() { try { self.removeHighlight(); } catch (e) { console.warn(e); } }, /** * Applies additional data from the given result. * In the results we have almost all data meaningful stored in value but in regions we have two places for it: * - region itself (fields in model) * - related results (in results array) * so for some fields we should control more if we want to apply fields that could be in both places into the region. * This method also helps to avoid region type detection at the deserialization stage. * * @param {Object} result - The result object containing additional data. * @returns {void} */ applyAdditionalDataFromResult(result) { const isMainResult = result?.type?.endsWith("labels"); const hasText = isDefined(result?.value?.text); if (isMainResult && hasText) { self.text = result.value.text; } }, serialize() { const res = { value: {}, }; if (self.isText) { Object.assign(res.value, { start: self.startOffset, end: self.endOffset, }); } else { try { const xpathRange = self.parent.globalOffsetsToRelativeOffsets(self.globalOffsets); Object.assign(res.value, { ...xpathRange, globalOffsets: self.globalOffsets.serialized, }); } catch (e) { // regions may be broken, so they don't have globalOffsets // or they can't be applied on current html, so just keep them untouched const { start, end, startOffset, endOffset } = self; Object.assign(res.value, { start, end, startOffset, endOffset }); if (self.globalOffsets) { Object.assign(res.value, { globalOffsets: self.globalOffsets.serialized, }); } } } if (self.object.savetextresult === "yes" && isDefined(self.text)) { res.value.text = self.text; } return res; }, // text regions have only start/end, so we should update start/endOffsets with these values updateTextOffsets(startOffset, endOffset) { Object.assign(self, { startOffset, endOffset }); }, updateGlobalOffsets(start, end) { self.globalOffsets = GlobalOffsets.create({ start, end, calculated: true, }); }, updateXPathsFromGlobalOffsets() { const xPathRange = self.parent.globalOffsetsToRelativeOffsets(self.globalOffsets); if (xPathRange) { self._setXPaths(xPathRange); } }, /** * Main method to detect HTML range and its offsets for LSF region * globalOffsets are used for: * - internal use (get ranges to highlight quickly) * - end users convenience * - for emergencies (xpath invalid) */ initRangeAndOffsets() { if (self.globalOffsets?.calculated) return; // 0. Text regions are simple — just get range by offsets if (self.isText) { const { startOffset: start, endOffset: end } = self; self.globalOffsets = { start, end, calculated: true }; return; } // 1. first try to find range by xpath in the original layout const offsets = self.parent.relativeOffsetsToGlobalOffsets( self.start, self.startOffset, self.end, self.endOffset, ); if (offsets) { const [start, end] = offsets; self.globalOffsets = { start, end, calculated: true }; return; } // 2. then try to find range on dynamically changed document // @todo or not todo? // 3. if xpaths are broken use globalOffsets if given if (self.globalOffsets) { self.updateXPathsFromGlobalOffsets(); return; } // 4. out of options — region is broken // @todo show error in console and regions list return undefined; }, _setXPaths(value) { self.start = value.start; self.end = value.end; self.startOffset = value.startOffset; self.endOffset = value.endOffset; }, })); const RichTextRegionModel = types.compose( "RichTextRegionModel", RegionsMixin, AreaMixin, NormalizationMixin, Model, HighlightMixin, ); Registry.addRegionType(RichTextRegionModel, "text"); Registry.addRegionType(RichTextRegionModel, "hypertext"); Registry.addRegionType(RichTextRegionModel, "richtext"); export { RichTextRegionModel };