@bbki.ng/bb-msg-history 0.14.1 → 1.0.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 +6 -18
- package/dist/component.js +43 -275
- package/dist/const/authors.js +1 -1
- package/dist/core/message-processor.d.ts +56 -0
- package/dist/core/message-processor.js +85 -0
- package/dist/core/renderer.d.ts +87 -0
- package/dist/core/renderer.js +204 -0
- package/dist/core/scroll-manager.d.ts +54 -0
- package/dist/core/scroll-manager.js +119 -0
- package/dist/utils/event-tracker.d.ts +23 -0
- package/dist/utils/event-tracker.js +33 -0
- package/package.json +1 -1
- package/src/component.ts +56 -338
- package/src/const/authors.ts +3 -2
- package/src/core/message-processor.ts +120 -0
- package/src/core/renderer.ts +286 -0
- package/src/core/scroll-manager.ts +148 -0
- package/src/utils/event-tracker.ts +38 -0
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { EMPTY_STYLES, LOADING_STYLES, MAIN_STYLES } from '../const/styles.js';
|
|
2
|
+
import { resolveAuthorConfig } from '../utils/author-resolver.js';
|
|
3
|
+
import { buildMessageRowHtml, setupTooltipForElement } from '../utils/message-builder.js';
|
|
4
|
+
import { buildScrollButtonHtml } from '../utils/scroll-button.js';
|
|
5
|
+
import { setupTooltips } from '../utils/tooltip.js';
|
|
6
|
+
/**
|
|
7
|
+
* Renderer - Manages all DOM rendering operations
|
|
8
|
+
*
|
|
9
|
+
* Centralizes DOM manipulation logic:
|
|
10
|
+
* - Full render of all messages
|
|
11
|
+
* - Incremental update when appending single messages
|
|
12
|
+
* - Empty state rendering
|
|
13
|
+
* - Loading overlay management
|
|
14
|
+
*/
|
|
15
|
+
export class Renderer {
|
|
16
|
+
constructor(shadowRoot) {
|
|
17
|
+
this.shadowRoot = shadowRoot;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Render the complete message history
|
|
21
|
+
*
|
|
22
|
+
* @param messages - Processed messages with grouping metadata
|
|
23
|
+
* @param authors - User-defined author configurations
|
|
24
|
+
* @param isLoading - Whether to show loading overlay
|
|
25
|
+
* @param hideScrollButton - Whether to hide the scroll-to-bottom button
|
|
26
|
+
* @returns Result indicating if we were at bottom before render
|
|
27
|
+
*/
|
|
28
|
+
render(messages, authors, isLoading, hideScrollButton) {
|
|
29
|
+
// Check if we need to create or update the structure
|
|
30
|
+
const historyContainer = this.shadowRoot.querySelector('.history');
|
|
31
|
+
const needsFullSetup = !historyContainer;
|
|
32
|
+
// Build messages HTML
|
|
33
|
+
const messagesHtml = this.buildMessagesHtml(messages, authors);
|
|
34
|
+
if (needsFullSetup) {
|
|
35
|
+
// First render - create full structure
|
|
36
|
+
return this.renderFullStructure(messagesHtml, isLoading, hideScrollButton);
|
|
37
|
+
}
|
|
38
|
+
else {
|
|
39
|
+
// Update only - preserve DOM structure
|
|
40
|
+
return this.updateContent(historyContainer, messagesHtml, isLoading);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Build HTML string for all messages
|
|
45
|
+
*/
|
|
46
|
+
buildMessagesHtml(messages, authors) {
|
|
47
|
+
return messages
|
|
48
|
+
.map(msg => {
|
|
49
|
+
const config = resolveAuthorConfig(msg.author, authors);
|
|
50
|
+
return buildMessageRowHtml(msg.author, msg.text, config, !msg.isFirstFromAuthor, // isSubsequent
|
|
51
|
+
msg.groupTimestamp, msg.isLastInGroup);
|
|
52
|
+
})
|
|
53
|
+
.join('');
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Render full structure including styles, container, and scroll button
|
|
57
|
+
*/
|
|
58
|
+
renderFullStructure(messagesHtml, isLoading, hideScrollButton) {
|
|
59
|
+
// For initial render, we consider it as "was at bottom" to scroll down
|
|
60
|
+
const wasAtBottom = true;
|
|
61
|
+
const loadingOverlay = isLoading
|
|
62
|
+
? `<div class="loading-overlay" role="status" aria-label="Loading messages">
|
|
63
|
+
<div class="loading-spinner"></div>
|
|
64
|
+
</div>`
|
|
65
|
+
: '';
|
|
66
|
+
this.shadowRoot.innerHTML = `
|
|
67
|
+
<style>${MAIN_STYLES}${LOADING_STYLES}</style>
|
|
68
|
+
<div class="history" role="log" aria-live="polite" aria-label="Message history">
|
|
69
|
+
${messagesHtml}
|
|
70
|
+
</div>
|
|
71
|
+
${hideScrollButton ? '' : buildScrollButtonHtml()}
|
|
72
|
+
${loadingOverlay}
|
|
73
|
+
`;
|
|
74
|
+
return { wasAtBottom };
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Update content while preserving DOM structure
|
|
78
|
+
*/
|
|
79
|
+
updateContent(historyContainer, messagesHtml, isLoading) {
|
|
80
|
+
// Check scroll position before update
|
|
81
|
+
const scrollContainer = historyContainer;
|
|
82
|
+
const wasAtBottom = scrollContainer.scrollHeight - scrollContainer.scrollTop - scrollContainer.clientHeight < 50;
|
|
83
|
+
// Update messages content only
|
|
84
|
+
historyContainer.innerHTML = messagesHtml;
|
|
85
|
+
// Update loading overlay
|
|
86
|
+
this.updateLoadingOverlay(isLoading);
|
|
87
|
+
// Restore scroll position or scroll to bottom if we were there
|
|
88
|
+
if (wasAtBottom) {
|
|
89
|
+
scrollContainer.scrollTop = scrollContainer.scrollHeight;
|
|
90
|
+
}
|
|
91
|
+
// Re-setup tooltips for new content
|
|
92
|
+
setupTooltips(this.shadowRoot);
|
|
93
|
+
return { wasAtBottom };
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Append a single message without full re-render
|
|
97
|
+
*
|
|
98
|
+
* @param message - The message to append
|
|
99
|
+
* @param authors - User-defined author configurations
|
|
100
|
+
* @param lastState - Previous state for grouping logic
|
|
101
|
+
* @returns Result indicating success and updated state
|
|
102
|
+
*/
|
|
103
|
+
appendSingleMessage(message, authors, lastState) {
|
|
104
|
+
const container = this.shadowRoot.querySelector('.history');
|
|
105
|
+
// If empty state or no container, signal that full render is needed
|
|
106
|
+
if (!container) {
|
|
107
|
+
return { success: false, lastAuthor: lastState.author };
|
|
108
|
+
}
|
|
109
|
+
const config = resolveAuthorConfig(message.author, authors);
|
|
110
|
+
// Determine grouping
|
|
111
|
+
const prevMessage = lastState.author
|
|
112
|
+
? { author: lastState.author, text: '', timestamp: lastState.groupTimestamp }
|
|
113
|
+
: null;
|
|
114
|
+
const canGroupWithLast = this.canGroupMessages(prevMessage, message);
|
|
115
|
+
const isFirstFromAuthor = !canGroupWithLast;
|
|
116
|
+
// Calculate new state
|
|
117
|
+
let lastGroupTimestamp = lastState.groupTimestamp;
|
|
118
|
+
if (isFirstFromAuthor) {
|
|
119
|
+
lastGroupTimestamp = message.timestamp;
|
|
120
|
+
}
|
|
121
|
+
else if (!lastGroupTimestamp && message.timestamp) {
|
|
122
|
+
lastGroupTimestamp = message.timestamp;
|
|
123
|
+
}
|
|
124
|
+
// Build and append HTML
|
|
125
|
+
const msgHtml = buildMessageRowHtml(message.author, message.text, config, !isFirstFromAuthor, // isSubsequent
|
|
126
|
+
lastGroupTimestamp, true // isLastInGroup - when appending, this is always last (for now)
|
|
127
|
+
);
|
|
128
|
+
container.insertAdjacentHTML('beforeend', msgHtml);
|
|
129
|
+
// Setup tooltip for new element
|
|
130
|
+
const newWrapper = container.lastElementChild?.querySelector('.avatar-wrapper');
|
|
131
|
+
if (newWrapper) {
|
|
132
|
+
setupTooltipForElement(newWrapper);
|
|
133
|
+
}
|
|
134
|
+
return {
|
|
135
|
+
success: true,
|
|
136
|
+
lastAuthor: message.author,
|
|
137
|
+
lastGroupTimestamp,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Check if two messages can be grouped
|
|
142
|
+
*/
|
|
143
|
+
canGroupMessages(prev, curr) {
|
|
144
|
+
if (!prev)
|
|
145
|
+
return false;
|
|
146
|
+
if (prev.author !== curr.author)
|
|
147
|
+
return false;
|
|
148
|
+
if (prev.timestamp && curr.timestamp && prev.timestamp !== curr.timestamp) {
|
|
149
|
+
return false;
|
|
150
|
+
}
|
|
151
|
+
return true;
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Render empty state (no messages)
|
|
155
|
+
*/
|
|
156
|
+
renderEmpty(isLoading) {
|
|
157
|
+
if (isLoading) {
|
|
158
|
+
// Show loading overlay with minimum height
|
|
159
|
+
this.shadowRoot.innerHTML = `
|
|
160
|
+
<style>${EMPTY_STYLES}${LOADING_STYLES}</style>
|
|
161
|
+
<div style="position: relative; min-height: 120px;">
|
|
162
|
+
<div class="loading-overlay" role="status" aria-label="Loading messages">
|
|
163
|
+
<div class="loading-spinner"></div>
|
|
164
|
+
</div>
|
|
165
|
+
</div>
|
|
166
|
+
`;
|
|
167
|
+
}
|
|
168
|
+
else {
|
|
169
|
+
this.shadowRoot.innerHTML = `
|
|
170
|
+
<style>${EMPTY_STYLES}</style>
|
|
171
|
+
<div class="empty-state">No messages</div>
|
|
172
|
+
`;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Update loading overlay visibility
|
|
177
|
+
*/
|
|
178
|
+
updateLoadingOverlay(shouldShow) {
|
|
179
|
+
const existingOverlay = this.shadowRoot.querySelector('.loading-overlay');
|
|
180
|
+
if (shouldShow && !existingOverlay) {
|
|
181
|
+
const overlay = document.createElement('div');
|
|
182
|
+
overlay.className = 'loading-overlay';
|
|
183
|
+
overlay.setAttribute('role', 'status');
|
|
184
|
+
overlay.setAttribute('aria-label', 'Loading messages');
|
|
185
|
+
overlay.innerHTML = '<div class="loading-spinner"></div>';
|
|
186
|
+
this.shadowRoot.appendChild(overlay);
|
|
187
|
+
}
|
|
188
|
+
else if (!shouldShow && existingOverlay) {
|
|
189
|
+
existingOverlay.remove();
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* Get the history container element
|
|
194
|
+
*/
|
|
195
|
+
getHistoryContainer() {
|
|
196
|
+
return this.shadowRoot.querySelector('.history');
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* Get the scroll-to-bottom button element
|
|
200
|
+
*/
|
|
201
|
+
getScrollButton() {
|
|
202
|
+
return this.shadowRoot.querySelector('.scroll-to-bottom');
|
|
203
|
+
}
|
|
204
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { EventTracker } from '../utils/event-tracker.js';
|
|
2
|
+
/**
|
|
3
|
+
* ScrollManager - Handles scroll behavior, position detection, and scroll button visibility
|
|
4
|
+
*
|
|
5
|
+
* Isolates all scroll-related logic from the main component:
|
|
6
|
+
* - Scroll to bottom with smooth animation
|
|
7
|
+
* - Detect when user is near/at bottom of content
|
|
8
|
+
* - Control scroll button visibility based on scroll position
|
|
9
|
+
* - Dispatch custom events for scroll button state changes
|
|
10
|
+
*/
|
|
11
|
+
export declare class ScrollManager {
|
|
12
|
+
private host;
|
|
13
|
+
private shadowRoot;
|
|
14
|
+
private eventTracker;
|
|
15
|
+
private onVisibilityChange;
|
|
16
|
+
private container?;
|
|
17
|
+
private button?;
|
|
18
|
+
private isButtonVisible;
|
|
19
|
+
private readonly BOTTOM_THRESHOLD;
|
|
20
|
+
constructor(host: HTMLElement, shadowRoot: ShadowRoot, eventTracker: EventTracker, onVisibilityChange: (visible: boolean) => void);
|
|
21
|
+
/**
|
|
22
|
+
* Initialize scroll tracking on the container
|
|
23
|
+
*
|
|
24
|
+
* @param container - The scrollable history container
|
|
25
|
+
* @param button - Optional scroll-to-bottom button element
|
|
26
|
+
* @param skipInitialCheck - If true, skip the initial position check (for initial render)
|
|
27
|
+
*/
|
|
28
|
+
init(container: HTMLElement, button?: HTMLButtonElement | null, skipInitialCheck?: boolean): void;
|
|
29
|
+
/**
|
|
30
|
+
* Check current scroll position and update button visibility
|
|
31
|
+
* Dispatches custom events when visibility changes
|
|
32
|
+
*/
|
|
33
|
+
checkPosition(): void;
|
|
34
|
+
/**
|
|
35
|
+
* Scroll the container to the bottom
|
|
36
|
+
*
|
|
37
|
+
* @param behavior - Scroll behavior: 'smooth' or 'auto'
|
|
38
|
+
*/
|
|
39
|
+
scrollToBottom(behavior?: ScrollBehavior): void;
|
|
40
|
+
/**
|
|
41
|
+
* Check if the container is currently at or near the bottom
|
|
42
|
+
*
|
|
43
|
+
* @returns true if within threshold pixels of bottom
|
|
44
|
+
*/
|
|
45
|
+
isAtBottom(): boolean;
|
|
46
|
+
/**
|
|
47
|
+
* Get the current button visibility state
|
|
48
|
+
*/
|
|
49
|
+
get isVisible(): boolean;
|
|
50
|
+
/**
|
|
51
|
+
* Get the scrollable container element
|
|
52
|
+
*/
|
|
53
|
+
getContainer(): HTMLElement | undefined;
|
|
54
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ScrollManager - Handles scroll behavior, position detection, and scroll button visibility
|
|
3
|
+
*
|
|
4
|
+
* Isolates all scroll-related logic from the main component:
|
|
5
|
+
* - Scroll to bottom with smooth animation
|
|
6
|
+
* - Detect when user is near/at bottom of content
|
|
7
|
+
* - Control scroll button visibility based on scroll position
|
|
8
|
+
* - Dispatch custom events for scroll button state changes
|
|
9
|
+
*/
|
|
10
|
+
export class ScrollManager {
|
|
11
|
+
constructor(host, shadowRoot, eventTracker, onVisibilityChange) {
|
|
12
|
+
this.host = host;
|
|
13
|
+
this.shadowRoot = shadowRoot;
|
|
14
|
+
this.eventTracker = eventTracker;
|
|
15
|
+
this.onVisibilityChange = onVisibilityChange;
|
|
16
|
+
this.isButtonVisible = false;
|
|
17
|
+
// Threshold in pixels from bottom to consider "at bottom"
|
|
18
|
+
this.BOTTOM_THRESHOLD = 50;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Initialize scroll tracking on the container
|
|
22
|
+
*
|
|
23
|
+
* @param container - The scrollable history container
|
|
24
|
+
* @param button - Optional scroll-to-bottom button element
|
|
25
|
+
* @param skipInitialCheck - If true, skip the initial position check (for initial render)
|
|
26
|
+
*/
|
|
27
|
+
init(container, button, skipInitialCheck = false) {
|
|
28
|
+
this.container = container;
|
|
29
|
+
this.button = button ?? null;
|
|
30
|
+
if (!skipInitialCheck) {
|
|
31
|
+
this.checkPosition();
|
|
32
|
+
}
|
|
33
|
+
// Listen for scroll events with passive listener for performance
|
|
34
|
+
this.eventTracker.add(container, 'scroll', () => this.checkPosition(), { passive: true });
|
|
35
|
+
// Also check on resize
|
|
36
|
+
this.eventTracker.add(window, 'resize', () => this.checkPosition());
|
|
37
|
+
// Setup button click handler
|
|
38
|
+
if (button && !this.host.hasAttribute('infinite')) {
|
|
39
|
+
button.addEventListener('click', () => this.scrollToBottom());
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Check current scroll position and update button visibility
|
|
44
|
+
* Dispatches custom events when visibility changes
|
|
45
|
+
*/
|
|
46
|
+
checkPosition() {
|
|
47
|
+
if (!this.container)
|
|
48
|
+
return;
|
|
49
|
+
const isAtBottom = this.isAtBottom();
|
|
50
|
+
const hasOverflow = this.container.scrollHeight > this.container.clientHeight;
|
|
51
|
+
// Show button when not at bottom and content has overflow
|
|
52
|
+
const shouldShow = !isAtBottom && hasOverflow;
|
|
53
|
+
if (shouldShow !== this.isButtonVisible) {
|
|
54
|
+
this.isButtonVisible = shouldShow;
|
|
55
|
+
// Update button UI
|
|
56
|
+
if (this.button) {
|
|
57
|
+
this.button.classList.toggle('visible', shouldShow);
|
|
58
|
+
}
|
|
59
|
+
// Notify parent component
|
|
60
|
+
this.onVisibilityChange(shouldShow);
|
|
61
|
+
// Dispatch custom event
|
|
62
|
+
this.host.dispatchEvent(new CustomEvent(shouldShow ? 'bb-scrollbuttonshow' : 'bb-scrollbuttonhide', {
|
|
63
|
+
bubbles: true,
|
|
64
|
+
composed: true,
|
|
65
|
+
detail: { visible: shouldShow },
|
|
66
|
+
}));
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Scroll the container to the bottom
|
|
71
|
+
*
|
|
72
|
+
* @param behavior - Scroll behavior: 'smooth' or 'auto'
|
|
73
|
+
*/
|
|
74
|
+
scrollToBottom(behavior = 'smooth') {
|
|
75
|
+
if (!this.container || this.host.hasAttribute('infinite')) {
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
this.container.scrollTo({
|
|
79
|
+
top: this.container.scrollHeight,
|
|
80
|
+
behavior,
|
|
81
|
+
});
|
|
82
|
+
// Hide button since we're scrolling to bottom
|
|
83
|
+
if (this.isButtonVisible) {
|
|
84
|
+
this.isButtonVisible = false;
|
|
85
|
+
if (this.button) {
|
|
86
|
+
this.button.classList.remove('visible');
|
|
87
|
+
}
|
|
88
|
+
this.onVisibilityChange(false);
|
|
89
|
+
this.host.dispatchEvent(new CustomEvent('bb-scrollbuttonhide', {
|
|
90
|
+
bubbles: true,
|
|
91
|
+
composed: true,
|
|
92
|
+
detail: { visible: false },
|
|
93
|
+
}));
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Check if the container is currently at or near the bottom
|
|
98
|
+
*
|
|
99
|
+
* @returns true if within threshold pixels of bottom
|
|
100
|
+
*/
|
|
101
|
+
isAtBottom() {
|
|
102
|
+
if (!this.container)
|
|
103
|
+
return true;
|
|
104
|
+
const distanceFromBottom = this.container.scrollHeight - this.container.scrollTop - this.container.clientHeight;
|
|
105
|
+
return distanceFromBottom < this.BOTTOM_THRESHOLD;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Get the current button visibility state
|
|
109
|
+
*/
|
|
110
|
+
get isVisible() {
|
|
111
|
+
return this.isButtonVisible;
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Get the scrollable container element
|
|
115
|
+
*/
|
|
116
|
+
getContainer() {
|
|
117
|
+
return this.container;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* EventTracker - Utility class for tracking and managing event listeners
|
|
3
|
+
*
|
|
4
|
+
* Provides centralized event listener registration and cleanup,
|
|
5
|
+
* preventing memory leaks by ensuring all listeners are removed when no longer needed.
|
|
6
|
+
*/
|
|
7
|
+
export declare class EventTracker {
|
|
8
|
+
private listeners;
|
|
9
|
+
/**
|
|
10
|
+
* Add an event listener and track it for automatic cleanup
|
|
11
|
+
*
|
|
12
|
+
* @param el - Event target element
|
|
13
|
+
* @param type - Event type (e.g., 'scroll', 'click')
|
|
14
|
+
* @param fn - Event listener function
|
|
15
|
+
* @param options - Optional addEventListener options
|
|
16
|
+
*/
|
|
17
|
+
add(el: EventTarget, type: string, fn: EventListener, options?: AddEventListenerOptions): void;
|
|
18
|
+
/**
|
|
19
|
+
* Remove all tracked event listeners
|
|
20
|
+
* Should be called when the component is disconnected to prevent memory leaks
|
|
21
|
+
*/
|
|
22
|
+
cleanup(): void;
|
|
23
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* EventTracker - Utility class for tracking and managing event listeners
|
|
3
|
+
*
|
|
4
|
+
* Provides centralized event listener registration and cleanup,
|
|
5
|
+
* preventing memory leaks by ensuring all listeners are removed when no longer needed.
|
|
6
|
+
*/
|
|
7
|
+
export class EventTracker {
|
|
8
|
+
constructor() {
|
|
9
|
+
this.listeners = [];
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Add an event listener and track it for automatic cleanup
|
|
13
|
+
*
|
|
14
|
+
* @param el - Event target element
|
|
15
|
+
* @param type - Event type (e.g., 'scroll', 'click')
|
|
16
|
+
* @param fn - Event listener function
|
|
17
|
+
* @param options - Optional addEventListener options
|
|
18
|
+
*/
|
|
19
|
+
add(el, type, fn, options) {
|
|
20
|
+
el.addEventListener(type, fn, options);
|
|
21
|
+
this.listeners.push({ el, type, fn, options });
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Remove all tracked event listeners
|
|
25
|
+
* Should be called when the component is disconnected to prevent memory leaks
|
|
26
|
+
*/
|
|
27
|
+
cleanup() {
|
|
28
|
+
this.listeners.forEach(({ el, type, fn, options }) => {
|
|
29
|
+
el.removeEventListener(type, fn, options);
|
|
30
|
+
});
|
|
31
|
+
this.listeners = [];
|
|
32
|
+
}
|
|
33
|
+
}
|