* Simplify favicons and other web icons browserconfig.xml seems to have died with Internet Explorer `apple-touch-icon` is awfully documented but seems like larger sizes are now preferred Use PNG for the favicon as things now support it across the board, we could even consider moving to SVG favicons in the future Optimised using oxipng, this is to simplify icon replacement in `element-web-bin` Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Fix paths Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Update tests Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Remove border around favicons Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Add test Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Add test Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --------- Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
312 lines
11 KiB
TypeScript
312 lines
11 KiB
TypeScript
/*
|
|
Copyright 2020-2025 New Vector Ltd.
|
|
|
|
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
|
Please see LICENSE files in the repository root for full details.
|
|
*/
|
|
|
|
interface IParams {
|
|
// colour parameters
|
|
bgColor: string;
|
|
textColor: string;
|
|
// font styling parameters
|
|
fontFamily: string;
|
|
fontWeight: "normal" | "italic" | "bold" | "bolder" | "lighter" | number;
|
|
|
|
// positioning parameters
|
|
isUp: boolean;
|
|
isLeft: boolean;
|
|
}
|
|
|
|
const defaults: IParams = {
|
|
bgColor: "#d00",
|
|
textColor: "#fff",
|
|
fontFamily: "sans-serif", // Arial,Verdana,Times New Roman,serif,sans-serif,...
|
|
fontWeight: "bold", // normal,italic,oblique,bold,bolder,lighter,100,200,300,400,500,600,700,800,900
|
|
|
|
isUp: false,
|
|
isLeft: false,
|
|
};
|
|
|
|
abstract class IconRenderer {
|
|
protected readonly canvas: HTMLCanvasElement;
|
|
protected readonly context: CanvasRenderingContext2D;
|
|
public constructor(
|
|
protected readonly params: IParams = defaults,
|
|
protected readonly baseImage?: HTMLImageElement,
|
|
) {
|
|
this.canvas = document.createElement("canvas");
|
|
const context = this.canvas.getContext("2d");
|
|
if (!context) {
|
|
throw Error("Could not get canvas context");
|
|
}
|
|
this.context = context;
|
|
}
|
|
|
|
private options(
|
|
n: number | string,
|
|
params: IParams,
|
|
): {
|
|
n: string | number;
|
|
len: number;
|
|
x: number;
|
|
y: number;
|
|
w: number;
|
|
h: number;
|
|
} {
|
|
const opt = {
|
|
n: typeof n === "number" ? Math.abs(n | 0) : n,
|
|
len: ("" + n).length,
|
|
// badge positioning constants as percentages
|
|
x: 0.4,
|
|
y: 0.4,
|
|
w: 0.6,
|
|
h: 0.6,
|
|
};
|
|
|
|
// apply positional transformations
|
|
if (params.isUp) {
|
|
if (opt.y < 0.6) {
|
|
opt.y = opt.y - 0.4;
|
|
} else {
|
|
opt.y = opt.y - 2 * opt.y + (1 - opt.w);
|
|
}
|
|
}
|
|
if (params.isLeft) {
|
|
if (opt.x < 0.6) {
|
|
opt.x = opt.x - 0.4;
|
|
} else {
|
|
opt.x = opt.x - 2 * opt.x + (1 - opt.h);
|
|
}
|
|
}
|
|
|
|
// scale the position to the canvas
|
|
opt.x = this.canvas.width * opt.x;
|
|
opt.y = this.canvas.height * opt.y;
|
|
opt.w = this.canvas.width * opt.w;
|
|
opt.h = this.canvas.height * opt.h;
|
|
return opt;
|
|
}
|
|
|
|
/**
|
|
* Draws a circualr status icon, usually over the top of the application icon.
|
|
* @param n The content of the circle. Should be a number or a single character.
|
|
* @param opts Options to adjust.
|
|
*/
|
|
protected circle(n: number | string, opts?: Partial<IParams>): void {
|
|
const params = { ...this.params, ...opts };
|
|
const opt = this.options(n, params);
|
|
|
|
let more = false;
|
|
if (!this.baseImage) {
|
|
// If we omit the background, assume the entire canvas is our target.
|
|
opt.x = 0;
|
|
opt.y = 0;
|
|
opt.w = this.canvas.width;
|
|
opt.h = this.canvas.height;
|
|
}
|
|
if (opt.len === 2) {
|
|
opt.x = opt.x - opt.w * 0.4;
|
|
opt.w = opt.w * 1.4;
|
|
more = true;
|
|
} else if (opt.len >= 3) {
|
|
opt.x = opt.x - opt.w * 0.65;
|
|
opt.w = opt.w * 1.65;
|
|
more = true;
|
|
}
|
|
|
|
this.context.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
|
if (this.baseImage) {
|
|
this.context.drawImage(this.baseImage, 0, 0, this.canvas.width, this.canvas.height);
|
|
}
|
|
this.context.beginPath();
|
|
const fontSize = Math.floor(opt.h * (typeof opt.n === "number" && opt.n > 99 ? 0.85 : 1)) + "px";
|
|
this.context.font = `${params.fontWeight} ${fontSize} ${params.fontFamily}`;
|
|
this.context.textAlign = "center";
|
|
|
|
if (more) {
|
|
this.context.moveTo(opt.x + opt.w / 2, opt.y);
|
|
this.context.lineTo(opt.x + opt.w - opt.h / 2, opt.y);
|
|
this.context.quadraticCurveTo(opt.x + opt.w, opt.y, opt.x + opt.w, opt.y + opt.h / 2);
|
|
this.context.lineTo(opt.x + opt.w, opt.y + opt.h - opt.h / 2);
|
|
this.context.quadraticCurveTo(opt.x + opt.w, opt.y + opt.h, opt.x + opt.w - opt.h / 2, opt.y + opt.h);
|
|
this.context.lineTo(opt.x + opt.h / 2, opt.y + opt.h);
|
|
this.context.quadraticCurveTo(opt.x, opt.y + opt.h, opt.x, opt.y + opt.h - opt.h / 2);
|
|
this.context.lineTo(opt.x, opt.y + opt.h / 2);
|
|
this.context.quadraticCurveTo(opt.x, opt.y, opt.x + opt.h / 2, opt.y);
|
|
} else {
|
|
this.context.arc(opt.x + opt.w / 2, opt.y + opt.h / 2, opt.h / 2, 0, 2 * Math.PI);
|
|
}
|
|
|
|
this.context.fillStyle = params.bgColor;
|
|
this.context.fill();
|
|
this.context.closePath();
|
|
this.context.beginPath();
|
|
this.context.stroke();
|
|
this.context.fillStyle = params.textColor;
|
|
|
|
if (typeof opt.n === "number" && opt.n > 999) {
|
|
const count = (opt.n > 9999 ? 9 : Math.floor(opt.n / 1000)) + "k+";
|
|
this.context.fillText(count, Math.floor(opt.x + opt.w / 2), Math.floor(opt.y + opt.h - opt.h * 0.2));
|
|
} else {
|
|
this.context.fillText("" + opt.n, Math.floor(opt.x + opt.w / 2), Math.floor(opt.y + opt.h - opt.h * 0.15));
|
|
}
|
|
|
|
this.context.closePath();
|
|
}
|
|
}
|
|
|
|
export class BadgeOverlayRenderer extends IconRenderer {
|
|
public constructor() {
|
|
super();
|
|
// Overlays are 16x16 https://www.electronjs.org/docs/latest/api/browser-window#winsetoverlayiconoverlay-description-windows
|
|
this.canvas.width = 16;
|
|
this.canvas.height = 16;
|
|
}
|
|
|
|
/**
|
|
* Generate an overlay badge without the application icon, and export
|
|
* as an ArrayBuffer
|
|
* @param contents The content of the circle. Should be a number or a single character.
|
|
* @param bgColor Optional alternative background colo.r
|
|
* @returns An ArrayBuffer representing a 16x16 icon in `image/png` format, or `null` if no badge should be drawn.
|
|
*/
|
|
public async render(contents: number | string, bgColor?: string): Promise<ArrayBuffer | null> {
|
|
if (contents === 0) {
|
|
return null;
|
|
}
|
|
|
|
this.circle(contents, { ...(bgColor ? { bgColor } : undefined) });
|
|
return new Promise((resolve, reject) => {
|
|
this.canvas.toBlob(
|
|
(blob) => {
|
|
if (blob) {
|
|
resolve(blob.arrayBuffer());
|
|
}
|
|
reject(new Error("Could not render badge overlay as blob"));
|
|
},
|
|
"image/png",
|
|
1,
|
|
);
|
|
});
|
|
}
|
|
}
|
|
|
|
// multiple of 48 as per Google guidelines
|
|
// multiple of 16 for higher quality scaling for small display
|
|
const DEFAULT_FAVICON_SIZE = 144;
|
|
|
|
// Allows dynamic rendering of a circular badge atop the loaded favicon
|
|
// supports colour, font and basic positioning parameters.
|
|
// Based upon https://github.com/ejci/favico.js/blob/master/favico.js [MIT license]
|
|
export default class Favicon extends IconRenderer {
|
|
private readonly browser = {
|
|
ff: typeof window.InstallTrigger !== "undefined",
|
|
opera: !!window.opera || navigator.userAgent.includes("Opera"),
|
|
};
|
|
|
|
private icons: HTMLLinkElement[];
|
|
|
|
private isReady = false;
|
|
// callback to run once isReady is asserted, allows for a badge to be queued for when it can be shown
|
|
private readyCb?: () => void;
|
|
|
|
public constructor() {
|
|
const baseImage = document.createElement("img");
|
|
super(defaults, baseImage);
|
|
|
|
this.icons = Favicon.getIcons();
|
|
|
|
const lastIcon = this.icons[this.icons.length - 1];
|
|
if (lastIcon.hasAttribute("href")) {
|
|
baseImage.setAttribute("crossOrigin", "anonymous");
|
|
baseImage.onload = (): void => {
|
|
// get height and width of the favicon
|
|
this.canvas.height = baseImage.height > 0 ? baseImage.height : DEFAULT_FAVICON_SIZE;
|
|
this.canvas.width = baseImage.width > 0 ? baseImage.width : DEFAULT_FAVICON_SIZE;
|
|
this.ready();
|
|
};
|
|
baseImage.setAttribute("src", lastIcon.getAttribute("href")!);
|
|
} else {
|
|
this.canvas.height = baseImage.height = DEFAULT_FAVICON_SIZE;
|
|
this.canvas.width = baseImage.width = DEFAULT_FAVICON_SIZE;
|
|
this.ready();
|
|
}
|
|
}
|
|
|
|
private reset(): void {
|
|
this.context.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
|
this.context.drawImage(this.baseImage!, 0, 0, this.canvas.width, this.canvas.height);
|
|
}
|
|
|
|
private ready(): void {
|
|
if (this.isReady) return;
|
|
this.isReady = true;
|
|
this.readyCb?.();
|
|
}
|
|
|
|
private setIcon(canvas: HTMLCanvasElement): void {
|
|
setTimeout(() => {
|
|
this.setIconSrc(canvas.toDataURL("image/png"));
|
|
}, 0);
|
|
}
|
|
|
|
private setIconSrc(url: string): void {
|
|
// if is attached to fav icon
|
|
if (this.browser.ff || this.browser.opera) {
|
|
// for FF we need to "recreate" element, attach to dom and remove old <link>
|
|
const old = this.icons[this.icons.length - 1];
|
|
const newIcon = window.document.createElement("link");
|
|
this.icons = [newIcon];
|
|
newIcon.setAttribute("rel", "icon");
|
|
newIcon.setAttribute("type", "image/png");
|
|
window.document.getElementsByTagName("head")[0].appendChild(newIcon);
|
|
newIcon.setAttribute("href", url);
|
|
old.parentNode?.removeChild(old);
|
|
} else {
|
|
this.icons.forEach((icon) => {
|
|
icon.setAttribute("href", url);
|
|
});
|
|
}
|
|
}
|
|
|
|
public badge(content: number | string, opts?: Partial<IParams>): void {
|
|
if (!this.isReady) {
|
|
this.readyCb = (): void => {
|
|
this.badge(content, opts);
|
|
};
|
|
return;
|
|
}
|
|
|
|
if (typeof content === "string" || content > 0) {
|
|
this.circle(content, opts);
|
|
} else {
|
|
this.reset();
|
|
}
|
|
|
|
this.setIcon(this.canvas);
|
|
}
|
|
|
|
private static getLinks(): HTMLLinkElement[] {
|
|
const icons: HTMLLinkElement[] = [];
|
|
const links = window.document.getElementsByTagName("head")[0].getElementsByTagName("link");
|
|
for (const link of links) {
|
|
if (link.hasAttribute("rel") && /(^|\s)icon(\s|$)/i.test(link.getAttribute("rel")!)) {
|
|
icons.push(link);
|
|
}
|
|
}
|
|
return icons;
|
|
}
|
|
|
|
private static getIcons(): HTMLLinkElement[] {
|
|
// get favicon link elements
|
|
let elms = Favicon.getLinks();
|
|
if (elms.length === 0) {
|
|
elms = [window.document.createElement("link")];
|
|
elms[0].setAttribute("rel", "icon");
|
|
window.document.getElementsByTagName("head")[0].appendChild(elms[0]);
|
|
}
|
|
return elms;
|
|
}
|
|
}
|