funda-ui 4.5.888 → 4.5.899

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.
@@ -8,7 +8,7 @@ import RootPortal from 'funda-root-portal';
8
8
  import useComId from 'funda-utils/dist/cjs/useComId';
9
9
  import useDebounce from 'funda-utils/dist/cjs/useDebounce';
10
10
  import useThrottle from 'funda-utils/dist/cjs/useThrottle';
11
-
11
+ import useClickOutside from 'funda-utils/dist/cjs/useClickOutside';
12
12
 
13
13
 
14
14
  // loader
@@ -20,11 +20,16 @@ import {
20
20
  formatLatestDisplayContent,
21
21
  formatName,
22
22
  fixHtmlTags,
23
- isStreamResponse
23
+ isStreamResponse,
24
+ htmlEncode
24
25
  } from './utils/func';
25
26
 
26
27
  import useStreamController from './useStreamController';
27
28
 
29
+ export interface CustomMethod {
30
+ name: string;
31
+ func: (...args: any[]) => any;
32
+ }
28
33
 
29
34
  export type MessageDetail = {
30
35
  sender: string; // Sender's name
@@ -37,6 +42,14 @@ export interface FloatingButton {
37
42
  label: string; // HTML string
38
43
  value: string;
39
44
  onClick: string;
45
+ isSelect?: boolean; // Mark whether it is a drop-down selection button
46
+ [key: string]: any; // Allows dynamic `onSelect__<number>` attributes, such as `onSelect__1`, `onSelect__2`, ...
47
+ }
48
+
49
+ export interface FloatingButtonSelectOption {
50
+ label: string;
51
+ value: string;
52
+ onClick: string;
40
53
  }
41
54
 
42
55
  export interface RequestConfig {
@@ -45,18 +58,18 @@ export interface RequestConfig {
45
58
  responseExtractor: string; // JSON path to extract response
46
59
  }
47
60
 
48
- type CustomRequestConfig = {
61
+ export type CustomRequestConfig = {
49
62
  requestBody: any;
50
63
  apiUrl: string;
51
64
  headers: any;
52
65
  };
53
66
 
54
- type CustomRequestResponse = {
67
+ export type CustomRequestResponse = {
55
68
  content: string | Response | null;
56
69
  isStream: boolean;
57
70
  };
58
71
 
59
- type CustomRequestFunction = (
72
+ export type CustomRequestFunction = (
60
73
  message: string,
61
74
  config: CustomRequestConfig
62
75
  ) => Promise<CustomRequestResponse>;
@@ -90,11 +103,13 @@ export type ChatboxProps = {
90
103
  contextData?: Record<string, any>; // Dynamic JSON data
91
104
  toolkitButtons?: FloatingButton[];
92
105
  newChatButton?: FloatingButton;
106
+ customMethods?: CustomMethod[]; // [{"name": "method1", "func": "() => { console.log('test'); }"}, ...]
93
107
  customRequest?: CustomRequestFunction;
94
108
  renderParser?: (input: string) => Promise<string>;
95
109
  requestBodyFormatter?: (body: any, contextData: Record<string, any>, conversationHistory: MessageDetail[]) => Promise<Record<string, any>>;
96
110
  nameFormatter?: (input: string) => string;
97
111
  onInputChange?: (controlRef: React.RefObject<any>, val: string) => any;
112
+ onInputCallback?: (input: string) => Promise<string>;
98
113
  onChunk?: (controlRef: React.RefObject<any>, lastContent: string, conversationHistory: MessageDetail[]) => any;
99
114
  onComplete?: (controlRef: React.RefObject<any>, lastContent: string, conversationHistory: MessageDetail[]) => any;
100
115
  };
@@ -148,6 +163,21 @@ const Chatbox = (props: ChatboxProps) => {
148
163
  //================================================================
149
164
  // helper
150
165
  //================================================================
166
+ const customMethodsRef = useRef<Record<string, Function>>({});
167
+ useEffect(() => {
168
+ if (props.customMethods && Array.isArray(props.customMethods)) {
169
+ const methodsMap: Record<string, Function> = {};
170
+
171
+ props.customMethods.forEach(method => {
172
+ if (typeof method.func === 'function') {
173
+ methodsMap[method.name] = method.func;
174
+ }
175
+ });
176
+
177
+ customMethodsRef.current = methodsMap;
178
+ }
179
+ }, [props.customMethods]);
180
+
151
181
  const exposedMethods = () => {
152
182
  return {
153
183
  chatOpen: () => {
@@ -187,7 +217,24 @@ const Chatbox = (props: ChatboxProps) => {
187
217
  },
188
218
  setMessages: (v: MessageDetail[]) => {
189
219
  setMsgList(v);
190
- }
220
+ },
221
+ //
222
+ getCustomMethods: () => {
223
+ return Object.keys(customMethodsRef.current);
224
+ },
225
+ executeCustomMethod: (methodName: string, ...args: any[]) => {
226
+ if (methodName in customMethodsRef.current) {
227
+ try {
228
+ return customMethodsRef.current[methodName](...args);
229
+ } catch (error) {
230
+ console.error(`Error executing custom method ${methodName}:`, error);
231
+ return null;
232
+ }
233
+ } else {
234
+ console.warn(`Custom method ${methodName} not found`);
235
+ return null;
236
+ }
237
+ },
191
238
 
192
239
  };
193
240
  };
@@ -233,6 +280,7 @@ const Chatbox = (props: ChatboxProps) => {
233
280
  requestBodyFormatter,
234
281
  nameFormatter,
235
282
  onInputChange,
283
+ onInputCallback,
236
284
  onChunk,
237
285
  onComplete,
238
286
  } = currentProps;
@@ -304,6 +352,7 @@ const Chatbox = (props: ChatboxProps) => {
304
352
  requestBodyFormatter,
305
353
  nameFormatter,
306
354
  onInputChange,
355
+ onInputCallback,
307
356
  onChunk,
308
357
  onComplete,
309
358
 
@@ -325,7 +374,21 @@ const Chatbox = (props: ChatboxProps) => {
325
374
  //================================================================
326
375
  // Custom buttons
327
376
  //================================================================
377
+ const toolkitBtnsRef = useRef<any>(null);
328
378
  const [activeButtons, setActiveButtons] = useState<Record<string, boolean>>({});
379
+ const closeDropdowns = () => {
380
+ setActiveButtons(prev => {
381
+ const newState = { ...prev };
382
+ // Turn off only buttons with "isSelect"
383
+ args().toolkitButtons?.forEach((btn, index) => {
384
+ if (btn.isSelect) {
385
+ const _id = `${args().prefix || 'custom-'}chatbox-btn-tools-${chatId}${index}`;
386
+ newState[_id] = false;
387
+ }
388
+ });
389
+ return newState;
390
+ });
391
+ };
329
392
  const executeButtonAction = (actionStr: string, buttonId: string, buttonElement: HTMLButtonElement) => {
330
393
  try {
331
394
  // Create a new function to execute
@@ -352,6 +415,66 @@ const Chatbox = (props: ChatboxProps) => {
352
415
  }
353
416
  };
354
417
 
418
+ // options
419
+ const [selectedOpt, setSelectedOpt] = useState<Record<string, string | number>>({});
420
+ const getButtonOptions = (btn: FloatingButton): FloatingButtonSelectOption[] => {
421
+ const options: FloatingButtonSelectOption[] = [];
422
+ let index = 1;
423
+
424
+ while (true) {
425
+ const optionKey = `onSelect__${index}`;
426
+ if (!(optionKey in btn)) break;
427
+
428
+ const [label, value, onClick] = btn[optionKey].split('{#}').map((s: string) => s.trim());
429
+ options.push({ label, value, onClick });
430
+ index++;
431
+ }
432
+
433
+ return options;
434
+ };
435
+
436
+ const handleExecuteButtonSelect = (buttonId: string, option: FloatingButtonSelectOption, index: number, value: string) => {
437
+
438
+ if (option.value === "cancel") {
439
+ setSelectedOpt(prev => {
440
+ const newLabels = { ...prev };
441
+ delete newLabels[buttonId]; // Deletes the saved selected label, which displays the default label
442
+ return {
443
+ ...newLabels,
444
+ curIndex: index,
445
+ curValue: value
446
+ };
447
+ });
448
+
449
+ } else {
450
+ setSelectedOpt(prev => ({
451
+ ...prev,
452
+ [buttonId]: option.label,
453
+ curIndex: index,
454
+ curValue: value
455
+ }));
456
+ }
457
+
458
+
459
+ executeButtonAction(option.onClick, buttonId, document.getElementById(buttonId) as HTMLButtonElement);
460
+
461
+ // Close the drop-down
462
+ closeDropdowns();
463
+ };
464
+
465
+ // click outside
466
+ useClickOutside({
467
+ enabled: Object.values(activeButtons).some(isActive => isActive),
468
+ isOutside: (event: any) => {
469
+ return event.target.closest('.toolkit-select-wrapper') === null;
470
+ },
471
+ handle: (event: any) => {
472
+ closeDropdowns();
473
+ }
474
+ }, [toolkitBtnsRef, activeButtons]);
475
+
476
+
477
+
355
478
  //================================================================
356
479
  // Conversation History
357
480
  //================================================================
@@ -635,13 +758,18 @@ const Chatbox = (props: ChatboxProps) => {
635
758
  if (rootRef.current === null || msgContainerRef.current === null || msInput.current === null) return;
636
759
 
637
760
  const messageInput: any = msInput.current;
638
- const message = messageInput.value;
761
+ let message = htmlEncode(messageInput.value);
762
+
763
+ // It fires in real time as the user enters
764
+ // Sanitizing input is the process of securing/cleaning/filtering input data.
765
+ if (typeof args().onInputCallback === 'function') {
766
+ message = await args().onInputCallback(message);
767
+ }
639
768
 
640
769
  if (message.trim() === '') {
641
770
  return;
642
771
  }
643
772
 
644
-
645
773
  // Start the timer
646
774
  setElapsedTime(0); // Reset elapsed time
647
775
  timer.current = setInterval(() => {
@@ -1247,18 +1375,72 @@ const Chatbox = (props: ChatboxProps) => {
1247
1375
 
1248
1376
  {/**------------- TOOLKIT BUTTONS -------------*/}
1249
1377
  {args().toolkitButtons && args().toolkitButtons.length > 0 && (
1250
- <div className="toolkit-btns">
1378
+ <div className="toolkit-btns" ref={toolkitBtnsRef}>
1251
1379
  {args().toolkitButtons.map((btn: FloatingButton, index: number) => {
1252
1380
  const _id = `${args().prefix || 'custom-'}chatbox-btn-tools-${chatId}${index}`;
1253
1381
  const isActive = activeButtons[_id];
1254
- return <button
1255
- key={index}
1256
- id={_id}
1257
- className={`${btn.value || ''} ${isActive ? 'active' : ''}`}
1258
- onClick={(e: React.MouseEvent<HTMLButtonElement>) => executeButtonAction(btn.onClick, _id, e.currentTarget)}
1259
- >
1260
- <span dangerouslySetInnerHTML={{ __html: btn.label }}></span>
1261
- </button>
1382
+
1383
+ if (btn.isSelect) {
1384
+ const options = getButtonOptions(btn);
1385
+
1386
+ return (
1387
+ <div key={index} className="toolkit-select-wrapper">
1388
+ <button
1389
+ id={_id}
1390
+ className={`toolkit-select-btn ${btn.value || ''} ${isActive ? 'active' : ''} ${selectedOpt.curValue !== 'cancel' && typeof selectedOpt.curValue !== 'undefined' && selectedOpt.curValue !== '' ? 'opt-active' : ''}`}
1391
+ onClick={(e: React.MouseEvent<HTMLButtonElement>) => {
1392
+ e.preventDefault();
1393
+ setActiveButtons(prev => ({
1394
+ ...prev,
1395
+ [_id]: !prev[_id]
1396
+ }));
1397
+ }}
1398
+ >
1399
+ <span dangerouslySetInnerHTML={{
1400
+ __html: selectedOpt[_id] as string || btn.label
1401
+ }}></span>
1402
+
1403
+ <span className="toolkit-select-arrow"><svg width="5px" height="5px" viewBox="0 -4.5 20 20">
1404
+ <g stroke="none" strokeWidth="1" fill="none">
1405
+ <g transform="translate(-180.000000, -6684.000000)" className="arrow-fill-g" fill="currentColor">
1406
+ <g transform="translate(56.000000, 160.000000)">
1407
+ <path d="M144,6525.39 L142.594,6524 L133.987,6532.261 L133.069,6531.38 L133.074,6531.385 L125.427,6524.045 L124,6525.414 C126.113,6527.443 132.014,6533.107 133.987,6535 C135.453,6533.594 134.024,6534.965 144,6525.39">
1408
+ </path>
1409
+ </g>
1410
+ </g>
1411
+ </g>
1412
+ </svg></span>
1413
+ </button>
1414
+
1415
+
1416
+
1417
+ <div className={`toolkit-select-options ${isActive ? 'active' : ''}`}>
1418
+ {options.map((option: FloatingButtonSelectOption, optIndex: number) => (
1419
+ <div
1420
+ key={optIndex}
1421
+ className={`toolkit-select-option ${option.value || ''} ${selectedOpt.curIndex === optIndex ? 'selected' : ''}`}
1422
+ onClick={() => handleExecuteButtonSelect(_id, option, optIndex, option.value)}
1423
+ >
1424
+ <span dangerouslySetInnerHTML={{ __html: option.label }}></span>
1425
+ </div>
1426
+ ))}
1427
+ </div>
1428
+ </div>
1429
+ );
1430
+ }
1431
+
1432
+ // The rendering of the normal button
1433
+ return (
1434
+ <button
1435
+ key={index}
1436
+ id={_id}
1437
+ className={`${btn.value || ''} ${isActive ? 'active' : ''}`}
1438
+ onClick={(e: React.MouseEvent<HTMLButtonElement>) =>
1439
+ executeButtonAction(btn.onClick, _id, e.currentTarget)}
1440
+ >
1441
+ <span dangerouslySetInnerHTML={{ __html: btn.label }}></span>
1442
+ </button>
1443
+ );
1262
1444
  })}
1263
1445
  </div>
1264
1446
  )}
@@ -40,12 +40,15 @@ export function formatName(str: any, isAnswer: boolean, props: ChatboxProps) {
40
40
  answerNameIcon,
41
41
  nameFormatter
42
42
  } = props;
43
+
44
+ let res = str.replace(/\{icon\}/g, `${isAnswer ? answerNameIcon : questionNameIcon}`);
43
45
 
44
46
  if (typeof nameFormatter === 'function') {
45
- return nameFormatter(str.replace(/\{icon\}/g, `${isAnswer ? answerNameIcon : questionNameIcon}`));
46
- } else {
47
- return str.replace(/\{icon\}/g, `${isAnswer ? answerNameIcon : questionNameIcon}`);
47
+ const newVal = nameFormatter(res);
48
+ return newVal;
48
49
  }
50
+
51
+ return res;
49
52
  }
50
53
 
51
54
 
@@ -122,4 +125,59 @@ export function isStreamResponse(response: Response): boolean {
122
125
 
123
126
  // Method 3: Check if response.body is ReadableStream
124
127
  return response.body instanceof ReadableStream;
125
- };
128
+ };
129
+
130
+
131
+
132
+ /**
133
+ * HTML entities encode
134
+ *
135
+ * @param {String} str Input text
136
+ * @return {String} Filtered text
137
+ */
138
+ export function htmlEncode(str) {
139
+
140
+ return str.replace(/[&<>'"]/g, tag => ({
141
+ '&': '&amp;',
142
+ '<': '&lt;',
143
+ '>': '&gt;',
144
+ "'": '&#39;',
145
+ '"': '&quot;'
146
+ }[tag]));
147
+
148
+ }
149
+
150
+
151
+ /**
152
+ * HTML entities decode
153
+ *
154
+ * @param {String} str Input text
155
+ * @return {String} Filtered text
156
+ */
157
+ export function htmlDecode(str) {
158
+
159
+ let res = '';
160
+ const entities = [
161
+ ['amp', '&'],
162
+ ['apos', '\''],
163
+ ['#x27', '\''],
164
+ ['#x2F', '/'],
165
+ ['#39', '\''],
166
+ ['#47', '/'],
167
+ ['lt', '<'],
168
+ ['gt', '>'],
169
+ ['nbsp', ' '],
170
+ ['quot', '"'],
171
+ ['#60', '<'],
172
+ ['#62', '>']
173
+ ];
174
+
175
+ for (let i = 0, max = entities.length; i < max; i++) {
176
+ str = str.replace(new RegExp('&' + entities[i][0] + ';', 'g'), entities[i][1]);
177
+ }
178
+ res = str;
179
+
180
+ return res;
181
+
182
+ }
183
+
@@ -1,7 +1,6 @@
1
1
  import React, { useState, useEffect, useRef, forwardRef, ChangeEvent, KeyboardEvent, useImperativeHandle } from 'react';
2
2
 
3
3
 
4
-
5
4
  import useComId from 'funda-utils/dist/cjs/useComId';
6
5
  import useAutosizeTextArea from 'funda-utils/dist/cjs/useAutosizeTextArea';
7
6
  import { clsWrite, combinedCls } from 'funda-utils/dist/cjs/cls';
@@ -88,7 +88,13 @@ const useAutosizeTextArea = ({
88
88
 
89
89
  // initialize default row height
90
90
  if (el.scrollHeight > 0 && !defaultRowHeightInit) {
91
- setDefaultRowHeight(el.scrollHeight + parseInt(style.borderTopWidth) + parseInt(style.borderBottomWidth));
91
+
92
+ let _defaultRowHeight = el.scrollHeight + parseInt(style.borderTopWidth) + parseInt(style.borderBottomWidth);
93
+ if (maxHeight != 0 && _defaultRowHeight >= maxHeight) {
94
+ _defaultRowHeight = maxHeight;
95
+ }
96
+
97
+ setDefaultRowHeight(_defaultRowHeight);
92
98
  setDefaultRowHeightInit(true);
93
99
  }
94
100
 
@@ -106,12 +112,14 @@ const useAutosizeTextArea = ({
106
112
 
107
113
  // !!! Compare initial height and changed height
108
114
  if (scrollHeight > defaultRowHeight && defaultRowHeight > 0) {
109
- if (maxHeight != 0 && scrollHeight >= maxHeight) {
110
- el.style.height = maxHeight + "px";
111
- } else {
112
- el.style.height = scrollHeight + "px";
115
+
116
+ let _scrollHeight = scrollHeight;
117
+ if (maxHeight != 0 && _scrollHeight >= maxHeight) {
118
+ _scrollHeight = maxHeight;
113
119
  }
114
120
 
121
+ el.style.height = _scrollHeight + "px";
122
+
115
123
  }
116
124
 
117
125
  cb?.([_controlWidth, scrollHeight]);
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Global Url Listener (including micro frontends, frameworks, hashes, etc., applicable to multiple react app)
3
+ *
4
+ * @usage:
5
+
6
+ const App = () => {
7
+ const url = useGlobalUrlListener();
8
+
9
+ useEffect(() => {
10
+ console.log("URL changed:", url);
11
+ }, [url]);
12
+ };
13
+
14
+ */
15
+ import { useEffect, useState } from "react";
16
+
17
+ const useGlobalUrlListener = (): string => {
18
+ // Initialize state with empty string to avoid SSR issues
19
+ const [url, setUrl] = useState<string>('');
20
+
21
+ useEffect(() => {
22
+ // Type guard for SSR
23
+ if (typeof window === 'undefined') return;
24
+
25
+ // Initialize the URL on the client side
26
+ setUrl(window.location.href);
27
+
28
+ // Create MutationObserver instance
29
+ const observer: MutationObserver = new MutationObserver(() => {
30
+ setUrl(window.location.href);
31
+ });
32
+
33
+ // Start observing
34
+ observer.observe(document, {
35
+ subtree: true,
36
+ childList: true
37
+ });
38
+
39
+ // Cleanup function
40
+ return () => observer.disconnect();
41
+ }, []);
42
+
43
+ return url;
44
+ };
45
+
46
+ export default useGlobalUrlListener;
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Session Storage Listener
3
+ *
4
+ * @usage:
5
+
6
+ const App = () => {
7
+ const myValue = useSessionStorageListener("myKey");
8
+
9
+ useEffect(() => {
10
+ console.log("sessionStorage change:", myValue);
11
+ }, [myValue]);
12
+ };
13
+
14
+ */
15
+ import { useEffect, useState } from "react";
16
+
17
+ const useSessionStorageListener = (key: string) => {
18
+ const [value, setValue] = useState(sessionStorage.getItem(key) || "");
19
+
20
+ useEffect(() => {
21
+ const handleStorageChange = (event: Event) => {
22
+ if (event instanceof CustomEvent && event.detail.key === key) {
23
+ setValue(event.detail.value);
24
+ }
25
+ };
26
+
27
+ window.addEventListener("sessionStorageChange", handleStorageChange);
28
+
29
+ return () => window.removeEventListener("sessionStorageChange", handleStorageChange);
30
+ }, [key]);
31
+
32
+ return value;
33
+ };
34
+
35
+ // interception sessionStorage.setItem
36
+ const originalSetItem = sessionStorage.setItem;
37
+ sessionStorage.setItem = function (key, value) {
38
+ const event = new CustomEvent("sessionStorageChange", {
39
+ detail: { key, value },
40
+ });
41
+ window.dispatchEvent(event);
42
+ originalSetItem.apply(this, [key, value]);
43
+ };
44
+
45
+ export default useSessionStorageListener;
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "author": "UIUX Lab",
3
3
  "email": "uiuxlab@gmail.com",
4
4
  "name": "funda-ui",
5
- "version": "4.5.888",
5
+ "version": "4.5.899",
6
6
  "description": "React components using pure Bootstrap 5+ which does not contain any external style and script libraries.",
7
7
  "repository": {
8
8
  "type": "git",