@openproject/primer-view-components
Version:
ViewComponents of the Primer Design System for OpenProject
106 lines (105 loc) • 4.44 kB
JavaScript
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
return c > 3 && r && Object.defineProperty(target, key, r), r;
};
import { controller } from '@github/catalyst';
/**
* AvatarFallbackElement implements "fallback first" loading pattern:
* 1. Fallback SVG is rendered immediately as <img> src
* 2. Real avatar URL is test-loaded in background using new Image()
* 3. On success, swaps to real image; on failure, fallback stays visible
*
* This approach prevents flicker by never showing a broken image state.
* Inspired by OpenProject's Angular PrincipalRendererService.
*
* Note: We read attributes directly via getAttribute() instead of using @attr
* due to a Catalyst bug where @attr accessors aren't properly initialized
* when elements have pre-existing attribute values.
*/
let AvatarFallbackElement = class AvatarFallbackElement extends HTMLElement {
constructor() {
super(...arguments);
this.img = null;
this.testImage = null;
}
connectedCallback() {
this.img = this.querySelector('img') ?? null;
if (!this.img)
return;
const uniqueId = this.getAttribute('data-unique-id') || '';
const altText = this.getAttribute('data-alt-text') || '';
const avatarSrc = this.getAttribute('data-avatar-src') || '';
// Apply hashed color to fallback SVG immediately
this.applyColor(this.img, uniqueId, altText);
// Test-load real avatar URL in background
if (avatarSrc) {
this.testLoadImage(avatarSrc);
}
}
disconnectedCallback() {
// Clean up test image and its event handler to prevent memory leaks
if (this.testImage) {
this.testImage.onload = null;
this.testImage = null;
}
this.img = null;
}
/**
* Test-loads the real avatar URL in background.
* On success, swaps the visible img to the real URL.
* On failure, does nothing - fallback stays visible.
*/
testLoadImage(url) {
this.testImage = new Image();
this.testImage.onload = () => {
// Success - swap to real image
if (this.img) {
this.img.src = url;
}
};
// On error: do nothing, fallback stays visible (no flicker)
this.testImage.src = url;
}
applyColor(img, uniqueId, altText) {
// If either uniqueId or altText is missing, skip color customization so the SVG
// keeps its default gray fill defined in the source and no color override is applied.
if (!uniqueId || !altText)
return;
const text = `${uniqueId}${altText}`;
const hue = this.valueHash(text);
const color = `hsl(${hue}, 50%, 30%)`;
this.updateSvgColor(img, color);
}
/*
* Mimics OP Core's string hash function to ensure consistent color generation
* @see https://github.com/opf/openproject/blob/1b6eb3f9e45c3bdb05ce49d2cbe92995b87b4df5/frontend/src/app/shared/components/colors/colors.service.ts#L19-L26
*/
valueHash(value) {
let hash = 0;
for (let i = 0; i < value.length; i++) {
hash = value.charCodeAt(i) + ((hash << 5) - hash);
}
return hash % 360;
}
updateSvgColor(img, color) {
const dataUri = img.src;
if (!dataUri.startsWith('data:image/svg+xml;base64,'))
return;
const base64 = dataUri.replace('data:image/svg+xml;base64,', '');
try {
const svg = atob(base64);
const updatedSvg = svg.replace(/fill="hsl\([^"]+\)"/, `fill="${color}"`);
img.src = `data:image/svg+xml;base64,${btoa(updatedSvg)}`;
}
catch {
// If the SVG data is malformed or not valid base64, skip updating the color
// to avoid breaking the component.
}
}
};
AvatarFallbackElement = __decorate([
controller
], AvatarFallbackElement);
export { AvatarFallbackElement };