@talex-touch/utils 1.0.39 → 1.0.42

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.
@@ -44,67 +44,141 @@ export interface ClipboardApplyOptions {
44
44
  type?: PluginClipboardItem['type']
45
45
  }
46
46
 
47
+ export interface ClipboardWriteOptions {
48
+ text?: string
49
+ html?: string
50
+ image?: string
51
+ files?: string[]
52
+ }
53
+
54
+ export interface ClipboardReadResult {
55
+ text: string
56
+ html: string
57
+ hasImage: boolean
58
+ hasFiles: boolean
59
+ formats: string[]
60
+ }
61
+
62
+ export interface ClipboardImageResult {
63
+ dataUrl: string
64
+ width: number
65
+ height: number
66
+ }
67
+
68
+ export interface ClipboardCopyAndPasteOptions {
69
+ text?: string
70
+ html?: string
71
+ image?: string
72
+ files?: string[]
73
+ delayMs?: number
74
+ hideCoreBox?: boolean
75
+ }
76
+
47
77
  export type ClipboardSearchOptions = PluginClipboardSearchOptions
48
78
  export type ClipboardSearchResponse = PluginClipboardSearchResponse
49
79
 
80
+ /**
81
+ * @deprecated Use `useClipboard()` instead. This function will be removed in a future version.
82
+ */
50
83
  export function useClipboardHistory() {
84
+ return useClipboard().history
85
+ }
86
+
87
+ /**
88
+ * Unified Clipboard SDK for plugin renderer context.
89
+ *
90
+ * Provides:
91
+ * - Basic clipboard operations (read/write) via IPC to main process
92
+ * - Clipboard history management
93
+ * - Copy and paste to active application
94
+ *
95
+ * All operations go through IPC to avoid WebContents focus issues.
96
+ *
97
+ * @example
98
+ * ```typescript
99
+ * const clipboard = useClipboard()
100
+ *
101
+ * // === Basic Operations ===
102
+ * await clipboard.writeText('Hello World')
103
+ * const content = await clipboard.read()
104
+ *
105
+ * // === Copy and Paste ===
106
+ * await clipboard.copyAndPaste({ text: 'Pasted content' })
107
+ *
108
+ * // === History Operations ===
109
+ * const latest = await clipboard.history.getLatest()
110
+ * const { history } = await clipboard.history.getHistory({ page: 1 })
111
+ *
112
+ * // === Listen to Changes ===
113
+ * // Note: Plugin must call box.allowClipboard(types) first
114
+ * const unsubscribe = clipboard.history.onDidChange((item) => {
115
+ * console.log('Clipboard changed:', item)
116
+ * })
117
+ * ```
118
+ */
119
+ export function useClipboard() {
51
120
  const channel = ensurePluginChannel()
52
121
 
53
- return {
122
+ const history = {
123
+ /**
124
+ * Gets the most recent clipboard item
125
+ */
54
126
  async getLatest(): Promise<PluginClipboardItem | null> {
55
127
  const result = await channel.send('clipboard:get-latest')
56
128
  return normalizeItem(result)
57
129
  },
58
130
 
131
+ /**
132
+ * Gets clipboard history with pagination
133
+ */
59
134
  async getHistory(options: ClipboardHistoryOptions = {}): Promise<PluginClipboardHistoryResponse> {
60
135
  const response = await channel.send('clipboard:get-history', options)
61
- const history = Array.isArray(response?.history)
136
+ const items = Array.isArray(response?.history)
62
137
  ? response.history.map((item: PluginClipboardItem) => normalizeItem(item) ?? item)
63
138
  : []
64
139
  return {
65
140
  ...response,
66
- history,
141
+ history: items,
67
142
  }
68
143
  },
69
144
 
145
+ /**
146
+ * Sets favorite status for a clipboard item
147
+ */
70
148
  async setFavorite(options: ClipboardFavoriteOptions): Promise<void> {
71
149
  await channel.send('clipboard:set-favorite', options)
72
150
  },
73
151
 
152
+ /**
153
+ * Deletes a clipboard item from history
154
+ */
74
155
  async deleteItem(options: ClipboardDeleteOptions): Promise<void> {
75
156
  await channel.send('clipboard:delete-item', options)
76
157
  },
77
158
 
159
+ /**
160
+ * Clears all clipboard history
161
+ */
78
162
  async clearHistory(): Promise<void> {
79
163
  await channel.send('clipboard:clear-history')
80
164
  },
81
165
 
82
166
  /**
83
- * Search clipboard history with advanced filtering options.
84
- * Supports keyword search, time-based filtering, and combined filters.
85
- *
86
- * @param options - Search options
87
- * @returns Search results with pagination metadata
167
+ * Search clipboard history with advanced filtering
88
168
  *
89
169
  * @example
90
170
  * ```typescript
91
171
  * // Search by keyword
92
- * const result = await searchHistory({ keyword: 'hello' })
93
- *
94
- * // Search by time range (last 24 hours)
95
- * const oneDayAgo = Date.now() - 24 * 60 * 60 * 1000
96
- * const recent = await searchHistory({ startTime: oneDayAgo })
172
+ * const result = await clipboard.history.searchHistory({ keyword: 'hello' })
97
173
  *
98
- * // Combined search: text type, favorite items from a specific app
99
- * const filtered = await searchHistory({
174
+ * // Filter by type and time
175
+ * const recent = await clipboard.history.searchHistory({
100
176
  * type: 'text',
101
- * isFavorite: true,
102
- * sourceApp: 'com.apple.Safari'
177
+ * startTime: Date.now() - 24 * 60 * 60 * 1000
103
178
  * })
104
179
  * ```
105
180
  */
106
181
  async searchHistory(options: ClipboardSearchOptions = {}): Promise<ClipboardSearchResponse> {
107
- // Use the extended clipboard:get-history interface with search parameters
108
182
  const response = await channel.send('clipboard:get-history', options)
109
183
  const items = Array.isArray(response?.history)
110
184
  ? response.history.map((item: PluginClipboardItem) => normalizeItem(item) ?? item)
@@ -117,21 +191,120 @@ export function useClipboardHistory() {
117
191
  }
118
192
  },
119
193
 
194
+ /**
195
+ * Listen to clipboard changes.
196
+ *
197
+ * **Important**: Plugin must call `box.allowClipboard(types)` first to enable monitoring.
198
+ * Only changes matching the allowed types will be received.
199
+ *
200
+ * @example
201
+ * ```typescript
202
+ * import { ClipboardType } from '@talex-touch/utils/plugin/sdk'
203
+ *
204
+ * // Enable monitoring for text and images
205
+ * await box.allowClipboard(ClipboardType.TEXT | ClipboardType.IMAGE)
206
+ *
207
+ * // Listen to changes
208
+ * const unsubscribe = clipboard.history.onDidChange((item) => {
209
+ * console.log('New clipboard item:', item.type, item.content)
210
+ * })
211
+ *
212
+ * // Later: stop listening
213
+ * unsubscribe()
214
+ * ```
215
+ */
120
216
  onDidChange(callback: (item: PluginClipboardItem) => void): () => void {
121
- return channel.regChannel('core-box:clipboard-change', ({ data }) => {
122
- const item = (data && 'item' in data ? data.item : data) as PluginClipboardItem
217
+ return channel.regChannel('core-box:clipboard-change', ({ data }: { data: unknown }) => {
218
+ const item = (data && typeof data === 'object' && 'item' in data ? (data as { item: PluginClipboardItem }).item : data) as PluginClipboardItem
123
219
  callback(normalizeItem(item) ?? item)
124
220
  })
125
221
  },
126
222
 
127
223
  /**
128
- * Writes the provided clipboard payload to the system clipboard and issues a paste command
129
- * to the foreground application.
224
+ * Apply a clipboard item to the active application (write + paste)
225
+ * @deprecated Use `clipboard.copyAndPaste()` instead
130
226
  */
131
227
  async applyToActiveApp(options: ClipboardApplyOptions = {}): Promise<boolean> {
132
228
  const response = await channel.send('clipboard:apply-to-active-app', options)
133
229
  if (typeof response === 'object' && response) {
134
- return Boolean((response as any).success)
230
+ return Boolean((response as { success?: boolean }).success)
231
+ }
232
+ return true
233
+ },
234
+ }
235
+
236
+ return {
237
+ /**
238
+ * Clipboard history operations
239
+ */
240
+ history,
241
+
242
+ /**
243
+ * Writes text to the system clipboard
244
+ */
245
+ async writeText(text: string): Promise<void> {
246
+ await channel.send('clipboard:write-text', { text })
247
+ },
248
+
249
+ /**
250
+ * Writes content to the system clipboard.
251
+ * Supports text, HTML, image (data URL), and files.
252
+ */
253
+ async write(options: ClipboardWriteOptions): Promise<void> {
254
+ await channel.send('clipboard:write', options)
255
+ },
256
+
257
+ /**
258
+ * Reads current clipboard content
259
+ */
260
+ async read(): Promise<ClipboardReadResult> {
261
+ return await channel.send('clipboard:read')
262
+ },
263
+
264
+ /**
265
+ * Reads image from clipboard as data URL
266
+ */
267
+ async readImage(): Promise<ClipboardImageResult | null> {
268
+ return await channel.send('clipboard:read-image')
269
+ },
270
+
271
+ /**
272
+ * Reads file paths from clipboard
273
+ */
274
+ async readFiles(): Promise<string[]> {
275
+ return await channel.send('clipboard:read-files')
276
+ },
277
+
278
+ /**
279
+ * Clears the system clipboard
280
+ */
281
+ async clear(): Promise<void> {
282
+ await channel.send('clipboard:clear')
283
+ },
284
+
285
+ /**
286
+ * Writes content to clipboard and simulates paste command to the active application.
287
+ * This is the recommended way to "paste" content from a plugin.
288
+ *
289
+ * @example
290
+ * ```typescript
291
+ * // Paste text
292
+ * await clipboard.copyAndPaste({ text: 'Hello' })
293
+ *
294
+ * // Paste with HTML formatting
295
+ * await clipboard.copyAndPaste({ text: 'Hello', html: '<b>Hello</b>' })
296
+ *
297
+ * // Paste image
298
+ * await clipboard.copyAndPaste({ image: 'data:image/png;base64,...' })
299
+ *
300
+ * // Paste files
301
+ * await clipboard.copyAndPaste({ files: ['/path/to/file.txt'] })
302
+ * ```
303
+ */
304
+ async copyAndPaste(options: ClipboardCopyAndPasteOptions): Promise<boolean> {
305
+ const response = await channel.send('clipboard:copy-and-paste', options)
306
+ if (typeof response === 'object' && response) {
307
+ return Boolean((response as { success?: boolean }).success)
135
308
  }
136
309
  return true
137
310
  },
@@ -1,4 +1,5 @@
1
1
  export enum BridgeEventForCoreBox {
2
2
  CORE_BOX_INPUT_CHANGE = 'core-box:input-change',
3
3
  CORE_BOX_CLIPBOARD_CHANGE = 'core-box:clipboard-change',
4
+ CORE_BOX_KEY_EVENT = 'core-box:key-event',
4
5
  }
@@ -14,6 +14,24 @@ import { ensureRendererChannel } from './channel'
14
14
  */
15
15
  export type InputChangeHandler = (input: string) => void
16
16
 
17
+ /**
18
+ * Keyboard event data forwarded from CoreBox
19
+ */
20
+ export interface ForwardedKeyEvent {
21
+ key: string
22
+ code: string
23
+ metaKey: boolean
24
+ ctrlKey: boolean
25
+ altKey: boolean
26
+ shiftKey: boolean
27
+ repeat: boolean
28
+ }
29
+
30
+ /**
31
+ * Key event handler
32
+ */
33
+ export type KeyEventHandler = (event: ForwardedKeyEvent) => void
34
+
17
35
  /**
18
36
  * Feature SDK interface for plugins
19
37
  *
@@ -125,6 +143,36 @@ export interface FeatureSDK {
125
143
  * ```
126
144
  */
127
145
  onInputChange(handler: InputChangeHandler): () => void
146
+
147
+ /**
148
+ * Registers a listener for keyboard events forwarded from CoreBox
149
+ *
150
+ * When a plugin's UI view is attached to CoreBox, certain key events
151
+ * (Enter, Arrow keys, Meta+key combinations) are forwarded to the plugin.
152
+ *
153
+ * @param handler - Callback function invoked when a key event is forwarded
154
+ * @returns Unsubscribe function
155
+ *
156
+ * @example
157
+ * ```typescript
158
+ * const unsubscribe = plugin.feature.onKeyEvent((event) => {
159
+ * if (event.key === 'Enter') {
160
+ * // Handle enter key
161
+ * submitSelection()
162
+ * } else if (event.key === 'ArrowDown') {
163
+ * // Navigate down in list
164
+ * selectNext()
165
+ * } else if (event.metaKey && event.key === 'k') {
166
+ * // Handle Cmd+K
167
+ * openSearch()
168
+ * }
169
+ * })
170
+ *
171
+ * // Later, unsubscribe
172
+ * unsubscribe()
173
+ * ```
174
+ */
175
+ onKeyEvent(handler: KeyEventHandler): () => void
128
176
  }
129
177
 
130
178
  /**
@@ -138,25 +186,48 @@ export interface FeatureSDK {
138
186
  */
139
187
  export function createFeatureSDK(boxItemsAPI: any, channel: any): FeatureSDK {
140
188
  const inputChangeHandlers: Set<InputChangeHandler> = new Set()
189
+ const keyEventHandlers: Set<KeyEventHandler> = new Set()
141
190
 
142
191
  // Register listener for input change events from main process
143
- const registerListener = () => {
192
+ const registerInputListener = () => {
144
193
  if (channel.onMain) {
145
194
  // Main process plugin context
146
- channel.onMain('core-box:input-changed', (event: any) => {
147
- const input = event.data?.input || event.input || ''
195
+ channel.onMain('core-box:input-change', (event: any) => {
196
+ const input = event.data?.input || event.data?.query?.text || event.input || ''
148
197
  inputChangeHandlers.forEach(handler => handler(input))
149
198
  })
150
199
  } else if (channel.on) {
151
200
  // Renderer process context
152
- channel.on('core-box:input-changed', (data: any) => {
153
- const input = data?.input || data || ''
201
+ channel.on('core-box:input-change', (data: any) => {
202
+ const input = data?.input || data?.query?.text || data || ''
154
203
  inputChangeHandlers.forEach(handler => handler(input))
155
204
  })
156
205
  }
157
206
  }
158
207
 
159
- registerListener()
208
+ // Register listener for key events from main process
209
+ const registerKeyListener = () => {
210
+ if (channel.onMain) {
211
+ // Main process plugin context
212
+ channel.onMain('core-box:key-event', (event: any) => {
213
+ const keyEvent = event.data as ForwardedKeyEvent
214
+ if (keyEvent) {
215
+ keyEventHandlers.forEach(handler => handler(keyEvent))
216
+ }
217
+ })
218
+ } else if (channel.on) {
219
+ // Renderer process context
220
+ channel.on('core-box:key-event', (data: any) => {
221
+ const keyEvent = data as ForwardedKeyEvent
222
+ if (keyEvent) {
223
+ keyEventHandlers.forEach(handler => handler(keyEvent))
224
+ }
225
+ })
226
+ }
227
+ }
228
+
229
+ registerInputListener()
230
+ registerKeyListener()
160
231
 
161
232
  return {
162
233
  pushItems(items: TuffItem[]): void {
@@ -200,6 +271,14 @@ export function createFeatureSDK(boxItemsAPI: any, channel: any): FeatureSDK {
200
271
  return () => {
201
272
  inputChangeHandlers.delete(handler)
202
273
  }
274
+ },
275
+
276
+ onKeyEvent(handler: KeyEventHandler): () => void {
277
+ keyEventHandlers.add(handler)
278
+
279
+ return () => {
280
+ keyEventHandlers.delete(handler)
281
+ }
203
282
  }
204
283
  }
205
284
  }
@@ -0,0 +1,246 @@
1
+ /**
2
+ * Flow SDK
3
+ *
4
+ * Plugin-side API for Flow Transfer operations.
5
+ * Allows plugins to dispatch flows and receive flow data.
6
+ */
7
+
8
+ import type {
9
+ FlowPayload,
10
+ FlowDispatchOptions,
11
+ FlowDispatchResult,
12
+ FlowTargetInfo,
13
+ FlowSessionUpdate,
14
+ FlowPayloadType
15
+ } from '../../types/flow'
16
+ import { FlowIPCChannel } from '../../types/flow'
17
+
18
+ /**
19
+ * Flow SDK interface
20
+ */
21
+ export interface IFlowSDK {
22
+ /**
23
+ * Dispatches a flow payload to another plugin
24
+ *
25
+ * @param payload - Data to transfer
26
+ * @param options - Dispatch options
27
+ * @returns Dispatch result
28
+ *
29
+ * @example
30
+ * ```typescript
31
+ * const result = await flow.dispatch(
32
+ * {
33
+ * type: 'text',
34
+ * data: 'Hello from plugin A',
35
+ * context: { sourcePluginId: 'plugin-a' }
36
+ * },
37
+ * {
38
+ * title: 'Share Text',
39
+ * preferredTarget: 'plugin-b.quick-note'
40
+ * }
41
+ * )
42
+ * ```
43
+ */
44
+ dispatch(payload: FlowPayload, options?: FlowDispatchOptions): Promise<FlowDispatchResult>
45
+
46
+ /**
47
+ * Gets available flow targets
48
+ *
49
+ * @param payloadType - Filter by payload type (optional)
50
+ * @returns List of available targets
51
+ */
52
+ getAvailableTargets(payloadType?: FlowPayloadType): Promise<FlowTargetInfo[]>
53
+
54
+ /**
55
+ * Listens for session updates
56
+ *
57
+ * @param sessionId - Session to listen to
58
+ * @param handler - Update handler
59
+ * @returns Unsubscribe function
60
+ */
61
+ onSessionUpdate(
62
+ sessionId: string,
63
+ handler: (update: FlowSessionUpdate) => void
64
+ ): () => void
65
+
66
+ /**
67
+ * Cancels a flow session
68
+ *
69
+ * @param sessionId - Session to cancel
70
+ */
71
+ cancel(sessionId: string): Promise<void>
72
+
73
+ /**
74
+ * Acknowledges a received flow (for target plugins)
75
+ *
76
+ * @param sessionId - Session to acknowledge
77
+ * @param ackPayload - Optional acknowledgment data
78
+ */
79
+ acknowledge(sessionId: string, ackPayload?: any): Promise<void>
80
+
81
+ /**
82
+ * Reports an error for a received flow (for target plugins)
83
+ *
84
+ * @param sessionId - Session to report error for
85
+ * @param message - Error message
86
+ */
87
+ reportError(sessionId: string, message: string): Promise<void>
88
+ }
89
+
90
+ /**
91
+ * Creates a Flow SDK instance
92
+ *
93
+ * @param channel - Channel for IPC communication
94
+ * @param pluginId - Current plugin ID
95
+ * @returns Flow SDK instance
96
+ */
97
+ export function createFlowSDK(
98
+ channel: { send: (event: string, data?: any) => Promise<any> },
99
+ pluginId: string
100
+ ): IFlowSDK {
101
+ const sessionListeners = new Map<string, Set<(update: FlowSessionUpdate) => void>>()
102
+
103
+ // Listen for session updates
104
+ if (typeof window !== 'undefined') {
105
+ window.addEventListener('message', (event) => {
106
+ if (event.data?.type === FlowIPCChannel.SESSION_UPDATE) {
107
+ const update = event.data.payload as FlowSessionUpdate
108
+ const listeners = sessionListeners.get(update.sessionId)
109
+ if (listeners) {
110
+ for (const listener of listeners) {
111
+ try {
112
+ listener(update)
113
+ } catch (error) {
114
+ console.error('[FlowSDK] Error in session listener:', error)
115
+ }
116
+ }
117
+ }
118
+ }
119
+ })
120
+ }
121
+
122
+ return {
123
+ async dispatch(payload: FlowPayload, options?: FlowDispatchOptions): Promise<FlowDispatchResult> {
124
+ // Ensure context has sourcePluginId
125
+ const enrichedPayload: FlowPayload = {
126
+ ...payload,
127
+ context: {
128
+ ...payload.context,
129
+ sourcePluginId: payload.context?.sourcePluginId || pluginId
130
+ }
131
+ }
132
+
133
+ const response = await channel.send(FlowIPCChannel.DISPATCH, {
134
+ senderId: pluginId,
135
+ payload: enrichedPayload,
136
+ options
137
+ })
138
+
139
+ if (!response?.success) {
140
+ throw new Error(response?.error?.message || 'Flow dispatch failed')
141
+ }
142
+
143
+ return response.data
144
+ },
145
+
146
+ async getAvailableTargets(payloadType?: FlowPayloadType): Promise<FlowTargetInfo[]> {
147
+ const response = await channel.send(FlowIPCChannel.GET_TARGETS, {
148
+ payloadType
149
+ })
150
+
151
+ if (!response?.success) {
152
+ throw new Error(response?.error?.message || 'Failed to get targets')
153
+ }
154
+
155
+ return response.data || []
156
+ },
157
+
158
+ onSessionUpdate(
159
+ sessionId: string,
160
+ handler: (update: FlowSessionUpdate) => void
161
+ ): () => void {
162
+ if (!sessionListeners.has(sessionId)) {
163
+ sessionListeners.set(sessionId, new Set())
164
+ }
165
+ sessionListeners.get(sessionId)!.add(handler)
166
+
167
+ return () => {
168
+ const listeners = sessionListeners.get(sessionId)
169
+ if (listeners) {
170
+ listeners.delete(handler)
171
+ if (listeners.size === 0) {
172
+ sessionListeners.delete(sessionId)
173
+ }
174
+ }
175
+ }
176
+ },
177
+
178
+ async cancel(sessionId: string): Promise<void> {
179
+ const response = await channel.send(FlowIPCChannel.CANCEL, {
180
+ sessionId
181
+ })
182
+
183
+ if (!response?.success) {
184
+ throw new Error(response?.error?.message || 'Failed to cancel session')
185
+ }
186
+ },
187
+
188
+ async acknowledge(sessionId: string, ackPayload?: any): Promise<void> {
189
+ const response = await channel.send(FlowIPCChannel.ACKNOWLEDGE, {
190
+ sessionId,
191
+ ackPayload
192
+ })
193
+
194
+ if (!response?.success) {
195
+ throw new Error(response?.error?.message || 'Failed to acknowledge session')
196
+ }
197
+ },
198
+
199
+ async reportError(sessionId: string, message: string): Promise<void> {
200
+ const response = await channel.send(FlowIPCChannel.REPORT_ERROR, {
201
+ sessionId,
202
+ message
203
+ })
204
+
205
+ if (!response?.success) {
206
+ throw new Error(response?.error?.message || 'Failed to report error')
207
+ }
208
+ }
209
+ }
210
+ }
211
+
212
+ /**
213
+ * Helper to extract flow data from TuffQuery
214
+ *
215
+ * When a feature is triggered via Flow, the query will contain flow information.
216
+ *
217
+ * @param query - TuffQuery from feature trigger
218
+ * @returns Flow data if present, null otherwise
219
+ */
220
+ export function extractFlowData(query: any): {
221
+ sessionId: string
222
+ payload: FlowPayload
223
+ senderId: string
224
+ senderName?: string
225
+ } | null {
226
+ if (!query?.flow) {
227
+ return null
228
+ }
229
+
230
+ return {
231
+ sessionId: query.flow.sessionId,
232
+ payload: query.flow.payload,
233
+ senderId: query.flow.senderId,
234
+ senderName: query.flow.senderName
235
+ }
236
+ }
237
+
238
+ /**
239
+ * Helper to check if a feature was triggered via Flow
240
+ *
241
+ * @param query - TuffQuery from feature trigger
242
+ * @returns True if triggered via Flow
243
+ */
244
+ export function isFlowTriggered(query: any): boolean {
245
+ return !!query?.flow
246
+ }