@pmate/chat-sdk 0.11.2 → 0.11.4

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/README.md CHANGED
@@ -106,3 +106,24 @@ await meetingClient.join(groupId, teamId, invitedProfileIds)
106
106
  All commands for an established call/meeting carry its stored Team. Foreign-Team responses are ignored, and a mismatched join acknowledgment releases capture. Hub reach call events require Team context and are checked against the authoritative attach response before showing incoming UI. A native call ID can still be attached directly: the server supplies its authoritative Team.
107
107
 
108
108
  This is a protocol-breaking minor release for the pre-1.0 SDK. It requires the Team-scoped call-api and updated notification consumers; coordinate deployment with the application's migration rollout. It does not migrate legacy meeting/group history itself.
109
+
110
+ ## Clipboard images in custom composers
111
+
112
+ ### Background
113
+
114
+ Applications with their own input UI need the same screenshot paste behavior as the SDK inputs. `handleImagePaste` reads the user's paste event without requesting clipboard access; it does not fetch HTML image URLs.
115
+
116
+ ```tsx
117
+ <textarea onPaste={event => handleImagePaste(event, {
118
+ disabled,
119
+ onImages: files => { void uploadImages(files).catch(showUploadError) },
120
+ })} />
121
+ ```
122
+
123
+ The handler leaves text-only pastes and events already handled by the host untouched. Image items take precedence over text/HTML; `clipboardData.files` is used when no readable image items exist. The host owns size/type validation, upload progress, error feedback and whether to preview or send the image. Both SDK inputs use this interface. Multiple attachments are appended without overwriting earlier selections.
124
+
125
+ ### Draft preview and upload
126
+
127
+ `useImageAttachments({ uploadImage, disabled })` provides `onPaste`, `addFiles`, `attachments`, `pending`, `remove` and `retry`. Render `ImageAttachmentTray` inside the composer's surface with these attachments and the input tokens. Local previews appear immediately; uploads happen in the background. Removal/unmount revokes local object URLs, and late upload results cannot reinsert removed drafts. Upload errors remain visible and retryable.
128
+
129
+ Only send on explicit submission: block send while `pending`, deliver each ready attachment's `url`, and remove it after delivery succeeds. Keep unsuccessful drafts to allow retry without reuploading. For `ChatInputDesktop`, use `hasPendingContent` and `sendDisabled` with host-owned drafts. Unmount or key the draft owner when switching accounts, Teams or conversations.
@@ -1,4 +1,5 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { handleImagePaste } from "../ux/imagePaste";
2
3
  import { useMentions } from "../hooks/useMentions";
3
4
  import { MentionMenu } from "./MentionMenu";
4
5
  import React from "react";
@@ -66,21 +67,26 @@ function DesktopInner(props) {
66
67
  ? "Transcript inserted. Edit or send."
67
68
  : undefined));
68
69
  const onSend = async () => {
69
- if (!props.onSend) {
70
+ if (!props.onSend || props.disabled || props.sendDisabled || isUploadingImage || state.status === "sending") {
70
71
  return;
71
72
  }
72
73
  state.setStatus("sending");
73
- await props.onSend({
74
- text: state.text,
75
- mentions: mentions.getMentions(),
76
- attachments: state.attachments,
77
- transcript: state.transcript || undefined,
78
- });
79
- state.setText("");
80
- state.setTranscript("");
81
- state.setAttachments([]);
82
- props.onChange?.("");
83
- state.setStatus("idle");
74
+ state.setError(null);
75
+ try {
76
+ await props.onSend({
77
+ text: state.text, mentions: mentions.getMentions(), attachments: state.attachments,
78
+ transcript: state.transcript || undefined,
79
+ });
80
+ state.setText("");
81
+ state.setTranscript("");
82
+ state.setAttachments([]);
83
+ props.onChange?.("");
84
+ state.setStatus("idle");
85
+ }
86
+ catch (error) {
87
+ state.setError(error instanceof Error ? error.message : "Failed to send message");
88
+ state.setStatus("error");
89
+ }
84
90
  };
85
91
  const handleImageFile = async (file) => {
86
92
  if (!props.image?.uploadImage) {
@@ -119,16 +125,11 @@ function DesktopInner(props) {
119
125
  };
120
126
  const handlePaste = (event) => {
121
127
  props.onPaste?.(event);
122
- if (event.defaultPrevented || props.enableImage === false)
123
- return;
124
- const images = [...event.clipboardData.items]
125
- .filter((item) => item.kind === "file" && item.type.startsWith("image/"))
126
- .flatMap((item) => item.getAsFile() ? [item.getAsFile()] : []);
127
- if (!images.length)
128
- return;
129
- event.preventDefault();
130
- for (const image of images)
131
- void handleImageFile(image);
128
+ handleImagePaste(event, {
129
+ disabled: props.disabled || props.enableImage === false || state.status === "sending",
130
+ onImages: images => { for (const image of images)
131
+ void handleImageFile(image); },
132
+ });
132
133
  };
133
134
  const insertEmoji = (emoji) => {
134
135
  const textarea = textComposerRef.current;
@@ -229,7 +230,7 @@ function DesktopInner(props) {
229
230
  event.keyCode === 229)
230
231
  return;
231
232
  event.preventDefault();
232
- if (state.text.trim() || state.attachments.length > 0)
233
+ if (state.text.trim() || state.attachments.length > 0 || props.hasPendingContent)
233
234
  void onSend();
234
235
  }, style: {
235
236
  flex: 1,
@@ -254,7 +255,7 @@ function DesktopInner(props) {
254
255
  color: voice.status === "recording" || voice.status === "transcribing" ? tokens.color.iconActive : tokens.color.iconDefault,
255
256
  cursor: props.disabled || props.enableVoice === false ? "not-allowed" : "pointer",
256
257
  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
+ }, "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 || props.sendDisabled || isUploadingImage || state.status === "sending" || (!state.text.trim() && state.attachments.length === 0 && !props.hasPendingContent), "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
259
  display: "grid",
259
260
  gridTemplateColumns: "32px minmax(0, 1fr) 32px 32px",
260
261
  alignItems: "center",
@@ -300,7 +301,7 @@ function DesktopInner(props) {
300
301
  const shouldSend = props.submitShortcut === "meta-enter" ? event.metaKey || event.ctrlKey : !event.shiftKey;
301
302
  if (shouldSend) {
302
303
  event.preventDefault();
303
- if (state.text.trim() || state.attachments.length > 0) {
304
+ if (state.text.trim() || state.attachments.length > 0 || props.hasPendingContent) {
304
305
  void onSend();
305
306
  }
306
307
  }
@@ -1,4 +1,5 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { handleImagePaste } from "../ux/imagePaste";
2
3
  import { useMentions } from "../hooks/useMentions";
3
4
  import { MentionMenu } from "./MentionMenu";
4
5
  import React from "react";
@@ -123,16 +124,11 @@ function MobileInner(props) {
123
124
  };
124
125
  const handlePaste = (event) => {
125
126
  props.onPaste?.(event);
126
- if (event.defaultPrevented || props.enableImage === false)
127
- return;
128
- const images = [...event.clipboardData.items]
129
- .filter((item) => item.kind === "file" && item.type.startsWith("image/"))
130
- .flatMap((item) => item.getAsFile() ? [item.getAsFile()] : []);
131
- if (!images.length)
132
- return;
133
- event.preventDefault();
134
- for (const image of images)
135
- void handleImageFile(image);
127
+ handleImagePaste(event, {
128
+ disabled: props.disabled || props.enableImage === false || state.status === "sending",
129
+ onImages: images => { for (const image of images)
130
+ void handleImageFile(image); },
131
+ });
136
132
  };
137
133
  const isRecordingOverlayVisible = state.status === "recording" || state.status === "transcribing";
138
134
  const isTranscriptConfirmVisible = state.status === "transcript-ready" && Boolean(state.transcript);
@@ -0,0 +1,11 @@
1
+ import type { ImageDraftAttachment } from "../hooks/useImageAttachments";
2
+ import type { ChatInputTokens } from "../types/chatInput";
3
+ /** Shared inline draft UI for host-owned inputs. Uses the same tokens as SDK composers. */
4
+ export declare function ImageAttachmentTray({ attachments, tokens, onRemove, onRetry, disabled, locale }: {
5
+ attachments: ImageDraftAttachment[];
6
+ tokens: ChatInputTokens;
7
+ onRemove: (id: string) => void;
8
+ onRetry: (id: string) => void;
9
+ disabled?: boolean;
10
+ locale?: "en" | "zh";
11
+ }): import("react/jsx-runtime").JSX.Element | null;
@@ -0,0 +1,9 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import React from "react";
3
+ /** Shared inline draft UI for host-owned inputs. Uses the same tokens as SDK composers. */
4
+ export function ImageAttachmentTray({ attachments, tokens, onRemove, onRetry, disabled, locale = "en" }) {
5
+ if (!attachments.length)
6
+ return null;
7
+ return _jsx("div", { "aria-label": locale === "zh" ? "待发送图片" : "Image drafts", style: { display: "flex", flexWrap: "wrap", gap: 10, padding: 12, minWidth: 0 }, children: attachments.map(item => _jsxs("div", { style: { position: "relative", width: 112, maxWidth: "100%", borderRadius: 12,
8
+ border: `1px solid ${item.status === "error" ? tokens.color.statusError : tokens.color.borderDefault}`, overflow: "hidden", background: tokens.color.surfaceInputDefault }, children: [_jsx("img", { src: item.previewUrl, alt: item.name, style: { display: "block", width: "100%", height: 96, objectFit: "contain" } }), _jsx("button", { type: "button", disabled: disabled, "aria-label": `${locale === "zh" ? "移除" : "Remove"} ${item.name}`, onClick: () => onRemove(item.id), style: { position: "absolute", top: 4, right: 4, width: 24, height: 24, borderRadius: 99, border: 0, background: tokens.color.surfaceInputFocus, color: tokens.color.textPrimary, cursor: "pointer" }, children: "\u00D7" }), item.status === "uploading" ? _jsx("div", { role: "status", style: { fontSize: 12, padding: 6, color: tokens.color.textSecondary }, children: locale === "zh" ? "正在上传…" : "Uploading…" }) : null, item.status === "error" ? _jsxs("div", { style: { fontSize: 12, padding: 6, overflowWrap: "anywhere", color: tokens.color.statusError }, children: [_jsx("span", { role: "alert", children: item.error }), _jsx("button", { type: "button", disabled: disabled, onClick: () => onRetry(item.id), style: { display: "block", marginTop: 4, cursor: "pointer" }, children: locale === "zh" ? "重试上传" : "Retry upload" })] }) : null] }, item.id)) });
9
+ }
@@ -4,8 +4,8 @@ export function useAttachmentState() {
4
4
  return {
5
5
  attachments,
6
6
  addAttachment(attachment) {
7
- setAttachments([
8
- ...attachments,
7
+ setAttachments(current => [
8
+ ...current,
9
9
  {
10
10
  id: crypto.randomUUID(),
11
11
  kind: "image",
@@ -15,7 +15,7 @@ export function useAttachmentState() {
15
15
  setStatus("attachment-selected");
16
16
  },
17
17
  removeAttachment(id) {
18
- setAttachments(attachments.filter((item) => item.id !== id));
18
+ setAttachments(current => current.filter((item) => item.id !== id));
19
19
  },
20
20
  };
21
21
  }
@@ -0,0 +1,19 @@
1
+ import type { ClipboardEventHandler } from "react";
2
+ import type { ChatAttachment } from "../types/attachment";
3
+ import type { ChatImageUploadResult } from "../types/chatInput";
4
+ export type ImageDraftAttachment = ChatAttachment & {
5
+ status: "uploading" | "ready" | "error";
6
+ error?: string;
7
+ };
8
+ /** Local previews appear immediately. Uploading never sends a chat message. */
9
+ export declare function useImageAttachments(options: {
10
+ uploadImage?: (file: File) => Promise<ChatImageUploadResult>;
11
+ disabled?: boolean;
12
+ }): {
13
+ attachments: ImageDraftAttachment[];
14
+ addFiles: (files: File[]) => void;
15
+ onPaste: ClipboardEventHandler<HTMLTextAreaElement>;
16
+ pending: boolean;
17
+ remove(id: string): void;
18
+ retry(id: string): void;
19
+ };
@@ -0,0 +1,68 @@
1
+ import { useEffect, useRef, useState } from "react";
2
+ import { handleImagePaste } from "../ux/imagePaste";
3
+ /** Local previews appear immediately. Uploading never sends a chat message. */
4
+ export function useImageAttachments(options) {
5
+ const [attachments, setAttachments] = useState([]);
6
+ const entries = useRef(new Map());
7
+ const active = useRef(true);
8
+ const sync = () => { if (active.current)
9
+ setAttachments([...entries.current.values()]); };
10
+ useEffect(() => {
11
+ active.current = true;
12
+ return () => {
13
+ active.current = false;
14
+ for (const item of entries.current.values())
15
+ if (item.previewUrl)
16
+ URL.revokeObjectURL(item.previewUrl);
17
+ entries.current.clear();
18
+ };
19
+ }, []);
20
+ async function upload(item) {
21
+ item.status = "uploading";
22
+ item.error = undefined;
23
+ sync();
24
+ try {
25
+ const result = options.uploadImage ? await options.uploadImage(item.file) : undefined;
26
+ if (entries.current.get(item.id) !== item)
27
+ return;
28
+ item.url = result?.url;
29
+ item.status = "ready";
30
+ }
31
+ catch (error) {
32
+ if (entries.current.get(item.id) !== item)
33
+ return;
34
+ item.status = "error";
35
+ item.error = error instanceof Error ? error.message : "Image upload failed";
36
+ }
37
+ sync();
38
+ }
39
+ function addFiles(files) {
40
+ if (options.disabled)
41
+ return;
42
+ for (const file of files) {
43
+ const item = { id: crypto.randomUUID(), kind: "image", name: file.name,
44
+ file, previewUrl: URL.createObjectURL(file), status: "uploading" };
45
+ entries.current.set(item.id, item);
46
+ void upload(item);
47
+ }
48
+ }
49
+ const onPaste = event => {
50
+ handleImagePaste(event, { disabled: options.disabled, onImages: addFiles });
51
+ };
52
+ return {
53
+ attachments, addFiles, onPaste,
54
+ pending: attachments.some(item => item.status !== "ready"),
55
+ remove(id) {
56
+ const item = entries.current.get(id);
57
+ if (item?.previewUrl)
58
+ URL.revokeObjectURL(item.previewUrl);
59
+ entries.current.delete(id);
60
+ sync();
61
+ },
62
+ retry(id) {
63
+ const item = entries.current.get(id);
64
+ if (!options.disabled && item?.status === "error")
65
+ void upload(item);
66
+ },
67
+ };
68
+ }
package/lib/index.d.ts CHANGED
@@ -38,3 +38,6 @@ export * from "./runtime/meeting";
38
38
  export * from "./runtime/teamScope";
39
39
  export * from "./runtime/teamMigrations";
40
40
  export * from "./runtime/socialReadClient";
41
+ export * from "./ux/imagePaste";
42
+ export * from "./hooks/useImageAttachments";
43
+ export * from "./components/ImageAttachmentTray";
package/lib/index.js CHANGED
@@ -38,3 +38,6 @@ export * from "./runtime/meeting";
38
38
  export * from "./runtime/teamScope";
39
39
  export * from "./runtime/teamMigrations";
40
40
  export * from "./runtime/socialReadClient";
41
+ export * from "./ux/imagePaste";
42
+ export * from "./hooks/useImageAttachments";
43
+ export * from "./components/ImageAttachmentTray";
@@ -84,8 +84,13 @@ export interface ReadIndexPersistence {
84
84
  }
85
85
  /** IndexedDB transactions serialize tabs, and retain offline outbox across process restarts. */
86
86
  export declare class IndexedDbReadIndex implements ReadIndexPersistence {
87
- private readonly database;
87
+ private factory;
88
+ private name;
89
+ private database?;
90
+ private closed;
88
91
  constructor(factory?: IDBFactory, name?: string);
92
+ private getDatabase;
93
+ private transaction;
89
94
  load(appId: string, profileId: string): Promise<ReadIndexState | undefined>;
90
95
  update(appId: string, profileId: string, transform: (current: ReadIndexState) => ReadIndexState): Promise<ReadIndexState>;
91
96
  close(): Promise<void>;
@@ -164,32 +164,80 @@ export function unreadInThread(state, threadId) {
164
164
  }
165
165
  /** IndexedDB transactions serialize tabs, and retain offline outbox across process restarts. */
166
166
  export class IndexedDbReadIndex {
167
+ factory;
168
+ name;
167
169
  database;
170
+ closed = false;
168
171
  constructor(factory = globalThis.indexedDB, name = "pmate-social-read-index") {
172
+ this.factory = factory;
173
+ this.name = name;
169
174
  if (!factory)
170
175
  throw new Error("READ_STORAGE_UNAVAILABLE");
171
- this.database = new Promise((resolve, reject) => {
172
- const request = factory.open(name, 1);
173
- request.onupgradeneeded = () => { request.result.createObjectStore("profiles"); };
174
- request.onsuccess = () => { request.result.onversionchange = () => request.result.close(); resolve(request.result); };
175
- request.onerror = () => reject(request.error);
176
- request.onblocked = () => reject(new Error("READ_STORAGE_BLOCKED"));
176
+ }
177
+ getDatabase() {
178
+ if (this.closed)
179
+ return Promise.reject(new Error("READ_STORAGE_CLOSED"));
180
+ if (this.database)
181
+ return this.database;
182
+ let abandoned = false;
183
+ const pending = new Promise((resolve, reject) => {
184
+ const request = this.factory.open(this.name);
185
+ request.onupgradeneeded = () => { if (!request.result.objectStoreNames.contains("profiles"))
186
+ request.result.createObjectStore("profiles"); };
187
+ request.onsuccess = () => {
188
+ const db = request.result;
189
+ if (abandoned || this.closed) {
190
+ db.close();
191
+ reject(new Error("READ_STORAGE_CLOSED"));
192
+ return;
193
+ }
194
+ const invalidate = () => { if (this.database === pending)
195
+ this.database = undefined; };
196
+ db.onversionchange = () => { db.close(); invalidate(); };
197
+ db.onclose = invalidate;
198
+ resolve(db);
199
+ };
200
+ request.onerror = () => { abandoned = true; reject(request.error); };
201
+ request.onblocked = () => { abandoned = true; reject(new Error("READ_STORAGE_BLOCKED")); };
177
202
  });
203
+ this.database = pending;
204
+ void pending.catch(() => { if (this.database === pending)
205
+ this.database = undefined; });
206
+ return pending;
207
+ }
208
+ async transaction(mode, execute) {
209
+ for (let attempt = 0; attempt < 2; attempt++) {
210
+ const pending = this.getDatabase(), db = await pending;
211
+ if (this.closed)
212
+ throw new Error("READ_STORAGE_CLOSED");
213
+ let transaction;
214
+ try {
215
+ transaction = db.transaction("profiles", mode);
216
+ }
217
+ catch (error) {
218
+ if (attempt || error?.name !== "InvalidStateError")
219
+ throw error;
220
+ // Retry only transaction creation: no transform or write has run yet.
221
+ db.close();
222
+ if (this.database === pending)
223
+ this.database = undefined;
224
+ continue;
225
+ }
226
+ // Start requests synchronously after creation, including on WebKit.
227
+ return execute(transaction);
228
+ }
229
+ throw new Error("READ_STORAGE_UNAVAILABLE");
178
230
  }
179
231
  async load(appId, profileId) {
180
- const database = await this.database;
181
- return new Promise((resolve, reject) => {
182
- const transaction = database.transaction("profiles", "readonly");
232
+ return this.transaction("readonly", transaction => new Promise((resolve, reject) => {
183
233
  const request = transaction.objectStore("profiles").get([appId, profileId]);
184
234
  transaction.oncomplete = () => resolve(request.result);
185
235
  transaction.onerror = () => reject(transaction.error);
186
236
  transaction.onabort = () => reject(transaction.error ?? new Error("READ_STORAGE_ABORTED"));
187
- });
237
+ }));
188
238
  }
189
239
  async update(appId, profileId, transform) {
190
- const database = await this.database;
191
- return new Promise((resolve, reject) => {
192
- const transaction = database.transaction("profiles", "readwrite");
240
+ return this.transaction("readwrite", transaction => new Promise((resolve, reject) => {
193
241
  const store = transaction.objectStore("profiles");
194
242
  const request = store.get([appId, profileId]);
195
243
  let next, failure;
@@ -208,9 +256,13 @@ export class IndexedDbReadIndex {
208
256
  transaction.oncomplete = () => resolve(next);
209
257
  transaction.onerror = () => reject(failure ?? transaction.error);
210
258
  transaction.onabort = () => reject(failure ?? transaction.error ?? new Error("READ_STORAGE_ABORTED"));
211
- });
259
+ }));
212
260
  }
213
- async close() { (await this.database).close(); }
261
+ async close() { this.closed = true; const pending = this.database; this.database = undefined; if (pending) {
262
+ const db = await pending.catch(error => { if (error?.message !== "READ_STORAGE_CLOSED")
263
+ throw error; return undefined; });
264
+ db?.close();
265
+ } }
214
266
  }
215
267
  export function sourcesInConversation(state, conversationId) {
216
268
  return state.threads.filter(thread => (thread.conversationId ?? thread.id) === conversationId);
@@ -140,6 +140,10 @@ export type ChatInputBaseProps = {
140
140
  audio?: Blob;
141
141
  mode?: "text" | "voice";
142
142
  }) => Promise<void> | void;
143
+ /** Host-owned drafts can enable sending even when the SDK text/attachment list is empty. */
144
+ hasPendingContent?: boolean;
145
+ /** Blocks submission while host-owned drafts are uploading or invalid. */
146
+ sendDisabled?: boolean;
143
147
  enableVoice?: boolean;
144
148
  enableEmoji?: boolean;
145
149
  enableImage?: boolean;
@@ -0,0 +1,13 @@
1
+ /** Clipboard image handling for SDK inputs and host-owned composers. No clipboard permission request. */
2
+ export type ImagePasteEvent = {
3
+ clipboardData: Pick<DataTransfer, "items" | "files"> | null;
4
+ defaultPrevented: boolean;
5
+ preventDefault(): void;
6
+ };
7
+ /** Prefer items; files is used when the browser exposes no readable image items. */
8
+ export declare function getClipboardImages(data: ImagePasteEvent["clipboardData"]): File[];
9
+ /** Consumes image pastes only. The host owns validation, upload, progress and errors. */
10
+ export declare function handleImagePaste(event: ImagePasteEvent, options: {
11
+ disabled?: boolean;
12
+ onImages: (files: File[]) => void;
13
+ }): boolean;
@@ -0,0 +1,23 @@
1
+ /** Prefer items; files is used when the browser exposes no readable image items. */
2
+ export function getClipboardImages(data) {
3
+ if (!data)
4
+ return [];
5
+ const images = Array.from(data.items).flatMap(item => {
6
+ if (item.kind !== "file" || !item.type.startsWith("image/"))
7
+ return [];
8
+ const file = item.getAsFile();
9
+ return file ? [file] : [];
10
+ });
11
+ return images.length ? images : Array.from(data.files).filter(file => file.type.startsWith("image/"));
12
+ }
13
+ /** Consumes image pastes only. The host owns validation, upload, progress and errors. */
14
+ export function handleImagePaste(event, options) {
15
+ if (event.defaultPrevented || options.disabled)
16
+ return false;
17
+ const images = getClipboardImages(event.clipboardData);
18
+ if (!images.length)
19
+ return false;
20
+ event.preventDefault();
21
+ options.onImages(images);
22
+ return true;
23
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pmate/chat-sdk",
3
- "version": "0.11.2",
3
+ "version": "0.11.4",
4
4
  "description": "Cross-platform PMate chat input components, voice input primitives, and ASR helpers",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",