@bbki.ng/bb-msg-history 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/component.d.ts +4 -0
- package/dist/component.js +44 -1
- package/dist/const/authors.js +6 -6
- package/dist/const/styles.js +156 -6
- package/dist/const/theme.js +14 -2
- package/dist/index.dev.js +1 -12
- package/dist/index.js.map +1 -0
- package/dist/src/component.d.ts +39 -0
- package/dist/src/component.js +184 -0
- package/dist/src/const/authors.d.ts +10 -0
- package/dist/src/const/authors.js +29 -0
- package/dist/src/const/styles.d.ts +12 -0
- package/dist/src/const/styles.js +425 -0
- package/dist/src/const/theme.d.ts +6 -0
- package/dist/src/const/theme.js +44 -0
- package/dist/src/index.d.ts +9 -0
- package/dist/src/index.js +12 -0
- package/dist/src/types/index.d.ts +34 -0
- package/dist/src/types/index.js +4 -0
- package/dist/src/utils/author-resolver.d.ts +11 -0
- package/dist/src/utils/author-resolver.js +75 -0
- package/dist/src/utils/avatar.d.ts +4 -0
- package/dist/src/utils/avatar.js +18 -0
- package/dist/src/utils/html.d.ts +12 -0
- package/dist/src/utils/html.js +33 -0
- package/dist/src/utils/message-builder.d.ts +13 -0
- package/dist/src/utils/message-builder.js +60 -0
- package/dist/src/utils/message-parser.d.ts +6 -0
- package/dist/src/utils/message-parser.js +22 -0
- package/dist/src/utils/registration.d.ts +8 -0
- package/dist/src/utils/registration.js +28 -0
- package/dist/src/utils/scroll-button.d.ts +4 -0
- package/dist/src/utils/scroll-button.js +12 -0
- package/dist/src/utils/tooltip.d.ts +5 -0
- package/dist/src/utils/tooltip.js +17 -0
- package/dist/tests/html.test.d.ts +1 -0
- package/dist/tests/html.test.js +35 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/utils/author-resolver.js +7 -3
- package/dist/utils/avatar.js +8 -8
- package/dist/utils/message-builder.js +15 -4
- package/dist/utils/registration.js +3 -4
- package/dist/utils/scroll-button.d.ts +4 -0
- package/dist/utils/scroll-button.js +12 -0
- package/dist/vitest.config.d.ts +2 -0
- package/dist/vitest.config.js +8 -0
- package/package.json +32 -7
- package/src/component.ts +67 -13
- package/src/const/authors.ts +6 -6
- package/src/const/styles.ts +156 -6
- package/src/const/theme.ts +14 -2
- package/src/types/index.ts +1 -0
- package/src/utils/author-resolver.ts +8 -4
- package/src/utils/avatar.ts +8 -8
- package/src/utils/message-builder.ts +19 -5
- package/src/utils/message-parser.ts +5 -5
- package/src/utils/registration.ts +5 -5
- package/src/utils/scroll-button.ts +12 -0
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTML utility functions
|
|
3
|
+
*/
|
|
4
|
+
/**
|
|
5
|
+
* Escape HTML special characters to prevent XSS
|
|
6
|
+
*/
|
|
7
|
+
export function escapeHtml(str) {
|
|
8
|
+
return str
|
|
9
|
+
.replace(/&/g, '&')
|
|
10
|
+
.replace(/</g, '<')
|
|
11
|
+
.replace(/>/g, '>')
|
|
12
|
+
.replace(/"/g, '"')
|
|
13
|
+
.replace(/'/g, ''');
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Wrap plain text/emoji avatar in a styled container
|
|
17
|
+
* If HTML tags are present, return as-is
|
|
18
|
+
*/
|
|
19
|
+
export function wrapAvatarHtml(html) {
|
|
20
|
+
// If it looks like a single emoji or short text (no HTML tags), wrap in a styled div
|
|
21
|
+
if (!html.includes('<')) {
|
|
22
|
+
return `<div style="
|
|
23
|
+
width: 100%;
|
|
24
|
+
height: 100%;
|
|
25
|
+
display: flex;
|
|
26
|
+
align-items: center;
|
|
27
|
+
justify-content: center;
|
|
28
|
+
font-size: 18px;
|
|
29
|
+
line-height: 1;
|
|
30
|
+
">${html}</div>`;
|
|
31
|
+
}
|
|
32
|
+
return html;
|
|
33
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { AuthorConfig } from '../types/index.js';
|
|
2
|
+
/**
|
|
3
|
+
* Build avatar HTML string
|
|
4
|
+
*/
|
|
5
|
+
export declare function buildAvatarHtml(author: string, config: AuthorConfig, showAvatar: boolean): string;
|
|
6
|
+
/**
|
|
7
|
+
* Build a single message row HTML string
|
|
8
|
+
*/
|
|
9
|
+
export declare function buildMessageRowHtml(author: string, text: string, config: AuthorConfig, isSubsequent: boolean): string;
|
|
10
|
+
/**
|
|
11
|
+
* Setup tooltip for a single avatar wrapper element
|
|
12
|
+
*/
|
|
13
|
+
export declare function setupTooltipForElement(wrapper: Element): void;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { THEME } from '../const/theme.js';
|
|
2
|
+
import { escapeHtml } from './html.js';
|
|
3
|
+
/**
|
|
4
|
+
* Build avatar HTML string
|
|
5
|
+
*/
|
|
6
|
+
export function buildAvatarHtml(author, config, showAvatar) {
|
|
7
|
+
return `
|
|
8
|
+
<div class="avatar-wrapper ${showAvatar ? '' : 'avatar-wrapper--hidden'}"
|
|
9
|
+
data-author="${escapeHtml(author)}">
|
|
10
|
+
<div class="avatar">${config.avatar}</div>
|
|
11
|
+
<div class="avatar-tooltip">${escapeHtml(author)}</div>
|
|
12
|
+
</div>
|
|
13
|
+
`;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Build a single message row HTML string
|
|
17
|
+
*/
|
|
18
|
+
export function buildMessageRowHtml(author, text, config, isSubsequent) {
|
|
19
|
+
const showAvatar = !isSubsequent;
|
|
20
|
+
const side = config.side;
|
|
21
|
+
const avatarHtml = buildAvatarHtml(author, config, showAvatar);
|
|
22
|
+
// Build inline style only for custom colors (not defaults)
|
|
23
|
+
const isDefaultBubbleColor = config.bubbleColor === THEME.gray[50];
|
|
24
|
+
const isDefaultTextColor = config.textColor === THEME.gray[900];
|
|
25
|
+
const inlineStyles = [];
|
|
26
|
+
if (!isDefaultBubbleColor) {
|
|
27
|
+
inlineStyles.push(`background-color: ${config.bubbleColor}`);
|
|
28
|
+
}
|
|
29
|
+
if (!isDefaultTextColor) {
|
|
30
|
+
inlineStyles.push(`color: ${config.textColor}`);
|
|
31
|
+
}
|
|
32
|
+
const styleAttr = inlineStyles.length > 0 ? ` style="${inlineStyles.join('; ')}"` : '';
|
|
33
|
+
return `
|
|
34
|
+
<div class="msg-row msg-row--${side} ${isSubsequent ? 'msg-row--subsequent' : 'msg-row--new-author'}">
|
|
35
|
+
${side === 'left' ? avatarHtml : ''}
|
|
36
|
+
|
|
37
|
+
<div class="msg-content">
|
|
38
|
+
<div class="msg-bubble msg-bubble--${side}"${styleAttr}>
|
|
39
|
+
${escapeHtml(text)}
|
|
40
|
+
</div>
|
|
41
|
+
</div>
|
|
42
|
+
|
|
43
|
+
${side === 'right' ? avatarHtml : ''}
|
|
44
|
+
</div>
|
|
45
|
+
`;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Setup tooltip for a single avatar wrapper element
|
|
49
|
+
*/
|
|
50
|
+
export function setupTooltipForElement(wrapper) {
|
|
51
|
+
wrapper.addEventListener('mouseenter', () => {
|
|
52
|
+
const tooltip = wrapper.querySelector('.avatar-tooltip');
|
|
53
|
+
if (!tooltip)
|
|
54
|
+
return;
|
|
55
|
+
const rect = wrapper.getBoundingClientRect();
|
|
56
|
+
const tooltipRect = tooltip.getBoundingClientRect();
|
|
57
|
+
tooltip.style.left = `${rect.left + rect.width / 2 - tooltipRect.width / 2}px`;
|
|
58
|
+
tooltip.style.top = `${rect.top - tooltipRect.height - 8}px`;
|
|
59
|
+
});
|
|
60
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parse text content into message array
|
|
3
|
+
* Format: `author: text` (one message per line)
|
|
4
|
+
*/
|
|
5
|
+
export function parseMessages(textContent) {
|
|
6
|
+
const raw = textContent || '';
|
|
7
|
+
const messages = [];
|
|
8
|
+
for (const line of raw.split('\n')) {
|
|
9
|
+
const trimmed = line.trim();
|
|
10
|
+
if (!trimmed)
|
|
11
|
+
continue;
|
|
12
|
+
const colonIdx = trimmed.indexOf(':');
|
|
13
|
+
if (colonIdx <= 0)
|
|
14
|
+
continue;
|
|
15
|
+
const author = trimmed.slice(0, colonIdx).trim();
|
|
16
|
+
const text = trimmed.slice(colonIdx + 1).trim();
|
|
17
|
+
if (author && text) {
|
|
18
|
+
messages.push({ author, text });
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return messages;
|
|
22
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Define the custom element
|
|
3
|
+
*/
|
|
4
|
+
export declare function define(BBMsgHistoryClass: CustomElementConstructor, tagName?: string): void;
|
|
5
|
+
/**
|
|
6
|
+
* Initialize with fallback for unsupported browsers
|
|
7
|
+
*/
|
|
8
|
+
export declare function initBBMsgHistory(BBMsgHistoryClass: CustomElementConstructor): void;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { FALLBACK_STYLES } from '../const/styles.js';
|
|
2
|
+
/**
|
|
3
|
+
* Define the custom element
|
|
4
|
+
*/
|
|
5
|
+
export function define(BBMsgHistoryClass, tagName = 'bb-msg-history') {
|
|
6
|
+
if (!customElements.get(tagName)) {
|
|
7
|
+
customElements.define(tagName, tagName === 'bb-msg-history' ? BBMsgHistoryClass : class extends BBMsgHistoryClass {
|
|
8
|
+
});
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Initialize with fallback for unsupported browsers
|
|
13
|
+
*/
|
|
14
|
+
export function initBBMsgHistory(BBMsgHistoryClass) {
|
|
15
|
+
try {
|
|
16
|
+
define(BBMsgHistoryClass);
|
|
17
|
+
}
|
|
18
|
+
catch (error) {
|
|
19
|
+
// eslint-disable-next-line no-console
|
|
20
|
+
console.warn('BBMsgHistory registration failed, falling back to plain text:', error);
|
|
21
|
+
document.querySelectorAll('bb-msg-history').forEach(el => {
|
|
22
|
+
const pre = document.createElement('pre');
|
|
23
|
+
pre.style.cssText = FALLBACK_STYLES;
|
|
24
|
+
pre.textContent = el.textContent || '';
|
|
25
|
+
el.replaceWith(pre);
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build scroll-to-bottom button HTML string
|
|
3
|
+
*/
|
|
4
|
+
export function buildScrollButtonHtml() {
|
|
5
|
+
return `
|
|
6
|
+
<button class="scroll-to-bottom" aria-label="Scroll to bottom" title="Scroll to bottom">
|
|
7
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
8
|
+
<polyline points="6 9 12 15 18 9"></polyline>
|
|
9
|
+
</svg>
|
|
10
|
+
</button>
|
|
11
|
+
`;
|
|
12
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Setup dynamic tooltip positioning
|
|
3
|
+
* Tooltips are positioned fixed to avoid overflow clipping from parent containers
|
|
4
|
+
*/
|
|
5
|
+
export function setupTooltips(shadowRoot) {
|
|
6
|
+
shadowRoot.querySelectorAll('.avatar-wrapper').forEach(wrapper => {
|
|
7
|
+
wrapper.addEventListener('mouseenter', () => {
|
|
8
|
+
const tooltip = wrapper.querySelector('.avatar-tooltip');
|
|
9
|
+
if (!tooltip)
|
|
10
|
+
return;
|
|
11
|
+
const rect = wrapper.getBoundingClientRect();
|
|
12
|
+
const tooltipRect = tooltip.getBoundingClientRect();
|
|
13
|
+
tooltip.style.left = `${rect.left + rect.width / 2 - tooltipRect.width / 2}px`;
|
|
14
|
+
tooltip.style.top = `${rect.top - tooltipRect.height - 8}px`;
|
|
15
|
+
});
|
|
16
|
+
});
|
|
17
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { escapeHtml, wrapAvatarHtml } from '../src/utils/html.js';
|
|
3
|
+
describe('escapeHtml', () => {
|
|
4
|
+
it('should escape HTML special characters', () => {
|
|
5
|
+
expect(escapeHtml('<script>alert("xss")</script>')).toBe('<script>alert("xss")</script>');
|
|
6
|
+
});
|
|
7
|
+
it('should handle empty string', () => {
|
|
8
|
+
expect(escapeHtml('')).toBe('');
|
|
9
|
+
});
|
|
10
|
+
it('should handle string without special characters', () => {
|
|
11
|
+
expect(escapeHtml('Hello World')).toBe('Hello World');
|
|
12
|
+
});
|
|
13
|
+
it('should escape single quotes', () => {
|
|
14
|
+
expect(escapeHtml("'test'")).toBe(''test'');
|
|
15
|
+
});
|
|
16
|
+
it('should escape ampersands', () => {
|
|
17
|
+
expect(escapeHtml('Tom & Jerry')).toBe('Tom & Jerry');
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
describe('wrapAvatarHtml', () => {
|
|
21
|
+
it('should wrap plain text in styled container', () => {
|
|
22
|
+
const result = wrapAvatarHtml('😀');
|
|
23
|
+
expect(result).toContain('<div style=');
|
|
24
|
+
expect(result).toContain('😀');
|
|
25
|
+
});
|
|
26
|
+
it('should return HTML as-is when tags are present', () => {
|
|
27
|
+
const html = '<img src="avatar.png" />';
|
|
28
|
+
expect(wrapAvatarHtml(html)).toBe(html);
|
|
29
|
+
});
|
|
30
|
+
it('should wrap emoji in styled container', () => {
|
|
31
|
+
const result = wrapAvatarHtml('🎉');
|
|
32
|
+
expect(result).toContain('display: flex');
|
|
33
|
+
expect(result).toContain('🎉');
|
|
34
|
+
});
|
|
35
|
+
});
|
package/dist/types/index.d.ts
CHANGED
|
@@ -44,9 +44,13 @@ export function resolveAuthorConfig(author, userAuthors) {
|
|
|
44
44
|
const config = AUTHOR_CONFIG[author];
|
|
45
45
|
const firstChar = author.charAt(0);
|
|
46
46
|
return {
|
|
47
|
-
...(config || {
|
|
47
|
+
...(config || {
|
|
48
|
+
bubbleColor: THEME.gray[50],
|
|
49
|
+
textColor: THEME.gray[900],
|
|
50
|
+
side: 'left',
|
|
51
|
+
}),
|
|
48
52
|
avatar: generateLetterAvatar(firstChar),
|
|
49
|
-
isCustomAvatar: false
|
|
53
|
+
isCustomAvatar: false,
|
|
50
54
|
};
|
|
51
55
|
}
|
|
52
56
|
// 4. Built-in exact match
|
|
@@ -66,6 +70,6 @@ export function resolveAuthorConfig(author, userAuthors) {
|
|
|
66
70
|
bubbleColor: THEME.gray[50],
|
|
67
71
|
textColor: THEME.gray[900],
|
|
68
72
|
side: 'left',
|
|
69
|
-
isCustomAvatar: false
|
|
73
|
+
isCustomAvatar: false,
|
|
70
74
|
};
|
|
71
75
|
}
|
package/dist/utils/avatar.js
CHANGED
|
@@ -4,14 +4,14 @@ import { THEME } from '../const/theme.js';
|
|
|
4
4
|
*/
|
|
5
5
|
export function generateLetterAvatar(letter) {
|
|
6
6
|
return `<div style="
|
|
7
|
-
width: 100%;
|
|
8
|
-
height: 100%;
|
|
9
|
-
display: flex;
|
|
10
|
-
align-items: center;
|
|
11
|
-
justify-content: center;
|
|
12
|
-
background: #ffffff;
|
|
13
|
-
color: ${THEME.gray[600]};
|
|
14
|
-
font-size: 14px;
|
|
7
|
+
width: 100%;
|
|
8
|
+
height: 100%;
|
|
9
|
+
display: flex;
|
|
10
|
+
align-items: center;
|
|
11
|
+
justify-content: center;
|
|
12
|
+
background: var(--bb-avatar-bg, #ffffff);
|
|
13
|
+
color: var(--bb-avatar-color, ${THEME.gray[600]});
|
|
14
|
+
font-size: 14px;
|
|
15
15
|
font-weight: 600;
|
|
16
16
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, monospace;
|
|
17
17
|
">${letter}</div>`;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { THEME } from '../const/theme.js';
|
|
1
2
|
import { escapeHtml } from './html.js';
|
|
2
3
|
/**
|
|
3
4
|
* Build avatar HTML string
|
|
@@ -18,17 +19,27 @@ export function buildMessageRowHtml(author, text, config, isSubsequent) {
|
|
|
18
19
|
const showAvatar = !isSubsequent;
|
|
19
20
|
const side = config.side;
|
|
20
21
|
const avatarHtml = buildAvatarHtml(author, config, showAvatar);
|
|
22
|
+
// Build inline style only for custom colors (not defaults)
|
|
23
|
+
const isDefaultBubbleColor = config.bubbleColor === THEME.gray[50];
|
|
24
|
+
const isDefaultTextColor = config.textColor === THEME.gray[900];
|
|
25
|
+
const inlineStyles = [];
|
|
26
|
+
if (!isDefaultBubbleColor) {
|
|
27
|
+
inlineStyles.push(`background-color: ${config.bubbleColor}`);
|
|
28
|
+
}
|
|
29
|
+
if (!isDefaultTextColor) {
|
|
30
|
+
inlineStyles.push(`color: ${config.textColor}`);
|
|
31
|
+
}
|
|
32
|
+
const styleAttr = inlineStyles.length > 0 ? ` style="${inlineStyles.join('; ')}"` : '';
|
|
21
33
|
return `
|
|
22
34
|
<div class="msg-row msg-row--${side} ${isSubsequent ? 'msg-row--subsequent' : 'msg-row--new-author'}">
|
|
23
35
|
${side === 'left' ? avatarHtml : ''}
|
|
24
|
-
|
|
36
|
+
|
|
25
37
|
<div class="msg-content">
|
|
26
|
-
<div class="msg-bubble msg-bubble--${side}"
|
|
27
|
-
style="background-color: ${config.bubbleColor}; color: ${config.textColor};">
|
|
38
|
+
<div class="msg-bubble msg-bubble--${side}"${styleAttr}>
|
|
28
39
|
${escapeHtml(text)}
|
|
29
40
|
</div>
|
|
30
41
|
</div>
|
|
31
|
-
|
|
42
|
+
|
|
32
43
|
${side === 'right' ? avatarHtml : ''}
|
|
33
44
|
</div>
|
|
34
45
|
`;
|
|
@@ -4,10 +4,8 @@ import { FALLBACK_STYLES } from '../const/styles.js';
|
|
|
4
4
|
*/
|
|
5
5
|
export function define(BBMsgHistoryClass, tagName = 'bb-msg-history') {
|
|
6
6
|
if (!customElements.get(tagName)) {
|
|
7
|
-
customElements.define(tagName, tagName === 'bb-msg-history'
|
|
8
|
-
|
|
9
|
-
: class extends BBMsgHistoryClass {
|
|
10
|
-
});
|
|
7
|
+
customElements.define(tagName, tagName === 'bb-msg-history' ? BBMsgHistoryClass : class extends BBMsgHistoryClass {
|
|
8
|
+
});
|
|
11
9
|
}
|
|
12
10
|
}
|
|
13
11
|
/**
|
|
@@ -18,6 +16,7 @@ export function initBBMsgHistory(BBMsgHistoryClass) {
|
|
|
18
16
|
define(BBMsgHistoryClass);
|
|
19
17
|
}
|
|
20
18
|
catch (error) {
|
|
19
|
+
// eslint-disable-next-line no-console
|
|
21
20
|
console.warn('BBMsgHistory registration failed, falling back to plain text:', error);
|
|
22
21
|
document.querySelectorAll('bb-msg-history').forEach(el => {
|
|
23
22
|
const pre = document.createElement('pre');
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build scroll-to-bottom button HTML string
|
|
3
|
+
*/
|
|
4
|
+
export function buildScrollButtonHtml() {
|
|
5
|
+
return `
|
|
6
|
+
<button class="scroll-to-bottom" aria-label="Scroll to bottom" title="Scroll to bottom">
|
|
7
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
8
|
+
<polyline points="6 9 12 15 18 9"></polyline>
|
|
9
|
+
</svg>
|
|
10
|
+
</button>
|
|
11
|
+
`;
|
|
12
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bbki.ng/bb-msg-history",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "A chat-style message history web component",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"type": "module",
|
|
@@ -23,16 +23,41 @@
|
|
|
23
23
|
"dist",
|
|
24
24
|
"src"
|
|
25
25
|
],
|
|
26
|
-
"
|
|
27
|
-
"
|
|
28
|
-
|
|
29
|
-
|
|
26
|
+
"lint-staged": {
|
|
27
|
+
"*.ts": [
|
|
28
|
+
"eslint --fix",
|
|
29
|
+
"prettier --write"
|
|
30
|
+
]
|
|
30
31
|
},
|
|
31
32
|
"publishConfig": {
|
|
32
33
|
"access": "public"
|
|
33
34
|
},
|
|
34
35
|
"devDependencies": {
|
|
36
|
+
"@eslint/js": "^9.21.0",
|
|
37
|
+
"@release-it/conventional-changelog": "^10.0.0",
|
|
38
|
+
"@typescript-eslint/eslint-plugin": "^8.25.0",
|
|
39
|
+
"@typescript-eslint/parser": "^8.25.0",
|
|
40
|
+
"eslint": "^9.21.0",
|
|
41
|
+
"globals": "^16.0.0",
|
|
42
|
+
"happy-dom": "^17.1.0",
|
|
43
|
+
"husky": "^9.1.7",
|
|
44
|
+
"lint-staged": "^15.4.3",
|
|
45
|
+
"prettier": "^3.5.2",
|
|
46
|
+
"release-it": "^18.0.0",
|
|
35
47
|
"terser": "^5.46.0",
|
|
36
|
-
"typescript": "^5.9.3"
|
|
48
|
+
"typescript": "^5.9.3",
|
|
49
|
+
"vitest": "^3.2.4"
|
|
50
|
+
},
|
|
51
|
+
"scripts": {
|
|
52
|
+
"start": "tsc -w",
|
|
53
|
+
"preview": "python3 -m http.server 8000",
|
|
54
|
+
"build": "tsc && cp dist/index.js dist/index.dev.js && terser dist/index.js --compress --mangle --source-map -o dist/index.js",
|
|
55
|
+
"lint": "eslint src/**/*.ts",
|
|
56
|
+
"lint:fix": "eslint src/**/*.ts --fix",
|
|
57
|
+
"format": "prettier --write \"src/**/*.ts\"",
|
|
58
|
+
"format:check": "prettier --check \"src/**/*.ts\"",
|
|
59
|
+
"test": "vitest run",
|
|
60
|
+
"test:watch": "vitest",
|
|
61
|
+
"release": "release-it"
|
|
37
62
|
}
|
|
38
|
-
}
|
|
63
|
+
}
|
package/src/component.ts
CHANGED
|
@@ -4,17 +4,27 @@ import { parseMessages } from './utils/message-parser.js';
|
|
|
4
4
|
import { resolveAuthorConfig } from './utils/author-resolver.js';
|
|
5
5
|
import { setupTooltips } from './utils/tooltip.js';
|
|
6
6
|
import { buildMessageRowHtml, setupTooltipForElement } from './utils/message-builder.js';
|
|
7
|
+
import { buildScrollButtonHtml } from './utils/scroll-button.js';
|
|
7
8
|
|
|
8
9
|
export class BBMsgHistory extends HTMLElement {
|
|
9
10
|
private _mutationObserver?: MutationObserver;
|
|
10
11
|
private _userAuthors = new Map<string, AuthorOptions>();
|
|
11
12
|
private _lastAuthor = '';
|
|
13
|
+
private _scrollButtonVisible = false;
|
|
14
|
+
|
|
15
|
+
static get observedAttributes() {
|
|
16
|
+
return ['theme'];
|
|
17
|
+
}
|
|
12
18
|
|
|
13
19
|
constructor() {
|
|
14
20
|
super();
|
|
15
21
|
this.attachShadow({ mode: 'open' });
|
|
16
22
|
}
|
|
17
23
|
|
|
24
|
+
attributeChangedCallback() {
|
|
25
|
+
this.render();
|
|
26
|
+
}
|
|
27
|
+
|
|
18
28
|
/**
|
|
19
29
|
* Configure an author's avatar, side, and colors.
|
|
20
30
|
* Call before or after rendering — the component re-renders automatically.
|
|
@@ -51,22 +61,22 @@ export class BBMsgHistory extends HTMLElement {
|
|
|
51
61
|
const currentText = this.textContent || '';
|
|
52
62
|
const separator = currentText && !currentText.endsWith('\n') ? '\n' : '';
|
|
53
63
|
this.textContent = currentText + separator + `${message.author}: ${message.text}`;
|
|
54
|
-
|
|
64
|
+
|
|
55
65
|
// Temporarily disconnect observer to prevent recursive render
|
|
56
66
|
this._mutationObserver?.disconnect();
|
|
57
|
-
|
|
67
|
+
|
|
58
68
|
// Append single message without re-rendering entire list
|
|
59
69
|
this._appendSingleMessage(message);
|
|
60
|
-
|
|
70
|
+
|
|
61
71
|
// Reconnect observer
|
|
62
72
|
this._setupMutationObserver();
|
|
63
|
-
|
|
73
|
+
|
|
64
74
|
return this;
|
|
65
75
|
}
|
|
66
76
|
|
|
67
77
|
private _appendSingleMessage(message: Message): void {
|
|
68
78
|
const container = this.shadowRoot!.querySelector('.history') as HTMLElement;
|
|
69
|
-
|
|
79
|
+
|
|
70
80
|
// If empty state or no container, do full render first
|
|
71
81
|
if (!container) {
|
|
72
82
|
this.render();
|
|
@@ -78,26 +88,33 @@ export class BBMsgHistory extends HTMLElement {
|
|
|
78
88
|
const config = resolveAuthorConfig(author, this._userAuthors);
|
|
79
89
|
const isFirstFromAuthor = author !== this._lastAuthor;
|
|
80
90
|
this._lastAuthor = author;
|
|
81
|
-
|
|
91
|
+
|
|
82
92
|
const isSubsequent = !isFirstFromAuthor;
|
|
83
|
-
|
|
93
|
+
|
|
84
94
|
// Use utility function to build message HTML
|
|
85
95
|
const msgHtml = buildMessageRowHtml(author, text, config, isSubsequent);
|
|
86
|
-
|
|
96
|
+
|
|
87
97
|
// Append to container
|
|
88
98
|
container.insertAdjacentHTML('beforeend', msgHtml);
|
|
89
|
-
|
|
99
|
+
|
|
90
100
|
// Setup tooltip for new element using utility function
|
|
91
101
|
const newWrapper = container.lastElementChild?.querySelector('.avatar-wrapper');
|
|
92
102
|
if (newWrapper) {
|
|
93
103
|
setupTooltipForElement(newWrapper);
|
|
94
104
|
}
|
|
95
|
-
|
|
105
|
+
|
|
96
106
|
// Smooth scroll to bottom
|
|
97
107
|
container.scrollTo({
|
|
98
108
|
top: container.scrollHeight,
|
|
99
|
-
behavior: 'smooth'
|
|
109
|
+
behavior: 'smooth',
|
|
100
110
|
});
|
|
111
|
+
|
|
112
|
+
// Hide scroll button since we're scrolling to bottom
|
|
113
|
+
const scrollButton = this.shadowRoot!.querySelector('.scroll-to-bottom') as HTMLButtonElement;
|
|
114
|
+
if (scrollButton && this._scrollButtonVisible) {
|
|
115
|
+
this._scrollButtonVisible = false;
|
|
116
|
+
scrollButton.classList.remove('visible');
|
|
117
|
+
}
|
|
101
118
|
}
|
|
102
119
|
|
|
103
120
|
connectedCallback() {
|
|
@@ -124,7 +141,7 @@ export class BBMsgHistory extends HTMLElement {
|
|
|
124
141
|
|
|
125
142
|
private render() {
|
|
126
143
|
const messages = parseMessages(this.textContent);
|
|
127
|
-
|
|
144
|
+
|
|
128
145
|
if (messages.length === 0) {
|
|
129
146
|
this._lastAuthor = '';
|
|
130
147
|
this._renderEmpty();
|
|
@@ -138,7 +155,7 @@ export class BBMsgHistory extends HTMLElement {
|
|
|
138
155
|
const isFirstFromAuthor = author !== lastAuthor;
|
|
139
156
|
lastAuthor = author;
|
|
140
157
|
const isSubsequent = !isFirstFromAuthor;
|
|
141
|
-
|
|
158
|
+
|
|
142
159
|
// Use utility function to build message HTML
|
|
143
160
|
return buildMessageRowHtml(author, text, config, isSubsequent);
|
|
144
161
|
})
|
|
@@ -151,12 +168,25 @@ export class BBMsgHistory extends HTMLElement {
|
|
|
151
168
|
<div class="history" role="log" aria-live="polite" aria-label="Message history">
|
|
152
169
|
${messagesHtml}
|
|
153
170
|
</div>
|
|
171
|
+
${buildScrollButtonHtml()}
|
|
154
172
|
`;
|
|
155
173
|
|
|
156
174
|
requestAnimationFrame(() => {
|
|
157
175
|
const container = this.shadowRoot!.querySelector('.history') as HTMLElement;
|
|
176
|
+
const scrollButton = this.shadowRoot!.querySelector('.scroll-to-bottom') as HTMLButtonElement;
|
|
177
|
+
|
|
158
178
|
if (container) {
|
|
159
179
|
container.scrollTop = container.scrollHeight;
|
|
180
|
+
this._setupScrollTracking(container, scrollButton);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (scrollButton) {
|
|
184
|
+
scrollButton.addEventListener('click', () => {
|
|
185
|
+
container?.scrollTo({
|
|
186
|
+
top: container.scrollHeight,
|
|
187
|
+
behavior: 'smooth',
|
|
188
|
+
});
|
|
189
|
+
});
|
|
160
190
|
}
|
|
161
191
|
|
|
162
192
|
setupTooltips(this.shadowRoot!);
|
|
@@ -169,4 +199,28 @@ export class BBMsgHistory extends HTMLElement {
|
|
|
169
199
|
<div class="empty-state">No messages</div>
|
|
170
200
|
`;
|
|
171
201
|
}
|
|
202
|
+
|
|
203
|
+
private _setupScrollTracking(container: HTMLElement, button: HTMLButtonElement): void {
|
|
204
|
+
const checkScrollPosition = () => {
|
|
205
|
+
const threshold = 50; // pixels from bottom
|
|
206
|
+
const isAtBottom =
|
|
207
|
+
container.scrollHeight - container.scrollTop - container.clientHeight < threshold;
|
|
208
|
+
const hasOverflow = container.scrollHeight > container.clientHeight;
|
|
209
|
+
const shouldShow = !isAtBottom && hasOverflow;
|
|
210
|
+
|
|
211
|
+
if (shouldShow !== this._scrollButtonVisible) {
|
|
212
|
+
this._scrollButtonVisible = shouldShow;
|
|
213
|
+
button.classList.toggle('visible', shouldShow);
|
|
214
|
+
}
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
// Check initial state
|
|
218
|
+
checkScrollPosition();
|
|
219
|
+
|
|
220
|
+
// Listen for scroll events with passive listener for performance
|
|
221
|
+
container.addEventListener('scroll', checkScrollPosition, { passive: true });
|
|
222
|
+
|
|
223
|
+
// Also check on resize
|
|
224
|
+
window.addEventListener('resize', checkScrollPosition, { passive: true });
|
|
225
|
+
}
|
|
172
226
|
}
|