@readium/navigator-html-injectables
Version:
An embeddable solution for connecting frames of HTML publications with a Readium Navigator
460 lines (407 loc) • 17.2 kB
text/typescript
import { Locator } from "@readium/shared";
import { Comms } from "../comms/comms";
import { Module } from "./Module";
import { rangeFromLocator } from "../helpers/locator";
import { ModuleName } from "./ModuleLibrary";
import { Rect, getClientRectsNoOverlap } from "../helpers/rect";
import { getProperty } from "../helpers/css";
import { ReadiumWindow } from "../helpers/dom";
import { isDarkColor } from "../helpers/color";
export enum Width {
Wrap = "wrap", // Smallest width fitting the CSS border box.
Viewport = "viewport", // Fills the whole viewport.
Bounds = "bounds", // Fills the anchor page, useful for dual page.
Page = "page", // Fills the whole viewport.
}
export enum Layout {
Boxes = "boxes", // One HTML element for each CSS border box (e.g. line of text).
Bounds = "bounds", // A single HTML element covering the smallest region containing all CSS border boxes.
}
// TODO improve
export interface Style {
tint: string; // CSS color string
layout: Layout; // Determines the number of created HTML elements and their position relative to the matching DOM range.
width: Width; // Indicates how the width of each created HTML element expands in the viewport.
}
export interface Decoration {
id: string; // Unique ID of the decoration. It must be unique in the group the decoration is applied to.
locator: Locator; // Location in the publication where the decoration will be rendered.
style: Style; // Declares the look and feel of the decoration.
// TODO extras (userInfo)
}
export interface DecoratorRequest {
group: string; // Unique ID of the decoration group
action: "add" | "remove" | "clear" | "update"; // Command
decoration: Decoration | undefined;
}
interface DecorationItem {
id: string;
decoration: Decoration;
range: Range;
clickableElements: HTMLElement[] | undefined;
container: HTMLElement | undefined;
}
const canNativeHighlight = () => ("Highlight" in window);
const cannotNativeHighlight = ["IMG", "IMAGE", "AUDIO", "VIDEO", "SVG"];
class DecorationGroup {
public readonly items: DecorationItem[] = [];
private lastItemId = 0;
private container: HTMLDivElement | undefined = undefined;
private activateable = false;
public readonly experimentalHighlights: boolean = false;
private readonly notTextFlag: Map<string, boolean> | undefined;
/**
* Creates a DecorationGroup object
* @param id Unique HTML ID-adhering name of the group
* @param name Human-readable name of the group
*/
constructor(
private readonly wnd: ReadiumWindow,
private readonly comms: Comms,
private readonly id: string,
private readonly name: string
) {
if (canNativeHighlight()) {
this.experimentalHighlights = true;
this.notTextFlag = new Map<string, boolean>();
}
}
get activeable() {
return this.activateable;
}
set activeable(value: boolean) {
this.activateable = value;
}
/**
* Adds a new decoration to the group.
* @param decoration Decoration to add
*/
add(decoration: Decoration) {
const id = `${this.id}-${this.lastItemId++}`;
const range = rangeFromLocator(this.wnd.document, decoration.locator);
if (!range) {
this.comms.log("Can't locate DOM range for decoration", decoration);
return;
}
const ancestor = range.commonAncestorContainer as HTMLElement;
if(ancestor.nodeType !== Node.TEXT_NODE && this.experimentalHighlights) {
if(cannotNativeHighlight.includes(ancestor.nodeName.toUpperCase())) {
// The common ancestor is an element that definitely cannot be highlighted
this.notTextFlag?.set(id, true);
}
if(ancestor.querySelector(cannotNativeHighlight.join(", ").toLowerCase())) {
// Contains elements that definitely cannot be highlighted as children
this.notTextFlag?.set(id, true);
}
if((ancestor.textContent?.trim() || "").length === 0) {
// No text to be highlighted
this.notTextFlag?.set(id, true);
}
}
const item = {
decoration,
id,
range,
} as DecorationItem;
this.items.push(item);
this.layout(item);
this.renderLayout([item]);
}
/**
* Removes the decoration with given ID from the group.
* @param identifier ID of item to remove
*/
remove(identifier: string) {
const index = this.items.findIndex(i => i.decoration.id === identifier);
if (index < 0) return;
const item = this.items[index];
this.items.splice(index, 1);
item.clickableElements = undefined;
if (item.container) {
item.container.remove();
item.container = undefined;
}
if (this.experimentalHighlights && !this.notTextFlag?.has(item.id)) {
// Remove highlight from ranges
const mm = ((this.wnd as any).CSS.highlights as Map<string, unknown>).get(this.id) as Set<Range>;
mm?.delete(item.range);
}
this.notTextFlag?.delete(item.id);
}
/**
* Notifies that the given decoration was modified and needs to be updated.
* @param decoration Decoration to update
*/
update(decoration: Decoration) {
this.remove(decoration.id);
this.add(decoration);
}
/**
* Removes all decorations from this group.
*/
clear() {
this.clearContainer();
this.items.length = 0;
this.notTextFlag?.clear();
}
/**
* Recreates the decoration elements.
* To be called after reflowing the resource, for example.
*/
requestLayout() {
this.wnd.cancelAnimationFrame(this.currentRender);
this.clearContainer();
this.items.forEach(i => this.layout(i));
this.renderLayout(this.items);
}
private experimentalLayout(item: DecorationItem) {
const [stylesheet, highlighter]: [HTMLStyleElement, any] = this.requireContainer(true) as [HTMLStyleElement, unknown];
highlighter.add(item.range);
// TODO add caching layer ("vdom") to this so we aren't completely replacing the CSS every time
stylesheet.innerHTML = `
::highlight(${this.id}) {
color: black;
background-color: ${item.decoration?.style?.tint ?? "yellow"};
}`;
}
/**
* Layouts a single DecorationItem.
* @param item
*/
private layout(item: DecorationItem) {
if (this.experimentalHighlights && !this.notTextFlag?.has(item.id)) {
// Highlight using the new Highlight Web API!
return this.experimentalLayout(item);
}
// this.comms.log("Environment does not support experimental Web Highlight API, can't layout decorations");
const itemContainer = this.wnd.document.createElement("div");
itemContainer.setAttribute("id", item.id);
// itemContainer.dataset.style = item.decoration.style; // TODO style
itemContainer.style.setProperty("pointer-events", "none");
const viewportWidth = this.wnd.innerWidth;
const columnCount = parseInt(
getComputedStyle(this.wnd.document.documentElement).getPropertyValue(
"column-count"
)
);
const pageWidth = viewportWidth / (columnCount || 1);
const scrollingElement = this.wnd.document.scrollingElement!;
const xOffset = scrollingElement.scrollLeft;
const yOffset = scrollingElement.scrollTop;
const positionElement = (element: HTMLElement, rect: Rect, boundingRect: DOMRect) => {
element.style.position = "absolute";
// TODO change to switch
if (item.decoration?.style?.width === Width.Viewport) {
element.style.width = `${viewportWidth}px`;
element.style.height = `${rect.height}px`;
let left = Math.floor(rect.left / viewportWidth) * viewportWidth;
element.style.left = `${left + xOffset}px`;
element.style.top = `${rect.top + yOffset}px`;
} else if (item.decoration?.style?.width === Width.Bounds) {
element.style.width = `${boundingRect.width}px`;
element.style.height = `${rect.height}px`;
element.style.left = `${boundingRect.left + xOffset}px`;
element.style.top = `${rect.top + yOffset}px`;
} else if (item.decoration?.style?.width === Width.Page) {
element.style.width = `${pageWidth}px`;
element.style.height = `${rect.height}px`;
let left = Math.floor(rect.left / pageWidth) * pageWidth;
element.style.left = `${left + xOffset}px`;
element.style.top = `${rect.top + yOffset}px`;
} else {
// Fall back to "wrap"
element.style.width = `${rect.width}px`;
element.style.height = `${rect.height}px`;
element.style.left = `${rect.left + xOffset}px`;
element.style.top = `${rect.top + yOffset}px`;
}
}
const boundingRect = item.range.getBoundingClientRect();
let template = this.wnd.document.createElement("template");
// template.innerHTML = item.decoration.element.trim();
// TODO more styles logic
const isDarkMode = getProperty(this.wnd, "--USER__appearance") === "readium-night-on" ||
isDarkColor(getProperty(this.wnd, "--USER__backgroundColor"));
template.innerHTML = `
<div
class="r2-highlight-0"
style="${[
`background-color: ${item.decoration?.style?.tint ?? "yellow"} !important`,
//"opacity: 0.3 !important",
`mix-blend-mode: ${isDarkMode ? "exclusion" : "multiply"} !important`,
"opacity: 1 !important",
"box-sizing: border-box !important"
].join("; ")}"
>
</div>
`.trim();
const elementTemplate = template.content.firstElementChild!;
if(item.decoration?.style?.layout === Layout.Bounds) {
const bounds = elementTemplate.cloneNode(true) as HTMLDivElement;
bounds.style.setProperty("pointer-events", "none");
positionElement(bounds, boundingRect, boundingRect);
itemContainer.append(bounds);
} else {
// Fall back to "boxes" value for layout
let clientRects = getClientRectsNoOverlap(
item.range,
true // doNotMergeHorizontallyAlignedRects
);
clientRects = clientRects.sort((r1, r2) => {
if (r1.top < r2.top) {
return -1;
} else if (r1.top > r2.top) {
return 1;
} else {
return 0;
}
});
for (let clientRect of clientRects) {
const line = elementTemplate.cloneNode(true) as HTMLDivElement;
line.style.setProperty("pointer-events", "none");
positionElement(line, clientRect, boundingRect);
itemContainer.append(line);
}
}
item.container = itemContainer;
item.clickableElements = Array.from(
itemContainer.querySelectorAll("[data-activable='1']")
);
if(!item.clickableElements.length) {
item.clickableElements = Array.from(itemContainer.children) as HTMLElement[];
}
}
private currentRender = 0;
private renderLayout(items: DecorationItem[]) {
this.wnd.cancelAnimationFrame(this.currentRender);
this.currentRender = this.wnd.requestAnimationFrame(() => {
items = items.filter(i => !this.experimentalHighlights || !!this.notTextFlag?.has(i.id));
if(!items || items.length === 0) return;
const groupContainer = this.requireContainer() as HTMLDivElement;
groupContainer.append(...items.map(i => i.container).filter(c => !!c) as Node[])
});
}
/**
* Returns the group container element, after making sure it exists.
* @returns Group's container
*/
private requireContainer(experimental=false): [HTMLStyleElement, any] | HTMLDivElement {
if (experimental) {
// Setup <style> for highlights
let d: HTMLStyleElement;
if (this.wnd.document.getElementById(`${this.id}-style`)) {
d = this.wnd.document.getElementById(`${this.id}-style`) as HTMLStyleElement;
} else {
d = this.wnd.document.createElement("style");
d.dataset.readium = "true";
d.id = `${this.id}-style`;
this.wnd.document.head.appendChild(d);
}
// Setup CSS.highlights
let h: unknown;
if (((this.wnd as any).CSS.highlights as Map<string, unknown>).has(this.id)) {
h = ((this.wnd as any).CSS.highlights as Map<string, unknown>).get(this.id)
} else {
h = new (this.wnd as any).Highlight();
((this.wnd as any).CSS.highlights as Map<string, unknown>).set(this.id, h);
}
return [d, h];
}
if (!this.container) {
this.container = this.wnd.document.createElement("div");
this.container.setAttribute("id", this.id);
this.container.dataset.group = this.name;
this.container.dataset.readium = "true";
this.container.style.setProperty("pointer-events", "none");
this.container.style.display = "contents";
this.wnd.document.body.append(this.container);
}
return this.container;
}
/**
* Removes the group container.
*/
private clearContainer() {
if (this.experimentalHighlights) {
((this.wnd as any).CSS.highlights as Map<string, unknown>).delete(this.id);
}
if (this.container) {
this.container.remove();
this.container = undefined;
}
}
}
export class Decorator extends Module {
static readonly moduleName: ModuleName = "decorator";
private resizeObserver!: ResizeObserver;
private wnd!: ReadiumWindow;
/*private readonly lastSize = {
width: 0,
height: 0
};*/
private resizeFrame = 0;
private lastGroupId = 0;
private groups = new Map<string, DecorationGroup>();
private cleanup() {
// TODO cleanup all decorators
this.groups.forEach(g => g.clear());
this.groups.clear();
}
private handleResize() {
this.wnd.clearTimeout(this.resizeFrame);
this.resizeFrame = this.wnd.setTimeout(() => {
this.groups.forEach(g => {
if(!g.experimentalHighlights) g.requestLayout();
});
}, 50);
}
private readonly handleResizer = this.handleResize.bind(this);
mount(wnd: ReadiumWindow, comms: Comms): boolean {
this.wnd = wnd;
comms.register("decorate", Decorator.moduleName, (data, ack) => {
const req = data as DecoratorRequest;
if (req.decoration && req.decoration.locator) {
req.decoration.locator = Locator.deserialize(req.decoration.locator)!;
}
if (!this.groups.has(req.group)) {
this.groups.set(req.group, new DecorationGroup(
wnd,
comms,
`readium-decoration-${this.lastGroupId++}`,
req.group
));
}
const group = this.groups.get(req.group);
switch (req.action) {
case "add":
group?.add(req.decoration!);
break;
case "remove":
group?.remove(req.decoration!.id);
break;
case "clear":
group?.clear();
break;
case "update":
group?.update(req.decoration!);
break;
}
ack(true);
});
this.resizeObserver = new ResizeObserver(() => wnd.requestAnimationFrame(() => this.handleResize()));
this.resizeObserver.observe(wnd.document.body);
wnd.addEventListener("orientationchange", this.handleResizer);
wnd.addEventListener("resize", this.handleResizer);
comms.log("Decorator Mounted");
return true;
}
unmount(wnd: ReadiumWindow, comms: Comms): boolean {
wnd.removeEventListener("orientationchange", this.handleResizer);
wnd.removeEventListener("resize", this.handleResizer);
comms.unregisterAll(Decorator.moduleName);
this.resizeObserver.disconnect();
this.cleanup();
comms.log("Decorator Unmounted");
return true;
}
}