Commit 529a0f8e authored by Robert Knight's avatar Robert Knight

Add `MediaTimeSelector` selectors and associated anchoring logic

Add a `MediaTimeSelector` selector that represents the time range in a piece of
media which an annotation refers to, expressed as seconds since the start of the
media.
parent aa4a8285
import type { import type {
MediaTimeSelector,
RangeSelector, RangeSelector,
Selector, Selector,
TextPositionSelector, TextPositionSelector,
TextQuoteSelector, TextQuoteSelector,
} from '../../types/api'; } from '../../types/api';
import { RangeAnchor, TextPositionAnchor, TextQuoteAnchor } from './types'; import {
MediaTimeAnchor,
RangeAnchor,
TextPositionAnchor,
TextQuoteAnchor,
} from './types';
type Options = { type Options = {
hint?: number; hint?: number;
}; };
async function querySelector( async function querySelector(
anchor: RangeAnchor | TextPositionAnchor | TextQuoteAnchor, anchor: MediaTimeAnchor | RangeAnchor | TextPositionAnchor | TextQuoteAnchor,
options: Options options: Options
) { ) {
return anchor.toRange(options); return anchor.toRange(options);
...@@ -32,6 +38,7 @@ export function anchor( ...@@ -32,6 +38,7 @@ export function anchor(
selectors: Selector[], selectors: Selector[],
options: Options = {} options: Options = {}
) { ) {
let mediaTime: MediaTimeSelector | null = null;
let position: TextPositionSelector | null = null; let position: TextPositionSelector | null = null;
let quote: TextQuoteSelector | null = null; let quote: TextQuoteSelector | null = null;
let range: RangeSelector | null = null; let range: RangeSelector | null = null;
...@@ -49,6 +56,9 @@ export function anchor( ...@@ -49,6 +56,9 @@ export function anchor(
case 'RangeSelector': case 'RangeSelector':
range = selector; range = selector;
break; break;
case 'MediaTimeSelector':
mediaTime = selector;
break;
} }
} }
...@@ -92,16 +102,30 @@ export function anchor( ...@@ -92,16 +102,30 @@ export function anchor(
}); });
} }
if (mediaTime) {
const mediaTime_ = mediaTime;
promise = promise.catch(() =>
MediaTimeAnchor.fromSelector(root, mediaTime_).toRange()
);
}
return promise; return promise;
} }
export function describe(root: Element, range: Range) { export function describe(root: Element, range: Range) {
const types = [RangeAnchor, TextPositionAnchor, TextQuoteAnchor]; const types = [
MediaTimeAnchor,
RangeAnchor,
TextPositionAnchor,
TextQuoteAnchor,
];
const result = []; const result = [];
for (const type of types) { for (const type of types) {
try { try {
const anchor = type.fromRange(root, range); const anchor = type.fromRange(root, range);
if (anchor) {
result.push(anchor.toSelector()); result.push(anchor.toSelector());
}
} catch (error) { } catch (error) {
// If resolving some anchor fails, we just want to skip it silently // If resolving some anchor fails, we just want to skip it silently
} }
......
...@@ -371,6 +371,30 @@ describe('HTML anchoring', () => { ...@@ -371,6 +371,30 @@ describe('HTML anchoring', () => {
}); });
}); });
it('anchors "MediaTimeSelector" selectors', async () => {
const container = document.createElement('div');
container.innerHTML = `<p data-time-start="0" data-time-end="10">One</p>
<p data-time-start="10" data-time-end="20">Two</p>`;
const range = new Range();
range.setStart(container.querySelector('p').firstChild, 0);
range.setEnd(container.querySelector('p:nth-of-type(2)').firstChild, 3);
const selectors = html.describe(container, range);
const mediaTimeSelector = selectors.find(
s => s.type === 'MediaTimeSelector'
);
assert.ok(mediaTimeSelector);
assert.deepEqual(mediaTimeSelector, {
type: 'MediaTimeSelector',
start: 0,
end: 20,
});
const anchored = await html.anchor(container, [mediaTimeSelector]);
assert.equal(anchored.toString(), 'One\nTwo');
});
describe('When anchoring fails', () => { describe('When anchoring fails', () => {
const validQuoteSelector = { const validQuoteSelector = {
type: 'TextQuoteSelector', type: 'TextQuoteSelector',
......
import { render } from 'preact';
import { TextRange } from '../text-range'; import { TextRange } from '../text-range';
import { import {
MediaTimeAnchor,
RangeAnchor, RangeAnchor,
TextPositionAnchor, TextPositionAnchor,
TextQuoteAnchor, TextQuoteAnchor,
...@@ -449,4 +452,140 @@ describe('annotator/anchoring/types', () => { ...@@ -449,4 +452,140 @@ describe('annotator/anchoring/types', () => {
}); });
}); });
}); });
describe('MediaTimeAnchor', () => {
function createTranscript() {
const container = document.createElement('div');
render(
<article>
<p data-time-start="0" data-time-end="2.2">
First segment.
</p>
<p data-time-start="2.2" data-time-end="5.6">
Second segment.
</p>
<p data-time-start="5.6" data-time-end="10.02">
Third segment.
</p>
<p data-time-start="invalid" data-time-end="12">
Invalid one
</p>
<p data-time-start="12" data-time-end="invalid">
Invalid two
</p>
</article>,
container
);
return container;
}
function createRange(
container,
startPara,
startOffset,
endPara,
endOffset
) {
const range = new Range();
range.setStart(
container.querySelector(`p:nth-of-type(${startPara + 1})`).firstChild,
startOffset
);
range.setEnd(
container.querySelector(`p:nth-of-type(${endPara + 1})`).firstChild,
endOffset
);
return range;
}
describe('#fromRange', () => {
it('can convert a range to a selector', () => {
const container = createTranscript();
const range = createRange(container, 0, 6, 1, 5);
const anchor = MediaTimeAnchor.fromRange(container, range);
assert.isNotNull(anchor);
const selector = anchor.toSelector();
assert.deepEqual(selector, {
type: 'MediaTimeSelector',
start: 0,
end: 5.6,
});
});
function replaceAttr(elem, attr, val) {
if (val === null) {
elem.removeAttribute(attr);
} else {
elem.setAttribute(attr, val);
}
}
[null, '', 'abc', '-2'].forEach(startAttr => {
it('returns `null` if start time is missing or invalid', () => {
const container = createTranscript();
const range = createRange(container, 0, 6, 1, 5);
replaceAttr(
range.startContainer.parentElement,
'data-time-start',
startAttr
);
const anchor = MediaTimeAnchor.fromRange(container, range);
assert.isNull(anchor);
});
});
[null, '', 'abc', '-2'].forEach(endAttr => {
it('returns `null` if end time is missing or invalid', () => {
const container = createTranscript();
const range = createRange(container, 0, 6, 1, 5);
replaceAttr(
range.endContainer.parentElement,
'data-time-end',
endAttr
);
const anchor = MediaTimeAnchor.fromRange(container, range);
assert.isNull(anchor);
});
});
});
describe('#toRange', () => {
it('can convert a selector to a range', () => {
const container = createTranscript();
const selector = {
type: 'MediaTimeSelector',
start: 0,
end: 5.6,
};
const range = MediaTimeAnchor.fromSelector(
container,
selector
).toRange();
assert.equal(range.toString(), 'First segment.Second segment.');
});
[
{ start: 20, end: 22, error: 'Start segment not found' },
{ start: 0, end: -1, error: 'End segment not found' },
].forEach(({ start, end, error }) => {
it('throws error if elements with matching time ranges are not found', () => {
const container = createTranscript();
const selector = {
type: 'MediaTimeSelector',
start,
end,
};
assert.throws(() => {
MediaTimeAnchor.fromSelector(container, selector).toRange();
}, error);
});
});
});
});
}); });
...@@ -8,6 +8,7 @@ ...@@ -8,6 +8,7 @@
* libraries. * libraries.
*/ */
import type { import type {
MediaTimeSelector,
RangeSelector, RangeSelector,
TextPositionSelector, TextPositionSelector,
TextQuoteSelector, TextQuoteSelector,
...@@ -228,3 +229,149 @@ export class TextQuoteAnchor { ...@@ -228,3 +229,149 @@ export class TextQuoteAnchor {
return new TextPositionAnchor(this.root, match.start, match.end); return new TextPositionAnchor(this.root, match.start, match.end);
} }
} }
/**
* Parse a string containing a time offset in seconds, since the start of some
* media, into a float.
*/
function parseMediaTime(timeStr: string): number | null {
const val = parseFloat(timeStr);
if (!Number.isFinite(val) || val < 0) {
return null;
}
return val;
}
/** Implementation of {@link Array.prototype.findLastIndex} */
function findLastIndex<T>(ary: T[], pred: (val: T) => boolean): number {
for (let i = ary.length - 1; i >= 0; i--) {
if (pred(ary[i])) {
return i;
}
}
return -1;
}
function closestElement(node: Node) {
return node instanceof Element ? node : node.parentElement;
}
/**
* Get the media time range associated with an element or pair of elements,
* from `data-time-{start, end}` attributes on them.
*/
function getMediaTimeRange(
start: Element | undefined | null,
end: Element | undefined | null = start
): [number, number] | null {
const startTime = parseMediaTime(
start?.getAttribute('data-time-start') ?? ''
);
const endTime = parseMediaTime(end?.getAttribute('data-time-end') ?? '');
if (
typeof startTime !== 'number' ||
typeof endTime !== 'number' ||
endTime < startTime
) {
return null;
}
return [startTime, endTime];
}
export class MediaTimeAnchor {
root: Element;
/** Offset from start of media in seconds. */
start: number;
/** Offset from end of media in seconds. */
end: number;
constructor(root: Element, start: number, end: number) {
this.root = root;
this.start = start;
this.end = end;
}
/**
* Return a {@link MediaTimeAnchor} that represents a range, or `null` if
* no time range information is present on elements in the range.
*/
static fromRange(root: Element, range: Range): MediaTimeAnchor | null {
const start = closestElement(range.startContainer)?.closest(
'[data-time-start]'
);
const end = closestElement(range.endContainer)?.closest('[data-time-end]');
const timeRange = getMediaTimeRange(start, end);
if (!timeRange) {
return null;
}
const [startTime, endTime] = timeRange;
return new MediaTimeAnchor(root, startTime, endTime);
}
/**
* Convert this anchor to a DOM range.
*
* This returned range will start from the beginning of the element whose
* associated time range includes `start` and continue to the end of the
* element whose associated time range includes `end`.
*/
toRange(): Range {
// Find the segments that span the start and end times of this anchor.
// This is inefficient since we re-find all segments for each annotation
// that is anchored. Changing this will involve revising the anchoring
// API however.
type Segment = { element: Element; start: number; end: number };
const segments = [...this.root.querySelectorAll('[data-time-start]')]
.map(element => {
const timeRange = getMediaTimeRange(element);
if (!timeRange) {
return null;
}
const [start, end] = timeRange;
return { element, start, end };
})
.filter(s => s !== null) as Segment[];
segments.sort((a, b) => a.start - b.start);
const startIdx = findLastIndex(
segments,
s => s.start <= this.start && s.end >= this.start
);
if (startIdx === -1) {
throw new Error('Start segment not found');
}
const endIdx =
startIdx +
segments
.slice(startIdx)
.findIndex(s => s.start <= this.end && s.end >= this.end);
if (endIdx === -1) {
throw new Error('End segment not found');
}
const range = new Range();
range.setStart(segments[startIdx].element, 0);
const endEl = segments[endIdx].element;
range.setEnd(endEl, endEl.childNodes.length);
return range;
}
static fromSelector(
root: Element,
selector: MediaTimeSelector
): MediaTimeAnchor {
const { start, end } = selector;
return new MediaTimeAnchor(root, start, end);
}
toSelector(): MediaTimeSelector {
return {
type: 'MediaTimeSelector',
start: this.start,
end: this.end,
};
}
}
...@@ -37,6 +37,19 @@ export type IndexResponse = { ...@@ -37,6 +37,19 @@ export type IndexResponse = {
*/ */
export type LinksResponse = Record<string, string>; export type LinksResponse = Record<string, string>;
/**
* Selector which indicates the time range within a video or audio file that
* an annotation refers to.
*/
export type MediaTimeSelector = {
type: 'MediaTimeSelector';
/** Offset from start of media in seconds. */
start: number;
/** Offset from start of media in seconds. */
end: number;
};
/** /**
* Selector which identifies a document region using the selected text plus * Selector which identifies a document region using the selected text plus
* the surrounding context. * the surrounding context.
...@@ -127,6 +140,7 @@ export type Selector = ...@@ -127,6 +140,7 @@ export type Selector =
| TextPositionSelector | TextPositionSelector
| RangeSelector | RangeSelector
| EPUBContentSelector | EPUBContentSelector
| MediaTimeSelector
| PageSelector; | PageSelector;
/** /**
......
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