buninu 0.6.0 → 0.7.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.
Files changed (190) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/apps/jsgotty/README.en.md +4 -0
  3. package/apps/jsgotty/README.md +4 -0
  4. package/apps/jsgotty/package-lock.json +2 -2
  5. package/apps/jsgotty/package.json +1 -1
  6. package/apps/jsgotty/static/css/index.css +7 -0
  7. package/apps/jsgotty/static/js/gotty.js +12 -4
  8. package/apps/jsgotty/static/js/kitty.js +115 -7
  9. package/apps/jsgotty/tests/manual/zoom-collector.cjs +55 -0
  10. package/apps/jsgotty/tests/manual/zoom-debug.js +39 -0
  11. package/apps/jsgotty/tests/touch-drag-browser.test.js +106 -0
  12. package/apps/jsgotty/tests/wheel-browser.test.js +60 -0
  13. package/apps/jsmdcui/CHANGELOG.md +44 -0
  14. package/apps/jsmdcui/package.json +1 -1
  15. package/apps/jsmdcui/src/highlight/go-regexp.js +83 -0
  16. package/apps/jsmdcui/src/highlight/highlighter.js +49 -62
  17. package/apps/jsmdcui/src/highlight/parser.js +8 -5
  18. package/apps/jsmdcui/tests/go-regexp.test.js +98 -0
  19. package/apps/jsmdcui/tests/heading-ids.test.js +21 -16
  20. package/apps/jsmdcui/tests/highlight-regions.test.js +73 -0
  21. package/apps/jsmdcui/tests/id-collision.test.js +32 -9
  22. package/apps/jsmdcui/tests/markdown-behavior.mjs +9 -0
  23. package/apps/jsmdcui/tests/syntax-parity/NORMAL-HTML.md +46 -0
  24. package/apps/jsmdcui/tests/syntax-parity/README.md +85 -0
  25. package/apps/jsmdcui/tests/syntax-parity/REPORT.md +328 -0
  26. package/apps/jsmdcui/tests/syntax-parity/compare.mjs +75 -0
  27. package/apps/jsmdcui/tests/syntax-parity/go.mod +5 -0
  28. package/apps/jsmdcui/tests/syntax-parity/normal-html-report.json +619 -0
  29. package/apps/jsmdcui/tests/syntax-parity/normal-html.html +9 -0
  30. package/apps/jsmdcui/tests/syntax-parity/oracle.go +49 -0
  31. package/apps/jsmdcui/tests/syntax-parity/report.json +2174 -0
  32. package/apps/jsmdcui/tests/syntax-parity/samples/PowerShell.txt +39 -0
  33. package/apps/jsmdcui/tests/syntax-parity/samples/ada.txt +25 -0
  34. package/apps/jsmdcui/tests/syntax-parity/samples/apacheconf.txt +49 -0
  35. package/apps/jsmdcui/tests/syntax-parity/samples/arduino.txt +38 -0
  36. package/apps/jsmdcui/tests/syntax-parity/samples/asciidoc.txt +33 -0
  37. package/apps/jsmdcui/tests/syntax-parity/samples/asm.txt +77 -0
  38. package/apps/jsmdcui/tests/syntax-parity/samples/ats.txt +48 -0
  39. package/apps/jsmdcui/tests/syntax-parity/samples/awk.txt +28 -0
  40. package/apps/jsmdcui/tests/syntax-parity/samples/b.txt +27 -0
  41. package/apps/jsmdcui/tests/syntax-parity/samples/bat.txt +39 -0
  42. package/apps/jsmdcui/tests/syntax-parity/samples/c.txt +34 -0
  43. package/apps/jsmdcui/tests/syntax-parity/samples/caddyfile.txt +14 -0
  44. package/apps/jsmdcui/tests/syntax-parity/samples/cake.txt +7 -0
  45. package/apps/jsmdcui/tests/syntax-parity/samples/clojure.txt +18 -0
  46. package/apps/jsmdcui/tests/syntax-parity/samples/cmake.txt +20 -0
  47. package/apps/jsmdcui/tests/syntax-parity/samples/coffeescript.txt +32 -0
  48. package/apps/jsmdcui/tests/syntax-parity/samples/colortest.txt +19 -0
  49. package/apps/jsmdcui/tests/syntax-parity/samples/conky.txt +12 -0
  50. package/apps/jsmdcui/tests/syntax-parity/samples/cpp.txt +46 -0
  51. package/apps/jsmdcui/tests/syntax-parity/samples/crontab.txt +16 -0
  52. package/apps/jsmdcui/tests/syntax-parity/samples/crystal.txt +28 -0
  53. package/apps/jsmdcui/tests/syntax-parity/samples/csharp.txt +28 -0
  54. package/apps/jsmdcui/tests/syntax-parity/samples/css.txt +19 -0
  55. package/apps/jsmdcui/tests/syntax-parity/samples/csx.txt +7 -0
  56. package/apps/jsmdcui/tests/syntax-parity/samples/cuda.txt +37 -0
  57. package/apps/jsmdcui/tests/syntax-parity/samples/cython.txt +22 -0
  58. package/apps/jsmdcui/tests/syntax-parity/samples/d.txt +52 -0
  59. package/apps/jsmdcui/tests/syntax-parity/samples/dart.txt +27 -0
  60. package/apps/jsmdcui/tests/syntax-parity/samples/default.txt +8 -0
  61. package/apps/jsmdcui/tests/syntax-parity/samples/dockerfile.txt +10 -0
  62. package/apps/jsmdcui/tests/syntax-parity/samples/dot.txt +15 -0
  63. package/apps/jsmdcui/tests/syntax-parity/samples/elixir.txt +23 -0
  64. package/apps/jsmdcui/tests/syntax-parity/samples/elm.txt +20 -0
  65. package/apps/jsmdcui/tests/syntax-parity/samples/erb.txt +32 -0
  66. package/apps/jsmdcui/tests/syntax-parity/samples/erlang.txt +23 -0
  67. package/apps/jsmdcui/tests/syntax-parity/samples/fish.txt +20 -0
  68. package/apps/jsmdcui/tests/syntax-parity/samples/forth.txt +17 -0
  69. package/apps/jsmdcui/tests/syntax-parity/samples/fortran.txt +40 -0
  70. package/apps/jsmdcui/tests/syntax-parity/samples/freebsd-kernel.txt +10 -0
  71. package/apps/jsmdcui/tests/syntax-parity/samples/fsharp.txt +27 -0
  72. package/apps/jsmdcui/tests/syntax-parity/samples/gdscript.txt +26 -0
  73. package/apps/jsmdcui/tests/syntax-parity/samples/gemini.txt +11 -0
  74. package/apps/jsmdcui/tests/syntax-parity/samples/gentoo-ebuild.txt +28 -0
  75. package/apps/jsmdcui/tests/syntax-parity/samples/gentoo-etc-portage.txt +14 -0
  76. package/apps/jsmdcui/tests/syntax-parity/samples/git-commit.txt +19 -0
  77. package/apps/jsmdcui/tests/syntax-parity/samples/git-config.txt +11 -0
  78. package/apps/jsmdcui/tests/syntax-parity/samples/git-rebase-todo.txt +10 -0
  79. package/apps/jsmdcui/tests/syntax-parity/samples/gleam.txt +44 -0
  80. package/apps/jsmdcui/tests/syntax-parity/samples/glsl.txt +18 -0
  81. package/apps/jsmdcui/tests/syntax-parity/samples/gnuplot.txt +11 -0
  82. package/apps/jsmdcui/tests/syntax-parity/samples/go.txt +33 -0
  83. package/apps/jsmdcui/tests/syntax-parity/samples/godoc.txt +9 -0
  84. package/apps/jsmdcui/tests/syntax-parity/samples/golo.txt +50 -0
  85. package/apps/jsmdcui/tests/syntax-parity/samples/gomod.txt +16 -0
  86. package/apps/jsmdcui/tests/syntax-parity/samples/graphql.txt +20 -0
  87. package/apps/jsmdcui/tests/syntax-parity/samples/groff.txt +18 -0
  88. package/apps/jsmdcui/tests/syntax-parity/samples/groovy.txt +42 -0
  89. package/apps/jsmdcui/tests/syntax-parity/samples/haml.txt +16 -0
  90. package/apps/jsmdcui/tests/syntax-parity/samples/hare.txt +31 -0
  91. package/apps/jsmdcui/tests/syntax-parity/samples/haskell.txt +26 -0
  92. package/apps/jsmdcui/tests/syntax-parity/samples/hc.txt +28 -0
  93. package/apps/jsmdcui/tests/syntax-parity/samples/html.txt +26 -0
  94. package/apps/jsmdcui/tests/syntax-parity/samples/html4.txt +20 -0
  95. package/apps/jsmdcui/tests/syntax-parity/samples/html5.txt +20 -0
  96. package/apps/jsmdcui/tests/syntax-parity/samples/ini.txt +16 -0
  97. package/apps/jsmdcui/tests/syntax-parity/samples/inputrc.txt +14 -0
  98. package/apps/jsmdcui/tests/syntax-parity/samples/java.txt +18 -0
  99. package/apps/jsmdcui/tests/syntax-parity/samples/javascript.txt +41 -0
  100. package/apps/jsmdcui/tests/syntax-parity/samples/jinja2.txt +13 -0
  101. package/apps/jsmdcui/tests/syntax-parity/samples/json.txt +21 -0
  102. package/apps/jsmdcui/tests/syntax-parity/samples/jsonnet.txt +28 -0
  103. package/apps/jsmdcui/tests/syntax-parity/samples/julia.txt +24 -0
  104. package/apps/jsmdcui/tests/syntax-parity/samples/justfile.txt +26 -0
  105. package/apps/jsmdcui/tests/syntax-parity/samples/keymap.txt +15 -0
  106. package/apps/jsmdcui/tests/syntax-parity/samples/kickstart.txt +16 -0
  107. package/apps/jsmdcui/tests/syntax-parity/samples/kotlin.txt +30 -0
  108. package/apps/jsmdcui/tests/syntax-parity/samples/kvlang.txt +37 -0
  109. package/apps/jsmdcui/tests/syntax-parity/samples/ledger.txt +13 -0
  110. package/apps/jsmdcui/tests/syntax-parity/samples/lfe.txt +17 -0
  111. package/apps/jsmdcui/tests/syntax-parity/samples/lilypond.txt +15 -0
  112. package/apps/jsmdcui/tests/syntax-parity/samples/lisp.txt +17 -0
  113. package/apps/jsmdcui/tests/syntax-parity/samples/log.txt +45 -0
  114. package/apps/jsmdcui/tests/syntax-parity/samples/lua.txt +48 -0
  115. package/apps/jsmdcui/tests/syntax-parity/samples/mail.txt +17 -0
  116. package/apps/jsmdcui/tests/syntax-parity/samples/makefile.txt +25 -0
  117. package/apps/jsmdcui/tests/syntax-parity/samples/man.txt +12 -0
  118. package/apps/jsmdcui/tests/syntax-parity/samples/markdown.txt +21 -0
  119. package/apps/jsmdcui/tests/syntax-parity/samples/mc.txt +11 -0
  120. package/apps/jsmdcui/tests/syntax-parity/samples/meson.txt +29 -0
  121. package/apps/jsmdcui/tests/syntax-parity/samples/micro.txt +25 -0
  122. package/apps/jsmdcui/tests/syntax-parity/samples/mpdconf.txt +13 -0
  123. package/apps/jsmdcui/tests/syntax-parity/samples/msbuild.txt +7 -0
  124. package/apps/jsmdcui/tests/syntax-parity/samples/nanorc.txt +16 -0
  125. package/apps/jsmdcui/tests/syntax-parity/samples/nftables.txt +22 -0
  126. package/apps/jsmdcui/tests/syntax-parity/samples/nginx.txt +17 -0
  127. package/apps/jsmdcui/tests/syntax-parity/samples/nim.txt +23 -0
  128. package/apps/jsmdcui/tests/syntax-parity/samples/nix.txt +15 -0
  129. package/apps/jsmdcui/tests/syntax-parity/samples/nu.txt +92 -0
  130. package/apps/jsmdcui/tests/syntax-parity/samples/objc.txt +36 -0
  131. package/apps/jsmdcui/tests/syntax-parity/samples/ocaml.txt +25 -0
  132. package/apps/jsmdcui/tests/syntax-parity/samples/octave.txt +35 -0
  133. package/apps/jsmdcui/tests/syntax-parity/samples/odin.txt +35 -0
  134. package/apps/jsmdcui/tests/syntax-parity/samples/pascal.txt +22 -0
  135. package/apps/jsmdcui/tests/syntax-parity/samples/patch.txt +13 -0
  136. package/apps/jsmdcui/tests/syntax-parity/samples/peg.txt +16 -0
  137. package/apps/jsmdcui/tests/syntax-parity/samples/perl.txt +27 -0
  138. package/apps/jsmdcui/tests/syntax-parity/samples/php.txt +44 -0
  139. package/apps/jsmdcui/tests/syntax-parity/samples/pkg-config.txt +12 -0
  140. package/apps/jsmdcui/tests/syntax-parity/samples/po.txt +12 -0
  141. package/apps/jsmdcui/tests/syntax-parity/samples/pony.txt +29 -0
  142. package/apps/jsmdcui/tests/syntax-parity/samples/pov.txt +17 -0
  143. package/apps/jsmdcui/tests/syntax-parity/samples/privoxy-action.txt +14 -0
  144. package/apps/jsmdcui/tests/syntax-parity/samples/privoxy-config.txt +10 -0
  145. package/apps/jsmdcui/tests/syntax-parity/samples/privoxy-filter.txt +12 -0
  146. package/apps/jsmdcui/tests/syntax-parity/samples/proto.txt +22 -0
  147. package/apps/jsmdcui/tests/syntax-parity/samples/prql.txt +42 -0
  148. package/apps/jsmdcui/tests/syntax-parity/samples/puppet.txt +22 -0
  149. package/apps/jsmdcui/tests/syntax-parity/samples/python2.txt +24 -0
  150. package/apps/jsmdcui/tests/syntax-parity/samples/python3.txt +28 -0
  151. package/apps/jsmdcui/tests/syntax-parity/samples/r.txt +16 -0
  152. package/apps/jsmdcui/tests/syntax-parity/samples/raku.txt +19 -0
  153. package/apps/jsmdcui/tests/syntax-parity/samples/reST.txt +18 -0
  154. package/apps/jsmdcui/tests/syntax-parity/samples/renpy.txt +11 -0
  155. package/apps/jsmdcui/tests/syntax-parity/samples/rpmspec.txt +31 -0
  156. package/apps/jsmdcui/tests/syntax-parity/samples/ruby.txt +34 -0
  157. package/apps/jsmdcui/tests/syntax-parity/samples/rust.txt +27 -0
  158. package/apps/jsmdcui/tests/syntax-parity/samples/sage.txt +24 -0
  159. package/apps/jsmdcui/tests/syntax-parity/samples/scad.txt +23 -0
  160. package/apps/jsmdcui/tests/syntax-parity/samples/scala.txt +16 -0
  161. package/apps/jsmdcui/tests/syntax-parity/samples/sed.txt +12 -0
  162. package/apps/jsmdcui/tests/syntax-parity/samples/sh.txt +20 -0
  163. package/apps/jsmdcui/tests/syntax-parity/samples/sls.txt +15 -0
  164. package/apps/jsmdcui/tests/syntax-parity/samples/smalltalk.txt +28 -0
  165. package/apps/jsmdcui/tests/syntax-parity/samples/solidity.txt +26 -0
  166. package/apps/jsmdcui/tests/syntax-parity/samples/sql.txt +35 -0
  167. package/apps/jsmdcui/tests/syntax-parity/samples/stata.txt +32 -0
  168. package/apps/jsmdcui/tests/syntax-parity/samples/svelte.txt +10 -0
  169. package/apps/jsmdcui/tests/syntax-parity/samples/swift.txt +53 -0
  170. package/apps/jsmdcui/tests/syntax-parity/samples/systemd.txt +15 -0
  171. package/apps/jsmdcui/tests/syntax-parity/samples/tcl.txt +17 -0
  172. package/apps/jsmdcui/tests/syntax-parity/samples/terraform.txt +28 -0
  173. package/apps/jsmdcui/tests/syntax-parity/samples/tex.txt +15 -0
  174. package/apps/jsmdcui/tests/syntax-parity/samples/toml.txt +31 -0
  175. package/apps/jsmdcui/tests/syntax-parity/samples/twig.txt +29 -0
  176. package/apps/jsmdcui/tests/syntax-parity/samples/typescript.txt +32 -0
  177. package/apps/jsmdcui/tests/syntax-parity/samples/v.txt +47 -0
  178. package/apps/jsmdcui/tests/syntax-parity/samples/vala.txt +22 -0
  179. package/apps/jsmdcui/tests/syntax-parity/samples/verilog.txt +37 -0
  180. package/apps/jsmdcui/tests/syntax-parity/samples/vhdl.txt +37 -0
  181. package/apps/jsmdcui/tests/syntax-parity/samples/vi.txt +17 -0
  182. package/apps/jsmdcui/tests/syntax-parity/samples/vue.txt +29 -0
  183. package/apps/jsmdcui/tests/syntax-parity/samples/xml.txt +14 -0
  184. package/apps/jsmdcui/tests/syntax-parity/samples/xresources.txt +14 -0
  185. package/apps/jsmdcui/tests/syntax-parity/samples/yaml.txt +19 -0
  186. package/apps/jsmdcui/tests/syntax-parity/samples/yum.txt +12 -0
  187. package/apps/jsmdcui/tests/syntax-parity/samples/zig.txt +24 -0
  188. package/apps/jsmdcui/tests/syntax-parity/samples/zscript.txt +31 -0
  189. package/apps/jsmdcui/tests/syntax-parity/samples/zsh.txt +21 -0
  190. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,31 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.7.0 - 2026-10-26
4
+
5
+ - Sync jsgotty to 1.4.0:
6
+ * Add horizontal and diagonal scrolling through terminal mouse reporting,
7
+ including Shift-wheel horizontal scrolling and preserved wheel modifiers.
8
+ * Double-tap and hold the second tap to drag with the left mouse button when
9
+ application mouse reporting is enabled. Release the button on touch
10
+ cancellation, backgrounding or disconnect; ordinary swipes still scroll.
11
+ * Add Right click to the existing image long-press menu, sending the click
12
+ at the position where the menu was opened without removing the image.
13
+ * Prevent Android WebView from automatically zooming the terminal's hidden
14
+ input after native long-press paste, including at reduced viewport zoom.
15
+ Keep native paste, IME input and pinch zoom on the visible terminal.
16
+ - Sync jsmdcui to 0.19.4:
17
+ * Restore Dockerfile highlighting and improve compatibility with Micro's
18
+ syntax definitions by translating Go regexp flags and POSIX character
19
+ classes while preserving scoped flags.
20
+ * Fix nested HTML/XML highlighting regions so quoted attributes return to
21
+ their enclosing tags and unfinished attributes retain state across lines.
22
+ * Use iterative region scanning to support long minified lines without
23
+ recursive stack exhaustion or truncating highlighting.
24
+ * Guard against zero-width region cycles, cyclic syntax/state trees and
25
+ excessively nested regexp conversion. Disable failing rules individually
26
+ instead of losing an entire syntax definition; retain support for long
27
+ lines. These guards do not impose timeouts on individual JavaScript regexps.
28
+
3
29
  ## 0.6.0 - 2026-10-25
4
30
 
5
31
  - Sync jsgotty to 1.3.0:
@@ -349,3 +349,7 @@ printf '\033]52;c;%s\007' "$(printf 'Hello' | base64 | tr -d '\n')"
349
349
  Applications can send `ESC ] 52 ; c ; ? ST` to read and receive a base64-encoded OSC 52 reply on stdin.
350
350
 
351
351
  `jsgotty --client` **does not intercept OSC 52**: it forwards reads and writes unchanged for the outer terminal emulator to handle.
352
+
353
+ ### Touch dragging and right click
354
+
355
+ When the application enables mouse reporting, double-tap and hold the second tap to drag with the left button; lifting your finger releases it. Single-finger swipes still scroll. The existing image long-press menu now includes `Right click` below `Remove image`; it sends a right click at the position where the menu was opened. Pinch zoom and the existing remove-image gesture remain unchanged. To disable double-tap dragging, set `window.touchDoubleTapDrag = false` in the page JavaScript.
@@ -351,3 +351,7 @@ printf '\033]52;c;%s\007' "$(printf 'Hello' | base64 | tr -d '\n')"
351
351
  程式可送出 `ESC ] 52 ; c ; ? ST` 讀取,並從 stdin 接收 base64 編碼的 OSC 52 回覆。
352
352
 
353
353
  `jsgotty --client` **不攔截 OSC 52**,讀寫控制碼都原樣轉送,交給最外層真正的終端模擬器決定。
354
+
355
+ ### 觸控拖曳與右鍵
356
+
357
+ 在程式啟用滑鼠回報時,快速點兩下並按住第二下可拖曳左鍵,放開即釋放;單指滑動仍是捲動。既有圖片長按選單的 `Remove image` 下方新增 `Right click`,會在叫出選單的原始位置送出右鍵點擊。雙指縮放與原本的移除圖片入口不變。若要停用雙擊拖曳,可在網頁 JavaScript 設定 `window.touchDoubleTapDrag = false`。
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "jsgotty",
3
- "version": "1.3.0",
3
+ "version": "1.4.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "jsgotty",
9
- "version": "1.3.0",
9
+ "version": "1.4.0",
10
10
  "dependencies": {
11
11
  "node-addon-api": "^7.1.1",
12
12
  "ws": "^8.18.3"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jsgotty",
3
- "version": "1.3.0",
3
+ "version": "1.4.0",
4
4
  "description": "Share your terminal as a web application: A JavaScript rewrite of GoTTY",
5
5
  "license": "MIT",
6
6
  "bin": {
@@ -17,3 +17,10 @@ html, body, #terminal {
17
17
  #terminal, #terminal .xterm, #terminal .xterm-viewport, #terminal .xterm-screen {
18
18
  touch-action: manipulation;
19
19
  }
20
+
21
+ /* Chromium skips focused-editable auto-zoom when that element disallows
22
+ pinch zoom. Restrict only the hidden input, not the terminal canvas:
23
+ native paste/IME remain available and the visible terminal can still pinch. */
24
+ #terminal .xterm-helper-textarea {
25
+ touch-action: none;
26
+ }
@@ -15997,8 +15997,15 @@
15997
15997
  !1 === e._customWheelEventHandler(t)
15998
15998
  )
15999
15999
  return !1;
16000
- if (0 === e.viewport.getLinesScrolled(t))
16001
- return !1;
16000
+ // Preserve both axes of diagonal wheel/touch gestures.
16001
+ // CoreMouseService already encodes wheel actions 2/3 as 66/67.
16002
+ if (t.deltaX) e.coreMouseService.triggerMouseEvent({
16003
+ col: i.col, row: i.row, x: i.x, y: i.y,
16004
+ button: 4, action: t.deltaX < 0 ? 2 : 3,
16005
+ ctrl: t.ctrlKey, alt: t.altKey, shift: t.shiftKey,
16006
+ });
16007
+ if (0 === e.viewport.getLinesScrolled(t, true))
16008
+ return !!t.deltaX;
16002
16009
  ((s = t.deltaY < 0 ? 0 : 1), (r = 4));
16003
16010
  break;
16004
16011
  default:
@@ -16972,8 +16979,9 @@
16972
16979
  }
16973
16980
  return { bufferElements: s, cursorElement: i };
16974
16981
  }
16975
- getLinesScrolled(e) {
16976
- if (0 === e.deltaY || e.shiftKey) return 0;
16982
+ getLinesScrolled(e, mouseReporting = false) {
16983
+ // Shift belongs to the remote application while mouse reporting is active.
16984
+ if (0 === e.deltaY || (e.shiftKey && !mouseReporting)) return 0;
16977
16985
  let t = this._applyScrollModifier(e.deltaY, e);
16978
16986
  return (
16979
16987
  e.deltaMode === WheelEvent.DOM_DELTA_PIXEL
@@ -561,6 +561,26 @@
561
561
  state.renderBound = state.renderDisposables.length > 0;
562
562
  }
563
563
 
564
+ // Touch gestures report mouse input without DOM mousedown: xterm's DOM
565
+ // handler focuses its hidden textarea, which can zoom Android WebView.
566
+ function reportTouchMouse(type, point, button = 0) {
567
+ const core = terminal?.__gottyXterm?._core;
568
+ if (!core?.coreMouseService?.areMouseEventsActive) return;
569
+ const screen = core.screenElement;
570
+ if (!screen) return;
571
+ const rect = screen.getBoundingClientRect();
572
+ const coords = core._mouseService.getMouseReportCoords({
573
+ clientX: Math.max(rect.left, Math.min(rect.right - 1, point.clientX)),
574
+ clientY: Math.max(rect.top, Math.min(rect.bottom - 1, point.clientY)),
575
+ }, screen);
576
+ if (!coords) return;
577
+ core.coreMouseService.triggerMouseEvent({
578
+ ...coords, button,
579
+ action: type === "mousedown" ? 1 : type === "mousemove" ? 32 : 0,
580
+ ctrl: false, alt: false, shift: false,
581
+ });
582
+ }
583
+
564
584
  function dispatchSyntheticWheel(target, touch, deltaX, deltaY) {
565
585
  if (!target) {
566
586
  return;
@@ -597,22 +617,69 @@
597
617
  return screen || viewport || xterm || terminal;
598
618
  };
599
619
 
620
+ let lastTap = null;
621
+ let drag = null;
622
+ const mouse = (type, point) => reportTouchMouse(type, point);
623
+ const releaseDrag = () => {
624
+ if (drag) {
625
+ const point = drag;
626
+ drag = null;
627
+ mouse("mouseup", point);
628
+ }
629
+ };
630
+ const cancelGesture = () => {
631
+ releaseDrag();
632
+ lastTap = null;
633
+ state.touchScroll = null;
634
+ };
635
+ state.cancelTouchGesture = cancelGesture;
636
+ bindTarget.addEventListener("contextmenu", (event) => {
637
+ if (drag) { event.preventDefault(); event.stopImmediatePropagation(); }
638
+ }, true);
639
+ window.addEventListener("blur", cancelGesture);
640
+ document.addEventListener("visibilitychange", () => {
641
+ if (document.hidden) cancelGesture();
642
+ });
643
+
600
644
  bindTarget.addEventListener("touchstart", (event) => {
601
645
  if (event.touches.length !== 1) {
602
- state.touchScroll = null;
646
+ cancelGesture();
603
647
  return;
604
648
  }
605
649
  const touch = event.touches[0];
650
+ const reporting = terminal.__gottyXterm?._core?.coreMouseService?.areMouseEventsActive;
651
+ if (reporting && window.touchDoubleTapDrag !== false && lastTap &&
652
+ performance.now() - lastTap.time < 350 &&
653
+ Math.hypot(touch.clientX - lastTap.x, touch.clientY - lastTap.y) < 24) {
654
+ lastTap = null;
655
+ state.touchScroll = null;
656
+ drag = { identifier: touch.identifier, clientX: touch.clientX, clientY: touch.clientY };
657
+ // Cancel compatibility mouse events: this is the second press, not a third click.
658
+ event.preventDefault();
659
+ mouse("mousedown", drag);
660
+ return;
661
+ }
662
+ lastTap = null;
606
663
  state.touchScroll = {
664
+ startX: touch.clientX, startY: touch.clientY, startTime: performance.now(), moved: false,
607
665
  identifier: touch.identifier,
608
666
  lastX: touch.clientX,
609
667
  lastY: touch.clientY,
610
668
  residualX: 0,
611
669
  residualY: 0
612
670
  };
613
- }, { passive: true });
671
+ }, { passive: false });
614
672
 
615
673
  bindTarget.addEventListener("touchmove", (event) => {
674
+ if (drag) {
675
+ const touch = Array.from(event.touches).find(t => t.identifier === drag.identifier);
676
+ if (!touch || event.touches.length !== 1) { cancelGesture(); return; }
677
+ event.preventDefault();
678
+ drag.clientX = touch.clientX;
679
+ drag.clientY = touch.clientY;
680
+ mouse("mousemove", drag);
681
+ return;
682
+ }
616
683
  if (!state.touchScroll) {
617
684
  return;
618
685
  }
@@ -621,6 +688,8 @@
621
688
  return;
622
689
  }
623
690
 
691
+ if (Math.hypot(touch.clientX - state.touchScroll.startX,
692
+ touch.clientY - state.touchScroll.startY) > 8) state.touchScroll.moved = true;
624
693
  const deltaX = state.touchScroll.lastX - touch.clientX;
625
694
  const deltaY = state.touchScroll.lastY - touch.clientY;
626
695
  state.touchScroll.lastX = touch.clientX;
@@ -653,11 +722,20 @@
653
722
  state.touchScroll.residualY -= ticksY * threshold * stepY;
654
723
  }, { passive: false });
655
724
 
656
- const clearTouchScroll = () => {
725
+ bindTarget.addEventListener("touchend", (event) => {
726
+ if (drag) {
727
+ event.preventDefault();
728
+ releaseDrag();
729
+ } else {
730
+ const scroll = state.touchScroll;
731
+ if (scroll && !scroll.moved && event.touches.length === 0 &&
732
+ performance.now() - scroll.startTime < 350) {
733
+ lastTap = { x: scroll.lastX, y: scroll.lastY, time: performance.now() };
734
+ }
735
+ }
657
736
  state.touchScroll = null;
658
- };
659
- bindTarget.addEventListener("touchend", clearTouchScroll, { passive: true });
660
- bindTarget.addEventListener("touchcancel", clearTouchScroll, { passive: true });
737
+ }, { passive: false });
738
+ bindTarget.addEventListener("touchcancel", cancelGesture, { passive: true });
661
739
  state.touchBound = true;
662
740
  }
663
741
 
@@ -676,9 +754,25 @@
676
754
  removeButton.textContent = "Remove image";
677
755
  menu.appendChild(removeButton);
678
756
 
757
+ const separator = document.createElement("hr");
758
+ const rightButton = document.createElement("button");
759
+ rightButton.type = "button";
760
+ rightButton.className = "kitty-context-menu__item";
761
+ rightButton.textContent = "Right click";
762
+ menu.append(separator, rightButton);
763
+ rightButton.addEventListener("click", (event) => {
764
+ event.preventDefault();
765
+ event.stopPropagation();
766
+ const point = state.contextMenu?.point;
767
+ hideContextMenu();
768
+ if (!point || !terminal.__gottyXterm?._core?.coreMouseService?.areMouseEventsActive) return;
769
+ reportTouchMouse("mousedown", { clientX: point.x, clientY: point.y }, 2);
770
+ reportTouchMouse("mouseup", { clientX: point.x, clientY: point.y }, 2);
771
+ });
772
+
679
773
  state.contextMenu = {
680
774
  root: menu,
681
- removeButton,
775
+ removeButton, rightButton, separator,
682
776
  targetKey: null
683
777
  };
684
778
 
@@ -722,6 +816,9 @@
722
816
  function showContextMenu(x, y, key) {
723
817
  const menu = ensureContextMenu();
724
818
  menu.targetKey = key;
819
+ menu.point = { x, y };
820
+ const reporting = !!terminal.__gottyXterm?._core?.coreMouseService?.areMouseEventsActive;
821
+ menu.rightButton.hidden = menu.separator.hidden = !reporting;
725
822
  menu.root.hidden = false;
726
823
 
727
824
  const margin = 10;
@@ -1004,6 +1101,15 @@
1004
1101
  return false;
1005
1102
  }
1006
1103
 
1104
+ const watchedSockets = new WeakSet();
1105
+ function watchTouchSocket(socket) {
1106
+ if (watchedSockets.has(socket)) return;
1107
+ watchedSockets.add(socket);
1108
+ socket.addEventListener("close", () => {
1109
+ if (state.socket === socket) state.cancelTouchGesture?.();
1110
+ });
1111
+ }
1112
+
1007
1113
  const nativeSend = WebSocket.prototype.send;
1008
1114
  const nativeOnOpen = WebSocket.prototype.addEventListener;
1009
1115
  WebSocket.prototype.addEventListener = function (type, listener, options) {
@@ -1012,6 +1118,7 @@
1012
1118
  }
1013
1119
  const wrapped = function (event) {
1014
1120
  state.socket = this;
1121
+ watchTouchSocket(this);
1015
1122
  inspectTerminalOutput(event.data);
1016
1123
  if (typeof event.data === "string" && event.data[0] === MSG_KITTY) {
1017
1124
  if (handleKittyMessage(event.data.slice(1))) {
@@ -1058,6 +1165,7 @@
1058
1165
  }
1059
1166
  const wrapped = function (event) {
1060
1167
  state.socket = this;
1168
+ watchTouchSocket(this);
1061
1169
  inspectTerminalOutput(event.data);
1062
1170
  if (typeof event.data === "string" && event.data[0] === MSG_KITTY) {
1063
1171
  if (handleKittyMessage(event.data.slice(1))) {
@@ -0,0 +1,55 @@
1
+ // Usage: node tests/manual/zoom-collector.cjs [port] [output.jsonl]
2
+ const http = require('node:http');
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+ const os = require('node:os');
6
+ const { randomBytes } = require('node:crypto');
7
+ const port = Number(process.argv[2] || 18767);
8
+ const file = path.resolve(process.argv[3] || path.join(os.tmpdir(), 'jsgotty-zoom-debug.jsonl'));
9
+ const token = randomBytes(16).toString('hex');
10
+ const endpoint = `http://127.0.0.1:${port}/${token}`;
11
+ const client = fs.readFileSync(path.join(__dirname, 'zoom-debug.js'), 'utf8') +
12
+ `\nwindow.startGottyZoomDebug({endpoint:${JSON.stringify(endpoint)}});\n`;
13
+ let count = 0;
14
+ http.createServer((req, res) => {
15
+ res.setHeader('Access-Control-Allow-Origin', '*');
16
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
17
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
18
+ res.setHeader('Access-Control-Allow-Private-Network', 'true');
19
+ res.setHeader('Cache-Control', 'no-store');
20
+ if (req.url !== '/' + token && req.url !== '/' + token + '/client.js') {
21
+ res.writeHead(404); return res.end();
22
+ }
23
+ if (req.method === 'OPTIONS') { res.writeHead(204); return res.end(); }
24
+ if (req.method === 'GET' && req.url.endsWith('/client.js')) {
25
+ res.setHeader('Content-Type', 'application/javascript');
26
+ return res.end(client);
27
+ }
28
+ if (req.method !== 'POST' || req.url !== '/' + token) {
29
+ res.writeHead(405); return res.end();
30
+ }
31
+ let body = '';
32
+ req.on('data', chunk => {
33
+ body += chunk;
34
+ if (body.length > 16384) req.destroy();
35
+ });
36
+ req.on('end', () => {
37
+ if (count >= 20000) { res.writeHead(429); return res.end(); }
38
+ let event;
39
+ try { event = JSON.parse(body); }
40
+ catch { res.writeHead(400); return res.end(); }
41
+ try {
42
+ fs.appendFileSync(file, JSON.stringify({receivedAt: new Date().toISOString(), event}) + '\n', {mode: 0o600});
43
+ count++;
44
+ res.writeHead(204); res.end();
45
+ } catch (error) {
46
+ console.error(error.message);
47
+ res.writeHead(500); res.end();
48
+ }
49
+ });
50
+ }).listen(port, '127.0.0.1', () => {
51
+ console.log('Log (append): ' + file);
52
+ console.log('Run in the connected jsgotty page via DevTools or CDP:');
53
+ console.log(`fetch(${JSON.stringify(endpoint + '/client.js')}).then(r => r.text()).then(source => (0, eval)(source))`);
54
+ console.log('Stop browser capture: window.stopGottyZoomDebug()');
55
+ });
@@ -0,0 +1,39 @@
1
+ // Manual WebView zoom diagnostics. Does not change focus, selection or gestures.
2
+ window.startGottyZoomDebug = ({endpoint} = {}) => {
3
+ window.stopGottyZoomDebug?.();
4
+ window.__zoomDiag?.stop();
5
+ const logs = [], cleanup = [], started = Date.now();
6
+ const session = Math.random().toString(36).slice(2);
7
+ let stopped = false;
8
+ const label = el => el ? `${el.tagName || ''}#${el.id || ''}.${String(el.className || '')}` : null;
9
+ const record = event => {
10
+ if (stopped || logs.length >= 4000 || Date.now() - started > 600000) return;
11
+ const v = window.visualViewport, active = document.activeElement;
12
+ const row = {session, sequence:logs.length, time:Date.now(), type:event.type,
13
+ target:label(event.target), active:label(active), trusted:event.isTrusted,
14
+ touches:event.touches?.length, viewport:document.querySelector('meta[name=viewport]')?.content,
15
+ scale:v?.scale, offset:v ? [v.offsetLeft,v.offsetTop] : null,
16
+ size:v ? [v.width,v.height] : null, scroll:[scrollX,scrollY],
17
+ selection:document.getSelection()?.type,
18
+ fontSize:active ? getComputedStyle(active).fontSize : null};
19
+ logs.push(row);
20
+ if(endpoint) fetch(endpoint,{method:'POST',headers:{'Content-Type':'text/plain'},body:JSON.stringify(row)})
21
+ .catch(e=>{window.__gottyZoomDeliveryError=String(e)});
22
+ };
23
+ const listen=(target,type)=>{
24
+ target.addEventListener(type,record,{capture:true,passive:true});
25
+ cleanup.push(()=>target.removeEventListener(type,record,true));
26
+ };
27
+ for(const type of ['touchstart','touchend','touchcancel','mousedown','mouseup','click',
28
+ 'contextmenu','focusin','focusout','selectionchange','visibilitychange'])listen(document,type);
29
+ if(window.visualViewport)for(const type of ['resize','scroll'])listen(visualViewport,type);
30
+ const observer=new MutationObserver(()=>record({type:'viewport-mutation'}));
31
+ const meta=document.querySelector('meta[name=viewport]');
32
+ if(meta)observer.observe(meta,{attributes:true});
33
+ cleanup.push(()=>observer.disconnect());
34
+ window.stopGottyZoomDebug=()=>{stopped=true;cleanup.forEach(fn=>fn())};
35
+ window.__gottyZoomRecords=logs;
36
+ window.markGottyZoomDebug=label=>record({type:'mark:'+label});
37
+ record({type:'ready'});
38
+ return 'Zoom diagnostics started';
39
+ };
@@ -0,0 +1,106 @@
1
+ // Opt-in real bundled-xterm integration; no browser/dependency downloads.
2
+ // CHROMIUM_HEADLESS=/path/to/chromium-headless-shell node --test tests/touch-drag-browser.test.js
3
+ const test=require('node:test');
4
+ const assert=require('node:assert/strict');
5
+ const fs=require('node:fs');
6
+ const path=require('node:path');
7
+ const http=require('node:http');
8
+ const {spawn}=require('node:child_process');
9
+ const {once}=require('node:events');
10
+ const WebSocket=require('ws');
11
+ const root=path.join(__dirname,'../static');
12
+ test('touch double-tap drag and image-menu right click use bundled xterm', {skip:!process.env.CHROMIUM_HEADLESS,timeout:60000},async()=>{
13
+ const bundle=fs.readFileSync(path.join(root,'js/gotty.js'),'utf8').replace('(i.nc = void 0));','(i.nc = void 0)); window.TestGoTTYXterm = i(634).GoTTYXterm;');
14
+ const server=http.createServer((req,res)=>{
15
+ if(req.url==='/index.css'){res.setHeader('Content-Type','text/css');res.end(fs.readFileSync(path.join(root,'css/index.css')));}
16
+ else if(req.url==='/bundle.js'){res.setHeader('Content-Type','text/javascript');res.end(bundle);}
17
+ else if(req.url==='/gestures.js'){
18
+ const source=fs.readFileSync(path.join(root,'js/kitty.js'),'utf8');
19
+ res.setHeader('Content-Type','text/javascript');
20
+ res.end(source.slice(source.indexOf(' function reportTouchMouse('),source.indexOf(' function ensureContextMenu('))+source.slice(source.indexOf(' function ensureContextMenu('),source.indexOf(' function bindImageContextMenu('))+source.slice(source.indexOf(' function bindImageContextMenu('),source.indexOf(' function bindImageContextMenu(')+source.slice(source.indexOf(' function bindImageContextMenu(')).indexOf('\n function ',5))+'\nbindTouchScrolling();bindImageContextMenu();');
21
+ }
22
+ else if(req.url==='/keyboard.js'){res.setHeader('Content-Type','text/javascript');res.end(fs.readFileSync(path.join(root,'js/kitty-keyboard.js')));}
23
+ else res.end('<!doctype html><div id="terminal"><div id="fixture" style="width:800px;height:600px"></div></div><link rel="stylesheet" href="/index.css"><script src="/keyboard.js"></script><script src="/bundle.js"></script><script>window.wrapper=new TestGoTTYXterm(document.getElementById("fixture"));window.sent=[];wrapper.onInput(b=>sent.push(new TextDecoder().decode(b)));wrapper.kittyKeyboard.android=true;</script>');
24
+ });
25
+ server.listen(0,'127.0.0.1');await once(server,'listening');
26
+ const child=spawn(process.env.CHROMIUM_HEADLESS,['--no-sandbox','--disable-gpu','--remote-debugging-port=0','http://127.0.0.1:'+server.address().port],{detached:true,stdio:['ignore','ignore','pipe']});
27
+ let ws;
28
+ try{
29
+ const endpoint=await new Promise((resolve,reject)=>{let log='';const timer=setTimeout(()=>reject(Error('Browser startup timeout: '+log)),15000);child.on('error',reject);child.on('exit',code=>{clearTimeout(timer);reject(Error('Browser exited '+code+': '+log));});child.stderr.on('data',b=>{log+=b;const m=log.match(/DevTools listening on (ws:\/\/[^\s]+)/);if(m){clearTimeout(timer);resolve(m[1]);}});});
30
+ const u=new URL(endpoint);let tab;
31
+ for(let i=0;i<50;i++){tab=(await (await fetch('http://'+u.host+'/json/list')).json()).find(x=>x.type==='page');if(tab)break;await new Promise(r=>setTimeout(r,100));}
32
+ ws=new WebSocket(tab.webSocketDebuggerUrl);await once(ws,'open');let id=0;const pending=new Map();ws.on('message',data=>{const m=JSON.parse(data);if(pending.has(m.id)){const {resolve,reject}=pending.get(m.id);pending.delete(m.id);m.error?reject(m.error):resolve(m.result);}});
33
+ const call=(method,params)=>new Promise((resolve,reject)=>{pending.set(++id,{resolve,reject});ws.send(JSON.stringify({id,method,params}));});
34
+ for(let i=0;i<50;i++){const r=await call('Runtime.evaluate',{expression:'Boolean(window.wrapper)',returnByValue:true});if(r.result.value)break;await new Promise(r=>setTimeout(r,100));}
35
+ const result=await call('Runtime.evaluate',{awaitPromise:true,returnByValue:true,expression:`(async()=>{
36
+ const term=wrapper.term;
37
+ const write=s=>new Promise(r=>term.write(s,r));
38
+ const equal=(a,b)=>{if(JSON.stringify(a)!==JSON.stringify(b))throw Error(JSON.stringify({actual:a,expected:b}));};
39
+ await write('\\x1b[?1002h\\x1b[?1006h');
40
+ const terminal=document.getElementById('fixture');terminal.__gottyXterm=term;
41
+ const screen=term.element.querySelector('.xterm-screen'), rect=screen.getBoundingClientRect();
42
+ const state={placements:new Map()};
43
+ const xtermElements=()=>({screen});
44
+ const DEFAULT_TOUCH_SCROLL_THRESHOLD=24;
45
+ const removePlacementNode=node=>node.remove();
46
+ eval(await (await fetch('/gestures.js')).text());
47
+ const touch=(type,x=30,y=30,count=1)=>{
48
+ const point=new Touch({identifier:1,target:screen,clientX:rect.left+x,clientY:rect.top+y});
49
+ const touches=type==='touchend'||type==='touchcancel'?[]:count===1?[point]:[point,new Touch({identifier:2,target:screen,clientX:rect.left+x+30,clientY:rect.top+y})];
50
+ const event=new TouchEvent(type,{bubbles:true,cancelable:true,touches,changedTouches:[point]});
51
+ screen.dispatchEvent(event);return event.defaultPrevented;
52
+ };
53
+ const codes=()=>sent.map(s=>{const m=s.match(/<([0-9]+);[0-9]+;[0-9]+([Mm])/);return m?m[1]+m[2]:s;});
54
+ let focusCalls=0;const originalFocus=term._core.focus;term._core.focus=function(...args){focusCalls++;return originalFocus.apply(this,args);};
55
+ const firstTap=()=>{touch('touchstart');touch('touchend');
56
+ // Browser compatibility mouse sequence after an ordinary tap.
57
+ for(const type of ['mousedown','mouseup'])screen.dispatchEvent(new MouseEvent(type,{bubbles:true,clientX:rect.left+30,clientY:rect.top+30,button:0,buttons:type==='mousedown'?1:0}));};
58
+ sent.length=0;firstTap();equal(codes(),['0M','0m']);
59
+ const focusBeforeDrag=focusCalls;
60
+ if(!touch('touchstart'))throw Error('Second press must suppress compatibility click');
61
+ equal(codes(),['0M','0m','0M']); // still held, no premature release
62
+ touch('touchmove',80,70);touch('touchend',80,70);
63
+ equal(codes(),['0M','0m','0M','32M','0m']);
64
+ equal(focusCalls,focusBeforeDrag);
65
+ sent.length=0;firstTap();touch('touchstart');touch('touchend');
66
+ equal(codes(),['0M','0m','0M','0m']); // ordinary double click
67
+ for(const cancel of [()=>touch('touchcancel'),()=>touch('touchstart',30,30,2),()=>window.dispatchEvent(new Event('blur')),()=>state.cancelTouchGesture()]){
68
+ sent.length=0;firstTap();touch('touchstart');cancel();touch('touchend');equal(codes(),['0M','0m','0M','0m']);
69
+ }
70
+ sent.length=0;firstTap();touch('touchstart');
71
+ touch('touchmove',-200,-200);touch('touchend',-200,-200);
72
+ equal(codes(),['0M','0m','0M','32M','0m']); // release outside grid still reaches application
73
+ sent.length=0;touch('touchstart');touch('touchmove',30,90);touch('touchend',30,90);
74
+ if(!codes().length||codes().some(x=>!x.startsWith('64')))throw Error('Single touch must remain scrolling: '+codes());
75
+ sent.length=0;touch('touchstart');touch('touchmove',30,90);touch('touchend',30,90);
76
+ if(codes().some(x=>x==='0M'))throw Error('Scroll must not arm double tap');
77
+ // Use precisely the existing image context-menu path, with its original location.
78
+ const node=document.createElement('div');node.style.zIndex='0';node.getBoundingClientRect=()=>({left:0,top:0,right:1000,bottom:1000});state.placements.set('image',node);
79
+ screen.dispatchEvent(new MouseEvent('contextmenu',{bubbles:true,cancelable:true,clientX:rect.left+30,clientY:rect.top+30,button:2}));
80
+ if(state.contextMenu.root.hidden)throw Error('Menu missing');
81
+ const focusBeforeRight=focusCalls;
82
+ sent.length=0;state.contextMenu.rightButton.click();equal(focusCalls,focusBeforeRight);equal(codes(),['2M','2m']);
83
+ if(!state.contextMenu.root.hidden||!state.placements.has('image'))throw Error('Right click must hide menu and preserve image');
84
+ await write('\\x1b[?1002l');state.cancelTouchGesture();sent.length=0;
85
+ firstTap();touch('touchstart');touch('touchmove',80,70);touch('touchend');
86
+ if(codes().some(x=>/^[032][Mm]$/.test(x)))throw Error('No application mouse reporting outside mouse mode');
87
+ state.cancelTouchGesture();state.placements.clear();
88
+ term.focus();const textarea=term.textarea;
89
+ screen.dispatchEvent(new MouseEvent('contextmenu',{bubbles:true,cancelable:true,clientX:rect.left+30,clientY:rect.top+30,button:2}));
90
+ if(document.activeElement!==textarea)throw Error('Context menu must keep textarea focused for native paste');
91
+ const data=new DataTransfer();data.setData('text/plain','paste-check');sent.length=0;
92
+ textarea.dispatchEvent(new ClipboardEvent('paste',{bubbles:true,cancelable:true,clipboardData:data}));
93
+ if(!sent.join('').includes('paste-check'))throw Error('Paste stopped working');
94
+ equal(getComputedStyle(textarea).touchAction,'none');
95
+ equal(getComputedStyle(screen).touchAction,'manipulation');
96
+ touch('touchstart');
97
+ if(document.activeElement!==textarea)throw Error('Touch must not blur the input');
98
+ touch('touchend');
99
+ wrapper.close();return 'ok';
100
+ })()`});
101
+ assert.equal(result.exceptionDetails,undefined,JSON.stringify(result.exceptionDetails));assert.equal(result.result.value,'ok');
102
+ }finally{
103
+ ws?.close();if(child.pid){try{process.kill(-child.pid,'SIGTERM');}catch{}}
104
+ server.closeAllConnections();await new Promise(r=>server.close(r));
105
+ }
106
+ });
@@ -0,0 +1,60 @@
1
+ // Opt-in real bundled-xterm integration; no browser/dependency downloads.
2
+ // CHROMIUM_HEADLESS=/path/to/chromium-headless-shell node --test tests/kitty-keyboard-browser.test.js
3
+ const test=require('node:test');
4
+ const assert=require('node:assert/strict');
5
+ const fs=require('node:fs');
6
+ const path=require('node:path');
7
+ const http=require('node:http');
8
+ const {spawn}=require('node:child_process');
9
+ const {once}=require('node:events');
10
+ const WebSocket=require('ws');
11
+ const root=path.join(__dirname,'../static');
12
+ test('shipped xterm reports horizontal, vertical and diagonal wheel events', {skip:!process.env.CHROMIUM_HEADLESS,timeout:60000},async()=>{
13
+ const bundle=fs.readFileSync(path.join(root,'js/gotty.js'),'utf8').replace('(i.nc = void 0));','(i.nc = void 0)); window.TestGoTTYXterm = i(634).GoTTYXterm;');
14
+ const server=http.createServer((req,res)=>{
15
+ if(req.url==='/bundle.js'){res.setHeader('Content-Type','text/javascript');res.end(bundle);}
16
+ else if(req.url==='/keyboard.js'){res.setHeader('Content-Type','text/javascript');res.end(fs.readFileSync(path.join(root,'js/kitty-keyboard.js')));}
17
+ else res.end('<!doctype html><div id="fixture" style="width:800px;height:600px"></div><script src="/keyboard.js"></script><script src="/bundle.js"></script><script>window.wrapper=new TestGoTTYXterm(document.getElementById("fixture"));window.sent=[];wrapper.onInput(b=>sent.push(new TextDecoder().decode(b)));wrapper.kittyKeyboard.android=true;</script>');
18
+ });
19
+ server.listen(0,'127.0.0.1');await once(server,'listening');
20
+ const child=spawn(process.env.CHROMIUM_HEADLESS,['--no-sandbox','--disable-gpu','--remote-debugging-port=0','http://127.0.0.1:'+server.address().port],{detached:true,stdio:['ignore','ignore','pipe']});
21
+ let ws;
22
+ try{
23
+ const endpoint=await new Promise((resolve,reject)=>{let log='';const timer=setTimeout(()=>reject(Error('Browser startup timeout: '+log)),15000);child.on('error',reject);child.on('exit',code=>{clearTimeout(timer);reject(Error('Browser exited '+code+': '+log));});child.stderr.on('data',b=>{log+=b;const m=log.match(/DevTools listening on (ws:\/\/[^\s]+)/);if(m){clearTimeout(timer);resolve(m[1]);}});});
24
+ const u=new URL(endpoint);let tab;
25
+ for(let i=0;i<50;i++){tab=(await (await fetch('http://'+u.host+'/json/list')).json()).find(x=>x.type==='page');if(tab)break;await new Promise(r=>setTimeout(r,100));}
26
+ ws=new WebSocket(tab.webSocketDebuggerUrl);await once(ws,'open');let id=0;const pending=new Map();ws.on('message',data=>{const m=JSON.parse(data);if(pending.has(m.id)){const {resolve,reject}=pending.get(m.id);pending.delete(m.id);m.error?reject(m.error):resolve(m.result);}});
27
+ const call=(method,params)=>new Promise((resolve,reject)=>{pending.set(++id,{resolve,reject});ws.send(JSON.stringify({id,method,params}));});
28
+ for(let i=0;i<50;i++){const r=await call('Runtime.evaluate',{expression:'Boolean(window.wrapper)',returnByValue:true});if(r.result.value)break;await new Promise(r=>setTimeout(r,100));}
29
+ const result=await call('Runtime.evaluate',{awaitPromise:true,returnByValue:true,expression:`(async()=>{
30
+ const term=wrapper.term;
31
+ const write=s=>new Promise(r=>term.write(s,r));
32
+ const equal=(a,b)=>{if(JSON.stringify(a)!==JSON.stringify(b))throw Error(JSON.stringify({actual:a,expected:b}));};
33
+ await write('\\x1b[?1002h\\x1b[?1006h');
34
+ const screen=term.element.querySelector('.xterm-screen'), rect=screen.getBoundingClientRect();
35
+ const wheel=(deltaX,deltaY,mods={})=>{
36
+ sent.length=0;
37
+ const event=new WheelEvent('wheel',{clientX:rect.left+20,clientY:rect.top+20,deltaX,deltaY,deltaMode:1,bubbles:true,cancelable:true,...mods});
38
+ screen.dispatchEvent(event);
39
+ if(term._core.coreMouseService.areMouseEventsActive&&!event.defaultPrevented)throw Error('Wheel default was not prevented');
40
+ return sent.map(s=>Number(s.match(/<([0-9]+);/)[1]));
41
+ };
42
+ equal(wheel(-1,0),[66]);equal(wheel(1,0),[67]);
43
+ equal(wheel(0,-1,{shiftKey:true}),[68]);equal(wheel(0,1,{shiftKey:true}),[69]);
44
+ equal(wheel(0,-1),[64]);equal(wheel(0,1),[65]);
45
+ equal(wheel(1,-1),[67,64]);
46
+ equal(wheel(-1,0,{shiftKey:true,altKey:true,ctrlKey:true}),[94]);
47
+ for(let bits=0;bits<8;bits++) {
48
+ const mods={shiftKey:!!(bits&1),altKey:!!(bits&2),ctrlKey:!!(bits&4)};
49
+ for(const [dx,dy,code] of [[0,-1,64],[0,1,65],[-1,0,66],[1,0,67]])
50
+ equal(wheel(dx,dy,mods),[code+(bits<<2)]);
51
+ }
52
+ await write('\\x1b[?1002l');equal(wheel(1,0),[]);
53
+ wrapper.close();return 'ok';
54
+ })()`});
55
+ assert.equal(result.exceptionDetails,undefined,JSON.stringify(result.exceptionDetails));assert.equal(result.result.value,'ok');
56
+ }finally{
57
+ ws?.close();if(child.pid){try{process.kill(-child.pid,'SIGTERM');}catch{}}
58
+ server.closeAllConnections();await new Promise(r=>server.close(r));
59
+ }
60
+ });
@@ -2,6 +2,50 @@
2
2
 
3
3
  All notable user-visible changes to jsmdcui are documented here.
4
4
 
5
+ ## [0.19.4] - 2026-10-26
6
+
7
+ ### Fixed
8
+
9
+ - Catch failures during Go regexp conversion as well as compilation, and
10
+ bound the converter's nested-group recursion. An excessively nested rule
11
+ is disabled individually instead of throwing from `SyntaxDefinition` or
12
+ causing the loader to silently discard the entire syntax definition.
13
+ Other rules remain usable; this does not limit input line length.
14
+
15
+ ## [0.19.3] - 2026-10-26
16
+
17
+ ### Fixed
18
+
19
+ - Restore Dockerfile highlighting and improve compatibility with Micro's
20
+ syntax definitions by translating Go regular-expression flags and POSIX
21
+ character classes into JavaScript equivalents. Scoped flags retain their
22
+ scope rather than changing the entire expression.
23
+ - Fix nested highlighting regions, especially HTML and XML attributes:
24
+ closing a quoted attribute now returns to the surrounding tag, and closing
25
+ the tag returns to normal text. Unfinished attributes still carry their
26
+ highlighting state across lines.
27
+ - Replace recursive region scanning with an iterative scanner so long,
28
+ minified lines can be highlighted through their final token without stack
29
+ exhaustion or truncation.
30
+ - Prevent zero-width region cycles and cyclic syntax/state trees from looping
31
+ indefinitely. Zero-width matches advance safely over Unicode characters,
32
+ and NUL characters or isolated surrogate units do not leak region state.
33
+ These guards do not impose a timeout on an individual JavaScript RegExp.
34
+
35
+ ### Added
36
+
37
+ - Add syntax samples and a reproducible comparison against Micro's Go
38
+ highlighter, including JavaScript, Python and Markdown with upstream syntax
39
+ definitions. See [the comparison guide](tests/syntax-parity/README.md) for
40
+ generating the HTML comparison locally; generated HTML is not bundled.
41
+
42
+ ### Changed
43
+
44
+ - Markdown heading tests detect Bun's actual raw-HTML parsing behavior instead
45
+ of relying on version checks, accommodating the slash-tag behavior change
46
+ while preserving fail-closed coverage. The heading suites were verified on
47
+ Bun 1.4.0 through 1.4.4.
48
+
5
49
  ## [0.19.2] - 2026-09-21
6
50
 
7
51
  ### Added
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jsmdcui",
3
- "version": "0.19.2",
3
+ "version": "0.19.4",
4
4
  "description": "Markdown as a Common UI for Terminals and Web Browsers",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",