@vanira/sdk-react-native 0.0.2
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 +239 -0
- package/package.json +53 -0
- package/src/__tests__/WebRTCClient.integration.test.ts +396 -0
- package/src/__tests__/adapters.test.ts +475 -0
- package/src/__tests__/httpResponse.test.ts +25 -0
- package/src/__tests__/mocks/react-native-incall-manager.ts +8 -0
- package/src/__tests__/mocks/react-native-permissions.ts +15 -0
- package/src/__tests__/mocks/react-native-webrtc.ts +6 -0
- package/src/__tests__/mocks/react-native.ts +28 -0
- package/src/__tests__/preset.test.ts +239 -0
- package/src/__tests__/resolveRuntimeConfig.test.ts +90 -0
- package/src/__tests__/storage.test.ts +211 -0
- package/src/__tests__/webrtcSignaling.test.ts +42 -0
- package/src/adapters/PeerConnectionAdapter.ts +101 -0
- package/src/adapters/browser/BrowserAudioAdapter.ts +43 -0
- package/src/adapters/browser/BrowserDataChannelAdapter.ts +69 -0
- package/src/adapters/browser/BrowserMediaAdapter.ts +15 -0
- package/src/adapters/browser/BrowserPeerAdapter.ts +14 -0
- package/src/adapters/browser/index.ts +4 -0
- package/src/adapters/interfaces.ts +84 -0
- package/src/adapters/react-native/RNAudioAdapter.ts +42 -0
- package/src/adapters/react-native/RNDataChannelAdapter.ts +79 -0
- package/src/adapters/react-native/RNMediaAdapter.ts +46 -0
- package/src/adapters/react-native/RNPeerAdapter.ts +28 -0
- package/src/adapters/react-native/callAudioRouting.ts +115 -0
- package/src/adapters/react-native/decodeUtf8.ts +72 -0
- package/src/adapters/react-native/index.ts +4 -0
- package/src/adapters/react-native/rnUploadFile.ts +76 -0
- package/src/adapters/storage/BrowserDualStorageAdapter.ts +71 -0
- package/src/adapters/storage/MemoryStorageAdapter.ts +50 -0
- package/src/adapters/storage/StorageAdapter.ts +21 -0
- package/src/adapters/storage/createSyncStorageAdapter.ts +40 -0
- package/src/adapters/storage/index.ts +7 -0
- package/src/api/services/ChatService.ts +304 -0
- package/src/api/services/ConfigService.ts +33 -0
- package/src/assets/icons.js +35 -0
- package/src/cdn.ts +68 -0
- package/src/core/CallSessionStore.ts +137 -0
- package/src/core/DraggableController.ts +83 -0
- package/src/core/SessionManager.ts +322 -0
- package/src/core/VaniraAI.ts +464 -0
- package/src/core/WebRTCClient.ts +1012 -0
- package/src/core/httpResponse.ts +22 -0
- package/src/core/iceServers.ts +18 -0
- package/src/core/toolCallNormalize.ts +80 -0
- package/src/core/voice-client.js +236 -0
- package/src/core/webrtcSignaling.ts +72 -0
- package/src/index.js +34 -0
- package/src/index.ts +6 -0
- package/src/platforms/browser.ts +67 -0
- package/src/platforms/react-native.ts +105 -0
- package/src/presets/BookingCalendarModal.tsx +457 -0
- package/src/presets/CameraModal.tsx +576 -0
- package/src/presets/DynamicFormModal.tsx +378 -0
- package/src/presets/NativePresetRenderer.tsx +350 -0
- package/src/presets/NavigateHandler.tsx +75 -0
- package/src/presets/PresetHost.tsx +155 -0
- package/src/presets/PresetShellModal.tsx +97 -0
- package/src/presets/UploadModal.tsx +321 -0
- package/src/presets/calendar/calendarUtils.ts +386 -0
- package/src/presets/call/CallSpeakerToggle.tsx +59 -0
- package/src/presets/call/callAudioRouting.ts +2 -0
- package/src/presets/call/useCallSpeaker.ts +31 -0
- package/src/presets/camera/cameraPermissions.ts +18 -0
- package/src/presets/camera/cameraStream.ts +19 -0
- package/src/presets/camera/cameraUtils.ts +21 -0
- package/src/presets/camera/useLivenessFlow.ts +95 -0
- package/src/presets/chalkboard/ChalkboardOverlay.tsx +156 -0
- package/src/presets/chalkboard/EraseTextHandler.tsx +95 -0
- package/src/presets/chalkboard/TypeTextHandler.tsx +107 -0
- package/src/presets/chalkboard/boardAbort.ts +36 -0
- package/src/presets/chalkboard/boardQueue.ts +620 -0
- package/src/presets/chalkboard/chalkboardSession.ts +75 -0
- package/src/presets/chalkboard/drawUtils.ts +123 -0
- package/src/presets/chalkboard/textUtils.ts +109 -0
- package/src/presets/clipRegion/ClipRegionModal.tsx +261 -0
- package/src/presets/clipRegion/clipRegionBridge.ts +19 -0
- package/src/presets/form/formValidation.ts +104 -0
- package/src/presets/form/parseFormFields.ts +171 -0
- package/src/presets/host/HostElementPresetHandler.tsx +155 -0
- package/src/presets/host/hostPresetBridge.ts +71 -0
- package/src/presets/index.ts +63 -0
- package/src/presets/liveScreen/CloseLiveScreenHandler.tsx +36 -0
- package/src/presets/liveScreen/LiveScreenCaptureHost.tsx +312 -0
- package/src/presets/liveScreen/LiveScreenHandler.tsx +25 -0
- package/src/presets/liveScreen/LiveScreenPipOverlay.tsx +6 -0
- package/src/presets/liveScreen/liveScreenSession.ts +73 -0
- package/src/presets/liveVision/CloseLiveVisionHandler.tsx +29 -0
- package/src/presets/liveVision/LiveVisionCameraHost.tsx +317 -0
- package/src/presets/liveVision/LiveVisionHandler.tsx +26 -0
- package/src/presets/liveVision/LiveVisionPipOverlay.tsx +7 -0
- package/src/presets/liveVision/liveVisionFrameLoop.ts +38 -0
- package/src/presets/liveVision/liveVisionSession.ts +75 -0
- package/src/presets/liveVision/liveVisionUpload.ts +62 -0
- package/src/presets/navigation/internalRouteRegistry.ts +25 -0
- package/src/presets/navigation/navigationBridge.ts +76 -0
- package/src/presets/navigation/navigationTypes.ts +12 -0
- package/src/presets/parseToolCall.ts +60 -0
- package/src/presets/presetClientAdapter.ts +29 -0
- package/src/presets/presetCompletion.ts +91 -0
- package/src/presets/presetEventHelpers.ts +45 -0
- package/src/presets/registry.ts +128 -0
- package/src/presets/streaming/mediaFrameUpload.ts +93 -0
- package/src/presets/types.ts +74 -0
- package/src/presets/upload/pickUploadFile.ts +256 -0
- package/src/presets/upload/uploadFormats.ts +163 -0
- package/src/presets/upload/uploadUtils.ts +68 -0
- package/src/react/PresetRenderer.tsx +144 -0
- package/src/react/index.ts +1 -0
- package/src/runtime/browserRuntime.ts +54 -0
- package/src/runtime/platform.ts +17 -0
- package/src/runtime/reactNativeRuntime.ts +68 -0
- package/src/runtime/resolveRuntimeConfig.ts +75 -0
- package/src/runtime/runtimeBundles.ts +74 -0
- package/src/runtime/types.ts +135 -0
- package/src/types/react-native-incall-manager.d.ts +17 -0
- package/src/types/react-native-webrtc.d.ts +47 -0
- package/src/types.ts +133 -0
- package/src/ui/VaniraWidget.ts +87 -0
- package/src/ui/abstraction/AbstractWidgetProvider.ts +18 -0
- package/src/ui/abstraction/interfaces.ts +12 -0
- package/src/ui/adapters/VaniraChatAdapter.ts +42 -0
- package/src/ui/components/AvatarView.ts +81 -0
- package/src/ui/components/ChatWindow.ts +263 -0
- package/src/ui/components/FloatingButton.ts +163 -0
- package/src/ui/components/FloatingWelcomeChips.ts +137 -0
- package/src/ui/components/Panel.ts +120 -0
- package/src/ui/components/VoiceOrb.ts +79 -0
- package/src/ui/components/VoiceOverlay.ts +497 -0
- package/src/ui/components/index.ts +7 -0
- package/src/ui/factory/WidgetFactory.ts +16 -0
- package/src/ui/icons_data.ts +2 -0
- package/src/ui/presets/WidgetPresetRenderer.ts +1802 -0
- package/src/ui/presets/types.ts +16 -0
- package/src/ui/providers/VaniraInternalProvider.ts +1066 -0
- package/src/ui/styles/index.ts +323 -0
- package/src/ui/styles/keyframes.ts +76 -0
- package/src/ui/styles/theme.ts +57 -0
- package/src/ui/styles/widget.css.ts +838 -0
- package/src/ui/utils.ts +37 -0
- package/src/ui/views/AbstractChatView.ts +93 -0
- package/src/ui/views/AbstractVoiceView.ts +57 -0
- package/src/ui/views/AvatarOnlyView.ts +78 -0
- package/src/ui/views/ChatAvatarView.ts +66 -0
- package/src/ui/views/ChatOnlyView.ts +28 -0
- package/src/ui/views/ChatVoiceView.ts +15 -0
- package/src/ui/views/VoiceOnlyView.ts +25 -0
- package/src/ui/views/index.ts +5 -0
|
@@ -0,0 +1,1802 @@
|
|
|
1
|
+
import { cleanLatexText } from '../utils';
|
|
2
|
+
|
|
3
|
+
type OnComplete = (result: Record<string, any>) => void;
|
|
4
|
+
type OnCancel = (reason?: string) => void;
|
|
5
|
+
|
|
6
|
+
interface PresetContext {
|
|
7
|
+
toolCallId: string;
|
|
8
|
+
presetId: string;
|
|
9
|
+
clientFields: Record<string, any>;
|
|
10
|
+
args: Record<string, any>;
|
|
11
|
+
toolCall: any;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// โโโ Format slot time helper โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
15
|
+
function formatSlotTime(item: any): string {
|
|
16
|
+
if (!item) return '';
|
|
17
|
+
if (typeof item === 'string') {
|
|
18
|
+
if (item.includes('T') || !isNaN(Date.parse(item))) {
|
|
19
|
+
try { return new Date(item).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' }); } catch (_) { }
|
|
20
|
+
}
|
|
21
|
+
return item;
|
|
22
|
+
}
|
|
23
|
+
if (typeof item === 'object') {
|
|
24
|
+
const sv = item.start || item.startTime || item.time || item.slot;
|
|
25
|
+
const ev = item.end || item.endTime;
|
|
26
|
+
if (sv) {
|
|
27
|
+
let s = String(sv), e = ev ? String(ev) : '';
|
|
28
|
+
try {
|
|
29
|
+
if (s.includes('T') || !isNaN(Date.parse(s))) s = new Date(s).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' });
|
|
30
|
+
if (e && (e.includes('T') || !isNaN(Date.parse(e)))) e = new Date(e).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' });
|
|
31
|
+
} catch (_) { }
|
|
32
|
+
if (e) return `${s} - ${e}`;
|
|
33
|
+
return s;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return String(item);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function getDayBoundsInTimezone(y: number, month: number, day: number, timezone?: string) {
|
|
40
|
+
// Default UTC bounds
|
|
41
|
+
let startMs = Date.UTC(y, month, day, 0, 0, 0, 0);
|
|
42
|
+
let endMs = Date.UTC(y, month, day, 23, 59, 59, 999);
|
|
43
|
+
|
|
44
|
+
if (timezone) {
|
|
45
|
+
try {
|
|
46
|
+
const noonUtc = new Date(Date.UTC(y, month, day, 12, 0, 0));
|
|
47
|
+
const formatter = new Intl.DateTimeFormat('en-US', {
|
|
48
|
+
timeZone: timezone,
|
|
49
|
+
year: 'numeric', month: 'numeric', day: 'numeric',
|
|
50
|
+
hour: 'numeric', minute: 'numeric', second: 'numeric',
|
|
51
|
+
hour12: false
|
|
52
|
+
});
|
|
53
|
+
const parts = formatter.formatToParts(noonUtc);
|
|
54
|
+
const getPart = (type: string) => parts.find(p => p.type === type)?.value;
|
|
55
|
+
|
|
56
|
+
const yr = parseInt(getPart('year') || '0', 10);
|
|
57
|
+
const mo = parseInt(getPart('month') || '0', 10) - 1;
|
|
58
|
+
const dy = parseInt(getPart('day') || '0', 10);
|
|
59
|
+
const hr = parseInt(getPart('hour') || '0', 10);
|
|
60
|
+
const min = parseInt(getPart('minute') || '0', 10);
|
|
61
|
+
const sec = parseInt(getPart('second') || '0', 10);
|
|
62
|
+
|
|
63
|
+
// Handle hour 24 edge case in some environments
|
|
64
|
+
const hourNormalized = hr === 24 ? 0 : hr;
|
|
65
|
+
|
|
66
|
+
const targetUtc = Date.UTC(yr, mo, dy, hourNormalized, min, sec, 0);
|
|
67
|
+
const offsetMs = targetUtc - noonUtc.getTime();
|
|
68
|
+
|
|
69
|
+
startMs = Date.UTC(y, month, day, 0, 0, 0, 0) - offsetMs;
|
|
70
|
+
endMs = Date.UTC(y, month, day, 23, 59, 59, 999) - offsetMs;
|
|
71
|
+
} catch (e) {
|
|
72
|
+
console.warn(`[WidgetPresetRenderer] Failed to calculate day bounds for timezone ${timezone}:`, e);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
start: new Date(startMs),
|
|
78
|
+
end: new Date(endMs)
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function toTimezoneISOString(date: Date, timeZone?: string): string {
|
|
83
|
+
if (!timeZone) {
|
|
84
|
+
const tzOffset = -date.getTimezoneOffset();
|
|
85
|
+
const diff = tzOffset >= 0 ? '+' : '-';
|
|
86
|
+
const pad = (num: number) => String(num).padStart(2, '0');
|
|
87
|
+
const pad3 = (num: number) => String(num).padStart(3, '0');
|
|
88
|
+
return date.getFullYear() +
|
|
89
|
+
'-' + pad(date.getMonth() + 1) +
|
|
90
|
+
'-' + pad(date.getDate()) +
|
|
91
|
+
'T' + pad(date.getHours()) +
|
|
92
|
+
':' + pad(date.getMinutes()) +
|
|
93
|
+
':' + pad(date.getSeconds()) +
|
|
94
|
+
'.' + pad3(date.getMilliseconds()) +
|
|
95
|
+
diff + pad(Math.floor(Math.abs(tzOffset) / 60)) +
|
|
96
|
+
':' + pad(Math.abs(tzOffset) % 60);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
try {
|
|
100
|
+
const formatter = new Intl.DateTimeFormat('en-US', {
|
|
101
|
+
timeZone,
|
|
102
|
+
year: 'numeric', month: 'numeric', day: 'numeric',
|
|
103
|
+
hour: 'numeric', minute: 'numeric', second: 'numeric',
|
|
104
|
+
hour12: false
|
|
105
|
+
});
|
|
106
|
+
const parts = formatter.formatToParts(date);
|
|
107
|
+
const getPart = (type: string) => parts.find(p => p.type === type)?.value || '0';
|
|
108
|
+
|
|
109
|
+
const yr = getPart('year');
|
|
110
|
+
const mo = getPart('month').padStart(2, '0');
|
|
111
|
+
const dy = getPart('day').padStart(2, '0');
|
|
112
|
+
let hr = parseInt(getPart('hour'), 10);
|
|
113
|
+
if (hr === 24) hr = 0;
|
|
114
|
+
const hrStr = String(hr).padStart(2, '0');
|
|
115
|
+
const min = getPart('minute').padStart(2, '0');
|
|
116
|
+
const sec = getPart('second').padStart(2, '0');
|
|
117
|
+
const ms = String(date.getMilliseconds()).padStart(3, '0');
|
|
118
|
+
|
|
119
|
+
const yrInt = parseInt(yr, 10);
|
|
120
|
+
const moInt = parseInt(mo, 10) - 1;
|
|
121
|
+
const dyInt = parseInt(dy, 10);
|
|
122
|
+
const targetUtc = Date.UTC(yrInt, moInt, dyInt, hr, parseInt(min, 10), parseInt(sec, 10), date.getMilliseconds());
|
|
123
|
+
const offsetMs = targetUtc - date.getTime();
|
|
124
|
+
|
|
125
|
+
const offsetMin = Math.round(offsetMs / 60000);
|
|
126
|
+
const diff = offsetMin >= 0 ? '+' : '-';
|
|
127
|
+
const absOffsetMin = Math.abs(offsetMin);
|
|
128
|
+
const offsetHrStr = String(Math.floor(absOffsetMin / 60)).padStart(2, '0');
|
|
129
|
+
const offsetMinStr = String(absOffsetMin % 60).padStart(2, '0');
|
|
130
|
+
|
|
131
|
+
return `${yr}-${mo}-${dy}T${hrStr}:${min}:${sec}.${ms}${diff}${offsetHrStr}:${offsetMinStr}`;
|
|
132
|
+
} catch (e) {
|
|
133
|
+
console.warn(`[WidgetPresetRenderer] Failed to format to timezone ISO string for ${timeZone}:`, e);
|
|
134
|
+
return date.toISOString();
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// โโโ Shared overlay CSS (injected once) โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
139
|
+
const OVERLAY_CSS = `
|
|
140
|
+
.vnr-overlay {
|
|
141
|
+
position: fixed; inset: 0; z-index: 2147483647;
|
|
142
|
+
background: rgba(0,0,0,0.45);
|
|
143
|
+
backdrop-filter: blur(4px);
|
|
144
|
+
display: flex; align-items: center; justify-content: center; padding: 16px;
|
|
145
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
|
146
|
+
box-sizing: border-box;
|
|
147
|
+
}
|
|
148
|
+
.vnr-modal {
|
|
149
|
+
background: #fff; border-radius: 16px;
|
|
150
|
+
box-shadow: 0 25px 60px rgba(0,0,0,0.25);
|
|
151
|
+
width: 100%; max-width: 400px; max-height: 88vh;
|
|
152
|
+
overflow-y: auto; box-sizing: border-box;
|
|
153
|
+
padding: 20px;
|
|
154
|
+
}
|
|
155
|
+
.vnr-close-btn {
|
|
156
|
+
position: absolute; top: 16px; right: 16px;
|
|
157
|
+
background: none; border: none; cursor: pointer;
|
|
158
|
+
color: #d1d5db; line-height: 1; padding: 4px;
|
|
159
|
+
}
|
|
160
|
+
.vnr-close-btn:hover { color: #6b7280; }
|
|
161
|
+
.vnr-title { margin: 0 0 8px; font-size: 17px; font-weight: 700; color: #111; letter-spacing: -0.02em; }
|
|
162
|
+
.vnr-reason {
|
|
163
|
+
display: flex; align-items: flex-start; gap: 8px;
|
|
164
|
+
padding: 10px 12px; background: #f9fafb; border: 1px solid #f3f4f6;
|
|
165
|
+
border-radius: 10px; font-size: 13px; color: #6b7280; line-height: 1.5; margin-bottom: 14px;
|
|
166
|
+
}
|
|
167
|
+
.vnr-divider { border-top: 1px solid #f3f4f6; margin: 12px 0; }
|
|
168
|
+
.vnr-section-label { font-size: 10px; font-weight: 600; color: #9ca3af; text-transform: uppercase; letter-spacing: 0.06em; margin-bottom: 8px; }
|
|
169
|
+
/* Month nav */
|
|
170
|
+
.vnr-month-nav { display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px; }
|
|
171
|
+
.vnr-nav-btn {
|
|
172
|
+
width: 30px; height: 30px; display: flex; align-items: center; justify-content: center;
|
|
173
|
+
border: 1px solid #e5e7eb; border-radius: 8px; background: #fff; cursor: pointer; color: #6b7280;
|
|
174
|
+
}
|
|
175
|
+
.vnr-nav-btn:hover:not(:disabled) { border-color: #9ca3af; background: #f9fafb; }
|
|
176
|
+
.vnr-nav-btn:disabled { opacity: 0.25; cursor: not-allowed; }
|
|
177
|
+
.vnr-month-label { font-size: 14px; font-weight: 600; color: #111; }
|
|
178
|
+
/* Calendar grid */
|
|
179
|
+
.vnr-cal-grid { display: grid; grid-template-columns: repeat(7, 1fr); gap: 2px 0; }
|
|
180
|
+
.vnr-wd { text-align: center; font-size: 10px; font-weight: 600; color: #9ca3af; text-transform: uppercase; letter-spacing: 0.05em; padding-bottom: 6px; }
|
|
181
|
+
.vnr-day-btn {
|
|
182
|
+
display: block; margin: 0 auto; width: 34px; height: 34px; border-radius: 8px;
|
|
183
|
+
background: transparent; border: 1.5px solid transparent;
|
|
184
|
+
font-size: 13px; font-weight: 500; color: #111; cursor: pointer;
|
|
185
|
+
}
|
|
186
|
+
.vnr-day-btn:hover:not(:disabled):not(.vnr-day-sel) { background: #f3f4f6; }
|
|
187
|
+
.vnr-day-past { color: #d1d5db !important; cursor: not-allowed !important; }
|
|
188
|
+
.vnr-day-today { border-color: #111 !important; }
|
|
189
|
+
.vnr-day-sel { background: #111 !important; color: #fff !important; border-color: transparent !important; }
|
|
190
|
+
/* Slots */
|
|
191
|
+
.vnr-slots-grid { display: grid; gap: 6px; }
|
|
192
|
+
.vnr-slot-btn {
|
|
193
|
+
padding: 9px 6px; border-radius: 8px; border: 1px solid #e5e7eb;
|
|
194
|
+
background: #fff; color: #374151; font-size: 12px; font-weight: 500; cursor: pointer; text-align: center;
|
|
195
|
+
}
|
|
196
|
+
.vnr-slot-btn:hover:not(.vnr-slot-sel) { border-color: #9ca3af; }
|
|
197
|
+
.vnr-slot-sel { background: #111 !important; color: #fff !important; border-color: #111 !important; }
|
|
198
|
+
/* Actions */
|
|
199
|
+
.vnr-actions { display: flex; gap: 8px; margin-top: 14px; padding-top: 12px; border-top: 1px solid #f3f4f6; }
|
|
200
|
+
.vnr-cancel-btn {
|
|
201
|
+
flex: 1; padding: 10px; border-radius: 10px; background: #f3f4f6;
|
|
202
|
+
border: none; color: #6b7280; font-size: 13px; font-weight: 500; cursor: pointer;
|
|
203
|
+
}
|
|
204
|
+
.vnr-confirm-btn {
|
|
205
|
+
flex: 1; padding: 10px; border-radius: 10px; background: #111;
|
|
206
|
+
border: none; color: #fff; font-size: 13px; font-weight: 600; cursor: pointer;
|
|
207
|
+
display: flex; align-items: center; justify-content: center; gap: 6px;
|
|
208
|
+
}
|
|
209
|
+
.vnr-confirm-btn:disabled { background: #e5e7eb !important; color: #9ca3af !important; cursor: not-allowed; }
|
|
210
|
+
/* Form */
|
|
211
|
+
.vnr-field-label { display: block; font-size: 10px; font-weight: 600; color: #9ca3af; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 6px; }
|
|
212
|
+
.vnr-input {
|
|
213
|
+
width: 100%; padding: 10px 12px; border: 1px solid #e5e7eb; border-radius: 10px;
|
|
214
|
+
font-size: 13px; color: #111; background: #fff; box-sizing: border-box; outline: none;
|
|
215
|
+
font-family: inherit;
|
|
216
|
+
}
|
|
217
|
+
.vnr-input:focus { border-color: #111; }
|
|
218
|
+
.vnr-fields { display: flex; flex-direction: column; gap: 12px; margin-bottom: 4px; }
|
|
219
|
+
/* Spinner */
|
|
220
|
+
@keyframes vnr-spin { to { transform: rotate(360deg); } }
|
|
221
|
+
.vnr-spinner {
|
|
222
|
+
width: 16px; height: 16px; border-radius: 50%;
|
|
223
|
+
border: 2px solid rgba(255,255,255,0.3); border-top-color: #fff;
|
|
224
|
+
animation: vnr-spin 0.7s linear infinite;
|
|
225
|
+
}
|
|
226
|
+
.vnr-center { text-align: center; padding: 28px 0; color: #9ca3af; font-size: 13px; }
|
|
227
|
+
/* Camera */
|
|
228
|
+
.vnr-video-wrap { width:100%; aspect-ratio:4/3; background:#0f172a; border-radius:12px; overflow:hidden; position:relative; }
|
|
229
|
+
.vnr-video-wrap video { width:100%; height:100%; object-fit:cover; display:block; }
|
|
230
|
+
.vnr-live-badge { position:absolute; top:10px; left:10px; display:flex; align-items:center; gap:6px; padding:3px 9px; border-radius:999px; background:rgba(15,23,42,0.72); border:1px solid rgba(255,255,255,0.1); font-size:11px; font-weight:600; color:#f8fafc; text-transform:uppercase; letter-spacing:.04em; }
|
|
231
|
+
.vnr-live-dot { width:7px; height:7px; border-radius:50%; background:#22c55e; flex-shrink:0; }
|
|
232
|
+
/* Upload dropzone */
|
|
233
|
+
.vnr-dropzone { border:2px dashed #cbd5e1; border-radius:14px; padding:32px 20px; text-align:center; cursor:pointer; background:#fafafa; transition:border-color 0.2s,background 0.2s; }
|
|
234
|
+
.vnr-dropzone:hover, .vnr-dropzone.drag { border-color:#374151; background:#f3f4f6; }
|
|
235
|
+
`;
|
|
236
|
+
|
|
237
|
+
// โโโ WidgetPresetRenderer โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
238
|
+
|
|
239
|
+
export class WidgetPresetRenderer {
|
|
240
|
+
private overlay: HTMLElement | null = null;
|
|
241
|
+
private styleInjected = false;
|
|
242
|
+
private chalkboardContent = ''; // Persisted session content for chalkboard fallback
|
|
243
|
+
|
|
244
|
+
// Calendar state
|
|
245
|
+
private calViewYear = 0;
|
|
246
|
+
private calViewMonth = 0;
|
|
247
|
+
private calSelectedTs: number | null = null;
|
|
248
|
+
private calSelectedTime: any = null;
|
|
249
|
+
|
|
250
|
+
// โโโ Live Vision state โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
251
|
+
private lvSessionId: string | null = null;
|
|
252
|
+
private lvStream: MediaStream | null = null;
|
|
253
|
+
private lvCaptureInterval: any = null;
|
|
254
|
+
private lvAutoStopTimeout: any = null;
|
|
255
|
+
private lvFrameSeq = 0;
|
|
256
|
+
private lvVideo: HTMLVideoElement | null = null;
|
|
257
|
+
private lvCanvas: HTMLCanvasElement | null = null;
|
|
258
|
+
private lvPip: HTMLElement | null = null; // floating PiP indicator
|
|
259
|
+
|
|
260
|
+
constructor(_root: ShadowRoot) {
|
|
261
|
+
// root is kept in signature for API compatibility but overlay renders in document.body
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
public resetChalkboard() {
|
|
265
|
+
this.chalkboardContent = '';
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** Returns true if this tool call contains a known preset_id and was handled */
|
|
269
|
+
handle(
|
|
270
|
+
toolCall: any,
|
|
271
|
+
onComplete: OnComplete,
|
|
272
|
+
onCancel: OnCancel,
|
|
273
|
+
client?: any
|
|
274
|
+
): boolean {
|
|
275
|
+
const data = toolCall.data || toolCall;
|
|
276
|
+
|
|
277
|
+
// Parse arguments โ may be a JSON string or already an object
|
|
278
|
+
let args: Record<string, any> = {};
|
|
279
|
+
const rawArgs = data.arguments || data.args;
|
|
280
|
+
if (typeof rawArgs === 'string') {
|
|
281
|
+
try { args = JSON.parse(rawArgs); } catch (_) { args = {}; }
|
|
282
|
+
} else if (rawArgs && typeof rawArgs === 'object') {
|
|
283
|
+
args = rawArgs;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const clientFields = data.client_fields || {};
|
|
287
|
+
|
|
288
|
+
// preset_id could be in client_fields OR arguments โ check both
|
|
289
|
+
const presetId = clientFields?.preset_id || args?.preset_id;
|
|
290
|
+
|
|
291
|
+
console.log('[VaniraPreset] tool_call data:', {
|
|
292
|
+
name: data.name,
|
|
293
|
+
client_fields: clientFields,
|
|
294
|
+
args,
|
|
295
|
+
presetId,
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
if (!presetId) return false;
|
|
299
|
+
|
|
300
|
+
// merge so ctx has everything
|
|
301
|
+
const mergedClientFields = { ...args, ...clientFields };
|
|
302
|
+
|
|
303
|
+
const ctx: PresetContext = {
|
|
304
|
+
toolCallId: data.tool_call_id || data.call_id || '',
|
|
305
|
+
presetId,
|
|
306
|
+
clientFields: mergedClientFields,
|
|
307
|
+
args,
|
|
308
|
+
toolCall: data,
|
|
309
|
+
};
|
|
310
|
+
|
|
311
|
+
this.injectStyles();
|
|
312
|
+
|
|
313
|
+
const isCurrentWhiteboard = !!document.querySelector('.vnr-whiteboard-modal');
|
|
314
|
+
const isIncomingWhiteboard = presetId === 'vanira_type_text' || presetId === 'vanira_erase_text';
|
|
315
|
+
|
|
316
|
+
if (isCurrentWhiteboard && isIncomingWhiteboard) {
|
|
317
|
+
console.log('[VaniraPreset] Keeping existing whiteboard modal active');
|
|
318
|
+
} else {
|
|
319
|
+
this.dismiss(); // close any existing preset first
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
if (presetId === 'vanira_calendar') {
|
|
323
|
+
this.renderCalendar(ctx, onComplete, onCancel);
|
|
324
|
+
return true;
|
|
325
|
+
}
|
|
326
|
+
if (presetId === 'vanira_form') {
|
|
327
|
+
this.renderForm(ctx, onComplete, onCancel);
|
|
328
|
+
return true;
|
|
329
|
+
}
|
|
330
|
+
if (presetId === 'vanira_camera') {
|
|
331
|
+
this.renderCamera(ctx, onComplete, onCancel, client);
|
|
332
|
+
return true;
|
|
333
|
+
}
|
|
334
|
+
if (presetId === 'vanira_upload') {
|
|
335
|
+
this.renderUpload(ctx, onComplete, onCancel, client);
|
|
336
|
+
return true;
|
|
337
|
+
}
|
|
338
|
+
if (presetId === 'vanira_navigate') {
|
|
339
|
+
this.renderNavigate(ctx, onComplete, onCancel);
|
|
340
|
+
return true;
|
|
341
|
+
}
|
|
342
|
+
if (presetId === 'vanira_highlight_element') {
|
|
343
|
+
this.renderHighlight(ctx, onComplete, onCancel);
|
|
344
|
+
return true;
|
|
345
|
+
}
|
|
346
|
+
if (presetId === 'vanira_type_text') {
|
|
347
|
+
this.renderTypeText(ctx, onComplete, onCancel);
|
|
348
|
+
return true;
|
|
349
|
+
}
|
|
350
|
+
if (presetId === 'vanira_erase_text') {
|
|
351
|
+
this.renderEraseText(ctx, onComplete, onCancel);
|
|
352
|
+
return true;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
if (presetId === 'vanira_live_vision') {
|
|
356
|
+
this.handleOpenLiveCamera(mergedClientFields, client);
|
|
357
|
+
return true;
|
|
358
|
+
}
|
|
359
|
+
if (presetId === 'vanira_close_live_camera') {
|
|
360
|
+
this.handleCloseLiveCamera(client);
|
|
361
|
+
return true;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
return false; // unknown preset โ let host handle it
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/** Force-dismiss the active preset (call on disconnect) */
|
|
368
|
+
dismiss() {
|
|
369
|
+
if (this.overlay) {
|
|
370
|
+
this.overlay.remove();
|
|
371
|
+
this.overlay = null;
|
|
372
|
+
}
|
|
373
|
+
// Stop any live vision session when the call ends
|
|
374
|
+
this.handleCloseLiveCamera(null);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// โโโ Live Vision โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
378
|
+
|
|
379
|
+
private handleOpenLiveCamera(args: Record<string, any>, client: any) {
|
|
380
|
+
// Stop any existing session first (re-open = restart)
|
|
381
|
+
this.handleCloseLiveCamera(client);
|
|
382
|
+
|
|
383
|
+
const scope: string = args.scope ?? 'until_call_end';
|
|
384
|
+
const targetFps: number = Math.min(Number(args.target_fps ?? 1), Number(args.max_fps ?? 3));
|
|
385
|
+
const maxWidth: number = Number(args.max_width ?? 640);
|
|
386
|
+
const reason: string = args.reason ?? 'camera_capture';
|
|
387
|
+
const durationSec: number = Number(args.duration_sec ?? 0);
|
|
388
|
+
const facingMode: string = args.facing_mode ?? 'environment';
|
|
389
|
+
// show_preview: optional floating PiP camera badge (default false)
|
|
390
|
+
// show_preview defaults to TRUE โ only hide when explicitly set to false/'false'
|
|
391
|
+
const showPreview: boolean = args.show_preview !== false && args.show_preview !== 'false';
|
|
392
|
+
|
|
393
|
+
const sessionId = `lv_${crypto.randomUUID()}`;
|
|
394
|
+
this.lvSessionId = sessionId;
|
|
395
|
+
this.lvFrameSeq = 0;
|
|
396
|
+
|
|
397
|
+
// Offscreen video + canvas โ always hidden, used only for frame capture
|
|
398
|
+
const video = document.createElement('video');
|
|
399
|
+
video.autoplay = true;
|
|
400
|
+
video.playsInline = true;
|
|
401
|
+
video.muted = true;
|
|
402
|
+
video.style.display = 'none';
|
|
403
|
+
document.body.appendChild(video);
|
|
404
|
+
this.lvVideo = video;
|
|
405
|
+
|
|
406
|
+
const canvas = document.createElement('canvas');
|
|
407
|
+
canvas.style.display = 'none';
|
|
408
|
+
document.body.appendChild(canvas);
|
|
409
|
+
this.lvCanvas = canvas;
|
|
410
|
+
|
|
411
|
+
// Floating PiP badge is OPTIONAL โ only shown when show_preview is true
|
|
412
|
+
if (showPreview) {
|
|
413
|
+
this.lvPip = this.createLivePip(() => this.handleCloseLiveCamera(client));
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
navigator.mediaDevices.getUserMedia({ video: { facingMode, width: { ideal: maxWidth * 2 } } })
|
|
417
|
+
.then((stream) => {
|
|
418
|
+
if (!this.lvSessionId) { stream.getTracks().forEach(t => t.stop()); return; }
|
|
419
|
+
this.lvStream = stream;
|
|
420
|
+
video.srcObject = stream;
|
|
421
|
+
|
|
422
|
+
// Notify server: session started
|
|
423
|
+
if (client?.sendEvent) {
|
|
424
|
+
client.sendEvent('client_live_camera_started', {
|
|
425
|
+
data: {
|
|
426
|
+
session_id: sessionId,
|
|
427
|
+
scope,
|
|
428
|
+
...(scope === 'timed' && durationSec > 0 ? { duration_sec: durationSec } : {}),
|
|
429
|
+
reason,
|
|
430
|
+
target_fps: targetFps,
|
|
431
|
+
}
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
console.log(`๐น [LiveVision] Session started: ${sessionId} (${scope}, ${targetFps} fps, preview=${showPreview})`);
|
|
435
|
+
|
|
436
|
+
// Begin capture loop
|
|
437
|
+
const intervalMs = Math.round(1000 / targetFps);
|
|
438
|
+
this.lvCaptureInterval = setInterval(() => {
|
|
439
|
+
this.captureAndSendFrame(client, sessionId, maxWidth, reason);
|
|
440
|
+
}, intervalMs);
|
|
441
|
+
|
|
442
|
+
// Auto-stop for timed scope
|
|
443
|
+
if (scope === 'timed' && durationSec > 0) {
|
|
444
|
+
this.lvAutoStopTimeout = setTimeout(() => {
|
|
445
|
+
console.log(`โฑ๏ธ [LiveVision] Timed session expired (${durationSec}s)`);
|
|
446
|
+
this.handleCloseLiveCamera(client);
|
|
447
|
+
}, durationSec * 1000);
|
|
448
|
+
}
|
|
449
|
+
})
|
|
450
|
+
.catch((err) => {
|
|
451
|
+
console.warn('[LiveVision] getUserMedia failed:', err);
|
|
452
|
+
this.lvSessionId = null;
|
|
453
|
+
this.lvPip?.remove();
|
|
454
|
+
this.lvPip = null;
|
|
455
|
+
video.remove();
|
|
456
|
+
canvas.remove();
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
private handleCloseLiveCamera(client: any | null) {
|
|
461
|
+
if (!this.lvSessionId) return;
|
|
462
|
+
|
|
463
|
+
const sessionId = this.lvSessionId;
|
|
464
|
+
this.lvSessionId = null;
|
|
465
|
+
|
|
466
|
+
// Stop capture loop + auto-stop timer
|
|
467
|
+
if (this.lvCaptureInterval) { clearInterval(this.lvCaptureInterval); this.lvCaptureInterval = null; }
|
|
468
|
+
if (this.lvAutoStopTimeout) { clearTimeout(this.lvAutoStopTimeout); this.lvAutoStopTimeout = null; }
|
|
469
|
+
|
|
470
|
+
// Release camera tracks
|
|
471
|
+
this.lvStream?.getTracks().forEach(t => t.stop());
|
|
472
|
+
this.lvStream = null;
|
|
473
|
+
this.lvVideo?.remove();
|
|
474
|
+
this.lvVideo = null;
|
|
475
|
+
this.lvCanvas?.remove();
|
|
476
|
+
this.lvCanvas = null;
|
|
477
|
+
|
|
478
|
+
// Remove PiP badge
|
|
479
|
+
this.lvPip?.remove();
|
|
480
|
+
this.lvPip = null;
|
|
481
|
+
|
|
482
|
+
// Notify server: session stopped
|
|
483
|
+
if (client?.sendEvent) {
|
|
484
|
+
client.sendEvent('client_live_camera_stopped', {
|
|
485
|
+
data: { session_id: sessionId }
|
|
486
|
+
});
|
|
487
|
+
}
|
|
488
|
+
console.log(`๐น [LiveVision] Session stopped: ${sessionId}`);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
private async captureAndSendFrame(
|
|
492
|
+
client: any,
|
|
493
|
+
sessionId: string,
|
|
494
|
+
maxWidth: number,
|
|
495
|
+
reason: string
|
|
496
|
+
) {
|
|
497
|
+
if (!client || !this.lvVideo || !this.lvCanvas || !this.lvSessionId) return;
|
|
498
|
+
const video = this.lvVideo;
|
|
499
|
+
if (video.readyState < 2) return; // not yet playing
|
|
500
|
+
|
|
501
|
+
const canvas = this.lvCanvas;
|
|
502
|
+
const vw = video.videoWidth || maxWidth;
|
|
503
|
+
const vh = video.videoHeight || maxWidth;
|
|
504
|
+
const scale = Math.min(1, maxWidth / vw);
|
|
505
|
+
canvas.width = Math.round(vw * scale);
|
|
506
|
+
canvas.height = Math.round(vh * scale);
|
|
507
|
+
const ctx2d = canvas.getContext('2d');
|
|
508
|
+
if (!ctx2d) return;
|
|
509
|
+
ctx2d.drawImage(video, 0, 0, canvas.width, canvas.height);
|
|
510
|
+
|
|
511
|
+
let blob: Blob;
|
|
512
|
+
try {
|
|
513
|
+
blob = await new Promise<Blob>((res, rej) =>
|
|
514
|
+
canvas.toBlob(b => b ? res(b) : rej(new Error('toBlob failed')), 'image/jpeg', 0.75)
|
|
515
|
+
);
|
|
516
|
+
} catch (e) {
|
|
517
|
+
console.warn('[LiveVision] Frame grab failed:', e);
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
try {
|
|
522
|
+
// โโโ RAW HTTP upload โ bypass client.uploadMedia() entirely โโโโโโโ
|
|
523
|
+
// client.uploadMedia() always fires client_media_update which triggers
|
|
524
|
+
// an LLM response and interrupts the call. For live vision frames we
|
|
525
|
+
// upload directly and ONLY send client_media_frame.
|
|
526
|
+
const serverUrl: string = client.serverUrl || '';
|
|
527
|
+
let uploadBase = '';
|
|
528
|
+
try { uploadBase = new URL(serverUrl).origin; } catch (_) {
|
|
529
|
+
uploadBase = serverUrl.replace(/\/webrtc.*$/, '').replace(/\/$/, '');
|
|
530
|
+
}
|
|
531
|
+
const callId: string = client.callId || '';
|
|
532
|
+
const uploadUrl = `${uploadBase}/media/upload`;
|
|
533
|
+
|
|
534
|
+
const form = new FormData();
|
|
535
|
+
form.append('file', blob, `frame_${sessionId}.jpg`);
|
|
536
|
+
form.append('call_id', callId);
|
|
537
|
+
form.append('reason', reason);
|
|
538
|
+
|
|
539
|
+
const res = await fetch(uploadUrl, { method: 'POST', body: form });
|
|
540
|
+
if (!res.ok) throw new Error(`Upload HTTP ${res.status}`);
|
|
541
|
+
const data = await res.json();
|
|
542
|
+
const media_id: string = data.media_id;
|
|
543
|
+
const url: string = data.url;
|
|
544
|
+
if (!media_id || !url) throw new Error('Upload response missing media_id/url');
|
|
545
|
+
|
|
546
|
+
// Guard: session may have closed while the upload was in flight
|
|
547
|
+
if (!this.lvSessionId || this.lvSessionId !== sessionId) return;
|
|
548
|
+
|
|
549
|
+
const seq = ++this.lvFrameSeq;
|
|
550
|
+
// Send ONLY client_media_frame โ no client_media_update, no LLM trigger
|
|
551
|
+
if (client.sendEvent) {
|
|
552
|
+
client.sendEvent('client_media_frame', {
|
|
553
|
+
data: {
|
|
554
|
+
session_id: sessionId,
|
|
555
|
+
frame_seq: seq,
|
|
556
|
+
media_id,
|
|
557
|
+
media_url: url,
|
|
558
|
+
content_type: 'image/jpeg',
|
|
559
|
+
reason,
|
|
560
|
+
}
|
|
561
|
+
});
|
|
562
|
+
}
|
|
563
|
+
console.log(`๐ธ [LiveVision] Frame #${seq} sent (media_id: ${media_id})`);
|
|
564
|
+
} catch (e) {
|
|
565
|
+
console.warn('[LiveVision] Frame upload failed:', e);
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
private createLivePip(onStop: () => void): HTMLElement {
|
|
570
|
+
const pip = document.createElement('div');
|
|
571
|
+
pip.id = 'vnr-live-pip';
|
|
572
|
+
pip.style.cssText = [
|
|
573
|
+
'position:fixed', 'top:12px', 'right:12px', 'z-index:2147483647',
|
|
574
|
+
'display:flex', 'align-items:center', 'gap:8px',
|
|
575
|
+
'padding:6px 12px 6px 10px',
|
|
576
|
+
'background:rgba(15,23,42,0.92)', 'border-radius:999px',
|
|
577
|
+
"font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif",
|
|
578
|
+
'font-size:11px', 'font-weight:600', 'color:#fff',
|
|
579
|
+
'box-shadow:0 4px 16px rgba(0,0,0,0.25)',
|
|
580
|
+
'backdrop-filter:blur(8px)',
|
|
581
|
+
'cursor:default', 'user-select:none',
|
|
582
|
+
].join(';');
|
|
583
|
+
pip.innerHTML = `
|
|
584
|
+
<span style="width:8px;height:8px;border-radius:50%;background:#ef4444;flex-shrink:0;animation:vnr-pip-pulse 1.5s ease-in-out infinite;"></span>
|
|
585
|
+
<span>Camera Live</span>
|
|
586
|
+
<button id="vnr-pip-stop" style="margin-left:4px;padding:2px 8px;background:rgba(255,255,255,0.15);border:1px solid rgba(255,255,255,0.2);border-radius:999px;color:#fff;font-size:10px;font-weight:600;cursor:pointer;line-height:1.4;">Stop</button>
|
|
587
|
+
`;
|
|
588
|
+
if (!document.getElementById('vnr-pip-style')) {
|
|
589
|
+
const s = document.createElement('style');
|
|
590
|
+
s.id = 'vnr-pip-style';
|
|
591
|
+
s.textContent = `@keyframes vnr-pip-pulse{0%,100%{opacity:1;transform:scale(1);}50%{opacity:.5;transform:scale(1.2);}}`;
|
|
592
|
+
document.head.appendChild(s);
|
|
593
|
+
}
|
|
594
|
+
pip.querySelector('#vnr-pip-stop')!.addEventListener('click', () => onStop());
|
|
595
|
+
document.body.appendChild(pip);
|
|
596
|
+
return pip;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
// โโโ Styles โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
600
|
+
|
|
601
|
+
private injectStyles() {
|
|
602
|
+
if (this.styleInjected || document.getElementById('vnr-preset-styles')) return;
|
|
603
|
+
const s = document.createElement('style');
|
|
604
|
+
s.id = 'vnr-preset-styles';
|
|
605
|
+
s.textContent = OVERLAY_CSS;
|
|
606
|
+
document.head.appendChild(s);
|
|
607
|
+
this.styleInjected = true;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
private createOverlay(): HTMLElement {
|
|
611
|
+
const el = document.createElement('div');
|
|
612
|
+
el.className = 'vnr-overlay';
|
|
613
|
+
// Append to document.body โ NOT shadow root โ so it sits above
|
|
614
|
+
// the widget's shadow stacking context at true document z-index
|
|
615
|
+
document.body.appendChild(el);
|
|
616
|
+
this.overlay = el;
|
|
617
|
+
return el;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
private createModal(overlay: HTMLElement, title: string, reason: string): HTMLElement {
|
|
621
|
+
const modal = document.createElement('div');
|
|
622
|
+
modal.className = 'vnr-modal';
|
|
623
|
+
modal.style.position = 'relative';
|
|
624
|
+
modal.innerHTML = `
|
|
625
|
+
<button class="vnr-close-btn" aria-label="Close">
|
|
626
|
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
|
|
627
|
+
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
|
628
|
+
</svg>
|
|
629
|
+
</button>
|
|
630
|
+
<h2 class="vnr-title">${title}</h2>
|
|
631
|
+
<div class="vnr-reason">
|
|
632
|
+
<svg width="14" height="14" style="flex-shrink:0;margin-top:1px" fill="none" stroke="#9ca3af" stroke-width="2" viewBox="0 0 24 24">
|
|
633
|
+
<path stroke-linecap="round" stroke-linejoin="round" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/>
|
|
634
|
+
</svg>
|
|
635
|
+
<span>${reason}</span>
|
|
636
|
+
</div>
|
|
637
|
+
`;
|
|
638
|
+
overlay.appendChild(modal);
|
|
639
|
+
return modal;
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
// โโโ Calendar Preset โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
643
|
+
|
|
644
|
+
private renderCalendar(ctx: PresetContext, onComplete: OnComplete, onCancel: OnCancel) {
|
|
645
|
+
const today = new Date(); today.setHours(0, 0, 0, 0);
|
|
646
|
+
this.calViewYear = today.getFullYear();
|
|
647
|
+
this.calViewMonth = today.getMonth();
|
|
648
|
+
this.calSelectedTs = null;
|
|
649
|
+
this.calSelectedTime = null;
|
|
650
|
+
|
|
651
|
+
const title = ctx.clientFields.title || 'Select Date & Time';
|
|
652
|
+
const reason = ctx.args.reason || 'Please select a preferred time for your appointment.';
|
|
653
|
+
|
|
654
|
+
const overlay = this.createOverlay();
|
|
655
|
+
const modal = this.createModal(overlay, title, reason);
|
|
656
|
+
|
|
657
|
+
// Close button
|
|
658
|
+
modal.querySelector('.vnr-close-btn')!.addEventListener('click', () => {
|
|
659
|
+
this.dismiss(); onCancel('User closed the preset');
|
|
660
|
+
});
|
|
661
|
+
|
|
662
|
+
// Calendar container
|
|
663
|
+
const calContainer = document.createElement('div');
|
|
664
|
+
modal.appendChild(calContainer);
|
|
665
|
+
|
|
666
|
+
// Slots container (hidden until date picked)
|
|
667
|
+
const slotsSection = document.createElement('div');
|
|
668
|
+
slotsSection.style.display = 'none';
|
|
669
|
+
modal.appendChild(slotsSection);
|
|
670
|
+
|
|
671
|
+
// Actions
|
|
672
|
+
const actionsEl = document.createElement('div');
|
|
673
|
+
actionsEl.className = 'vnr-actions';
|
|
674
|
+
actionsEl.innerHTML = `
|
|
675
|
+
<button class="vnr-cancel-btn">Cancel</button>
|
|
676
|
+
<button class="vnr-confirm-btn" disabled>
|
|
677
|
+
<svg width="13" height="13" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg>
|
|
678
|
+
Confirm
|
|
679
|
+
</button>
|
|
680
|
+
`;
|
|
681
|
+
modal.appendChild(actionsEl);
|
|
682
|
+
|
|
683
|
+
const confirmBtn = actionsEl.querySelector('.vnr-confirm-btn') as HTMLButtonElement;
|
|
684
|
+
actionsEl.querySelector('.vnr-cancel-btn')!.addEventListener('click', () => {
|
|
685
|
+
this.dismiss(); onCancel('User cancelled');
|
|
686
|
+
});
|
|
687
|
+
confirmBtn.addEventListener('click', () => {
|
|
688
|
+
if (!this.calSelectedTs || !this.calSelectedTime) return;
|
|
689
|
+
const dateObj = new Date(this.calSelectedTs);
|
|
690
|
+
const timeStr = formatSlotTime(this.calSelectedTime);
|
|
691
|
+
const y = dateObj.getFullYear();
|
|
692
|
+
const m = String(dateObj.getMonth() + 1).padStart(2, '0');
|
|
693
|
+
const d = String(dateObj.getDate()).padStart(2, '0');
|
|
694
|
+
const dateStr = `${y}-${m}-${d}`;
|
|
695
|
+
|
|
696
|
+
const tzStr = ctx.clientFields.timezone || '';
|
|
697
|
+
let slotData = typeof this.calSelectedTime === 'object' ? { ...this.calSelectedTime } : undefined;
|
|
698
|
+
if (slotData) {
|
|
699
|
+
for (const key of ['start', 'startTime', 'end', 'endTime']) {
|
|
700
|
+
if (slotData[key] && typeof slotData[key] === 'string' && (slotData[key].includes('T') || !isNaN(Date.parse(slotData[key])))) {
|
|
701
|
+
try {
|
|
702
|
+
slotData[key] = toTimezoneISOString(new Date(slotData[key]), tzStr);
|
|
703
|
+
} catch (_) { }
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
this.dismiss();
|
|
709
|
+
onComplete({
|
|
710
|
+
date: dateStr,
|
|
711
|
+
time: timeStr,
|
|
712
|
+
timestamp: this.calSelectedTs,
|
|
713
|
+
formatted: `${dateObj.toLocaleDateString('en-US', { weekday: 'long', month: 'short', day: 'numeric' })} at ${timeStr}`,
|
|
714
|
+
slot_data: slotData,
|
|
715
|
+
});
|
|
716
|
+
});
|
|
717
|
+
|
|
718
|
+
const renderGrid = () => {
|
|
719
|
+
const today2 = new Date(); today2.setHours(0, 0, 0, 0);
|
|
720
|
+
const firstDay = new Date(this.calViewYear, this.calViewMonth, 1).getDay();
|
|
721
|
+
const daysInMonth = new Date(this.calViewYear, this.calViewMonth + 1, 0).getDate();
|
|
722
|
+
const monthLabel = new Date(this.calViewYear, this.calViewMonth, 1)
|
|
723
|
+
.toLocaleDateString('en-US', { month: 'long', year: 'numeric' });
|
|
724
|
+
const isPrevDisabled = this.calViewYear === today2.getFullYear() && this.calViewMonth === today2.getMonth();
|
|
725
|
+
|
|
726
|
+
calContainer.innerHTML = `
|
|
727
|
+
<div class="vnr-month-nav">
|
|
728
|
+
<button class="vnr-nav-btn" id="vnr-prev" ${isPrevDisabled ? 'disabled' : ''}>
|
|
729
|
+
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><polyline points="15 18 9 12 15 6"/></svg>
|
|
730
|
+
</button>
|
|
731
|
+
<span class="vnr-month-label">${monthLabel}</span>
|
|
732
|
+
<button class="vnr-nav-btn" id="vnr-next">
|
|
733
|
+
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><polyline points="9 18 15 12 9 6"/></svg>
|
|
734
|
+
</button>
|
|
735
|
+
</div>
|
|
736
|
+
<div class="vnr-cal-grid" id="vnr-grid"></div>
|
|
737
|
+
`;
|
|
738
|
+
|
|
739
|
+
calContainer.querySelector('#vnr-prev')!.addEventListener('click', () => {
|
|
740
|
+
if (this.calViewMonth === 0) { this.calViewMonth = 11; this.calViewYear--; }
|
|
741
|
+
else this.calViewMonth--;
|
|
742
|
+
renderGrid();
|
|
743
|
+
});
|
|
744
|
+
calContainer.querySelector('#vnr-next')!.addEventListener('click', () => {
|
|
745
|
+
if (this.calViewMonth === 11) { this.calViewMonth = 0; this.calViewYear++; }
|
|
746
|
+
else this.calViewMonth++;
|
|
747
|
+
renderGrid();
|
|
748
|
+
});
|
|
749
|
+
|
|
750
|
+
const grid = calContainer.querySelector('#vnr-grid')!;
|
|
751
|
+
['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'].forEach(wd => {
|
|
752
|
+
const d = document.createElement('div'); d.className = 'vnr-wd'; d.textContent = wd;
|
|
753
|
+
grid.appendChild(d);
|
|
754
|
+
});
|
|
755
|
+
for (let i = 0; i < firstDay; i++) {
|
|
756
|
+
const empty = document.createElement('div'); grid.appendChild(empty);
|
|
757
|
+
}
|
|
758
|
+
for (let day = 1; day <= daysInMonth; day++) {
|
|
759
|
+
const ts = new Date(this.calViewYear, this.calViewMonth, day).getTime();
|
|
760
|
+
const isPast = ts < today2.getTime();
|
|
761
|
+
const isToday = ts === today2.getTime();
|
|
762
|
+
const isSel = this.calSelectedTs === ts;
|
|
763
|
+
const wrap = document.createElement('div'); wrap.style.textAlign = 'center';
|
|
764
|
+
const btn = document.createElement('button');
|
|
765
|
+
btn.className = 'vnr-day-btn';
|
|
766
|
+
if (isPast) btn.classList.add('vnr-day-past');
|
|
767
|
+
if (isToday && !isSel) btn.classList.add('vnr-day-today');
|
|
768
|
+
if (isSel) btn.classList.add('vnr-day-sel');
|
|
769
|
+
btn.disabled = isPast;
|
|
770
|
+
btn.textContent = String(day);
|
|
771
|
+
btn.addEventListener('click', () => {
|
|
772
|
+
this.calSelectedTs = ts;
|
|
773
|
+
this.calSelectedTime = null;
|
|
774
|
+
confirmBtn.disabled = true;
|
|
775
|
+
renderGrid();
|
|
776
|
+
this.loadSlots(ctx, slotsSection, confirmBtn);
|
|
777
|
+
});
|
|
778
|
+
wrap.appendChild(btn); grid.appendChild(wrap);
|
|
779
|
+
}
|
|
780
|
+
};
|
|
781
|
+
|
|
782
|
+
renderGrid();
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
private loadSlots(ctx: PresetContext, slotsSection: HTMLElement, confirmBtn: HTMLButtonElement) {
|
|
786
|
+
const slotsApiUrl = ctx.clientFields.slots_api_url;
|
|
787
|
+
|
|
788
|
+
if (!this.calSelectedTs || !slotsApiUrl) {
|
|
789
|
+
this.renderSlots(slotsSection, ['09:00 AM', '10:30 AM', '01:00 PM', '02:30 PM', '04:00 PM'], confirmBtn);
|
|
790
|
+
return;
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
const dateObj = new Date(this.calSelectedTs);
|
|
794
|
+
const y = dateObj.getFullYear();
|
|
795
|
+
const m = String(dateObj.getMonth() + 1).padStart(2, '0');
|
|
796
|
+
const d = String(dateObj.getDate()).padStart(2, '0');
|
|
797
|
+
const dateStr = `${y}-${m}-${d}`;
|
|
798
|
+
const tzStr = ctx.clientFields.timezone || '';
|
|
799
|
+
const bounds = getDayBoundsInTimezone(y, dateObj.getMonth(), dateObj.getDate(), tzStr);
|
|
800
|
+
const startOfDay = bounds.start;
|
|
801
|
+
const endOfDay = bounds.end;
|
|
802
|
+
|
|
803
|
+
const rp = (val: string) => {
|
|
804
|
+
if (typeof val !== 'string') return val;
|
|
805
|
+
let replaced = val
|
|
806
|
+
.replace(/\{\{date\}\}/g, dateStr)
|
|
807
|
+
.replace(/\{\{timezone\}\}/g, tzStr)
|
|
808
|
+
.replace(/\{\{start_of_day\}\}/g, startOfDay.toISOString())
|
|
809
|
+
.replace(/\{\{end_of_day\}\}/g, endOfDay.toISOString());
|
|
810
|
+
|
|
811
|
+
Object.entries(ctx.clientFields).forEach(([k, v]) => {
|
|
812
|
+
if (v !== undefined && v !== null) {
|
|
813
|
+
const escapedKey = k.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&');
|
|
814
|
+
const regex = new RegExp(`\\{\\{${escapedKey}\\}\\}`, 'g');
|
|
815
|
+
replaced = replaced.replace(regex, String(v));
|
|
816
|
+
}
|
|
817
|
+
});
|
|
818
|
+
return replaced;
|
|
819
|
+
};
|
|
820
|
+
|
|
821
|
+
const method = (ctx.clientFields.slots_api_method || 'GET').toUpperCase();
|
|
822
|
+
let headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
|
823
|
+
try { Object.entries(JSON.parse(ctx.clientFields.slots_api_headers || '{}')).forEach(([k, v]) => { headers[k] = rp(String(v)); }); } catch (_) { }
|
|
824
|
+
|
|
825
|
+
let bodyObj: Record<string, any> = {};
|
|
826
|
+
try { Object.entries(JSON.parse(ctx.clientFields.slots_api_body || '{}')).forEach(([k, v]) => { bodyObj[k] = rp(String(v)); }); } catch (_) { }
|
|
827
|
+
|
|
828
|
+
let url = rp(slotsApiUrl);
|
|
829
|
+
const fetchOpts: RequestInit = { method, headers };
|
|
830
|
+
if (method === 'GET') {
|
|
831
|
+
const qs = new URLSearchParams(Object.entries(bodyObj).map(([k, v]) => [k, String(v)])).toString();
|
|
832
|
+
if (qs) url += (url.includes('?') ? '&' : '?') + qs;
|
|
833
|
+
} else {
|
|
834
|
+
fetchOpts.body = JSON.stringify(bodyObj);
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
slotsSection.style.display = 'block';
|
|
838
|
+
slotsSection.innerHTML = `<div class="vnr-divider"></div><div class="vnr-center"><div class="vnr-spinner" style="margin:0 auto 8px"></div>Loading slotsโฆ</div>`;
|
|
839
|
+
|
|
840
|
+
console.log("๐ฏ [SDK Preset Renderer] Calling slots API with URL:", url, "fetchOpts:", fetchOpts);
|
|
841
|
+
fetch(url, fetchOpts)
|
|
842
|
+
.then(r => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.json(); })
|
|
843
|
+
.then(data => {
|
|
844
|
+
let arr: any[] = [];
|
|
845
|
+
if (Array.isArray(data)) arr = data;
|
|
846
|
+
else if (data && typeof data === 'object') {
|
|
847
|
+
for (const k of ['slots', 'free_slots', 'times', 'data', 'available_slots']) {
|
|
848
|
+
if (Array.isArray(data[k])) { arr = data[k]; break; }
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
this.renderSlots(slotsSection, arr, confirmBtn);
|
|
852
|
+
})
|
|
853
|
+
.catch(() => {
|
|
854
|
+
slotsSection.innerHTML = `<div class="vnr-divider"></div><div class="vnr-center" style="color:#ef4444">Failed to load slots. Please try again.</div>`;
|
|
855
|
+
});
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
private renderSlots(slotsSection: HTMLElement, times: any[], confirmBtn: HTMLButtonElement) {
|
|
859
|
+
const label = this.calSelectedTs
|
|
860
|
+
? new Date(this.calSelectedTs).toLocaleDateString('en-US', { weekday: 'long', month: 'short', day: 'numeric' })
|
|
861
|
+
: 'Available Times';
|
|
862
|
+
|
|
863
|
+
const cols = times.some(t => formatSlotTime(t).length > 8) ? 2 : 3;
|
|
864
|
+
slotsSection.style.display = 'block';
|
|
865
|
+
slotsSection.innerHTML = `
|
|
866
|
+
<div class="vnr-divider"></div>
|
|
867
|
+
<div class="vnr-section-label">${label}</div>
|
|
868
|
+
${times.length === 0
|
|
869
|
+
? `<div class="vnr-center">No slots available for this date.</div>`
|
|
870
|
+
: `<div class="vnr-slots-grid" style="grid-template-columns:repeat(${cols},1fr)"></div>`
|
|
871
|
+
}
|
|
872
|
+
`;
|
|
873
|
+
if (times.length === 0) return;
|
|
874
|
+
|
|
875
|
+
const grid = slotsSection.querySelector('.vnr-slots-grid')!;
|
|
876
|
+
times.forEach((t) => {
|
|
877
|
+
const btn = document.createElement('button');
|
|
878
|
+
btn.className = 'vnr-slot-btn';
|
|
879
|
+
const isSel = this.calSelectedTime !== null && (
|
|
880
|
+
typeof t === 'object' && typeof this.calSelectedTime === 'object'
|
|
881
|
+
? JSON.stringify(t) === JSON.stringify(this.calSelectedTime)
|
|
882
|
+
: t === this.calSelectedTime
|
|
883
|
+
);
|
|
884
|
+
if (isSel) btn.classList.add('vnr-slot-sel');
|
|
885
|
+
btn.textContent = formatSlotTime(t);
|
|
886
|
+
btn.addEventListener('click', () => {
|
|
887
|
+
this.calSelectedTime = t;
|
|
888
|
+
confirmBtn.disabled = false;
|
|
889
|
+
grid.querySelectorAll('.vnr-slot-btn').forEach(b => b.classList.remove('vnr-slot-sel'));
|
|
890
|
+
btn.classList.add('vnr-slot-sel');
|
|
891
|
+
});
|
|
892
|
+
grid.appendChild(btn);
|
|
893
|
+
});
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
// โโโ Form Preset โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
897
|
+
|
|
898
|
+
private renderForm(ctx: PresetContext, onComplete: OnComplete, onCancel: OnCancel) {
|
|
899
|
+
const title = ctx.clientFields.title || ctx.args.title || 'Please fill out this form';
|
|
900
|
+
const reason = ctx.args.reason || 'Please provide the following information.';
|
|
901
|
+
// fields may come as an array OR a comma-separated string (e.g. "Name, Phone, Email")
|
|
902
|
+
const rawFields = ctx.clientFields.fields || ctx.args.fields;
|
|
903
|
+
const fields: string[] = Array.isArray(rawFields)
|
|
904
|
+
? rawFields
|
|
905
|
+
: typeof rawFields === 'string'
|
|
906
|
+
? rawFields.split(',').map((f: string) => f.trim()).filter(Boolean)
|
|
907
|
+
: [];
|
|
908
|
+
|
|
909
|
+
const overlay = this.createOverlay();
|
|
910
|
+
const modal = this.createModal(overlay, title, reason);
|
|
911
|
+
modal.querySelector('.vnr-close-btn')!.addEventListener('click', () => {
|
|
912
|
+
this.dismiss(); onCancel('User closed');
|
|
913
|
+
});
|
|
914
|
+
|
|
915
|
+
// Fields
|
|
916
|
+
const fieldsEl = document.createElement('div');
|
|
917
|
+
fieldsEl.className = 'vnr-fields';
|
|
918
|
+
fields.forEach((field: string) => {
|
|
919
|
+
const id = field.toLowerCase().replace(/[^a-z0-9]/g, '_');
|
|
920
|
+
const isEmail = id.includes('email');
|
|
921
|
+
const isPhone = id.includes('phone') || id.includes('number');
|
|
922
|
+
const type = isEmail ? 'email' : isPhone ? 'tel' : 'text';
|
|
923
|
+
const wrap = document.createElement('div');
|
|
924
|
+
wrap.innerHTML = `
|
|
925
|
+
<label class="vnr-field-label" for="vnr_${id}">${field} *</label>
|
|
926
|
+
<input class="vnr-input" type="${type}" id="vnr_${id}" name="${id}" placeholder="Enter your ${field.toLowerCase()}" required />
|
|
927
|
+
`;
|
|
928
|
+
fieldsEl.appendChild(wrap);
|
|
929
|
+
});
|
|
930
|
+
modal.appendChild(fieldsEl);
|
|
931
|
+
|
|
932
|
+
// Actions
|
|
933
|
+
const actionsEl = document.createElement('div');
|
|
934
|
+
actionsEl.className = 'vnr-actions';
|
|
935
|
+
actionsEl.style.marginTop = '16px';
|
|
936
|
+
actionsEl.innerHTML = `
|
|
937
|
+
<button class="vnr-cancel-btn">Cancel</button>
|
|
938
|
+
<button class="vnr-confirm-btn">
|
|
939
|
+
<svg width="13" height="13" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg>
|
|
940
|
+
Submit
|
|
941
|
+
</button>
|
|
942
|
+
`;
|
|
943
|
+
modal.appendChild(actionsEl);
|
|
944
|
+
|
|
945
|
+
actionsEl.querySelector('.vnr-cancel-btn')!.addEventListener('click', () => {
|
|
946
|
+
this.dismiss(); onCancel('User cancelled');
|
|
947
|
+
});
|
|
948
|
+
|
|
949
|
+
const submitBtn = actionsEl.querySelector('.vnr-confirm-btn') as HTMLButtonElement;
|
|
950
|
+
submitBtn.addEventListener('click', () => {
|
|
951
|
+
const result: Record<string, string> = {};
|
|
952
|
+
let allFilled = true;
|
|
953
|
+
fields.forEach((field: string) => {
|
|
954
|
+
const id = field.toLowerCase().replace(/[^a-z0-9]/g, '_');
|
|
955
|
+
const input = modal.querySelector(`#vnr_${id}`) as HTMLInputElement;
|
|
956
|
+
if (!input?.value.trim()) { allFilled = false; input?.focus(); return; }
|
|
957
|
+
result[id] = input.value.trim();
|
|
958
|
+
});
|
|
959
|
+
if (!allFilled) return;
|
|
960
|
+
submitBtn.disabled = true;
|
|
961
|
+
submitBtn.innerHTML = `<div class="vnr-spinner"></div>`;
|
|
962
|
+
setTimeout(() => { this.dismiss(); onComplete(result); }, 300);
|
|
963
|
+
});
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
// โโโ Camera Preset โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
967
|
+
|
|
968
|
+
private renderCamera(ctx: PresetContext, onComplete: OnComplete, onCancel: OnCancel, client?: any) {
|
|
969
|
+
const title = ctx.clientFields.title || 'Take a Photo';
|
|
970
|
+
const description = ctx.clientFields.description || 'Point your camera at the subject, then tap capture.';
|
|
971
|
+
const facingMode = ctx.clientFields.facing_mode || ctx.args.facing_mode || 'environment';
|
|
972
|
+
const reason = ctx.clientFields.reason || 'camera_capture';
|
|
973
|
+
const requireLivenessDefault = ctx.clientFields.liveness_check === true ||
|
|
974
|
+
ctx.clientFields.liveness_check === 'true' ||
|
|
975
|
+
ctx.args.liveness_check === true ||
|
|
976
|
+
ctx.args.liveness_check === 'true';
|
|
977
|
+
|
|
978
|
+
let stream: MediaStream | null = null;
|
|
979
|
+
let livenessInterval: any = null;
|
|
980
|
+
|
|
981
|
+
const overlay = this.createOverlay();
|
|
982
|
+
|
|
983
|
+
const modal = document.createElement('div');
|
|
984
|
+
modal.className = 'vnr-modal';
|
|
985
|
+
modal.style.cssText = 'position:relative;max-width:480px;width:100%';
|
|
986
|
+
modal.innerHTML = `
|
|
987
|
+
<button class="vnr-close-btn" aria-label="Close">
|
|
988
|
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
|
989
|
+
</button>
|
|
990
|
+
<h2 class="vnr-title" style="padding-right:32px">${title}</h2>
|
|
991
|
+
<p style="margin:0 0 14px;font-size:13px;color:#6b7280;line-height:1.5">${description}</p>
|
|
992
|
+
|
|
993
|
+
<div class="vnr-liveness-toggle-wrap" style="display:flex;align-items:center;justify-content:space-between;margin-bottom:12px;background:#f9fafb;border:1px solid #e5e7eb;border-radius:10px;padding:8px 12px">
|
|
994
|
+
<span style="font-size:12px;font-weight:600;color:#374151">Liveness Verification</span>
|
|
995
|
+
<label class="vnr-switch" style="position:relative;display:inline-block;width:34px;height:20px">
|
|
996
|
+
<input type="checkbox" id="vnr-cam-liveness-toggle" style="opacity:0;width:0;height:0">
|
|
997
|
+
<span class="vnr-slider" style="position:absolute;cursor:pointer;inset:0;background-color:#ccc;transition:.3s;border-radius:20px"></span>
|
|
998
|
+
</label>
|
|
999
|
+
</div>
|
|
1000
|
+
|
|
1001
|
+
<div class="vnr-video-wrap" id="vnr-cam-wrap" style="position:relative">
|
|
1002
|
+
<video id="vnr-cam-video" autoplay playsinline muted></video>
|
|
1003
|
+
<div id="vnr-cam-guide" style="display:none;position:absolute;inset:0;pointer-events:none;border:3px dashed rgba(255,255,255,0.45);border-radius:50%;margin:auto;width:180px;height:240px;box-shadow:0 0 0 9999px rgba(15,23,42,0.6);transition:border-color 0.3s ease, box-shadow 0.3s ease">
|
|
1004
|
+
<div id="vnr-cam-guide-text" style="position:absolute;bottom:-45px;left:-40px;right:-40px;text-align:center;color:#fff;font-size:11px;font-weight:600;background:rgba(15,23,42,0.85);padding:6px 10px;border-radius:6px;border:1px solid rgba(255,255,255,0.1)">
|
|
1005
|
+
Align your face
|
|
1006
|
+
</div>
|
|
1007
|
+
</div>
|
|
1008
|
+
<div id="vnr-cam-overlay" style="position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:12px;background:#0f172a">
|
|
1009
|
+
<div class="vnr-spinner" style="width:32px;height:32px;border-width:3px;border-color:rgba(255,255,255,.15);border-top-color:#fff"></div>
|
|
1010
|
+
<p style="margin:0;font-size:13px;color:#94a3b8">Opening cameraโฆ</p>
|
|
1011
|
+
</div>
|
|
1012
|
+
</div>
|
|
1013
|
+
|
|
1014
|
+
<div id="vnr-liveness-progress-container" style="display:none;margin-top:12px">
|
|
1015
|
+
<div style="display:flex;justify-content:space-between;font-size:11px;font-weight:700;color:#6b7280;margin-bottom:6px;letter-spacing:0.04em">
|
|
1016
|
+
<span>LIVENESS ANALYSIS</span>
|
|
1017
|
+
<span id="vnr-liveness-pct" style="color:#6366f1">0%</span>
|
|
1018
|
+
</div>
|
|
1019
|
+
<div style="width:100%;height:6px;background:#f3f4f6;border-radius:3px;overflow:hidden">
|
|
1020
|
+
<div id="vnr-liveness-bar" style="width:0%;height:100%;background:#6366f1;transition:width 0.1s linear"></div>
|
|
1021
|
+
</div>
|
|
1022
|
+
</div>
|
|
1023
|
+
|
|
1024
|
+
<canvas id="vnr-cam-canvas" style="display:none"></canvas>
|
|
1025
|
+
<div id="vnr-cam-status" style="display:none"></div>
|
|
1026
|
+
<div class="vnr-actions" style="margin-top:14px">
|
|
1027
|
+
<button class="vnr-cancel-btn" id="vnr-cam-cancel">Cancel</button>
|
|
1028
|
+
<button class="vnr-confirm-btn" id="vnr-cam-capture" disabled>
|
|
1029
|
+
<svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" viewBox="0 0 24 24"><path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"/><circle cx="12" cy="13" r="4"/></svg>
|
|
1030
|
+
Capture & Send
|
|
1031
|
+
</button>
|
|
1032
|
+
</div>
|
|
1033
|
+
`;
|
|
1034
|
+
overlay.appendChild(modal);
|
|
1035
|
+
|
|
1036
|
+
const video = modal.querySelector('#vnr-cam-video') as HTMLVideoElement;
|
|
1037
|
+
const canvas = modal.querySelector('#vnr-cam-canvas') as HTMLCanvasElement;
|
|
1038
|
+
const camOverlay = modal.querySelector('#vnr-cam-overlay') as HTMLElement;
|
|
1039
|
+
const statusEl = modal.querySelector('#vnr-cam-status') as HTMLElement;
|
|
1040
|
+
const captureBtn = modal.querySelector('#vnr-cam-capture') as HTMLButtonElement;
|
|
1041
|
+
|
|
1042
|
+
const livenessToggle = modal.querySelector('#vnr-cam-liveness-toggle') as HTMLInputElement;
|
|
1043
|
+
const livenessContainer = modal.querySelector('#vnr-liveness-progress-container') as HTMLElement;
|
|
1044
|
+
const livenessProgressBar = modal.querySelector('#vnr-liveness-bar') as HTMLElement;
|
|
1045
|
+
const livenessPctText = modal.querySelector('#vnr-liveness-pct') as HTMLElement;
|
|
1046
|
+
const guide = modal.querySelector('#vnr-cam-guide') as HTMLElement;
|
|
1047
|
+
const guideText = modal.querySelector('#vnr-cam-guide-text') as HTMLElement;
|
|
1048
|
+
|
|
1049
|
+
const stopStream = () => {
|
|
1050
|
+
if (livenessInterval) {
|
|
1051
|
+
clearInterval(livenessInterval);
|
|
1052
|
+
livenessInterval = null;
|
|
1053
|
+
}
|
|
1054
|
+
stream?.getTracks().forEach(t => t.stop());
|
|
1055
|
+
stream = null;
|
|
1056
|
+
video.srcObject = null;
|
|
1057
|
+
};
|
|
1058
|
+
|
|
1059
|
+
modal.querySelector('.vnr-close-btn')!.addEventListener('click', () => { stopStream(); this.dismiss(); onCancel('User closed'); });
|
|
1060
|
+
modal.querySelector('#vnr-cam-cancel')!.addEventListener('click', () => { stopStream(); this.dismiss(); onCancel('User cancelled'); });
|
|
1061
|
+
|
|
1062
|
+
// Start camera
|
|
1063
|
+
navigator.mediaDevices.getUserMedia({ video: { facingMode: { ideal: facingMode } }, audio: false })
|
|
1064
|
+
.then(s => {
|
|
1065
|
+
stream = s;
|
|
1066
|
+
video.srcObject = s;
|
|
1067
|
+
return video.play().catch(() => { });
|
|
1068
|
+
})
|
|
1069
|
+
.then(() => {
|
|
1070
|
+
camOverlay.style.display = 'none';
|
|
1071
|
+
// Add LIVE badge
|
|
1072
|
+
const badge = document.createElement('div');
|
|
1073
|
+
badge.className = 'vnr-live-badge';
|
|
1074
|
+
badge.innerHTML = '<span class="vnr-live-dot"></span>LIVE';
|
|
1075
|
+
modal.querySelector('#vnr-cam-wrap')!.appendChild(badge);
|
|
1076
|
+
|
|
1077
|
+
livenessToggle.checked = requireLivenessDefault;
|
|
1078
|
+
updateLivenessState();
|
|
1079
|
+
})
|
|
1080
|
+
.catch(() => {
|
|
1081
|
+
camOverlay.innerHTML = '<p style="color:#f87171;font-size:13px;text-align:center;padding:24px">Camera access denied.<br>Please allow camera permissions.</p>';
|
|
1082
|
+
});
|
|
1083
|
+
|
|
1084
|
+
function updateLivenessState() {
|
|
1085
|
+
if (livenessInterval) {
|
|
1086
|
+
clearInterval(livenessInterval);
|
|
1087
|
+
livenessInterval = null;
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
if (livenessToggle.checked) {
|
|
1091
|
+
livenessContainer.style.display = 'block';
|
|
1092
|
+
guide.style.display = 'block';
|
|
1093
|
+
captureBtn.disabled = true;
|
|
1094
|
+
startLivenessCheck();
|
|
1095
|
+
} else {
|
|
1096
|
+
livenessContainer.style.display = 'none';
|
|
1097
|
+
guide.style.display = 'none';
|
|
1098
|
+
captureBtn.disabled = false;
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
livenessToggle.addEventListener('change', updateLivenessState);
|
|
1103
|
+
|
|
1104
|
+
function startLivenessCheck() {
|
|
1105
|
+
let prevPixels: Uint8ClampedArray | null = null;
|
|
1106
|
+
const baselineDiffs: number[] = [];
|
|
1107
|
+
let baseline = 1.0;
|
|
1108
|
+
let livenessProgress = 0;
|
|
1109
|
+
let livenessState = 0; // 0: calibrating, 1: action prompt, 2: verified
|
|
1110
|
+
let calibrationFrames = 0;
|
|
1111
|
+
let actionDetected = false;
|
|
1112
|
+
|
|
1113
|
+
const offscreenCanvas = document.createElement('canvas');
|
|
1114
|
+
const offscreenCtx = offscreenCanvas.getContext('2d')!;
|
|
1115
|
+
offscreenCanvas.width = 80;
|
|
1116
|
+
offscreenCanvas.height = 60;
|
|
1117
|
+
|
|
1118
|
+
livenessProgressBar.style.width = '0%';
|
|
1119
|
+
livenessProgressBar.style.backgroundColor = '#6366f1';
|
|
1120
|
+
livenessPctText.style.color = '#6366f1';
|
|
1121
|
+
livenessPctText.innerText = '0%';
|
|
1122
|
+
guide.style.borderColor = 'rgba(255,255,255,0.45)';
|
|
1123
|
+
guide.style.boxShadow = '0 0 0 9999px rgba(15,23,42,0.6)';
|
|
1124
|
+
guideText.innerText = "Align your face in center";
|
|
1125
|
+
guideText.style.color = "#fff";
|
|
1126
|
+
|
|
1127
|
+
livenessInterval = setInterval(() => {
|
|
1128
|
+
if (!stream || video.paused || video.ended) return;
|
|
1129
|
+
|
|
1130
|
+
offscreenCtx.drawImage(video, 0, 0, 80, 60);
|
|
1131
|
+
const imgData = offscreenCtx.getImageData(0, 0, 80, 60);
|
|
1132
|
+
const pixels = imgData.data;
|
|
1133
|
+
|
|
1134
|
+
if (prevPixels) {
|
|
1135
|
+
let totalDiff = 0;
|
|
1136
|
+
for (let i = 0; i < pixels.length; i += 4) {
|
|
1137
|
+
totalDiff += Math.abs(pixels[i] - prevPixels[i]);
|
|
1138
|
+
totalDiff += Math.abs(pixels[i + 1] - prevPixels[i + 1]);
|
|
1139
|
+
totalDiff += Math.abs(pixels[i + 2] - prevPixels[i + 2]);
|
|
1140
|
+
}
|
|
1141
|
+
const avgDiff = totalDiff / (pixels.length / 4 * 3);
|
|
1142
|
+
|
|
1143
|
+
if (livenessState === 0) {
|
|
1144
|
+
// Calibration
|
|
1145
|
+
calibrationFrames++;
|
|
1146
|
+
baselineDiffs.push(avgDiff);
|
|
1147
|
+
|
|
1148
|
+
const pct = Math.min(100, Math.round((calibrationFrames / 15) * 100));
|
|
1149
|
+
livenessProgressBar.style.width = `${pct * 0.3}%`;
|
|
1150
|
+
livenessPctText.innerText = `${Math.round(pct * 0.3)}%`;
|
|
1151
|
+
guideText.innerText = "Hold still to calibrateโฆ";
|
|
1152
|
+
guideText.style.color = "#a5b4fc";
|
|
1153
|
+
|
|
1154
|
+
if (calibrationFrames >= 15) {
|
|
1155
|
+
const sum = baselineDiffs.reduce((a, b) => a + b, 0);
|
|
1156
|
+
baseline = sum / baselineDiffs.length;
|
|
1157
|
+
if (baseline < 0.2) baseline = 0.2;
|
|
1158
|
+
livenessState = 1;
|
|
1159
|
+
calibrationFrames = 0;
|
|
1160
|
+
livenessProgressBar.style.width = '30%';
|
|
1161
|
+
livenessPctText.innerText = '30%';
|
|
1162
|
+
}
|
|
1163
|
+
} else if (livenessState === 1) {
|
|
1164
|
+
// Action verification
|
|
1165
|
+
guideText.innerText = "Now blink or turn head slightly";
|
|
1166
|
+
guideText.style.color = "#ffd2d2";
|
|
1167
|
+
|
|
1168
|
+
const threshold = Math.max(3.0, baseline * 3.5);
|
|
1169
|
+
|
|
1170
|
+
if (avgDiff > threshold && avgDiff < 30.0) {
|
|
1171
|
+
livenessProgress += 15;
|
|
1172
|
+
if (livenessProgress > 70) {
|
|
1173
|
+
livenessProgress = 70;
|
|
1174
|
+
actionDetected = true;
|
|
1175
|
+
}
|
|
1176
|
+
const currentPct = 30 + livenessProgress;
|
|
1177
|
+
livenessProgressBar.style.width = `${currentPct}%`;
|
|
1178
|
+
livenessPctText.innerText = `${currentPct}%`;
|
|
1179
|
+
} else if (actionDetected && avgDiff <= threshold * 1.5) {
|
|
1180
|
+
livenessState = 2;
|
|
1181
|
+
livenessProgressBar.style.width = '100%';
|
|
1182
|
+
livenessProgressBar.style.backgroundColor = '#10b981';
|
|
1183
|
+
livenessPctText.innerText = '100%';
|
|
1184
|
+
livenessPctText.style.color = '#10b981';
|
|
1185
|
+
guideText.innerText = "โ Liveness Verified! Click Capture & Send.";
|
|
1186
|
+
guideText.style.color = '#10b981';
|
|
1187
|
+
guide.style.borderColor = '#10b981';
|
|
1188
|
+
guide.style.boxShadow = '0 0 0 9999px rgba(16,185,129,0.2)';
|
|
1189
|
+
captureBtn.disabled = false;
|
|
1190
|
+
|
|
1191
|
+
if (livenessInterval) {
|
|
1192
|
+
clearInterval(livenessInterval);
|
|
1193
|
+
livenessInterval = null;
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
prevPixels = pixels;
|
|
1199
|
+
}, 100);
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
captureBtn.addEventListener('click', async () => {
|
|
1203
|
+
if (!stream || video.videoWidth === 0) return;
|
|
1204
|
+
canvas.width = video.videoWidth;
|
|
1205
|
+
canvas.height = video.videoHeight;
|
|
1206
|
+
const ctx2d = canvas.getContext('2d')!;
|
|
1207
|
+
ctx2d.drawImage(video, 0, 0);
|
|
1208
|
+
stopStream();
|
|
1209
|
+
|
|
1210
|
+
modal.querySelector('#vnr-cam-wrap')!.setAttribute('style', 'display:none');
|
|
1211
|
+
if (modal.querySelector('.vnr-liveness-toggle-wrap')) {
|
|
1212
|
+
(modal.querySelector('.vnr-liveness-toggle-wrap') as HTMLElement).style.display = 'none';
|
|
1213
|
+
}
|
|
1214
|
+
livenessContainer.style.display = 'none';
|
|
1215
|
+
captureBtn.disabled = true;
|
|
1216
|
+
statusEl.style.display = 'block';
|
|
1217
|
+
statusEl.innerHTML = `<div class="vnr-center"><div class="vnr-spinner" style="margin:0 auto 8px"></div>Sending photoโฆ</div>`;
|
|
1218
|
+
|
|
1219
|
+
try {
|
|
1220
|
+
const blob = await new Promise<Blob | null>(res => canvas.toBlob(b => res(b), 'image/jpeg', 0.85));
|
|
1221
|
+
if (!blob) throw new Error('Capture failed');
|
|
1222
|
+
const file = new File([blob], `capture-${Date.now()}.jpg`, { type: 'image/jpeg' });
|
|
1223
|
+
|
|
1224
|
+
if (client && typeof client.uploadMedia === 'function') {
|
|
1225
|
+
const { media_id, url } = await client.uploadMedia(file, reason, 'User captured a photo via camera');
|
|
1226
|
+
statusEl.innerHTML = `<div class="vnr-center" style="color:#22c55e">โ Photo sent!</div>`;
|
|
1227
|
+
setTimeout(() => { this.dismiss(); onComplete({ file_name: file.name, file_type: file.type, file_size: file.size, media_id, url, message: 'User captured and sent a photo.' }); }, 400);
|
|
1228
|
+
} else {
|
|
1229
|
+
const dataUrl = canvas.toDataURL('image/jpeg', 0.85);
|
|
1230
|
+
statusEl.innerHTML = `<div class="vnr-center" style="color:#22c55e">โ Photo ready!</div>`;
|
|
1231
|
+
setTimeout(() => { this.dismiss(); onComplete({ file_name: file.name, data_url: dataUrl, message: 'User captured a photo.' }); }, 400);
|
|
1232
|
+
}
|
|
1233
|
+
} catch (err: any) {
|
|
1234
|
+
statusEl.innerHTML = `<div class="vnr-center" style="color:#ef4444">${err?.message || 'Capture failed'}</div>`;
|
|
1235
|
+
}
|
|
1236
|
+
});
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
// โโโ Upload Preset โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
1240
|
+
|
|
1241
|
+
private renderUpload(ctx: PresetContext, onComplete: OnComplete, onCancel: OnCancel, client?: any) {
|
|
1242
|
+
const title = ctx.clientFields.title || 'Upload a File';
|
|
1243
|
+
const description = ctx.clientFields.description || 'Drop or select a file to send to your agent.';
|
|
1244
|
+
const reason = ctx.clientFields.reason || 'upload';
|
|
1245
|
+
const acceptTypes = ctx.clientFields.accept || 'image/*,application/pdf,text/plain,text/csv';
|
|
1246
|
+
|
|
1247
|
+
let selectedFile: File | null = null;
|
|
1248
|
+
|
|
1249
|
+
const overlay = this.createOverlay();
|
|
1250
|
+
|
|
1251
|
+
const modal = document.createElement('div');
|
|
1252
|
+
modal.className = 'vnr-modal';
|
|
1253
|
+
modal.style.cssText = 'position:relative;max-width:480px;width:100%';
|
|
1254
|
+
modal.innerHTML = `
|
|
1255
|
+
<button class="vnr-close-btn" aria-label="Close">
|
|
1256
|
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
|
1257
|
+
</button>
|
|
1258
|
+
<h2 class="vnr-title" style="padding-right:32px">${title}</h2>
|
|
1259
|
+
<p style="margin:0 0 14px;font-size:13px;color:#6b7280;line-height:1.5">${description}</p>
|
|
1260
|
+
<div class="vnr-dropzone" id="vnr-drop-zone">
|
|
1261
|
+
<div style="font-size:32px;margin-bottom:8px">๐</div>
|
|
1262
|
+
<p style="margin:0 0 4px;font-weight:600;color:#374151;font-size:14px">Drag & drop here</p>
|
|
1263
|
+
<p style="margin:0 0 14px;color:#9ca3af;font-size:12px">or click to browse</p>
|
|
1264
|
+
<span style="display:inline-block;padding:7px 16px;background:#111;color:#fff;border-radius:9px;font-size:13px;font-weight:600">Choose File</span>
|
|
1265
|
+
<p style="margin:12px 0 0;font-size:11px;color:#9ca3af">Images, PDF, CSV, Docs ยท Max 10 MB</p>
|
|
1266
|
+
</div>
|
|
1267
|
+
<input id="vnr-file-input" type="file" accept="${acceptTypes}" style="display:none">
|
|
1268
|
+
<div id="vnr-file-preview" style="display:none;border:1px solid #e5e7eb;border-radius:12px;padding:14px;background:#f9fafb"></div>
|
|
1269
|
+
<div id="vnr-upload-status" style="display:none"></div>
|
|
1270
|
+
<div class="vnr-actions" style="margin-top:14px">
|
|
1271
|
+
<button class="vnr-cancel-btn" id="vnr-up-cancel">Cancel</button>
|
|
1272
|
+
<button class="vnr-confirm-btn" id="vnr-up-submit" disabled>
|
|
1273
|
+
<svg width="13" height="13" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg>
|
|
1274
|
+
Upload & Send
|
|
1275
|
+
</button>
|
|
1276
|
+
</div>
|
|
1277
|
+
`;
|
|
1278
|
+
overlay.appendChild(modal);
|
|
1279
|
+
|
|
1280
|
+
const dropZone = modal.querySelector('#vnr-drop-zone') as HTMLElement;
|
|
1281
|
+
const fileInput = modal.querySelector('#vnr-file-input') as HTMLInputElement;
|
|
1282
|
+
const previewEl = modal.querySelector('#vnr-file-preview') as HTMLElement;
|
|
1283
|
+
const statusEl = modal.querySelector('#vnr-upload-status') as HTMLElement;
|
|
1284
|
+
const submitBtn = modal.querySelector('#vnr-up-submit') as HTMLButtonElement;
|
|
1285
|
+
|
|
1286
|
+
modal.querySelector('.vnr-close-btn')!.addEventListener('click', () => { this.dismiss(); onCancel('User closed'); });
|
|
1287
|
+
modal.querySelector('#vnr-up-cancel')!.addEventListener('click', () => { this.dismiss(); onCancel('User cancelled'); });
|
|
1288
|
+
|
|
1289
|
+
const processFile = (f: File) => {
|
|
1290
|
+
selectedFile = f;
|
|
1291
|
+
dropZone.style.display = 'none';
|
|
1292
|
+
const icon = f.type.startsWith('image/') ? '๐ผ๏ธ' : f.type.includes('pdf') ? '๐' : '๐';
|
|
1293
|
+
const kb = (f.size / 1024).toFixed(1);
|
|
1294
|
+
previewEl.style.display = 'block';
|
|
1295
|
+
previewEl.innerHTML = `
|
|
1296
|
+
<div style="display:flex;align-items:center;gap:10px">
|
|
1297
|
+
<span style="font-size:28px">${icon}</span>
|
|
1298
|
+
<div style="flex:1;min-width:0">
|
|
1299
|
+
<p style="margin:0;font-weight:600;font-size:13px;color:#111;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${f.name}</p>
|
|
1300
|
+
<p style="margin:3px 0 0;font-size:11px;color:#6b7280">${f.type || 'unknown'} ยท ${kb} KB</p>
|
|
1301
|
+
</div>
|
|
1302
|
+
<button id="vnr-remove-file" style="width:30px;height:30px;display:flex;align-items:center;justify-content:center;background:#fff;border:1px solid #e5e7eb;border-radius:8px;cursor:pointer;color:#6b7280;flex-shrink:0">
|
|
1303
|
+
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
|
1304
|
+
</button>
|
|
1305
|
+
</div>
|
|
1306
|
+
`;
|
|
1307
|
+
previewEl.querySelector('#vnr-remove-file')!.addEventListener('click', () => {
|
|
1308
|
+
selectedFile = null;
|
|
1309
|
+
previewEl.style.display = 'none';
|
|
1310
|
+
dropZone.style.display = 'block';
|
|
1311
|
+
submitBtn.disabled = true;
|
|
1312
|
+
});
|
|
1313
|
+
submitBtn.disabled = false;
|
|
1314
|
+
};
|
|
1315
|
+
|
|
1316
|
+
dropZone.addEventListener('click', () => fileInput.click());
|
|
1317
|
+
dropZone.addEventListener('dragover', e => { e.preventDefault(); dropZone.classList.add('drag'); });
|
|
1318
|
+
dropZone.addEventListener('dragleave', () => dropZone.classList.remove('drag'));
|
|
1319
|
+
dropZone.addEventListener('drop', e => {
|
|
1320
|
+
e.preventDefault(); dropZone.classList.remove('drag');
|
|
1321
|
+
const f = (e as DragEvent).dataTransfer?.files[0];
|
|
1322
|
+
if (f) processFile(f);
|
|
1323
|
+
});
|
|
1324
|
+
fileInput.addEventListener('change', () => {
|
|
1325
|
+
const f = fileInput.files?.[0];
|
|
1326
|
+
if (f) processFile(f);
|
|
1327
|
+
});
|
|
1328
|
+
|
|
1329
|
+
submitBtn.addEventListener('click', async () => {
|
|
1330
|
+
if (!selectedFile) return;
|
|
1331
|
+
const file = selectedFile;
|
|
1332
|
+
dropZone.style.display = 'none';
|
|
1333
|
+
previewEl.style.display = 'none';
|
|
1334
|
+
statusEl.style.display = 'block';
|
|
1335
|
+
statusEl.innerHTML = `<div class="vnr-center"><div class="vnr-spinner" style="margin:0 auto 8px"></div>Uploadingโฆ</div>`;
|
|
1336
|
+
submitBtn.disabled = true;
|
|
1337
|
+
|
|
1338
|
+
try {
|
|
1339
|
+
if (client && typeof client.uploadMedia === 'function') {
|
|
1340
|
+
const { media_id, url } = await client.uploadMedia(file, reason, `User uploaded: ${file.name}`);
|
|
1341
|
+
statusEl.innerHTML = `<div class="vnr-center" style="color:#22c55e">โ File sent!</div>`;
|
|
1342
|
+
setTimeout(() => { this.dismiss(); onComplete({ file_name: file.name, file_type: file.type, file_size: file.size, media_id, url, message: `User uploaded: ${file.name}` }); }, 400);
|
|
1343
|
+
} else {
|
|
1344
|
+
const reader = new FileReader();
|
|
1345
|
+
reader.onload = e => {
|
|
1346
|
+
statusEl.innerHTML = `<div class="vnr-center" style="color:#22c55e">โ File ready!</div>`;
|
|
1347
|
+
setTimeout(() => { this.dismiss(); onComplete({ file_name: file.name, file_type: file.type, file_size: file.size, data_url: (e.target as FileReader).result, message: `User uploaded: ${file.name}` }); }, 400);
|
|
1348
|
+
};
|
|
1349
|
+
reader.readAsDataURL(file);
|
|
1350
|
+
}
|
|
1351
|
+
} catch (err: any) {
|
|
1352
|
+
statusEl.innerHTML = `<div class="vnr-center" style="color:#ef4444">${err?.message || 'Upload failed. Please try again.'}</div>`;
|
|
1353
|
+
submitBtn.disabled = false;
|
|
1354
|
+
}
|
|
1355
|
+
});
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
// โโโ Navigate Preset โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
1359
|
+
|
|
1360
|
+
private renderNavigate(ctx: PresetContext, onComplete: OnComplete, _onCancel: OnCancel) {
|
|
1361
|
+
const targetUrl = String(
|
|
1362
|
+
ctx.clientFields?.target_url ||
|
|
1363
|
+
ctx.args?.target_url ||
|
|
1364
|
+
'/'
|
|
1365
|
+
);
|
|
1366
|
+
|
|
1367
|
+
console.log(`๐ [WidgetPresetRenderer] Navigating to: ${targetUrl}`);
|
|
1368
|
+
|
|
1369
|
+
const isExternal = targetUrl.startsWith('http://') || targetUrl.startsWith('https://');
|
|
1370
|
+
|
|
1371
|
+
if (isExternal) {
|
|
1372
|
+
window.open(targetUrl, '_blank', 'noopener,noreferrer');
|
|
1373
|
+
} else {
|
|
1374
|
+
const oldUrl = window.location.href;
|
|
1375
|
+
window.dispatchEvent(new CustomEvent('vanira:navigate', { detail: { url: targetUrl } }));
|
|
1376
|
+
|
|
1377
|
+
// Check if page/URL changed after a short delay. If not, fallback to direct location change
|
|
1378
|
+
setTimeout(() => {
|
|
1379
|
+
if (window.location.href === oldUrl) {
|
|
1380
|
+
window.location.href = targetUrl;
|
|
1381
|
+
}
|
|
1382
|
+
}, 150);
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
onComplete({ navigated_to: targetUrl, status: 'success' });
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1388
|
+
// โโโ Highlight Element Preset โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
1389
|
+
|
|
1390
|
+
private renderHighlight(ctx: PresetContext, onComplete: OnComplete, onCancel: OnCancel) {
|
|
1391
|
+
const toolArgs = ctx.args || {};
|
|
1392
|
+
const clientFields = ctx.clientFields || {};
|
|
1393
|
+
|
|
1394
|
+
const selector = String(toolArgs.css_selector || clientFields.css_selector || toolArgs.selector || clientFields.selector || '');
|
|
1395
|
+
const color = String(toolArgs.highlight_color || clientFields.highlight_color || '#a855f7');
|
|
1396
|
+
const durationMs = Number(toolArgs.duration_ms || clientFields.duration_ms || 3000);
|
|
1397
|
+
|
|
1398
|
+
console.log(`๐ฆ [WidgetPresetRenderer] renderHighlight triggered`);
|
|
1399
|
+
console.log(` โ selector: "${selector}"`);
|
|
1400
|
+
console.log(` โ color: "${color}"`);
|
|
1401
|
+
console.log(` โ duration: ${durationMs}ms`);
|
|
1402
|
+
|
|
1403
|
+
if (!selector) {
|
|
1404
|
+
console.warn(`โ ๏ธ [WidgetPresetRenderer] No css_selector provided for highlight`);
|
|
1405
|
+
onCancel('No css_selector provided for highlight');
|
|
1406
|
+
return;
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
const el = document.querySelector(selector) as HTMLElement | null;
|
|
1410
|
+
console.log(`๐ฆ [WidgetPresetRenderer] querySelector("${selector}") โ`, el);
|
|
1411
|
+
|
|
1412
|
+
if (!el) {
|
|
1413
|
+
console.warn(`โ ๏ธ [WidgetPresetRenderer] Element not found for selector: "${selector}"`);
|
|
1414
|
+
onCancel(`Element not found for selector: "${selector}"`);
|
|
1415
|
+
return;
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
// Save original styles
|
|
1419
|
+
const prevOutline = el.style.outline;
|
|
1420
|
+
const prevOutlineOffset = el.style.outlineOffset;
|
|
1421
|
+
const prevBoxShadow = el.style.boxShadow;
|
|
1422
|
+
const prevTransition = el.style.transition;
|
|
1423
|
+
const prevScrollMargin = el.style.scrollMarginTop;
|
|
1424
|
+
|
|
1425
|
+
// Apply glowing highlight
|
|
1426
|
+
el.style.transition = 'outline 0.2s ease, box-shadow 0.2s ease';
|
|
1427
|
+
el.style.outline = `3px solid ${color}`;
|
|
1428
|
+
el.style.outlineOffset = '6px';
|
|
1429
|
+
el.style.boxShadow = `0 0 0 8px ${color}33, 0 0 32px ${color}88`;
|
|
1430
|
+
el.style.scrollMarginTop = '100px';
|
|
1431
|
+
|
|
1432
|
+
console.log(`โ
[WidgetPresetRenderer] Highlight applied to:`, el);
|
|
1433
|
+
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
1434
|
+
|
|
1435
|
+
// Remove after duration
|
|
1436
|
+
setTimeout(() => {
|
|
1437
|
+
el.style.outline = prevOutline;
|
|
1438
|
+
el.style.outlineOffset = prevOutlineOffset;
|
|
1439
|
+
el.style.boxShadow = prevBoxShadow;
|
|
1440
|
+
el.style.transition = prevTransition;
|
|
1441
|
+
el.style.scrollMarginTop = prevScrollMargin;
|
|
1442
|
+
console.log(`๐ฆ [WidgetPresetRenderer] Highlight removed from: "${selector}"`);
|
|
1443
|
+
}, durationMs);
|
|
1444
|
+
|
|
1445
|
+
onComplete({ status: 'success', message: `Element highlighted: ${selector}` });
|
|
1446
|
+
}
|
|
1447
|
+
|
|
1448
|
+
// โโโ Type Text Preset โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
1449
|
+
|
|
1450
|
+
private renderTypeText(ctx: PresetContext, onComplete: OnComplete, onCancel: OnCancel) {
|
|
1451
|
+
const toolArgs = ctx.args || {};
|
|
1452
|
+
const clientFields = ctx.clientFields || {};
|
|
1453
|
+
|
|
1454
|
+
const idOrSelector = String(
|
|
1455
|
+
toolArgs.element_id || clientFields.element_id ||
|
|
1456
|
+
toolArgs.css_selector || clientFields.css_selector ||
|
|
1457
|
+
toolArgs.selector || clientFields.selector || ''
|
|
1458
|
+
).trim();
|
|
1459
|
+
let text = String(
|
|
1460
|
+
toolArgs.text_to_type || clientFields.text_to_type ||
|
|
1461
|
+
toolArgs.text || clientFields.text ||
|
|
1462
|
+
toolArgs.value || clientFields.value ||
|
|
1463
|
+
toolArgs.search_query || clientFields.search_query ||
|
|
1464
|
+
toolArgs.query || clientFields.query || ''
|
|
1465
|
+
);
|
|
1466
|
+
|
|
1467
|
+
// Clean up LaTeX formulas for better plain text readability
|
|
1468
|
+
text = cleanLatexText(text);
|
|
1469
|
+
const delayMs = Number(
|
|
1470
|
+
toolArgs.delay_ms !== undefined ? toolArgs.delay_ms :
|
|
1471
|
+
clientFields.delay_ms !== undefined ? clientFields.delay_ms :
|
|
1472
|
+
50
|
|
1473
|
+
);
|
|
1474
|
+
|
|
1475
|
+
console.log(`โ๏ธ [WidgetPresetRenderer] renderTypeText triggered`);
|
|
1476
|
+
console.log(` โ idOrSelector: "${idOrSelector}"`);
|
|
1477
|
+
console.log(` โ text_to_type: "${text}"`);
|
|
1478
|
+
console.log(` โ delayMs: ${delayMs}ms`);
|
|
1479
|
+
|
|
1480
|
+
// Find element by selector or ID
|
|
1481
|
+
let el: HTMLInputElement | HTMLTextAreaElement | HTMLElement | null = null;
|
|
1482
|
+
if (idOrSelector) {
|
|
1483
|
+
try {
|
|
1484
|
+
el = document.querySelector(idOrSelector) as HTMLElement | null;
|
|
1485
|
+
} catch (_) { }
|
|
1486
|
+
if (!el) {
|
|
1487
|
+
el = document.getElementById(idOrSelector);
|
|
1488
|
+
}
|
|
1489
|
+
}
|
|
1490
|
+
|
|
1491
|
+
const showFallbackPopup = () => {
|
|
1492
|
+
console.log(`โน๏ธ [WidgetPresetRenderer] Target element not found or selector not provided. Displaying fallback popup.`);
|
|
1493
|
+
|
|
1494
|
+
// Check if there is already an existing whiteboard modal in the document
|
|
1495
|
+
let overlay = document.querySelector('.vnr-overlay') as HTMLElement | null;
|
|
1496
|
+
let modal = document.querySelector('.vnr-whiteboard-modal') as HTMLElement | null;
|
|
1497
|
+
let textarea = document.querySelector('.vnr-whiteboard-textarea') as HTMLTextAreaElement | null;
|
|
1498
|
+
|
|
1499
|
+
if (!overlay || !modal || !textarea) {
|
|
1500
|
+
// Load chalk fonts
|
|
1501
|
+
if (!document.getElementById('vnr-chalk-font')) {
|
|
1502
|
+
const link = document.createElement('link');
|
|
1503
|
+
link.id = 'vnr-chalk-font';
|
|
1504
|
+
link.rel = 'stylesheet';
|
|
1505
|
+
link.href = 'https://fonts.googleapis.com/css2?family=Architects+Daughter&family=Caveat:wght@400;700&display=swap';
|
|
1506
|
+
document.head.appendChild(link);
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1509
|
+
// If it doesn't exist, create it
|
|
1510
|
+
overlay = this.createOverlay();
|
|
1511
|
+
modal = document.createElement('div');
|
|
1512
|
+
modal.className = 'vnr-modal vnr-whiteboard-modal';
|
|
1513
|
+
|
|
1514
|
+
// Style modal as a large chalkboard
|
|
1515
|
+
modal.style.position = 'relative';
|
|
1516
|
+
modal.style.width = '100%';
|
|
1517
|
+
modal.style.maxWidth = '750px';
|
|
1518
|
+
modal.style.height = '600px';
|
|
1519
|
+
modal.style.maxHeight = '85vh';
|
|
1520
|
+
modal.style.display = 'flex';
|
|
1521
|
+
modal.style.flexDirection = 'column';
|
|
1522
|
+
modal.style.background = '#0d0e12'; // Slate black
|
|
1523
|
+
modal.style.border = '12px solid #5c4033'; // Thick wooden frame
|
|
1524
|
+
modal.style.borderRadius = '16px';
|
|
1525
|
+
modal.style.boxShadow = '0 25px 60px rgba(0,0,0,0.65), inset 0 0 30px rgba(0,0,0,0.95)';
|
|
1526
|
+
modal.style.padding = '0'; // Clean slate without padding, textarea spans the whole inner area
|
|
1527
|
+
modal.style.overflow = 'hidden';
|
|
1528
|
+
|
|
1529
|
+
// Setup close button on the chalkboard frame/top-right
|
|
1530
|
+
const closeBtn = document.createElement('button');
|
|
1531
|
+
closeBtn.className = 'vnr-close-btn';
|
|
1532
|
+
closeBtn.ariaLabel = 'Close';
|
|
1533
|
+
closeBtn.style.cssText = 'position:absolute;top:16px;right:16px;z-index:10;background:rgba(0,0,0,0.4);border:none;cursor:pointer;color:#9ca3af;line-height:1;padding:6px;border-radius:50%;display:flex;align-items:center;justify-content:center;transition:background 0.2s, color 0.2s;';
|
|
1534
|
+
closeBtn.innerHTML = `
|
|
1535
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
|
|
1536
|
+
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
|
1537
|
+
</svg>
|
|
1538
|
+
`;
|
|
1539
|
+
closeBtn.onmouseenter = () => {
|
|
1540
|
+
closeBtn.style.background = 'rgba(0,0,0,0.6)';
|
|
1541
|
+
closeBtn.style.color = '#ffffff';
|
|
1542
|
+
};
|
|
1543
|
+
closeBtn.onmouseleave = () => {
|
|
1544
|
+
closeBtn.style.background = 'rgba(0,0,0,0.4)';
|
|
1545
|
+
closeBtn.style.color = '#9ca3af';
|
|
1546
|
+
};
|
|
1547
|
+
closeBtn.onclick = () => {
|
|
1548
|
+
this.dismiss();
|
|
1549
|
+
onCancel('User dismissed typing popup');
|
|
1550
|
+
};
|
|
1551
|
+
modal.appendChild(closeBtn);
|
|
1552
|
+
|
|
1553
|
+
const container = document.createElement('div');
|
|
1554
|
+
container.style.display = 'flex';
|
|
1555
|
+
container.style.flexDirection = 'column';
|
|
1556
|
+
container.style.flex = '1';
|
|
1557
|
+
container.style.height = '100%';
|
|
1558
|
+
|
|
1559
|
+
textarea = document.createElement('textarea');
|
|
1560
|
+
textarea.className = 'vnr-whiteboard-textarea';
|
|
1561
|
+
textarea.style.width = '100%';
|
|
1562
|
+
textarea.style.height = '100%';
|
|
1563
|
+
textarea.style.flex = '1';
|
|
1564
|
+
textarea.style.background = '#0a0a0c'; // Blackboard black
|
|
1565
|
+
textarea.style.border = 'none';
|
|
1566
|
+
textarea.style.padding = '32px 48px 32px 32px'; // Right padding extra for close button clearance
|
|
1567
|
+
textarea.style.fontSize = '24px'; // Slightly larger font size for premium chalk look
|
|
1568
|
+
textarea.style.lineHeight = '1.6';
|
|
1569
|
+
textarea.style.fontFamily = "'Architects Daughter', 'Caveat', 'Chalkboard SE', 'Chalkboard', cursive, sans-serif";
|
|
1570
|
+
textarea.style.color = '#ffffff';
|
|
1571
|
+
textarea.style.textShadow = '0 0 3px rgba(255, 255, 255, 0.45), 0 0 8px rgba(255, 255, 255, 0.15)'; // realistic soft chalk glow
|
|
1572
|
+
textarea.style.outline = 'none';
|
|
1573
|
+
textarea.style.resize = 'none';
|
|
1574
|
+
textarea.style.boxSizing = 'border-box';
|
|
1575
|
+
textarea.placeholder = 'Chalkboard active...';
|
|
1576
|
+
|
|
1577
|
+
// Initialize value from persisted chalkboardContent
|
|
1578
|
+
textarea.value = this.chalkboardContent;
|
|
1579
|
+
|
|
1580
|
+
// Keep manual user edits synchronized with this.chalkboardContent
|
|
1581
|
+
textarea.addEventListener('input', () => {
|
|
1582
|
+
this.chalkboardContent = textarea!.value;
|
|
1583
|
+
});
|
|
1584
|
+
|
|
1585
|
+
container.appendChild(textarea);
|
|
1586
|
+
modal.appendChild(container);
|
|
1587
|
+
overlay.appendChild(modal);
|
|
1588
|
+
}
|
|
1589
|
+
|
|
1590
|
+
textarea.focus();
|
|
1591
|
+
|
|
1592
|
+
const initialText = this.chalkboardContent || '';
|
|
1593
|
+
const textToAppend = initialText ? '\n' + text : text;
|
|
1594
|
+
|
|
1595
|
+
if (delayMs <= 0) {
|
|
1596
|
+
this.chalkboardContent = initialText + textToAppend;
|
|
1597
|
+
textarea.value = this.chalkboardContent;
|
|
1598
|
+
textarea.scrollTop = textarea.scrollHeight;
|
|
1599
|
+
onComplete({ status: 'success' });
|
|
1600
|
+
} else {
|
|
1601
|
+
let currentText = initialText;
|
|
1602
|
+
let index = 0;
|
|
1603
|
+
|
|
1604
|
+
const typeNextChar = () => {
|
|
1605
|
+
if (!this.overlay) return; // dismissed
|
|
1606
|
+
if (index < textToAppend.length) {
|
|
1607
|
+
currentText += textToAppend[index];
|
|
1608
|
+
this.chalkboardContent = currentText;
|
|
1609
|
+
textarea!.value = currentText;
|
|
1610
|
+
textarea!.scrollTop = textarea!.scrollHeight;
|
|
1611
|
+
index++;
|
|
1612
|
+
setTimeout(typeNextChar, delayMs);
|
|
1613
|
+
} else {
|
|
1614
|
+
onComplete({ status: 'success' });
|
|
1615
|
+
}
|
|
1616
|
+
};
|
|
1617
|
+
typeNextChar();
|
|
1618
|
+
}
|
|
1619
|
+
};
|
|
1620
|
+
|
|
1621
|
+
if (!el) {
|
|
1622
|
+
showFallbackPopup();
|
|
1623
|
+
return;
|
|
1624
|
+
}
|
|
1625
|
+
|
|
1626
|
+
// Focus the input
|
|
1627
|
+
el.focus();
|
|
1628
|
+
|
|
1629
|
+
const isInput = el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement || 'value' in el;
|
|
1630
|
+
if (!isInput) {
|
|
1631
|
+
console.log(`โน๏ธ [WidgetPresetRenderer] Target is not a form input. Setting textContent directly.`);
|
|
1632
|
+
el.textContent = text;
|
|
1633
|
+
onComplete({ status: 'success' });
|
|
1634
|
+
return;
|
|
1635
|
+
}
|
|
1636
|
+
|
|
1637
|
+
const inputEl = el as HTMLInputElement | HTMLTextAreaElement;
|
|
1638
|
+
|
|
1639
|
+
const initialVal = inputEl.value || '';
|
|
1640
|
+
|
|
1641
|
+
if (delayMs <= 0) {
|
|
1642
|
+
inputEl.value = initialVal + text;
|
|
1643
|
+
inputEl.dispatchEvent(new Event('input', { bubbles: true }));
|
|
1644
|
+
inputEl.dispatchEvent(new Event('change', { bubbles: true }));
|
|
1645
|
+
onComplete({ status: 'success' });
|
|
1646
|
+
return;
|
|
1647
|
+
}
|
|
1648
|
+
|
|
1649
|
+
// Simulate character-by-character typing
|
|
1650
|
+
let currentText = initialVal;
|
|
1651
|
+
let index = 0;
|
|
1652
|
+
|
|
1653
|
+
const typeNextChar = () => {
|
|
1654
|
+
if (index < text.length) {
|
|
1655
|
+
currentText += text[index];
|
|
1656
|
+
inputEl.value = currentText;
|
|
1657
|
+
inputEl.dispatchEvent(new Event('input', { bubbles: true }));
|
|
1658
|
+
index++;
|
|
1659
|
+
setTimeout(typeNextChar, delayMs);
|
|
1660
|
+
} else {
|
|
1661
|
+
inputEl.dispatchEvent(new Event('change', { bubbles: true }));
|
|
1662
|
+
onComplete({ status: 'success' });
|
|
1663
|
+
}
|
|
1664
|
+
};
|
|
1665
|
+
|
|
1666
|
+
typeNextChar();
|
|
1667
|
+
}
|
|
1668
|
+
|
|
1669
|
+
private renderEraseText(ctx: PresetContext, onComplete: OnComplete, _onCancel: OnCancel) {
|
|
1670
|
+
const toolArgs = ctx.args || {};
|
|
1671
|
+
const clientFields = ctx.clientFields || {};
|
|
1672
|
+
|
|
1673
|
+
const idOrSelector = String(
|
|
1674
|
+
toolArgs.element_id || clientFields.element_id ||
|
|
1675
|
+
toolArgs.css_selector || clientFields.css_selector ||
|
|
1676
|
+
toolArgs.selector || clientFields.selector || ''
|
|
1677
|
+
).trim();
|
|
1678
|
+
|
|
1679
|
+
// Options: 'all' (default) | 'words'
|
|
1680
|
+
const mode = String(
|
|
1681
|
+
toolArgs.mode || clientFields.mode || 'all'
|
|
1682
|
+
).trim().toLowerCase();
|
|
1683
|
+
|
|
1684
|
+
// Options: num_words / words_count / count (default: 1)
|
|
1685
|
+
const numWords = Number(
|
|
1686
|
+
toolArgs.num_words !== undefined ? toolArgs.num_words :
|
|
1687
|
+
toolArgs.words_count !== undefined ? toolArgs.words_count :
|
|
1688
|
+
toolArgs.count !== undefined ? toolArgs.count : 1
|
|
1689
|
+
);
|
|
1690
|
+
|
|
1691
|
+
const delayMs = Number(
|
|
1692
|
+
toolArgs.delay_ms !== undefined ? toolArgs.delay_ms :
|
|
1693
|
+
clientFields.delay_ms !== undefined ? clientFields.delay_ms :
|
|
1694
|
+
50
|
|
1695
|
+
);
|
|
1696
|
+
|
|
1697
|
+
console.log(`๐งน [WidgetPresetRenderer] renderEraseText triggered`);
|
|
1698
|
+
console.log(` โ idOrSelector: "${idOrSelector}"`);
|
|
1699
|
+
console.log(` โ mode: "${mode}"`);
|
|
1700
|
+
console.log(` โ numWords: ${numWords}`);
|
|
1701
|
+
console.log(` โ delayMs: ${delayMs}ms`);
|
|
1702
|
+
|
|
1703
|
+
// Find element by selector or ID, fallback to whiteboard
|
|
1704
|
+
let el: HTMLInputElement | HTMLTextAreaElement | HTMLElement | null = null;
|
|
1705
|
+
if (idOrSelector) {
|
|
1706
|
+
try {
|
|
1707
|
+
el = document.querySelector(idOrSelector) as HTMLElement | null;
|
|
1708
|
+
} catch (_) { }
|
|
1709
|
+
if (!el) {
|
|
1710
|
+
el = document.getElementById(idOrSelector);
|
|
1711
|
+
}
|
|
1712
|
+
}
|
|
1713
|
+
|
|
1714
|
+
// Fallback to whiteboard if no element found
|
|
1715
|
+
if (!el) {
|
|
1716
|
+
el = document.querySelector('.vnr-whiteboard-textarea') as HTMLTextAreaElement | null;
|
|
1717
|
+
}
|
|
1718
|
+
|
|
1719
|
+
if (!el) {
|
|
1720
|
+
console.warn(`[WidgetPresetRenderer] Target element for erase not found.`);
|
|
1721
|
+
onComplete({ status: 'error', message: 'Target element not found' });
|
|
1722
|
+
return;
|
|
1723
|
+
}
|
|
1724
|
+
|
|
1725
|
+
// If it's a generic div or span, just clear textContent
|
|
1726
|
+
if (!(el instanceof HTMLInputElement) && !(el instanceof HTMLTextAreaElement)) {
|
|
1727
|
+
if (mode === 'words') {
|
|
1728
|
+
const text = el.textContent || '';
|
|
1729
|
+
el.textContent = this.eraseLastWords(text, numWords);
|
|
1730
|
+
} else {
|
|
1731
|
+
el.textContent = '';
|
|
1732
|
+
}
|
|
1733
|
+
onComplete({ status: 'success' });
|
|
1734
|
+
return;
|
|
1735
|
+
}
|
|
1736
|
+
|
|
1737
|
+
const inputEl = el as HTMLInputElement | HTMLTextAreaElement;
|
|
1738
|
+
const currentVal = inputEl.value || '';
|
|
1739
|
+
let targetVal = '';
|
|
1740
|
+
|
|
1741
|
+
if (mode === 'words') {
|
|
1742
|
+
targetVal = this.eraseLastWords(currentVal, numWords);
|
|
1743
|
+
} else {
|
|
1744
|
+
targetVal = '';
|
|
1745
|
+
}
|
|
1746
|
+
|
|
1747
|
+
if (delayMs <= 0) {
|
|
1748
|
+
inputEl.value = targetVal;
|
|
1749
|
+
if (inputEl.classList.contains('vnr-whiteboard-textarea')) {
|
|
1750
|
+
this.chalkboardContent = targetVal;
|
|
1751
|
+
}
|
|
1752
|
+
inputEl.dispatchEvent(new Event('input', { bubbles: true }));
|
|
1753
|
+
inputEl.dispatchEvent(new Event('change', { bubbles: true }));
|
|
1754
|
+
onComplete({ status: 'success' });
|
|
1755
|
+
return;
|
|
1756
|
+
}
|
|
1757
|
+
|
|
1758
|
+
// Animate deletion character-by-character (simulating backspaces)
|
|
1759
|
+
const deleteNextChar = () => {
|
|
1760
|
+
if (inputEl.value.length > targetVal.length) {
|
|
1761
|
+
inputEl.value = inputEl.value.slice(0, -1);
|
|
1762
|
+
if (inputEl.classList.contains('vnr-whiteboard-textarea')) {
|
|
1763
|
+
this.chalkboardContent = inputEl.value;
|
|
1764
|
+
}
|
|
1765
|
+
inputEl.dispatchEvent(new Event('input', { bubbles: true }));
|
|
1766
|
+
|
|
1767
|
+
// Keep scroll at bottom if it's the whiteboard
|
|
1768
|
+
if (inputEl.classList.contains('vnr-whiteboard-textarea')) {
|
|
1769
|
+
inputEl.scrollTop = inputEl.scrollHeight;
|
|
1770
|
+
}
|
|
1771
|
+
|
|
1772
|
+
// Delete slightly faster than standard typing delay
|
|
1773
|
+
setTimeout(deleteNextChar, Math.max(10, delayMs / 2));
|
|
1774
|
+
} else {
|
|
1775
|
+
inputEl.dispatchEvent(new Event('change', { bubbles: true }));
|
|
1776
|
+
onComplete({ status: 'success' });
|
|
1777
|
+
}
|
|
1778
|
+
};
|
|
1779
|
+
|
|
1780
|
+
deleteNextChar();
|
|
1781
|
+
}
|
|
1782
|
+
|
|
1783
|
+
private eraseLastWords(text: string, count: number): string {
|
|
1784
|
+
if (!text) return '';
|
|
1785
|
+
const trimmed = text.trimEnd();
|
|
1786
|
+
if (!trimmed) return '';
|
|
1787
|
+
|
|
1788
|
+
let current = trimmed;
|
|
1789
|
+
for (let i = 0; i < count; i++) {
|
|
1790
|
+
const lastSpace = current.lastIndexOf(' ');
|
|
1791
|
+
const lastNewline = current.lastIndexOf('\n');
|
|
1792
|
+
const cutIndex = Math.max(lastSpace, lastNewline);
|
|
1793
|
+
if (cutIndex === -1) {
|
|
1794
|
+
current = '';
|
|
1795
|
+
break;
|
|
1796
|
+
} else {
|
|
1797
|
+
current = current.slice(0, cutIndex).trimEnd();
|
|
1798
|
+
}
|
|
1799
|
+
}
|
|
1800
|
+
return current;
|
|
1801
|
+
}
|
|
1802
|
+
}
|