Commit 99073c0b authored by Lyza Danger Gardner's avatar Lyza Danger Gardner Committed by Lyza Gardner

Convert `highlighter` to TS

Prepare for some upcoming changes to this module.
parent b66168ff
...@@ -5,17 +5,20 @@ import { isNodeInRange } from './range-util'; ...@@ -5,17 +5,20 @@ import { isNodeInRange } from './range-util';
const SVG_NAMESPACE = 'http://www.w3.org/2000/svg'; const SVG_NAMESPACE = 'http://www.w3.org/2000/svg';
type HighlightProps = {
// Associated SVG rect drawn to represent this highlight (in PDFs)
svgHighlight?: SVGRectElement;
};
export type HighlightElement = HTMLElement & HighlightProps;
/** /**
* Return the canvas element underneath a highlight element in a PDF page's * Return the canvas element underneath a highlight element in a PDF page's
* text layer. * text layer.
* *
* Returns `null` if the highlight is not above a PDF canvas. * Returns `null` if the highlight is not above a PDF canvas.
*
* @param {HTMLElement} highlightEl -
* A `<hypothesis-highlight>` element in the page's text layer
* @return {HTMLCanvasElement|null}
*/ */
function getPDFCanvas(highlightEl) { function getPDFCanvas(highlightEl: HighlightElement): HTMLCanvasElement | null {
// This code assumes that PDF.js renders pages with a structure like: // This code assumes that PDF.js renders pages with a structure like:
// //
// <div class="page"> // <div class="page">
...@@ -40,7 +43,7 @@ function getPDFCanvas(highlightEl) { ...@@ -40,7 +43,7 @@ function getPDFCanvas(highlightEl) {
return null; return null;
} }
return /** @type {HTMLCanvasElement} */ (canvasEl); return canvasEl as HTMLCanvasElement;
} }
/** /**
...@@ -49,12 +52,15 @@ function getPDFCanvas(highlightEl) { ...@@ -49,12 +52,15 @@ function getPDFCanvas(highlightEl) {
* The created SVG elements are stored in the `svgHighlight` property of * The created SVG elements are stored in the `svgHighlight` property of
* each `HighlightElement`. * each `HighlightElement`.
* *
* @param {HighlightElement[]} highlightEls - * @param highlightEls -
* An element that wraps the highlighted text in the transparent text layer * An element that wraps the highlighted text in the transparent text layer
* above the PDF. * above the PDF.
* @param {string} [cssClass] - CSS class(es) to add to the SVG highlight elements * @param [cssClass] - CSS class(es) to add to the SVG highlight elements
*/ */
function drawHighlightsAbovePDFCanvas(highlightEls, cssClass) { function drawHighlightsAbovePDFCanvas(
highlightEls: HighlightElement[],
cssClass?: string
) {
if (highlightEls.length === 0) { if (highlightEls.length === 0) {
return; return;
} }
...@@ -66,10 +72,9 @@ function drawHighlightsAbovePDFCanvas(highlightEls, cssClass) { ...@@ -66,10 +72,9 @@ function drawHighlightsAbovePDFCanvas(highlightEls, cssClass) {
return; return;
} }
/** @type {SVGElement|null} */
let svgHighlightLayer = canvasEl.parentElement.querySelector( let svgHighlightLayer = canvasEl.parentElement.querySelector(
'.hypothesis-highlight-layer' '.hypothesis-highlight-layer'
); ) as SVGSVGElement | null;
if (!svgHighlightLayer) { if (!svgHighlightLayer) {
// Create SVG layer. This must be in the same stacking context as // Create SVG layer. This must be in the same stacking context as
...@@ -124,27 +129,13 @@ function drawHighlightsAbovePDFCanvas(highlightEls, cssClass) { ...@@ -124,27 +129,13 @@ function drawHighlightsAbovePDFCanvas(highlightEls, cssClass) {
svgHighlightLayer.append(...highlightRects); svgHighlightLayer.append(...highlightRects);
} }
/**
* Additional properties added to text highlight HTML elements.
*
* @typedef HighlightProps
* @prop {SVGElement} [svgHighlight]
*/
/**
* @typedef {HTMLElement & HighlightProps} HighlightElement
*/
/** /**
* Return text nodes which are entirely inside `range`. * Return text nodes which are entirely inside `range`.
* *
* If a range starts or ends part-way through a text node, the node is split * If a range starts or ends part-way through a text node, the node is split
* and the part inside the range is returned. * and the part inside the range is returned.
*
* @param {Range} range
* @return {Text[]}
*/ */
function wholeTextNodesInRange(range) { function wholeTextNodesInRange(range: Range): Text[] {
if (range.collapsed) { if (range.collapsed) {
// Exit early for an empty range to avoid an edge case that breaks the algorithm // Exit early for an empty range to avoid an edge case that breaks the algorithm
// below. Splitting a text node at the start of an empty range can leave the // below. Splitting a text node at the start of an empty range can leave the
...@@ -152,9 +143,8 @@ function wholeTextNodesInRange(range) { ...@@ -152,9 +143,8 @@ function wholeTextNodesInRange(range) {
return []; return [];
} }
/** @type {Node|null} */ let root = range.commonAncestorContainer as Node | null;
let root = range.commonAncestorContainer; if (root && root.nodeType !== Node.ELEMENT_NODE) {
if (root.nodeType !== Node.ELEMENT_NODE) {
// If the common ancestor is not an element, set it to the parent element to // If the common ancestor is not an element, set it to the parent element to
// ensure that the loop below visits any text nodes generated by splitting // ensure that the loop below visits any text nodes generated by splitting
// the common ancestor. // the common ancestor.
...@@ -169,9 +159,7 @@ function wholeTextNodesInRange(range) { ...@@ -169,9 +159,7 @@ function wholeTextNodesInRange(range) {
} }
const textNodes = []; const textNodes = [];
const nodeIter = /** @type {Document} */ ( const nodeIter = root!.ownerDocument!.createNodeIterator(
root.ownerDocument
).createNodeIterator(
root, root,
NodeFilter.SHOW_TEXT // Only return `Text` nodes. NodeFilter.SHOW_TEXT // Only return `Text` nodes.
); );
...@@ -180,7 +168,7 @@ function wholeTextNodesInRange(range) { ...@@ -180,7 +168,7 @@ function wholeTextNodesInRange(range) {
if (!isNodeInRange(range, node)) { if (!isNodeInRange(range, node)) {
continue; continue;
} }
let text = /** @type {Text} */ (node); const text = node as Text;
if (text === range.startContainer && range.startOffset > 0) { if (text === range.startContainer && range.startOffset > 0) {
// Split `text` where the range starts. The split will create a new `Text` // Split `text` where the range starts. The split will create a new `Text`
...@@ -204,11 +192,14 @@ function wholeTextNodesInRange(range) { ...@@ -204,11 +192,14 @@ function wholeTextNodesInRange(range) {
* Wraps the DOM Nodes within the provided range with a highlight * Wraps the DOM Nodes within the provided range with a highlight
* element of the specified class and returns the highlight Elements. * element of the specified class and returns the highlight Elements.
* *
* @param {Range} range - Range to be highlighted * @param range - Range to be highlighted
* @param {string} [cssClass] - CSS class(es) to add to the highlight elements * @param [cssClass] - CSS class(es) to add to the highlight elements
* @return {HighlightElement[]} - Elements wrapping text in `normedRange` to add a highlight effect * @return Elements wrapping text in `normedRange` to add a highlight effect
*/ */
export function highlightRange(range, cssClass) { export function highlightRange(
range: Range,
cssClass?: string
): HighlightElement[] {
const textNodes = wholeTextNodesInRange(range); const textNodes = wholeTextNodesInRange(range);
// Check if this range refers to a placeholder for not-yet-rendered content in // Check if this range refers to a placeholder for not-yet-rendered content in
...@@ -217,8 +208,8 @@ export function highlightRange(range, cssClass) { ...@@ -217,8 +208,8 @@ export function highlightRange(range, cssClass) {
// Group text nodes into spans of adjacent nodes. If a group of text nodes are // Group text nodes into spans of adjacent nodes. If a group of text nodes are
// adjacent, we only need to create one highlight element for the group. // adjacent, we only need to create one highlight element for the group.
let textNodeSpans = /** @type {Text[][]} */ ([]); let textNodeSpans: Text[][] = [];
let prevNode = /** @type {Node|null} */ (null); let prevNode: Node | null = null;
let currentSpan = null; let currentSpan = null;
textNodes.forEach(node => { textNodes.forEach(node => {
...@@ -241,16 +232,15 @@ export function highlightRange(range, cssClass) { ...@@ -241,16 +232,15 @@ export function highlightRange(range, cssClass) {
); );
// Wrap each text node span with a `<hypothesis-highlight>` element. // Wrap each text node span with a `<hypothesis-highlight>` element.
const highlights = /** @type {HighlightElement[]} */ ([]); const highlights: HighlightElement[] = [];
textNodeSpans.forEach(nodes => { textNodeSpans.forEach(nodes => {
// A custom element name is used here rather than `<span>` to reduce the // A custom element name is used here rather than `<span>` to reduce the
// likelihood of highlights being hidden by page styling. // likelihood of highlights being hidden by page styling.
/** @type {HighlightElement} */
const highlightEl = document.createElement('hypothesis-highlight'); const highlightEl = document.createElement('hypothesis-highlight');
highlightEl.className = classnames('hypothesis-highlight', cssClass); highlightEl.className = classnames('hypothesis-highlight', cssClass);
const parent = /** @type {Node} */ (nodes[0].parentNode); const parent = nodes[0].parentNode as ParentNode;
parent.replaceChild(highlightEl, nodes[0]); parent.replaceChild(highlightEl, nodes[0]);
nodes.forEach(node => highlightEl.appendChild(node)); nodes.forEach(node => highlightEl.appendChild(node));
...@@ -277,33 +267,26 @@ export function highlightRange(range, cssClass) { ...@@ -277,33 +267,26 @@ export function highlightRange(range, cssClass) {
* Replace a child `node` with `replacements`. * Replace a child `node` with `replacements`.
* *
* nb. This is like `ChildNode.replaceWith` but it works in older browsers. * nb. This is like `ChildNode.replaceWith` but it works in older browsers.
*
* @param {ChildNode} node
* @param {Node[]} replacements
*/ */
function replaceWith(node, replacements) { function replaceWith(node: ChildNode, replacements: Node[]) {
const parent = /** @type {Node} */ (node.parentNode); const parent = node.parentNode as ParentNode;
replacements.forEach(r => parent.insertBefore(r, node)); replacements.forEach(r => parent.insertBefore(r, node));
node.remove(); node.remove();
} }
/** /**
* Remove all highlights under a given root element. * Remove all highlights under a given root element.
*
* @param {HTMLElement} root
*/ */
export function removeAllHighlights(root) { export function removeAllHighlights(root: HTMLElement) {
const highlights = Array.from(root.querySelectorAll('hypothesis-highlight')); const highlights = Array.from(root.querySelectorAll('hypothesis-highlight'));
removeHighlights(/** @type {HighlightElement[]} */ (highlights)); removeHighlights(highlights as HighlightElement[]);
} }
/** /**
* Remove highlights from a range previously highlighted with `highlightRange`. * Remove highlights from a range previously highlighted with `highlightRange`.
*
* @param {HighlightElement[]} highlights - The highlight elements returned by `highlightRange`
*/ */
export function removeHighlights(highlights) { export function removeHighlights(highlights: HighlightElement[]) {
for (let h of highlights) { for (const h of highlights) {
if (h.parentNode) { if (h.parentNode) {
const children = Array.from(h.childNodes); const children = Array.from(h.childNodes);
replaceWith(h, children); replaceWith(h, children);
...@@ -321,11 +304,11 @@ export function removeHighlights(highlights) { ...@@ -321,11 +304,11 @@ export function removeHighlights(highlights) {
* A highlight can be displayed in a different ("focused") style to indicate * A highlight can be displayed in a different ("focused") style to indicate
* that it is current in some other context - for example the user has selected * that it is current in some other context - for example the user has selected
* the corresponding annotation in the sidebar. * the corresponding annotation in the sidebar.
*
* @param {HighlightElement[]} highlights
* @param {boolean} focused
*/ */
export function setHighlightsFocused(highlights, focused) { export function setHighlightsFocused(
highlights: HighlightElement[],
focused: boolean
) {
highlights.forEach(h => { highlights.forEach(h => {
// In PDFs the visible highlight is created by an SVG element, so the focused // In PDFs the visible highlight is created by an SVG element, so the focused
// effect is applied to that. In other documents the effect is applied to the // effect is applied to that. In other documents the effect is applied to the
...@@ -339,7 +322,7 @@ export function setHighlightsFocused(highlights, focused) { ...@@ -339,7 +322,7 @@ export function setHighlightsFocused(highlights, focused) {
// SVG elements are rendered in document order so to achieve this we need // SVG elements are rendered in document order so to achieve this we need
// to move the element to be the last child of its parent. // to move the element to be the last child of its parent.
if (focused) { if (focused) {
const parent = /** @type {SVGElement} */ (h.svgHighlight.parentNode); const parent = h.svgHighlight.parentNode as SVGElement;
parent.append(h.svgHighlight); parent.append(h.svgHighlight);
} }
} else { } else {
...@@ -350,48 +333,40 @@ export function setHighlightsFocused(highlights, focused) { ...@@ -350,48 +333,40 @@ export function setHighlightsFocused(highlights, focused) {
/** /**
* Set whether highlights under the given root element should be visible. * Set whether highlights under the given root element should be visible.
*
* @param {HTMLElement} root
* @param {boolean} visible
*/ */
export function setHighlightsVisible(root, visible) { export function setHighlightsVisible(root: HTMLElement, visible: boolean) {
const showHighlightsClass = 'hypothesis-highlights-always-on'; const showHighlightsClass = 'hypothesis-highlights-always-on';
root.classList.toggle(showHighlightsClass, visible); root.classList.toggle(showHighlightsClass, visible);
} }
/** /**
* Get the highlight elements that contain the given node. * Get the highlight elements that contain the given node.
*
* @param {Node} node
* @return {HighlightElement[]}
*/ */
export function getHighlightsContainingNode(node) { export function getHighlightsContainingNode(node: Node): HighlightElement[] {
let el = let el =
node.nodeType === Node.ELEMENT_NODE node.nodeType === Node.ELEMENT_NODE
? /** @type {Element} */ (node) ? (node as Element)
: node.parentElement; : node.parentElement;
const highlights = []; const highlights = [];
while (el) { while (el) {
if (el.classList.contains('hypothesis-highlight')) { if (el.classList.contains('hypothesis-highlight')) {
highlights.push(/** @type {HighlightElement} */ (el)); highlights.push(el);
} }
el = el.parentElement; el = el.parentElement;
} }
return highlights; return highlights as HighlightElement[];
} }
/** // Subset of `DOMRect` interface
* Subset of `DOMRect` interface. type Rect = {
* top: number;
* @typedef Rect left: number;
* @prop {number} top bottom: number;
* @prop {number} left right: number;
* @prop {number} bottom };
* @prop {number} right
*/
/** /**
* Get the bounding client rectangle of a collection in viewport coordinates. * Get the bounding client rectangle of a collection in viewport coordinates.
...@@ -399,15 +374,10 @@ export function getHighlightsContainingNode(node) { ...@@ -399,15 +374,10 @@ export function getHighlightsContainingNode(node) {
* could just use that. * could just use that.
* *
* [1] https://bugs.chromium.org/p/chromium/issues/detail?id=324437 * [1] https://bugs.chromium.org/p/chromium/issues/detail?id=324437
*
* @param {HTMLElement[]} collection
* @return {Rect}
*/ */
export function getBoundingClientRect(collection) { export function getBoundingClientRect(collection: HTMLElement[]): Rect {
// Reduce the client rectangles of the highlights to a bounding box // Reduce the client rectangles of the highlights to a bounding box
const rects = collection.map( const rects = collection.map(n => n.getBoundingClientRect() as Rect);
n => /** @type {Rect} */ (n.getBoundingClientRect())
);
return rects.reduce((acc, r) => ({ return rects.reduce((acc, r) => ({
top: Math.min(acc.top, r.top), top: Math.min(acc.top, r.top),
left: Math.min(acc.left, r.left), left: Math.min(acc.left, r.left),
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment