@ribajs/bs4
Version:
Bootstrap 4 module for Riba.js
324 lines (297 loc) • 10.1 kB
text/typescript
import { extend } from "@ribajs/utils";
import { Component, TemplateFunction, ScopeBase } from "@ribajs/core";
import { PopoverOptions } from "@ribajs/bs4";
import { hasChildNodesTrim } from "@ribajs/utils/src/dom.js";
import { TaggedImageTag as Tag } from "../../interfaces/index.js";
import template from "./bs4-tagged-image.component.html?raw";
import { debounce } from "@ribajs/utils/src/control.js";
interface Options {
popoverOptions: Partial<PopoverOptions>;
tagOptions: Partial<Tag>;
multiPopover?: boolean;
}
interface Scope extends ScopeBase {
debug: boolean;
options: Options;
tags: Tag[];
fillPopoverOptions: (
options: Partial<PopoverOptions>,
) => Partial<PopoverOptions>;
triggerOnFocus: (options: Partial<PopoverOptions>) => any;
onPopoverBound: EventListener;
onPopoverShown: EventListener;
onPopoverHidden: EventListener;
onClick: EventListener;
updateTagPositions: EventListener;
}
export class Bs4TaggedImageComponent extends Component {
/**
* ATTRIBUTES AND SCOPE
*/
public static tagName = "bs4-tagged-image";
protected autobind = true;
public _debug = false;
static get observedAttributes(): string[] {
return ["tags", "options", "debug"];
}
image?: HTMLImageElement;
public scope: Scope = {
debug: false,
tags: [],
options: {
popoverOptions: {}, // set container = this in constructor
multiPopover: false,
tagOptions: {},
},
fillPopoverOptions: (options: Partial<PopoverOptions>) => {
return {
...this.scope.options.popoverOptions,
...this.scope.options.tagOptions.popoverOptions,
...options,
};
},
triggerOnFocus: (options: Partial<PopoverOptions>) => {
return this.scope.fillPopoverOptions(options).trigger ? 0 : null;
},
onClick: this.onClick.bind(this),
onPopoverBound: this.onPopoverBound.bind(this),
onPopoverShown: this.onPopoverShown.bind(this),
onPopoverHidden: this.onPopoverHidden.bind(this),
updateTagPositions: debounce(this.updateTagPositions.bind(this)),
};
/**
* CONSTRUCTOR AND LIFECYCLE HANDLERS
*/
constructor() {
super();
this.scope.options.popoverOptions.container = this;
}
protected parsedAttributeChangedCallback(
attributeName: string,
oldValue: any,
newValue: any,
) {
if (attributeName === "options") {
// before the component is bound, we just want to extend the default options
if (this.bound) {
this.scope.options = newValue;
} else {
this.scope.options = extend({ deep: true }, oldValue, newValue);
}
const po = this.scope.options.popoverOptions;
if (po && typeof po.container === "string") {
po.container = document.querySelector(po.container) || undefined;
}
}
}
protected template(): ReturnType<TemplateFunction> {
if (hasChildNodesTrim(this)) {
this.parseChildTags();
}
return template;
}
protected async beforeBind() {
await super.beforeBind();
// Template has been loaded. So the <img> tag should be there now.
this.image = this.querySelector("img") as HTMLImageElement;
this.addEventListeners();
this.initTags();
}
protected addEventListeners() {
const img = this.image as HTMLImageElement;
img.addEventListener("load", this.scope.updateTagPositions);
img.addEventListener("click", this.scope.onClick);
window.addEventListener("resize", this.scope.updateTagPositions, {
passive: true,
});
}
protected removeEventListeners() {
const img = this.image as HTMLImageElement;
img.removeEventListener("load", this.scope.updateTagPositions);
img.removeEventListener("click", this.scope.onClick);
window.removeEventListener("resize", this.scope.updateTagPositions);
}
protected async afterBind() {
this.passImageAttributes();
await super.afterBind();
}
protected connectedCallback() {
super.connectedCallback();
this.init(Bs4TaggedImageComponent.observedAttributes);
}
disconnectedCallback() {
this.removeEventListener("click", this.scope.onClick);
window.removeEventListener("resize", this.scope.updateTagPositions);
}
/**
* LIFECYCLE HELPERS
*/
protected parseChildTags() {
this.debug(`parseChildTags()`);
for (const tagEl of Array.from(
this.querySelectorAll("tag") as NodeListOf<HTMLElement>,
)) {
const title = tagEl.getAttribute("title") || "";
const content = tagEl.innerHTML;
const x = ((v) => (isNaN(v) ? Math.random() : v))(
parseFloat(tagEl.getAttribute("x") || ""),
);
const y = ((v) => (isNaN(v) ? Math.random() : v))(
parseFloat(tagEl.getAttribute("y") || ""),
);
const shape = tagEl.getAttribute("shape") || undefined;
const color = tagEl.getAttribute("color") || undefined;
const borderRadius = tagEl.getAttribute("border-radius") || undefined;
const fullSize = tagEl.getAttribute("full-size") || undefined;
const smallSize = tagEl.getAttribute("small-size") || undefined;
const tagData = {
...this.scope.options.tagOptions,
popoverOptions: this.scope.fillPopoverOptions({
title,
content,
html: true,
}),
x,
y,
shape,
color,
borderRadius,
fullSize,
smallSize,
};
this.scope.tags.push(tagData);
}
}
protected initTags() {
const scopeTagOptions = this.scope.options.tagOptions;
for (const [index, tag] of this.scope.tags.entries()) {
tag.index = index;
tag.shape = tag.shape || scopeTagOptions.shape;
tag.borderRadius = tag.borderRadius || scopeTagOptions.borderRadius;
tag.smallSize = tag.smallSize || scopeTagOptions.smallSize;
tag.fullSize = tag.fullSize || scopeTagOptions.fullSize;
tag.color = tag.color || scopeTagOptions.color;
}
}
/**
* Pass all attributes starting with "img-" down to the <img> Tag, without the prefix.
*/
protected passImageAttributes() {
const img = this.image as HTMLImageElement;
const attrs = this.attributes;
for (let i = attrs.length - 1; i >= 0; i--) {
if (attrs[i].name.startsWith("img-")) {
img.setAttribute(attrs[i].name.substr(4), attrs[i].value);
}
}
}
/**
* EVENT LISTENERS
*/
onClick(e: Event) {
if (this.scope.debug) {
// adapted from here: https://stackoverflow.com/a/42111623/7048200
// TODO: avoid using "as any"
const img = this.image as HTMLImageElement;
const {
clientTop,
clientLeft,
width,
height,
naturalWidth,
naturalHeight,
} = img;
const { clientX, clientY } = e as any;
let x = clientX - clientLeft;
let y = clientY - clientTop;
const wRatio = width / naturalWidth;
const hRatio = height / naturalHeight;
let actualWidth = width;
let actualHeight = height;
if (wRatio < hRatio) {
// left, right cut off
actualWidth = (width * hRatio) / wRatio;
x += (actualWidth - width) / 2;
} else if (hRatio < wRatio) {
// left, right cut off
actualHeight = (height * wRatio) / hRatio;
y += (actualHeight - height) / 2;
}
x *= 100 / actualWidth;
y *= 100 / actualHeight;
console.log({ x, y });
}
}
onPopoverBound(event: Event) {
/*
* We get the anchor `el` for each tag here, after they have been bound in the rv-each,
* so we can trigger events on them later.
*/
const boundIndexAttr = (event.target as HTMLElement).getAttribute("index");
if (boundIndexAttr === null) {
throw new Error("popup bound on no index");
}
const boundIndex = parseInt(boundIndexAttr);
if (isNaN(boundIndex)) {
throw new Error(`boundIndex "${boundIndexAttr}" is not a number!`);
}
const foundTag = this.scope.tags.find((tag) => tag.index === boundIndex);
if (foundTag) {
foundTag.el = event.target as HTMLElement;
} else {
throw new Error(
`Tag with index (${boundIndex}, "${boundIndexAttr}") not found`,
);
}
}
onPopoverShown(event: Event) {
for (const tag of this.scope.tags) {
if (tag.el === event.target) {
// Set shown popover's anchor as active.
tag.el.classList.add("active");
} else {
// Hide all other popovers and remove active class from other tags if multiPopover option is false.
if (!this.scope.options.multiPopover) {
tag.el?.classList.remove("active");
tag.el?.dispatchEvent(new CustomEvent("trigger-hide"));
}
}
}
}
onPopoverHidden(event: Event) {
const found = this.scope.tags.find((tag) => tag.el === event.target);
if (found) {
found.el?.classList.remove("active");
}
}
protected updateTagPositions() {
/*
* Currently working for object-fit: cover, contain or fill, and object-position: 50% 50% (default)
* TODO: make this work for all CSS values of "object-position" and "object-fit"!
*/
const img = this.image as HTMLImageElement;
const { width, height, naturalWidth, naturalHeight } = img;
const wRatio = naturalWidth / width;
const hRatio = naturalHeight / height;
const fit = window.getComputedStyle(img).getPropertyValue("object-fit");
if (
(fit === "cover" && wRatio > hRatio) ||
(fit === "contain" && hRatio > wRatio)
) {
for (const tag of this.scope.tags) {
tag.top = tag.y * 100 + "%";
tag.left = ((wRatio / hRatio) * (tag.x - 0.5) + 0.5) * 100 + "%";
}
} else if (fit === "cover" || fit === "contain") {
for (const tag of this.scope.tags) {
tag.left = tag.x * 100 + "%";
tag.top = ((hRatio / wRatio) * (tag.y - 0.5) + 0.5) * 100 + "%";
}
} else {
for (const tag of this.scope.tags) {
tag.left = tag.x * 100 + "%";
tag.top = tag.y * 100 + "%";
}
}
}
}