js_lis 3.0.0 → 3.1.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.
@@ -1,6 +1,188 @@
1
1
  import { layouts } from "./layouts.js";
2
- import { injectCSS } from "./style/kbstyle.js";
2
+ import { getCSS, injectCSS } from "./style/kbstyle.js";
3
3
 
4
+ //AES-GCM ผ่าน WeakMap
5
+ class SecureBufferStore {
6
+ constructor() {
7
+ this._store = new WeakMap();
8
+ }
9
+
10
+ //สร้างกุญแจเข้ารหัส
11
+ async _getEntry(inputEl) {
12
+ if (!this._store.has(inputEl)) {
13
+ const key = await crypto.subtle.generateKey(
14
+ { name: "AES-GCM", length: 256 },
15
+ false, // ไม่ export ออก
16
+ ["encrypt", "decrypt"]
17
+ );
18
+ const iv = crypto.getRandomValues(new Uint8Array(12));
19
+ const ciphertext = await crypto.subtle.encrypt(
20
+ { name: "AES-GCM", iv },
21
+ key,
22
+ new TextEncoder().encode("")
23
+ );
24
+ this._store.set(inputEl, { key, iv, ciphertext });
25
+ }
26
+ return this._store.get(inputEl);
27
+ }
28
+
29
+ //ถอดรหัสชั่วคราว
30
+ async read(inputEl) {
31
+ if (!this._store.has(inputEl)) return "";
32
+ const { key, iv, ciphertext } = this._store.get(inputEl);
33
+ try {
34
+ const plain = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, ciphertext);
35
+ return new TextDecoder().decode(plain);
36
+ } catch {
37
+ return "";
38
+ }
39
+ }
40
+
41
+ //เข้ารหัสใหม่
42
+ async write(inputEl, plaintext) {
43
+ const entry = await this._getEntry(inputEl);
44
+ const iv = crypto.getRandomValues(new Uint8Array(12));
45
+ const ciphertext = await crypto.subtle.encrypt(
46
+ { name: "AES-GCM", iv },
47
+ entry.key,
48
+ new TextEncoder().encode(plaintext)
49
+ );
50
+ entry.iv = iv;
51
+ entry.ciphertext = ciphertext;
52
+ }
53
+
54
+ //ค่าเริ่มต้นให้กับ Input
55
+ async init(inputEl, initialPlaintext) {
56
+ if (!this._store.has(inputEl)) {
57
+ await this._getEntry(inputEl);
58
+ }
59
+ await this.write(inputEl, initialPlaintext);
60
+ }
61
+
62
+ has(inputEl) { return this._store.has(inputEl); }
63
+ delete(inputEl) { this._store.delete(inputEl); }
64
+ }
65
+
66
+ //จัดการการพิมพ์
67
+ class IMEReconciliationEngine {
68
+ constructor() {
69
+ this._sessions = new Map();
70
+ }
71
+
72
+ //เริ่มต้น
73
+ start(inputEl, cursorStart, cursorEnd) {
74
+ this._sessions.set(inputEl, {
75
+ active: true,
76
+ compositionText: "",
77
+ anchorStart: cursorStart,
78
+ anchorEnd: cursorEnd,
79
+ lastDisplayLen: 0,
80
+ });
81
+ }
82
+
83
+ isActive(inputEl) {
84
+ return this._sessions.get(inputEl)?.active ?? false;
85
+ }
86
+
87
+ //อัปเดตตอนพิมพ์
88
+ update(inputEl, newCompositionText) {
89
+ const s = this._sessions.get(inputEl);
90
+ if (!s) return null;
91
+ const prev = s.compositionText;
92
+ s.compositionText = newCompositionText;
93
+ s.lastDisplayLen = newCompositionText.length;
94
+ return {
95
+ replaceStart: s.anchorStart,
96
+ replaceEnd: s.anchorStart + prev.length,
97
+ insertText: newCompositionText,
98
+ };
99
+ }
100
+
101
+ //จบและส่งข้อความ
102
+ end(inputEl, finalText) {
103
+ const s = this._sessions.get(inputEl);
104
+ if (!s) return null;
105
+ const op = {
106
+ replaceStart: s.anchorStart,
107
+ replaceEnd: s.anchorStart + s.compositionText.length,
108
+ insertText: finalText,
109
+ };
110
+ this._sessions.delete(inputEl);
111
+ return op;
112
+ }
113
+
114
+ //ยกเลิกการพิมพ์
115
+ abort(inputEl) {
116
+ const s = this._sessions.get(inputEl);
117
+ if (!s) return null;
118
+ const op = {
119
+ replaceStart: s.anchorStart,
120
+ replaceEnd: s.anchorStart + s.compositionText.length,
121
+ insertText: "",
122
+ };
123
+ this._sessions.delete(inputEl);
124
+ return op;
125
+ }
126
+ }
127
+
128
+ //ป้องกันดักอ่าน Clipboard
129
+ class AntiClipboardSniff {
130
+ constructor() {
131
+ this._copyListeners = [];
132
+ this._pasteListeners = [];
133
+ this._active = false;
134
+ }
135
+
136
+ //
137
+ install(getIsVKActive) {
138
+ if (this._active) return;
139
+ this._active = true;
140
+ this._getIsVKActive = getIsVKActive;
141
+
142
+ this._onCopy = (e) => {
143
+ if (!this._getIsVKActive()) return;
144
+ e.stopImmediatePropagation();
145
+ };
146
+ this._onCut = (e) => {
147
+ if (!this._getIsVKActive()) return;
148
+ e.stopImmediatePropagation();
149
+ };
150
+
151
+ document.addEventListener("copy", this._onCopy, { capture: true });
152
+ document.addEventListener("cut", this._onCut, { capture: true });
153
+
154
+ //block การใช้คำสั่ง JS ของหน้าเว็บ
155
+ const _origExecCommand = document.execCommand.bind(document);
156
+ document.execCommand = (cmd, ...args) => {
157
+ if (
158
+ this._getIsVKActive() &&
159
+ (cmd === "copy" || cmd === "cut" || cmd === "insertText")
160
+ ) {
161
+ return false;
162
+ }
163
+ return _origExecCommand(cmd, ...args);
164
+ };
165
+ }
166
+
167
+ uninstall() {
168
+ if (!this._active) return;
169
+ document.removeEventListener("copy", this._onCopy, { capture: true });
170
+ document.removeEventListener("cut", this._onCut, { capture: true });
171
+ this._active = false;
172
+ }
173
+
174
+ //อ่านค่า Clipboard ผ่าน API
175
+ async securePaste() {
176
+ try {
177
+ if (navigator.clipboard?.readText) {
178
+ return await navigator.clipboard.readText();
179
+ }
180
+ } catch {}
181
+ return null;
182
+ }
183
+ }
184
+
185
+ //VirtualKeyboard
4
186
  export class VirtualKeyboard {
5
187
  constructor() {
6
188
  this.currentLayout = "full";
@@ -15,14 +197,19 @@ export class VirtualKeyboard {
15
197
  this.capsLockActive = false;
16
198
  this.isScrambled = false;
17
199
  this.isVirtualKeyboardActive = false;
200
+ this.isInternalUpdate = false;
201
+
202
+ this.maskChar = "•";
203
+
204
+ this._secureStore = new SecureBufferStore();
205
+ this._imeEngine = new IMEReconciliationEngine();
206
+ this._antiClip = new AntiClipboardSniff();
18
207
 
19
- this.inputPlaintextBuffers = new WeakMap();
208
+ this._shadowRoot = null;
209
+ this._shadowHost = null;
20
210
 
21
- // FIX BUG-2: เก็บ bound reference เพื่อให้ removeEventListener ทำงานได้
22
211
  this._boundDrag = this.drag.bind(this);
23
212
  this._boundStopDrag = this.stopDrag.bind(this);
24
-
25
- // FIX WARN-3: เก็บ flag ว่า listeners ถูก add ไปแล้วหรือยัง
26
213
  this._listenersInitialized = false;
27
214
 
28
215
  this.initialize();
@@ -30,348 +217,428 @@ export class VirtualKeyboard {
30
217
 
31
218
  async initialize() {
32
219
  try {
33
- injectCSS();
220
+ this._antiClip.install(() => this.isVirtualKeyboardActive);
221
+ this._mountShadowDOM();
34
222
  this.render();
35
- // FIX WARN-3: เรียก initializeInputListeners ครั้งเดียวเท่านั้น
36
223
  if (!this._listenersInitialized) {
37
224
  this.initializeInputListeners();
38
225
  this._listenersInitialized = true;
39
226
  }
40
- console.log("VirtualKeyboard initialized successfully.");
41
- } catch (error) {
42
- console.error("Error initializing VirtualKeyboard:", error);
227
+ console.log("VirtualKeyboard (secure) initialized.");
228
+ } catch (err) {
229
+ console.error("VirtualKeyboard init error:", err);
43
230
  }
44
231
  }
45
232
 
233
+ //ป้องกัน CSS/JS ภายนอกเข้าถึง
234
+ _mountShadowDOM() {
235
+ this._shadowHost = document.createElement("div");
236
+ this._shadowHost.id = "vk-shadow-host";
237
+ this.container.appendChild(this._shadowHost);
238
+
239
+ this._shadowRoot = this._shadowHost.attachShadow({ mode: "closed" });
240
+
241
+ const styleEl = document.createElement("style");
242
+ styleEl.textContent = this._getShadowStyles();
243
+ this._shadowRoot.appendChild(styleEl);
244
+
245
+ const inner = document.createElement("div");
246
+ inner.id = "keyboard-shadow-inner";
247
+ this._shadowRoot.appendChild(inner);
248
+ }
249
+
250
+ _getShadowStyles() {
251
+ return getCSS();
252
+ }
253
+
254
+ _q(selector) {
255
+ return this._shadowRoot?.querySelector(selector) ?? null;
256
+ }
257
+
258
+ _qAll(selector) {
259
+ return this._shadowRoot?.querySelectorAll(selector) ?? [];
260
+ }
261
+
46
262
  getLayoutName(layout) {
47
- switch (layout) {
48
- case "full":
49
- return "Full Keyboard";
50
- case "en":
51
- return "English Keyboard";
52
- case "th":
53
- return "Thai keyboard";
54
- case "numpad":
55
- return "Numpad Keyboard";
56
- case "symbols":
57
- return "Symbols Keyboard";
58
- case "full-layouts":
59
- return "Full English + Numpad";
60
- default:
61
- return "Unknown Layout";
263
+ const names = {
264
+ full: "Full Keyboard", en: "English Keyboard", th: "Thai keyboard",
265
+ numpad: "Numpad Keyboard", symbols: "Symbols Keyboard",
266
+ "full-layouts": "Full English + Numpad",
267
+ };
268
+ return names[layout] ?? "Unknown Layout";
269
+ }
270
+
271
+ _isPasswordField(el) {
272
+ return el?.type === "password";
273
+ }
274
+
275
+ async getCurrentBuffer() {
276
+ if (!this.currentInput) return "";
277
+ return this._secureStore.read(this.currentInput);
278
+ }
279
+
280
+ async getBufferForInput(inputEl) {
281
+ if (!inputEl) return "";
282
+ return this._secureStore.read(inputEl);
283
+ }
284
+
285
+ async setCurrentBuffer(plaintext) {
286
+ if (!this.currentInput) return;
287
+ await this._secureStore.write(this.currentInput, plaintext);
288
+ }
289
+
290
+ //อัปเดตค่าที่แสดงใน Input
291
+ async updateDisplayedValue(preserveCursor = true) {
292
+ if (!this.currentInput || this.isInternalUpdate) return;
293
+
294
+ const buffer = await this.getCurrentBuffer();
295
+ const isPassword = this._isPasswordField(this.currentInput);
296
+
297
+ const start = preserveCursor ? this.currentInput.selectionStart : null;
298
+ const end = preserveCursor ? this.currentInput.selectionEnd : null;
299
+
300
+ this.isInternalUpdate = true;
301
+
302
+ this.currentInput.value = isPassword
303
+ ? this.maskChar.repeat(buffer.length)
304
+ : buffer;
305
+
306
+ if (preserveCursor && start !== null) {
307
+ this.currentInput.setSelectionRange(start, end);
62
308
  }
309
+
310
+ this.isInternalUpdate = false;
63
311
  }
64
312
 
313
+ //ตั้งค่า listeners สำหรับการโฟกัส, IME, clipboard
65
314
  initializeInputListeners() {
66
315
  document.addEventListener("click", (e) => {
67
- const target = e.target;
68
- if (target.tagName === "INPUT" || target.tagName === "TEXTAREA") {
69
- const clickCursorPos = target.selectionStart;
70
- this.setCurrentInput(target, !this.isVirtualKeyboardActive);
316
+ const t = e.target;
317
+ if (t.tagName === "INPUT" || t.tagName === "TEXTAREA") {
318
+ const pos = t.selectionStart;
319
+ this.setCurrentInput(t, !this.isVirtualKeyboardActive);
71
320
  setTimeout(() => {
72
- if (target.selectionStart !== clickCursorPos) {
73
- target.setSelectionRange(clickCursorPos, clickCursorPos);
74
- }
321
+ if (t.selectionStart !== pos) t.setSelectionRange(pos, pos);
75
322
  }, 10);
76
323
  }
77
324
  });
78
325
 
79
- document.addEventListener(
80
- "focus",
81
- (e) => {
82
- const target = e.target;
83
- if (target.tagName === "INPUT" || target.tagName === "TEXTAREA") {
84
- this.setCurrentInput(target, !this.isVirtualKeyboardActive);
85
- }
86
- },
87
- true,
88
- );
326
+ document.addEventListener("focus", (e) => {
327
+ const t = e.target;
328
+ if (t.tagName === "INPUT" || t.tagName === "TEXTAREA") {
329
+ this.setCurrentInput(t, !this.isVirtualKeyboardActive);
330
+ }
331
+ }, true);
89
332
 
90
- // FIX BUG-4: handle paste event เพื่อ sync buffer
91
- document.addEventListener("paste", (e) => {
92
- const target = e.target;
333
+ document.addEventListener("blur", (e) => {
334
+ const t = e.target;
93
335
  if (
94
- (target.tagName === "INPUT" || target.tagName === "TEXTAREA") &&
95
- target === this.currentInput &&
96
- !this.isVirtualKeyboardActive
336
+ (t.tagName === "INPUT" || t.tagName === "TEXTAREA") &&
337
+ this._imeEngine.isActive(t)
97
338
  ) {
98
- e.preventDefault();
99
- const pasteText = (e.clipboardData || window.clipboardData).getData(
100
- "text",
101
- );
102
- if (!pasteText) return;
103
-
104
- const start = target.selectionStart;
105
- const end = target.selectionEnd;
106
- const buffer = this.getCurrentBuffer();
107
- const newBuffer =
108
- buffer.slice(0, start) + pasteText + buffer.slice(end);
109
- this.setCurrentBuffer(newBuffer);
110
- this.updateDisplayedValue(false);
111
-
112
- const newCursorPos = start + pasteText.length;
113
- setTimeout(() => {
114
- target.setSelectionRange(newCursorPos, newCursorPos);
115
- target.focus();
116
- }, 0);
339
+ this._imeAbortAsync(t);
340
+ }
341
+ }, true);
117
342
 
118
- const event = new Event("input", { bubbles: true });
119
- target.dispatchEvent(event);
343
+ //IME Reconciliation
344
+ document.addEventListener("compositionstart", (e) => {
345
+ const t = e.target;
346
+ if ((t.tagName === "INPUT" || t.tagName === "TEXTAREA") && t === this.currentInput) {
347
+ this._imeEngine.start(t, t.selectionStart, t.selectionEnd);
120
348
  }
121
349
  });
122
350
 
123
- // FIX BUG-4: handle cut event เพื่อ sync buffer
124
- document.addEventListener("cut", (e) => {
125
- const target = e.target;
351
+ document.addEventListener("compositionupdate", (e) => {
352
+ const t = e.target;
126
353
  if (
127
- (target.tagName === "INPUT" || target.tagName === "TEXTAREA") &&
128
- target === this.currentInput &&
129
- !this.isVirtualKeyboardActive
354
+ (t.tagName === "INPUT" || t.tagName === "TEXTAREA") &&
355
+ t === this.currentInput &&
356
+ this._imeEngine.isActive(t)
130
357
  ) {
131
- const start = target.selectionStart;
132
- const end = target.selectionEnd;
133
- if (start === end) return;
358
+ this._imeUpdateAsync(t, e.data ?? "");
359
+ }
360
+ });
134
361
 
135
- const buffer = this.getCurrentBuffer();
136
- const selectedText = buffer.slice(start, end);
362
+ document.addEventListener("compositionend", (e) => {
363
+ const t = e.target;
364
+ if (
365
+ (t.tagName === "INPUT" || t.tagName === "TEXTAREA") &&
366
+ t === this.currentInput &&
367
+ this._imeEngine.isActive(t)
368
+ ) {
369
+ this._imeEndAsync(t, e.data ?? "");
370
+ }
371
+ });
137
372
 
138
- e.preventDefault();
139
- e.clipboardData.setData("text/plain", selectedText);
373
+ //คีย์บอร์ดจริง
374
+ document.addEventListener("keydown", (e) => {
375
+ const t = e.target;
376
+ if ((t.tagName === "INPUT" || t.tagName === "TEXTAREA") && t === this.currentInput) {
377
+ if (this._imeEngine.isActive(t)) return;
140
378
 
141
- const newBuffer = buffer.slice(0, start) + buffer.slice(end);
142
- this.setCurrentBuffer(newBuffer);
143
- this.updateDisplayedValue(false);
379
+ const navKeys = ["ArrowLeft","ArrowRight","ArrowUp","ArrowDown","Home","End"];
380
+ if (!navKeys.includes(e.code)) this.isVirtualKeyboardActive = false;
144
381
 
145
- setTimeout(() => {
146
- target.setSelectionRange(start, start);
147
- target.focus();
148
- }, 0);
382
+ if (!this.isVirtualKeyboardActive) {
383
+ this.handleRealKeyboardKeydown(t, e);
384
+ }
149
385
 
150
- const event = new Event("input", { bubbles: true });
151
- target.dispatchEvent(event);
386
+ if (navKeys.includes(e.code)) setTimeout(() => t.focus(), 0);
152
387
  }
153
388
  });
154
389
 
155
390
  document.addEventListener("input", (e) => {
156
- const target = e.target;
391
+ const t = e.target;
157
392
  if (
158
- (target.tagName === "INPUT" || target.tagName === "TEXTAREA") &&
159
- target === this.currentInput
393
+ (t.tagName === "INPUT" || t.tagName === "TEXTAREA") &&
394
+ t === this.currentInput &&
395
+ !this.isVirtualKeyboardActive &&
396
+ !this.isInternalUpdate
160
397
  ) {
161
- if (!this.isVirtualKeyboardActive) {
162
- this.handleRealKeyboardInput(target, e);
163
- }
398
+ this._handleRealKeyboardInputAsync(t, e);
164
399
  }
165
400
  });
166
401
 
167
- document.addEventListener("keydown", (e) => {
168
- const target = e.target;
402
+ document.addEventListener("paste", async (e) => {
403
+ const t = e.target;
169
404
  if (
170
- (target.tagName === "INPUT" || target.tagName === "TEXTAREA") &&
171
- target === this.currentInput
405
+ (t.tagName === "INPUT" || t.tagName === "TEXTAREA") &&
406
+ t === this.currentInput
172
407
  ) {
173
- const navKeys = [
174
- "ArrowLeft",
175
- "ArrowRight",
176
- "ArrowUp",
177
- "ArrowDown",
178
- "Home",
179
- "End",
180
- ];
181
-
182
- if (!navKeys.includes(e.code)) {
183
- this.isVirtualKeyboardActive = false;
184
- }
408
+ e.preventDefault();
409
+ e.stopImmediatePropagation();
185
410
 
186
- if (!this.isVirtualKeyboardActive) {
187
- this.handleRealKeyboardKeydown(target, e);
411
+ let pasteText = await this._antiClip.securePaste();
412
+ if (pasteText === null) {
413
+ pasteText = (e.clipboardData || window.clipboardData)?.getData("text") ?? "";
188
414
  }
415
+ if (!pasteText) return;
189
416
 
190
- if (navKeys.includes(e.code)) {
191
- setTimeout(() => {
192
- target.focus();
193
- }, 0);
194
- }
417
+ const start = t.selectionStart;
418
+ const end = t.selectionEnd;
419
+ const buffer = await this.getCurrentBuffer();
420
+ await this.setCurrentBuffer(buffer.slice(0, start) + pasteText + buffer.slice(end));
421
+ await this.updateDisplayedValue(false);
422
+
423
+ const newCursor = start + pasteText.length;
424
+ setTimeout(() => { t.setSelectionRange(newCursor, newCursor); t.focus(); }, 0);
425
+
426
+ this.isInternalUpdate = true;
427
+ t.dispatchEvent(new Event("input", { bubbles: true }));
428
+ this.isInternalUpdate = false;
195
429
  }
196
- });
430
+ }, { capture: true });
197
431
 
198
- const toggle = document.getElementById("toggle");
199
- if (toggle) {
200
- toggle.addEventListener("click", this.toggle.bind(this));
201
- }
432
+ document.addEventListener("cut", async (e) => {
433
+ const t = e.target;
434
+ if (
435
+ (t.tagName === "INPUT" || t.tagName === "TEXTAREA") &&
436
+ t === this.currentInput
437
+ ) {
438
+ const start = t.selectionStart;
439
+ const end = t.selectionEnd;
440
+ if (start === end) return;
441
+
442
+ e.preventDefault();
443
+ e.stopImmediatePropagation();
444
+
445
+ const buffer = await this.getCurrentBuffer();
446
+ const selected = buffer.slice(start, end);
447
+
448
+ try {
449
+ if (navigator.clipboard?.writeText) {
450
+ await navigator.clipboard.writeText(selected);
451
+ } else {
452
+ e.clipboardData?.setData("text/plain", selected);
453
+ }
454
+ } catch {}
455
+
456
+ await this.setCurrentBuffer(buffer.slice(0, start) + buffer.slice(end));
457
+ await this.updateDisplayedValue(false);
458
+
459
+ setTimeout(() => { t.setSelectionRange(start, start); t.focus(); }, 0);
460
+
461
+ this.isInternalUpdate = true;
462
+ t.dispatchEvent(new Event("input", { bubbles: true }));
463
+ this.isInternalUpdate = false;
464
+ }
465
+ }, { capture: true });
466
+
467
+ document.getElementById("toggle")
468
+ ?.addEventListener("click", this.toggle.bind(this));
469
+ }
470
+
471
+ //จัดการ IME แบบ Async
472
+ async _imeUpdateAsync(inputEl, compositionText) {
473
+ const op = this._imeEngine.update(inputEl, compositionText);
474
+ if (!op) return;
475
+
476
+ const buffer = await this.getBufferForInput(inputEl);
477
+ const newBuf = buffer.slice(0, op.replaceStart) + op.insertText + buffer.slice(op.replaceEnd);
478
+ await this._secureStore.write(inputEl, newBuf);
479
+
480
+ const newCursor = op.replaceStart + op.insertText.length;
481
+ this.isInternalUpdate = true;
482
+ inputEl.value = this._isPasswordField(inputEl)
483
+ ? this.maskChar.repeat(newBuf.length)
484
+ : newBuf;
485
+ inputEl.setSelectionRange(newCursor, newCursor);
486
+ this.isInternalUpdate = false;
202
487
  }
203
488
 
489
+ async _imeEndAsync(inputEl, finalText) {
490
+ const op = this._imeEngine.end(inputEl, finalText);
491
+ if (!op) return;
492
+
493
+ const buffer = await this.getBufferForInput(inputEl);
494
+ const newBuf = buffer.slice(0, op.replaceStart) + op.insertText + buffer.slice(op.replaceEnd);
495
+ await this._secureStore.write(inputEl, newBuf);
496
+
497
+ const newCursor = op.replaceStart + op.insertText.length;
498
+ this.isInternalUpdate = true;
499
+ inputEl.value = this._isPasswordField(inputEl)
500
+ ? this.maskChar.repeat(newBuf.length)
501
+ : newBuf;
502
+ inputEl.setSelectionRange(newCursor, newCursor);
503
+ this.isInternalUpdate = false;
504
+ }
505
+
506
+ async _imeAbortAsync(inputEl) {
507
+ const op = this._imeEngine.abort(inputEl);
508
+ if (!op) return;
509
+
510
+ const buffer = await this.getBufferForInput(inputEl);
511
+ const newBuf = buffer.slice(0, op.replaceStart) + buffer.slice(op.replaceEnd);
512
+ await this._secureStore.write(inputEl, newBuf);
513
+
514
+ this.isInternalUpdate = true;
515
+ inputEl.value = this._isPasswordField(inputEl)
516
+ ? this.maskChar.repeat(newBuf.length)
517
+ : newBuf;
518
+ inputEl.setSelectionRange(op.replaceStart, op.replaceStart);
519
+ this.isInternalUpdate = false;
520
+ }
521
+
522
+ //setCurrentInput
204
523
  setCurrentInput(inputElement, moveToEnd = true) {
205
- if (this.currentInput) {
206
- this.currentInput.classList.remove("keyboard-active");
207
- }
524
+ if (this.currentInput) this.currentInput.classList.remove("keyboard-active");
208
525
 
209
526
  this.currentInput = inputElement;
210
527
  this.currentInput.classList.add("keyboard-active");
211
528
  this.currentInput.focus();
212
529
 
213
- if (!this.inputPlaintextBuffers.has(this.currentInput)) {
214
- const initial = this.currentInput.value || "";
215
- this.inputPlaintextBuffers.set(this.currentInput, initial);
216
- }
217
- this.updateDisplayedValue();
218
-
219
- if (moveToEnd) {
220
- setTimeout(() => {
221
- const length = this.currentInput.value.length;
222
- this.currentInput.setSelectionRange(length, length);
223
- }, 0);
224
- }
530
+ const initBuffer = async () => {
531
+ if (!this._secureStore.has(this.currentInput)) {
532
+ const initial = this._isPasswordField(this.currentInput)
533
+ ? ""
534
+ : (this.currentInput.value || "");
535
+ await this._secureStore.init(this.currentInput, initial);
536
+ }
537
+ await this.updateDisplayedValue(false);
538
+ if (moveToEnd) {
539
+ const len = this.currentInput.value.length;
540
+ this.currentInput.setSelectionRange(len, len);
541
+ }
542
+ };
543
+ initBuffer();
225
544
  }
226
545
 
546
+ //render shadow DOM
227
547
  render() {
548
+ const inner = this._shadowRoot?.getElementById("keyboard-shadow-inner");
549
+ if (!inner) return;
550
+
228
551
  const keyboard = document.createElement("div");
229
552
  keyboard.className = `virtual-keyboard ${this.currentLayout}`;
230
553
  keyboard.style.display = this.isVisible ? "block" : "none";
231
554
  keyboard.id = "keyboard";
232
555
 
233
- const controlsContainer = document.createElement("div");
234
- controlsContainer.className = "controls";
235
- controlsContainer.style.display = "flex";
236
- controlsContainer.style.justifyContent = "center";
237
- controlsContainer.style.alignItems = "center";
238
- controlsContainer.style.marginBottom = "10px";
239
-
240
- const layoutSelector = document.createElement("select");
241
- layoutSelector.id = "layout-selector";
242
- layoutSelector.setAttribute("aria-label", "Select keyboard layout");
243
- layoutSelector.onchange = (e) => this.changeLayout(e.target.value);
244
-
245
- const availableLayouts = ["full", "en", "th", "numpad", "symbols"];
246
- availableLayouts.forEach((layout) => {
247
- const option = document.createElement("option");
248
- option.value = layout;
249
- option.innerText = this.getLayoutName(layout);
250
- layoutSelector.appendChild(option);
556
+ const controls = document.createElement("div");
557
+ controls.className = "controls";
558
+ Object.assign(controls.style, {
559
+ display: "flex", justifyContent: "center",
560
+ alignItems: "center", marginBottom: "10px",
251
561
  });
252
- layoutSelector.value = this.currentLayout;
253
- controlsContainer.appendChild(layoutSelector);
254
-
255
- keyboard.appendChild(controlsContainer);
256
-
257
- const layout = this.layouts[this.currentLayout];
258
-
259
- layout.forEach((row) => {
260
- const rowElement = document.createElement("div");
261
- rowElement.className = "keyboard-row";
262
-
263
- row.forEach((key, index) => {
264
- const keyElement = document.createElement("button");
265
- keyElement.className = "keyboard-key key";
266
- keyElement.textContent = key;
267
- keyElement.type = "button";
268
- keyElement.dataset.key = key;
269
-
270
- // FIX IMP-4: เพิ่ม aria-label บนทุก key
271
- keyElement.setAttribute(
272
- "aria-label",
273
- key === " "
274
- ? "spacebar"
275
- : key === "backspace" || key === "Backspace"
276
- ? "backspace"
277
- : key,
278
- );
279
-
280
- if (index >= row.length - 4) {
281
- keyElement.classList.add("concat-keys");
282
- }
283
-
284
- if (key === " ") {
285
- keyElement.className += " spacebar";
286
- keyElement.innerHTML = "";
287
- }
288
562
 
563
+ const sel = document.createElement("select");
564
+ sel.id = "layout-selector";
565
+ sel.setAttribute("aria-label", "Select keyboard layout");
566
+ sel.onchange = (e) => this.changeLayout(e.target.value);
567
+ ["full","en","th","numpad","symbols"].forEach((l) => {
568
+ const o = document.createElement("option");
569
+ o.value = l; o.innerText = this.getLayoutName(l);
570
+ sel.appendChild(o);
571
+ });
572
+ sel.value = this.currentLayout;
573
+ controls.appendChild(sel);
574
+ keyboard.appendChild(controls);
575
+
576
+ // Keys
577
+ this.layouts[this.currentLayout].forEach((row) => {
578
+ const rowEl = document.createElement("div");
579
+ rowEl.className = "keyboard-row";
580
+ row.forEach((key, i) => {
581
+ const btn = document.createElement("button");
582
+ btn.className = "keyboard-key key";
583
+ btn.textContent = key;
584
+ btn.type = "button";
585
+ btn.dataset.key = key;
586
+ btn.setAttribute("aria-label",
587
+ key === " " ? "spacebar" :
588
+ (key === "backspace" || key === "Backspace") ? "backspace" : key
589
+ );
590
+ if (i >= row.length - 4) btn.classList.add("concat-keys");
591
+ if (key === " ") { btn.className += " spacebar"; btn.innerHTML = ""; }
289
592
  if (key === "backspace" || key === "Backspace") {
290
- keyElement.className += " backspacew";
291
- keyElement.innerHTML = '<i class="fa fa-backspace"></i>';
292
- }
293
-
294
- // FIX WARN-2: restore active state หลัง re-render
295
- if (key === "Caps 🄰" && this.capsLockActive) {
296
- keyElement.classList.add("active", "bg-gray-400");
297
- }
298
- if (key === "Shift ⇧" && this.shiftActive) {
299
- keyElement.classList.add("active", "bg-gray-400");
593
+ btn.className += " backspacew";
594
+ btn.innerHTML = '';
300
595
  }
596
+ if (key === "Caps 🄰" && this.capsLockActive) btn.classList.add("active","bg-gray-400");
597
+ if (key === "Shift ⇧" && this.shiftActive) btn.classList.add("active","bg-gray-400");
301
598
 
302
- keyElement.onclick = (e) => {
599
+ btn.onclick = (e) => {
303
600
  e.preventDefault();
304
- const keyPressed = keyElement.dataset.key || keyElement.textContent;
305
- if (keyPressed) {
306
- this.handleKeyPress(keyPressed);
307
- } else {
308
- console.error("The key element does not have a valid key value.");
309
- }
601
+ const k = btn.dataset.key || btn.textContent;
602
+ if (k) this.handleKeyPress(k);
310
603
  };
311
-
312
- rowElement.appendChild(keyElement);
604
+ rowEl.appendChild(btn);
313
605
  });
314
-
315
- keyboard.appendChild(rowElement);
606
+ keyboard.appendChild(rowEl);
316
607
  });
317
608
 
318
- this.container.innerHTML = "";
319
- this.container.appendChild(keyboard);
320
-
321
- // FIX BUG-2: ใช้ bound reference แทน bind() inline
322
- keyboard.addEventListener("mousedown", (event) => this.startDrag(event));
609
+ inner.innerHTML = "";
610
+ inner.appendChild(keyboard);
611
+ keyboard.addEventListener("mousedown", (ev) => this.startDrag(ev));
323
612
  }
324
613
 
325
- // FIX BUG-3 & WARN-5: เปลี่ยนเป็น sync, จัดการ isVirtualKeyboardActive อย่างถูกต้อง
326
- handleKeyPress(keyPressed) {
327
- if (!this.currentInput) return;
614
+ //handleKeyPress
615
+
616
+ async handleKeyPress(keyPressed) {
617
+ if (!this.currentInput || !keyPressed) return;
618
+
619
+ if (this._imeEngine.isActive(this.currentInput)) return;
328
620
 
329
621
  this.isVirtualKeyboardActive = true;
330
622
 
331
623
  const start = this.currentInput.selectionStart;
332
624
  const end = this.currentInput.selectionEnd;
333
- const buffer = this.getCurrentBuffer();
334
-
335
- const isCapsActive = this.capsLockActive;
336
- const isShiftActive = this.shiftActive;
337
-
338
- const convertToCorrectCase = (char) => {
339
- if (isCapsActive || isShiftActive) return char.toUpperCase();
340
- return char.toLowerCase();
341
- };
342
-
343
- if (!keyPressed) {
344
- this.isVirtualKeyboardActive = false;
345
- return console.error("Invalid key pressed.");
346
- }
625
+ const buffer = await this.getCurrentBuffer();
626
+ const isCaps = this.capsLockActive;
627
+ const isShift = this.shiftActive;
347
628
 
629
+ //scramble
348
630
  if (keyPressed === "scr" || keyPressed === "scrambled") {
349
- const layoutScrambleMap = {
350
- en: () =>
351
- this.isScrambled ? this.unscrambleKeys() : this.scrambleEnglishKeys(),
352
- th: () =>
353
- this.isScrambled ? this.unscrambleKeys() : this.scrambleThaiKeys(),
354
- numpad: () =>
355
- this.isScrambled ? this.unscrambleKeys() : this.scrambleKeyboard(),
356
- symbols: () =>
357
- this.isScrambled ? this.unscrambleKeys() : this.scrambleSymbols(),
358
- full: () =>
359
- this.isScrambled
360
- ? this.unscrambleKeys()
361
- : this.scrambleFullLayoutKeys(),
631
+ const h = {
632
+ en: () => this.isScrambled ? this.unscrambleKeys() : this.scrambleEnglishKeys(),
633
+ th: () => this.isScrambled ? this.unscrambleKeys() : this.scrambleThaiKeys(),
634
+ numpad: () => this.isScrambled ? this.unscrambleKeys() : this.scrambleKeyboard(),
635
+ symbols: () => this.isScrambled ? this.unscrambleKeys() : this.scrambleSymbols(),
636
+ full: () => this.isScrambled ? this.unscrambleKeys() : this.scrambleFullLayoutKeys(),
362
637
  };
363
-
364
- const handler = layoutScrambleMap[this.currentLayout];
365
- if (handler) {
366
- handler();
367
- this.isScrambled = !this.isScrambled;
368
- document
369
- .querySelectorAll('.key[data-key="scr"], .key[data-key="scrambled"]')
370
- .forEach((key) => {
371
- key.classList.toggle("active", this.isScrambled);
372
- });
373
- }
374
-
638
+ h[this.currentLayout]?.();
639
+ this.isScrambled = !this.isScrambled;
640
+ this._qAll('.key[data-key="scr"],.key[data-key="scrambled"]')
641
+ .forEach((k) => k.classList.toggle("active", this.isScrambled));
375
642
  this.isVirtualKeyboardActive = false;
376
643
  return;
377
644
  }
@@ -380,645 +647,333 @@ export class VirtualKeyboard {
380
647
  case "HOME":
381
648
  this.currentInput.setSelectionRange(0, 0);
382
649
  this.currentInput.focus();
383
- setTimeout(() => {
384
- this.currentInput.setSelectionRange(0, 0);
385
- this.currentInput.focus();
386
- }, 5);
387
- this.isVirtualKeyboardActive = false;
388
- return;
650
+ setTimeout(() => { this.currentInput.setSelectionRange(0, 0); this.currentInput.focus(); }, 5);
651
+ this.isVirtualKeyboardActive = false; return;
389
652
 
390
653
  case "END": {
391
- const bufferLengthEnd = this.getCurrentBuffer().length;
392
- this.currentInput.setSelectionRange(bufferLengthEnd, bufferLengthEnd);
654
+ const len = buffer.length;
655
+ this.currentInput.setSelectionRange(len, len);
393
656
  this.currentInput.focus();
394
- setTimeout(() => {
395
- this.currentInput.setSelectionRange(bufferLengthEnd, bufferLengthEnd);
396
- this.currentInput.focus();
397
- }, 5);
398
- this.isVirtualKeyboardActive = false;
399
- return;
657
+ setTimeout(() => { this.currentInput.setSelectionRange(len, len); this.currentInput.focus(); }, 5);
658
+ this.isVirtualKeyboardActive = false; return;
400
659
  }
401
660
 
402
- case "Backspace":
403
- case "backspace":
404
- if (start === end && start > 0) {
405
- const newBuffer = buffer.slice(0, start - 1) + buffer.slice(end);
406
- this.setCurrentBuffer(newBuffer);
407
- const newCursorPos = start - 1;
408
- this.updateDisplayedValue(false);
409
- this.currentInput.setSelectionRange(newCursorPos, newCursorPos);
410
- } else if (start !== end) {
411
- const newBuffer = buffer.slice(0, start) + buffer.slice(end);
412
- this.setCurrentBuffer(newBuffer);
413
- this.updateDisplayedValue(false);
414
- this.currentInput.setSelectionRange(start, start);
415
- }
416
- break;
417
-
418
- case "DEL⌦":
419
- if (start === end && start < buffer.length) {
420
- const newBuffer = buffer.slice(0, start) + buffer.slice(end + 1);
421
- this.setCurrentBuffer(newBuffer);
422
- this.updateDisplayedValue(false);
423
- this.currentInput.setSelectionRange(start, start);
424
- } else if (start !== end) {
425
- const newBuffer = buffer.slice(0, start) + buffer.slice(end);
426
- this.setCurrentBuffer(newBuffer);
427
- this.updateDisplayedValue(false);
428
- this.currentInput.setSelectionRange(start, start);
429
- }
661
+ case "Backspace": case "backspace": {
662
+ let nb = buffer, np = start;
663
+ if (start === end && start > 0) { nb = buffer.slice(0, start - 1) + buffer.slice(end); np = start - 1; }
664
+ else if (start !== end) { nb = buffer.slice(0, start) + buffer.slice(end); np = start; }
665
+ await this.setCurrentBuffer(nb);
666
+ await this.updateDisplayedValue(false);
667
+ this.currentInput.setSelectionRange(np, np);
430
668
  break;
669
+ }
431
670
 
432
- case "Space":
433
- this.insertText(" ");
671
+ case "DEL⌦": {
672
+ let nb = buffer;
673
+ if (start === end && start < buffer.length) nb = buffer.slice(0, start) + buffer.slice(end + 1);
674
+ else if (start !== end) nb = buffer.slice(0, start) + buffer.slice(end);
675
+ await this.setCurrentBuffer(nb);
676
+ await this.updateDisplayedValue(false);
677
+ this.currentInput.setSelectionRange(start, start);
434
678
  break;
679
+ }
435
680
 
436
- case "Tab ":
437
- this.insertText("\t");
438
- break;
681
+ case "Space": await this._insertText(" "); break;
682
+ case "Tab ↹": await this._insertText("\t"); break;
439
683
 
440
684
  case "Enter":
441
685
  if (this.currentInput.tagName === "TEXTAREA") {
442
- const newBuffer = buffer.slice(0, start) + "\n" + buffer.slice(end);
443
- this.setCurrentBuffer(newBuffer);
444
- const newCursorPos = start + 1;
445
- this.updateDisplayedValue(false);
446
- this.currentInput.setSelectionRange(newCursorPos, newCursorPos);
447
- } else if (
448
- this.currentInput.tagName === "INPUT" ||
449
- this.currentInput.type === "password" ||
450
- this.currentInput.type === "text"
451
- ) {
452
- if (this.currentInput.form) {
453
- const submitButton = this.currentInput.form.querySelector(
454
- 'input[type="submit"], button[type="submit"], button[type="button"], button[onclick]',
455
- );
456
- if (submitButton) submitButton.click();
457
- else this.currentInput.form.submit();
458
- } else {
459
- const newBuffer = buffer + "\n";
460
- this.setCurrentBuffer(newBuffer);
461
- this.updateDisplayedValue(false);
462
- }
686
+ const nb = buffer.slice(0, start) + "\n" + buffer.slice(end);
687
+ await this.setCurrentBuffer(nb);
688
+ await this.updateDisplayedValue(false);
689
+ this.currentInput.setSelectionRange(start + 1, start + 1);
690
+ } else if (this.currentInput.form) {
691
+ const submit = this.currentInput.form.querySelector(
692
+ 'input[type="submit"],button[type="submit"],button[type="button"],button[onclick]'
693
+ );
694
+ if (submit) submit.click(); else this.currentInput.form.submit();
463
695
  }
464
696
  break;
465
697
 
466
- case "Caps 🄰":
467
- this.toggleCapsLock();
468
- break;
469
-
470
- case "Shift ⇧":
471
- this.toggleShift();
472
- break;
698
+ case "Caps 🄰": this.toggleCapsLock(); break;
699
+ case "Shift ⇧": this.toggleShift(); break;
473
700
 
474
701
  case "←": {
475
- const leftNewPos = Math.max(0, start - 1);
476
- this.currentInput.setSelectionRange(leftNewPos, leftNewPos);
477
- this.isVirtualKeyboardActive = false;
478
- return;
702
+ const p = Math.max(0, start - 1);
703
+ this.currentInput.setSelectionRange(p, p);
704
+ this.isVirtualKeyboardActive = false; return;
479
705
  }
480
-
481
706
  case "→": {
482
- const bufferLength = this.getCurrentBuffer().length;
483
- const rightNewPos = Math.min(bufferLength, start + 1);
484
- this.currentInput.setSelectionRange(rightNewPos, rightNewPos);
485
- this.isVirtualKeyboardActive = false;
486
- return;
707
+ const p = Math.min(buffer.length, start + 1);
708
+ this.currentInput.setSelectionRange(p, p);
709
+ this.isVirtualKeyboardActive = false; return;
487
710
  }
488
711
 
489
- case "↑":
490
- case "↓": {
491
- const actualText = this.getCurrentBuffer();
492
- const actualLines = actualText.substring(0, start).split("\n");
493
- const actualCurrentLineIndex = actualLines.length - 1;
494
- const actualCurrentLine = actualLines[actualCurrentLineIndex];
495
- const actualColumnIndex =
496
- start - actualText.lastIndexOf("\n", start - 1) - 1;
497
-
498
- if (keyPressed === "↑" && actualCurrentLineIndex > 0) {
499
- const prevLineLength = actualLines[actualCurrentLineIndex - 1].length;
500
- const upNewPos =
501
- start -
502
- actualCurrentLine.length -
503
- 1 -
504
- Math.max(0, actualColumnIndex - prevLineLength);
505
- this.currentInput.setSelectionRange(
506
- Math.max(0, upNewPos),
507
- Math.max(0, upNewPos),
508
- );
712
+ case "↑": case "↓": {
713
+ const text = buffer;
714
+ const lines = text.substring(0, start).split("\n");
715
+ const li = lines.length - 1;
716
+ const col = start - text.lastIndexOf("\n", start - 1) - 1;
717
+ if (keyPressed === "↑" && li > 0) {
718
+ const prevLen = lines[li - 1].length;
719
+ const np = start - lines[li].length - 1 - Math.max(0, col - prevLen);
720
+ this.currentInput.setSelectionRange(Math.max(0, np), Math.max(0, np));
509
721
  } else if (keyPressed === "↓") {
510
- const remainingText = actualText.substring(start);
511
- const nextLineBreak = remainingText.indexOf("\n");
512
- if (nextLineBreak !== -1) {
513
- const textAfterNextLineBreak = remainingText.substring(
514
- nextLineBreak + 1,
515
- );
516
- const nextLineEnd = textAfterNextLineBreak.indexOf("\n");
517
- const nextLineLength =
518
- nextLineEnd === -1 ? textAfterNextLineBreak.length : nextLineEnd;
519
- const downNewPos =
520
- start +
521
- nextLineBreak +
522
- 1 +
523
- Math.min(actualColumnIndex, nextLineLength);
524
- this.currentInput.setSelectionRange(downNewPos, downNewPos);
722
+ const rem = text.substring(start);
723
+ const nl = rem.indexOf("\n");
724
+ if (nl !== -1) {
725
+ const afterNL = rem.substring(nl + 1);
726
+ const nextEnd = afterNL.indexOf("\n");
727
+ const nextLen = nextEnd === -1 ? afterNL.length : nextEnd;
728
+ const np = start + nl + 1 + Math.min(col, nextLen);
729
+ this.currentInput.setSelectionRange(np, np);
525
730
  }
526
731
  }
527
-
528
732
  this.currentInput.focus();
529
- this.isVirtualKeyboardActive = false;
530
- return;
733
+ this.isVirtualKeyboardActive = false; return;
531
734
  }
532
735
 
533
736
  default: {
534
- const textvalue = convertToCorrectCase(keyPressed);
535
- this.insertText(textvalue);
737
+ const ch = (isCaps || isShift) ? keyPressed.toUpperCase() : keyPressed.toLowerCase();
738
+ await this._insertText(ch);
536
739
  break;
537
740
  }
538
741
  }
539
742
 
540
- if (isShiftActive && !isCapsActive) this.toggleShift();
743
+ if (isShift && !isCaps) this.toggleShift();
541
744
 
542
745
  this.currentInput.focus();
543
-
544
- // FIX BUG-3: reset flag หลัง setTimeout เพื่อป้องกัน real keyboard listener เข้ามาแทรก
545
746
  setTimeout(() => {
747
+ this.isVirtualKeyboardActive = false;
546
748
  if (this.currentInput) {
749
+ const p = this.currentInput.selectionStart;
750
+ this.currentInput.setSelectionRange(p, p);
547
751
  this.currentInput.focus();
548
- const cursorPos = this.currentInput.selectionStart;
549
- this.currentInput.setSelectionRange(cursorPos, cursorPos);
550
752
  }
551
- this.isVirtualKeyboardActive = false;
552
753
  }, 0);
553
754
 
554
- this.currentInput.focus();
555
- const event = new Event("input", { bubbles: true });
556
- this.currentInput.dispatchEvent(event);
755
+ this.isInternalUpdate = true;
756
+ this.currentInput.dispatchEvent(new Event("input", { bubbles: true }));
757
+ this.isInternalUpdate = false;
557
758
  }
558
759
 
559
- insertText(text) {
760
+ //แทรกข้อความลงในตำแหน่ง Cursor และเข้ารหัสเก็บใน Store
761
+ async _insertText(text) {
762
+ if (!this.currentInput) return;
560
763
  const start = this.currentInput.selectionStart;
561
764
  const end = this.currentInput.selectionEnd;
765
+ const buffer = await this.getCurrentBuffer();
766
+ const newBuf = buffer.slice(0, start) + text + buffer.slice(end);
767
+ await this.setCurrentBuffer(newBuf);
562
768
 
563
- const buffer = this.getCurrentBuffer();
564
- const newBuffer = buffer.slice(0, start) + text + buffer.slice(end);
565
- this.setCurrentBuffer(newBuffer);
566
-
567
- const newCursorPos = start + text.length;
568
- this.updateDisplayedValue(false);
569
- this.currentInput.setSelectionRange(newCursorPos, newCursorPos);
570
- }
571
-
572
- getCurrentBuffer() {
573
- if (!this.currentInput) return "";
574
- return this.inputPlaintextBuffers.get(this.currentInput) || "";
575
- }
576
-
577
- getBufferForInput(inputElement) {
578
- if (!inputElement) return "";
579
- const bufferedValue = this.inputPlaintextBuffers.get(inputElement);
580
- if (typeof bufferedValue === "string") {
581
- return bufferedValue;
582
- }
583
- return inputElement.value || "";
584
- }
769
+ this.isInternalUpdate = true;
770
+ await this.updateDisplayedValue(false);
771
+ this.isInternalUpdate = false;
585
772
 
586
- setCurrentBuffer(newBuffer) {
587
- if (!this.currentInput) return;
588
- this.inputPlaintextBuffers.set(this.currentInput, newBuffer);
589
- }
590
-
591
- updateDisplayedValue(preserveCursor = true) {
592
- if (!this.currentInput) return;
593
- const buffer = this.getCurrentBuffer();
594
- const isPassword = this.currentInput.type === "password";
595
- const maskChar = "•";
596
-
597
- const currentStart = preserveCursor ? this.currentInput.selectionStart : 0;
598
- const currentEnd = preserveCursor ? this.currentInput.selectionEnd : 0;
599
-
600
- this.currentInput.value = isPassword
601
- ? maskChar.repeat(buffer.length)
602
- : buffer;
603
-
604
- if (preserveCursor) {
605
- this.currentInput.setSelectionRange(currentStart, currentEnd);
606
- }
773
+ const np = start + text.length;
774
+ this.currentInput.setSelectionRange(np, np);
607
775
  }
608
776
 
609
- handleRealKeyboardKeydown(input, event) {
777
+ //บันทึกลง Store (ป้องกันการเก็บ plaintext ใน .value)
778
+ async handleRealKeyboardKeydown(input, event) {
610
779
  if (!input) return;
611
-
612
780
  const key = event.key;
613
781
  const start = input.selectionStart;
614
782
  const end = input.selectionEnd;
615
- const buffer = this.getCurrentBuffer();
783
+ const buffer = await this.getBufferForInput(input);
616
784
 
617
785
  if (event.ctrlKey || event.altKey || event.metaKey) {
618
- // FIX BUG-6: handle Ctrl+A
619
- if ((event.ctrlKey || event.metaKey) && key === "a") {
620
- // Allow default selection — no buffer change needed
621
- return;
622
- }
623
- // FIX BUG-6: handle Ctrl+Z (undo) — best-effort: เราไม่เก็บ history จึง ignore
786
+ if ((event.ctrlKey || event.metaKey) && key === "a") return;
624
787
  return;
625
788
  }
626
789
 
627
- if (
628
- [
629
- "Shift",
630
- "Control",
631
- "Alt",
632
- "Meta",
633
- "CapsLock",
634
- "Tab",
635
- "Escape",
636
- "ArrowLeft",
637
- "ArrowRight",
638
- "ArrowUp",
639
- "ArrowDown",
640
- "Home",
641
- "End",
642
- "F1",
643
- "F2",
644
- "F3",
645
- "F4",
646
- "F5",
647
- "F6",
648
- "F7",
649
- "F8",
650
- "F9",
651
- "F10",
652
- "F11",
653
- "F12",
654
- ].includes(key)
655
- ) {
656
- return;
657
- }
790
+ const ignored = [
791
+ "Shift","Control","Alt","Meta","CapsLock","Tab","Escape",
792
+ "ArrowLeft","ArrowRight","ArrowUp","ArrowDown","Home","End",
793
+ "F1","F2","F3","F4","F5","F6","F7","F8","F9","F10","F11","F12",
794
+ ];
795
+ if (ignored.includes(key)) return;
658
796
 
659
- let newBuffer = buffer;
797
+ let newBuf = buffer, newCursor = start;
660
798
 
661
799
  if (key === "Backspace") {
662
- if (start === end && start > 0) {
663
- newBuffer = buffer.slice(0, start - 1) + buffer.slice(start);
664
- } else if (start !== end) {
665
- newBuffer = buffer.slice(0, start) + buffer.slice(end);
666
- }
667
-
668
- event.preventDefault();
669
- this.setCurrentBuffer(newBuffer);
670
- this.updateDisplayedValue(false);
671
-
672
- const newCursorPos = start === end ? Math.max(0, start - 1) : start;
673
- setTimeout(() => {
674
- input.setSelectionRange(newCursorPos, newCursorPos);
675
- input.focus();
676
- }, 0);
800
+ if (start === end && start > 0) { newBuf = buffer.slice(0, start - 1) + buffer.slice(start); newCursor = start - 1; }
801
+ else if (start !== end) { newBuf = buffer.slice(0, start) + buffer.slice(end); }
677
802
  } else if (key === "Delete") {
678
- if (start === end && start < buffer.length) {
679
- newBuffer = buffer.slice(0, start) + buffer.slice(start + 1);
680
- } else if (start !== end) {
681
- newBuffer = buffer.slice(0, start) + buffer.slice(end);
682
- }
803
+ if (start === end && start < buffer.length) newBuf = buffer.slice(0, start) + buffer.slice(start + 1);
804
+ else if (start !== end) newBuf = buffer.slice(0, start) + buffer.slice(end);
805
+ newCursor = start;
806
+ } else if (key === "Enter" && input.tagName === "TEXTAREA") {
807
+ newBuf = buffer.slice(0, start) + "\n" + buffer.slice(end);
808
+ newCursor = start + 1;
809
+ } else if (key.length === 1) {
810
+ newBuf = buffer.slice(0, start) + key + buffer.slice(end);
811
+ newCursor = start + 1;
812
+ } else { return; }
683
813
 
684
- event.preventDefault();
685
- this.setCurrentBuffer(newBuffer);
686
- this.updateDisplayedValue(false);
814
+ event.preventDefault();
815
+ await this._secureStore.write(input, newBuf);
687
816
 
688
- setTimeout(() => {
689
- input.setSelectionRange(start, start);
690
- input.focus();
691
- }, 0);
692
- } else if (key === "Enter") {
693
- if (input.tagName === "TEXTAREA") {
694
- newBuffer = buffer.slice(0, start) + "\n" + buffer.slice(end);
817
+ this.isInternalUpdate = true;
818
+ input.value = this._isPasswordField(input)
819
+ ? this.maskChar.repeat(newBuf.length)
820
+ : newBuf;
821
+ this.isInternalUpdate = false;
695
822
 
696
- event.preventDefault();
697
- this.setCurrentBuffer(newBuffer);
698
- this.updateDisplayedValue(false);
823
+ setTimeout(() => { input.setSelectionRange(newCursor, newCursor); input.focus(); }, 0);
824
+ }
699
825
 
700
- const newCursorPos = start + 1;
701
- setTimeout(() => {
702
- input.setSelectionRange(newCursorPos, newCursorPos);
703
- input.focus();
704
- }, 0);
826
+ async _handleRealKeyboardInputAsync(input, event) {
827
+ if (!input || !event || this.isInternalUpdate) return;
828
+ // Autocomplete / browser autofill (not going through keydown)
829
+ if (event.inputType?.startsWith("insertReplacementText")) {
830
+ if (!this._isPasswordField(input)) {
831
+ await this._secureStore.write(input, input.value);
705
832
  }
706
- } else if (key.length === 1) {
707
- newBuffer = buffer.slice(0, start) + key + buffer.slice(end);
708
-
709
- event.preventDefault();
710
- this.setCurrentBuffer(newBuffer);
711
- this.updateDisplayedValue(false);
712
-
713
- const newCursorPos = start + key.length;
714
- setTimeout(() => {
715
- input.setSelectionRange(newCursorPos, newCursorPos);
716
- input.focus();
717
- }, 0);
718
833
  }
719
834
  }
720
835
 
721
- // FIX BUG-4: handleRealKeyboardInput ทำหน้าที่ sync buffer เมื่อ autocomplete / IME
722
- handleRealKeyboardInput(input, event) {
723
- if (!input || !event) return;
724
-
725
- // กรณี autocomplete, IME composition, หรือ browser fill ที่ไม่ผ่าน keydown
726
- // sync จาก actual value กลับมาใส่ buffer
727
- if (
728
- event.inputType &&
729
- event.inputType.startsWith("insertReplacementText")
730
- ) {
731
- this.inputPlaintextBuffers.set(input, input.value);
732
- return;
733
- }
836
+ //scramble
837
+
838
+ unscrambleKeys() { this.render(); }
734
839
 
735
- // IME composition end
736
- if (
737
- event.isComposing === false &&
738
- event.inputType === "insertCompositionText"
739
- ) {
740
- this.inputPlaintextBuffers.set(input, input.value);
840
+ shuffleArray(arr) {
841
+ for (let i = arr.length - 1; i > 0; i--) {
842
+ const j = Math.floor(Math.random() * (i + 1));
843
+ [arr[i], arr[j]] = [arr[j], arr[i]];
741
844
  }
742
845
  }
743
846
 
744
- unscrambleKeys() {
745
- this.render();
847
+ _scramble(selector, chars) {
848
+ this._qAll(selector).forEach((k, i) => {
849
+ if (i < chars.length) { k.textContent = chars[i]; k.dataset.key = chars[i]; }
850
+ });
746
851
  }
747
852
 
748
- // FIX BUG-1: รวม toggleCapsLock และ toggleShift ให้มี updateKeyContent เดียว
749
- // ลบเมธอด updateKeyContent ที่ซ้ำกันออก และใช้เมธอดเดียวที่รับ parameter
853
+ scrambleKeyboard() {
854
+ const c = "1234567890-=+%/*.()".split(""); this.shuffleArray(c);
855
+ this._scramble(".key:not([data-key='std']):not([data-key='scr']):not([data-key='backspace']):not([data-key='Backspace'])", c);
856
+ }
857
+ scrambleEnglishKeys() {
858
+ const c = "abcdefghijklmnopqrstuvwxyz/.,';\\][`1234567890-=".split(""); this.shuffleArray(c);
859
+ this._scramble(".key:not([data-key='scrambled']):not([data-key='std']):not([data-key='scr']):not([data-key=' ']):not([data-key='Backspace']):not([data-key='Caps 🄰']):not([data-key='Shift ⇧']):not([data-key='Enter']):not([data-key='Tab ↹'])", c);
860
+ }
861
+ scrambleThaiKeys() {
862
+ const c = '_ๅ/-ภถุึคตจขชๆไำพะัีรนยบลฃฟหกดเ้่าสวงผปแอิืทมใฝ%+๑๒๓๔ู฿๕๖๗๘๙๐"ฎฑธํ๊ณฯญฐ,ฅฤฆฏโฌ็๋ษศซ.ฦฬฒ?์ฺฮฉ)('.split(""); this.shuffleArray(c);
863
+ this._scramble(".key:not([data-key='scrambled']):not([data-key='scr']):not([data-key='Backspace']):not([data-key='Caps 🄰']):not([data-key='Shift ⇧']):not([data-key='Enter']):not([data-key=' ']):not([data-key='Tab ↹'])", c);
864
+ }
865
+ scrambleSymbols() {
866
+ const c = "@#$%^&*()_+~`{}|\\:'<>?/[]±§¶!€£¥¢©®™℅‰†".split(""); this.shuffleArray(c);
867
+ this._scramble(".key:not([data-key='std']):not([data-key='scr']):not([data-key='backspace']):not([data-key='Backspace'])", c);
868
+ }
869
+ scrambleFullLayoutKeys() {
870
+ const c = "abcdefghijklmnopqrstuvwxyz1234567890;'\\/][`,.-=".split(""); this.shuffleArray(c);
871
+ this._scramble(".key:not(.concat-keys):not([data-key='scrambled']):not([data-key='std']):not([data-key='scr']):not([data-key=' ']):not([data-key='Backspace']):not([data-key='Caps 🄰']):not([data-key='Shift ⇧']):not([data-key='Enter']):not([data-key='Tab ↹'])", c);
872
+ }
873
+
874
+ //caps/shift
750
875
  _updateAllKeyContent(isActive) {
751
- const layoutMap = {
752
- th: this.ThaiAlphabetShift,
753
- en: this.EngAlphabetShift,
754
- full: this.FullAlphabetShift,
755
- };
756
- const layout = layoutMap[this.currentLayout];
757
-
758
- document.querySelectorAll(".key").forEach((key) => {
759
- const isLetter =
760
- key.dataset.key &&
761
- key.dataset.key.length === 1 &&
762
- /[a-zA-Zก-๙]/.test(key.dataset.key);
763
-
764
- if (isLetter) {
765
- key.textContent = isActive
766
- ? key.dataset.key.toUpperCase()
767
- : key.dataset.key.toLowerCase();
768
- }
876
+ const layoutMap = {
877
+ th: this.ThaiAlphabetShift,
878
+ en: this.EngAlphabetShift,
879
+ full: this.FullAlphabetShift
880
+ };
769
881
 
770
- if (!layout) return;
882
+ const layout = layoutMap[this.currentLayout];
771
883
 
772
- const currentChar = key.textContent.trim();
773
- if (isActive && layout[currentChar]) {
774
- key.textContent = layout[currentChar];
775
- key.dataset.key = layout[currentChar];
776
- } else if (!isActive && Object.values(layout).includes(currentChar)) {
777
- const originalKey = Object.keys(layout).find(
778
- (k) => layout[k] === currentChar,
779
- );
780
- if (originalKey) {
781
- key.textContent = originalKey;
782
- key.dataset.key = originalKey;
783
- }
884
+ this._qAll(".key").forEach((key) => {
885
+ const dk = key.dataset.originalKey || key.dataset.key;
886
+ if (!dk) return;
887
+
888
+ key.dataset.originalKey = dk;
889
+
890
+ if (this.currentLayout === "full" && key.classList.contains("concat-keys")) {
891
+ return;
892
+ }
893
+
894
+ if (dk.length === 1 && /[a-zA-Z]/.test(dk)) {
895
+ key.textContent = isActive ? dk.toUpperCase() : dk.toLowerCase();
896
+ }
897
+
898
+ if (!layout) return;
899
+
900
+ const cur = key.textContent.trim();
901
+
902
+ if (isActive && layout[cur]) {
903
+ key.textContent = layout[cur];
904
+ key.dataset.key = layout[cur];
905
+ } else if (!isActive) {
906
+ const orig = Object.keys(layout).find((k) => layout[k] === cur);
907
+ if (orig) {
908
+ key.textContent = orig;
909
+ key.dataset.key = orig;
784
910
  }
785
- });
786
- }
911
+ }
912
+ });
913
+ }
787
914
 
788
915
  toggleCapsLock() {
789
916
  this.capsLockActive = !this.capsLockActive;
790
-
791
- document.querySelectorAll('.key[data-key="Caps 🄰"]').forEach((key) => {
792
- key.classList.toggle("active", this.capsLockActive);
793
- key.classList.toggle("bg-gray-400", this.capsLockActive);
917
+ this._qAll('.key[data-key="Caps 🄰"]').forEach((k) => {
918
+ k.classList.toggle("active", this.capsLockActive);
919
+ k.classList.toggle("bg-gray-400", this.capsLockActive);
794
920
  });
795
-
796
- // FIX BUG-1: ใช้ _updateAllKeyContent แทน updateKeyContent ที่ซ้ำกัน
797
921
  this._updateAllKeyContent(this.capsLockActive);
798
922
  }
799
923
 
800
924
  toggleShift() {
801
- if (this.capsLockActive) {
802
- return this.toggleCapsLock();
803
- }
804
-
925
+ if (this.capsLockActive) return this.toggleCapsLock();
805
926
  this.shiftActive = !this.shiftActive;
806
-
807
- document.querySelectorAll('.key[data-key="Shift ⇧"]').forEach((key) => {
808
- key.classList.toggle("active", this.shiftActive);
809
- key.classList.toggle("bg-gray-400", this.shiftActive);
927
+ this._qAll('.key[data-key="Shift ⇧"]').forEach((k) => {
928
+ k.classList.toggle("active", this.shiftActive);
929
+ k.classList.toggle("bg-gray-400", this.shiftActive);
810
930
  });
811
-
812
- // FIX BUG-1: ใช้ _updateAllKeyContent แทน updateKeyContent ที่ซ้ำกัน
813
931
  this._updateAllKeyContent(this.shiftActive);
814
932
  }
815
933
 
934
+ //shift maps
816
935
  EngAlphabetShift = {
817
- "`": "~",
818
- 1: "!",
819
- 2: "@",
820
- 3: "#",
821
- 4: "$",
822
- 5: "%",
823
- 6: "^",
824
- 7: "&",
825
- 8: "*",
826
- 9: "(",
827
- 0: ")",
828
- "-": "_",
829
- "=": "+",
830
- "[": "{",
831
- "]": "}",
832
- "\\": "|",
833
- ";": ":",
834
- "'": '"',
835
- ",": "<",
836
- ".": ">",
837
- "/": "?",
936
+ "`":"~","1":"!","2":"@","3":"#","4":"$","5":"%","6":"^","7":"&","8":"*",
937
+ "9":"(","0":")","-":"_","=":"+","[":"{","]":"}","\\":"|",";":":","'":'"',",":"<",".":">","/":"?",
838
938
  };
839
-
840
939
  ThaiAlphabetShift = {
841
- _: "%",
842
- ๅ: "+",
843
- "/": "",
844
- "-": "๒",
845
- ภ: "๓",
846
- ถ: "๔",
847
- "ุ": "ู",
848
- "ึ": "฿",
849
- ค: "๕",
850
- ต: "๖",
851
- จ: "๗",
852
- ข: "๘",
853
- ช: "๙",
854
- ๆ: "๐",
855
- ไ: '"',
856
- ำ: "ฎ",
857
- พ: "ฑ",
858
- ะ: "ธ",
859
- "ั": "ํ",
860
- "ี": "๋",
861
- ร: "ณ",
862
- น: "ฯ",
863
- ย: "ญ",
864
- บ: "ฐ",
865
- ล: ",",
866
- ฃ: "ฅ",
867
- ฟ: "ฤ",
868
- ห: "ฆ",
869
- ก: "ฏ",
870
- ด: "โ",
871
- เ: "ฌ",
872
- "้": "็",
873
- "่": "๋",
874
- า: "ษ",
875
- ส: "ศ",
876
- ว: "ซ",
877
- ง: ".",
878
- ผ: "(",
879
- ป: ")",
880
- แ: "ฉ",
881
- อ: "ฮ",
882
- "ิ": "ฺ",
883
- "ื": "์",
884
- ท: "?",
885
- ม: "ฒ",
886
- ใ: "ฬ",
887
- ฝ: "ฦ",
940
+ _:"%",ๅ:"+","/":"๑","-":"๒",ภ:"๓",ถ:"๔","ุ":"ู","ึ":"฿",ค:"๕",ต:"๖",จ:"๗",ข:"๘",ช:"๙",ๆ:"๐",ไ:'"',
941
+ ำ:"",พ:"ฑ",ะ:"ธ","ั":"ํ","ี":"๋",ร:"ณ",น:"ฯ",ย:"ญ",บ:"ฐ",ล:",",ฃ:"ฅ",ฟ:"ฤ",ห:"ฆ",ก:"ฏ",ด:"โ",เ:"ฌ",
942
+ "":"","่":"๋",า:"ษ",ส:"ศ",ว:"ซ",ง:".",ผ:"(",ป:")",แ:"ฉ",อ:"ฮ","ิ":"ฺ","ื":"์",ท:"?",ม:"ฒ",ใ:"ฬ",ฝ:"ฦ",
888
943
  };
889
-
890
944
  FullAlphabetShift = {
891
- "[": "{",
892
- "]": "}",
893
- "\\": "|",
894
- ";": ":",
895
- "'": '"',
896
- ",": "<",
897
- ".": ">",
898
- "/": "?",
945
+ "`":"~","1":"!","2":"@","3":"#","4":"$","5":"%","6":"^","7":"&","8":"*",
946
+ "9":"(","0":")","-":"_","=":"+","[":"{","]":"}","\\":"|",";":":","'":'"',",":"<",".":">","/":"?",
899
947
  };
900
948
 
901
- toggle() {
902
- this.isVisible = !this.isVisible;
903
- this.render();
904
- }
949
+ //layout
950
+ toggle() { this.isVisible = !this.isVisible; this.render(); }
905
951
 
906
- // FIX WARN-1: reset isScrambled และ shift/caps เมื่อเปลี่ยน layout
907
952
  changeLayout(layout) {
908
953
  this.currentLayout = layout;
909
- this.isScrambled = false;
910
- this.shiftActive = false;
911
- this.capsLockActive = false;
954
+ this.isScrambled = false; this.shiftActive = false; this.capsLockActive = false;
912
955
  this.render();
913
956
  }
914
957
 
915
- shuffleArray(array) {
916
- for (let i = array.length - 1; i > 0; i--) {
917
- const j = Math.floor(Math.random() * (i + 1));
918
- [array[i], array[j]] = [array[j], array[i]];
919
- }
920
- }
921
-
922
- // FIX BUG-7: แก้ CSS selector — เพิ่มวงเล็บปิด ) และ fix data-key case
923
- scrambleKeyboard() {
924
- const keys = document.querySelectorAll(
925
- ":not([data-key='std']):not([data-key='scr']).key:not([data-key='backspace']):not([data-key='Backspace'])",
926
- );
927
- const numbers = "1234567890-=+%/*.()".split("");
928
- this.shuffleArray(numbers);
929
- keys.forEach((key, index) => {
930
- if (index < numbers.length) {
931
- key.textContent = numbers[index];
932
- key.dataset.key = numbers[index];
933
- }
934
- });
935
- }
936
-
937
- scrambleEnglishKeys() {
938
- const keys = document.querySelectorAll(
939
- ".key:not([data-key='scrambled']):not([data-key='std']):not([data-key='scr']):not([data-key=' ']):not([data-key='Backspace']):not([data-key='Caps 🄰']):not([data-key='Shift ⇧']):not([data-key='Enter']):not([data-key='Tab ↹'])",
940
- );
941
- const englishAlphabet =
942
- "abcdefghijklmnopqrstuvwxyz/.,';\\][`1234567890-=".split("");
943
- this.shuffleArray(englishAlphabet);
944
- keys.forEach((key, index) => {
945
- if (index < englishAlphabet.length) {
946
- key.textContent = englishAlphabet[index];
947
- key.dataset.key = englishAlphabet[index];
948
- }
949
- });
950
- }
951
-
952
- scrambleThaiKeys() {
953
- const keys = document.querySelectorAll(
954
- ".key:not([data-key='scrambled']):not([data-key='scr']):not([data-key='Backspace']):not([data-key='Caps 🄰']):not([data-key='Shift ⇧']):not([data-key='Enter']):not([data-key=' ']):not([data-key='Tab ↹'])",
955
- );
956
- const ThaiAlphabet =
957
- '_ๅ/-ภถุึคตจขชๆไำพะัีรนยบลฃฟหกดเ้่าสวงผปแอิืทมใฝ%+๑๒๓๔ู฿๕๖๗๘๙๐"ฎฑธํ๊ณฯญฐ,ฅฤฆฏโฌ็๋ษศซ.ฦฬฒ?์ฺฮฉ)('.split(
958
- "",
959
- );
960
- this.shuffleArray(ThaiAlphabet);
961
- keys.forEach((key, index) => {
962
- if (index < ThaiAlphabet.length) {
963
- key.textContent = ThaiAlphabet[index];
964
- key.dataset.key = ThaiAlphabet[index];
965
- }
966
- });
967
- }
968
-
969
- // FIX BUG-7: แก้ CSS selector ของ scrambleSymbols
970
- scrambleSymbols() {
971
- const keys = document.querySelectorAll(
972
- ":not([data-key='std']):not([data-key='scr']).key:not([data-key='backspace']):not([data-key='Backspace'])",
973
- );
974
- const symbols = "@#$%^&*()_+~`{}|\\:'<>?/[]±§¶!€£¥¢©®™℅‰†".split("");
975
- this.shuffleArray(symbols);
976
- keys.forEach((key, index) => {
977
- if (index < symbols.length) {
978
- key.textContent = symbols[index];
979
- key.dataset.key = symbols[index];
980
- }
981
- });
982
- }
983
-
984
- scrambleFullLayoutKeys() {
985
- const keys = document.querySelectorAll(
986
- ".key:not(.concat-keys):not([data-key='scrambled']):not([data-key='std']):not([data-key='scr']):not([data-key=' ']):not([data-key='Backspace']):not([data-key='Caps 🄰']):not([data-key='Shift ⇧']):not([data-key='Enter']):not([data-key='Tab ↹'])",
987
- );
988
- const englishChars =
989
- "abcdefghijklmnopqrstuvwxyz1234567890;'\\/][`,.-=".split("");
990
- this.shuffleArray(englishChars);
991
- keys.forEach((key, index) => {
992
- if (index < englishChars.length) {
993
- key.textContent = englishChars[index];
994
- key.dataset.key = englishChars[index];
995
- }
996
- });
997
- }
998
-
999
- // FIX BUG-2: เก็บ bound reference และ cleanup ให้ถูกต้อง
958
+ //drag
1000
959
  startDrag(event) {
1001
960
  this.isDragging = true;
1002
- this.offsetX =
1003
- event.clientX - document.getElementById("keyboard").offsetLeft;
1004
- this.offsetY =
1005
- event.clientY - document.getElementById("keyboard").offsetTop;
1006
-
961
+ const kb = this._q("#keyboard");
962
+ this.offsetX = event.clientX - (kb?.offsetLeft ?? 0);
963
+ this.offsetY = event.clientY - (kb?.offsetTop ?? 0);
1007
964
  document.addEventListener("mousemove", this._boundDrag);
1008
965
  document.addEventListener("mouseup", this._boundStopDrag);
1009
966
  }
1010
-
1011
967
  stopDrag() {
1012
968
  this.isDragging = false;
1013
969
  document.removeEventListener("mousemove", this._boundDrag);
1014
970
  document.removeEventListener("mouseup", this._boundStopDrag);
1015
971
  }
1016
-
1017
972
  drag(event) {
1018
- if (this.isDragging) {
1019
- const keyboard = document.getElementById("keyboard");
1020
- keyboard.style.left = `${event.clientX - this.offsetX}px`;
1021
- keyboard.style.top = `${event.clientY - this.offsetY}px`;
1022
- }
973
+ if (!this.isDragging) return;
974
+ const kb = this._q("#keyboard");
975
+ if (!kb) return;
976
+ kb.style.left = `${event.clientX - this.offsetX}px`;
977
+ kb.style.top = `${event.clientY - this.offsetY}px`;
1023
978
  }
1024
- }
979
+ }