import { types, getRoot } from "mobx-state-tree"; import Utils from "../utils"; import Constants, { defaultStyle } from "../core/Constants"; import { isDefined } from "../utils/utilities"; const HIGHLIGHT_CN = "htx-highlight"; const HIGHLIGHT_NO_LABEL_CN = "htx-no-label"; const LABEL_COLOR_ALPHA = 0.3; const LABEL_COLOR_ALPHA_ACTIVE = 0.8; export const HighlightMixin = types .model() .volatile(() => ({ _spans: null, })) .views((self) => ({ get _hasSpans() { // @todo is it possible that only some spans are connected? // @TODO: Need to check if it is still necessary condition. The way of working with spans was changed and it could affect this part. The main question, is there still a way to get `isConnected === false` return self._spans ? self._spans.every((span) => span.isConnected) : false; }, get identifier() { return `${self.id.split("#")[0]}-${self.ouid}`; }, get className() { return `${HIGHLIGHT_CN}-${self.identifier}`; }, get classNames() { const classNames = [HIGHLIGHT_CN, self.className]; if (!(self.parent.showlabels ?? self.store.settings.showLabels)) { classNames.push(HIGHLIGHT_NO_LABEL_CN); } if (self.selected) { classNames.push(STATE_CLASS_MODS.active); } // in this case labels presence can't be changed from settings — manual mode if (isDefined(self.parent.showlabels)) { classNames.push("htx-manual-label"); } return classNames; }, /** * Generate styles for region and active region, but with a lighter background is that's a resized region, * so the selection of the same color will give the original color of active region. * @see getColors * @param {string} className * @param {object} colors see `getColors()` * @param {boolean} resize lighter background for resized region or original one for active region * @returns {string} styles to apply to the region */ generateStyles(className, colors, resize = false) { return ` .${className} { background-color: ${colors.background} !important; border: 1px dashed transparent; } .${className}.${STATE_CLASS_MODS.active}:not(.${STATE_CLASS_MODS.hidden}) { color: ${colors.activeText} !important; background-color: ${resize ? colors.resizeBackground : colors.activeBackground} !important; } `; }, get styles() { return this.generateStyles(self.className, self.getColors()); }, get resizeStyles() { return this.generateStyles(self.className, self.getColors(), true); }, })) .actions((self) => ({ /** * Create highlights from the stored `Range` */ applyHighlight(init = false) { // skip re-initialization if (self._hasSpans) { return void 0; } self._spans = self.parent.createSpansByGlobalOffsets(self.globalOffsets); self._spans?.forEach((span) => (span.className = self.classNames.join(" "))); self.updateSpans(); if (!init) { self.parent.setStyles({ [self.identifier]: self.styles }); } return void 0; }, /** * Get text from object tag by region offsets and set it to the region. * Normally it would only set it initially for better performance. * But when we edit the region we need to update it on every change. * @param {object} options * @param {boolean} options.force - always update the text */ updateHighlightedText({ force = false } = {}) { if (!self.text || force) { self.text = self.parent.getTextFromGlobalOffsets(self.globalOffsets); } }, updateSpans() { // @TODO: Is `_hasSpans` some artifact from the old version? if (self._hasSpans || self._spans?.length) { const firstSpan = self._spans[0]; const lastSpan = self._spans.at(-1); const offsets = self.globalOffsets; // @TODO: Should we manage it in domManager? // update label tag (index + attached labels) which sits in the last span Utils.Selection.applySpanStyles(lastSpan, { index: self.region_index, label: self.getLabels() }); // store offsets in spans for further comparison if region got resized firstSpan.setAttribute("data-start", offsets.start); lastSpan.setAttribute("data-end", offsets.end); } }, clearSpans() { self._spans = null; }, /** * Removes current highlights */ removeHighlight() { if (self.globalOffsets) { self.parent?.removeSpansInGlobalOffsets(self._spans, self.globalOffsets); } self.parent?.removeStyles([self.identifier]); self._spans = null; }, /** * Update region's appearance if the label was changed */ updateAppearenceFromState() { if (!self._spans?.length) return; // Update label visibility based on settings const settings = getRoot(self).settings; const lastSpan = self._spans[self._spans.length - 1]; if (lastSpan) { if (!self.parent?.showlabels && !settings?.showLabels) { lastSpan.classList.add("htx-no-label"); } else { lastSpan.classList.remove("htx-no-label"); } } if (self.parent?.canResizeSpans) { const start = self._spans[0].getAttribute("data-start"); const end = self._spans.at(-1).getAttribute("data-end"); const offsets = self.globalOffsets; // if spans have different offsets stored, then we resized the region and need to recreate spans if (isDefined(start) && (+start !== offsets.start || +end !== offsets.end)) { self.removeHighlight(); self.applyHighlight(); } else { self.parent.setStyles?.({ [self.identifier]: self.styles }); self.updateSpans(); } } else { self.parent.setStyles?.({ [self.identifier]: self.styles }); self.updateSpans(); } }, /** * Attach resize handles to the first and last spans. `area` is used to be less possible to be * in user's document. They are not fully valid inside spans, but they work. */ attachHandles() { const classes = [STATE_CLASS_MODS.leftHandle, STATE_CLASS_MODS.rightHandle]; const spanStart = self._spans[0]; const spanEnd = self._spans.at(-1); classes.forEach((resizeClass, index) => { // html element that can't be encountered in a usual html const handleArea = document.createElement("area"); handleArea.classList.add(resizeClass); index === 0 ? spanStart.prepend(handleArea) : spanEnd.append(handleArea); }); }, detachHandles() { self._spans?.forEach((span) => span.querySelectorAll("area").forEach((area) => area.remove())); }, /** * Make current region selected */ selectRegion() { self.annotation.setHighlightedNode(self); self.addClass(STATE_CLASS_MODS.active); const first = self._spans?.[0]; if (!first) return; if (self.parent?.canResizeSpans) { self.attachHandles(); } if (first.scrollIntoViewIfNeeded) { first.scrollIntoViewIfNeeded(); } else { first.scrollIntoView({ block: "center", behavior: "smooth" }); } }, /** * Unselect text region */ afterUnselectRegion() { self.removeClass(STATE_CLASS_MODS.active); if (self.parent?.canResizeSpans) { self.detachHandles(); } }, /** * Remove stylesheet before removing the highlight itself */ beforeDestroy() { self.parent?.removeStyles([self.identifier]); }, /** * Draw region outline on hover * @param {boolean} val */ setHighlight(val) { if (!self._spans) { return; } self._highlighted = val; if (self.highlighted) { self.addClass(STATE_CLASS_MODS.highlighted); } else { self.removeClass(STATE_CLASS_MODS.highlighted); } }, getLabels() { const index = self.region_index; const text = (self.labeling?.selectedLabels ?? []).map((label) => label.value).join(","); return [index, text].filter(Boolean).join(":"); }, // @todo should not this be a view? getColors() { const labelColor = self.parent.highlightcolor || (self.style || self.tag || defaultStyle).fillcolor; const background = Utils.Colors.convertToRGBA(labelColor ?? Constants.LABEL_BACKGROUND, LABEL_COLOR_ALPHA); const activeBackground = Utils.Colors.convertToRGBA( labelColor ?? Constants.LABEL_BACKGROUND, LABEL_COLOR_ALPHA_ACTIVE, ); // Extended/reduced parts of the region should be colored differently in a lighter color. // With extension it's simple, because it's the browser selection, so we just set a different color to it. // But to color the reduced part we use opacity of overlayed blocks — region hightlight and browser selection, // and multiplication of them should be the same as original activeBackground. // Region color should also be different from the original one, and for simplicity we use just one color. // So this color should have an opacity twice closer to 1 than the original one: 1 - (1 - alpha) * 2 const resizeBackground = Utils.Colors.convertToRGBA( labelColor ?? Constants.LABEL_BACKGROUND, 2 * LABEL_COLOR_ALPHA_ACTIVE - 1, ); const activeText = Utils.Colors.contrastColor(activeBackground); return { background, activeBackground, resizeBackground, activeText, }; }, find(span) { return self._spans && self._spans.indexOf(span) >= 0 ? self : undefined; }, /** * Add classes to all spans * @param {string[]} classNames */ addClass(classNames) { if (!classNames || !self._spans) { return; } const classList = [].concat(classNames); // convert any input to array self._spans.forEach((span) => span.classList.add(...classList)); }, /** * Remove classes from all spans * @param {string[]} classNames */ removeClass(classNames) { if (!classNames || !self._spans) { return; } const classList = [].concat(classNames); // convert any input to array self._spans.forEach((span) => span.classList.remove(...classList)); }, toggleHidden(e) { self.hidden = !self.hidden; if (self.hidden) { self.addClass("__hidden"); } else { self.removeClass("__hidden"); } e?.stopPropagation(); }, })); export const STATE_CLASS_MODS = { active: "__active", highlighted: "__highlighted", collapsed: "__collapsed", hidden: "__hidden", rightHandle: "__resize_right", leftHandle: "__resize_left", noLabel: HIGHLIGHT_NO_LABEL_CN, };