Commit a13024c2 authored by Robert Knight's avatar Robert Knight

Add `PreactContainer` utility and refactor adder/bucket-bar to use it

The various top-level Hypothesis UI elements in the annotator have a
common structure where:

 - There is a `<hypothesis-{name}>` container element

 - The container has a shadow root with the annotator stylesheet loaded
   into it

 - Preact is used to render the contents of the element, into the shadow
   root.

 - There is a controller which wraps the container element and has
   methods to read/write the displayed data. When the data is changed,
   the Preact tree is re-rendered.

Add a `PreactContainer` utility which implements common parts of this pattern,
and refactor two of the top-level UI controls (bucket bar and adder) to use it.
parent 46b84d21
import { render } from 'preact';
import { isTouchDevice } from '../shared/user-agent'; import { isTouchDevice } from '../shared/user-agent';
import type { Destroyable } from '../types/annotator'; import type { Destroyable } from '../types/annotator';
import AdderToolbar from './components/AdderToolbar'; import AdderToolbar from './components/AdderToolbar';
import type { Command } from './components/AdderToolbar'; import type { Command } from './components/AdderToolbar';
import { createShadowRoot } from './util/shadow-root'; import { PreactContainer } from './util/preact-container';
export enum ArrowDirection { export enum ArrowDirection {
DOWN = 1, DOWN = 1,
...@@ -64,8 +62,7 @@ type AdderOptions = { ...@@ -64,8 +62,7 @@ type AdderOptions = {
* component which actually renders the toolbar. * component which actually renders the toolbar.
*/ */
export class Adder implements Destroyable { export class Adder implements Destroyable {
private _outerContainer: HTMLElement; private _container: PreactContainer;
private _shadowRoot: ShadowRoot;
private _view: Window; private _view: Window;
private _isVisible: boolean; private _isVisible: boolean;
private _arrowDirection: 'up' | 'down'; private _arrowDirection: 'up' | 'down';
...@@ -85,18 +82,6 @@ export class Adder implements Destroyable { ...@@ -85,18 +82,6 @@ export class Adder implements Destroyable {
* event handlers. * event handlers.
*/ */
constructor(element: HTMLElement, options: AdderOptions) { constructor(element: HTMLElement, options: AdderOptions) {
this._outerContainer = document.createElement('hypothesis-adder');
element.appendChild(this._outerContainer);
this._shadowRoot = createShadowRoot(this._outerContainer);
// Set initial style
Object.assign(this._outerContainer.style, {
// take position out of layout flow initially
position: 'absolute',
top: 0,
left: 0,
});
this._view = element.ownerDocument.defaultView!; this._view = element.ownerDocument.defaultView!;
this._isVisible = false; this._isVisible = false;
this._arrowDirection = 'up'; this._arrowDirection = 'up';
...@@ -106,7 +91,16 @@ export class Adder implements Destroyable { ...@@ -106,7 +91,16 @@ export class Adder implements Destroyable {
this._onHighlight = options.onHighlight; this._onHighlight = options.onHighlight;
this._onShowAnnotations = options.onShowAnnotations; this._onShowAnnotations = options.onShowAnnotations;
this._render(); this._container = new PreactContainer('adder', () => this._render());
element.appendChild(this._container.element);
// Take position out of layout flow initially
Object.assign(this._container.element.style, {
position: 'absolute',
top: 0,
left: 0,
});
this._container.render();
} }
get annotationsForSelection() { get annotationsForSelection() {
...@@ -122,24 +116,24 @@ export class Adder implements Destroyable { ...@@ -122,24 +116,24 @@ export class Adder implements Destroyable {
*/ */
set annotationsForSelection(ids) { set annotationsForSelection(ids) {
this._annotationsForSelection = ids; this._annotationsForSelection = ids;
this._render(); this._container.render();
} }
/** Hide the adder */ /** Hide the adder */
hide() { hide() {
this._isVisible = false; this._isVisible = false;
this._render(); this._container.render();
// Reposition the outerContainer because it affects the responsiveness of host page
// Reposition the container because it affects the responsiveness of host page
// https://github.com/hypothesis/client/issues/3193 // https://github.com/hypothesis/client/issues/3193
Object.assign(this._outerContainer.style, { Object.assign(this._container.element.style, {
top: 0, top: 0,
left: 0, left: 0,
}); });
} }
destroy() { destroy() {
render(null, this._shadowRoot); // First, unload the Preact component this._container.destroy();
this._outerContainer.remove();
} }
/** /**
...@@ -160,18 +154,19 @@ export class Adder implements Destroyable { ...@@ -160,18 +154,19 @@ export class Adder implements Destroyable {
this._isVisible = true; this._isVisible = true;
this._arrowDirection = arrowDirection === ArrowDirection.UP ? 'up' : 'down'; this._arrowDirection = arrowDirection === ArrowDirection.UP ? 'up' : 'down';
this._container.render();
}
this._render(); private _firstChild(): Element {
return this._container.element.shadowRoot!.firstChild as Element;
} }
private _width(): number { private _width(): number {
const firstChild = this._shadowRoot.firstChild as Element; return this._firstChild().getBoundingClientRect().width;
return firstChild.getBoundingClientRect().width;
} }
private _height(): number { private _height(): number {
const firstChild = this._shadowRoot.firstChild as Element; return this._firstChild().getBoundingClientRect().height;
return firstChild.getBoundingClientRect().height;
} }
/** /**
...@@ -310,12 +305,14 @@ export class Adder implements Destroyable { ...@@ -310,12 +305,14 @@ export class Adder implements Destroyable {
// Typically, the adder is a child of the `<body>` and the NPA is the root // Typically, the adder is a child of the `<body>` and the NPA is the root
// `<html>` element. However, page styling may make the `<body>` positioned. // `<html>` element. However, page styling may make the `<body>` positioned.
// See https://github.com/hypothesis/client/issues/487. // See https://github.com/hypothesis/client/issues/487.
const positionedAncestor = nearestPositionedAncestor(this._outerContainer); const positionedAncestor = nearestPositionedAncestor(
this._container.element,
);
const parentRect = positionedAncestor.getBoundingClientRect(); const parentRect = positionedAncestor.getBoundingClientRect();
const zIndex = this._findZindex(left, top); const zIndex = this._findZindex(left, top);
Object.assign(this._outerContainer.style, { Object.assign(this._container.element.style, {
left: toPx(left - parentRect.left), left: toPx(left - parentRect.left),
top: toPx(top - parentRect.top), top: toPx(top - parentRect.top),
zIndex, zIndex,
...@@ -342,14 +339,13 @@ export class Adder implements Destroyable { ...@@ -342,14 +339,13 @@ export class Adder implements Destroyable {
} }
}; };
render( return (
<AdderToolbar <AdderToolbar
isVisible={this._isVisible} isVisible={this._isVisible}
arrowDirection={this._arrowDirection} arrowDirection={this._arrowDirection}
onCommand={handleCommand} onCommand={handleCommand}
annotationCount={this.annotationsForSelection.length} annotationCount={this.annotationsForSelection.length}
/>, />
this._shadowRoot,
); );
} }
} }
import { render } from 'preact';
import type { AnchorPosition, Destroyable } from '../types/annotator'; import type { AnchorPosition, Destroyable } from '../types/annotator';
import Buckets from './components/Buckets'; import Buckets from './components/Buckets';
import { computeBuckets } from './util/buckets'; import { computeBuckets } from './util/buckets';
import { createShadowRoot } from './util/shadow-root'; import { PreactContainer } from './util/preact-container';
export type BucketBarOptions = { export type BucketBarOptions = {
onFocusAnnotations: (tags: string[]) => void; onFocusAnnotations: (tags: string[]) => void;
...@@ -18,7 +16,8 @@ export type BucketBarOptions = { ...@@ -18,7 +16,8 @@ export type BucketBarOptions = {
* rendered elsewhere for certain content viewers. * rendered elsewhere for certain content viewers.
*/ */
export class BucketBar implements Destroyable { export class BucketBar implements Destroyable {
private _bucketsContainer: HTMLElement; private _container: PreactContainer;
private _positions: AnchorPosition[];
private _onFocusAnnotations: BucketBarOptions['onFocusAnnotations']; private _onFocusAnnotations: BucketBarOptions['onFocusAnnotations'];
private _onScrollToAnnotation: BucketBarOptions['onScrollToAnnotation']; private _onScrollToAnnotation: BucketBarOptions['onScrollToAnnotation'];
private _onSelectAnnotations: BucketBarOptions['onSelectAnnotations']; private _onSelectAnnotations: BucketBarOptions['onSelectAnnotations'];
...@@ -31,8 +30,9 @@ export class BucketBar implements Destroyable { ...@@ -31,8 +30,9 @@ export class BucketBar implements Destroyable {
onSelectAnnotations, onSelectAnnotations,
}: BucketBarOptions, }: BucketBarOptions,
) { ) {
this._bucketsContainer = document.createElement('hypothesis-bucket-bar'); this._positions = [];
Object.assign(this._bucketsContainer.style, { this._container = new PreactContainer('bucket-bar', () => this._render());
Object.assign(this._container.element.style, {
display: 'block', display: 'block',
flexGrow: '1', flexGrow: '1',
...@@ -43,25 +43,27 @@ export class BucketBar implements Destroyable { ...@@ -43,25 +43,27 @@ export class BucketBar implements Destroyable {
width: '100%', width: '100%',
}); });
createShadowRoot(this._bucketsContainer); container.appendChild(this._container.element);
container.appendChild(this._bucketsContainer);
this._onFocusAnnotations = onFocusAnnotations; this._onFocusAnnotations = onFocusAnnotations;
this._onScrollToAnnotation = onScrollToAnnotation; this._onScrollToAnnotation = onScrollToAnnotation;
this._onSelectAnnotations = onSelectAnnotations; this._onSelectAnnotations = onSelectAnnotations;
// Immediately render the bucket bar this._container.render();
this.update([]);
} }
destroy() { destroy() {
render(null, this._bucketsContainer); this._container.destroy();
this._bucketsContainer.remove();
} }
/** Update the set of anchors from which buckets are generated. */
update(positions: AnchorPosition[]) { update(positions: AnchorPosition[]) {
const buckets = computeBuckets(positions, this._bucketsContainer); this._positions = positions;
render( this._container.render();
}
private _render() {
const buckets = computeBuckets(this._positions, this._container.element);
return (
<Buckets <Buckets
above={buckets.above} above={buckets.above}
below={buckets.below} below={buckets.below}
...@@ -71,8 +73,7 @@ export class BucketBar implements Destroyable { ...@@ -71,8 +73,7 @@ export class BucketBar implements Destroyable {
onSelectAnnotations={(tags, toogle) => onSelectAnnotations={(tags, toogle) =>
this._onSelectAnnotations(tags, toogle) this._onSelectAnnotations(tags, toogle)
} }
/>, />
this._bucketsContainer.shadowRoot!,
); );
} }
} }
...@@ -57,7 +57,7 @@ describe('Adder', () => { ...@@ -57,7 +57,7 @@ describe('Adder', () => {
} }
function getContent() { function getContent() {
return adder._shadowRoot; return adder._container.element.shadowRoot;
} }
function adderRect() { function adderRect() {
...@@ -255,7 +255,7 @@ describe('Adder', () => { ...@@ -255,7 +255,7 @@ describe('Adder', () => {
describe('adder Z index', () => { describe('adder Z index', () => {
function getAdderZIndex(left, top) { function getAdderZIndex(left, top) {
adder._showAt(left, top); adder._showAt(left, top);
return parseInt(adder._outerContainer.style.zIndex); return parseInt(adder._container.element.style.zIndex);
} }
it('returns hard coded value if `document.elementsFromPoint` is not available', () => { it('returns hard coded value if `document.elementsFromPoint` is not available', () => {
......
...@@ -51,7 +51,7 @@ describe('BucketBar', () => { ...@@ -51,7 +51,7 @@ describe('BucketBar', () => {
const bucketBar = createBucketBar(); const bucketBar = createBucketBar();
assert.calledWith(fakeComputeBuckets, []); assert.calledWith(fakeComputeBuckets, []);
assert.ok( assert.ok(
bucketBar._bucketsContainer.shadowRoot.querySelector('.FakeBuckets'), bucketBar._container.element.shadowRoot.querySelector('.FakeBuckets'),
); );
}); });
......
import type { JSX } from 'preact';
import { render } from 'preact';
import type { Destroyable } from '../../types/annotator';
import { createShadowRoot } from './shadow-root';
/**
* Manages the root `<hypothesis-*>` container for a top-level Hypothesis UI
* element.
*
* This implements common functionality for these elements, such as:
*
* - Creating the `<hypothesis-{name}>` element with a shadow root, and loading
* stylesheets into it.
* - Re-rendering the Preact component tree when {@link PreactContainer.render} is called.
* - Unmounting the component and removing the container element when
* {@link PreactContainer.destroy} is called
*/
export class PreactContainer implements Destroyable {
private _element: HTMLElement;
private _shadowRoot: ShadowRoot;
private _render: () => JSX.Element;
/**
* Create a new `<hypothesis-{name}>` container element.
*
* After constructing the container, {@link PreactContainer.render} should be
* called to perform the initial render.
*
* @param name - Suffix for the element
* @param render - Callback that renders the root JSX element for this container
*/
constructor(name: string, render: () => JSX.Element) {
const tag = `hypothesis-${name}`;
this._element = document.createElement(tag);
this._shadowRoot = createShadowRoot(this._element);
this._render = render;
}
/** Unmount the Preact component and remove the container element from the DOM. */
destroy() {
render(null, this._shadowRoot);
this._element.remove();
}
/** Return a reference to the container element. */
get element(): HTMLElement {
return this._element;
}
/** Re-render the root Preact component. */
render() {
render(this._render(), this._shadowRoot);
}
}
import { useLayoutEffect } from 'preact/hooks';
import { PreactContainer } from '../preact-container';
describe('PreactContainer', () => {
let rootContainer;
let unmounted;
function Widget({ label }) {
// Use a layout effect here so it runs synchronously on unmount.
useLayoutEffect(() => {
return () => {
unmounted = true;
};
}, []);
return <button>{label}</button>;
}
beforeEach(() => {
rootContainer = document.createElement('div');
unmounted = false;
});
afterEach(() => {
rootContainer.remove();
});
it('should create container and render element', () => {
let label = 'foo';
const container = new PreactContainer('widget', () => (
<Widget label={label} />
));
container.render();
assert.equal(container.element.localName, 'hypothesis-widget');
const button = container.element.shadowRoot.querySelector('button');
assert.ok(button);
assert.equal(button.textContent, 'foo');
label = 'bar';
container.render();
assert.equal(button.textContent, 'bar');
});
it('should unmount and remove element when `destroy` is called', () => {
let label = 'foo';
const container = new PreactContainer('widget', () => (
<Widget label={label} />
));
rootContainer.append(container);
container.render();
container.destroy();
assert.equal(rootContainer.children.length, 0);
assert.isTrue(unmounted);
});
});
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