@clawlabz/clawskin 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,118 @@
1
+ /**
2
+ * DeviceIdentity.js — Ed25519 device identity for OpenClaw Gateway authentication
3
+ *
4
+ * The Gateway requires every WebSocket client to present a device identity:
5
+ * - Generate an Ed25519 keypair on first use (stored in localStorage)
6
+ * - Sign the connect message with the private key
7
+ * - Include deviceId, publicKey, signature in connect params
8
+ *
9
+ * Uses Web Crypto API (Ed25519 supported in Chrome 113+, Firefox 129+, Safari 17+).
10
+ */
11
+ class DeviceIdentity {
12
+ static STORAGE_KEY = 'clawskin-device-identity-v1';
13
+
14
+ /**
15
+ * Get or create device identity (keypair + deviceId)
16
+ */
17
+ static async getOrCreate() {
18
+ // Try loading from localStorage
19
+ const stored = DeviceIdentity._load();
20
+ if (stored) return stored;
21
+
22
+ // Generate new keypair
23
+ const keyPair = await crypto.subtle.generateKey('Ed25519', true, ['sign', 'verify']);
24
+ const rawPublic = await crypto.subtle.exportKey('raw', keyPair.publicKey);
25
+ const rawPrivate = await crypto.subtle.exportKey('pkcs8', keyPair.privateKey);
26
+
27
+ const publicKeyB64 = DeviceIdentity._bufToB64url(rawPublic);
28
+ const deviceId = await DeviceIdentity._sha256hex(rawPublic);
29
+
30
+ const identity = {
31
+ deviceId,
32
+ publicKey: publicKeyB64,
33
+ privateKeyPkcs8: DeviceIdentity._bufToB64url(rawPrivate),
34
+ };
35
+
36
+ DeviceIdentity._save(identity);
37
+ return identity;
38
+ }
39
+
40
+ /**
41
+ * Sign a connect message
42
+ * Format: "v2|deviceId|clientId|clientMode|role|scopes|signedAtMs|token|nonce"
43
+ */
44
+ static async sign(identity, params) {
45
+ const { deviceId, privateKeyPkcs8 } = identity;
46
+ const { clientId, clientMode, role, scopes, token, nonce } = params;
47
+
48
+ const signedAtMs = Date.now();
49
+ const scopeStr = (scopes || []).join(',');
50
+ const message = [
51
+ 'v2', deviceId, clientId, clientMode, role || '',
52
+ scopeStr, String(signedAtMs), token || '', nonce || ''
53
+ ].join('|');
54
+
55
+ // Import private key
56
+ const rawKey = DeviceIdentity._b64urlToBuf(privateKeyPkcs8);
57
+ const privateKey = await crypto.subtle.importKey(
58
+ 'pkcs8', rawKey, 'Ed25519', false, ['sign']
59
+ );
60
+
61
+ // Sign
62
+ const msgBytes = new TextEncoder().encode(message);
63
+ const sigBytes = await crypto.subtle.sign('Ed25519', privateKey, msgBytes);
64
+
65
+ return {
66
+ id: deviceId,
67
+ publicKey: identity.publicKey,
68
+ signature: DeviceIdentity._bufToB64url(sigBytes),
69
+ signedAt: signedAtMs,
70
+ nonce: nonce || '',
71
+ };
72
+ }
73
+
74
+ // ── Helpers ──
75
+
76
+ static _bufToB64url(buf) {
77
+ const bytes = new Uint8Array(buf);
78
+ let binary = '';
79
+ for (const b of bytes) binary += String.fromCharCode(b);
80
+ return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
81
+ }
82
+
83
+ static _b64urlToBuf(b64) {
84
+ const standard = b64.replace(/-/g, '+').replace(/_/g, '/');
85
+ const padded = standard + '='.repeat((4 - standard.length % 4) % 4);
86
+ const binary = atob(padded);
87
+ const bytes = new Uint8Array(binary.length);
88
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
89
+ return bytes.buffer;
90
+ }
91
+
92
+ static async _sha256hex(buf) {
93
+ const hash = await crypto.subtle.digest('SHA-256', buf);
94
+ return Array.from(new Uint8Array(hash)).map(b => b.toString(16).padStart(2, '0')).join('');
95
+ }
96
+
97
+ static _load() {
98
+ try {
99
+ const raw = localStorage.getItem(DeviceIdentity.STORAGE_KEY);
100
+ if (!raw) return null;
101
+ const data = JSON.parse(raw);
102
+ if (data?.deviceId && data?.publicKey && data?.privateKeyPkcs8) return data;
103
+ return null;
104
+ } catch { return null; }
105
+ }
106
+
107
+ static _save(identity) {
108
+ try {
109
+ localStorage.setItem(DeviceIdentity.STORAGE_KEY, JSON.stringify({
110
+ ...identity,
111
+ version: 1,
112
+ createdAtMs: Date.now(),
113
+ }));
114
+ } catch {}
115
+ }
116
+ }
117
+
118
+ if (typeof window !== 'undefined') window.DeviceIdentity = DeviceIdentity;
@@ -0,0 +1,284 @@
1
+ /**
2
+ * GatewayClient.js — OpenClaw Gateway WebSocket protocol client
3
+ * Handles connection, authentication, RPC calls, and real-time events
4
+ */
5
+ class GatewayClient {
6
+ constructor(options = {}) {
7
+ this.url = options.url || null;
8
+ this.token = options.token || null;
9
+ // OpenClaw Gateway validates client.id against an allowlist.
10
+ // Allowed values: webchat-ui, webchat, openclaw-control-ui, cli,
11
+ // gateway-client, openclaw-macos, openclaw-ios, openclaw-android,
12
+ // node-host, test, fingerprint, openclaw-probe
13
+ // Default to "webchat" — lightweight client that doesn't require
14
+ // device identity signing (unlike webchat-ui / control-ui which need Ed25519).
15
+ // Users on custom Gateway builds can override this.
16
+ this.clientId = options.clientId || 'webchat';
17
+ this.clientMode = options.clientMode || 'webchat';
18
+ this.clientVersion = options.clientVersion || '1.0.0';
19
+ this.ws = null;
20
+ this.connected = false;
21
+ this.connecting = false;
22
+ this.pending = new Map();
23
+ this.backoffMs = 800;
24
+ this.maxBackoff = 15000;
25
+ this.connectNonce = null;
26
+ this.connectSent = false;
27
+ this.autoReconnect = options.autoReconnect !== false;
28
+ this.reconnectTimer = null;
29
+ this.seqCounter = 0;
30
+
31
+ this.lastError = null;
32
+
33
+ // Callbacks
34
+ this.onConnected = options.onConnected || null;
35
+ this.onDisconnected = options.onDisconnected || null;
36
+ this.onEvent = options.onEvent || null;
37
+ this.onError = options.onError || null;
38
+ this.onStateChange = options.onStateChange || null; // 'connecting'|'connected'|'disconnected'|'error'
39
+ }
40
+
41
+ _uuid() {
42
+ return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
43
+ const r = Math.random() * 16 | 0;
44
+ return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16);
45
+ });
46
+ }
47
+
48
+ _setState(state, detail) {
49
+ if (state === 'error') this.lastError = detail || 'Unknown error';
50
+ else if (state === 'connected') this.lastError = null;
51
+ if (this.onStateChange) this.onStateChange(state, detail);
52
+ }
53
+
54
+ connect(url, token) {
55
+ if (url) this.url = url;
56
+ if (token !== undefined) this.token = token;
57
+ if (!this.url) {
58
+ this._setState('error', 'No Gateway URL provided');
59
+ return;
60
+ }
61
+
62
+ this.disconnect(false);
63
+ this.connecting = true;
64
+ this.lastError = null;
65
+ this._setState('connecting');
66
+
67
+ try {
68
+ this.ws = new WebSocket(this.url);
69
+ } catch (e) {
70
+ this.connecting = false;
71
+ this._setState('error', `Invalid URL: ${e.message}`);
72
+ return;
73
+ }
74
+
75
+ this.ws.addEventListener('open', () => {
76
+ // Wait for connect.challenge event from Gateway
77
+ });
78
+
79
+ this.ws.addEventListener('message', (e) => {
80
+ this._handleMessage(e.data);
81
+ });
82
+
83
+ this.ws.addEventListener('close', (e) => {
84
+ const wasConnected = this.connected;
85
+ this.connected = false;
86
+ this.connecting = false;
87
+ this.connectSent = false;
88
+ this.connectNonce = null;
89
+ this._flushPending(new Error(`Connection closed (${e.code})`));
90
+
91
+ if (wasConnected && this.onDisconnected) {
92
+ this.onDisconnected({ code: e.code, reason: e.reason });
93
+ }
94
+
95
+ // Don't overwrite error state — keep the auth/pairing error visible
96
+ if (!this.lastError) {
97
+ this._setState('disconnected', e.reason || `Code ${e.code}`);
98
+ }
99
+
100
+ if (this.autoReconnect && wasConnected) {
101
+ this._scheduleReconnect();
102
+ }
103
+ });
104
+
105
+ this.ws.addEventListener('error', () => {
106
+ if (!this.connected) {
107
+ this.connecting = false;
108
+ this._setState('error', 'Connection failed');
109
+ }
110
+ });
111
+ }
112
+
113
+ disconnect(permanent = true) {
114
+ if (permanent) this.autoReconnect = false;
115
+ if (this.reconnectTimer) {
116
+ clearTimeout(this.reconnectTimer);
117
+ this.reconnectTimer = null;
118
+ }
119
+ if (this.ws) {
120
+ this.ws.close();
121
+ this.ws = null;
122
+ }
123
+ this.connected = false;
124
+ this.connecting = false;
125
+ this.connectSent = false;
126
+ this.lastError = null;
127
+ this._flushPending(new Error('Disconnected'));
128
+ this._setState('disconnected');
129
+ }
130
+
131
+ _scheduleReconnect() {
132
+ if (this.reconnectTimer) return;
133
+ const delay = this.backoffMs;
134
+ this.backoffMs = Math.min(this.backoffMs * 1.7, this.maxBackoff);
135
+ this._setState('connecting', `Reconnecting in ${Math.round(delay/1000)}s...`);
136
+ this.reconnectTimer = setTimeout(() => {
137
+ this.reconnectTimer = null;
138
+ this.connect();
139
+ }, delay);
140
+ }
141
+
142
+ _handleMessage(raw) {
143
+ let msg;
144
+ try { msg = JSON.parse(raw); } catch { return; }
145
+
146
+ if (msg.type === 'event') {
147
+ // Handle connect challenge
148
+ if (msg.event === 'connect.challenge') {
149
+ const nonce = msg.payload?.nonce;
150
+ if (nonce) this.connectNonce = nonce;
151
+ this._sendConnect();
152
+ return;
153
+ }
154
+ // Forward all other events
155
+ if (this.onEvent) {
156
+ try { this.onEvent(msg); } catch (e) {
157
+ console.error('[GatewayClient] event handler error:', e);
158
+ }
159
+ }
160
+ return;
161
+ }
162
+
163
+ if (msg.type === 'res') {
164
+ const pending = this.pending.get(msg.id);
165
+ if (!pending) return;
166
+ this.pending.delete(msg.id);
167
+ if (msg.ok) {
168
+ pending.resolve(msg.payload);
169
+ } else {
170
+ pending.reject(new Error(msg.error?.message || 'Request failed'));
171
+ }
172
+ }
173
+ }
174
+
175
+ async _sendConnect() {
176
+ if (this.connectSent) return;
177
+ this.connectSent = true;
178
+
179
+ const role = 'operator';
180
+ const scopes = ['operator.admin'];
181
+
182
+ const params = {
183
+ minProtocol: 3,
184
+ maxProtocol: 3,
185
+ client: {
186
+ id: this.clientId,
187
+ version: this.clientVersion,
188
+ platform: navigator.platform || 'web',
189
+ mode: this.clientMode,
190
+ },
191
+ role,
192
+ scopes,
193
+ caps: [],
194
+ auth: {},
195
+ userAgent: navigator.userAgent,
196
+ locale: navigator.language,
197
+ };
198
+
199
+ if (this.token) {
200
+ params.auth.token = this.token;
201
+ }
202
+
203
+ // Device identity — Gateway requires Ed25519 signed device identity.
204
+ // The signed message MUST use the same role/scopes/clientId/clientMode
205
+ // as the connect params, because Gateway reconstructs the message to verify.
206
+ if (typeof DeviceIdentity !== 'undefined') {
207
+ try {
208
+ const identity = await DeviceIdentity.getOrCreate();
209
+ const device = await DeviceIdentity.sign(identity, {
210
+ clientId: this.clientId,
211
+ clientMode: this.clientMode,
212
+ role,
213
+ scopes,
214
+ token: this.token || null,
215
+ nonce: this.connectNonce || '',
216
+ });
217
+ params.device = device;
218
+ } catch (e) {
219
+ console.warn('[GatewayClient] device identity failed:', e.message);
220
+ }
221
+ }
222
+
223
+ try {
224
+ const result = await this.request('connect', params);
225
+ this.connected = true;
226
+ this.connecting = false;
227
+ this.backoffMs = 800;
228
+ this._setState('connected');
229
+ if (this.onConnected) this.onConnected(result);
230
+ } catch (e) {
231
+ this.connecting = false;
232
+ const code = e.message || '';
233
+ if (code.includes('AUTH') || code.includes('auth') || code.includes('token')) {
234
+ this._setState('error', 'Authentication failed — check your token');
235
+ } else if (code.includes('PAIRING') || code.includes('pairing')) {
236
+ this._setState('error', 'Device pairing required — approve in Gateway dashboard');
237
+ } else if (code.includes('device identity') || code.includes('DEVICE_IDENTITY')) {
238
+ this._setState('error', 'Device identity required — your browser may not support Ed25519');
239
+ } else {
240
+ this._setState('error', e.message || 'Connection failed');
241
+ }
242
+ if (this.ws) this.ws.close();
243
+ }
244
+ }
245
+
246
+ request(method, params = {}) {
247
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
248
+ return Promise.reject(new Error('Not connected'));
249
+ }
250
+ const id = this._uuid();
251
+ const msg = { type: 'req', id, method, params };
252
+ return new Promise((resolve, reject) => {
253
+ this.pending.set(id, { resolve, reject });
254
+ this.ws.send(JSON.stringify(msg));
255
+ // Timeout after 30s
256
+ setTimeout(() => {
257
+ if (this.pending.has(id)) {
258
+ this.pending.delete(id);
259
+ reject(new Error('Request timeout'));
260
+ }
261
+ }, 30000);
262
+ });
263
+ }
264
+
265
+ _flushPending(error) {
266
+ for (const [, p] of this.pending) p.reject(error);
267
+ this.pending.clear();
268
+ }
269
+
270
+ // Convenience methods
271
+ async getStatus() { return this.request('status', {}); }
272
+ async getHealth() { return this.request('health', {}); }
273
+ async getAgentIdentity(agentId) {
274
+ return this.request('agent.identity.get', agentId ? { agentId } : {});
275
+ }
276
+ async getChatHistory(sessionKey, limit = 50) {
277
+ return this.request('chat.history', { sessionKey, limit });
278
+ }
279
+ async getSessionsList(opts = {}) {
280
+ return this.request('sessions.list', { activeMinutes: 120, ...opts });
281
+ }
282
+ }
283
+
284
+ if (typeof window !== 'undefined') window.GatewayClient = GatewayClient;
@@ -0,0 +1,47 @@
1
+ /**
2
+ * SettingsManager.js — Persistent settings via localStorage
3
+ */
4
+ class SettingsManager {
5
+ constructor(storageKey = 'clawskin_settings') {
6
+ this.storageKey = storageKey;
7
+ this.defaults = {
8
+ gatewayUrl: '',
9
+ token: '',
10
+ sessionKey: 'main',
11
+ autoConnect: true,
12
+ scene: 0,
13
+ character: null,
14
+ lastConnected: null,
15
+ };
16
+ }
17
+
18
+ load() {
19
+ try {
20
+ const raw = localStorage.getItem(this.storageKey);
21
+ if (!raw) return { ...this.defaults };
22
+ const saved = JSON.parse(raw);
23
+ return { ...this.defaults, ...saved };
24
+ } catch { return { ...this.defaults }; }
25
+ }
26
+
27
+ save(settings) {
28
+ try {
29
+ localStorage.setItem(this.storageKey, JSON.stringify(settings));
30
+ } catch (e) {
31
+ console.warn('[SettingsManager] save failed:', e);
32
+ }
33
+ }
34
+
35
+ update(patch) {
36
+ const current = this.load();
37
+ const updated = { ...current, ...patch };
38
+ this.save(updated);
39
+ return updated;
40
+ }
41
+
42
+ clear() {
43
+ try { localStorage.removeItem(this.storageKey); } catch {}
44
+ }
45
+ }
46
+
47
+ if (typeof window !== 'undefined') window.SettingsManager = SettingsManager;
@@ -0,0 +1,69 @@
1
+ /**
2
+ * AnimationManager.js — Character animation state machine
3
+ * Manages transitions between agent states and animation frames
4
+ */
5
+ class AnimationManager {
6
+ constructor() {
7
+ this.currentState = 'idle';
8
+ this.frameIndex = 0;
9
+ this.frameTimer = 0;
10
+ this.transitionTimer = 0;
11
+
12
+ // Animation frame indices in sprite sheet (pairs for 2-frame anims)
13
+ this.stateFrames = {
14
+ idle: [0, 1],
15
+ typing: [2, 3],
16
+ thinking: [4, 5],
17
+ walking: [6, 7],
18
+ sleeping: [8, 8],
19
+ error: [9, 9],
20
+ waving: [10, 11],
21
+ coffee: [12, 13],
22
+ browsing: [14, 15],
23
+ executing: [6, 7] // reuse walking
24
+ };
25
+
26
+ // Frame durations (ms) per state
27
+ this.frameSpeeds = {
28
+ idle: 1200, typing: 200, thinking: 800, walking: 300,
29
+ sleeping: 2000, error: 500, waving: 400, coffee: 1500,
30
+ browsing: 1000, executing: 300
31
+ };
32
+
33
+ // State display names
34
+ this.stateLabels = {
35
+ idle: '💤 Idle', typing: '⌨️ Writing', thinking: '🤔 Thinking',
36
+ walking: '🚶 Moving', sleeping: '😴 Sleeping', error: '❌ Error',
37
+ waving: '👋 Waving', coffee: '☕ Coffee Break', browsing: '🌐 Browsing',
38
+ executing: '⚡ Executing'
39
+ };
40
+ }
41
+
42
+ setState(newState) {
43
+ if (!this.stateFrames[newState] || newState === this.currentState) return;
44
+ this.currentState = newState;
45
+ this.frameIndex = 0;
46
+ this.frameTimer = 0;
47
+ }
48
+
49
+ update(dt) {
50
+ this.frameTimer += dt;
51
+ const speed = this.frameSpeeds[this.currentState] || 500;
52
+ if (this.frameTimer >= speed) {
53
+ this.frameTimer = 0;
54
+ const frames = this.stateFrames[this.currentState];
55
+ this.frameIndex = (this.frameIndex + 1) % frames.length;
56
+ }
57
+ }
58
+
59
+ getCurrentFrame() {
60
+ const frames = this.stateFrames[this.currentState];
61
+ return frames[this.frameIndex];
62
+ }
63
+
64
+ getLabel() {
65
+ return this.stateLabels[this.currentState] || this.currentState;
66
+ }
67
+ }
68
+
69
+ if (typeof window !== 'undefined') window.AnimationManager = AnimationManager;
@@ -0,0 +1,157 @@
1
+ /**
2
+ * BubbleManager.js — Speech/thought bubble with typewriter effect
3
+ */
4
+ class BubbleManager {
5
+ constructor() {
6
+ this.active = false;
7
+ this.text = '';
8
+ this.displayText = '';
9
+ this.charIndex = 0;
10
+ this.timer = 0;
11
+ this.displayDuration = 0;
12
+ this.maxDuration = 5000;
13
+ this.typeSpeed = 50; // ms per char
14
+ this.bubbleType = 'speech'; // speech | thought | status
15
+ this.queue = [];
16
+ }
17
+
18
+ show(text, type = 'speech', duration = 5000) {
19
+ if (this.active) {
20
+ this.queue.push({ text, type, duration });
21
+ return;
22
+ }
23
+ this.active = true;
24
+ this.text = text.slice(0, 60); // max 60 chars
25
+ this.displayText = '';
26
+ this.charIndex = 0;
27
+ this.timer = 0;
28
+ this.displayDuration = 0;
29
+ this.maxDuration = duration;
30
+ this.bubbleType = type;
31
+ }
32
+
33
+ update(dt) {
34
+ if (!this.active) return;
35
+
36
+ if (this.charIndex < this.text.length) {
37
+ this.timer += dt;
38
+ if (this.timer >= this.typeSpeed) {
39
+ this.timer = 0;
40
+ this.charIndex++;
41
+ this.displayText = this.text.slice(0, this.charIndex);
42
+ }
43
+ } else {
44
+ this.displayDuration += dt;
45
+ if (this.displayDuration >= this.maxDuration) {
46
+ this.active = false;
47
+ if (this.queue.length > 0) {
48
+ const next = this.queue.shift();
49
+ this.show(next.text, next.type, next.duration);
50
+ }
51
+ }
52
+ }
53
+ }
54
+
55
+ render(ctx, x, y) {
56
+ if (!this.active || !this.displayText) return;
57
+
58
+ ctx.save();
59
+ ctx.font = '10px "Press Start 2P", monospace';
60
+ ctx.textBaseline = 'top';
61
+
62
+ const lines = this._wrapText(ctx, this.displayText, 120);
63
+ const lineHeight = 13;
64
+ const padding = 6;
65
+ const maxWidth = Math.max(...lines.map(l => ctx.measureText(l).width));
66
+ const bw = maxWidth + padding * 2;
67
+ const bh = lines.length * lineHeight + padding * 2;
68
+ const bx = x - bw / 2;
69
+ const by = y - bh - 12;
70
+
71
+ // Bubble background
72
+ ctx.fillStyle = this.bubbleType === 'thought' ? '#F0F0F0' : '#FFFFFF';
73
+ ctx.strokeStyle = '#333';
74
+ ctx.lineWidth = 2;
75
+
76
+ // Rounded rect
77
+ const r = 4;
78
+ ctx.beginPath();
79
+ ctx.moveTo(bx + r, by);
80
+ ctx.lineTo(bx + bw - r, by);
81
+ ctx.quadraticCurveTo(bx + bw, by, bx + bw, by + r);
82
+ ctx.lineTo(bx + bw, by + bh - r);
83
+ ctx.quadraticCurveTo(bx + bw, by + bh, bx + bw - r, by + bh);
84
+ ctx.lineTo(bx + r, by + bh);
85
+ ctx.quadraticCurveTo(bx, by + bh, bx, by + bh - r);
86
+ ctx.lineTo(bx, by + r);
87
+ ctx.quadraticCurveTo(bx, by, bx + r, by);
88
+ ctx.closePath();
89
+ ctx.fill();
90
+ ctx.stroke();
91
+
92
+ // Tail
93
+ if (this.bubbleType === 'thought') {
94
+ ctx.fillStyle = '#F0F0F0';
95
+ ctx.beginPath();
96
+ ctx.arc(x - 4, by + bh + 5, 3, 0, Math.PI * 2);
97
+ ctx.fill(); ctx.stroke();
98
+ ctx.beginPath();
99
+ ctx.arc(x - 2, by + bh + 10, 2, 0, Math.PI * 2);
100
+ ctx.fill(); ctx.stroke();
101
+ } else {
102
+ ctx.fillStyle = '#FFFFFF';
103
+ ctx.beginPath();
104
+ ctx.moveTo(x - 5, by + bh);
105
+ ctx.lineTo(x, by + bh + 8);
106
+ ctx.lineTo(x + 5, by + bh);
107
+ ctx.closePath();
108
+ ctx.fill();
109
+ ctx.strokeStyle = '#333';
110
+ ctx.beginPath();
111
+ ctx.moveTo(x - 5, by + bh);
112
+ ctx.lineTo(x, by + bh + 8);
113
+ ctx.lineTo(x + 5, by + bh);
114
+ ctx.stroke();
115
+ // Cover top of tail
116
+ ctx.fillStyle = '#FFFFFF';
117
+ ctx.fillRect(x - 4, by + bh - 1, 9, 2);
118
+ }
119
+
120
+ // Text
121
+ ctx.fillStyle = '#333';
122
+ lines.forEach((line, i) => {
123
+ ctx.fillText(line, bx + padding, by + padding + i * lineHeight);
124
+ });
125
+
126
+ // Cursor blink when typing
127
+ if (this.charIndex < this.text.length) {
128
+ const lastLine = lines[lines.length - 1];
129
+ const lw = ctx.measureText(lastLine).width;
130
+ if (Math.floor(Date.now() / 300) % 2 === 0) {
131
+ ctx.fillStyle = '#333';
132
+ ctx.fillRect(bx + padding + lw + 1, by + padding + (lines.length - 1) * lineHeight, 6, 10);
133
+ }
134
+ }
135
+
136
+ ctx.restore();
137
+ }
138
+
139
+ _wrapText(ctx, text, maxWidth) {
140
+ const words = text.split(' ');
141
+ const lines = [];
142
+ let current = '';
143
+ for (const word of words) {
144
+ const test = current ? current + ' ' + word : word;
145
+ if (ctx.measureText(test).width > maxWidth && current) {
146
+ lines.push(current);
147
+ current = word;
148
+ } else {
149
+ current = test;
150
+ }
151
+ }
152
+ if (current) lines.push(current);
153
+ return lines.length ? lines : [''];
154
+ }
155
+ }
156
+
157
+ if (typeof window !== 'undefined') window.BubbleManager = BubbleManager;