Commit 1635f714 authored by Robert Knight's avatar Robert Knight

Convert Guest class to TypeScript syntax

parent 445bfef7
......@@ -25,37 +25,33 @@ import { findClosestOffscreenAnchor } from './util/buckets';
import { frameFillsAncestor } from './util/frame';
import { normalizeURI } from './util/url';
/**
* @typedef {import('../types/annotator').AnnotationData} AnnotationData
* @typedef {import('../types/annotator').Annotator} Annotator
* @typedef {import('../types/annotator').Anchor} Anchor
* @typedef {import('../types/annotator').ContentInfoConfig} ContentInfoConfig
* @typedef {import('../types/annotator').Destroyable} Destroyable
* @typedef {import('../types/annotator').SidebarLayout} SidebarLayout
* @typedef {import('../types/api').Target} Target
* @typedef {import('../types/port-rpc-events').HostToGuestEvent} HostToGuestEvent
* @typedef {import('../types/port-rpc-events').GuestToHostEvent} GuestToHostEvent
* @typedef {import('../types/port-rpc-events').GuestToSidebarEvent} GuestToSidebarEvent
* @typedef {import('../types/port-rpc-events').SidebarToGuestEvent} SidebarToGuestEvent
*/
/**
* HTML element created by the highlighter with an associated annotation.
*
* @typedef {HTMLElement & { _annotation?: AnnotationData }} AnnotationHighlight
*/
/**
* Return all the annotations tags associated with the selected text.
*
* @return {string[]}
*/
function annotationsForSelection() {
const selection = /** @type {Selection} */ (window.getSelection());
import type {
AnnotationData,
Annotator,
Anchor,
ContentInfoConfig,
Destroyable,
Integration,
SidebarLayout,
} from '../types/annotator';
import type { Target } from '../types/api';
import type {
HostToGuestEvent,
GuestToHostEvent,
GuestToSidebarEvent,
SidebarToGuestEvent,
} from '../types/port-rpc-events';
/** HTML element created by the highlighter with an associated annotation. */
type AnnotationHighlight = HTMLElement & { _annotation?: AnnotationData };
/** Return all the annotations tags associated with the selected text. */
function annotationsForSelection(): string[] {
const selection = window.getSelection()!;
const range = selection.getRangeAt(0);
const tags = rangeUtil.itemsForRange(
range,
node => /** @type {AnnotationHighlight} */ (node)._annotation?.$tag
node => (node as AnnotationHighlight)._annotation?.$tag
);
return tags;
}
......@@ -63,16 +59,13 @@ function annotationsForSelection() {
/**
* Return the annotation tags associated with any highlights that contain a given
* DOM node.
*
* @param {Node} node
* @return {string[]}
*/
function annotationsAt(node) {
function annotationsAt(node: Node): string[] {
const items = getHighlightsContainingNode(node)
.map(h => /** @type {AnnotationHighlight} */ (h)._annotation)
.map(h => (h as AnnotationHighlight)._annotation)
.filter(ann => ann !== undefined)
.map(ann => ann?.$tag);
return /** @type {string[]} */ (items);
return items as string[];
}
/**
......@@ -80,11 +73,8 @@ function annotationsAt(node) {
*
* This may fail if anchoring failed or if the document has been mutated since
* the anchor was created in a way that invalidates the anchor.
*
* @param {Anchor} anchor
* @return {Range|null}
*/
function resolveAnchor(anchor) {
function resolveAnchor(anchor: Anchor): Range | null {
if (!anchor.range) {
return null;
}
......@@ -101,14 +91,17 @@ function removeTextSelection() {
/**
* Subset of the Hypothesis client configuration that is used by {@link Guest}.
*
* @typedef GuestConfig
* @prop {string} [subFrameIdentifier] - An identifier used by this guest to
* identify the current frame when communicating with the sidebar. This is
* only set in non-host frames.
* @prop {ContentInfoConfig} [contentInfoBanner] - Configures a banner or other indicators
* showing where the content has come from.
*/
export type GuestConfig = {
/**
* An identifier used by this guest to identify the current frame when
* communicating with the sidebar. This is only set in non-host frames.
*/
subFrameIdentifier?: string;
/** Configures a banner or other indicators showing where the content has come from. */
contentInfoBanner?: ContentInfoConfig;
};
/**
* `Guest` is the central class of the annotator that handles anchoring (locating)
......@@ -126,27 +119,83 @@ function removeTextSelection() {
* class that shows the sidebar app and surrounding UI. The `Guest` instance in
* each frame connects to the sidebar and host frames as part of its
* initialization.
*/
export class Guest implements Annotator, Destroyable {
public element: HTMLElement;
/** Ranges of the current text selection. */
public selectedRanges: Range[];
/**
* The anchors generated by resolving annotation selectors to locations in the
* document. These are added by `anchor` and removed by `detach`.
*
* @implements {Annotator}
* @implements {Destroyable}
* There is one anchor per annotation `Target`, which typically means one
* anchor per annotation.
*/
export class Guest {
public anchors: Anchor[];
public features: FeatureFlags;
private _adder: Adder;
private _clusterToolbar?: HighlightClusterController;
private _hostFrame: Window;
private _highlightsVisible: boolean;
private _isAdderVisible: boolean;
private _informHostOnNextSelectionClear: boolean;
private _selectionObserver: SelectionObserver;
/**
* @param {HTMLElement} element -
* Tags of annotations that are currently anchored or being anchored in
* the guest.
*/
private _annotations: Set<string>;
private _frameIdentifier: string | null;
private _portFinder: PortFinder;
/**
* Integration that handles document-type specific functionality in the
* guest.
*/
private _integration: Integration;
/** Channel for host-guest communication. */
private _hostRPC: PortRPC<HostToGuestEvent, GuestToHostEvent>;
/** Channel for guest-sidebar communication. */
private _sidebarRPC: PortRPC<SidebarToGuestEvent, GuestToSidebarEvent>;
private _bucketBarClient: BucketBarClient;
private _sideBySideActive: boolean;
private _listeners: ListenerCollection;
/**
* Tags of currently hovered annotations. This is used to set the hovered
* state correctly for new highlights if the associated annotation is already
* hovered in the sidebar.
*/
private _hoveredAnnotations: Set<string>;
/**
* @param element -
* The root element in which the `Guest` instance should be able to anchor
* or create annotations. In an ordinary web page this typically `document.body`.
* @param {GuestConfig} [config]
* @param {Window} [hostFrame] -
* @param [config]
* @param [hostFrame] -
* Host frame which this guest is associated with. This is expected to be
* an ancestor of the guest frame. It may be same or cross origin.
*/
constructor(element, config = {}, hostFrame = window) {
constructor(
element: HTMLElement,
config: GuestConfig = {},
hostFrame: Window = window
) {
this.element = element;
this._hostFrame = hostFrame;
this._highlightsVisible = false;
this._isAdderVisible = false;
this._informHostOnNextSelectionClear = true;
/** @type {Range[]} - Ranges of the current text selection. */
this.selectedRanges = [];
this._adder = new Adder(this.element, {
......@@ -170,26 +219,11 @@ export class Guest {
}
});
/**
* The anchors generated by resolving annotation selectors to locations in the
* document. These are added by `anchor` and removed by `detach`.
*
* There is one anchor per annotation `Target`, which typically means one
* anchor per annotation.
*
* @type {Anchor[]}
*/
this.anchors = [];
/**
* Tags of annotations that are currently anchored or being anchored in
* the guest.
*/
this._annotations = /** @type {Set<string>} */ (new Set());
this._annotations = new Set();
// Set the frame identifier if it's available.
// The "top" guest instance will have this as null since it's in a top frame not a sub frame
/** @type {string|null} */
this._frameIdentifier = config.subFrameIdentifier || null;
this._portFinder = new PortFinder({
......@@ -199,10 +233,7 @@ export class Guest {
});
this.features = new FeatureFlags();
/**
* Integration that handles document-type specific functionality in the
* guest.
*/
this._integration = createIntegration(this);
this._integration.on('uriChanged', async () => {
const metadata = await this.getDocumentInfo();
......@@ -218,19 +249,9 @@ export class Guest {
});
}
/**
* Channel for host-guest communication.
*
* @type {PortRPC<HostToGuestEvent, GuestToHostEvent>}
*/
this._hostRPC = new PortRPC();
this._connectHost(hostFrame);
/**
* Channel for guest-sidebar communication.
*
* @type {PortRPC<SidebarToGuestEvent, GuestToSidebarEvent>}
*/
this._sidebarRPC = new PortRPC();
this._connectSidebar();
......@@ -245,13 +266,6 @@ export class Guest {
this._listeners = new ListenerCollection();
this._setupElementEvents();
/**
* Tags of currently hovered annotations. This is used to set the hovered
* state correctly for new highlights if the associated annotation is already
* hovered in the sidebar.
*
* @type {Set<string>}
*/
this._hoveredAnnotations = new Set();
}
......@@ -260,8 +274,7 @@ export class Guest {
_setupElementEvents() {
// Hide the sidebar in response to a document click or tap, so it doesn't obscure
// the document content.
/** @param {Element} element */
const maybeCloseSidebar = element => {
const maybeCloseSidebar = (element: Element) => {
if (this._sideBySideActive) {
// Don't hide the sidebar if event was disabled because the sidebar
// doesn't overlap the content.
......@@ -276,7 +289,7 @@ export class Guest {
this._listeners.add(this.element, 'mouseup', event => {
const { target, metaKey, ctrlKey } = event;
const tags = annotationsAt(/** @type {Element} */ (target));
const tags = annotationsAt(target as Element);
if (tags.length && this._highlightsVisible) {
const toggle = metaKey || ctrlKey;
this.selectAnnotations(tags, { toggle });
......@@ -284,17 +297,17 @@ export class Guest {
});
this._listeners.add(this.element, 'mousedown', ({ target }) => {
maybeCloseSidebar(/** @type {Element} */ (target));
maybeCloseSidebar(target as Element);
});
// Allow taps on the document to hide the sidebar as well as clicks.
// On iOS < 13 (2019), elements like h2 or div don't emit 'click' events.
this._listeners.add(this.element, 'touchstart', ({ target }) => {
maybeCloseSidebar(/** @type {Element} */ (target));
maybeCloseSidebar(target as Element);
});
this._listeners.add(this.element, 'mouseover', ({ target }) => {
const tags = annotationsAt(/** @type {Element} */ (target));
const tags = annotationsAt(target as Element);
if (tags.length && this._highlightsVisible) {
this._sidebarRPC.call('hoverAnnotations', tags);
}
......@@ -343,8 +356,7 @@ export class Guest {
}
}
/** @param {Window} hostFrame */
async _connectHost(hostFrame) {
async _connectHost(hostFrame: Window) {
this._hostRPC.on('clearSelection', () => {
if (selectedRange(document)) {
this._informHostOnNextSelectionClear = false;
......@@ -354,39 +366,25 @@ export class Guest {
this._hostRPC.on('createAnnotation', () => this.createAnnotation());
this._hostRPC.on(
'hoverAnnotations',
/** @param {string[]} tags */
tags => this._hoverAnnotations(tags)
this._hostRPC.on('hoverAnnotations', (tags: string[]) =>
this._hoverAnnotations(tags)
);
this._hostRPC.on(
'scrollToClosestOffScreenAnchor',
/**
* @param {string[]} tags
* @param {'down'|'up'} direction
*/
(tags, direction) => this._scrollToClosestOffScreenAnchor(tags, direction)
(tags: string[], direction: 'down' | 'up') =>
this._scrollToClosestOffScreenAnchor(tags, direction)
);
this._hostRPC.on(
'selectAnnotations',
/**
* @param {string[]} tags
* @param {boolean} toggle
*/
(tags, toggle) => this.selectAnnotations(tags, { toggle })
this._hostRPC.on('selectAnnotations', (tags: string[], toggle: boolean) =>
this.selectAnnotations(tags, { toggle })
);
this._hostRPC.on(
'sidebarLayoutChanged',
/** @param {SidebarLayout} sidebarLayout */
sidebarLayout => {
this._hostRPC.on('sidebarLayoutChanged', (sidebarLayout: SidebarLayout) => {
if (frameFillsAncestor(window, hostFrame)) {
this.fitSideBySide(sidebarLayout);
}
}
);
});
// Discover and connect to the host frame. All RPC events must be
// registered before creating the channel.
......@@ -397,22 +395,16 @@ export class Guest {
async _connectSidebar() {
this._sidebarRPC.on(
'featureFlagsUpdated',
/** @param {Record<string, boolean>} flags */ flags =>
this.features.update(flags)
(flags: Record<string, boolean>) => this.features.update(flags)
);
// Handlers for events sent when user hovers or clicks on an annotation card
// in the sidebar.
this._sidebarRPC.on(
'hoverAnnotations',
/** @param {string[]} tags */
tags => this._hoverAnnotations(tags)
this._sidebarRPC.on('hoverAnnotations', (tags: string[]) =>
this._hoverAnnotations(tags)
);
this._sidebarRPC.on(
'scrollToAnnotation',
/** @param {string} tag */
tag => {
this._sidebarRPC.on('scrollToAnnotation', (tag: string) => {
const anchor = this.anchors.find(a => a.annotation.$tag === tag);
if (!anchor?.highlights) {
return;
......@@ -435,33 +427,21 @@ export class Guest {
if (defaultNotPrevented) {
this._integration.scrollToAnchor(anchor);
}
}
);
});
// Handler for controls on the sidebar
this._sidebarRPC.on(
'setHighlightsVisible',
/** @param {boolean} showHighlights */ showHighlights => {
this._sidebarRPC.on('setHighlightsVisible', (showHighlights: boolean) => {
this.setHighlightsVisible(showHighlights, false /* notifyHost */);
}
);
});
this._sidebarRPC.on(
'deleteAnnotation',
/** @param {string} tag */
tag => this.detach(tag)
);
this._sidebarRPC.on('deleteAnnotation', (tag: string) => this.detach(tag));
this._sidebarRPC.on(
'loadAnnotations',
/** @param {AnnotationData[]} annotations */
annotations => annotations.forEach(annotation => this.anchor(annotation))
this._sidebarRPC.on('loadAnnotations', (annotations: AnnotationData[]) =>
annotations.forEach(annotation => this.anchor(annotation))
);
this._sidebarRPC.on(
'showContentInfo',
/** @param {ContentInfoConfig} info */
info => this._integration.showContentInfo?.(info)
this._sidebarRPC.on('showContentInfo', (info: ContentInfoConfig) =>
this._integration.showContentInfo?.(info)
);
// Connect to sidebar and send document info/URIs to it.
......@@ -501,18 +481,12 @@ export class Guest {
*
* Any existing anchors associated with `annotation` will be removed before
* re-anchoring the annotation.
*
* @param {AnnotationData} annotation
* @return {Promise<Anchor[]>}
*/
async anchor(annotation) {
async anchor(annotation: AnnotationData): Promise<Anchor[]> {
/**
* Resolve an annotation's selectors to a concrete range.
*
* @param {Target} target
* @return {Promise<Anchor>}
*/
const locate = async target => {
const locate = async (target: Target): Promise<Anchor> => {
// Only annotations with an associated quote can currently be anchored.
// This is because the quote is used to verify anchoring with other selector
// types.
......@@ -523,8 +497,7 @@ export class Guest {
return { annotation, target };
}
/** @type {Anchor} */
let anchor;
let anchor: Anchor;
try {
const range = await this._integration.anchor(
this.element,
......@@ -544,21 +517,17 @@ export class Guest {
/**
* Highlight the text range that `anchor` refers to.
*
* @param {Anchor} anchor
*/
const highlight = anchor => {
const highlight = (anchor: Anchor) => {
const range = resolveAnchor(anchor);
if (!range) {
return;
}
const highlights = /** @type {AnnotationHighlight[]} */ (
highlightRange(
const highlights = highlightRange(
range,
classnames('hypothesis-highlight', anchor.annotation?.$cluster)
)
);
) as AnnotationHighlight[];
highlights.forEach(h => {
h._annotation = anchor.annotation;
});
......@@ -585,7 +554,7 @@ export class Guest {
return [];
}
for (let anchor of anchors) {
for (const anchor of anchors) {
highlight(anchor);
}
......@@ -607,16 +576,14 @@ export class Guest {
/**
* Remove the anchors and associated highlights for an annotation from the document.
*
* @param {string} tag
* @param {boolean} [notify] - For internal use. Whether to inform the host
* @param [notify] - For internal use. Whether to inform the host
* frame about the removal of an anchor.
*/
detach(tag, notify = true) {
detach(tag: string, notify = true) {
this._annotations.delete(tag);
/** @type {Anchor[]} */
const anchors = [];
for (let anchor of this.anchors) {
const anchors = [] as Anchor[];
for (const anchor of this.anchors) {
if (anchor.annotation.$tag !== tag) {
anchors.push(anchor);
} else if (anchor.highlights) {
......@@ -626,11 +593,7 @@ export class Guest {
this._updateAnchors(anchors, notify);
}
/**
* @param {Anchor[]} anchors
* @param {boolean} notify
*/
_updateAnchors(anchors, notify) {
_updateAnchors(anchors: Anchor[], notify: boolean) {
this.anchors = anchors;
if (notify) {
this._bucketBarClient.update(this.anchors);
......@@ -641,13 +604,13 @@ export class Guest {
* Create a new annotation that is associated with the selected region of
* the current document.
*
* @param {object} options
* @param {boolean} [options.highlight] - If true, the new annotation has
* @param options
* @param [options.highlight] - If true, the new annotation has
* the `$highlight` flag set, causing it to be saved immediately without
* prompting for a comment.
* @return {Promise<AnnotationData>} - The new annotation
* @return The new annotation
*/
async createAnnotation({ highlight = false } = {}) {
async createAnnotation({ highlight = false } = {}): Promise<AnnotationData> {
const ranges = this.selectedRanges;
this.selectedRanges = [];
......@@ -664,8 +627,7 @@ export class Guest {
selector: selectors,
}));
/** @type {AnnotationData} */
const annotation = {
const annotation: AnnotationData = {
uri: info.uri,
document: info.metadata,
target,
......@@ -687,14 +649,12 @@ export class Guest {
/**
* Indicate in the sidebar that certain annotations are focused (ie. the
* associated document region(s) is hovered).
*
* @param {string[]} tags
*/
_hoverAnnotations(tags) {
_hoverAnnotations(tags: string[]) {
this._hoveredAnnotations.clear();
tags.forEach(tag => this._hoveredAnnotations.add(tag));
for (let anchor of this.anchors) {
for (const anchor of this.anchors) {
if (anchor.highlights) {
const toggle = tags.includes(anchor.annotation.$tag);
setHighlightsFocused(anchor.highlights, toggle);
......@@ -706,11 +666,8 @@ export class Guest {
/**
* Scroll to the closest off screen anchor.
*
* @param {string[]} tags
* @param {'down'|'up'} direction
*/
_scrollToClosestOffScreenAnchor(tags, direction) {
_scrollToClosestOffScreenAnchor(tags: string[], direction: 'down' | 'up') {
const anchors = this.anchors.filter(({ annotation }) =>
tags.includes(annotation.$tag)
);
......@@ -722,16 +679,14 @@ export class Guest {
/**
* Show or hide the adder toolbar when the selection changes.
*
* @param {Range} range
*/
_onSelection(range) {
_onSelection(range: Range) {
if (!this._integration.canAnnotate(range)) {
this._onClearSelection();
return;
}
const selection = /** @type {Selection} */ (document.getSelection());
const selection = document.getSelection()!;
const isBackwards = rangeUtil.isSelectionBackwards(selection);
const focusRect = rangeUtil.selectionFocusRect(selection);
if (!focusRect) {
......@@ -765,15 +720,18 @@ export class Guest {
* and opens the sidebar. Optionally it can also transfer keyboard focus to
* the annotation card for the first selected annotation.
*
* @param {string[]} tags
* @param {object} options
* @param {boolean} [options.toggle] - Toggle whether the annotations are
* @param tags
* @param options
* @param [options.toggle] - Toggle whether the annotations are
* selected, as opposed to just selecting them
* @param {boolean} [options.focusInSidebar] - Whether to transfer keyboard
* @param [options.focusInSidebar] - Whether to transfer keyboard
* focus to the card for the first annotation in the selection. This
* option has no effect if {@link toggle} is true.
*/
selectAnnotations(tags, { toggle = false, focusInSidebar = false } = {}) {
selectAnnotations(
tags: string[],
{ toggle = false, focusInSidebar = false } = {}
) {
if (toggle) {
this._sidebarRPC.call('toggleAnnotationSelection', tags);
} else {
......@@ -785,12 +743,12 @@ export class Guest {
/**
* Set whether highlights are visible in the document or not.
*
* @param {boolean} visible
* @param {boolean} notifyHost - Whether to notify the host frame about this
* @param visible
* @param notifyHost - Whether to notify the host frame about this
* change. This should be true unless the request to change highlight
* visibility is coming from the host frame.
*/
setHighlightsVisible(visible, notifyHost = true) {
setHighlightsVisible(visible: boolean, notifyHost = true) {
setHighlightsVisible(this.element, visible);
this._highlightsVisible = visible;
if (notifyHost) {
......@@ -805,9 +763,9 @@ export class Guest {
/**
* Attempt to fit the document content alongside the sidebar.
*
* @param {SidebarLayout} sidebarLayout
* @param sidebarLayout
*/
fitSideBySide(sidebarLayout) {
fitSideBySide(sidebarLayout: SidebarLayout) {
this._sideBySideActive = this._integration.fitSideBySide(sidebarLayout);
}
......@@ -825,19 +783,15 @@ export class Guest {
/**
* Return the tags of annotations that are currently displayed in a hovered
* state.
*
* @return {Set<string>}
*/
get hoveredAnnotationTags() {
get hoveredAnnotationTags(): Set<string> {
return this._hoveredAnnotations;
}
/**
* Handle a potential shortcut trigger.
*
* @param {KeyboardEvent} event
*/
_handleShortcut(event) {
_handleShortcut(event: KeyboardEvent) {
if (matchShortcut(event, 'Ctrl+Shift+H')) {
this.setHighlightsVisible(!this._highlightsVisible);
}
......
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