diff --git a/src/SerializedHighlight.ts b/src/SerializedHighlight.ts index 63d12eb..fd6abed 100644 --- a/src/SerializedHighlight.ts +++ b/src/SerializedHighlight.ts @@ -1,9 +1,10 @@ -import {camelCase, snakeCase} from 'change-case'; -import {Highlight as ApiHighlight, NewHighlight as NewApiHighlight, styleIsColor } from './api'; -import Highlight, {IHighlightData} from './Highlight'; +import { camelCase, snakeCase } from 'change-case'; +import { Highlight as ApiHighlight, NewHighlight as NewApiHighlight, styleIsColor } from './api'; +import { getContentPath } from './contentPath'; +import Highlight, { IHighlightData } from './Highlight'; import Highlighter from './Highlighter'; -import {getDeserializer, IDeserializer, ISerializationData} from './serializationStrategies'; -import {serialize as defaultSerializer} from './serializationStrategies/XpathRangeSelector'; +import { getDeserializer, IDeserializer, ISerializationData } from './serializationStrategies'; +import { serialize as defaultSerializer } from './serializationStrategies/XpathRangeSelector'; const mapKeys = (transform: (key: string) => string, obj: {[key: string]: any}) => Object.keys(obj).reduce((result, key) => ({ ...result, [transform(key)]: obj[key], @@ -83,6 +84,10 @@ export default class SerializedHighlight { const prevHighlight = highlighter.getHighlightBefore(highlight); const nextHighlight = highlighter.getHighlightAfter(highlight); + let contentPath: number[] | undefined; + + contentPath = getContentPath({ referenceElementId, ...serializationData }, highlighter); + if (!style) { throw new Error('a style is requred to create an api payload'); } @@ -94,12 +99,12 @@ export default class SerializedHighlight { anchor: referenceElementId, annotation, color: style, + contentPath, highlightedContent: content, id, locationStrategies: [mapKeys(snakeCase, serializationData)], nextHighlightId: nextHighlight && nextHighlight.id, prevHighlightId: prevHighlight && prevHighlight.id, - }; } diff --git a/src/contentPath.test.ts b/src/contentPath.test.ts new file mode 100644 index 0000000..ffe724c --- /dev/null +++ b/src/contentPath.test.ts @@ -0,0 +1,90 @@ +import { getContentPath } from './contentPath'; +import Highlighter from './Highlighter'; +import { adjacentTextSections } from './injectHighlightWrappers.spec.data'; +import { IData as TextPositionData } from './serializationStrategies/TextPositionSelector'; +import { IData as XpathRangeData } from './serializationStrategies/XpathRangeSelector'; + +describe('getContentPath', () => { + describe('with XpathRangeSelector type', () => { + it('creates a path of indexes', () => { + document.body.innerHTML = `
${adjacentTextSections}
`; + + const container = document.getElementById('page')!; + const highlighter = new Highlighter(container, { formatMessage: jest.fn() }); + + const result = getContentPath( + { + referenceElementId: 'test-container', + // tslint:disable-next-line quotemark + startContainer: "./*[name()='section'][1]/*[name()='div'][1]/*[name()='p'][1]/text()[1]", + startOffset: 4, + type: 'XpathRangeSelector', + } as XpathRangeData, + highlighter + ); + + expect(result).toEqual([3, 1, 1, 0, 4]); + }); + + it('handles adjacent text highlights', () => { + document.body.innerHTML = `
+
+

+ A shortcut called FOIL is sometimes used to find the product of two binomials. +

+
+
`; + + const container = document.getElementById('test-container')!; + const highlighter = new Highlighter(container, { formatMessage: jest.fn() }); + + const range: any = { + collapse: false, + commonAncestorContainer: container, + endContainer: './text()[1]', + endOffset: 31, + setEndAfter: jest.fn(), + setStartBefore: jest.fn(), + startContainer: './text()[1]', + startOffset: 27, + }; + + const result = getContentPath( + { + referenceElementId: 'test-p', + startContainer: range.startContainer, + startOffset: range.startOffset, + type: 'XpathRangeSelector', + } as XpathRangeData, + highlighter + ); + + expect(result).toEqual([0, 27]); + }); + }); + + describe('with TextPositionSelector type', () => { + it('returns a simplified path and offset', () => { + document.body.innerHTML = `
+
+ A shortcut called FOIL is sometimes used to find the product of two binomials. +
+
`; + + const container = document.getElementById('page')!; + const highlighter = new Highlighter(container, { formatMessage: jest.fn() }); + + const result = getContentPath( + { + end: 11, + referenceElementId: 'test-container', + start: 3, + type: 'TextPositionSelector', + } as TextPositionData, + highlighter + ); + + expect(result).toEqual([0, 3]); + }); + }); +}); diff --git a/src/contentPath.ts b/src/contentPath.ts new file mode 100644 index 0000000..7741bd4 --- /dev/null +++ b/src/contentPath.ts @@ -0,0 +1,58 @@ +import Highlighter from './Highlighter'; +import { IData as TextPositionData } from './serializationStrategies/TextPositionSelector'; +import { IData as XpathData } from './serializationStrategies/XpathRangeSelector'; +import { getFirstByXPath, isText, isTextHighlightOrScreenReaderNode } from './serializationStrategies/XpathRangeSelector/xpath'; + +type IData = XpathData | TextPositionData; + +export function getContentPath(serializationData: IData, highlighter: Highlighter) { + if (serializationData.type === 'TextPositionSelector') { + return [0, serializationData.start]; + } + + const referenceElement = highlighter.getReferenceElement(serializationData.referenceElementId); + if (!referenceElement) { return; } + + const element = getFirstByXPath( + serializationData.startContainer, + serializationData.startOffset, + referenceElement + ); + if (!element[0]) { return; } + + const nodePath = getNodePath(element[0], referenceElement); + nodePath.push(serializationData.startOffset); + + return nodePath; +} + +function getNodePath(element: HTMLElement, container: HTMLElement): number[] { + let currentNode: HTMLElement | null = element; + const nodePath: number[] = []; + + // Go up the stack, capturing the index of each node to create a path + while (currentNode !== container) { + if (currentNode && currentNode.parentNode) { + let filteredNodes = Array.from(currentNode.parentNode.childNodes).filter((n) => !isTextHighlightOrScreenReaderNode(n)); + + filteredNodes = filteredNodes.filter((node, i) => { + if (node === currentNode) { + // Always include the node with the content to get the index + return true; + } + + const nextNode = filteredNodes[i + 1]; + const isCollapsible = (nextNode && isText(nextNode) && isText(node)); + + // Remove adjacent text nodes + return !isCollapsible; + }); + + const index = filteredNodes.indexOf(currentNode); + nodePath.unshift(index); + currentNode = currentNode.parentElement; + } + } + + return nodePath.length > 0 ? nodePath : [0]; +} diff --git a/src/serializationStrategies/XpathRangeSelector/xpath.ts b/src/serializationStrategies/XpathRangeSelector/xpath.ts index ec794fb..5d8a50e 100644 --- a/src/serializationStrategies/XpathRangeSelector/xpath.ts +++ b/src/serializationStrategies/XpathRangeSelector/xpath.ts @@ -9,9 +9,9 @@ const findNonTextChild = (node: Node) => Array.prototype.find.call(node.childNod const isHighlight = (node: Nullable): node is HTMLElement => !!node && (node as Element).getAttribute && (node as Element).getAttribute(DATA_ATTR) !== null; const isHighlightOrScreenReaderNode = (node: Nullable) => isHighlight(node) || isScreenReaderNode(node); const isTextHighlight = (node: Nullable): node is HTMLElement => isHighlight(node) && !findNonTextChild(node); -const isTextHighlightOrScreenReaderNode = (node: Nullable): node is HTMLElement => (isHighlight(node) || isScreenReaderNode(node)) && !findNonTextChild(node); -const isText = (node: Nullable): node is Text => !!node && node.nodeType === 3; -const isTextOrTextHighlight = (node: Nullable): node is Text | HTMLElement => isText(node) || isTextHighlight(node); +export const isTextHighlightOrScreenReaderNode = (node: Nullable): node is HTMLElement => (isHighlight(node) || isScreenReaderNode(node)) && !findNonTextChild(node); +export const isText = (node: Nullable): node is Text => !!node && node.nodeType === 3; +const isTextOrTextHighlight = (node: Nullable): node is Text | HTMLElement => isText(node) || isTextHighlight(node); const isTextOrTextHighlightOrScreenReaderNode = (node: Nullable) => isText(node) || isTextHighlightOrScreenReaderNode(node) || isScreenReaderNode(node); const isElement = (node: Node): node is HTMLElement => node && node.nodeType === 1; const isElementNotHighlight = (node: Node) => isElement(node) && !isHighlight(node); @@ -87,7 +87,7 @@ const floatThroughText = (element: Node, offset: number, container: Node): [Node } }; -const resolveToNextElementOffsetIfPossible = (element: Node, offset: number) => { +const resolveToNextElementOffsetIfPossible = (element: Node, offset: number): [Node, number] => { if (isTextOrTextHighlightOrScreenReaderNode(element) && element.parentNode && offset === getMaxOffset(element) && (!element.nextSibling || !isHighlightOrScreenReaderNode(element.nextSibling))) { return [element.parentNode, nodeIndex(element.parentNode.childNodes, element) + 1]; } @@ -95,7 +95,7 @@ const resolveToNextElementOffsetIfPossible = (element: Node, offset: number) => return [element, offset]; }; -const resolveToPreviousElementOffsetIfPossible = (element: Node, offset: number) => { +const resolveToPreviousElementOffsetIfPossible = (element: Node, offset: number): [Node, number] => { if (isTextOrTextHighlightOrScreenReaderNode(element) && element.parentNode && offset === 0 && (!element.previousSibling || !isHighlightOrScreenReaderNode(element.previousSibling))) { return [element.parentNode, nodeIndex(element.parentNode.childNodes, element)]; @@ -234,7 +234,7 @@ function followPart(node: Node, part: string) { const findFirst = (nodeList: NodeList, predicate: (node: Node) => boolean) => Array.prototype.find.call(nodeList, (node: Node) => predicate(node)); const findFirstAfter = (nodeList: NodeList, afterThis: Node, predicate: (node: Node) => boolean) => findFirst( - Array.prototype.slice.call(nodeList, Array.prototype.indexOf.call(nodeList, afterThis) + 1), + Array.prototype.slice.call(nodeList, Array.prototype.indexOf.call(nodeList, afterThis) + 1) as unknown as NodeList, predicate ); diff --git a/yarn.lock b/yarn.lock index f9835e5..996f33f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5201,9 +5201,9 @@ typescript@^2.4.1: integrity sha512-Gr4p6nFNaoufRIY4NMdpQRNmgxVIGMs4Fcu/ujdYk3nAZqk7supzBE9idmvfZIlH/Cuj//dvi+019qEue9lV0w== typescript@^3.1.1: - version "3.1.1" - resolved "https://registry.yarnpkg.com/typescript/-/typescript-3.1.1.tgz#3362ba9dd1e482ebb2355b02dfe8bcd19a2c7c96" - integrity sha512-Veu0w4dTc/9wlWNf2jeRInNodKlcdLgemvPsrNpfu5Pq39sgfFjvIIgTsvUHCoLBnMhPoUA+tFxsXjU6VexVRQ== + version "3.9.10" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-3.9.10.tgz#70f3910ac7a51ed6bef79da7800690b19bf778b8" + integrity sha512-w6fIxVE/H1PkLKcCPsFqKE7Kv7QUwhU8qQY2MueZXWx5cPZdwFupLgKK3vntcK98BtNHZtAF4LA/yl2a7k8R6Q== uglify-js@^3.1.4: version "3.13.5"