@composy/chat-core 0.0.1

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.
Files changed (123) hide show
  1. package/README.md +123 -0
  2. package/dist/index.js +1868 -0
  3. package/dist/index.js.map +1 -0
  4. package/dist/index.min.js +1868 -0
  5. package/es/constants/dom.d.ts +41 -0
  6. package/es/constants/dom.d.ts.map +1 -0
  7. package/es/constants/dom.js +30 -0
  8. package/es/constants/dom.js.map +1 -0
  9. package/es/core/chat-manager.d.ts +34 -0
  10. package/es/core/chat-manager.d.ts.map +1 -0
  11. package/es/core/chat-manager.js +93 -0
  12. package/es/core/chat-manager.js.map +1 -0
  13. package/es/core/event-emitter.d.ts +48 -0
  14. package/es/core/event-emitter.d.ts.map +1 -0
  15. package/es/core/event-emitter.js +75 -0
  16. package/es/core/event-emitter.js.map +1 -0
  17. package/es/core/mention-engine.d.ts +41 -0
  18. package/es/core/mention-engine.d.ts.map +1 -0
  19. package/es/core/mention-engine.js +117 -0
  20. package/es/core/mention-engine.js.map +1 -0
  21. package/es/index.d.ts +28 -0
  22. package/es/index.d.ts.map +1 -0
  23. package/es/index.js +140 -0
  24. package/es/index.js.map +1 -0
  25. package/es/renderer/dom-renderer.d.ts +79 -0
  26. package/es/renderer/dom-renderer.d.ts.map +1 -0
  27. package/es/renderer/dom-renderer.js +677 -0
  28. package/es/renderer/dom-renderer.js.map +1 -0
  29. package/es/renderer/style-manager.d.ts +58 -0
  30. package/es/renderer/style-manager.d.ts.map +1 -0
  31. package/es/renderer/style-manager.js +641 -0
  32. package/es/renderer/style-manager.js.map +1 -0
  33. package/es/theme/theme-manager.d.ts +33 -0
  34. package/es/theme/theme-manager.d.ts.map +1 -0
  35. package/es/theme/theme-manager.js +70 -0
  36. package/es/theme/theme-manager.js.map +1 -0
  37. package/es/theme/tokens.d.ts +57 -0
  38. package/es/theme/tokens.d.ts.map +1 -0
  39. package/es/theme/tokens.js +108 -0
  40. package/es/theme/tokens.js.map +1 -0
  41. package/es/types/index.d.ts +204 -0
  42. package/es/types/index.d.ts.map +1 -0
  43. package/es/types/index.js +10 -0
  44. package/esm/constants/dom.d.ts +41 -0
  45. package/esm/constants/dom.d.ts.map +1 -0
  46. package/esm/constants/dom.js +30 -0
  47. package/esm/constants/dom.js.map +1 -0
  48. package/esm/core/chat-manager.d.ts +34 -0
  49. package/esm/core/chat-manager.d.ts.map +1 -0
  50. package/esm/core/chat-manager.js +93 -0
  51. package/esm/core/chat-manager.js.map +1 -0
  52. package/esm/core/event-emitter.d.ts +48 -0
  53. package/esm/core/event-emitter.d.ts.map +1 -0
  54. package/esm/core/event-emitter.js +75 -0
  55. package/esm/core/event-emitter.js.map +1 -0
  56. package/esm/core/mention-engine.d.ts +41 -0
  57. package/esm/core/mention-engine.d.ts.map +1 -0
  58. package/esm/core/mention-engine.js +117 -0
  59. package/esm/core/mention-engine.js.map +1 -0
  60. package/esm/index.d.ts +28 -0
  61. package/esm/index.d.ts.map +1 -0
  62. package/esm/index.js +140 -0
  63. package/esm/index.js.map +1 -0
  64. package/esm/renderer/dom-renderer.d.ts +79 -0
  65. package/esm/renderer/dom-renderer.d.ts.map +1 -0
  66. package/esm/renderer/dom-renderer.js +677 -0
  67. package/esm/renderer/dom-renderer.js.map +1 -0
  68. package/esm/renderer/style-manager.d.ts +58 -0
  69. package/esm/renderer/style-manager.d.ts.map +1 -0
  70. package/esm/renderer/style-manager.js +641 -0
  71. package/esm/renderer/style-manager.js.map +1 -0
  72. package/esm/theme/theme-manager.d.ts +33 -0
  73. package/esm/theme/theme-manager.d.ts.map +1 -0
  74. package/esm/theme/theme-manager.js +70 -0
  75. package/esm/theme/theme-manager.js.map +1 -0
  76. package/esm/theme/tokens.d.ts +57 -0
  77. package/esm/theme/tokens.d.ts.map +1 -0
  78. package/esm/theme/tokens.js +108 -0
  79. package/esm/theme/tokens.js.map +1 -0
  80. package/esm/types/index.d.ts +204 -0
  81. package/esm/types/index.d.ts.map +1 -0
  82. package/esm/types/index.js +10 -0
  83. package/lib/constants/dom.cjs +36 -0
  84. package/lib/constants/dom.cjs.map +1 -0
  85. package/lib/constants/dom.d.ts +41 -0
  86. package/lib/constants/dom.d.ts.map +1 -0
  87. package/lib/core/chat-manager.cjs +94 -0
  88. package/lib/core/chat-manager.cjs.map +1 -0
  89. package/lib/core/chat-manager.d.ts +34 -0
  90. package/lib/core/chat-manager.d.ts.map +1 -0
  91. package/lib/core/event-emitter.cjs +76 -0
  92. package/lib/core/event-emitter.cjs.map +1 -0
  93. package/lib/core/event-emitter.d.ts +48 -0
  94. package/lib/core/event-emitter.d.ts.map +1 -0
  95. package/lib/core/mention-engine.cjs +118 -0
  96. package/lib/core/mention-engine.cjs.map +1 -0
  97. package/lib/core/mention-engine.d.ts +41 -0
  98. package/lib/core/mention-engine.d.ts.map +1 -0
  99. package/lib/index.cjs +160 -0
  100. package/lib/index.cjs.map +1 -0
  101. package/lib/index.d.ts +28 -0
  102. package/lib/index.d.ts.map +1 -0
  103. package/lib/package.json +3 -0
  104. package/lib/renderer/dom-renderer.cjs +678 -0
  105. package/lib/renderer/dom-renderer.cjs.map +1 -0
  106. package/lib/renderer/dom-renderer.d.ts +79 -0
  107. package/lib/renderer/dom-renderer.d.ts.map +1 -0
  108. package/lib/renderer/style-manager.cjs +646 -0
  109. package/lib/renderer/style-manager.cjs.map +1 -0
  110. package/lib/renderer/style-manager.d.ts +58 -0
  111. package/lib/renderer/style-manager.d.ts.map +1 -0
  112. package/lib/theme/theme-manager.cjs +71 -0
  113. package/lib/theme/theme-manager.cjs.map +1 -0
  114. package/lib/theme/theme-manager.d.ts +33 -0
  115. package/lib/theme/theme-manager.d.ts.map +1 -0
  116. package/lib/theme/tokens.cjs +110 -0
  117. package/lib/theme/tokens.cjs.map +1 -0
  118. package/lib/theme/tokens.d.ts +57 -0
  119. package/lib/theme/tokens.d.ts.map +1 -0
  120. package/lib/types/index.cjs +11 -0
  121. package/lib/types/index.d.ts +204 -0
  122. package/lib/types/index.d.ts.map +1 -0
  123. package/package.json +107 -0
@@ -0,0 +1,677 @@
1
+ /*!
2
+ * ***********************************
3
+ * @composy/chat-core v0.0.1 *
4
+ * Built with rollup *
5
+ * Build time: 2024-08-17 11:30:32 *
6
+ * Build mode: production *
7
+ * Minified: No *
8
+ * ***********************************
9
+ */
10
+ import { cx, normalizeClassPrefix } from "../constants/dom.js";
11
+ //#region src/renderer/dom-renderer.ts
12
+ const SEND_ICON_SVG = "<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><line x1=\"22\" y1=\"2\" x2=\"11\" y2=\"13\"></line><polygon points=\"22 2 15 22 11 13 2 9 22 2\"></polygon></svg>";
13
+ const ICON_IMAGE = "<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><rect width=\"18\" height=\"18\" x=\"3\" y=\"3\" rx=\"2\" ry=\"2\"/><circle cx=\"9\" cy=\"9\" r=\"2\"/><path d=\"m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21\"/></svg>";
14
+ const ICON_PAPERCLIP = "<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l8.57-8.57A4 4 0 1 1 18 8.84l-8.59 8.57a2 2 0 0 1-2.83-2.83l8.49-8.48\"/></svg>";
15
+ const ICON_MIC = "<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z\"/><path d=\"M19 10v2a7 7 0 0 1-14 0v-2\"/><line x1=\"12\" x2=\"12\" y1=\"19\" y2=\"22\"/></svg>";
16
+ const ICON_MIC_OFF = "<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><line x1=\"2\" x2=\"22\" y1=\"2\" y2=\"22\"/><path d=\"M18.89 13.23A7.12 7.12 0 0 0 19 12v-2\"/><path d=\"M5 10v2a7 7 0 0 0 12 5.29\"/><path d=\"M15 9.34V5a3 3 0 0 0-5.68-1.33\"/><path d=\"M9 9v3a3 3 0 0 0 5.12 2.12\"/><line x1=\"12\" x2=\"12\" y1=\"19\" y2=\"22\"/></svg>";
17
+ const FILE_ICON = "<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z\"/><path d=\"M14 2v4a2 2 0 0 0 2 2h4\"/></svg>";
18
+ const STATUS_MAP = {
19
+ sending: "...",
20
+ sent: "✓",
21
+ delivered: "✓✓",
22
+ read: "✓✓",
23
+ failed: "!"
24
+ };
25
+ var DOMRenderer = class {
26
+ constructor(container, config, emitter, mentionEngine) {
27
+ this.inputArea = null;
28
+ this.textareaEl = null;
29
+ this.sendBtn = null;
30
+ this.mentionPanel = null;
31
+ this.loadMoreEl = null;
32
+ this.toolbarEl = null;
33
+ this.dropOverlay = null;
34
+ this.voiceBtn = null;
35
+ this.loadingMore = false;
36
+ this.isRecording = false;
37
+ this.recognition = null;
38
+ this.lastVoiceTranscript = "";
39
+ this.container = container;
40
+ this.config = config;
41
+ this.emitter = emitter;
42
+ this.mentionEngine = mentionEngine;
43
+ this.prefix = normalizeClassPrefix(config.classPrefix ?? "ldesign-chat");
44
+ }
45
+ init() {
46
+ this.container.innerHTML = "";
47
+ this.root = this.el("div", `${this.prefix}-container`);
48
+ this.messageListEl = this.el("div", `${this.prefix}-message-list`);
49
+ this.root.appendChild(this.messageListEl);
50
+ if (this.config.showInput !== false) this.renderInputArea();
51
+ this.container.appendChild(this.root);
52
+ this.setupEventListeners();
53
+ }
54
+ updateMessageContent(id, content) {
55
+ const bubble = this.messageListEl.querySelector(`[data-msg-id="${this.escapeAttr(id)}"]`)?.querySelector(`.${this.prefix}-bubble`);
56
+ if (!bubble) return;
57
+ bubble.innerHTML = this.renderTextContent(content);
58
+ }
59
+ renderMessages(messages) {
60
+ this.messageListEl.innerHTML = "";
61
+ if (this.config.enableLoadMore) this.renderLoadMoreButton();
62
+ if (messages.length === 0) {
63
+ this.renderEmpty();
64
+ return;
65
+ }
66
+ let lastTimestamp = 0;
67
+ const interval = this.config.timeGroupInterval ?? 3e5;
68
+ for (const message of messages) {
69
+ if (this.config.showTimestamp !== false && message.timestamp - lastTimestamp > interval) {
70
+ this.renderTimeDivider(message.timestamp);
71
+ lastTimestamp = message.timestamp;
72
+ }
73
+ this.renderMessageRow(message);
74
+ }
75
+ }
76
+ appendMessage(message) {
77
+ this.removeEmpty();
78
+ const messageEls = this.getMessageElements();
79
+ const lastTime = messageEls.length > 0 ? Number.parseInt(messageEls[messageEls.length - 1].dataset.timestamp ?? "0", 10) : 0;
80
+ const interval = this.config.timeGroupInterval ?? 3e5;
81
+ if (this.config.showTimestamp !== false && message.timestamp - lastTime > interval) this.renderTimeDivider(message.timestamp);
82
+ this.renderMessageRow(message);
83
+ }
84
+ prependMessages(messages) {
85
+ if (!messages.length) return;
86
+ this.removeEmpty();
87
+ const scrollHeight = this.messageListEl.scrollHeight;
88
+ const scrollTop = this.messageListEl.scrollTop;
89
+ const fragment = document.createDocumentFragment();
90
+ let lastTimestamp = 0;
91
+ const interval = this.config.timeGroupInterval ?? 3e5;
92
+ for (const message of messages) {
93
+ if (this.config.showTimestamp !== false && message.timestamp - lastTimestamp > interval) {
94
+ fragment.appendChild(this.createTimeDivider(message.timestamp));
95
+ lastTimestamp = message.timestamp;
96
+ }
97
+ fragment.appendChild(this.createMessageRow(message));
98
+ }
99
+ const firstMessage = this.messageListEl.querySelector(`.${this.prefix}-message-row, .${this.prefix}-time-divider`);
100
+ if (firstMessage) firstMessage.before(fragment);
101
+ else if (this.loadMoreEl) this.loadMoreEl.after(fragment);
102
+ else this.messageListEl.appendChild(fragment);
103
+ this.messageListEl.scrollTop = scrollTop + (this.messageListEl.scrollHeight - scrollHeight);
104
+ }
105
+ updateMessage(id, message) {
106
+ const rowEl = this.messageListEl.querySelector(`[data-msg-id="${this.escapeAttr(id)}"]`);
107
+ if (!rowEl) return;
108
+ rowEl.replaceWith(this.createMessageRow(message));
109
+ }
110
+ removeMessage(id) {
111
+ this.messageListEl.querySelector(`[data-msg-id="${this.escapeAttr(id)}"]`)?.remove();
112
+ if (this.getMessageElements().length === 0) this.renderEmpty();
113
+ }
114
+ scrollToBottom(smooth = true) {
115
+ if (typeof this.messageListEl.scrollTo === "function") {
116
+ this.messageListEl.scrollTo({
117
+ top: this.messageListEl.scrollHeight,
118
+ behavior: smooth ? "smooth" : "auto"
119
+ });
120
+ return;
121
+ }
122
+ this.messageListEl.scrollTop = this.messageListEl.scrollHeight;
123
+ }
124
+ isAtBottom(threshold = 4) {
125
+ return this.messageListEl.scrollHeight - this.messageListEl.scrollTop - this.messageListEl.clientHeight <= threshold;
126
+ }
127
+ setLoadingMore(loading) {
128
+ this.loadingMore = loading;
129
+ if (!this.loadMoreEl) return;
130
+ this.loadMoreEl.textContent = loading ? "加载中..." : this.config.loadMoreText ?? "加载更多";
131
+ this.loadMoreEl.classList.toggle(`${this.prefix}-load-more--loading`, loading);
132
+ }
133
+ focusInput() {
134
+ this.textareaEl?.focus();
135
+ }
136
+ setInputContent(content) {
137
+ if (!this.textareaEl) return;
138
+ this.textareaEl.value = this.limitInput(content);
139
+ this.autoResize();
140
+ this.updateSendButtonState();
141
+ this.config.onInputChange?.(this.textareaEl.value);
142
+ }
143
+ getInputContent() {
144
+ return this.textareaEl?.value ?? "";
145
+ }
146
+ clearInput() {
147
+ this.setInputContent("");
148
+ }
149
+ renderMentionPanel(members, activeIndex) {
150
+ if (!this.mentionPanel) return;
151
+ if (members.length === 0) {
152
+ this.mentionPanel.style.display = "none";
153
+ return;
154
+ }
155
+ this.mentionPanel.style.display = "block";
156
+ this.mentionPanel.innerHTML = "";
157
+ members.forEach((member, index) => {
158
+ const item = this.el("div", cx(`${this.prefix}-mention-item`, index === activeIndex && `${this.prefix}-mention-item--active`));
159
+ item.setAttribute("role", "option");
160
+ item.setAttribute("aria-selected", String(index === activeIndex));
161
+ const avatar = this.el("div", `${this.prefix}-mention-item-avatar`);
162
+ if (member.avatar) {
163
+ const img = document.createElement("img");
164
+ img.src = member.avatar;
165
+ img.alt = "";
166
+ avatar.appendChild(img);
167
+ } else avatar.textContent = member.name[0]?.toUpperCase() ?? "";
168
+ item.appendChild(avatar);
169
+ const name = this.el("span", `${this.prefix}-mention-item-name`);
170
+ name.textContent = member.name;
171
+ item.appendChild(name);
172
+ item.addEventListener("click", () => {
173
+ this.mentionEngine.select(index);
174
+ });
175
+ this.mentionPanel?.appendChild(item);
176
+ });
177
+ }
178
+ hideMentionPanel() {
179
+ if (this.mentionPanel) this.mentionPanel.style.display = "none";
180
+ }
181
+ destroy() {
182
+ this.stopVoice();
183
+ this.container.innerHTML = "";
184
+ }
185
+ renderInputArea() {
186
+ this.inputArea = this.el("div", `${this.prefix}-input-area`);
187
+ const wrapper = this.el("div", `${this.prefix}-input-wrapper`);
188
+ this.mentionPanel = this.el("div", `${this.prefix}-mention-panel`);
189
+ this.mentionPanel.style.display = "none";
190
+ this.mentionPanel.setAttribute("role", "listbox");
191
+ wrapper.appendChild(this.mentionPanel);
192
+ if (this.config.enableToolbar !== false) this.renderToolbar(wrapper);
193
+ this.textareaEl = document.createElement("textarea");
194
+ this.textareaEl.className = `${this.prefix}-textarea`;
195
+ this.textareaEl.placeholder = this.config.inputPlaceholder ?? "输入消息...";
196
+ this.textareaEl.rows = 1;
197
+ if (this.config.maxInputLength && this.config.maxInputLength > 0) this.textareaEl.maxLength = this.config.maxInputLength;
198
+ wrapper.appendChild(this.textareaEl);
199
+ this.dropOverlay = this.el("div", `${this.prefix}-drop-overlay`);
200
+ this.dropOverlay.innerHTML = "<span>拖放文件到这里</span>";
201
+ this.dropOverlay.style.display = "none";
202
+ wrapper.appendChild(this.dropOverlay);
203
+ this.inputArea.appendChild(wrapper);
204
+ this.sendBtn = document.createElement("button");
205
+ this.sendBtn.className = `${this.prefix}-send-btn`;
206
+ this.sendBtn.innerHTML = SEND_ICON_SVG;
207
+ this.sendBtn.title = "发送";
208
+ this.sendBtn.type = "button";
209
+ this.sendBtn.disabled = true;
210
+ this.inputArea.appendChild(this.sendBtn);
211
+ this.root.appendChild(this.inputArea);
212
+ }
213
+ renderToolbar(wrapper) {
214
+ const toolbar = this.config.toolbarConfig ?? {};
215
+ this.toolbarEl = this.el("div", `${this.prefix}-toolbar`);
216
+ if (toolbar.image !== false) {
217
+ const imageBtn = this.createToolbarBtn(ICON_IMAGE, "插入图片");
218
+ imageBtn.addEventListener("click", () => this.openFilePicker("image/*"));
219
+ this.toolbarEl.appendChild(imageBtn);
220
+ }
221
+ if (toolbar.file !== false) {
222
+ const fileBtn = this.createToolbarBtn(ICON_PAPERCLIP, "插入文件");
223
+ fileBtn.addEventListener("click", () => this.openFilePicker("*/*"));
224
+ this.toolbarEl.appendChild(fileBtn);
225
+ }
226
+ if (toolbar.voice !== false) {
227
+ this.voiceBtn = this.createToolbarBtn(ICON_MIC, "语音输入");
228
+ this.voiceBtn.addEventListener("click", () => this.toggleVoice());
229
+ this.toolbarEl.appendChild(this.voiceBtn);
230
+ }
231
+ wrapper.insertBefore(this.toolbarEl, wrapper.firstChild);
232
+ }
233
+ createToolbarBtn(iconSvg, title) {
234
+ const btn = document.createElement("button");
235
+ btn.className = `${this.prefix}-toolbar-btn`;
236
+ btn.innerHTML = iconSvg;
237
+ btn.title = title;
238
+ btn.type = "button";
239
+ return btn;
240
+ }
241
+ openFilePicker(accept) {
242
+ const input = document.createElement("input");
243
+ input.type = "file";
244
+ input.accept = accept;
245
+ input.multiple = true;
246
+ input.style.display = "none";
247
+ input.addEventListener("change", () => {
248
+ const files = Array.from(input.files ?? []);
249
+ input.remove();
250
+ if (!files.length) return;
251
+ const isImage = accept.startsWith("image");
252
+ this.emitter.emit(isImage ? "toolbar-image" : "toolbar-file", files);
253
+ if (isImage) this.config.onImageUpload?.(files);
254
+ else this.config.onFileUpload?.(files);
255
+ });
256
+ document.body.appendChild(input);
257
+ input.click();
258
+ }
259
+ toggleVoice() {
260
+ if (this.isRecording) this.stopVoice();
261
+ else this.startVoice();
262
+ }
263
+ startVoice() {
264
+ if (typeof window === "undefined") return;
265
+ const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
266
+ if (!SpeechRecognition) {
267
+ this.emitter.emit("toolbar-voice-unsupported");
268
+ this.config.onVoiceUnsupported?.();
269
+ return;
270
+ }
271
+ try {
272
+ this.lastVoiceTranscript = "";
273
+ this.recognition = new SpeechRecognition();
274
+ this.recognition.continuous = false;
275
+ this.recognition.interimResults = true;
276
+ this.recognition.lang = this.config.voiceLang ?? "zh-CN";
277
+ this.recognition.onresult = (event) => {
278
+ let transcript = "";
279
+ for (let i = 0; i < event.results.length; i++) transcript += event.results[i][0].transcript;
280
+ this.lastVoiceTranscript = transcript;
281
+ if (event.results[event.results.length - 1]?.isFinal && this.textareaEl) this.insertTextAtCursor(transcript);
282
+ };
283
+ this.recognition.onend = () => {
284
+ this.finishVoice();
285
+ this.emitter.emit("toolbar-voice-end", this.lastVoiceTranscript);
286
+ this.config.onVoiceEnd?.(this.lastVoiceTranscript);
287
+ };
288
+ this.recognition.onerror = (error) => {
289
+ this.finishVoice();
290
+ this.emitter.emit("toolbar-voice-error", error);
291
+ this.config.onVoiceError?.(error);
292
+ };
293
+ this.isRecording = true;
294
+ this.voiceBtn?.classList.add(`${this.prefix}-toolbar-btn--active`);
295
+ if (this.voiceBtn) this.voiceBtn.innerHTML = ICON_MIC_OFF;
296
+ this.recognition.start();
297
+ this.emitter.emit("toolbar-voice-start");
298
+ this.config.onVoiceStart?.();
299
+ } catch (error) {
300
+ this.finishVoice();
301
+ this.emitter.emit("toolbar-voice-error", error);
302
+ this.config.onVoiceError?.(error);
303
+ }
304
+ }
305
+ stopVoice() {
306
+ if (!this.recognition) return;
307
+ const recognition = this.recognition;
308
+ this.recognition = null;
309
+ try {
310
+ recognition.stop();
311
+ } catch {
312
+ this.finishVoice();
313
+ }
314
+ }
315
+ finishVoice() {
316
+ this.isRecording = false;
317
+ this.recognition = null;
318
+ this.voiceBtn?.classList.remove(`${this.prefix}-toolbar-btn--active`);
319
+ if (this.voiceBtn) this.voiceBtn.innerHTML = ICON_MIC;
320
+ }
321
+ setupEventListeners() {
322
+ if (this.textareaEl) {
323
+ this.textareaEl.addEventListener("input", () => {
324
+ const limited = this.limitInput(this.textareaEl?.value ?? "");
325
+ if (this.textareaEl && limited !== this.textareaEl.value) this.textareaEl.value = limited;
326
+ this.autoResize();
327
+ this.updateSendButtonState();
328
+ this.config.onInputChange?.(this.textareaEl?.value ?? "");
329
+ if (this.config.enableMention && this.textareaEl) this.mentionEngine.handleInput(this.textareaEl.value, this.textareaEl.selectionStart);
330
+ });
331
+ this.textareaEl.addEventListener("keydown", (event) => {
332
+ if (this.mentionEngine.getState().active) {
333
+ if (event.key === "ArrowUp") {
334
+ event.preventDefault();
335
+ this.mentionEngine.moveUp();
336
+ } else if (event.key === "ArrowDown") {
337
+ event.preventDefault();
338
+ this.mentionEngine.moveDown();
339
+ } else if (event.key === "Enter" && !event.shiftKey) {
340
+ event.preventDefault();
341
+ const member = this.mentionEngine.selectCurrent();
342
+ if (member) this.insertMention(member);
343
+ } else if (event.key === "Escape") {
344
+ event.preventDefault();
345
+ this.mentionEngine.deactivate();
346
+ }
347
+ return;
348
+ }
349
+ if (this.config.enterToSend !== false && event.key === "Enter" && !event.shiftKey) {
350
+ event.preventDefault();
351
+ this.handleSend();
352
+ }
353
+ });
354
+ this.textareaEl.addEventListener("paste", (event) => {
355
+ const items = event.clipboardData?.items;
356
+ if (!items) return;
357
+ const files = [];
358
+ for (let i = 0; i < items.length; i++) if (items[i].kind === "file") {
359
+ const file = items[i].getAsFile();
360
+ if (file) files.push(file);
361
+ }
362
+ if (files.length > 0) {
363
+ event.preventDefault();
364
+ this.emitter.emit("toolbar-paste", files);
365
+ this.config.onPaste?.(files);
366
+ }
367
+ });
368
+ }
369
+ if (this.inputArea) {
370
+ const wrapper = this.inputArea.querySelector(`.${this.prefix}-input-wrapper`);
371
+ if (wrapper) this.setupDropEvents(wrapper);
372
+ }
373
+ this.sendBtn?.addEventListener("click", () => {
374
+ this.handleSend();
375
+ });
376
+ this.emitter.on("mention-filter", (state) => {
377
+ this.renderMentionPanel(state.filtered, state.activeIndex);
378
+ });
379
+ this.emitter.on("mention-active-change", (index) => {
380
+ (this.mentionPanel?.querySelectorAll(`.${this.prefix}-mention-item`))?.forEach((el, i) => {
381
+ el.classList.toggle(`${this.prefix}-mention-item--active`, i === index);
382
+ el.setAttribute("aria-selected", String(i === index));
383
+ });
384
+ });
385
+ this.emitter.on("mention-deactivate", () => {
386
+ this.hideMentionPanel();
387
+ });
388
+ }
389
+ setupDropEvents(wrapper) {
390
+ let dragCounter = 0;
391
+ wrapper.addEventListener("dragenter", (event) => {
392
+ event.preventDefault();
393
+ dragCounter++;
394
+ if (this.dropOverlay) this.dropOverlay.style.display = "flex";
395
+ });
396
+ wrapper.addEventListener("dragleave", (event) => {
397
+ event.preventDefault();
398
+ dragCounter--;
399
+ if (dragCounter <= 0) {
400
+ dragCounter = 0;
401
+ if (this.dropOverlay) this.dropOverlay.style.display = "none";
402
+ }
403
+ });
404
+ wrapper.addEventListener("dragover", (event) => {
405
+ event.preventDefault();
406
+ });
407
+ wrapper.addEventListener("drop", (event) => {
408
+ event.preventDefault();
409
+ dragCounter = 0;
410
+ if (this.dropOverlay) this.dropOverlay.style.display = "none";
411
+ const files = Array.from(event.dataTransfer?.files ?? []);
412
+ if (files.length > 0) {
413
+ this.emitter.emit("toolbar-drop", files);
414
+ this.config.onDrop?.(files);
415
+ }
416
+ });
417
+ }
418
+ handleSend() {
419
+ const content = (this.textareaEl?.value ?? "").trim();
420
+ if (!content) return;
421
+ const mentions = this.mentionEngine.parseMentions(content);
422
+ this.emitter.emit("send", content, mentions);
423
+ this.config.onSend?.(content, mentions);
424
+ if (this.config.clearInputOnSend !== false) this.clearInput();
425
+ this.mentionEngine.resetSelected();
426
+ }
427
+ insertMention(member) {
428
+ if (!this.textareaEl) return;
429
+ const state = this.mentionEngine.getState();
430
+ const text = this.textareaEl.value;
431
+ const pos = this.textareaEl.selectionStart;
432
+ const insertText = this.mentionEngine.getInsertText(member);
433
+ const before = text.slice(0, state.triggerPosition);
434
+ const after = text.slice(pos);
435
+ this.textareaEl.value = this.limitInput(before + insertText + after);
436
+ const newPos = Math.min(before.length + insertText.length, this.textareaEl.value.length);
437
+ this.textareaEl.setSelectionRange(newPos, newPos);
438
+ this.textareaEl.focus();
439
+ this.autoResize();
440
+ this.updateSendButtonState();
441
+ this.config.onInputChange?.(this.textareaEl.value);
442
+ }
443
+ insertTextAtCursor(text) {
444
+ if (!this.textareaEl) return;
445
+ const current = this.textareaEl.value;
446
+ const start = this.textareaEl.selectionStart;
447
+ const end = this.textareaEl.selectionEnd;
448
+ this.textareaEl.value = this.limitInput(current.slice(0, start) + text + current.slice(end));
449
+ const nextCursor = Math.min(start + text.length, this.textareaEl.value.length);
450
+ this.textareaEl.setSelectionRange(nextCursor, nextCursor);
451
+ this.autoResize();
452
+ this.updateSendButtonState();
453
+ this.config.onInputChange?.(this.textareaEl.value);
454
+ }
455
+ autoResize() {
456
+ if (!this.textareaEl) return;
457
+ this.textareaEl.style.height = "auto";
458
+ const maxHeight = this.getCssSizeValue("--ldesign-chat-textarea-max-height", 120);
459
+ this.textareaEl.style.height = `${Math.min(this.textareaEl.scrollHeight, maxHeight)}px`;
460
+ }
461
+ updateSendButtonState() {
462
+ if (!this.sendBtn) return;
463
+ this.sendBtn.disabled = !this.getInputContent().trim();
464
+ }
465
+ renderLoadMoreButton() {
466
+ this.loadMoreEl = this.el("div", `${this.prefix}-load-more`);
467
+ this.loadMoreEl.textContent = this.config.loadMoreText ?? "加载更多";
468
+ this.loadMoreEl.addEventListener("click", () => {
469
+ if (this.loadingMore) return;
470
+ this.setLoadingMore(true);
471
+ this.emitter.emit("load-more");
472
+ Promise.resolve(this.config.onLoadMore?.()).finally(() => {
473
+ this.setLoadingMore(false);
474
+ });
475
+ });
476
+ this.messageListEl.appendChild(this.loadMoreEl);
477
+ }
478
+ renderTimeDivider(timestamp) {
479
+ this.messageListEl.appendChild(this.createTimeDivider(timestamp));
480
+ }
481
+ createTimeDivider(timestamp) {
482
+ const divider = this.el("div", `${this.prefix}-time-divider`);
483
+ const span = document.createElement("span");
484
+ span.textContent = this.formatTime(timestamp);
485
+ divider.appendChild(span);
486
+ return divider;
487
+ }
488
+ renderMessageRow(message) {
489
+ this.messageListEl.appendChild(this.createMessageRow(message));
490
+ }
491
+ createMessageRow(message) {
492
+ const p = this.prefix;
493
+ if (message.type === "system") {
494
+ const row2 = this.el("div", cx(`${p}-message-row`, `${p}-message-row--system`));
495
+ row2.dataset.msgId = message.id;
496
+ row2.dataset.timestamp = String(message.timestamp);
497
+ const system = this.el("div", `${p}-system-message`);
498
+ system.textContent = message.content ?? "";
499
+ row2.appendChild(system);
500
+ return row2;
501
+ }
502
+ const isOutgoing = message.direction === "outgoing";
503
+ const row = this.el("div", cx(`${p}-message-row`, isOutgoing && `${p}-message-row--outgoing`));
504
+ row.dataset.msgId = message.id;
505
+ row.dataset.timestamp = String(message.timestamp);
506
+ if (this.config.showAvatar !== false) row.appendChild(this.createAvatar(message.sender.nickname, message.sender.avatar));
507
+ const body = this.el("div", `${p}-message-body`);
508
+ if (this.config.showNickname !== false && !isOutgoing) {
509
+ const nickname = this.el("div", `${p}-nickname`);
510
+ nickname.textContent = message.sender.nickname;
511
+ body.appendChild(nickname);
512
+ }
513
+ body.appendChild(this.createBubble(message, isOutgoing));
514
+ body.appendChild(this.createMessageFooter(message, isOutgoing));
515
+ row.appendChild(body);
516
+ row.addEventListener("click", () => {
517
+ this.emitter.emit("message-click", message);
518
+ this.config.onMessageClick?.(message);
519
+ });
520
+ return row;
521
+ }
522
+ createAvatar(name, avatarUrl) {
523
+ const avatar = this.el("div", `${this.prefix}-avatar`);
524
+ if (avatarUrl) {
525
+ const img = document.createElement("img");
526
+ img.src = avatarUrl;
527
+ img.alt = name;
528
+ avatar.appendChild(img);
529
+ } else avatar.textContent = name[0]?.toUpperCase() ?? "";
530
+ return avatar;
531
+ }
532
+ createMessageFooter(message, isOutgoing) {
533
+ const footer = this.el("div", `${this.prefix}-message-footer`);
534
+ if (this.config.showTimestamp !== false) {
535
+ const time = this.el("span", `${this.prefix}-timestamp`);
536
+ time.textContent = this.formatShortTime(message.timestamp);
537
+ footer.appendChild(time);
538
+ }
539
+ if (this.config.showStatus !== false && isOutgoing && message.status) {
540
+ const status = this.el("span", cx(`${this.prefix}-status`, `${this.prefix}-status--${message.status}`));
541
+ status.textContent = STATUS_MAP[message.status] ?? "";
542
+ if (message.status === "failed") status.addEventListener("click", (event) => {
543
+ event.stopPropagation();
544
+ this.emitter.emit("message-retry", message);
545
+ });
546
+ footer.appendChild(status);
547
+ }
548
+ return footer;
549
+ }
550
+ createBubble(message, isOutgoing) {
551
+ const p = this.prefix;
552
+ const dirClass = isOutgoing ? `${p}-bubble--outgoing` : `${p}-bubble--incoming`;
553
+ if (message.revoked) {
554
+ const bubble = this.el("div", cx(`${p}-bubble`, dirClass, `${p}-bubble--revoked`));
555
+ bubble.textContent = "消息已撤回";
556
+ return bubble;
557
+ }
558
+ switch (message.type) {
559
+ case "image": {
560
+ const bubble = this.el("div", cx(`${p}-bubble`, dirClass, `${p}-bubble--image`));
561
+ if (message.image) {
562
+ const img = document.createElement("img");
563
+ img.src = message.image.thumbUrl ?? message.image.url;
564
+ img.alt = message.content ?? "";
565
+ if (message.image.width) img.width = Math.min(message.image.width, 240);
566
+ bubble.appendChild(img);
567
+ }
568
+ return bubble;
569
+ }
570
+ case "file": {
571
+ const bubble = this.el("div", cx(`${p}-bubble`, dirClass, `${p}-bubble--file`));
572
+ const icon = this.el("div", `${p}-file-icon`);
573
+ icon.innerHTML = FILE_ICON;
574
+ bubble.appendChild(icon);
575
+ const info = this.el("div", `${p}-file-info`);
576
+ const name = this.el("div", `${p}-file-name`);
577
+ name.textContent = message.file?.name ?? "file";
578
+ info.appendChild(name);
579
+ if (message.file?.size) {
580
+ const size = this.el("div", `${p}-file-size`);
581
+ size.textContent = this.formatFileSize(message.file.size);
582
+ info.appendChild(size);
583
+ }
584
+ bubble.appendChild(info);
585
+ return bubble;
586
+ }
587
+ case "custom": {
588
+ const bubble = this.el("div", cx(`${p}-bubble`, dirClass));
589
+ if (message.customHtml) bubble.innerHTML = message.customHtml;
590
+ else bubble.textContent = message.content ?? "";
591
+ return bubble;
592
+ }
593
+ default: {
594
+ const bubble = this.el("div", cx(`${p}-bubble`, dirClass, message.streaming && `${p}-bubble--streaming`));
595
+ bubble.innerHTML = this.renderTextContent(message.content ?? "", message.mentions);
596
+ return bubble;
597
+ }
598
+ }
599
+ }
600
+ renderTextContent(content, mentions) {
601
+ let html = this.escapeHtml(content);
602
+ if (mentions?.length) for (const mention of mentions) {
603
+ const tag = `<span class="${this.prefix}-mention-tag" data-mention-id="${this.escapeAttr(mention.id)}">@${this.escapeHtml(mention.name)}</span>`;
604
+ html = html.replace(new RegExp(`@${this.escapeRegex(mention.name)}`, "g"), tag);
605
+ }
606
+ return html.replace(/(https?:\/\/[^\s<]+)/g, `<a class="${this.prefix}-link" href="$1" target="_blank" rel="noopener noreferrer">$1</a>`);
607
+ }
608
+ renderEmpty() {
609
+ if (this.messageListEl.querySelector(`.${this.prefix}-empty`)) return;
610
+ const empty = this.el("div", `${this.prefix}-empty`);
611
+ empty.textContent = "暂无消息";
612
+ this.messageListEl.appendChild(empty);
613
+ }
614
+ removeEmpty() {
615
+ this.messageListEl.querySelector(`.${this.prefix}-empty`)?.remove();
616
+ }
617
+ el(tag, className) {
618
+ const el = document.createElement(tag);
619
+ el.className = className;
620
+ return el;
621
+ }
622
+ getMessageElements() {
623
+ return Array.from(this.messageListEl.querySelectorAll(`.${this.prefix}-message-row`));
624
+ }
625
+ formatTime(timestamp) {
626
+ if (this.config.formatTime) return this.config.formatTime(timestamp);
627
+ const date = new Date(timestamp);
628
+ const now = /* @__PURE__ */ new Date();
629
+ if (date.toDateString() === now.toDateString()) return date.toLocaleTimeString("zh-CN", {
630
+ hour: "2-digit",
631
+ minute: "2-digit"
632
+ });
633
+ return date.toLocaleDateString("zh-CN", {
634
+ month: "2-digit",
635
+ day: "2-digit",
636
+ hour: "2-digit",
637
+ minute: "2-digit"
638
+ });
639
+ }
640
+ formatShortTime(timestamp) {
641
+ return new Date(timestamp).toLocaleTimeString("zh-CN", {
642
+ hour: "2-digit",
643
+ minute: "2-digit"
644
+ });
645
+ }
646
+ formatFileSize(bytes) {
647
+ if (bytes < 1024) return `${bytes} B`;
648
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
649
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
650
+ }
651
+ limitInput(content) {
652
+ const max = this.config.maxInputLength;
653
+ if (!max || max <= 0) return content;
654
+ return content.slice(0, max);
655
+ }
656
+ getCssSizeValue(name, fallback) {
657
+ if (typeof window === "undefined" || !this.root) return fallback;
658
+ const value = window.getComputedStyle(this.root).getPropertyValue(name).trim();
659
+ const parsed = Number.parseFloat(value);
660
+ return Number.isFinite(parsed) ? parsed : fallback;
661
+ }
662
+ escapeHtml(str) {
663
+ const div = document.createElement("div");
664
+ div.textContent = str;
665
+ return div.innerHTML;
666
+ }
667
+ escapeAttr(str) {
668
+ return str.replace(/"/g, "&quot;").replace(/'/g, "&#39;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
669
+ }
670
+ escapeRegex(str) {
671
+ return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
672
+ }
673
+ };
674
+ //#endregion
675
+ export { DOMRenderer };
676
+ /*! End of @composy/chat-core | Powered by @composy/builder */
677
+ //# sourceMappingURL=dom-renderer.js.map