@pmate/chat-sdk 0.2.3 → 0.3.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.
@@ -1 +1 @@
1
- import{r as s}from"./iframe-Bo_J3UyC.js";import{c as l}from"./client-CMlGEQEC.js";import"./preload-helper-PPVm8Dsz.js";var n=new Map;function m(){return globalThis.IS_REACT_ACT_ENVIRONMENT}var a=({callback:e,children:r})=>{let t=s.useRef();return s.useLayoutEffect(()=>{t.current!==e&&(t.current=e,e())},[e]),r};typeof Promise.withResolvers>"u"&&(Promise.withResolvers=()=>{let e=null,r=null;return{promise:new Promise((t,o)=>{e=t,r=o}),resolve:e,reject:r}});var v=async(e,r,t)=>{let o=await c(r,t);if(m()){o.render(e);return}let{promise:i,resolve:u}=Promise.withResolvers();return o.render(s.createElement(a,{callback:u},e)),i},f=(e,r)=>{let t=n.get(e);t&&(t.unmount(),n.delete(e))},c=async(e,r)=>{let t=n.get(e);return t||(t=l.createRoot(e,r),n.set(e,t)),t};export{v as renderElement,f as unmountElement};
1
+ import{r as s}from"./iframe-1QrxCBqr.js";import{c as l}from"./client-DTiYOHAb.js";import"./preload-helper-PPVm8Dsz.js";var n=new Map;function m(){return globalThis.IS_REACT_ACT_ENVIRONMENT}var a=({callback:e,children:r})=>{let t=s.useRef();return s.useLayoutEffect(()=>{t.current!==e&&(t.current=e,e())},[e]),r};typeof Promise.withResolvers>"u"&&(Promise.withResolvers=()=>{let e=null,r=null;return{promise:new Promise((t,o)=>{e=t,r=o}),resolve:e,reject:r}});var v=async(e,r,t)=>{let o=await c(r,t);if(m()){o.render(e);return}let{promise:i,resolve:u}=Promise.withResolvers();return o.render(s.createElement(a,{callback:u},e)),i},f=(e,r)=>{let t=n.get(e);t&&(t.unmount(),n.delete(e))},c=async(e,r)=>{let t=n.get(e);return t||(t=l.createRoot(e,r),n.set(e,t)),t};export{v as renderElement,f as unmountElement};
package/dist/iframe.html CHANGED
@@ -529,7 +529,7 @@
529
529
  }
530
530
  </script>
531
531
 
532
- <script type="module" crossorigin src="./assets/iframe-Bo_J3UyC.js"></script>
532
+ <script type="module" crossorigin src="./assets/iframe-1QrxCBqr.js"></script>
533
533
  <link rel="modulepreload" crossorigin href="./assets/preload-helper-PPVm8Dsz.js">
534
534
  <link rel="stylesheet" crossorigin href="./assets/iframe-4FbYaDjf.css">
535
535
  </head>
package/dist/index.html CHANGED
@@ -127,7 +127,7 @@
127
127
 
128
128
 
129
129
 
130
- window['STORYBOOK_FRAMEWORK'] = "/Users/ramroll/dev/pmate/pmate-mono/node_modules/.pnpm/@storybook+react-vite@9.0.17_react-dom@19.2.3_react@19.2.3__react@19.2.3_rollup@4.54.0__90095e863266ef31e7a8a2925998abad/node_modules/@storybook/react-vite";
130
+ window['STORYBOOK_FRAMEWORK'] = "/Users/ramroll/dev/pmate-envs/pmate-mono-9/node_modules/.pnpm/@storybook+react-vite@9.0.17_react-dom@19.2.3_react@19.2.3__react@19.2.3_rollup@4.54.0__90095e863266ef31e7a8a2925998abad/node_modules/@storybook/react-vite";
131
131
 
132
132
 
133
133
  </script>
package/dist/index.json CHANGED
@@ -1 +1 @@
1
- {"v":5,"entries":{"pmate-chat-sdk-chatinput--playground":{"type":"story","id":"pmate-chat-sdk-chatinput--playground","name":"Playground","title":"@pmate/chat-sdk/ChatInput","importPath":"./src/stories/ChatInput.stories.tsx","tags":["dev","test"]},"pmate-chat-sdk-chatinput--desktop-multiline":{"type":"story","id":"pmate-chat-sdk-chatinput--desktop-multiline","name":"Desktop Multiline","title":"@pmate/chat-sdk/ChatInput","importPath":"./src/stories/ChatInput.stories.tsx","tags":["dev","test"]}}}
1
+ {"v":5,"entries":{"pmate-chat-sdk-chatinput--playground":{"type":"story","id":"pmate-chat-sdk-chatinput--playground","name":"Playground","title":"@pmate/chat-sdk/ChatInput","importPath":"./src/stories/ChatInput.stories.tsx","tags":["dev","test"]},"pmate-chat-sdk-chatinput--desktop-multiline":{"type":"story","id":"pmate-chat-sdk-chatinput--desktop-multiline","name":"Desktop Multiline","title":"@pmate/chat-sdk/ChatInput","importPath":"./src/stories/ChatInput.stories.tsx","tags":["dev","test"]},"pmate-chat-sdk-chatinput--group-mentions":{"type":"story","id":"pmate-chat-sdk-chatinput--group-mentions","name":"Group Mentions","title":"@pmate/chat-sdk/ChatInput","importPath":"./src/stories/ChatInput.stories.tsx","tags":["dev","test"]},"pmate-chat-sdk-chatinput--mobile-group-mentions":{"type":"story","id":"pmate-chat-sdk-chatinput--mobile-group-mentions","name":"Mobile Group Mentions","title":"@pmate/chat-sdk/ChatInput","importPath":"./src/stories/ChatInput.stories.tsx","tags":["dev","test"]},"pmate-chat-sdk-chatinput--dark-group-mentions":{"type":"story","id":"pmate-chat-sdk-chatinput--dark-group-mentions","name":"Dark Group Mentions","title":"@pmate/chat-sdk/ChatInput","importPath":"./src/stories/ChatInput.stories.tsx","tags":["dev","test"]}}}
package/dist/project.json CHANGED
@@ -1 +1 @@
1
- {"generatedAt":1787881497340,"userSince":1774065191593,"hasCustomBabel":false,"hasCustomWebpack":false,"hasStaticDirs":false,"hasStorybookEslint":false,"refCount":0,"testPackages":{"vitest":"4.0.17"},"hasRouterPackage":false,"monorepo":"Turborepo","packageManager":{"type":"pnpm","version":"10.6.5","agent":"pnpm","nodeLinker":"undefined"},"preview":{"usesGlobals":false},"framework":{"name":"@storybook/react-vite","options":{}},"builder":"@storybook/builder-vite","renderer":"@storybook/react","portableStoriesFileCount":0,"applicationFileCount":0,"storybookVersion":"9.1.17","language":"typescript","storybookPackages":{"@storybook/addon-docs":{"version":"9.1.17"},"@storybook/react-vite":{"version":"9.0.17"},"storybook":{"version":"9.1.17"}},"addons":{"$SNIP/node_modules/.pnpm/@storybook+addon-docs@9.1.17_@types+react@19.2.8_storybook@9.1.17_@testing-library+dom@_6282815dfd5a1a048c68a92ca86a2d86/node_modules/@storybook/addon-docs":{}}}
1
+ {"generatedAt":1791336224560,"userSince":1774065191593,"hasCustomBabel":false,"hasCustomWebpack":false,"hasStaticDirs":false,"hasStorybookEslint":false,"refCount":0,"testPackages":{"vitest":"4.0.17"},"hasRouterPackage":false,"monorepo":"Turborepo","packageManager":{"type":"pnpm","version":"10.6.5","agent":"pnpm","nodeLinker":"undefined"},"preview":{"usesGlobals":false},"framework":{"name":"@storybook/react-vite","options":{}},"builder":"@storybook/builder-vite","renderer":"@storybook/react","portableStoriesFileCount":0,"applicationFileCount":0,"storybookVersion":"9.1.17","language":"typescript","storybookPackages":{"@storybook/addon-docs":{"version":"9.1.17"},"@storybook/react-vite":{"version":"9.0.17"},"storybook":{"version":"9.1.17"}},"addons":{"$SNIP/node_modules/.pnpm/@storybook+addon-docs@9.1.17_@types+react@19.2.8_storybook@9.1.17_@testing-library+dom@_6282815dfd5a1a048c68a92ca86a2d86/node_modules/@storybook/addon-docs":{}}}
@@ -1,4 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useMentions } from "../hooks/useMentions";
3
+ import { MentionMenu } from "./MentionMenu";
2
4
  import React from "react";
3
5
  import { Provider, createStore } from "jotai";
4
6
  import { useEffect, useRef, useState } from "react";
@@ -22,6 +24,10 @@ function DesktopInner(props) {
22
24
  const tokens = mergeChatInputTokens(props.tokens);
23
25
  const state = useChatInputState();
24
26
  const attachments = useAttachmentState();
27
+ const mentions = useMentions({
28
+ value: state.text, config: props.mentions, disabled: props.disabled || state.status === "sending" || state.mode === "voice",
29
+ onChange(value) { state.setText(value); state.setStatus(value.trim() ? "typing" : "idle"); props.onChange?.(value); },
30
+ });
25
31
  const [isEmojiOpen, setIsEmojiOpen] = useState(false);
26
32
  const [isMoreOpen, setIsMoreOpen] = useState(false);
27
33
  const [isUploadingImage, setIsUploadingImage] = useState(false);
@@ -66,6 +72,7 @@ function DesktopInner(props) {
66
72
  state.setStatus("sending");
67
73
  await props.onSend({
68
74
  text: state.text,
75
+ mentions: mentions.getMentions(),
69
76
  attachments: state.attachments,
70
77
  transcript: state.transcript || undefined,
71
78
  });
@@ -178,153 +185,151 @@ function DesktopInner(props) {
178
185
  alignSelf: "center",
179
186
  transition: `color ${tokens.motion.stateTransition}, transform ${tokens.motion.pressFeedback}`,
180
187
  };
181
- return (_jsx("div", { style: { position: "relative", width: "100%" }, children: _jsxs("div", { className: "grid gap-3", style: { position: "relative", width: "100%" }, children: [_jsx("input", { ref: imageInputRef, type: "file", accept: props.image?.accept ?? "image/*", style: { display: "none" }, onChange: (event) => {
182
- const file = event.target.files?.[0];
183
- if (file) {
184
- void handleImageFile(file);
185
- setIsMoreOpen(false);
186
- }
187
- event.currentTarget.value = "";
188
- } }), _jsx("input", { ref: fileInputRef, type: "file", style: { display: "none" }, onChange: (event) => {
189
- const file = event.target.files?.[0];
190
- if (file) {
191
- handleFileAttachment(file);
192
- setIsMoreOpen(false);
193
- }
194
- event.currentTarget.value = "";
195
- } }), _jsx(EmojiPicker, { open: isEmojiOpen, isMobile: false, anchorRef: emojiAnchorRef, onSelect: insertEmoji, onClose: () => setIsEmojiOpen(false) }), _jsx(AttachmentPreview, { attachments: attachments.attachments, tokens: tokens, onRemove: attachments.removeAttachment }), isMoreOpen ? (_jsxs("div", { style: {
196
- position: "absolute",
197
- left: 0,
198
- right: 0,
199
- bottom: props.multiline ? 154 : 66,
200
- padding: "12px 14px",
201
- borderRadius: 24,
202
- background: `color-mix(in srgb, ${tokens.color.surfaceInputFocus} 94%, transparent)`,
203
- border: `1px solid ${tokens.color.borderDefault}`,
204
- display: "flex",
205
- gap: 12,
206
- boxShadow: "0 18px 50px rgba(0, 0, 0, 0.16)",
207
- backdropFilter: "blur(18px)",
208
- zIndex: 3,
209
- }, children: [_jsxs("button", { type: "button", style: attachmentActionStyle, disabled: props.disabled || isUploadingImage, onClick: () => imageInputRef.current?.click(), children: [_jsx("span", { style: attachmentIconStyle, children: _jsx(ImageOutlineIcon, { size: 24 }) }), "\u56FE\u7247"] }), _jsxs("button", { type: "button", style: attachmentActionStyle, disabled: props.disabled, onClick: () => fileInputRef.current?.click(), children: [_jsx("span", { style: attachmentIconStyle, children: _jsx(FileOutlineIcon, { size: 24 }) }), "\u6587\u4EF6"] })] })) : null, voice.status === "recording" ? (_jsxs("div", { className: "flex items-center gap-3", children: [_jsx(WaveformFeedback, { levels: voice.waveformLevels, tokens: tokens, compact: true }), _jsxs("span", { style: { color: tokens.color.textSecondary, fontSize: 12 }, children: [Math.max(1, Math.round(voice.durationMs / 1000)), "s"] })] })) : null, props.multiline ? (_jsxs(InputSurface, { tokens: tokens, status: state.status, styleOverride: {
210
- minHeight: tokens.sizing.multilineMinHeight,
211
- padding: "16px 18px 12px",
212
- display: "flex",
213
- flexDirection: "column",
214
- gap: 12,
215
- }, children: [_jsx(TextComposer, { ref: textComposerRef, tokens: tokens, value: state.text, rows: 3, placeholder: "Message PMate", onPaste: handlePaste, onDrop: props.onDrop, onKeyDown: (event) => {
216
- if (event.key !== "Enter" ||
217
- event.shiftKey ||
218
- event.nativeEvent.isComposing ||
219
- event.keyCode === 229)
220
- return;
221
- event.preventDefault();
222
- if (state.text.trim() || state.attachments.length > 0)
223
- void onSend();
224
- }, onChange: (event) => {
225
- state.setText(event.target.value);
226
- state.setStatus(event.target.value.trim() ? "typing" : "idle");
227
- props.onChange?.(event.target.value);
228
- }, style: {
229
- flex: 1,
230
- minHeight: tokens.sizing.multilineTextMinHeight,
231
- maxHeight: tokens.sizing.multilineTextMaxHeight,
232
- padding: 0,
233
- overflowY: "auto",
234
- caretColor: tokens.color.brandPrimary,
235
- } }), _jsxs("div", { style: { display: "flex", alignItems: "center", justifyContent: "space-between", gap: 12 }, children: [_jsx(MoreTrigger, { "data-icon-size": tokens.sizing.desktopIcon, onClick: () => { setIsEmojiOpen(false); setIsMoreOpen((prev) => !prev); }, disabled: props.disabled, style: { ...sideActionStyle, color: isMoreOpen ? tokens.color.iconActive : tokens.color.iconDefault, opacity: props.disabled ? 0.4 : 1 } }), _jsxs("div", { style: { display: "flex", alignItems: "center", gap: 8 }, children: [_jsx("button", { type: "button", onClick: () => {
236
- if (props.disabled || props.enableVoice === false)
237
- return;
238
- if (voice.status === "recording") {
239
- voice.stopRecording("transcribe");
240
- return;
241
- }
242
- if (voice.status === "transcribing")
243
- return;
244
- voice.beginVoiceMode();
245
- voice.startRecording();
246
- }, style: {
247
- ...sideActionStyle,
248
- color: voice.status === "recording" || voice.status === "transcribing" ? tokens.color.iconActive : tokens.color.iconDefault,
249
- cursor: props.disabled || props.enableVoice === false ? "not-allowed" : "pointer",
250
- opacity: props.disabled || props.enableVoice === false ? 0.4 : 1,
251
- }, "aria-label": voice.status === "recording" ? "停止录音并转文字" : voice.status === "transcribing" ? "正在转写语音" : "开始语音输入", children: voice.status === "recording" ? _jsx(StopCircleOutlineIcon, { size: tokens.sizing.desktopIcon }) : _jsx(MicOutlineIcon, { size: tokens.sizing.desktopIcon, strokeWidth: 2 }) }), _jsx(EmojiTrigger, { ref: emojiAnchorRef, "data-icon-size": tokens.sizing.desktopIcon, style: sideActionStyle, onClick: () => { setIsMoreOpen(false); setIsEmojiOpen((prev) => !prev); } }), _jsx(SendAction, { tokens: tokens, disabled: props.disabled || state.status === "sending" || (!state.text.trim() && state.attachments.length === 0), "aria-label": "\u53D1\u9001\u6D88\u606F", onClick: () => void onSend(), style: { width: tokens.sizing.desktopSendAction, height: tokens.sizing.desktopSendAction, padding: 0, borderRadius: 999, fontSize: tokens.sizing.desktopSendIcon, lineHeight: `${tokens.sizing.desktopSendAction}px` }, children: "\u2191" })] })] })] })) : _jsxs("div", { style: {
252
- display: "grid",
253
- gridTemplateColumns: "32px minmax(0, 1fr) 32px 32px",
254
- alignItems: "center",
255
- gap: 6,
256
- }, children: [_jsx("button", { type: "button", onClick: () => {
257
- if (props.disabled || props.enableVoice === false) {
258
- return;
259
- }
260
- if (voice.status === "recording") {
261
- voice.stopRecording("transcribe");
262
- return;
263
- }
264
- if (voice.status === "transcribing") {
265
- return;
266
- }
267
- voice.beginVoiceMode();
268
- voice.startRecording();
269
- }, style: {
270
- ...sideActionStyle,
271
- color: voice.status === "recording" || voice.status === "transcribing"
272
- ? tokens.color.iconActive
273
- : tokens.color.iconDefault,
274
- cursor: props.disabled || props.enableVoice === false ? "not-allowed" : "pointer",
275
- opacity: props.disabled || props.enableVoice === false ? 0.4 : 1,
276
- }, "aria-label": voice.status === "recording"
277
- ? "停止录音并转文字"
278
- : voice.status === "transcribing"
279
- ? "正在转写语音"
280
- : "开始语音输入", children: voice.status === "recording" ? (_jsx(StopCircleOutlineIcon, { size: 27 })) : (_jsx(MicOutlineIcon, { size: 24, strokeWidth: 2 })) }), _jsx(InputSurface, { tokens: tokens, status: state.status, styleOverride: {
281
- minHeight: 52,
282
- padding: "0 14px",
283
- display: "flex",
284
- alignItems: "center",
285
- }, children: _jsx(TextComposer, { ref: textComposerRef, tokens: tokens, value: state.text, rows: 1, placeholder: "Message PMate", onPaste: handlePaste, onDrop: props.onDrop, onKeyDown: (event) => {
188
+ return (_jsxs("div", { style: { position: "relative", width: "100%" }, children: [_jsx(MentionMenu, { ...mentions.menuProps, tokens: tokens }), _jsxs("div", { className: "grid gap-3", style: { position: "relative", width: "100%" }, children: [_jsx("input", { ref: imageInputRef, type: "file", accept: props.image?.accept ?? "image/*", style: { display: "none" }, onChange: (event) => {
189
+ const file = event.target.files?.[0];
190
+ if (file) {
191
+ void handleImageFile(file);
192
+ setIsMoreOpen(false);
193
+ }
194
+ event.currentTarget.value = "";
195
+ } }), _jsx("input", { ref: fileInputRef, type: "file", style: { display: "none" }, onChange: (event) => {
196
+ const file = event.target.files?.[0];
197
+ if (file) {
198
+ handleFileAttachment(file);
199
+ setIsMoreOpen(false);
200
+ }
201
+ event.currentTarget.value = "";
202
+ } }), _jsx(EmojiPicker, { open: isEmojiOpen, isMobile: false, anchorRef: emojiAnchorRef, onSelect: insertEmoji, onClose: () => setIsEmojiOpen(false) }), _jsx(AttachmentPreview, { attachments: attachments.attachments, tokens: tokens, onRemove: attachments.removeAttachment }), isMoreOpen ? (_jsxs("div", { style: {
203
+ position: "absolute",
204
+ left: 0,
205
+ right: 0,
206
+ bottom: props.multiline ? 154 : 66,
207
+ padding: "12px 14px",
208
+ borderRadius: 24,
209
+ background: `color-mix(in srgb, ${tokens.color.surfaceInputFocus} 94%, transparent)`,
210
+ border: `1px solid ${tokens.color.borderDefault}`,
211
+ display: "flex",
212
+ gap: 12,
213
+ boxShadow: "0 18px 50px rgba(0, 0, 0, 0.16)",
214
+ backdropFilter: "blur(18px)",
215
+ zIndex: 3,
216
+ }, children: [_jsxs("button", { type: "button", style: attachmentActionStyle, disabled: props.disabled || isUploadingImage, onClick: () => imageInputRef.current?.click(), children: [_jsx("span", { style: attachmentIconStyle, children: _jsx(ImageOutlineIcon, { size: 24 }) }), "\u56FE\u7247"] }), _jsxs("button", { type: "button", style: attachmentActionStyle, disabled: props.disabled, onClick: () => fileInputRef.current?.click(), children: [_jsx("span", { style: attachmentIconStyle, children: _jsx(FileOutlineIcon, { size: 24 }) }), "\u6587\u4EF6"] })] })) : null, voice.status === "recording" ? (_jsxs("div", { className: "flex items-center gap-3", children: [_jsx(WaveformFeedback, { levels: voice.waveformLevels, tokens: tokens, compact: true }), _jsxs("span", { style: { color: tokens.color.textSecondary, fontSize: 12 }, children: [Math.max(1, Math.round(voice.durationMs / 1000)), "s"] })] })) : null, props.multiline ? (_jsxs(InputSurface, { tokens: tokens, status: state.status, styleOverride: {
217
+ minHeight: tokens.sizing.multilineMinHeight,
218
+ padding: "16px 18px 12px",
219
+ display: "flex",
220
+ flexDirection: "column",
221
+ gap: 12,
222
+ }, children: [_jsx(TextComposer, { ref: textComposerRef, tokens: tokens, ...mentions.inputProps, disabled: props.disabled || state.status === "sending", value: state.text, rows: 3, placeholder: "Message PMate", onPaste: handlePaste, onDrop: props.onDrop, onKeyDown: (event) => {
223
+ mentions.inputProps.onKeyDown(event);
224
+ if (event.defaultPrevented)
225
+ return;
286
226
  if (event.key !== "Enter" ||
227
+ event.shiftKey ||
287
228
  event.nativeEvent.isComposing ||
288
- event.keyCode === 229) {
229
+ event.keyCode === 229)
230
+ return;
231
+ event.preventDefault();
232
+ if (state.text.trim() || state.attachments.length > 0)
233
+ void onSend();
234
+ }, style: {
235
+ flex: 1,
236
+ minHeight: tokens.sizing.multilineTextMinHeight,
237
+ maxHeight: tokens.sizing.multilineTextMaxHeight,
238
+ padding: 0,
239
+ overflowY: "auto",
240
+ caretColor: tokens.color.brandPrimary,
241
+ } }), _jsxs("div", { style: { display: "flex", alignItems: "center", justifyContent: "space-between", gap: 12 }, children: [_jsx(MoreTrigger, { "data-icon-size": tokens.sizing.desktopIcon, onClick: () => { setIsEmojiOpen(false); setIsMoreOpen((prev) => !prev); }, disabled: props.disabled, style: { ...sideActionStyle, color: isMoreOpen ? tokens.color.iconActive : tokens.color.iconDefault, opacity: props.disabled ? 0.4 : 1 } }), _jsxs("div", { style: { display: "flex", alignItems: "center", gap: 8 }, children: [_jsx("button", { type: "button", onClick: () => {
242
+ if (props.disabled || props.enableVoice === false)
243
+ return;
244
+ if (voice.status === "recording") {
245
+ voice.stopRecording("transcribe");
246
+ return;
247
+ }
248
+ if (voice.status === "transcribing")
249
+ return;
250
+ voice.beginVoiceMode();
251
+ voice.startRecording();
252
+ }, style: {
253
+ ...sideActionStyle,
254
+ color: voice.status === "recording" || voice.status === "transcribing" ? tokens.color.iconActive : tokens.color.iconDefault,
255
+ cursor: props.disabled || props.enableVoice === false ? "not-allowed" : "pointer",
256
+ opacity: props.disabled || props.enableVoice === false ? 0.4 : 1,
257
+ }, "aria-label": voice.status === "recording" ? "停止录音并转文字" : voice.status === "transcribing" ? "正在转写语音" : "开始语音输入", children: voice.status === "recording" ? _jsx(StopCircleOutlineIcon, { size: tokens.sizing.desktopIcon }) : _jsx(MicOutlineIcon, { size: tokens.sizing.desktopIcon, strokeWidth: 2 }) }), _jsx(EmojiTrigger, { ref: emojiAnchorRef, "data-icon-size": tokens.sizing.desktopIcon, style: sideActionStyle, onClick: () => { setIsMoreOpen(false); setIsEmojiOpen((prev) => !prev); } }), _jsx(SendAction, { tokens: tokens, disabled: props.disabled || state.status === "sending" || (!state.text.trim() && state.attachments.length === 0), "aria-label": "\u53D1\u9001\u6D88\u606F", onClick: () => void onSend(), style: { width: tokens.sizing.desktopSendAction, height: tokens.sizing.desktopSendAction, padding: 0, borderRadius: 999, fontSize: tokens.sizing.desktopSendIcon, lineHeight: `${tokens.sizing.desktopSendAction}px` }, children: "\u2191" })] })] })] })) : _jsxs("div", { style: {
258
+ display: "grid",
259
+ gridTemplateColumns: "32px minmax(0, 1fr) 32px 32px",
260
+ alignItems: "center",
261
+ gap: 6,
262
+ }, children: [_jsx("button", { type: "button", onClick: () => {
263
+ if (props.disabled || props.enableVoice === false) {
289
264
  return;
290
265
  }
291
- const shouldSend = props.submitShortcut === "meta-enter" ? event.metaKey || event.ctrlKey : !event.shiftKey;
292
- if (shouldSend) {
293
- event.preventDefault();
294
- if (state.text.trim() || state.attachments.length > 0) {
295
- void onSend();
296
- }
266
+ if (voice.status === "recording") {
267
+ voice.stopRecording("transcribe");
268
+ return;
269
+ }
270
+ if (voice.status === "transcribing") {
271
+ return;
297
272
  }
298
- }, onChange: (event) => {
299
- state.setText(event.target.value);
300
- state.setStatus(event.target.value.trim() ? "typing" : "idle");
301
- props.onChange?.(event.target.value);
273
+ voice.beginVoiceMode();
274
+ voice.startRecording();
302
275
  }, style: {
303
- minHeight: tokens.typography.inputText.lineHeight,
304
- height: tokens.typography.inputText.lineHeight,
305
- maxHeight: 128,
306
- paddingTop: 0,
307
- paddingBottom: 0,
308
- display: "block",
309
- overflow: "hidden",
310
- caretColor: tokens.color.brandPrimary,
311
- } }) }), _jsx(EmojiTrigger, { ref: emojiAnchorRef, "data-icon-size": 24, style: {
312
- ...sideActionStyle,
313
- width: 32,
314
- height: 32,
315
- }, onClick: () => {
316
- setIsMoreOpen(false);
317
- setIsEmojiOpen((prev) => !prev);
318
- } }), _jsx(MoreTrigger, { "data-icon-size": 24, onClick: () => {
319
- setIsEmojiOpen(false);
320
- setIsMoreOpen((prev) => !prev);
321
- }, disabled: props.disabled, style: {
322
- ...sideActionStyle,
323
- color: isMoreOpen ? tokens.color.iconActive : tokens.color.iconDefault,
324
- width: 32,
325
- height: 32,
326
- opacity: props.disabled ? 0.4 : 1,
327
- } })] }), _jsx(StatusHint, { tokens: tokens, text: desktopStatusText, tone: state.error ? "error" : "secondary" })] }) }));
276
+ ...sideActionStyle,
277
+ color: voice.status === "recording" || voice.status === "transcribing"
278
+ ? tokens.color.iconActive
279
+ : tokens.color.iconDefault,
280
+ cursor: props.disabled || props.enableVoice === false ? "not-allowed" : "pointer",
281
+ opacity: props.disabled || props.enableVoice === false ? 0.4 : 1,
282
+ }, "aria-label": voice.status === "recording"
283
+ ? "停止录音并转文字"
284
+ : voice.status === "transcribing"
285
+ ? "正在转写语音"
286
+ : "开始语音输入", children: voice.status === "recording" ? (_jsx(StopCircleOutlineIcon, { size: 27 })) : (_jsx(MicOutlineIcon, { size: 24, strokeWidth: 2 })) }), _jsx(InputSurface, { tokens: tokens, status: state.status, styleOverride: {
287
+ minHeight: 52,
288
+ padding: "0 14px",
289
+ display: "flex",
290
+ alignItems: "center",
291
+ }, children: _jsx(TextComposer, { ref: textComposerRef, tokens: tokens, ...mentions.inputProps, disabled: props.disabled || state.status === "sending", value: state.text, rows: 1, placeholder: "Message PMate", onPaste: handlePaste, onDrop: props.onDrop, onKeyDown: (event) => {
292
+ mentions.inputProps.onKeyDown(event);
293
+ if (event.defaultPrevented)
294
+ return;
295
+ if (event.key !== "Enter" ||
296
+ event.nativeEvent.isComposing ||
297
+ event.keyCode === 229) {
298
+ return;
299
+ }
300
+ const shouldSend = props.submitShortcut === "meta-enter" ? event.metaKey || event.ctrlKey : !event.shiftKey;
301
+ if (shouldSend) {
302
+ event.preventDefault();
303
+ if (state.text.trim() || state.attachments.length > 0) {
304
+ void onSend();
305
+ }
306
+ }
307
+ }, style: {
308
+ minHeight: tokens.typography.inputText.lineHeight,
309
+ height: tokens.typography.inputText.lineHeight,
310
+ maxHeight: 128,
311
+ paddingTop: 0,
312
+ paddingBottom: 0,
313
+ display: "block",
314
+ overflow: "hidden",
315
+ caretColor: tokens.color.brandPrimary,
316
+ } }) }), _jsx(EmojiTrigger, { ref: emojiAnchorRef, "data-icon-size": 24, style: {
317
+ ...sideActionStyle,
318
+ width: 32,
319
+ height: 32,
320
+ }, onClick: () => {
321
+ setIsMoreOpen(false);
322
+ setIsEmojiOpen((prev) => !prev);
323
+ } }), _jsx(MoreTrigger, { "data-icon-size": 24, onClick: () => {
324
+ setIsEmojiOpen(false);
325
+ setIsMoreOpen((prev) => !prev);
326
+ }, disabled: props.disabled, style: {
327
+ ...sideActionStyle,
328
+ color: isMoreOpen ? tokens.color.iconActive : tokens.color.iconDefault,
329
+ width: 32,
330
+ height: 32,
331
+ opacity: props.disabled ? 0.4 : 1,
332
+ } })] }), _jsx(StatusHint, { tokens: tokens, text: desktopStatusText, tone: state.error ? "error" : "secondary" })] })] }));
328
333
  }
329
334
  export function ChatInputDesktop(props) {
330
335
  const stateKey = JSON.stringify({
@@ -1,4 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { useMentions } from "../hooks/useMentions";
3
+ import { MentionMenu } from "./MentionMenu";
2
4
  import React from "react";
3
5
  import { Provider, createStore } from "jotai";
4
6
  import { useEffect, useRef, useState } from "react";
@@ -23,6 +25,10 @@ function MobileInner(props) {
23
25
  const tokens = mergeChatInputTokens(props.tokens);
24
26
  const state = useChatInputState();
25
27
  const attachments = useAttachmentState();
28
+ const mentions = useMentions({
29
+ value: state.text, config: props.mentions, disabled: props.disabled || state.status === "sending" || state.mode === "voice",
30
+ onChange(value) { state.setText(value); state.setStatus(value.trim() ? "typing" : "idle"); props.onChange?.(value); },
31
+ });
26
32
  const [recordingChoice, setRecordingChoice] = useState("send");
27
33
  const [isEmojiOpen, setIsEmojiOpen] = useState(false);
28
34
  const [isMoreOpen, setIsMoreOpen] = useState(false);
@@ -69,6 +75,7 @@ function MobileInner(props) {
69
75
  state.setStatus("sending");
70
76
  await props.onSend({
71
77
  text: state.text,
78
+ mentions: mentions.getMentions(),
72
79
  attachments: state.attachments,
73
80
  transcript: state.transcript || undefined,
74
81
  mode: "text",
@@ -264,7 +271,7 @@ function MobileInner(props) {
264
271
  userSelect: "none",
265
272
  WebkitUserSelect: "none",
266
273
  WebkitTouchCallout: "none",
267
- }, children: [isRecordingOverlayVisible ? (_jsxs("div", { style: {
274
+ }, children: [_jsx(MentionMenu, { ...mentions.menuProps, tokens: tokens }), isRecordingOverlayVisible ? (_jsxs("div", { style: {
268
275
  position: "fixed",
269
276
  left: "50%",
270
277
  bottom: "calc(72px + env(safe-area-inset-bottom, 0px))",
@@ -552,7 +559,10 @@ function MobileInner(props) {
552
559
  }, onPressCancel: () => {
553
560
  setRecordingChoice("send");
554
561
  voice.cancelRecording();
555
- } }) })) : (_jsx(TextComposer, { ref: textComposerRef, tokens: tokens, value: state.text, rows: 1, wrap: "off", placeholder: "Message", enterKeyHint: "send", onPaste: handlePaste, onDrop: props.onDrop, onKeyDown: (event) => {
562
+ } }) })) : (_jsx(TextComposer, { ref: textComposerRef, tokens: tokens, ...mentions.inputProps, disabled: props.disabled || state.status === "sending", value: state.text, rows: 1, wrap: "off", placeholder: "Message", enterKeyHint: "send", onPaste: handlePaste, onDrop: props.onDrop, onKeyDown: (event) => {
563
+ mentions.inputProps.onKeyDown(event);
564
+ if (event.defaultPrevented)
565
+ return;
556
566
  if (event.key === "Enter" &&
557
567
  !event.shiftKey &&
558
568
  !event.nativeEvent.isComposing &&
@@ -562,10 +572,6 @@ function MobileInner(props) {
562
572
  void onSend();
563
573
  }
564
574
  }
565
- }, onChange: (event) => {
566
- state.setText(event.target.value);
567
- state.setStatus(event.target.value.trim() ? "typing" : "idle");
568
- props.onChange?.(event.target.value);
569
575
  }, style: {
570
576
  minHeight: tokens.typography.inputText.lineHeight,
571
577
  height: tokens.typography.inputText.lineHeight,
@@ -0,0 +1,15 @@
1
+ import type { RefObject } from "react";
2
+ import type { ChatInputTokens, DeepPartial } from "../types/chatInput";
3
+ import type { MentionCandidate } from "../mentions/model";
4
+ export declare function MentionMenu({ id, open, candidates, activeIndex, onSelect, onActiveIndex, menuRef, label, emptyText, tokens: overrides }: {
5
+ id: string;
6
+ open: boolean;
7
+ candidates: MentionCandidate[];
8
+ activeIndex: number;
9
+ onSelect: (candidate: MentionCandidate) => void;
10
+ onActiveIndex: (index: number) => void;
11
+ menuRef: RefObject<HTMLDivElement | null>;
12
+ label: string;
13
+ emptyText: string;
14
+ tokens?: DeepPartial<ChatInputTokens>;
15
+ }): import("react/jsx-runtime").JSX.Element | null;
@@ -0,0 +1,16 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import React, { useEffect } from "react";
3
+ import { mergeChatInputTokens } from "../tokens/defaultTokens";
4
+ export function MentionMenu({ id, open, candidates, activeIndex, onSelect, onActiveIndex, menuRef, label, emptyText, tokens: overrides }) {
5
+ const tokens = mergeChatInputTokens(overrides);
6
+ useEffect(() => {
7
+ menuRef.current?.querySelector('[aria-selected="true"]')?.scrollIntoView({ block: "nearest" });
8
+ }, [activeIndex, open]);
9
+ if (!open)
10
+ return null;
11
+ return _jsx("div", { ref: menuRef, id: id, role: "listbox", "aria-label": label, style: {
12
+ position: "absolute", bottom: "calc(100% + 8px)", left: 0, width: "min(360px, 100%)", maxHeight: "min(280px, 40dvh)", overflowY: "auto", overscrollBehavior: "contain", zIndex: 20,
13
+ boxSizing: "border-box", padding: 6, borderRadius: tokens.radius.inputSurface, border: `1px solid ${tokens.color.borderDefault}`,
14
+ background: tokens.color.surfaceInputFocus, color: tokens.color.textPrimary, boxShadow: tokens.shadow.inputElevated, fontFamily: tokens.typography.familyBase,
15
+ }, children: candidates.length ? candidates.map((candidate, index) => _jsxs("button", { id: `${id}-${index}`, role: "option", "aria-selected": index === activeIndex, tabIndex: -1, type: "button", onMouseDown: event => event.preventDefault(), onMouseMove: () => onActiveIndex(index), onClick: () => onSelect(candidate), style: { display: "flex", alignItems: "center", gap: 10, width: "100%", minHeight: 44, padding: "8px 10px", border: 0, borderRadius: 8, textAlign: "left", cursor: "pointer", color: "inherit", background: index === activeIndex ? `color-mix(in srgb, ${tokens.color.brandPrimary} 14%, ${tokens.color.surfaceInputFocus})` : "transparent" }, children: [candidate.avatar ? _jsx("img", { src: candidate.avatar, alt: "", width: 28, height: 28, style: { borderRadius: "50%", objectFit: "cover" } }) : _jsx("span", { "aria-hidden": true, style: { width: 28, flexShrink: 0, textAlign: "center", color: tokens.color.brandPrimary }, children: "@" }), _jsxs("span", { style: { minWidth: 0, overflowWrap: "anywhere" }, children: [_jsx("span", { style: { display: "block", fontSize: tokens.typography.inputText.size }, children: candidate.name }), _jsx("small", { style: { color: tokens.color.textSecondary }, children: candidate.description ?? candidate.id })] })] }, candidate.id)) : _jsx("div", { role: "status", style: { padding: 12, color: tokens.color.textSecondary }, children: emptyText }) });
16
+ }
@@ -0,0 +1,38 @@
1
+ import type { ChangeEvent, KeyboardEvent, SyntheticEvent } from "react";
2
+ import type { ChatMention, MentionCandidate, MentionConfig } from "../mentions/model";
3
+ /** Attach inputProps to a textarea, render MentionMenu with menuProps, and pass
4
+ * getMentions() with the untrimmed value when sending. No business lookup is performed. */
5
+ export declare function useMentions({ value, onChange, config, disabled }: {
6
+ value: string;
7
+ onChange: (value: string) => void;
8
+ config?: MentionConfig;
9
+ disabled?: boolean;
10
+ }): {
11
+ getMentions: () => ChatMention[];
12
+ inputProps: {
13
+ onChange(event: ChangeEvent<HTMLTextAreaElement>): void;
14
+ onSelect: (event: SyntheticEvent<HTMLTextAreaElement>) => void;
15
+ onFocus(event: SyntheticEvent<HTMLTextAreaElement>): void;
16
+ onBlur(event: React.FocusEvent<HTMLTextAreaElement>): void;
17
+ onCompositionStart(): void;
18
+ onCompositionEnd(event: SyntheticEvent<HTMLTextAreaElement>): void;
19
+ onKeyDown: (event: KeyboardEvent<HTMLTextAreaElement>) => void;
20
+ role?: "combobox" | undefined;
21
+ "aria-autocomplete"?: "list" | undefined;
22
+ "aria-haspopup"?: "listbox" | undefined;
23
+ "aria-expanded"?: boolean | undefined;
24
+ "aria-controls"?: string | undefined;
25
+ "aria-activedescendant"?: string | undefined;
26
+ };
27
+ menuProps: {
28
+ id: string;
29
+ open: boolean;
30
+ candidates: MentionCandidate[];
31
+ activeIndex: number;
32
+ onSelect: (candidate: MentionCandidate) => void;
33
+ onActiveIndex: import("react").Dispatch<import("react").SetStateAction<number>>;
34
+ menuRef: import("react").RefObject<HTMLDivElement | null>;
35
+ label: string;
36
+ emptyText: string;
37
+ };
38
+ };
@@ -0,0 +1,83 @@
1
+ import { useId, useRef, useState } from "react";
2
+ import { filterMentionCandidates, findMentionQuery, rebaseMentions } from "../mentions/model";
3
+ /** Attach inputProps to a textarea, render MentionMenu with menuProps, and pass
4
+ * getMentions() with the untrimmed value when sending. No business lookup is performed. */
5
+ export function useMentions({ value, onChange, config, disabled = false }) {
6
+ const id = useId();
7
+ const inputRef = useRef(null);
8
+ const menuRef = useRef(null);
9
+ const document = useRef({ text: value, mentions: [] });
10
+ const [caret, setCaret] = useState(0);
11
+ const [focused, setFocused] = useState(false);
12
+ const [composing, setComposing] = useState(false);
13
+ const [dismissed, setDismissed] = useState(false);
14
+ const [activeIndex, setActiveIndex] = useState(0);
15
+ const mentions = rebaseMentions(document.current.text, value, document.current.mentions);
16
+ document.current = { text: value, mentions };
17
+ const query = config && focused && !composing && !disabled && !dismissed ? findMentionQuery(value, caret) : null;
18
+ const candidates = query ? filterMentionCandidates(config.candidates, query.query) : [];
19
+ const index = Math.min(activeIndex, Math.max(0, candidates.length - 1));
20
+ function select(candidate) {
21
+ if (!query)
22
+ return;
23
+ const insertion = `@${candidate.name}`;
24
+ const next = value.slice(0, query.start) + insertion + " " + value.slice(query.end);
25
+ document.current = {
26
+ text: next,
27
+ mentions: [...rebaseMentions(value, next, mentions), { id: candidate.id, name: candidate.name, start: query.start, end: query.start + insertion.length }].sort((a, b) => a.start - b.start),
28
+ };
29
+ setDismissed(true);
30
+ onChange(next);
31
+ requestAnimationFrame(() => {
32
+ const position = query.start + insertion.length + 1;
33
+ inputRef.current?.focus();
34
+ inputRef.current?.setSelectionRange(position, position);
35
+ setCaret(position);
36
+ });
37
+ }
38
+ function updateSelection(event) {
39
+ inputRef.current = event.currentTarget;
40
+ setCaret(event.currentTarget.selectionStart);
41
+ if (event.currentTarget.selectionStart !== event.currentTarget.selectionEnd)
42
+ setDismissed(true);
43
+ }
44
+ function onKeyDown(event) {
45
+ if (!query || event.nativeEvent.isComposing || event.keyCode === 229)
46
+ return;
47
+ if (event.key === "Escape") {
48
+ event.preventDefault();
49
+ setDismissed(true);
50
+ }
51
+ if (event.key === "ArrowDown" || event.key === "ArrowUp") {
52
+ event.preventDefault();
53
+ setActiveIndex(candidates.length ? (index + (event.key === "ArrowDown" ? 1 : candidates.length - 1)) % candidates.length : 0);
54
+ }
55
+ if (event.key === "Enter" && !event.shiftKey && !event.metaKey && !event.ctrlKey) {
56
+ event.preventDefault();
57
+ if (candidates[index])
58
+ select(candidates[index]);
59
+ }
60
+ }
61
+ return {
62
+ getMentions: () => config ? document.current.mentions.filter(mention => config.candidates.some(candidate => candidate.id === mention.id)) : [],
63
+ inputProps: {
64
+ ...(config ? { role: "combobox", "aria-autocomplete": "list", "aria-haspopup": "listbox", "aria-expanded": Boolean(query), "aria-controls": query ? id : undefined, "aria-activedescendant": query && candidates[index] ? `${id}-${index}` : undefined } : {}),
65
+ onChange(event) {
66
+ updateSelection(event);
67
+ setDismissed(false);
68
+ setActiveIndex(0);
69
+ onChange(event.currentTarget.value);
70
+ },
71
+ onSelect: updateSelection,
72
+ onFocus(event) { updateSelection(event); setFocused(true); },
73
+ onBlur(event) {
74
+ if (!menuRef.current?.contains(event.relatedTarget))
75
+ setFocused(false);
76
+ },
77
+ onCompositionStart() { setComposing(true); },
78
+ onCompositionEnd(event) { setComposing(false); setDismissed(false); updateSelection(event); },
79
+ onKeyDown,
80
+ },
81
+ menuProps: { id, open: Boolean(query), candidates, activeIndex: index, onSelect: select, onActiveIndex: setActiveIndex, menuRef, label: config?.label ?? "选择要提及的成员", emptyText: config?.emptyText ?? "没有匹配的成员" },
82
+ };
83
+ }
package/lib/index.d.ts CHANGED
@@ -28,3 +28,6 @@ export * from "./types/attachment";
28
28
  export * from "./types/chatInput";
29
29
  export * from "./types/voice";
30
30
  export * from "./voice/Recorder";
31
+ export * from "./mentions/model";
32
+ export * from "./hooks/useMentions";
33
+ export * from "./components/MentionMenu";
package/lib/index.js CHANGED
@@ -28,3 +28,6 @@ export * from "./types/attachment";
28
28
  export * from "./types/chatInput";
29
29
  export * from "./types/voice";
30
30
  export * from "./voice/Recorder";
31
+ export * from "./mentions/model";
32
+ export * from "./hooks/useMentions";
33
+ export * from "./components/MentionMenu";