评论区表情包放大

在评论区中,表情的宽度和高度通常是固定的。因此,尺寸较大的表情包会被缩放显示,导致模糊,并可能引起误解。本教程为表情图案添加了一个简单且实用的放大弹出层,帮助用户更清晰地查看表情包的细节。

教程

实现的原理其实非常简单。首先,创建一个容器,将表情包的内容放入其中,并读取图片的 Alt 等描述属性以在容器内展示。然后,通过控制容器的位置及其显示和隐藏来实现放大效果。关于表情包的放大逻辑,基本属性如下:放大倍数为 2 倍,最大显示宽高为 200px(若超过此尺寸,则按比例缩小)。

实现

具体实现方法是利用 observer 观察评论区中新增的元素节点,根据相关选择器筛选出包含表情包的节点,并为其添加相应的事件。以 Artalk 评论系统为例,处理的选择器如下:

/**
* 放大项:
* ① 表情包选择
* ② 评论内容
* ③ 预览窗(只有一张图)
* ④ 预览窗(任意)
*/
function shouldEnlarge(element: HTMLElement): boolean {
const classList = element.classList;
const attributes = element.attributes;
const querySelector = element.querySelector;

return classList?.contains('atk-grp') ||
classList?.contains('atk-comment-wrap') ||
attributes?.getNamedItem('atk-emoticon') !== null ||
querySelector?.('img[atk-emoticon]') !== null;
}

然后根据元素的当前宽度和高度及其自然宽高,计算并返回一个临时尺寸。也就是在考虑缩放比例和最大长度的情况下,计算并调整元素的临时尺寸,使其不超过指定的最大长度并保持正确的宽高比例。

/**
* 计算临时宽度和高度
* @param clientHeight 客户端高度
* @param clientWidth 客户端宽度
* @param naturalHeight 自然高度
* @param naturalWidth 自然宽度
* @param ratio 缩放比例
* @param maxLength 最大长度
* @returns 临时宽度和高度
*/
function calculateSize(
clientHeight: number,
clientWidth: number,
naturalHeight: number,
naturalWidth: number,
ratio: number,
maxLength: number
): { tempWidth: number, tempHeight: number } {
const zoomHeight = clientHeight * ratio;
const zoomWidth = clientWidth * ratio;
const constrainedHeight = Math.min(zoomHeight, maxLength, Math.max(clientHeight, naturalHeight));
const constrainedWidth = Math.min(zoomWidth, maxLength, Math.max(clientWidth, naturalWidth));
const aspectRatio = constrainedWidth / constrainedHeight;
const tempWidth = aspectRatio >= 1
? Math.min(constrainedWidth, maxLength)
: Math.min((constrainedWidth * maxLength) / constrainedHeight, constrainedWidth);
const tempHeight = aspectRatio < 1
? Math.min(constrainedHeight, maxLength)
: Math.min((constrainedHeight * maxLength) / constrainedWidth, constrainedHeight);
return { tempWidth, tempHeight };
}

最后,通过 pointerover 事件来过滤并处理来自鼠标的事件::

element.addEventListener('pointerover', (e: PointerEvent) => {
if (e.pointerType !== 'mouse') return;

//执行相关操作
})

代码

JavaScript
以下代码适用于 Artalk,需要自行调用。
/** 表情包放大 */
showOwoBig(target: Node) {
const RATIO = 2;
const MaxLength = 200;
const body = document.querySelector('body') || document.createElement('body');
let div = document.querySelector('#owo-big') as HTMLElement;

if (!div) {
div = document.createElement('div');
div.id = 'owo-big';
body.appendChild(div);
}

const observer = new MutationObserver(mutations => {
mutations.forEach(mutation => {
mutation.addedNodes.forEach(node => {
const element = node as HTMLElement;

if (shouldEnlarge(element)) {
setupHoverEffects(element);
}
});
});
});

observer.observe(target, { subtree: true, childList: true });

function shouldEnlarge(element: HTMLElement): boolean {
const classList = element.classList;
const attributes = element.attributes;
const querySelector = element.querySelector;

return classList?.contains('atk-grp') ||
classList?.contains('atk-comment-wrap') ||
attributes?.getNamedItem('atk-emoticon') !== null ||
querySelector?.('img[atk-emoticon]') !== null;
}

function setupHoverEffects(element: HTMLElement) {
let flag = true;
let owoTime: number;
element.addEventListener('pointerover', (e: PointerEvent) => {
if (e.pointerType !== 'mouse') return;

const imgElement = e.target as HTMLImageElement;
if (flag && imgElement.tagName === 'IMG' && imgElement.hasAttribute('atk-emoticon')) {
flag = false;
owoTime = window.setTimeout(() => {
const alt = imgElement.getAttribute("notitle") === "true" ? '' : imgElement.alt || '';
const { clientHeight, clientWidth, naturalHeight, naturalWidth } = imgElement;

if (clientHeight <= MaxLength && clientWidth <= MaxLength) {
const { tempWidth, tempHeight } = calculateSize(clientHeight, clientWidth, naturalHeight, naturalWidth, RATIO, MaxLength);
const { top, left } = calculatePosition(e, tempWidth, clientWidth, body);

div.style.cssText = `
display: block;
width: ${tempWidth + 32}px; // div padding: 16px;
left: ${left}px;
top: ${top}px;
`;
div.innerHTML = `
<img src="${imgElement.src}" style="height: ${tempHeight}px;width: ${tempWidth}px" onerror="this.classList.add('error')">
<p>${alt.trim().replace(/\s+/g, ' ').replace(/ /g, '<br>')}</p>
`;
}
}, 300);
}
});

element.addEventListener('pointerout', () => {
flag = true;
div.style.display = 'none';
clearTimeout(owoTime);
});
}

function calculateSize(
clientHeight: number,
clientWidth: number,
naturalHeight: number,
naturalWidth: number,
ratio: number,
maxLength: number
): { tempWidth: number, tempHeight: number } {
const zoomHeight = clientHeight * ratio;
const zoomWidth = clientWidth * ratio;
const constrainedHeight = Math.min(zoomHeight, maxLength, Math.max(clientHeight, naturalHeight));
const constrainedWidth = Math.min(zoomWidth, maxLength, Math.max(clientWidth, naturalWidth));
const aspectRatio = constrainedWidth / constrainedHeight;
const tempWidth = aspectRatio >= 1
? Math.min(constrainedWidth, maxLength)
: Math.min((constrainedWidth * maxLength) / constrainedHeight, constrainedWidth);
const tempHeight = aspectRatio < 1
? Math.min(constrainedHeight, maxLength)
: Math.min((constrainedHeight * maxLength) / constrainedWidth, constrainedHeight);
return { tempWidth, tempHeight };
}

function calculatePosition(
e: MouseEvent,
tempWidth: number,
clientWidth: number,
bodyElement: HTMLElement
): { top: number, left: number } {
const top = e.clientY - e.offsetY;
let left = e.clientX - e.offsetX - (tempWidth - clientWidth) / 2;
left = Math.max(10, Math.min(left, bodyElement.clientWidth - tempWidth - 10));
return { top, left };
}
}
SCSS
#owo-big {
display: none;
position: fixed;
z-index: 9999;
padding: 16px;
overflow: hidden;
user-select: none;
align-items: center;
transform: translate(0, -105%);
background-color: var(--at-color-owo);
backdrop-filter: saturate(200%) blur(6px);
border: 1px solid var(--at-color-bg-light);
border-radius: 12px;
box-shadow: 0 0 12px 4px rgb(0 0 0 / 5%);
animation: owoIn 0.3s cubic-bezier(0.42, 0, 0.3, 1.11);

img {
width: 100%;
border-radius: 10px;
}

p {
color: var(--at-color-meta);
text-align: center;
font-size: 12px;
word-wrap: break-word;
white-space: normal;
overflow-wrap: break-word;
margin: 4px 0 -8px;
}

&:has(img.error) {
display: none !important;
}
}

@keyframes owoIn {
0% {
transform: translate(0, -95%);
opacity: 0;
}
100% {
transform: translate(0, -105%);
opacity: 1;
}
}

评论