@react-x11/components 0.1.0 → 0.2.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 (783) hide show
  1. package/README.md +979 -21
  2. package/dist/ansi/cast.d.ts +48 -0
  3. package/dist/ansi/cast.d.ts.map +1 -0
  4. package/dist/ansi/cast.js +135 -0
  5. package/dist/ansi/cast.js.map +1 -0
  6. package/dist/ansi/index.d.ts +10 -0
  7. package/dist/ansi/index.d.ts.map +1 -0
  8. package/dist/ansi/index.js +27 -0
  9. package/dist/ansi/index.js.map +1 -0
  10. package/dist/ansi/palette.d.ts +79 -0
  11. package/dist/ansi/palette.d.ts.map +1 -0
  12. package/dist/ansi/palette.js +155 -0
  13. package/dist/ansi/palette.js.map +1 -0
  14. package/dist/ansi/parse.d.ts +114 -0
  15. package/dist/ansi/parse.d.ts.map +1 -0
  16. package/dist/ansi/parse.js +687 -0
  17. package/dist/ansi/parse.js.map +1 -0
  18. package/dist/ansi/sgr.d.ts +87 -0
  19. package/dist/ansi/sgr.d.ts.map +1 -0
  20. package/dist/ansi/sgr.js +302 -0
  21. package/dist/ansi/sgr.js.map +1 -0
  22. package/dist/ansi/utf8.d.ts +15 -0
  23. package/dist/ansi/utf8.d.ts.map +1 -0
  24. package/dist/ansi/utf8.js +82 -0
  25. package/dist/ansi/utf8.js.map +1 -0
  26. package/dist/calendar/Calendar.d.ts +136 -0
  27. package/dist/calendar/Calendar.d.ts.map +1 -0
  28. package/dist/calendar/Calendar.js +460 -0
  29. package/dist/calendar/Calendar.js.map +1 -0
  30. package/dist/calendar/DatePicker.d.ts +52 -0
  31. package/dist/calendar/DatePicker.d.ts.map +1 -0
  32. package/dist/calendar/DatePicker.js +257 -0
  33. package/dist/calendar/DatePicker.js.map +1 -0
  34. package/dist/calendar/dates.d.ts +90 -0
  35. package/dist/calendar/dates.d.ts.map +1 -0
  36. package/dist/calendar/dates.js +236 -0
  37. package/dist/calendar/dates.js.map +1 -0
  38. package/dist/calendar/index.d.ts +8 -0
  39. package/dist/calendar/index.d.ts.map +1 -0
  40. package/dist/calendar/index.js +15 -0
  41. package/dist/calendar/index.js.map +1 -0
  42. package/dist/charts/components.d.ts +141 -0
  43. package/dist/charts/components.d.ts.map +1 -0
  44. package/dist/charts/components.js +580 -0
  45. package/dist/charts/components.js.map +1 -0
  46. package/dist/charts/data.d.ts +198 -0
  47. package/dist/charts/data.d.ts.map +1 -0
  48. package/dist/charts/data.js +616 -0
  49. package/dist/charts/data.js.map +1 -0
  50. package/dist/charts/hx.d.ts +21 -0
  51. package/dist/charts/hx.d.ts.map +1 -0
  52. package/dist/charts/hx.js +15 -0
  53. package/dist/charts/hx.js.map +1 -0
  54. package/dist/charts/index.d.ts +27 -0
  55. package/dist/charts/index.d.ts.map +1 -0
  56. package/dist/charts/index.js +38 -0
  57. package/dist/charts/index.js.map +1 -0
  58. package/dist/charts/node.d.ts +96 -0
  59. package/dist/charts/node.d.ts.map +1 -0
  60. package/dist/charts/node.js +720 -0
  61. package/dist/charts/node.js.map +1 -0
  62. package/dist/charts/render.d.ts +153 -0
  63. package/dist/charts/render.d.ts.map +1 -0
  64. package/dist/charts/render.js +762 -0
  65. package/dist/charts/render.js.map +1 -0
  66. package/dist/charts/scale.d.ts +42 -0
  67. package/dist/charts/scale.d.ts.map +1 -0
  68. package/dist/charts/scale.js +173 -0
  69. package/dist/charts/scale.js.map +1 -0
  70. package/dist/charts/spec.d.ts +91 -0
  71. package/dist/charts/spec.d.ts.map +1 -0
  72. package/dist/charts/spec.js +33 -0
  73. package/dist/charts/spec.js.map +1 -0
  74. package/dist/charts/timers.d.ts +6 -0
  75. package/dist/charts/timers.d.ts.map +1 -0
  76. package/dist/charts/timers.js +14 -0
  77. package/dist/charts/timers.js.map +1 -0
  78. package/dist/code/hx.d.ts +24 -0
  79. package/dist/code/hx.d.ts.map +1 -0
  80. package/dist/code/hx.js +25 -0
  81. package/dist/code/hx.js.map +1 -0
  82. package/dist/code/index.d.ts +45 -0
  83. package/dist/code/index.d.ts.map +1 -0
  84. package/dist/code/index.js +93 -0
  85. package/dist/code/index.js.map +1 -0
  86. package/dist/code-editor/completion.d.ts +26 -0
  87. package/dist/code-editor/completion.d.ts.map +1 -0
  88. package/dist/code-editor/completion.js +129 -0
  89. package/dist/code-editor/completion.js.map +1 -0
  90. package/dist/code-editor/doc.d.ts +35 -0
  91. package/dist/code-editor/doc.d.ts.map +1 -0
  92. package/dist/code-editor/doc.js +162 -0
  93. package/dist/code-editor/doc.js.map +1 -0
  94. package/dist/code-editor/hx.d.ts +13 -0
  95. package/dist/code-editor/hx.d.ts.map +1 -0
  96. package/dist/code-editor/hx.js +10 -0
  97. package/dist/code-editor/hx.js.map +1 -0
  98. package/dist/code-editor/index.d.ts +88 -0
  99. package/dist/code-editor/index.d.ts.map +1 -0
  100. package/dist/code-editor/index.js +401 -0
  101. package/dist/code-editor/index.js.map +1 -0
  102. package/dist/code-editor/node.d.ts +343 -0
  103. package/dist/code-editor/node.d.ts.map +1 -0
  104. package/dist/code-editor/node.js +1521 -0
  105. package/dist/code-editor/node.js.map +1 -0
  106. package/dist/code-language/hljs.d.ts +47 -0
  107. package/dist/code-language/hljs.d.ts.map +1 -0
  108. package/dist/code-language/hljs.js +211 -0
  109. package/dist/code-language/hljs.js.map +1 -0
  110. package/dist/code-language/index.d.ts +22 -0
  111. package/dist/code-language/index.d.ts.map +1 -0
  112. package/dist/code-language/index.js +20 -0
  113. package/dist/code-language/index.js.map +1 -0
  114. package/dist/code-language/languages/glsl.d.ts +4 -0
  115. package/dist/code-language/languages/glsl.d.ts.map +1 -0
  116. package/dist/code-language/languages/glsl.js +99 -0
  117. package/dist/code-language/languages/glsl.js.map +1 -0
  118. package/dist/code-language/languages/javascript.d.ts +10 -0
  119. package/dist/code-language/languages/javascript.d.ts.map +1 -0
  120. package/dist/code-language/languages/javascript.js +360 -0
  121. package/dist/code-language/languages/javascript.js.map +1 -0
  122. package/dist/code-language/languages/json.d.ts +4 -0
  123. package/dist/code-language/languages/json.d.ts.map +1 -0
  124. package/dist/code-language/languages/json.js +68 -0
  125. package/dist/code-language/languages/json.js.map +1 -0
  126. package/dist/code-language/languages/shell.d.ts +4 -0
  127. package/dist/code-language/languages/shell.d.ts.map +1 -0
  128. package/dist/code-language/languages/shell.js +189 -0
  129. package/dist/code-language/languages/shell.js.map +1 -0
  130. package/dist/code-language/languages/sql.d.ts +11 -0
  131. package/dist/code-language/languages/sql.d.ts.map +1 -0
  132. package/dist/code-language/languages/sql.js +133 -0
  133. package/dist/code-language/languages/sql.js.map +1 -0
  134. package/dist/code-language/lezer.d.ts +24 -0
  135. package/dist/code-language/lezer.d.ts.map +1 -0
  136. package/dist/code-language/lezer.js +148 -0
  137. package/dist/code-language/lezer.js.map +1 -0
  138. package/dist/code-language/registry.d.ts +19 -0
  139. package/dist/code-language/registry.d.ts.map +1 -0
  140. package/dist/code-language/registry.js +56 -0
  141. package/dist/code-language/registry.js.map +1 -0
  142. package/dist/code-language/runs.d.ts +35 -0
  143. package/dist/code-language/runs.d.ts.map +1 -0
  144. package/dist/code-language/runs.js +88 -0
  145. package/dist/code-language/runs.js.map +1 -0
  146. package/dist/code-language/stream.d.ts +70 -0
  147. package/dist/code-language/stream.d.ts.map +1 -0
  148. package/dist/code-language/stream.js +243 -0
  149. package/dist/code-language/stream.js.map +1 -0
  150. package/dist/code-language/textmate.d.ts +27 -0
  151. package/dist/code-language/textmate.d.ts.map +1 -0
  152. package/dist/code-language/textmate.js +86 -0
  153. package/dist/code-language/textmate.js.map +1 -0
  154. package/dist/code-language/theme.d.ts +26 -0
  155. package/dist/code-language/theme.d.ts.map +1 -0
  156. package/dist/code-language/theme.js +108 -0
  157. package/dist/code-language/theme.js.map +1 -0
  158. package/dist/code-language/timers.d.ts +7 -0
  159. package/dist/code-language/timers.d.ts.map +1 -0
  160. package/dist/code-language/timers.js +22 -0
  161. package/dist/code-language/timers.js.map +1 -0
  162. package/dist/code-language/types.d.ts +173 -0
  163. package/dist/code-language/types.d.ts.map +1 -0
  164. package/dist/code-language/types.js +41 -0
  165. package/dist/code-language/types.js.map +1 -0
  166. package/dist/codeblock/index.d.ts +69 -0
  167. package/dist/codeblock/index.d.ts.map +1 -0
  168. package/dist/codeblock/index.js +85 -0
  169. package/dist/codeblock/index.js.map +1 -0
  170. package/dist/color-picker/ColorField.d.ts +39 -0
  171. package/dist/color-picker/ColorField.d.ts.map +1 -0
  172. package/dist/color-picker/ColorField.js +239 -0
  173. package/dist/color-picker/ColorField.js.map +1 -0
  174. package/dist/color-picker/ColorPicker.d.ts +109 -0
  175. package/dist/color-picker/ColorPicker.d.ts.map +1 -0
  176. package/dist/color-picker/ColorPicker.js +757 -0
  177. package/dist/color-picker/ColorPicker.js.map +1 -0
  178. package/dist/color-picker/color.d.ts +105 -0
  179. package/dist/color-picker/color.d.ts.map +1 -0
  180. package/dist/color-picker/color.js +313 -0
  181. package/dist/color-picker/color.js.map +1 -0
  182. package/dist/color-picker/index.d.ts +8 -0
  183. package/dist/color-picker/index.d.ts.map +1 -0
  184. package/dist/color-picker/index.js +19 -0
  185. package/dist/color-picker/index.js.map +1 -0
  186. package/dist/desktop-calendar/eds.d.ts +102 -0
  187. package/dist/desktop-calendar/eds.d.ts.map +1 -0
  188. package/dist/desktop-calendar/eds.js +356 -0
  189. package/dist/desktop-calendar/eds.js.map +1 -0
  190. package/dist/desktop-calendar/ical.d.ts +63 -0
  191. package/dist/desktop-calendar/ical.d.ts.map +1 -0
  192. package/dist/desktop-calendar/ical.js +57 -0
  193. package/dist/desktop-calendar/ical.js.map +1 -0
  194. package/dist/desktop-calendar/index.d.ts +72 -0
  195. package/dist/desktop-calendar/index.d.ts.map +1 -0
  196. package/dist/desktop-calendar/index.js +213 -0
  197. package/dist/desktop-calendar/index.js.map +1 -0
  198. package/dist/embed/client.d.ts +121 -0
  199. package/dist/embed/client.d.ts.map +1 -0
  200. package/dist/embed/client.js +203 -0
  201. package/dist/embed/client.js.map +1 -0
  202. package/dist/embed/host.d.ts +96 -0
  203. package/dist/embed/host.d.ts.map +1 -0
  204. package/dist/embed/host.js +266 -0
  205. package/dist/embed/host.js.map +1 -0
  206. package/dist/embed/index.d.ts +5 -0
  207. package/dist/embed/index.d.ts.map +1 -0
  208. package/dist/embed/index.js +13 -0
  209. package/dist/embed/index.js.map +1 -0
  210. package/dist/embed/timers.d.ts +6 -0
  211. package/dist/embed/timers.d.ts.map +1 -0
  212. package/dist/embed/timers.js +16 -0
  213. package/dist/embed/timers.js.map +1 -0
  214. package/dist/flow/draw.d.ts +45 -0
  215. package/dist/flow/draw.d.ts.map +1 -0
  216. package/dist/flow/draw.js +295 -0
  217. package/dist/flow/draw.js.map +1 -0
  218. package/dist/flow/index.d.ts +64 -0
  219. package/dist/flow/index.d.ts.map +1 -0
  220. package/dist/flow/index.js +306 -0
  221. package/dist/flow/index.js.map +1 -0
  222. package/dist/flow/model.d.ts +158 -0
  223. package/dist/flow/model.d.ts.map +1 -0
  224. package/dist/flow/model.js +566 -0
  225. package/dist/flow/model.js.map +1 -0
  226. package/dist/flow/node.d.ts +429 -0
  227. package/dist/flow/node.d.ts.map +1 -0
  228. package/dist/flow/node.js +3025 -0
  229. package/dist/flow/node.js.map +1 -0
  230. package/dist/flow/paths.d.ts +43 -0
  231. package/dist/flow/paths.d.ts.map +1 -0
  232. package/dist/flow/paths.js +327 -0
  233. package/dist/flow/paths.js.map +1 -0
  234. package/dist/flow/types.d.ts +561 -0
  235. package/dist/flow/types.d.ts.map +1 -0
  236. package/dist/flow/types.js +2 -0
  237. package/dist/flow/types.js.map +1 -0
  238. package/dist/formula/hx.d.ts +24 -0
  239. package/dist/formula/hx.d.ts.map +1 -0
  240. package/dist/formula/hx.js +25 -0
  241. package/dist/formula/hx.js.map +1 -0
  242. package/dist/formula/index.d.ts +74 -0
  243. package/dist/formula/index.d.ts.map +1 -0
  244. package/dist/formula/index.js +155 -0
  245. package/dist/formula/index.js.map +1 -0
  246. package/dist/formula/katex.d.ts +78 -0
  247. package/dist/formula/katex.d.ts.map +1 -0
  248. package/dist/formula/katex.js +130 -0
  249. package/dist/formula/katex.js.map +1 -0
  250. package/dist/formula/layout.d.ts +103 -0
  251. package/dist/formula/layout.d.ts.map +1 -0
  252. package/dist/formula/layout.js +716 -0
  253. package/dist/formula/layout.js.map +1 -0
  254. package/dist/formula/node.d.ts +50 -0
  255. package/dist/formula/node.d.ts.map +1 -0
  256. package/dist/formula/node.js +351 -0
  257. package/dist/formula/node.js.map +1 -0
  258. package/dist/html/controls.d.ts +48 -0
  259. package/dist/html/controls.d.ts.map +1 -0
  260. package/dist/html/controls.js +195 -0
  261. package/dist/html/controls.js.map +1 -0
  262. package/dist/html/css/cascade.d.ts +51 -0
  263. package/dist/html/css/cascade.d.ts.map +1 -0
  264. package/dist/html/css/cascade.js +552 -0
  265. package/dist/html/css/cascade.js.map +1 -0
  266. package/dist/html/css/parse.d.ts +74 -0
  267. package/dist/html/css/parse.d.ts.map +1 -0
  268. package/dist/html/css/parse.js +464 -0
  269. package/dist/html/css/parse.js.map +1 -0
  270. package/dist/html/css/style.d.ts +142 -0
  271. package/dist/html/css/style.d.ts.map +1 -0
  272. package/dist/html/css/style.js +1145 -0
  273. package/dist/html/css/style.js.map +1 -0
  274. package/dist/html/css/ua.d.ts +5 -0
  275. package/dist/html/css/ua.d.ts.map +1 -0
  276. package/dist/html/css/ua.js +171 -0
  277. package/dist/html/css/ua.js.map +1 -0
  278. package/dist/html/css/values.d.ts +78 -0
  279. package/dist/html/css/values.d.ts.map +1 -0
  280. package/dist/html/css/values.js +330 -0
  281. package/dist/html/css/values.js.map +1 -0
  282. package/dist/html/dom.d.ts +112 -0
  283. package/dist/html/dom.d.ts.map +1 -0
  284. package/dist/html/dom.js +374 -0
  285. package/dist/html/dom.js.map +1 -0
  286. package/dist/html/hx.d.ts +24 -0
  287. package/dist/html/hx.d.ts.map +1 -0
  288. package/dist/html/hx.js +29 -0
  289. package/dist/html/hx.js.map +1 -0
  290. package/dist/html/index.d.ts +137 -0
  291. package/dist/html/index.d.ts.map +1 -0
  292. package/dist/html/index.js +339 -0
  293. package/dist/html/index.js.map +1 -0
  294. package/dist/html/layout/block.d.ts +58 -0
  295. package/dist/html/layout/block.d.ts.map +1 -0
  296. package/dist/html/layout/block.js +724 -0
  297. package/dist/html/layout/block.js.map +1 -0
  298. package/dist/html/layout/boxes.d.ts +261 -0
  299. package/dist/html/layout/boxes.d.ts.map +1 -0
  300. package/dist/html/layout/boxes.js +725 -0
  301. package/dist/html/layout/boxes.js.map +1 -0
  302. package/dist/html/layout/flex.d.ts +16 -0
  303. package/dist/html/layout/flex.d.ts.map +1 -0
  304. package/dist/html/layout/flex.js +242 -0
  305. package/dist/html/layout/flex.js.map +1 -0
  306. package/dist/html/layout/floats.d.ts +50 -0
  307. package/dist/html/layout/floats.d.ts.map +1 -0
  308. package/dist/html/layout/floats.js +110 -0
  309. package/dist/html/layout/floats.js.map +1 -0
  310. package/dist/html/layout/inline.d.ts +44 -0
  311. package/dist/html/layout/inline.d.ts.map +1 -0
  312. package/dist/html/layout/inline.js +700 -0
  313. package/dist/html/layout/inline.js.map +1 -0
  314. package/dist/html/layout/table.d.ts +5 -0
  315. package/dist/html/layout/table.d.ts.map +1 -0
  316. package/dist/html/layout/table.js +340 -0
  317. package/dist/html/layout/table.js.map +1 -0
  318. package/dist/html/node.d.ts +149 -0
  319. package/dist/html/node.d.ts.map +1 -0
  320. package/dist/html/node.js +861 -0
  321. package/dist/html/node.js.map +1 -0
  322. package/dist/html/paint.d.ts +48 -0
  323. package/dist/html/paint.d.ts.map +1 -0
  324. package/dist/html/paint.js +495 -0
  325. package/dist/html/paint.js.map +1 -0
  326. package/dist/html/resources.d.ts +48 -0
  327. package/dist/html/resources.d.ts.map +1 -0
  328. package/dist/html/resources.js +156 -0
  329. package/dist/html/resources.js.map +1 -0
  330. package/dist/index.d.ts +48 -0
  331. package/dist/index.d.ts.map +1 -0
  332. package/dist/index.js +92 -0
  333. package/dist/index.js.map +1 -0
  334. package/dist/internal/heights.d.ts +81 -0
  335. package/dist/internal/heights.d.ts.map +1 -0
  336. package/dist/internal/heights.js +193 -0
  337. package/dist/internal/heights.js.map +1 -0
  338. package/dist/internal/hx.d.ts +24 -0
  339. package/dist/internal/hx.d.ts.map +1 -0
  340. package/dist/internal/hx.js +27 -0
  341. package/dist/internal/hx.js.map +1 -0
  342. package/dist/internal/text.d.ts +6 -0
  343. package/dist/internal/text.d.ts.map +1 -0
  344. package/dist/internal/text.js +44 -0
  345. package/dist/internal/text.js.map +1 -0
  346. package/dist/internal/timers.d.ts +8 -0
  347. package/dist/internal/timers.d.ts.map +1 -0
  348. package/dist/internal/timers.js +22 -0
  349. package/dist/internal/timers.js.map +1 -0
  350. package/dist/internal/widget.d.ts +40 -0
  351. package/dist/internal/widget.d.ts.map +1 -0
  352. package/dist/internal/widget.js +44 -0
  353. package/dist/internal/widget.js.map +1 -0
  354. package/dist/markdown/ast.d.ts +126 -0
  355. package/dist/markdown/ast.d.ts.map +1 -0
  356. package/dist/markdown/ast.js +8 -0
  357. package/dist/markdown/ast.js.map +1 -0
  358. package/dist/markdown/hx.d.ts +24 -0
  359. package/dist/markdown/hx.d.ts.map +1 -0
  360. package/dist/markdown/hx.js +25 -0
  361. package/dist/markdown/hx.js.map +1 -0
  362. package/dist/markdown/index.d.ts +90 -0
  363. package/dist/markdown/index.d.ts.map +1 -0
  364. package/dist/markdown/index.js +413 -0
  365. package/dist/markdown/index.js.map +1 -0
  366. package/dist/markdown/parse.d.ts +8 -0
  367. package/dist/markdown/parse.d.ts.map +1 -0
  368. package/dist/markdown/parse.js +911 -0
  369. package/dist/markdown/parse.js.map +1 -0
  370. package/dist/markdown/spans.d.ts +20 -0
  371. package/dist/markdown/spans.d.ts.map +1 -0
  372. package/dist/markdown/spans.js +96 -0
  373. package/dist/markdown/spans.js.map +1 -0
  374. package/dist/media-player/backends.d.ts +64 -0
  375. package/dist/media-player/backends.d.ts.map +1 -0
  376. package/dist/media-player/backends.js +110 -0
  377. package/dist/media-player/backends.js.map +1 -0
  378. package/dist/media-player/control.d.ts +40 -0
  379. package/dist/media-player/control.d.ts.map +1 -0
  380. package/dist/media-player/control.js +44 -0
  381. package/dist/media-player/control.js.map +1 -0
  382. package/dist/media-player/hx.d.ts +24 -0
  383. package/dist/media-player/hx.d.ts.map +1 -0
  384. package/dist/media-player/hx.js +25 -0
  385. package/dist/media-player/hx.js.map +1 -0
  386. package/dist/media-player/index.d.ts +107 -0
  387. package/dist/media-player/index.d.ts.map +1 -0
  388. package/dist/media-player/index.js +219 -0
  389. package/dist/media-player/index.js.map +1 -0
  390. package/dist/media-player/mpv.d.ts +4 -0
  391. package/dist/media-player/mpv.d.ts.map +1 -0
  392. package/dist/media-player/mpv.js +120 -0
  393. package/dist/media-player/mpv.js.map +1 -0
  394. package/dist/media-player/vlc.d.ts +8 -0
  395. package/dist/media-player/vlc.d.ts.map +1 -0
  396. package/dist/media-player/vlc.js +83 -0
  397. package/dist/media-player/vlc.js.map +1 -0
  398. package/dist/richtext/index.d.ts +19 -0
  399. package/dist/richtext/index.d.ts.map +1 -0
  400. package/dist/richtext/index.js +4 -0
  401. package/dist/richtext/index.js.map +1 -0
  402. package/dist/richtext/links.d.ts +12 -0
  403. package/dist/richtext/links.d.ts.map +1 -0
  404. package/dist/richtext/links.js +64 -0
  405. package/dist/richtext/links.js.map +1 -0
  406. package/dist/richtext/menu.d.ts +11 -0
  407. package/dist/richtext/menu.d.ts.map +1 -0
  408. package/dist/richtext/menu.js +43 -0
  409. package/dist/richtext/menu.js.map +1 -0
  410. package/dist/richtext/node.d.ts +151 -0
  411. package/dist/richtext/node.d.ts.map +1 -0
  412. package/dist/richtext/node.js +255 -0
  413. package/dist/richtext/node.js.map +1 -0
  414. package/dist/richtext/runs.d.ts +92 -0
  415. package/dist/richtext/runs.d.ts.map +1 -0
  416. package/dist/richtext/runs.js +158 -0
  417. package/dist/richtext/runs.js.map +1 -0
  418. package/dist/table/hx.d.ts +24 -0
  419. package/dist/table/hx.d.ts.map +1 -0
  420. package/dist/table/hx.js +25 -0
  421. package/dist/table/hx.js.map +1 -0
  422. package/dist/table/index.d.ts +169 -0
  423. package/dist/table/index.d.ts.map +1 -0
  424. package/dist/table/index.js +844 -0
  425. package/dist/table/index.js.map +1 -0
  426. package/dist/table/rows.d.ts +136 -0
  427. package/dist/table/rows.d.ts.map +1 -0
  428. package/dist/table/rows.js +146 -0
  429. package/dist/table/rows.js.map +1 -0
  430. package/dist/terminal/backends.d.ts +105 -0
  431. package/dist/terminal/backends.d.ts.map +1 -0
  432. package/dist/terminal/backends.js +185 -0
  433. package/dist/terminal/backends.js.map +1 -0
  434. package/dist/terminal/hx.d.ts +24 -0
  435. package/dist/terminal/hx.d.ts.map +1 -0
  436. package/dist/terminal/hx.js +25 -0
  437. package/dist/terminal/hx.js.map +1 -0
  438. package/dist/terminal/index.d.ts +160 -0
  439. package/dist/terminal/index.d.ts.map +1 -0
  440. package/dist/terminal/index.js +344 -0
  441. package/dist/terminal/index.js.map +1 -0
  442. package/dist/terminal/title.d.ts +24 -0
  443. package/dist/terminal/title.d.ts.map +1 -0
  444. package/dist/terminal/title.js +129 -0
  445. package/dist/terminal/title.js.map +1 -0
  446. package/dist/terminal/vt/colors.d.ts +63 -0
  447. package/dist/terminal/vt/colors.d.ts.map +1 -0
  448. package/dist/terminal/vt/colors.js +203 -0
  449. package/dist/terminal/vt/colors.js.map +1 -0
  450. package/dist/terminal/vt/diff.d.ts +117 -0
  451. package/dist/terminal/vt/diff.d.ts.map +1 -0
  452. package/dist/terminal/vt/diff.js +358 -0
  453. package/dist/terminal/vt/diff.js.map +1 -0
  454. package/dist/terminal/vt/fonts.d.ts +92 -0
  455. package/dist/terminal/vt/fonts.d.ts.map +1 -0
  456. package/dist/terminal/vt/fonts.js +178 -0
  457. package/dist/terminal/vt/fonts.js.map +1 -0
  458. package/dist/terminal/vt/index.d.ts +78 -0
  459. package/dist/terminal/vt/index.d.ts.map +1 -0
  460. package/dist/terminal/vt/index.js +312 -0
  461. package/dist/terminal/vt/index.js.map +1 -0
  462. package/dist/terminal/vt/keys.d.ts +74 -0
  463. package/dist/terminal/vt/keys.d.ts.map +1 -0
  464. package/dist/terminal/vt/keys.js +309 -0
  465. package/dist/terminal/vt/keys.js.map +1 -0
  466. package/dist/terminal/vt/mouse.d.ts +44 -0
  467. package/dist/terminal/vt/mouse.d.ts.map +1 -0
  468. package/dist/terminal/vt/mouse.js +106 -0
  469. package/dist/terminal/vt/mouse.js.map +1 -0
  470. package/dist/terminal/vt/node.d.ts +246 -0
  471. package/dist/terminal/vt/node.d.ts.map +1 -0
  472. package/dist/terminal/vt/node.js +1068 -0
  473. package/dist/terminal/vt/node.js.map +1 -0
  474. package/dist/terminal/vt/pty.d.ts +81 -0
  475. package/dist/terminal/vt/pty.d.ts.map +1 -0
  476. package/dist/terminal/vt/pty.js +201 -0
  477. package/dist/terminal/vt/pty.js.map +1 -0
  478. package/dist/terminal/vt/renderer.d.ts +179 -0
  479. package/dist/terminal/vt/renderer.d.ts.map +1 -0
  480. package/dist/terminal/vt/renderer.js +357 -0
  481. package/dist/terminal/vt/renderer.js.map +1 -0
  482. package/dist/terminal/vt/timers.d.ts +6 -0
  483. package/dist/terminal/vt/timers.d.ts.map +1 -0
  484. package/dist/terminal/vt/timers.js +16 -0
  485. package/dist/terminal/vt/timers.js.map +1 -0
  486. package/dist/terminal/vt/xterm.d.ts +142 -0
  487. package/dist/terminal/vt/xterm.d.ts.map +1 -0
  488. package/dist/terminal/vt/xterm.js +32 -0
  489. package/dist/terminal/vt/xterm.js.map +1 -0
  490. package/dist/terminal-output/hx.d.ts +24 -0
  491. package/dist/terminal-output/hx.d.ts.map +1 -0
  492. package/dist/terminal-output/hx.js +25 -0
  493. package/dist/terminal-output/hx.js.map +1 -0
  494. package/dist/terminal-output/index.d.ts +92 -0
  495. package/dist/terminal-output/index.d.ts.map +1 -0
  496. package/dist/terminal-output/index.js +179 -0
  497. package/dist/terminal-output/index.js.map +1 -0
  498. package/dist/terminal-output/runs.d.ts +27 -0
  499. package/dist/terminal-output/runs.d.ts.map +1 -0
  500. package/dist/terminal-output/runs.js +66 -0
  501. package/dist/terminal-output/runs.js.map +1 -0
  502. package/dist/three/canvas.d.ts +46 -0
  503. package/dist/three/canvas.d.ts.map +1 -0
  504. package/dist/three/canvas.js +305 -0
  505. package/dist/three/canvas.js.map +1 -0
  506. package/dist/three/events.d.ts +64 -0
  507. package/dist/three/events.d.ts.map +1 -0
  508. package/dist/three/events.js +146 -0
  509. package/dist/three/events.js.map +1 -0
  510. package/dist/three/geometries.d.ts +89 -0
  511. package/dist/three/geometries.d.ts.map +1 -0
  512. package/dist/three/geometries.js +345 -0
  513. package/dist/three/geometries.js.map +1 -0
  514. package/dist/three/globals.d.ts +7 -0
  515. package/dist/three/globals.d.ts.map +1 -0
  516. package/dist/three/globals.js +20 -0
  517. package/dist/three/globals.js.map +1 -0
  518. package/dist/three/hx.d.ts +9 -0
  519. package/dist/three/hx.d.ts.map +1 -0
  520. package/dist/three/hx.js +10 -0
  521. package/dist/three/hx.js.map +1 -0
  522. package/dist/three/index.d.ts +22 -0
  523. package/dist/three/index.d.ts.map +1 -0
  524. package/dist/three/index.js +24 -0
  525. package/dist/three/index.js.map +1 -0
  526. package/dist/three/jsx-dev-runtime.d.ts +19 -0
  527. package/dist/three/jsx-dev-runtime.d.ts.map +1 -0
  528. package/dist/three/jsx-dev-runtime.js +2 -0
  529. package/dist/three/jsx-dev-runtime.js.map +1 -0
  530. package/dist/three/jsx-runtime.d.ts +19 -0
  531. package/dist/three/jsx-runtime.d.ts.map +1 -0
  532. package/dist/three/jsx-runtime.js +2 -0
  533. package/dist/three/jsx-runtime.js.map +1 -0
  534. package/dist/three/jsx.d.ts +198 -0
  535. package/dist/three/jsx.d.ts.map +1 -0
  536. package/dist/three/jsx.js +2 -0
  537. package/dist/three/jsx.js.map +1 -0
  538. package/dist/three/materials.d.ts +125 -0
  539. package/dist/three/materials.d.ts.map +1 -0
  540. package/dist/three/materials.js +229 -0
  541. package/dist/three/materials.js.map +1 -0
  542. package/dist/three/math.d.ts +146 -0
  543. package/dist/three/math.d.ts.map +1 -0
  544. package/dist/three/math.js +464 -0
  545. package/dist/three/math.js.map +1 -0
  546. package/dist/three/objects.d.ts +185 -0
  547. package/dist/three/objects.d.ts.map +1 -0
  548. package/dist/three/objects.js +309 -0
  549. package/dist/three/objects.js.map +1 -0
  550. package/dist/three/passes.d.ts +54 -0
  551. package/dist/three/passes.d.ts.map +1 -0
  552. package/dist/three/passes.js +78 -0
  553. package/dist/three/passes.js.map +1 -0
  554. package/dist/three/postprocess.d.ts +85 -0
  555. package/dist/three/postprocess.d.ts.map +1 -0
  556. package/dist/three/postprocess.js +503 -0
  557. package/dist/three/postprocess.js.map +1 -0
  558. package/dist/three/raycast.d.ts +24 -0
  559. package/dist/three/raycast.d.ts.map +1 -0
  560. package/dist/three/raycast.js +138 -0
  561. package/dist/three/raycast.js.map +1 -0
  562. package/dist/three/reconciler.d.ts +48 -0
  563. package/dist/three/reconciler.d.ts.map +1 -0
  564. package/dist/three/reconciler.js +416 -0
  565. package/dist/three/reconciler.js.map +1 -0
  566. package/dist/three/renderer-direct.d.ts +102 -0
  567. package/dist/three/renderer-direct.d.ts.map +1 -0
  568. package/dist/three/renderer-direct.js +782 -0
  569. package/dist/three/renderer-direct.js.map +1 -0
  570. package/dist/three/renderer-indirect.d.ts +68 -0
  571. package/dist/three/renderer-indirect.d.ts.map +1 -0
  572. package/dist/three/renderer-indirect.js +438 -0
  573. package/dist/three/renderer-indirect.js.map +1 -0
  574. package/dist/three/store.d.ts +81 -0
  575. package/dist/three/store.d.ts.map +1 -0
  576. package/dist/three/store.js +110 -0
  577. package/dist/three/store.js.map +1 -0
  578. package/dist/timeline/hx.d.ts +18 -0
  579. package/dist/timeline/hx.d.ts.map +1 -0
  580. package/dist/timeline/hx.js +10 -0
  581. package/dist/timeline/hx.js.map +1 -0
  582. package/dist/timeline/index.d.ts +152 -0
  583. package/dist/timeline/index.d.ts.map +1 -0
  584. package/dist/timeline/index.js +383 -0
  585. package/dist/timeline/index.js.map +1 -0
  586. package/dist/tray-host/hx.d.ts +24 -0
  587. package/dist/tray-host/hx.d.ts.map +1 -0
  588. package/dist/tray-host/hx.js +25 -0
  589. package/dist/tray-host/hx.js.map +1 -0
  590. package/dist/tray-host/index.d.ts +110 -0
  591. package/dist/tray-host/index.d.ts.map +1 -0
  592. package/dist/tray-host/index.js +191 -0
  593. package/dist/tray-host/index.js.map +1 -0
  594. package/dist/tray-host/manager.d.ts +137 -0
  595. package/dist/tray-host/manager.d.ts.map +1 -0
  596. package/dist/tray-host/manager.js +462 -0
  597. package/dist/tray-host/manager.js.map +1 -0
  598. package/dist/tray-host/notify.d.ts +22 -0
  599. package/dist/tray-host/notify.d.ts.map +1 -0
  600. package/dist/tray-host/notify.js +74 -0
  601. package/dist/tray-host/notify.js.map +1 -0
  602. package/dist/tray-host/protocol.d.ts +106 -0
  603. package/dist/tray-host/protocol.d.ts.map +1 -0
  604. package/dist/tray-host/protocol.js +191 -0
  605. package/dist/tray-host/protocol.js.map +1 -0
  606. package/dist/tree/hx.d.ts +24 -0
  607. package/dist/tree/hx.d.ts.map +1 -0
  608. package/dist/tree/hx.js +25 -0
  609. package/dist/tree/hx.js.map +1 -0
  610. package/dist/tree/index.d.ts +221 -0
  611. package/dist/tree/index.d.ts.map +1 -0
  612. package/dist/tree/index.js +716 -0
  613. package/dist/tree/index.js.map +1 -0
  614. package/dist/tree/internal.d.ts +18 -0
  615. package/dist/tree/internal.d.ts.map +1 -0
  616. package/dist/tree/internal.js +66 -0
  617. package/dist/tree/internal.js.map +1 -0
  618. package/dist/tree/rows.d.ts +177 -0
  619. package/dist/tree/rows.d.ts.map +1 -0
  620. package/dist/tree/rows.js +179 -0
  621. package/dist/tree/rows.js.map +1 -0
  622. package/package.json +166 -12
  623. package/src/ansi/cast.ts +159 -0
  624. package/src/ansi/index.ts +53 -0
  625. package/src/ansi/palette.ts +226 -0
  626. package/src/ansi/parse.ts +780 -0
  627. package/src/ansi/sgr.ts +380 -0
  628. package/src/ansi/utf8.ts +84 -0
  629. package/src/calendar/Calendar.ts +744 -0
  630. package/src/calendar/DatePicker.ts +368 -0
  631. package/src/calendar/dates.ts +307 -0
  632. package/src/calendar/index.ts +51 -0
  633. package/src/charts/components.ts +864 -0
  634. package/src/charts/data.ts +776 -0
  635. package/src/charts/hx.ts +39 -0
  636. package/src/charts/index.ts +99 -0
  637. package/src/charts/node.ts +932 -0
  638. package/src/charts/render.ts +982 -0
  639. package/src/charts/scale.ts +214 -0
  640. package/src/charts/spec.ts +122 -0
  641. package/src/charts/timers.ts +24 -0
  642. package/src/code/hx.ts +51 -0
  643. package/src/code/index.ts +174 -0
  644. package/src/code-editor/completion.ts +161 -0
  645. package/src/code-editor/doc.ts +198 -0
  646. package/src/code-editor/hx.ts +26 -0
  647. package/src/code-editor/index.ts +615 -0
  648. package/src/code-editor/node.ts +1926 -0
  649. package/src/code-language/hljs.ts +302 -0
  650. package/src/code-language/index.ts +51 -0
  651. package/src/code-language/languages/glsl.ts +120 -0
  652. package/src/code-language/languages/javascript.ts +419 -0
  653. package/src/code-language/languages/json.ts +72 -0
  654. package/src/code-language/languages/shell.ts +221 -0
  655. package/src/code-language/languages/sql.ts +165 -0
  656. package/src/code-language/lezer.ts +190 -0
  657. package/src/code-language/registry.ts +66 -0
  658. package/src/code-language/runs.ts +128 -0
  659. package/src/code-language/stream.ts +306 -0
  660. package/src/code-language/textmate.ts +134 -0
  661. package/src/code-language/theme.ts +124 -0
  662. package/src/code-language/timers.ts +36 -0
  663. package/src/code-language/types.ts +252 -0
  664. package/src/codeblock/index.ts +146 -0
  665. package/src/color-picker/ColorField.ts +333 -0
  666. package/src/color-picker/ColorPicker.ts +1069 -0
  667. package/src/color-picker/color.ts +399 -0
  668. package/src/color-picker/index.ts +50 -0
  669. package/src/desktop-calendar/eds.ts +484 -0
  670. package/src/desktop-calendar/ical.ts +119 -0
  671. package/src/desktop-calendar/index.ts +287 -0
  672. package/src/embed/client.ts +311 -0
  673. package/src/embed/host.ts +432 -0
  674. package/src/embed/index.ts +33 -0
  675. package/src/embed/timers.ts +32 -0
  676. package/src/flow/draw.ts +418 -0
  677. package/src/flow/index.ts +473 -0
  678. package/src/flow/model.ts +687 -0
  679. package/src/flow/node.ts +3694 -0
  680. package/src/flow/paths.ts +403 -0
  681. package/src/flow/types.ts +634 -0
  682. package/src/formula/hx.ts +51 -0
  683. package/src/formula/index.ts +243 -0
  684. package/src/formula/katex.ts +209 -0
  685. package/src/formula/layout.ts +876 -0
  686. package/src/formula/node.ts +467 -0
  687. package/src/html/controls.ts +244 -0
  688. package/src/html/css/cascade.ts +606 -0
  689. package/src/html/css/parse.ts +500 -0
  690. package/src/html/css/style.ts +1371 -0
  691. package/src/html/css/ua.ts +174 -0
  692. package/src/html/css/values.ts +358 -0
  693. package/src/html/dom.ts +410 -0
  694. package/src/html/hx.ts +55 -0
  695. package/src/html/index.ts +545 -0
  696. package/src/html/layout/block.ts +876 -0
  697. package/src/html/layout/boxes.ts +981 -0
  698. package/src/html/layout/flex.ts +297 -0
  699. package/src/html/layout/floats.ts +125 -0
  700. package/src/html/layout/inline.ts +870 -0
  701. package/src/html/layout/table.ts +380 -0
  702. package/src/html/node.ts +967 -0
  703. package/src/html/paint.ts +605 -0
  704. package/src/html/resources.ts +214 -0
  705. package/src/index.ts +638 -0
  706. package/src/internal/heights.ts +211 -0
  707. package/src/internal/hx.ts +53 -0
  708. package/src/internal/text.ts +44 -0
  709. package/src/internal/timers.ts +44 -0
  710. package/src/internal/widget.ts +92 -0
  711. package/src/markdown/ast.ts +163 -0
  712. package/src/markdown/hx.ts +51 -0
  713. package/src/markdown/index.ts +686 -0
  714. package/src/markdown/parse.ts +1037 -0
  715. package/src/markdown/spans.ts +143 -0
  716. package/src/media-player/backends.ts +155 -0
  717. package/src/media-player/control.ts +70 -0
  718. package/src/media-player/hx.ts +51 -0
  719. package/src/media-player/index.ts +378 -0
  720. package/src/media-player/mpv.ts +146 -0
  721. package/src/media-player/vlc.ts +97 -0
  722. package/src/richtext/index.ts +42 -0
  723. package/src/richtext/links.ts +78 -0
  724. package/src/richtext/menu.ts +49 -0
  725. package/src/richtext/node.ts +406 -0
  726. package/src/richtext/runs.ts +253 -0
  727. package/src/table/hx.ts +51 -0
  728. package/src/table/index.ts +1219 -0
  729. package/src/table/rows.ts +275 -0
  730. package/src/terminal/backends.ts +242 -0
  731. package/src/terminal/hx.ts +51 -0
  732. package/src/terminal/index.ts +574 -0
  733. package/src/terminal/title.ts +172 -0
  734. package/src/terminal/vt/colors.ts +247 -0
  735. package/src/terminal/vt/diff.ts +467 -0
  736. package/src/terminal/vt/fonts.ts +233 -0
  737. package/src/terminal/vt/index.ts +443 -0
  738. package/src/terminal/vt/keys.ts +369 -0
  739. package/src/terminal/vt/mouse.ts +144 -0
  740. package/src/terminal/vt/node.ts +1242 -0
  741. package/src/terminal/vt/pty.ts +334 -0
  742. package/src/terminal/vt/renderer.ts +555 -0
  743. package/src/terminal/vt/timers.ts +31 -0
  744. package/src/terminal/vt/xterm.ts +193 -0
  745. package/src/terminal-output/hx.ts +51 -0
  746. package/src/terminal-output/index.ts +340 -0
  747. package/src/terminal-output/runs.ts +79 -0
  748. package/src/three/canvas.ts +451 -0
  749. package/src/three/events.ts +197 -0
  750. package/src/three/geometries.ts +429 -0
  751. package/src/three/globals.ts +34 -0
  752. package/src/three/hx.ts +22 -0
  753. package/src/three/index.ts +98 -0
  754. package/src/three/jsx-dev-runtime.ts +27 -0
  755. package/src/three/jsx-runtime.ts +38 -0
  756. package/src/three/jsx.ts +275 -0
  757. package/src/three/materials.ts +270 -0
  758. package/src/three/math.ts +585 -0
  759. package/src/three/objects.ts +397 -0
  760. package/src/three/passes.ts +90 -0
  761. package/src/three/postprocess.ts +677 -0
  762. package/src/three/raycast.ts +189 -0
  763. package/src/three/react-reconciler.d.ts +50 -0
  764. package/src/three/reconciler.ts +624 -0
  765. package/src/three/renderer-direct.ts +1055 -0
  766. package/src/three/renderer-indirect.ts +570 -0
  767. package/src/three/store.ts +207 -0
  768. package/src/timeline/hx.ts +31 -0
  769. package/src/timeline/index.ts +603 -0
  770. package/src/tray-host/hx.ts +51 -0
  771. package/src/tray-host/index.ts +331 -0
  772. package/src/tray-host/manager.ts +639 -0
  773. package/src/tray-host/notify.ts +102 -0
  774. package/src/tray-host/protocol.ts +235 -0
  775. package/src/tree/hx.ts +51 -0
  776. package/src/tree/index.ts +1087 -0
  777. package/src/tree/internal.ts +87 -0
  778. package/src/tree/rows.ts +334 -0
  779. package/src/index.d.ts +0 -2
  780. package/src/index.js +0 -9
  781. package/src/sparkline/index.d.ts +0 -29
  782. package/src/sparkline/index.js +0 -50
  783. package/src/sparkline/node.js +0 -72
@@ -0,0 +1,3694 @@
1
+ // The retained node behind `<flowgraph>`: the viewport, every gesture, and
2
+ // all of the drawing.
3
+ //
4
+ // **Why one node draws the whole graph.** react-flow gives each node a DOM
5
+ // subtree and pans and zooms with a CSS transform on their common parent, so
6
+ // the browser moves ten thousand boxes for free. This renderer has no
7
+ // transform — `style` is yoga plus paint, and zoom is not among them — so
8
+ // the same design here would mean re-laying-out and re-rendering every node
9
+ // on every pointer step of a pan, through React and through yoga, sixty
10
+ // times a second. Drawing the graph instead makes the viewport arithmetic:
11
+ // panning is two numbers and a repaint of one node's rect, zoom scales the
12
+ // text along with everything else, and React is not involved at all unless
13
+ // the graph itself changed. `<codeeditor>` in this package is the same call
14
+ // made for the same reason.
15
+ //
16
+ // **How input arrives.** Through the default-action seam (react-x11#266):
17
+ // `defaultMouseDown`/`Drag`/`Up`, `defaultKeyDown`, `defaultContextMenu` run
18
+ // after the app's own handlers and not at all if one of them called
19
+ // `preventDefault()`. The wheel and plain pointer motion have no such seam
20
+ // (filed as react-x11#302), so `<Flow>` forwards those two through
21
+ // `handleWheel`/`handleHover` — with the same veto, checked here.
22
+ import * as ntk from 'react-x11/ntk';
23
+ import { Node } from 'react-x11/node';
24
+ import type { A11ySceneAction, A11ySceneItem } from 'react-x11/node';
25
+ import type { Context2D } from 'react-x11/node';
26
+ import { tint } from 'react-x11/style';
27
+ import type { KeyboardEvent, MouseEvent, WheelEvent } from 'react-x11';
28
+ import {
29
+ ctrlChordLetter,
30
+ keysymOf,
31
+ XK_BACKSPACE,
32
+ XK_DELETE,
33
+ XK_DOWN,
34
+ XK_ESCAPE,
35
+ XK_HOME,
36
+ XK_LEFT,
37
+ XK_RIGHT,
38
+ XK_UP,
39
+ } from 'react-x11/keysyms';
40
+
41
+ import { createPainter, measureText } from './draw.js';
42
+ import type { CachedText, FontsLike, PainterOptions } from './draw.js';
43
+ import {
44
+ boundsOf,
45
+ canConnect,
46
+ clamp,
47
+ DEFAULT_MARKER_SIZE,
48
+ DEFAULT_MAX_ZOOM,
49
+ DEFAULT_MIN_ZOOM,
50
+ EDGE_SLOP,
51
+ EDGE_STEP_OFFSET,
52
+ fitViewport,
53
+ handleAnchor,
54
+ HANDLE_RADIUS,
55
+ HANDLE_SLOP,
56
+ gripPoint,
57
+ inflateRect,
58
+ intersectRects,
59
+ measureNode,
60
+ MIN_NODE_HEIGHT,
61
+ MIN_NODE_WIDTH,
62
+ NODE_BODY_INSET,
63
+ NODE_DESC_SIZE,
64
+ NODE_HEADER,
65
+ NODE_LABEL_SIZE,
66
+ NODE_PAD_X,
67
+ NODE_PAD_Y,
68
+ normalizeBackground,
69
+ normalizeMarker,
70
+ orientConnection,
71
+ rectContains,
72
+ rectsOverlap,
73
+ RENDER_ZOOM,
74
+ RESIZE_DIRECTIONS,
75
+ RESIZE_GRIP,
76
+ RESIZE_SLOP,
77
+ resizeRect,
78
+ resolveHandles,
79
+ resolvePalette,
80
+ snapTo,
81
+ unionRects,
82
+ ZOOM_STEP,
83
+ } from './model.js';
84
+ import {
85
+ distanceToPath,
86
+ edgePath,
87
+ endAngle,
88
+ pathBounds,
89
+ pointAtFraction,
90
+ startAngle,
91
+ trimEnd,
92
+ } from './paths.js';
93
+ import type {
94
+ BackgroundOptions,
95
+ BackgroundVariant,
96
+ ControlsOptions,
97
+ EdgeChange,
98
+ FitViewOptions,
99
+ FlowEdge,
100
+ FlowNode,
101
+ FlowNodeData,
102
+ FlowNodeType,
103
+ FlowPainter,
104
+ FlowPalette,
105
+ FlowRect,
106
+ FlowInstance,
107
+ HandleAnchor,
108
+ HandleSpec,
109
+ MiniMapOptions,
110
+ NodeBodyRect,
111
+ NodeChange,
112
+ ShapeOptions,
113
+ StrokeOptions,
114
+ TextOptions,
115
+ Viewport,
116
+ XYPosition,
117
+ } from './types.js';
118
+
119
+ /** Registration key, `kind` and JSX tag, one string — react-x11 rejects a
120
+ * node whose `kind` is not the name it was registered under, because `kind`
121
+ * is what paint order, the test queries and the DEV style assertion all
122
+ * match on. */
123
+ export const ELEMENT = 'flowgraph';
124
+
125
+ /** The slice of ntk's Surface/pattern API the grid tile uses, typed here
126
+ * structurally — `react-x11/ntk`'s declarations are deliberately loose. */
127
+ interface SurfaceLike {
128
+ render(fn: (ctx: TileContext) => void): unknown;
129
+ destroy?(): void;
130
+ }
131
+ interface TileContext {
132
+ fillStyle: unknown;
133
+ fillRect(x: number, y: number, w: number, h: number): void;
134
+ clearRect?(x: number, y: number, w: number, h: number): void;
135
+ }
136
+ type SurfaceCtor = new (
137
+ app: unknown,
138
+ options: { width: number; height: number; format: string },
139
+ ) => SurfaceLike;
140
+ interface PatternLike {
141
+ _picture?: { destroy?(): void };
142
+ }
143
+
144
+ /** Timers, through `globalThis`: `src/` compiles with `types: []` so a Node
145
+ * global that wandered in would become an implicit `@types/node` dependency
146
+ * a consumer has to satisfy. */
147
+ const timers = globalThis as {
148
+ setInterval?(fn: () => void, ms: number): unknown;
149
+ clearInterval?(id: unknown): void;
150
+ };
151
+
152
+ /** How often the dash on an animated edge moves. Slow enough that a graph
153
+ * full of them is not a repaint storm, fast enough to read as motion. */
154
+ const ANIMATION_MS = 60;
155
+ const ANIMATION_SPEED = 1.4; // px of dash travel per tick, at zoom 1
156
+ const DEFAULT_DASH = [7, 5];
157
+
158
+ /** Below these zooms the pane stops drawing detail nobody could read — the
159
+ * cheapest optimisation there is, and the one that keeps a zoomed-out
160
+ * overview interactive. */
161
+ const LABEL_ZOOM = 0.45;
162
+ const HANDLE_ZOOM = 0.5;
163
+ const DESC_ZOOM = 0.6;
164
+
165
+ /** Screen pixels the pointer may travel before a press becomes a drag. */
166
+ const DRAG_THRESHOLD = 3;
167
+ /** The grid never draws denser than this on screen, whatever the zoom. */
168
+ const MIN_GRID_PX = 16;
169
+
170
+ /** See `StrokeBuckets.BATCH_MIN`. */
171
+ const MARKER_BATCH_MIN = 24;
172
+
173
+ /**
174
+ * How far outside its own box a node's ink can land: handles (capped at
175
+ * 6 × 1.4 plus their outline), resize grips, the selection border, a pixel
176
+ * of antialiasing. Damage rects grow by this before they are invalidated,
177
+ * and cull tests grow their target by the same amount — the two must agree,
178
+ * or a moved node leaves crumbs of its old handles behind.
179
+ */
180
+ const CULL_MARGIN = 16;
181
+
182
+ /**
183
+ * The props whose change means different pixels. `applyProps` repaints when
184
+ * one of these moves and stays quiet otherwise — the event handlers are
185
+ * recreated on every render of the component above, and a full repaint per
186
+ * re-render is what made dragging repaint the world twice.
187
+ *
188
+ * Compared by shallow value, not identity: `background={{ variant: 'dots' }}`
189
+ * written inline is a new object on every render of the app, and treating
190
+ * that as a change would put the full repaint right back.
191
+ */
192
+ const VISUAL_PROPS = [
193
+ 'background',
194
+ 'minimap',
195
+ 'controls',
196
+ 'palette',
197
+ 'viewport',
198
+ 'minZoom',
199
+ 'maxZoom',
200
+ 'nodesConnectable',
201
+ 'nodesResizable',
202
+ 'disabled',
203
+ ] as const;
204
+
205
+ const CONTROL_SIZE = 26;
206
+ const PANEL_MARGIN = 10;
207
+ const MINIMAP_W = 190;
208
+ const MINIMAP_H = 130;
209
+
210
+ type AnyNode = FlowNode<unknown>;
211
+ type AnyEdge = FlowEdge<unknown>;
212
+ type AnyType = FlowNodeType<unknown>;
213
+
214
+ /** A node, resolved: its size and its handle specs, both of which only
215
+ * change when the graph does. Positions are *not* cached — a drag moves them
216
+ * between commits, and reading them through {@link FlowGraphNode.rectOf} is
217
+ * what keeps the two in step. */
218
+ interface NodeEntry {
219
+ node: AnyNode;
220
+ width: number;
221
+ height: number;
222
+ specs: readonly HandleSpec[];
223
+ type: AnyType | undefined;
224
+ }
225
+
226
+ type Gesture =
227
+ | {
228
+ kind: 'pan';
229
+ /** False when `panOnDrag` is off: the gesture still exists, because it
230
+ * is also what turns a press and release into `onPaneClick`. */
231
+ pans: boolean;
232
+ startX: number;
233
+ startY: number;
234
+ vx: number;
235
+ vy: number;
236
+ }
237
+ | {
238
+ kind: 'drag';
239
+ ids: string[];
240
+ primary: string;
241
+ origin: Map<string, XYPosition>;
242
+ startX: number;
243
+ startY: number;
244
+ moved: boolean;
245
+ }
246
+ | {
247
+ kind: 'connect';
248
+ from: HandleAnchor;
249
+ pointer: XYPosition;
250
+ to: HandleAnchor | null;
251
+ valid: boolean;
252
+ /** Where the line was last drawn, so the next step can erase it. */
253
+ box?: FlowRect;
254
+ }
255
+ | {
256
+ kind: 'select';
257
+ startX: number;
258
+ startY: number;
259
+ x: number;
260
+ y: number;
261
+ base: Set<string>;
262
+ }
263
+ /** A press that landed on an edge. It moves nothing; it exists so that
264
+ * the release can tell a click from the start of something else. */
265
+ | { kind: 'edge'; id: string; startX: number; startY: number }
266
+ | {
267
+ kind: 'resize';
268
+ id: string;
269
+ /** Which grip, as a unit direction: `{-1,-1}` is the top-left. */
270
+ dir: XYPosition;
271
+ startX: number;
272
+ startY: number;
273
+ origin: FlowRect;
274
+ }
275
+ | { kind: 'minimap' };
276
+
277
+ /** What the pointer is over, for hover styling and for the cursor a press
278
+ * would produce. */
279
+ interface HoverState {
280
+ nodeId: string | null;
281
+ handle: HandleAnchor | null;
282
+ edgeId: string | null;
283
+ }
284
+
285
+ const NO_HOVER: HoverState = { nodeId: null, handle: null, edgeId: null };
286
+
287
+ /** The rectangle a box-selection gesture spans on screen. */
288
+ function selectBoxRect(g: {
289
+ startX: number;
290
+ startY: number;
291
+ x: number;
292
+ y: number;
293
+ }): FlowRect {
294
+ return {
295
+ x: Math.min(g.startX, g.x),
296
+ y: Math.min(g.startY, g.y),
297
+ width: Math.abs(g.x - g.startX),
298
+ height: Math.abs(g.y - g.startY),
299
+ };
300
+ }
301
+
302
+ /**
303
+ * The props whose repaints this element claims for itself — everything
304
+ * `applyProps` below either diffs into a damage rect (`nodes`, `edges`…),
305
+ * repaints in full when it truly changed (the visual list), or that changes
306
+ * behaviour without touching a pixel (`snapToGrid`, the gesture switches).
307
+ * Declared to `registerElement` as `selfDamagedProps`, so core's per-commit
308
+ * `paintChanged` contributes no damage for them — without the declaration,
309
+ * every drag step is a full-pane repaint again, because the commit's new
310
+ * `nodes` array identity would damage the whole node.
311
+ */
312
+ export const SELF_DAMAGED_PROPS: readonly string[] = [
313
+ 'nodes',
314
+ 'edges',
315
+ 'nodeTypes',
316
+ 'defaultEdgeOptions',
317
+ ...VISUAL_PROPS,
318
+ 'defaultViewport',
319
+ 'fitView',
320
+ 'fitViewOptions',
321
+ 'isValidConnection',
322
+ 'connectionMode',
323
+ 'nodesDraggable',
324
+ 'elementsSelectable',
325
+ 'panOnDrag',
326
+ 'zoomOnScroll',
327
+ 'zoomOnDoubleClick',
328
+ 'selectionOnDrag',
329
+ 'deleteOnKey',
330
+ 'snapToGrid',
331
+ 'snapGrid',
332
+ ];
333
+
334
+ /** Two edges saying the same thing, whatever the object identity. `style`
335
+ * and the markers compare one level deep; `data` is the app's and compares
336
+ * by identity, the same rule the node diff applies. */
337
+ function edgeValueEqual(a: AnyEdge, b: AnyEdge): boolean {
338
+ return (
339
+ a.source === b.source &&
340
+ a.target === b.target &&
341
+ (a.sourceHandle ?? null) === (b.sourceHandle ?? null) &&
342
+ (a.targetHandle ?? null) === (b.targetHandle ?? null) &&
343
+ a.type === b.type &&
344
+ a.label === b.label &&
345
+ a.animated === b.animated &&
346
+ a.selected === b.selected &&
347
+ a.hidden === b.hidden &&
348
+ a.selectable === b.selectable &&
349
+ a.deletable === b.deletable &&
350
+ a.zIndex === b.zIndex &&
351
+ a.data === b.data &&
352
+ shallowEqual(a.style, b.style) &&
353
+ shallowEqual(a.markerEnd, b.markerEnd) &&
354
+ shallowEqual(a.markerStart, b.markerStart)
355
+ );
356
+ }
357
+
358
+ /** One level of value equality, for the object-shaped props above. */
359
+ function shallowEqual(a: unknown, b: unknown): boolean {
360
+ if (Object.is(a, b)) return true;
361
+ if (typeof a !== 'object' || typeof b !== 'object' || !a || !b) return false;
362
+ if (Array.isArray(a) !== Array.isArray(b)) return false;
363
+ const ka = Object.keys(a as Record<string, unknown>);
364
+ const kb = Object.keys(b as Record<string, unknown>);
365
+ if (ka.length !== kb.length) return false;
366
+ for (const key of ka) {
367
+ if (
368
+ !Object.is(
369
+ (a as Record<string, unknown>)[key],
370
+ (b as Record<string, unknown>)[key],
371
+ )
372
+ ) {
373
+ return false;
374
+ }
375
+ }
376
+ return true;
377
+ }
378
+
379
+ /** Union where either side may be missing. */
380
+ function unionMaybe(a: FlowRect | null, b: FlowRect | null): FlowRect | null {
381
+ if (!a) return b;
382
+ if (!b) return a;
383
+ return unionRects(a, b);
384
+ }
385
+
386
+ /** An arrowhead pile: the filled heads and the open ones share a colour, so
387
+ * they share a bucket and differ only in which list they land in. */
388
+ interface MarkerBucket {
389
+ color: string;
390
+ lineWidth: number;
391
+ filled: XYPosition[][];
392
+ open: XYPosition[][];
393
+ }
394
+
395
+ /**
396
+ * Edge strokes, grouped by the pen that will draw them.
397
+ *
398
+ * Everything a graph strokes is the same two or three pens — the default
399
+ * edge, the selected one, the animated dash — so grouping collapses a
400
+ * per-edge request into a per-pen one. The key has to carry the dash *and*
401
+ * its offset: two edges marching out of phase cannot share a path, because
402
+ * the offset is set on the context, not on the subpath.
403
+ */
404
+ class StrokeBuckets {
405
+ private readonly byPen = new Map<
406
+ string,
407
+ { options: StrokeOptions; runs: XYPosition[][] }
408
+ >();
409
+
410
+ bucket(
411
+ stroke: string,
412
+ lineWidth: number,
413
+ dash: readonly number[] | undefined,
414
+ phase: number,
415
+ zoom: number,
416
+ ): XYPosition[][] {
417
+ const scaled = dash?.map((d) => d * zoom);
418
+ const key = `${stroke}|${lineWidth}|${scaled?.join(',') ?? ''}|${phase}`;
419
+ let entry = this.byPen.get(key);
420
+ if (!entry) {
421
+ entry = {
422
+ options: {
423
+ stroke,
424
+ lineWidth,
425
+ dash: scaled,
426
+ dashOffset: phase ? -phase * zoom : 0,
427
+ },
428
+ runs: [],
429
+ };
430
+ this.byPen.set(key, entry);
431
+ }
432
+ return entry.runs;
433
+ }
434
+
435
+ /**
436
+ * Below this many edges in one pen, they are stroked one at a time.
437
+ *
438
+ * A path's mask is its bounding box, so batching scattered geometry trades
439
+ * many small masks for one the size of the pane — about three quarters of
440
+ * a megabyte at a normal window size. That is a large win at seven hundred
441
+ * edges (measured: 3.9 MB a frame down to 1.3) and a loss at twenty, where
442
+ * the individual masks never add up to a paneful. The threshold is where
443
+ * they start to.
444
+ */
445
+ private static readonly BATCH_MIN = 24;
446
+
447
+ paint(painter: FlowPainter): void {
448
+ for (const { options, runs } of this.byPen.values()) {
449
+ if (runs.length >= StrokeBuckets.BATCH_MIN) {
450
+ painter.strokeRuns(runs, options);
451
+ } else {
452
+ for (const run of runs) painter.polyline(run, options);
453
+ }
454
+ }
455
+ }
456
+ }
457
+
458
+ /**
459
+ * Are these the same handle? Anchors are rebuilt from the node on every read
460
+ * — a drag moves them between commits — so identity says nothing, and the
461
+ * `id` alone says too little: the node every graph starts with has *two*
462
+ * handles with no id at all. Node, id, type and side together are what
463
+ * distinguish them.
464
+ */
465
+ function sameHandle(
466
+ a: HandleAnchor | null | undefined,
467
+ b: HandleAnchor,
468
+ ): boolean {
469
+ return (
470
+ a != null &&
471
+ a.nodeId === b.nodeId &&
472
+ (a.id ?? null) === (b.id ?? null) &&
473
+ a.type === b.type &&
474
+ a.position === b.position
475
+ );
476
+ }
477
+
478
+ export class FlowGraphNode extends Node implements FlowInstance {
479
+ // --- viewport, owned here unless the `viewport` prop takes it over ------
480
+ private _vp: Viewport = { x: 0, y: 0, zoom: 1 };
481
+ private _fitPending = false;
482
+
483
+ // --- derived graph, rebuilt only when the arrays change -----------------
484
+ private _nodesSeen: unknown;
485
+ private _typesSeen: unknown;
486
+ private _edgesSeen: unknown;
487
+ private _edgeDefaultsSeen: unknown;
488
+ private _fontSeen = '';
489
+ private _entries: NodeEntry[] = [];
490
+ private _byId = new Map<string, NodeEntry>();
491
+ private _edgesByNode = new Map<string, AnyEdge[]>();
492
+ /** The prop array the built edges came from, for the per-edge diff. */
493
+ private _edgesRaw: AnyEdge[] = [];
494
+ /** Paint order: `zIndex`, then selection, so a selected node is not hidden
495
+ * under one it overlaps. Hit testing walks it backwards. */
496
+ private _order: NodeEntry[] = [];
497
+ private _edges: AnyEdge[] = [];
498
+
499
+ // --- interaction --------------------------------------------------------
500
+ private _gesture: Gesture | null = null;
501
+ private _hover: HoverState = NO_HOVER;
502
+ /** Where a drag is putting each node it moves. Read in preference to
503
+ * `node.position`, so the pane is never a frame behind the pointer even
504
+ * when the app applies the changes it is being sent. Cleared on release,
505
+ * which is also how a refused drag snaps back. */
506
+ private _dragTo: Map<string, XYPosition> | null = null;
507
+ /** The box a resize is currently making, for the same reason. */
508
+ private _resizeTo: { id: string; rect: FlowRect } | null = null;
509
+ /** What was last handed to `onNodeBodies`, so the React half is told only
510
+ * when something actually moved. */
511
+ private _bodiesKey = '';
512
+ private _dashPhase = 0;
513
+ private _animTimer: unknown = null;
514
+ /** Inside `paint`, where an invalidation would only schedule a redraw of
515
+ * the frame being drawn. */
516
+ private _painting = false;
517
+ /** Inside a live input dispatch — what makes a body emission `sync`.
518
+ * Motion and the wheel run at continuous priority, whose React updates
519
+ * can trail the pane's own painting by frames; an emission made under
520
+ * this flag asks the receiver to commit before the dispatch returns. */
521
+ private _gestureSync = false;
522
+ /** The scene has been offered to assistive tech at least once — items
523
+ * only exist once layout has placed them, which no commit marks. */
524
+ private _sceneAnnounced = false;
525
+ /** The damage rect of the pass being painted, or null for a full one —
526
+ * what the drawing subroutines cull against. */
527
+ private _frameClip: FlowRect | null = null;
528
+ /** Where the animated edges were last frame, so the dash timer can
529
+ * invalidate that box instead of the whole pane. */
530
+ private _animBox: FlowRect | null = null;
531
+
532
+ private _textCache = new Map<string, CachedText>();
533
+ /** The grid's repeating tile: one surface for as long as the pitch
534
+ * holds, re-rendered in place when the phase or the colours move. */
535
+ private _gridTile: {
536
+ key: string;
537
+ size: number;
538
+ surface: SurfaceLike;
539
+ pattern: PatternLike;
540
+ } | null = null;
541
+
542
+ constructor(props: Record<string, unknown>, app: unknown) {
543
+ super(ELEMENT, props, app as ConstructorParameters<typeof Node>[2]);
544
+ // Without this nothing focuses the pane and no key ever reaches it; an
545
+ // app's `focusable`/`tabIndex` still overrides either way.
546
+ this.focusableByDefault = true;
547
+ // What the element is to a screen reader when the app says nothing —
548
+ // the scene below fills in what is inside it.
549
+ this.a11yRole = 'group';
550
+ const initial = (props.defaultViewport ?? props.viewport) as
551
+ Viewport | undefined;
552
+ if (initial) this._vp = { ...initial };
553
+ this._fitPending = props.fitView === true;
554
+ }
555
+
556
+ // --- props ---------------------------------------------------------------
557
+
558
+ private _prop<T>(name: string): T | undefined {
559
+ return this.props[name] as T | undefined;
560
+ }
561
+
562
+ private _bool(name: string, fallback: boolean): boolean {
563
+ const v = this.props[name];
564
+ return typeof v === 'boolean' ? v : fallback;
565
+ }
566
+
567
+ private _num(name: string, fallback: number): number {
568
+ const v = this.props[name];
569
+ return typeof v === 'number' && Number.isFinite(v) ? v : fallback;
570
+ }
571
+
572
+ private get _minZoom(): number {
573
+ return this._num('minZoom', DEFAULT_MIN_ZOOM);
574
+ }
575
+
576
+ private get _maxZoom(): number {
577
+ return this._num('maxZoom', DEFAULT_MAX_ZOOM);
578
+ }
579
+
580
+ private _nodes(): readonly AnyNode[] {
581
+ const v = this.props.nodes;
582
+ return Array.isArray(v) ? (v as AnyNode[]) : [];
583
+ }
584
+
585
+ private _rawEdges(): readonly AnyEdge[] {
586
+ const v = this.props.edges;
587
+ return Array.isArray(v) ? (v as AnyEdge[]) : [];
588
+ }
589
+
590
+ // --- text ----------------------------------------------------------------
591
+
592
+ private _fonts(): FontsLike | null {
593
+ return (
594
+ (this.app as { fonts?: FontsLike } | null | undefined)?.fonts ?? null
595
+ );
596
+ }
597
+
598
+ private _themeString(token: string): string | undefined {
599
+ const v = (this.theme as Record<string, unknown> | null)?.[token];
600
+ return typeof v === 'string' ? v : undefined;
601
+ }
602
+
603
+ private _textOptions(): PainterOptions {
604
+ return {
605
+ fonts: this._fonts(),
606
+ family:
607
+ (this.style.fontFamily as string | undefined) ??
608
+ this._themeString('fontFamily') ??
609
+ 'sans-serif',
610
+ color: this._palette().text,
611
+ cache: this._textCache,
612
+ };
613
+ }
614
+
615
+ private _measure = (text: string, options?: TextOptions): number =>
616
+ measureText(this._textOptions(), text, options).width;
617
+
618
+ private _palette(): FlowPalette {
619
+ return resolvePalette(
620
+ this.theme as Record<string, unknown> | null,
621
+ this._prop<Partial<FlowPalette>>('palette'),
622
+ );
623
+ }
624
+
625
+ // --- the derived graph ---------------------------------------------------
626
+
627
+ /**
628
+ * Rebuild the node index when — and only when — one of its inputs changed
629
+ * identity. Sizes come out of the font stack, so the face is an input too:
630
+ * a theme switch that changes it has to re-measure or every node keeps the
631
+ * width it had under the old one.
632
+ */
633
+ private _sync(): void {
634
+ const types = this.props.nodeTypes;
635
+ const opts = this._textOptions();
636
+ const fontKey = `${opts.family}|${this._fonts() ? 1 : 0}`;
637
+ if (types !== this._typesSeen || fontKey !== this._fontSeen) {
638
+ this._typesSeen = types;
639
+ this._fontSeen = fontKey;
640
+ this._nodesSeen = this.props.nodes;
641
+ this._rebuildNodes();
642
+ } else if (this.props.nodes !== this._nodesSeen) {
643
+ this._applyNodes(this._nodes());
644
+ }
645
+ const defaults = this.props.defaultEdgeOptions;
646
+ if (defaults !== this._edgeDefaultsSeen) {
647
+ this._edgeDefaultsSeen = defaults;
648
+ this._edgesSeen = this.props.edges;
649
+ this._rebuildEdges();
650
+ } else if (this.props.edges !== this._edgesSeen) {
651
+ this._applyEdges(this._rawEdges());
652
+ }
653
+ }
654
+
655
+ /**
656
+ * Fold a new `edges` array into the built list, the cheap way when that
657
+ * is honest — the `_applyNodes` treatment, for the other array.
658
+ *
659
+ * An app that writes `edges={[…]}` inline hands over a fresh array of
660
+ * fresh objects on every render, most of them value-identical to the
661
+ * last. Those keep their built entry (same object, so everything
662
+ * downstream that compares by identity stays quiet). An edge that truly
663
+ * changed is rebuilt alone and claims its own route, old and new. Adds,
664
+ * removes and reorders fall back to the full rebuild.
665
+ */
666
+ private _applyEdges(raw: readonly AnyEdge[]): 'full' | FlowRect | null {
667
+ const prev = this._edgesRaw;
668
+ this._edgesSeen = this.props.edges;
669
+ if (prev.length !== raw.length) {
670
+ this._rebuildEdges();
671
+ return 'full';
672
+ }
673
+ const defaults = this._prop<Partial<AnyEdge>>('defaultEdgeOptions');
674
+ let damage: FlowRect | null = null;
675
+ let adjacencyDirty = false;
676
+ for (let i = 0; i < raw.length; i++) {
677
+ const next = raw[i];
678
+ const old = prev[i];
679
+ if (next === old) continue;
680
+ if (next.id !== old.id) {
681
+ this._rebuildEdges();
682
+ return 'full';
683
+ }
684
+ if (edgeValueEqual(next, old)) {
685
+ // a fresh literal saying the same thing: keep the built entry
686
+ continue;
687
+ }
688
+ const wasBox = this._edgeCoarseBox(this._edges[i]);
689
+ this._edges[i] = defaults
690
+ ? ({ ...defaults, ...next } as AnyEdge)
691
+ : (next as AnyEdge);
692
+ this._edgesRaw[i] = next;
693
+ const isBox = this._edgeCoarseBox(this._edges[i]);
694
+ if (wasBox) damage = damage ? unionRects(damage, wasBox) : wasBox;
695
+ if (isBox) damage = damage ? unionRects(damage, isBox) : isBox;
696
+ if (next.source !== old.source || next.target !== old.target) {
697
+ adjacencyDirty = true;
698
+ }
699
+ }
700
+ // keep the raw array in step even where entries were kept
701
+ this._edgesRaw = raw as AnyEdge[];
702
+ if (adjacencyDirty) this._rebuildAdjacency();
703
+ return damage;
704
+ }
705
+
706
+ /**
707
+ * Fold a new `nodes` array into the existing entries, the cheap way when
708
+ * that is honest.
709
+ *
710
+ * During a drag the array is rebuilt by the app on every pointer step, and
711
+ * the only thing in it that changed is one node's `position`. Re-measuring
712
+ * and re-sorting three hundred nodes per step — the full `_rebuildNodes` —
713
+ * was most of a drag frame's CPU. So: if every node differs from its entry
714
+ * in nothing but `position`, the entries are updated in place (same
715
+ * objects, same order, same measured sizes) and the return value is the
716
+ * screen box that actually changed — the moved nodes, old and new, plus
717
+ * every edge that touches them. Anything else — a label, a size, a
718
+ * selection, an add or remove — falls back to the full rebuild, because it
719
+ * can change measurement or paint order.
720
+ *
721
+ * Returns `'full'` after a rebuild, a damage rect after an in-place fold,
722
+ * and `null` when nothing visual changed at all.
723
+ */
724
+ private _applyNodes(nodes: readonly AnyNode[]): 'full' | FlowRect | null {
725
+ const prev = this._entries;
726
+ this._nodesSeen = this.props.nodes;
727
+ let structural = nodes.length !== prev.length;
728
+ if (!structural) {
729
+ for (let i = 0; i < nodes.length; i++) {
730
+ const n = nodes[i];
731
+ const o = prev[i].node;
732
+ if (n === o) continue;
733
+ if (
734
+ n.id !== o.id ||
735
+ n.type !== o.type ||
736
+ n.selected !== o.selected ||
737
+ n.hidden !== o.hidden ||
738
+ n.zIndex !== o.zIndex ||
739
+ n.handles !== o.handles ||
740
+ n.sourcePosition !== o.sourcePosition ||
741
+ n.targetPosition !== o.targetPosition ||
742
+ n.resizable !== o.resizable ||
743
+ n.connectable !== o.connectable
744
+ ) {
745
+ structural = true;
746
+ break;
747
+ }
748
+ }
749
+ }
750
+ if (structural) {
751
+ this._rebuildNodes();
752
+ return 'full';
753
+ }
754
+ let damage: FlowRect | null = null;
755
+ for (let i = 0; i < nodes.length; i++) {
756
+ const entry = prev[i];
757
+ const next = nodes[i];
758
+ const old = entry.node;
759
+ if (next === old) continue;
760
+ const moved =
761
+ next.position.x !== old.position.x ||
762
+ next.position.y !== old.position.y;
763
+ // `data`, an explicit size or a paint style are the node's own to
764
+ // change: they re-measure and repaint *this* node, never the graph. A
765
+ // keystroke into a mounted node's textarea patches `data` on every
766
+ // character — as a structural change that was a full re-measure and a
767
+ // full-pane repaint per keypress, which is what "typing feels slow"
768
+ // turned out to mean.
769
+ const reshaped =
770
+ next.data !== old.data ||
771
+ next.width !== old.width ||
772
+ next.height !== old.height;
773
+ const restyled = next.style !== old.style;
774
+ if (!moved && !reshaped && !restyled) {
775
+ // a behavioural flag (draggable, deletable…) — nothing drawn reads it
776
+ entry.node = next;
777
+ continue;
778
+ }
779
+ const widthBefore = entry.width;
780
+ const heightBefore = entry.height;
781
+ let box = moved ? this._nodeDamage(entry) : this._screenRect(entry);
782
+ entry.node = next;
783
+ if (reshaped) {
784
+ const size = measureNode(next, entry.type, this._measure);
785
+ entry.width = size.width;
786
+ entry.height = size.height;
787
+ entry.specs = resolveHandles(next, entry.type);
788
+ }
789
+ const grew = entry.width !== widthBefore || entry.height !== heightBefore;
790
+ // The edges ride along only when an endpoint actually moved — a
791
+ // label edit that kept the box is the box's own business, and a
792
+ // keystroke that unioned its node's edges swept half the layer's
793
+ // neighbours into every repaint.
794
+ box = unionRects(
795
+ box,
796
+ moved || grew ? this._nodeDamage(entry) : this._screenRect(entry),
797
+ );
798
+ damage = damage ? unionRects(damage, box) : box;
799
+ }
800
+ return damage ? inflateRect(damage, CULL_MARGIN) : null;
801
+ }
802
+
803
+ /**
804
+ * The screen box a node's redraw touches: its own rect and every edge on
805
+ * it — what a move must invalidate, before and after.
806
+ *
807
+ * Edge bounds come from the real routed geometry, not the coarse box: the
808
+ * coarse slack is sized for a bezier that *might* bulge, which in a dense
809
+ * graph sweeps dozens of neighbours into every drag step's damage. The
810
+ * exact path costs what drawing the edge costs, and a moved node's edges
811
+ * are about to be drawn anyway. Past a fan-out worth of edges the coarse
812
+ * box wins again — one huge union is one huge union either way, and the
813
+ * geometry walk stops being free.
814
+ */
815
+ private _nodeDamage(entry: NodeEntry): FlowRect {
816
+ let box = this._screenRect(entry);
817
+ const edges = this._edgesByNode.get(entry.node.id) ?? [];
818
+ const tight = edges.length <= 16;
819
+ for (const edge of edges) {
820
+ const geometry = tight ? this._edgeGeometry(edge) : null;
821
+ const bounds = geometry
822
+ ? pathBounds(geometry.points)
823
+ : this._edgeCoarseBox(edge);
824
+ if (bounds) box = unionRects(box, bounds);
825
+ }
826
+ return box;
827
+ }
828
+
829
+ private _rebuildNodes(): void {
830
+ const registry = this._prop<Record<string, AnyType>>('nodeTypes');
831
+ const entries: NodeEntry[] = [];
832
+ const byId = new Map<string, NodeEntry>();
833
+ for (const node of this._nodes()) {
834
+ if (!node || typeof node.id !== 'string' || !node.position) continue;
835
+ const type = registry?.[node.type ?? 'default'];
836
+ const { width, height } = measureNode(node, type, this._measure);
837
+ const entry: NodeEntry = {
838
+ node,
839
+ width,
840
+ height,
841
+ specs: resolveHandles(node, type),
842
+ type,
843
+ };
844
+ entries.push(entry);
845
+ byId.set(node.id, entry);
846
+ }
847
+ this._entries = entries;
848
+ this._byId = byId;
849
+ this._order = entries
850
+ .map((entry, index) => ({ entry, index }))
851
+ .sort((a, b) => {
852
+ const az = a.entry.node.zIndex ?? 0;
853
+ const bz = b.entry.node.zIndex ?? 0;
854
+ if (az !== bz) return az - bz;
855
+ const as = a.entry.node.selected ? 1 : 0;
856
+ const bs = b.entry.node.selected ? 1 : 0;
857
+ if (as !== bs) return as - bs;
858
+ return a.index - b.index; // stable: declaration order breaks ties
859
+ })
860
+ .map((e) => e.entry);
861
+ }
862
+
863
+ private _rebuildEdges(): void {
864
+ const defaults = this._prop<Partial<AnyEdge>>('defaultEdgeOptions');
865
+ const raw = this._rawEdges();
866
+ this._edgesRaw = raw as AnyEdge[];
867
+ this._edges = defaults
868
+ ? raw.map((edge) => ({ ...defaults, ...edge }) as AnyEdge)
869
+ : (raw as AnyEdge[]);
870
+ this._rebuildAdjacency();
871
+ }
872
+
873
+ /** who touches whom — what a moved node's damage has to include */
874
+ private _rebuildAdjacency(): void {
875
+ const byNode = new Map<string, AnyEdge[]>();
876
+ const push = (id: string, edge: AnyEdge): void => {
877
+ const list = byNode.get(id);
878
+ if (list) list.push(edge);
879
+ else byNode.set(id, [edge]);
880
+ };
881
+ for (const edge of this._edges) {
882
+ push(edge.source, edge);
883
+ if (edge.target !== edge.source) push(edge.target, edge);
884
+ }
885
+ this._edgesByNode = byNode;
886
+ }
887
+
888
+ // --- coordinates ---------------------------------------------------------
889
+
890
+ private _viewport(): Viewport {
891
+ return this._prop<Viewport>('viewport') ?? this._vp;
892
+ }
893
+
894
+ /**
895
+ * The pane proper: `abs` inset by border and padding, off core's own
896
+ * resolved layout (react-x11#254) — so a `<Flow style={{ borderWidth: 1,
897
+ * padding: 8 }}>` keeps its border, and its padding means what it does on
898
+ * a `<box>`.
899
+ */
900
+ private _pane(): FlowRect {
901
+ return this.contentBox();
902
+ }
903
+
904
+ /** Graph point to window pixels. */
905
+ private _toScreen(p: XYPosition): XYPosition {
906
+ const v = this._viewport();
907
+ const pane = this._pane();
908
+ return {
909
+ x: pane.x + p.x * v.zoom + v.x,
910
+ y: pane.y + p.y * v.zoom + v.y,
911
+ };
912
+ }
913
+
914
+ /** Window pixels to graph point. */
915
+ private _toGraph(x: number, y: number): XYPosition {
916
+ const v = this._viewport();
917
+ const pane = this._pane();
918
+ return {
919
+ x: (x - pane.x - v.x) / v.zoom,
920
+ y: (y - pane.y - v.y) / v.zoom,
921
+ };
922
+ }
923
+
924
+ /** Where a node is *now*: mid-drag that is not where its props say. */
925
+ private _positionOf(node: AnyNode): XYPosition {
926
+ return this._dragTo?.get(node.id) ?? node.position;
927
+ }
928
+
929
+ /** A node's box in graph space — including whatever a gesture in flight
930
+ * is making of it, which is what keeps the pane from being a frame behind
931
+ * the pointer even when the app is applying the changes it is sent. */
932
+ rectOf(entry: NodeEntry): FlowRect {
933
+ const resizing = this._resizeTo;
934
+ if (resizing && resizing.id === entry.node.id) return resizing.rect;
935
+ const p = this._positionOf(entry.node);
936
+ return { x: p.x, y: p.y, width: entry.width, height: entry.height };
937
+ }
938
+
939
+ /** The strip a `render` node keeps for its title and for dragging. Zero
940
+ * for a node whose body the pane draws itself. */
941
+ private _headerHeight(entry: NodeEntry): number {
942
+ if (!entry.type?.render) return 0;
943
+ return entry.type.headerHeight ?? NODE_HEADER;
944
+ }
945
+
946
+ private _resizable(entry: NodeEntry): boolean {
947
+ return (
948
+ (entry.node.resizable ?? this._bool('nodesResizable', false)) !== false
949
+ );
950
+ }
951
+
952
+ /** The grips a node offers right now: none unless it is selected and
953
+ * resizable, because eight dots on every node is a graph nobody can
954
+ * read. */
955
+ private _grips(entry: NodeEntry): readonly XYPosition[] {
956
+ if (!entry.node.selected || !this._resizable(entry)) return [];
957
+ if (this._viewport().zoom < HANDLE_ZOOM) return [];
958
+ if (!this._connectable(entry)) return RESIZE_DIRECTIONS;
959
+ // Both families live on the border, and a side-centred handle sits
960
+ // exactly on a side-centred grip. The handle wins the hit test, so a
961
+ // grip drawn under one is a control that does not work: drop it. The
962
+ // corners — which is what a resize actually reaches for — are never the
963
+ // ones lost, unless a handle was put there on purpose.
964
+ const rect = this.rectOf(entry);
965
+ const anchors = this._handlesOf(entry);
966
+ const near = RESIZE_GRIP + RESIZE_SLOP;
967
+ return RESIZE_DIRECTIONS.filter((dir) => {
968
+ const at = gripPoint(rect, dir);
969
+ return !anchors.some(
970
+ (a) => Math.abs(a.x - at.x) <= near && Math.abs(a.y - at.y) <= near,
971
+ );
972
+ });
973
+ }
974
+
975
+ private _handlesOf(entry: NodeEntry): HandleAnchor[] {
976
+ const rect = this.rectOf(entry);
977
+ return entry.specs.map((spec) => handleAnchor(entry.node.id, rect, spec));
978
+ }
979
+
980
+ /**
981
+ * A node's box on screen, **on whole pixels**.
982
+ *
983
+ * The rounding is not cosmetic. ntk draws a rounded box as cached corner
984
+ * glyphs plus `FillRectangles` when its geometry is integral, and
985
+ * rasterizes a mask it has to `PutImage` when it is not — and a zoom of
986
+ * 0.42 makes every one of them fractional. On a 300-node graph that was
987
+ * six hundred mask uploads a frame and about four megabytes on the wire;
988
+ * `react-x11/debug`'s trace names it as `fell back … fractional`.
989
+ *
990
+ * Hit testing reads the same rect, so what is drawn and what is clicked
991
+ * still agree to the pixel.
992
+ */
993
+ private _screenRect(entry: NodeEntry): FlowRect {
994
+ const v = this._viewport();
995
+ const rect = this.rectOf(entry);
996
+ const p = this._toScreen(rect);
997
+ const x = Math.round(p.x);
998
+ const y = Math.round(p.y);
999
+ return {
1000
+ x,
1001
+ y,
1002
+ // rounded as edges rather than as a size, so two nodes that share a
1003
+ // column still share it after rounding
1004
+ width: Math.round(p.x + rect.width * v.zoom) - x,
1005
+ height: Math.round(p.y + rect.height * v.zoom) - y,
1006
+ };
1007
+ }
1008
+
1009
+ private _visible(): boolean {
1010
+ const pane = this._pane();
1011
+ return pane.width > 0 && pane.height > 0;
1012
+ }
1013
+
1014
+ // --- viewport control ----------------------------------------------------
1015
+
1016
+ private _repaint(reason = 'content'): void {
1017
+ this.invalidate(false, this.abs, reason);
1018
+ }
1019
+
1020
+ private _applyViewport(next: Viewport): void {
1021
+ const zoom = clamp(next.zoom, this._minZoom, this._maxZoom);
1022
+ const previous = this._viewport();
1023
+ const v = { x: next.x, y: next.y, zoom };
1024
+ const controlled = this.props.viewport !== undefined;
1025
+ if (!controlled) this._vp = v;
1026
+ this._prop<(vp: Viewport) => void>('onViewportChange')?.(v);
1027
+ // The `fitView` that runs at the top of a paint has already changed what
1028
+ // this frame will draw, so asking for another one would only draw the
1029
+ // same picture twice.
1030
+ if (!controlled && !this._painting) {
1031
+ if (!this._blitPan(previous, v)) this._repaint('scroll');
1032
+ // same-frame compositing for mounted bodies — see `_dragStep`
1033
+ this._emitBodies();
1034
+ }
1035
+ this.notifyA11ySceneChanged();
1036
+ }
1037
+
1038
+ /**
1039
+ * A pan is a scroll in every way but the bookkeeping, and react-x11#303
1040
+ * made the bookkeeping public: `scrollContents` claims the pane, arms the
1041
+ * frame to blit the surviving band, and narrows the claim to the strip
1042
+ * the shift exposed — which `paintDamage()` then hands to `paint`, so the
1043
+ * existing culling draws the sliver and nothing else.
1044
+ *
1045
+ * The furniture — minimap, zoom controls — is pinned to the pane while
1046
+ * its pixels would ride the blit, so its bands are carved out of the
1047
+ * region that shifts and claimed as ordinary damage. The blit gate tests
1048
+ * foreign claims against the *rect* (react-x11#309/#310), so a claim
1049
+ * sitting edge to edge with it leaves the frame a blit: the middle of
1050
+ * the pane is copied, the strips repaint with the new viewport, and the
1051
+ * panels repaint in place.
1052
+ *
1053
+ * Still a full repaint when:
1054
+ * - the zoom moved (scaling is not a blit) or the shift is fractional —
1055
+ * every real pan gesture is whole pixels;
1056
+ * - mounted node bodies exist: they are core-owned siblings whose
1057
+ * gesture-time commits claim *inside* the rect every step, which
1058
+ * declines the blit anyway — bailing early skips the churn;
1059
+ * - the furniture bands would eat the pane (a tiny pane, or panels on
1060
+ * both the top and the bottom of a short one).
1061
+ */
1062
+ private _blitPan(previous: Viewport, next: Viewport): boolean {
1063
+ if (next.zoom !== previous.zoom) return false;
1064
+ if (this._bodiesKey !== '') return false;
1065
+ const dx = Math.round(next.x - previous.x);
1066
+ const dy = Math.round(next.y - previous.y);
1067
+ if (dx === 0 && dy === 0) return true; // sub-pixel: nothing to show yet
1068
+ if (next.x - previous.x !== dx || next.y - previous.y !== dy) return false;
1069
+ const pane = this._pane();
1070
+
1071
+ // The horizontal bands the furniture lives in. Full-width, because the
1072
+ // graph beside a panel must repaint too — it does not ride the blit.
1073
+ let topBand = 0;
1074
+ let bottomBand = 0;
1075
+ const claim = (panel: FlowRect, position: string | undefined): void => {
1076
+ if ((position ?? 'bottom-right').startsWith('top')) {
1077
+ topBand = Math.max(topBand, panel.y + panel.height - pane.y);
1078
+ } else {
1079
+ bottomBand = Math.max(bottomBand, pane.y + pane.height - panel.y);
1080
+ }
1081
+ };
1082
+ const map = this._miniMapOptions();
1083
+ if (map) {
1084
+ claim(
1085
+ this._corner(
1086
+ map.position,
1087
+ map.width ?? MINIMAP_W,
1088
+ map.height ?? MINIMAP_H,
1089
+ 'bottom-right',
1090
+ ),
1091
+ map.position,
1092
+ );
1093
+ }
1094
+ const buttons = this._controlButtons();
1095
+ if (buttons.length > 0) {
1096
+ const first = buttons[0].rect;
1097
+ const last = buttons[buttons.length - 1].rect;
1098
+ claim(
1099
+ unionRects(first, last),
1100
+ this._controlsOptions()?.position ?? 'bottom-left',
1101
+ );
1102
+ }
1103
+ const blit: FlowRect = {
1104
+ x: pane.x,
1105
+ y: pane.y + topBand,
1106
+ width: pane.width,
1107
+ height: pane.height - topBand - bottomBand,
1108
+ };
1109
+ if (blit.width < 64 || blit.height < 64) return false;
1110
+ if (Math.abs(dx) >= blit.width || Math.abs(dy) >= blit.height) {
1111
+ return false;
1112
+ }
1113
+ this.scrollContents(blit, dx, dy);
1114
+ if (topBand > 0) {
1115
+ this.invalidate(
1116
+ false,
1117
+ { x: pane.x, y: pane.y, width: pane.width, height: topBand },
1118
+ 'scroll',
1119
+ );
1120
+ }
1121
+ if (bottomBand > 0) {
1122
+ this.invalidate(
1123
+ false,
1124
+ {
1125
+ x: pane.x,
1126
+ y: blit.y + blit.height,
1127
+ width: pane.width,
1128
+ height: bottomBand,
1129
+ },
1130
+ 'scroll',
1131
+ );
1132
+ }
1133
+ return true;
1134
+ }
1135
+
1136
+ /** Zoom about a point that must not move — the pointer under a wheel, the
1137
+ * pane's centre for a keyboard zoom. */
1138
+ private _zoomAbout(factor: number, screenX: number, screenY: number): void {
1139
+ const v = this._viewport();
1140
+ const zoom = clamp(v.zoom * factor, this._minZoom, this._maxZoom);
1141
+ if (zoom === v.zoom) return;
1142
+ const pane = this._pane();
1143
+ const px = screenX - pane.x;
1144
+ const py = screenY - pane.y;
1145
+ const k = zoom / v.zoom;
1146
+ this._applyViewport({
1147
+ zoom,
1148
+ x: px - (px - v.x) * k,
1149
+ y: py - (py - v.y) * k,
1150
+ });
1151
+ }
1152
+
1153
+ private _fit(options?: FitViewOptions): void {
1154
+ if (!this._visible()) return;
1155
+ this._sync();
1156
+ const pane = this._pane();
1157
+ const only = options?.nodes && new Set(options.nodes.map((n) => n.id));
1158
+ const rects: FlowRect[] = [];
1159
+ for (const entry of this._entries) {
1160
+ if (entry.node.hidden) continue;
1161
+ if (only && !only.has(entry.node.id)) continue;
1162
+ rects.push(this.rectOf(entry));
1163
+ }
1164
+ const bounds = boundsOf(rects);
1165
+ if (!bounds) return;
1166
+ this._applyViewport(
1167
+ fitViewport(bounds, { width: pane.width, height: pane.height }, options, {
1168
+ minZoom: this._minZoom,
1169
+ maxZoom: this._maxZoom,
1170
+ }),
1171
+ );
1172
+ }
1173
+
1174
+ // --- the imperative surface (`FlowInstance`) -----------------------------
1175
+
1176
+ getViewport(): Viewport {
1177
+ return { ...this._viewport() };
1178
+ }
1179
+
1180
+ setViewport(viewport: Partial<Viewport>): void {
1181
+ const v = this._viewport();
1182
+ this._applyViewport({
1183
+ x: viewport.x ?? v.x,
1184
+ y: viewport.y ?? v.y,
1185
+ zoom: viewport.zoom ?? v.zoom,
1186
+ });
1187
+ }
1188
+
1189
+ zoomIn(step = ZOOM_STEP): void {
1190
+ const pane = this._pane();
1191
+ this._zoomAbout(step, pane.x + pane.width / 2, pane.y + pane.height / 2);
1192
+ }
1193
+
1194
+ zoomOut(step = ZOOM_STEP): void {
1195
+ this.zoomIn(1 / step);
1196
+ }
1197
+
1198
+ zoomTo(zoom: number): void {
1199
+ const v = this._viewport();
1200
+ const pane = this._pane();
1201
+ this._zoomAbout(
1202
+ zoom / v.zoom,
1203
+ pane.x + pane.width / 2,
1204
+ pane.y + pane.height / 2,
1205
+ );
1206
+ }
1207
+
1208
+ fitView(options?: FitViewOptions): void {
1209
+ this._fit(options ?? this._prop<FitViewOptions>('fitViewOptions'));
1210
+ }
1211
+
1212
+ setCenter(x: number, y: number, options?: { zoom?: number }): void {
1213
+ const v = this._viewport();
1214
+ const zoom = clamp(options?.zoom ?? v.zoom, this._minZoom, this._maxZoom);
1215
+ const pane = this._pane();
1216
+ this._applyViewport({
1217
+ zoom,
1218
+ x: pane.width / 2 - x * zoom,
1219
+ y: pane.height / 2 - y * zoom,
1220
+ });
1221
+ }
1222
+
1223
+ screenToFlowPosition(point: XYPosition): XYPosition {
1224
+ return this._toGraph(point.x, point.y);
1225
+ }
1226
+
1227
+ flowToScreenPosition(point: XYPosition): XYPosition {
1228
+ return this._toScreen(point);
1229
+ }
1230
+
1231
+ getNodeBounds(id: string): FlowRect | null {
1232
+ this._sync();
1233
+ const entry = this._byId.get(id);
1234
+ return entry ? this.rectOf(entry) : null;
1235
+ }
1236
+
1237
+ getNodesBounds(ids?: readonly string[]): FlowRect | null {
1238
+ this._sync();
1239
+ const only = ids && new Set(ids);
1240
+ const rects: FlowRect[] = [];
1241
+ for (const entry of this._entries) {
1242
+ if (only && !only.has(entry.node.id)) continue;
1243
+ rects.push(this.rectOf(entry));
1244
+ }
1245
+ return boundsOf(rects);
1246
+ }
1247
+
1248
+ // --- change emission -----------------------------------------------------
1249
+
1250
+ private _emitNodes(changes: NodeChange<unknown>[]): void {
1251
+ if (changes.length === 0) return;
1252
+ this._prop<(c: NodeChange<unknown>[]) => void>('onNodesChange')?.(changes);
1253
+ }
1254
+
1255
+ private _emitEdges(changes: EdgeChange<unknown>[]): void {
1256
+ if (changes.length === 0) return;
1257
+ this._prop<(c: EdgeChange<unknown>[]) => void>('onEdgesChange')?.(changes);
1258
+ }
1259
+
1260
+ /** Make this the selection. `additive` toggles instead, which is what
1261
+ * Shift and Ctrl do everywhere else. */
1262
+ private _select(
1263
+ target: { kind: 'node' | 'edge'; id: string } | null,
1264
+ additive: boolean,
1265
+ ): void {
1266
+ if (!this._bool('elementsSelectable', true)) return;
1267
+ const nodeChanges: NodeChange<unknown>[] = [];
1268
+ const edgeChanges: EdgeChange<unknown>[] = [];
1269
+ const isNode = target?.kind === 'node';
1270
+ const isEdge = target?.kind === 'edge';
1271
+
1272
+ for (const entry of this._entries) {
1273
+ const hit = isNode && entry.node.id === target.id;
1274
+ const selected = entry.node.selected ?? false;
1275
+ const next = hit
1276
+ ? additive
1277
+ ? !selected
1278
+ : true
1279
+ : additive
1280
+ ? selected
1281
+ : false;
1282
+ if (next !== selected) {
1283
+ nodeChanges.push({ type: 'select', id: entry.node.id, selected: next });
1284
+ }
1285
+ }
1286
+ for (const edge of this._edges) {
1287
+ const hit = isEdge && edge.id === target.id;
1288
+ const selected = edge.selected ?? false;
1289
+ const next = hit
1290
+ ? additive
1291
+ ? !selected
1292
+ : true
1293
+ : additive
1294
+ ? selected
1295
+ : false;
1296
+ if (next !== selected) {
1297
+ edgeChanges.push({ type: 'select', id: edge.id, selected: next });
1298
+ }
1299
+ }
1300
+ this._emitNodes(nodeChanges);
1301
+ this._emitEdges(edgeChanges);
1302
+ }
1303
+
1304
+ private _selectedNodeIds(): string[] {
1305
+ const ids: string[] = [];
1306
+ for (const entry of this._entries) {
1307
+ if (entry.node.selected) ids.push(entry.node.id);
1308
+ }
1309
+ return ids;
1310
+ }
1311
+
1312
+ // --- hit testing ---------------------------------------------------------
1313
+
1314
+ /** The topmost node under a window point, or null. Walks paint order
1315
+ * backwards, which is what "topmost" means. */
1316
+ private _nodeAt(x: number, y: number): NodeEntry | null {
1317
+ for (let i = this._order.length - 1; i >= 0; i--) {
1318
+ const entry = this._order[i];
1319
+ if (entry.node.hidden) continue;
1320
+ if (rectContains(this._screenRect(entry), { x, y })) return entry;
1321
+ }
1322
+ return null;
1323
+ }
1324
+
1325
+ private _handleAt(
1326
+ x: number,
1327
+ y: number,
1328
+ accept?: (anchor: HandleAnchor, entry: NodeEntry) => boolean,
1329
+ ): HandleAnchor | null {
1330
+ const zoom = this._viewport().zoom;
1331
+ const reach = Math.max(7, (HANDLE_RADIUS + HANDLE_SLOP) * zoom);
1332
+ for (let i = this._order.length - 1; i >= 0; i--) {
1333
+ const entry = this._order[i];
1334
+ if (entry.node.hidden) continue;
1335
+ if (!this._connectable(entry)) continue;
1336
+ // cheap reject: the handles are on the node's border, so nothing more
1337
+ // than `reach` outside its box can be one
1338
+ const rect = this._screenRect(entry);
1339
+ if (
1340
+ x < rect.x - reach ||
1341
+ x > rect.x + rect.width + reach ||
1342
+ y < rect.y - reach ||
1343
+ y > rect.y + rect.height + reach
1344
+ ) {
1345
+ continue;
1346
+ }
1347
+ for (const anchor of this._handlesOf(entry)) {
1348
+ if (accept && !accept(anchor, entry)) continue;
1349
+ const s = this._toScreen(anchor);
1350
+ if (Math.hypot(s.x - x, s.y - y) <= reach) return anchor;
1351
+ }
1352
+ }
1353
+ return null;
1354
+ }
1355
+
1356
+ /**
1357
+ * A resize grip under the point, if any. Tested *after* the connection
1358
+ * handles: the two families both live on the border and a side-centred
1359
+ * handle sits exactly on a side-centred grip, so one of them has to give
1360
+ * way, and it should be the one whose corners are still reachable.
1361
+ */
1362
+ private _gripAt(
1363
+ x: number,
1364
+ y: number,
1365
+ ): { entry: NodeEntry; dir: XYPosition } | null {
1366
+ const zoom = this._viewport().zoom;
1367
+ const reach = Math.max(6, (RESIZE_GRIP + RESIZE_SLOP) * zoom);
1368
+ for (let i = this._order.length - 1; i >= 0; i--) {
1369
+ const entry = this._order[i];
1370
+ if (entry.node.hidden) continue;
1371
+ const grips = this._grips(entry);
1372
+ if (grips.length === 0) continue;
1373
+ const rect = this._screenRect(entry);
1374
+ if (
1375
+ x < rect.x - reach ||
1376
+ x > rect.x + rect.width + reach ||
1377
+ y < rect.y - reach ||
1378
+ y > rect.y + rect.height + reach
1379
+ ) {
1380
+ continue;
1381
+ }
1382
+ for (const dir of grips) {
1383
+ const at = gripPoint(rect, dir);
1384
+ if (Math.abs(at.x - x) <= reach && Math.abs(at.y - y) <= reach) {
1385
+ return { entry, dir };
1386
+ }
1387
+ }
1388
+ }
1389
+ return null;
1390
+ }
1391
+
1392
+ private _edgeAt(x: number, y: number): AnyEdge | null {
1393
+ const reach = Math.max(5, EDGE_SLOP * this._viewport().zoom);
1394
+ const point = { x, y };
1395
+ for (let i = this._edges.length - 1; i >= 0; i--) {
1396
+ const edge = this._edges[i];
1397
+ if (edge.hidden) continue;
1398
+ const box = this._edgeCoarseBox(edge);
1399
+ if (!box) continue;
1400
+ if (!rectContains(box, point)) continue;
1401
+ const geometry = this._edgeGeometry(edge);
1402
+ if (!geometry) continue;
1403
+ if (distanceToPath(geometry.points, point) <= reach) return edge;
1404
+ }
1405
+ return null;
1406
+ }
1407
+
1408
+ /**
1409
+ * A box the edge cannot leave, from its two nodes alone — no path built.
1410
+ * Hover runs this over every edge on every pointer step, and building the
1411
+ * real geometry to reject it would make motion cost what drawing does.
1412
+ *
1413
+ * The slack covers the two ways a route leaves the span between its nodes:
1414
+ * a step edge by a fixed offset, a bezier by its shoulder, which grows as
1415
+ * the square root of the gap (see `paths.ts`) — `8` where the shoulder
1416
+ * uses `6.25`, so the bound is generous rather than tight.
1417
+ */
1418
+ private _edgeCoarseBox(edge: AnyEdge): FlowRect | null {
1419
+ const source = this._byId.get(edge.source);
1420
+ const target = this._byId.get(edge.target);
1421
+ if (!source || !target) return null;
1422
+ if (source.node.hidden || target.node.hidden) return null;
1423
+ const zoom = this._viewport().zoom;
1424
+ const a = this._screenRect(source);
1425
+ const b = this._screenRect(target);
1426
+ const span = Math.hypot(
1427
+ a.x + a.width / 2 - (b.x + b.width / 2),
1428
+ a.y + a.height / 2 - (b.y + b.height / 2),
1429
+ );
1430
+ const slack =
1431
+ Math.max(5, EDGE_SLOP * zoom) +
1432
+ Math.max(EDGE_STEP_OFFSET * 3 * zoom, 8 * Math.sqrt(zoom * span));
1433
+ const x = Math.min(a.x, b.x) - slack;
1434
+ const y = Math.min(a.y, b.y) - slack;
1435
+ return {
1436
+ x,
1437
+ y,
1438
+ width: Math.max(a.x + a.width, b.x + b.width) + slack - x,
1439
+ height: Math.max(a.y + a.height, b.y + b.height) + slack - y,
1440
+ };
1441
+ }
1442
+
1443
+ /** The drawn handle, in screen pixels. Capped as well as floored: a dot
1444
+ * that scaled all the way up would be a saucer at 2.5×, and the thing it
1445
+ * marks — a point on the border — does not get bigger. */
1446
+ private _handleRadius(): number {
1447
+ return clamp(HANDLE_RADIUS * this._viewport().zoom, 2.5, 6);
1448
+ }
1449
+
1450
+ private _connectable(entry: NodeEntry): boolean {
1451
+ return (
1452
+ (entry.node.connectable ?? this._bool('nodesConnectable', true)) !== false
1453
+ );
1454
+ }
1455
+
1456
+ private _draggable(entry: NodeEntry): boolean {
1457
+ return (
1458
+ (entry.node.draggable ?? this._bool('nodesDraggable', true)) !== false
1459
+ );
1460
+ }
1461
+
1462
+ // --- edge geometry -------------------------------------------------------
1463
+
1464
+ /** Pick the handle an edge attaches to: the one it names, else the only
1465
+ * one of the right type, else the one facing the other end. */
1466
+ private _endpoint(
1467
+ entry: NodeEntry,
1468
+ handleId: string | null | undefined,
1469
+ type: 'source' | 'target',
1470
+ towards: XYPosition | null,
1471
+ ): HandleAnchor | null {
1472
+ const anchors = this._handlesOf(entry);
1473
+ if (handleId != null) {
1474
+ const named = anchors.find((a) => a.id === handleId);
1475
+ if (named) return named;
1476
+ }
1477
+ const typed = anchors.filter((a) => a.type === type);
1478
+ const candidates = typed.length > 0 ? typed : anchors;
1479
+ if (candidates.length === 0) return null;
1480
+ if (candidates.length === 1 || !towards) return candidates[0];
1481
+ let best = candidates[0];
1482
+ let bestDistance = Infinity;
1483
+ for (const anchor of candidates) {
1484
+ const d = Math.hypot(anchor.x - towards.x, anchor.y - towards.y);
1485
+ if (d < bestDistance) {
1486
+ bestDistance = d;
1487
+ best = anchor;
1488
+ }
1489
+ }
1490
+ return best;
1491
+ }
1492
+
1493
+ /** An edge's polyline, in **screen** pixels, plus the two ends it joins.
1494
+ * Null when either end is missing — an edge to a node that was removed is
1495
+ * a normal state during an edit, not an error. */
1496
+ private _edgeGeometry(
1497
+ edge: AnyEdge,
1498
+ ): { points: XYPosition[]; from: HandleAnchor; to: HandleAnchor } | null {
1499
+ const sourceEntry = this._byId.get(edge.source);
1500
+ const targetEntry = this._byId.get(edge.target);
1501
+ if (!sourceEntry || !targetEntry) return null;
1502
+ if (sourceEntry.node.hidden || targetEntry.node.hidden) return null;
1503
+ const sourceCentre = this._centre(sourceEntry);
1504
+ const targetCentre = this._centre(targetEntry);
1505
+ const from = this._endpoint(
1506
+ sourceEntry,
1507
+ edge.sourceHandle,
1508
+ 'source',
1509
+ targetCentre,
1510
+ );
1511
+ const to = this._endpoint(
1512
+ targetEntry,
1513
+ edge.targetHandle,
1514
+ 'target',
1515
+ sourceCentre,
1516
+ );
1517
+ if (!from || !to) return null;
1518
+
1519
+ const zoom = this._viewport().zoom;
1520
+ const s = this._toScreen(from);
1521
+ const t = this._toScreen(to);
1522
+ const points = edgePath(
1523
+ edge.type,
1524
+ { x: s.x, y: s.y, position: from.position },
1525
+ { x: t.x, y: t.y, position: to.position },
1526
+ {
1527
+ stepOffset: EDGE_STEP_OFFSET * zoom,
1528
+ radius: 8 * zoom,
1529
+ scale: zoom,
1530
+ loop: edge.source === edge.target,
1531
+ },
1532
+ );
1533
+ return { points, from, to };
1534
+ }
1535
+
1536
+ private _centre(entry: NodeEntry): XYPosition {
1537
+ const r = this.rectOf(entry);
1538
+ return { x: r.x + r.width / 2, y: r.y + r.height / 2 };
1539
+ }
1540
+
1541
+ // --- pane furniture geometry --------------------------------------------
1542
+
1543
+ private _corner(
1544
+ where: string | undefined,
1545
+ width: number,
1546
+ height: number,
1547
+ fallback: string,
1548
+ ): FlowRect {
1549
+ const at = where ?? fallback;
1550
+ const right = at.endsWith('right');
1551
+ const bottom = at.startsWith('bottom');
1552
+ const pane = this._pane();
1553
+ return {
1554
+ x: right
1555
+ ? pane.x + pane.width - width - PANEL_MARGIN
1556
+ : pane.x + PANEL_MARGIN,
1557
+ y: bottom
1558
+ ? pane.y + pane.height - height - PANEL_MARGIN
1559
+ : pane.y + PANEL_MARGIN,
1560
+ width,
1561
+ height,
1562
+ };
1563
+ }
1564
+
1565
+ private _controlsOptions(): ControlsOptions | null {
1566
+ const value = this.props.controls;
1567
+ if (value === false) return null;
1568
+ return typeof value === 'object' && value !== null
1569
+ ? (value as ControlsOptions)
1570
+ : {};
1571
+ }
1572
+
1573
+ private _controlButtons(): {
1574
+ rect: FlowRect;
1575
+ action: 'in' | 'out' | 'fit';
1576
+ }[] {
1577
+ const options = this._controlsOptions();
1578
+ if (!options) return [];
1579
+ const actions: ('in' | 'out' | 'fit')[] = [];
1580
+ if (options.showZoom !== false) actions.push('in', 'out');
1581
+ if (options.showFitView !== false) actions.push('fit');
1582
+ if (actions.length === 0) return [];
1583
+ const panel = this._corner(
1584
+ options.position,
1585
+ CONTROL_SIZE,
1586
+ CONTROL_SIZE * actions.length,
1587
+ 'bottom-left',
1588
+ );
1589
+ return actions.map((action, i) => ({
1590
+ action,
1591
+ rect: {
1592
+ x: panel.x,
1593
+ y: panel.y + i * CONTROL_SIZE,
1594
+ width: CONTROL_SIZE,
1595
+ height: CONTROL_SIZE,
1596
+ },
1597
+ }));
1598
+ }
1599
+
1600
+ private _miniMapOptions(): MiniMapOptions | null {
1601
+ const value = this.props.minimap;
1602
+ if (!value) return null;
1603
+ return typeof value === 'object' ? (value as MiniMapOptions) : {};
1604
+ }
1605
+
1606
+ /** The minimap panel and the graph-to-panel transform, or null when there
1607
+ * is no minimap or nothing to put in it. */
1608
+ private _miniMap(): {
1609
+ panel: FlowRect;
1610
+ bounds: FlowRect;
1611
+ scale: number;
1612
+ options: MiniMapOptions;
1613
+ } | null {
1614
+ const options = this._miniMapOptions();
1615
+ if (!options) return null;
1616
+ const width = options.width ?? MINIMAP_W;
1617
+ const height = options.height ?? MINIMAP_H;
1618
+ const pane = this._pane();
1619
+ if (pane.width < width * 1.6 || pane.height < height * 1.6) {
1620
+ // A minimap that covers the pane it summarises is worse than none.
1621
+ return null;
1622
+ }
1623
+ const rects: FlowRect[] = [];
1624
+ for (const entry of this._entries) {
1625
+ if (!entry.node.hidden) rects.push(this.rectOf(entry));
1626
+ }
1627
+ // The viewport joins the bounds, so panning off the graph still shows
1628
+ // where you are rather than pinning the box to an edge.
1629
+ const v = this._viewport();
1630
+ rects.push({
1631
+ x: -v.x / v.zoom,
1632
+ y: -v.y / v.zoom,
1633
+ width: pane.width / v.zoom,
1634
+ height: pane.height / v.zoom,
1635
+ });
1636
+ const bounds = boundsOf(rects);
1637
+ if (!bounds) return null;
1638
+ const pad = Math.max(bounds.width, bounds.height) * 0.05 + 10;
1639
+ const padded = {
1640
+ x: bounds.x - pad,
1641
+ y: bounds.y - pad,
1642
+ width: bounds.width + pad * 2,
1643
+ height: bounds.height + pad * 2,
1644
+ };
1645
+ const panel = this._corner(options.position, width, height, 'bottom-right');
1646
+ const scale = Math.min(
1647
+ panel.width / Math.max(1, padded.width),
1648
+ panel.height / Math.max(1, padded.height),
1649
+ );
1650
+ return { panel, bounds: padded, scale, options };
1651
+ }
1652
+
1653
+ private _miniToGraph(
1654
+ map: { panel: FlowRect; bounds: FlowRect; scale: number },
1655
+ x: number,
1656
+ y: number,
1657
+ ): XYPosition {
1658
+ const ox =
1659
+ map.panel.x + (map.panel.width - map.bounds.width * map.scale) / 2;
1660
+ const oy =
1661
+ map.panel.y + (map.panel.height - map.bounds.height * map.scale) / 2;
1662
+ return {
1663
+ x: (x - ox) / map.scale + map.bounds.x,
1664
+ y: (y - oy) / map.scale + map.bounds.y,
1665
+ };
1666
+ }
1667
+
1668
+ private _miniToScreen(
1669
+ map: { panel: FlowRect; bounds: FlowRect; scale: number },
1670
+ p: XYPosition,
1671
+ ): XYPosition {
1672
+ const ox =
1673
+ map.panel.x + (map.panel.width - map.bounds.width * map.scale) / 2;
1674
+ const oy =
1675
+ map.panel.y + (map.panel.height - map.bounds.height * map.scale) / 2;
1676
+ return {
1677
+ x: ox + (p.x - map.bounds.x) * map.scale,
1678
+ y: oy + (p.y - map.bounds.y) * map.scale,
1679
+ };
1680
+ }
1681
+
1682
+ // --- gestures ------------------------------------------------------------
1683
+
1684
+ override defaultMouseDown(ev: MouseEvent): void {
1685
+ if (this.props.disabled) return;
1686
+ this.focus();
1687
+ this._sync();
1688
+ if (ev.button === 3) return; // the context menu is its own seam
1689
+
1690
+ const { x, y } = ev;
1691
+ // 1. the pane's own furniture, which is over everything else
1692
+ for (const button of this._controlButtons()) {
1693
+ if (rectContains(button.rect, { x, y })) {
1694
+ if (button.action === 'in') this.zoomIn();
1695
+ else if (button.action === 'out') this.zoomOut();
1696
+ else this.fitView();
1697
+ ev.preventDefault();
1698
+ return;
1699
+ }
1700
+ }
1701
+ const map = this._miniMap();
1702
+ if (map && rectContains(map.panel, { x, y })) {
1703
+ this._gesture = { kind: 'minimap' };
1704
+ const p = this._miniToGraph(map, x, y);
1705
+ this.setCenter(p.x, p.y);
1706
+ ev.capturePointer();
1707
+ ev.preventDefault();
1708
+ return;
1709
+ }
1710
+
1711
+ // 2. a handle, which sits proud of its node and so is tested first
1712
+ if (ev.button === 1 && this._bool('nodesConnectable', true)) {
1713
+ const handle = this._handleAt(x, y);
1714
+ if (handle) {
1715
+ this._gesture = {
1716
+ kind: 'connect',
1717
+ from: handle,
1718
+ pointer: this._toGraph(x, y),
1719
+ to: null,
1720
+ valid: false,
1721
+ };
1722
+ this._prop<(s: unknown) => void>('onConnectStart')?.({
1723
+ nodeId: handle.nodeId,
1724
+ handleId: handle.id ?? null,
1725
+ handleType: handle.type,
1726
+ });
1727
+ ev.capturePointer();
1728
+ ev.preventDefault();
1729
+ this._repaint('style-state');
1730
+ return;
1731
+ }
1732
+ }
1733
+
1734
+ // 3. a resize grip on a selected node
1735
+ if (ev.button === 1) {
1736
+ const grip = this._gripAt(x, y);
1737
+ if (grip) {
1738
+ this._gesture = {
1739
+ kind: 'resize',
1740
+ id: grip.entry.node.id,
1741
+ dir: grip.dir,
1742
+ startX: x,
1743
+ startY: y,
1744
+ origin: this.rectOf(grip.entry),
1745
+ };
1746
+ ev.capturePointer();
1747
+ ev.preventDefault();
1748
+ return;
1749
+ }
1750
+ }
1751
+
1752
+ // 4. a node
1753
+ const entry = ev.button === 1 ? this._nodeAt(x, y) : null;
1754
+ if (entry) {
1755
+ const additive = ev.shiftKey || ev.ctrlKey;
1756
+ const selectable =
1757
+ (entry.node.selectable ?? this._bool('elementsSelectable', true)) !==
1758
+ false;
1759
+ // A press inside an existing multi-selection keeps it, so that
1760
+ // dragging the group does not first collapse it to one node.
1761
+ if (selectable && !(entry.node.selected && !additive)) {
1762
+ this._select({ kind: 'node', id: entry.node.id }, additive);
1763
+ }
1764
+ // The gesture is armed even for a node nothing may move: it is also
1765
+ // what turns a press-and-release into `onNodeClick`, and a node that
1766
+ // cannot be dragged can still be clicked. It simply moves nothing.
1767
+ const ids =
1768
+ entry.node.selected && this._draggable(entry)
1769
+ ? Array.from(new Set([...this._selectedNodeIds(), entry.node.id]))
1770
+ : this._draggable(entry)
1771
+ ? [entry.node.id]
1772
+ : [];
1773
+ const origin = new Map<string, XYPosition>();
1774
+ for (const id of ids) {
1775
+ const target = this._byId.get(id);
1776
+ if (target && this._draggable(target)) {
1777
+ origin.set(id, { ...this._positionOf(target.node) });
1778
+ }
1779
+ }
1780
+ this._gesture = {
1781
+ kind: 'drag',
1782
+ ids: Array.from(origin.keys()),
1783
+ primary: entry.node.id,
1784
+ origin,
1785
+ startX: x,
1786
+ startY: y,
1787
+ moved: false,
1788
+ };
1789
+ ev.capturePointer();
1790
+ ev.preventDefault();
1791
+ return;
1792
+ }
1793
+
1794
+ // 5. an edge
1795
+ if (ev.button === 1) {
1796
+ const edge = this._edgeAt(x, y);
1797
+ if (edge) {
1798
+ if (
1799
+ (edge.selectable ?? this._bool('elementsSelectable', true)) !== false
1800
+ ) {
1801
+ this._select(
1802
+ { kind: 'edge', id: edge.id },
1803
+ ev.shiftKey || ev.ctrlKey,
1804
+ );
1805
+ }
1806
+ this._gesture = { kind: 'edge', id: edge.id, startX: x, startY: y };
1807
+ ev.capturePointer();
1808
+ ev.preventDefault();
1809
+ return;
1810
+ }
1811
+ }
1812
+
1813
+ // 6. the empty pane: a box selection, or a pan
1814
+ const boxSelect =
1815
+ ev.button === 1 &&
1816
+ (ev.shiftKey || this._bool('selectionOnDrag', false)) &&
1817
+ this._bool('elementsSelectable', true);
1818
+ if (boxSelect) {
1819
+ this._gesture = {
1820
+ kind: 'select',
1821
+ startX: x,
1822
+ startY: y,
1823
+ x,
1824
+ y,
1825
+ base: ev.shiftKey ? new Set(this._selectedNodeIds()) : new Set(),
1826
+ };
1827
+ if (!ev.shiftKey) this._select(null, false);
1828
+ ev.capturePointer();
1829
+ ev.preventDefault();
1830
+ return;
1831
+ }
1832
+ const v = this._viewport();
1833
+ this._gesture = {
1834
+ kind: 'pan',
1835
+ pans: ev.button === 2 || this._bool('panOnDrag', true),
1836
+ startX: x,
1837
+ startY: y,
1838
+ vx: v.x,
1839
+ vy: v.y,
1840
+ };
1841
+ ev.capturePointer();
1842
+ ev.preventDefault();
1843
+ }
1844
+
1845
+ override defaultMouseDrag(ev: MouseEvent): void {
1846
+ const gesture = this._gesture;
1847
+ if (!gesture) return;
1848
+ ev.preventDefault();
1849
+ this._gestureSync = true;
1850
+ try {
1851
+ this._dragDispatch(gesture, ev);
1852
+ } finally {
1853
+ this._gestureSync = false;
1854
+ }
1855
+ }
1856
+
1857
+ private _dragDispatch(gesture: Gesture, ev: MouseEvent): void {
1858
+ switch (gesture.kind) {
1859
+ case 'pan':
1860
+ if (!gesture.pans) return;
1861
+ this._applyViewport({
1862
+ ...this._viewport(),
1863
+ x: gesture.vx + (ev.x - gesture.startX),
1864
+ y: gesture.vy + (ev.y - gesture.startY),
1865
+ });
1866
+ return;
1867
+ case 'drag':
1868
+ this._dragStep(gesture, ev);
1869
+ return;
1870
+ case 'connect':
1871
+ this._connectStep(gesture, ev);
1872
+ return;
1873
+ case 'resize':
1874
+ this._resizeStep(gesture, ev);
1875
+ return;
1876
+ case 'select': {
1877
+ const oldBox = selectBoxRect(gesture);
1878
+ gesture.x = ev.x;
1879
+ gesture.y = ev.y;
1880
+ this._selectBox(gesture);
1881
+ // The box outline is all that moves this step; a node crossing the
1882
+ // boundary changes `selected`, and that repaints through the
1883
+ // controlled round trip like any other selection change.
1884
+ this.invalidate(
1885
+ false,
1886
+ inflateRect(unionRects(oldBox, selectBoxRect(gesture)), CULL_MARGIN),
1887
+ 'style-state',
1888
+ );
1889
+ return;
1890
+ }
1891
+ case 'minimap': {
1892
+ const map = this._miniMap();
1893
+ if (!map) return;
1894
+ const p = this._miniToGraph(map, ev.x, ev.y);
1895
+ this.setCenter(p.x, p.y);
1896
+ return;
1897
+ }
1898
+ }
1899
+ }
1900
+
1901
+ override defaultMouseUp(ev: MouseEvent): void {
1902
+ const gesture = this._gesture;
1903
+ this._gesture = null;
1904
+ if (!gesture) return;
1905
+
1906
+ if (gesture.kind === 'drag') {
1907
+ const to = this._dragTo;
1908
+ this._dragTo = null;
1909
+ if (gesture.moved && to && gesture.ids.length > 0) {
1910
+ // The settling change: `dragging: false` is what tells an app this
1911
+ // is the position worth persisting.
1912
+ this._emitNodes(
1913
+ gesture.ids.map((id) => ({
1914
+ type: 'position' as const,
1915
+ id,
1916
+ position: to.get(id) ?? this._byId.get(id)?.node.position,
1917
+ dragging: false,
1918
+ })),
1919
+ );
1920
+ const primary = this._byId.get(gesture.primary);
1921
+ if (primary) {
1922
+ this._prop<(e: MouseEvent, n: AnyNode) => void>('onNodeDragStop')?.(
1923
+ ev,
1924
+ primary.node,
1925
+ );
1926
+ }
1927
+ } else {
1928
+ this._clickNode(ev, gesture.primary);
1929
+ }
1930
+ this._repaint();
1931
+ return;
1932
+ }
1933
+
1934
+ if (gesture.kind === 'connect') {
1935
+ const connection =
1936
+ gesture.to && gesture.valid
1937
+ ? orientConnection(
1938
+ {
1939
+ nodeId: gesture.from.nodeId,
1940
+ handleId: gesture.from.id ?? null,
1941
+ type: gesture.from.type,
1942
+ },
1943
+ {
1944
+ nodeId: gesture.to.nodeId,
1945
+ handleId: gesture.to.id ?? null,
1946
+ type: gesture.to.type,
1947
+ },
1948
+ )
1949
+ : null;
1950
+ if (connection) {
1951
+ this._prop<(c: unknown) => void>('onConnect')?.(connection);
1952
+ }
1953
+ this._prop<(c: unknown) => void>('onConnectEnd')?.(connection);
1954
+ this._repaint('style-state');
1955
+ return;
1956
+ }
1957
+
1958
+ if (gesture.kind === 'pan') {
1959
+ const moved =
1960
+ Math.abs(ev.x - gesture.startX) > DRAG_THRESHOLD ||
1961
+ Math.abs(ev.y - gesture.startY) > DRAG_THRESHOLD;
1962
+ if (!moved && ev.button === 1) {
1963
+ this._select(null, false);
1964
+ this._prop<(e: MouseEvent) => void>('onPaneClick')?.(ev);
1965
+ if (ev.detail === 2 && this._bool('zoomOnDoubleClick', true)) {
1966
+ this._zoomAbout(ZOOM_STEP, ev.x, ev.y);
1967
+ }
1968
+ }
1969
+ return;
1970
+ }
1971
+
1972
+ if (gesture.kind === 'resize') {
1973
+ this._resizeStep(gesture, ev, true);
1974
+ this._resizeTo = null;
1975
+ this._repaint();
1976
+ return;
1977
+ }
1978
+
1979
+ if (gesture.kind === 'edge') {
1980
+ const moved =
1981
+ Math.abs(ev.x - gesture.startX) > DRAG_THRESHOLD ||
1982
+ Math.abs(ev.y - gesture.startY) > DRAG_THRESHOLD;
1983
+ const edge = this._edges.find((e) => e.id === gesture.id);
1984
+ if (!moved && edge) {
1985
+ this._prop<(e: MouseEvent, x: AnyEdge) => void>('onEdgeClick')?.(
1986
+ ev,
1987
+ edge,
1988
+ );
1989
+ }
1990
+ return;
1991
+ }
1992
+
1993
+ if (gesture.kind === 'select' || gesture.kind === 'minimap') {
1994
+ this._repaint('style-state');
1995
+ }
1996
+ }
1997
+
1998
+ override defaultContextMenu(ev: MouseEvent): void {
1999
+ if (this.props.disabled) return;
2000
+ this._sync();
2001
+ const entry = this._nodeAt(ev.x, ev.y);
2002
+ if (entry) {
2003
+ this._prop<(e: MouseEvent, n: AnyNode) => void>('onNodeContextMenu')?.(
2004
+ ev,
2005
+ entry.node,
2006
+ );
2007
+ return;
2008
+ }
2009
+ const edge = this._edgeAt(ev.x, ev.y);
2010
+ if (edge) {
2011
+ this._prop<(e: MouseEvent, x: AnyEdge) => void>('onEdgeContextMenu')?.(
2012
+ ev,
2013
+ edge,
2014
+ );
2015
+ return;
2016
+ }
2017
+ this._prop<(e: MouseEvent) => void>('onPaneContextMenu')?.(ev);
2018
+ }
2019
+
2020
+ private _clickNode(ev: MouseEvent, id: string): void {
2021
+ const entry = this._byId.get(id);
2022
+ if (!entry) return;
2023
+ if (ev.detail === 2) {
2024
+ this._prop<(e: MouseEvent, n: AnyNode) => void>('onNodeDoubleClick')?.(
2025
+ ev,
2026
+ entry.node,
2027
+ );
2028
+ return;
2029
+ }
2030
+ this._prop<(e: MouseEvent, n: AnyNode) => void>('onNodeClick')?.(
2031
+ ev,
2032
+ entry.node,
2033
+ );
2034
+ }
2035
+
2036
+ private _dragStep(
2037
+ gesture: Extract<Gesture, { kind: 'drag' }>,
2038
+ ev: MouseEvent,
2039
+ ): void {
2040
+ const zoom = this._viewport().zoom;
2041
+ const dx = (ev.x - gesture.startX) / zoom;
2042
+ const dy = (ev.y - gesture.startY) / zoom;
2043
+ if (
2044
+ !gesture.moved &&
2045
+ Math.abs(ev.x - gesture.startX) < DRAG_THRESHOLD &&
2046
+ Math.abs(ev.y - gesture.startY) < DRAG_THRESHOLD
2047
+ ) {
2048
+ return;
2049
+ }
2050
+ if (!gesture.moved) {
2051
+ gesture.moved = true;
2052
+ const primary = this._byId.get(gesture.primary);
2053
+ if (primary) {
2054
+ this._prop<(e: MouseEvent, n: AnyNode) => void>('onNodeDragStart')?.(
2055
+ ev,
2056
+ primary.node,
2057
+ );
2058
+ }
2059
+ }
2060
+ const snap = this._bool('snapToGrid', false);
2061
+ const grid = this._prop<readonly [number, number]>('snapGrid') ?? [16, 16];
2062
+ const to = new Map<string, XYPosition>();
2063
+ const changes: NodeChange<unknown>[] = [];
2064
+ for (const id of gesture.ids) {
2065
+ const origin = gesture.origin.get(id);
2066
+ if (!origin) continue;
2067
+ let x = origin.x + dx;
2068
+ let y = origin.y + dy;
2069
+ if (snap) {
2070
+ x = snapTo(x, grid[0]);
2071
+ y = snapTo(y, grid[1]);
2072
+ }
2073
+ to.set(id, { x, y });
2074
+ changes.push({
2075
+ type: 'position',
2076
+ id,
2077
+ position: { x, y },
2078
+ dragging: true,
2079
+ });
2080
+ }
2081
+ // Old places first, new places second: the union is everything this
2082
+ // step uncovers plus everything it covers, and nothing else — with the
2083
+ // edges that follow the moved nodes, coarse, on both sides.
2084
+ let damage: FlowRect | null = null;
2085
+ for (const id of gesture.ids) {
2086
+ const entry = this._byId.get(id);
2087
+ if (entry) damage = unionMaybe(damage, this._nodeDamage(entry));
2088
+ }
2089
+ this._dragTo = to;
2090
+ for (const id of gesture.ids) {
2091
+ const entry = this._byId.get(id);
2092
+ if (entry) damage = unionMaybe(damage, this._nodeDamage(entry));
2093
+ }
2094
+ this._emitNodes(changes);
2095
+ if (damage) {
2096
+ this.invalidate(false, inflateRect(damage, CULL_MARGIN), 'content');
2097
+ } else {
2098
+ this._repaint();
2099
+ }
2100
+ // Inside the gesture dispatch, deliberately: the body-rect setState this
2101
+ // triggers is a discrete-priority update, so React commits the moved
2102
+ // overlay box before the frame runs — the drawn card and the mounted
2103
+ // body land in the same frame. Emitted only from paint, the commit
2104
+ // chases the frame and the body is always one step behind the card.
2105
+ this._emitBodies();
2106
+ this.notifyA11ySceneChanged();
2107
+ }
2108
+
2109
+ /** The box a resize is making, reported as it goes. A grip that moves the
2110
+ * top or the left edge moves the node too, so both changes go out — an
2111
+ * app that applied only one would watch the node crawl. */
2112
+ private _resizeStep(
2113
+ gesture: Extract<Gesture, { kind: 'resize' }>,
2114
+ ev: MouseEvent,
2115
+ settling = false,
2116
+ ): void {
2117
+ const entry = this._byId.get(gesture.id);
2118
+ if (!entry) return;
2119
+ const zoom = this._viewport().zoom;
2120
+ const grid = this._prop<readonly [number, number]>('snapGrid') ?? [16, 16];
2121
+ const snap = this._bool('snapToGrid', false)
2122
+ ? (value: number, axis: 0 | 1) => snapTo(value, grid[axis])
2123
+ : undefined;
2124
+ const rect = resizeRect(
2125
+ gesture.origin,
2126
+ gesture.dir,
2127
+ (ev.x - gesture.startX) / zoom,
2128
+ (ev.y - gesture.startY) / zoom,
2129
+ {
2130
+ minWidth: entry.node.minWidth ?? MIN_NODE_WIDTH,
2131
+ minHeight: entry.node.minHeight ?? MIN_NODE_HEIGHT,
2132
+ },
2133
+ snap,
2134
+ );
2135
+ let damage = this._nodeDamage(entry);
2136
+ this._resizeTo = { id: gesture.id, rect };
2137
+ damage = unionRects(damage, this._nodeDamage(entry));
2138
+ const changes: NodeChange<unknown>[] = [
2139
+ {
2140
+ type: 'dimensions',
2141
+ id: gesture.id,
2142
+ dimensions: { width: rect.width, height: rect.height },
2143
+ resizing: !settling,
2144
+ },
2145
+ ];
2146
+ if (rect.x !== entry.node.position.x || rect.y !== entry.node.position.y) {
2147
+ changes.push({
2148
+ type: 'position',
2149
+ id: gesture.id,
2150
+ position: { x: rect.x, y: rect.y },
2151
+ dragging: !settling,
2152
+ });
2153
+ }
2154
+ this._emitNodes(changes);
2155
+ this.invalidate(false, inflateRect(damage, CULL_MARGIN), 'content');
2156
+ this._emitBodies();
2157
+ }
2158
+
2159
+ private _connectStep(
2160
+ gesture: Extract<Gesture, { kind: 'connect' }>,
2161
+ ev: MouseEvent,
2162
+ ): void {
2163
+ gesture.pointer = this._toGraph(ev.x, ev.y);
2164
+ const mode =
2165
+ this._prop<'strict' | 'loose'>('connectionMode') === 'loose'
2166
+ ? 'loose'
2167
+ : 'strict';
2168
+ const from = {
2169
+ nodeId: gesture.from.nodeId,
2170
+ type: gesture.from.type,
2171
+ handleId: gesture.from.id ?? null,
2172
+ };
2173
+ // A handle if the pointer is on one, else the nearest usable handle of
2174
+ // the node it is over — dropping on a node is what people try first, and
2175
+ // refusing it teaches nothing.
2176
+ let to =
2177
+ this._handleAt(ev.x, ev.y, (anchor) =>
2178
+ canConnect(from, { nodeId: anchor.nodeId, type: anchor.type }, mode),
2179
+ ) ?? null;
2180
+ if (!to) {
2181
+ const entry = this._nodeAt(ev.x, ev.y);
2182
+ if (entry && this._connectable(entry) && entry.node.id !== from.nodeId) {
2183
+ to = this._endpoint(
2184
+ entry,
2185
+ null,
2186
+ from.type === 'source' ? 'target' : 'source',
2187
+ gesture.from,
2188
+ );
2189
+ if (
2190
+ to &&
2191
+ !canConnect(from, { nodeId: to.nodeId, type: to.type }, mode)
2192
+ ) {
2193
+ to = null;
2194
+ }
2195
+ }
2196
+ }
2197
+ const oldBox = gesture.box ?? null;
2198
+ const oldTarget = gesture.to ? this._byId.get(gesture.to.nodeId) : null;
2199
+ gesture.to = to;
2200
+ gesture.valid = to
2201
+ ? this._validConnection(
2202
+ orientConnection(from, {
2203
+ nodeId: to.nodeId,
2204
+ handleId: to.id ?? null,
2205
+ type: to.type,
2206
+ }),
2207
+ )
2208
+ : false;
2209
+ // the line's own bounds, plus the node whose handle lights up (old and
2210
+ // new — the one that stops glowing needs repainting too)
2211
+ let box = pathBounds(this._connectionPath(gesture));
2212
+ if (oldTarget) box = unionRects(box, this._screenRect(oldTarget));
2213
+ const target = to ? this._byId.get(to.nodeId) : null;
2214
+ if (target) box = unionRects(box, this._screenRect(target));
2215
+ gesture.box = box;
2216
+ this.invalidate(
2217
+ false,
2218
+ inflateRect(oldBox ? unionRects(oldBox, box) : box, CULL_MARGIN),
2219
+ 'style-state',
2220
+ );
2221
+ }
2222
+
2223
+ /** The pending connection's polyline — one builder for the paint and for
2224
+ * the damage it has to claim, so the two cannot disagree. */
2225
+ private _connectionPath(
2226
+ gesture: Extract<Gesture, { kind: 'connect' }>,
2227
+ ): XYPosition[] {
2228
+ const v = this._viewport();
2229
+ const from = this._toScreen(gesture.from);
2230
+ const target = gesture.to ?? gesture.pointer;
2231
+ const to = this._toScreen(target);
2232
+ const toPosition =
2233
+ gesture.to?.position ??
2234
+ (gesture.from.position === 'left'
2235
+ ? 'right'
2236
+ : gesture.from.position === 'right'
2237
+ ? 'left'
2238
+ : gesture.from.position === 'top'
2239
+ ? 'bottom'
2240
+ : 'top');
2241
+ return edgePath(
2242
+ 'bezier',
2243
+ { x: from.x, y: from.y, position: gesture.from.position },
2244
+ { x: to.x, y: to.y, position: toPosition },
2245
+ {
2246
+ stepOffset: EDGE_STEP_OFFSET * v.zoom,
2247
+ radius: 8 * v.zoom,
2248
+ scale: v.zoom,
2249
+ },
2250
+ );
2251
+ }
2252
+
2253
+ private _validConnection(connection: {
2254
+ source: string;
2255
+ target: string;
2256
+ sourceHandle: string | null;
2257
+ targetHandle: string | null;
2258
+ }): boolean {
2259
+ const check = this._prop<(c: unknown) => boolean>('isValidConnection');
2260
+ return check ? check(connection) !== false : true;
2261
+ }
2262
+
2263
+ private _selectBox(gesture: Extract<Gesture, { kind: 'select' }>): void {
2264
+ const a = this._toGraph(gesture.startX, gesture.startY);
2265
+ const b = this._toGraph(gesture.x, gesture.y);
2266
+ const box: FlowRect = {
2267
+ x: Math.min(a.x, b.x),
2268
+ y: Math.min(a.y, b.y),
2269
+ width: Math.abs(a.x - b.x),
2270
+ height: Math.abs(a.y - b.y),
2271
+ };
2272
+ const changes: NodeChange<unknown>[] = [];
2273
+ for (const entry of this._entries) {
2274
+ if (entry.node.hidden) continue;
2275
+ if (
2276
+ (entry.node.selectable ?? this._bool('elementsSelectable', true)) ===
2277
+ false
2278
+ ) {
2279
+ continue;
2280
+ }
2281
+ const inside = rectsOverlap(box, this.rectOf(entry));
2282
+ const next = inside || gesture.base.has(entry.node.id);
2283
+ if (next !== (entry.node.selected ?? false)) {
2284
+ changes.push({ type: 'select', id: entry.node.id, selected: next });
2285
+ }
2286
+ }
2287
+ this._emitNodes(changes);
2288
+ }
2289
+
2290
+ // --- input with no default-action seam -----------------------------------
2291
+
2292
+ /** Wheel: zoom about the pointer, or pan when zooming is off. The seam
2293
+ * runs it after the app's own `onWheel` and not at all if that vetoed;
2294
+ * consuming it here is what keeps the scroll chain out of the pane. */
2295
+ override defaultWheel(ev: WheelEvent): void {
2296
+ if (this.props.disabled) return;
2297
+ this._gestureSync = true;
2298
+ try {
2299
+ this._wheelDispatch(ev);
2300
+ } finally {
2301
+ this._gestureSync = false;
2302
+ }
2303
+ }
2304
+
2305
+ private _wheelDispatch(ev: WheelEvent): void {
2306
+ this._sync();
2307
+ const zooming = ev.ctrlKey || this._bool('zoomOnScroll', true);
2308
+ if (zooming) {
2309
+ // X delivers a notch as ±48; e^(48·0.002) is about a 10% step, which
2310
+ // is roughly what a browser does for one detent.
2311
+ this._zoomAbout(Math.exp(-ev.deltaY * 0.002), ev.x, ev.y);
2312
+ } else {
2313
+ const v = this._viewport();
2314
+ this._applyViewport({ ...v, x: v.x - ev.deltaX, y: v.y - ev.deltaY });
2315
+ }
2316
+ ev.preventDefault();
2317
+ }
2318
+
2319
+ /** Pointer motion with no button down: hover highlighting. The seam skips
2320
+ * delivery while a capture is in force, so the gesture guard is belt to
2321
+ * its braces. */
2322
+ override defaultMouseMove(ev: MouseEvent): void {
2323
+ if (this.props.disabled || this._gesture) return;
2324
+ this._sync();
2325
+ const handle = this._bool('nodesConnectable', true)
2326
+ ? this._handleAt(ev.x, ev.y)
2327
+ : null;
2328
+ const entry = handle ? null : this._nodeAt(ev.x, ev.y);
2329
+ const edge = handle || entry ? null : this._edgeAt(ev.x, ev.y);
2330
+ const next: HoverState = {
2331
+ nodeId: handle?.nodeId ?? entry?.node.id ?? null,
2332
+ handle,
2333
+ edgeId: edge?.id ?? null,
2334
+ };
2335
+ if (
2336
+ next.nodeId === this._hover.nodeId &&
2337
+ next.edgeId === this._hover.edgeId &&
2338
+ (next.handle?.id ?? null) === (this._hover.handle?.id ?? null) &&
2339
+ (next.handle?.nodeId ?? null) === (this._hover.handle?.nodeId ?? null)
2340
+ ) {
2341
+ return;
2342
+ }
2343
+ const damage = unionMaybe(
2344
+ this._hoverDamage(this._hover),
2345
+ this._hoverDamage(next),
2346
+ );
2347
+ this._hover = next;
2348
+ if (damage) {
2349
+ this.invalidate(false, inflateRect(damage, CULL_MARGIN), 'style-state');
2350
+ } else {
2351
+ this._repaint('style-state');
2352
+ }
2353
+ }
2354
+
2355
+ override defaultMouseLeave(): void {
2356
+ if (this._hover === NO_HOVER) return;
2357
+ const damage = this._hoverDamage(this._hover);
2358
+ this._hover = NO_HOVER;
2359
+ if (damage) {
2360
+ this.invalidate(false, inflateRect(damage, CULL_MARGIN), 'style-state');
2361
+ } else {
2362
+ this._repaint('style-state');
2363
+ }
2364
+ }
2365
+
2366
+ /** What a hover state lights up on screen: the node (whose border and
2367
+ * handles restyle), or the edge. Null when it points at nothing. */
2368
+ private _hoverDamage(state: HoverState): FlowRect | null {
2369
+ const nodeId = state.handle?.nodeId ?? state.nodeId;
2370
+ if (nodeId) {
2371
+ const entry = this._byId.get(nodeId);
2372
+ return entry ? this._screenRect(entry) : null;
2373
+ }
2374
+ if (state.edgeId) {
2375
+ const edge = this._edges.find((e) => e.id === state.edgeId);
2376
+ return edge ? this._edgeCoarseBox(edge) : null;
2377
+ }
2378
+ return null;
2379
+ }
2380
+
2381
+ // --- what a screen reader meets (react-x11#304) --------------------------
2382
+
2383
+ /**
2384
+ * The graph, described: every visible node as an item a screen reader
2385
+ * can reach, name and activate. Edges ride on each node's description
2386
+ * rather than as items of their own — "what is this connected to" is the
2387
+ * useful question, and a hundred unlabelled `link` items between the
2388
+ * nodes would only bury them.
2389
+ *
2390
+ * Called once per question an AT asks, so it answers from the entries the
2391
+ * pane already holds — no measuring, no layout.
2392
+ */
2393
+ override a11yScene(): A11ySceneItem[] {
2394
+ this._sync();
2395
+ const pane = this._pane();
2396
+ const items: A11ySceneItem[] = [];
2397
+ for (const entry of this._order) {
2398
+ if (entry.node.hidden) continue;
2399
+ const rect = this._screenRect(entry);
2400
+ if (!rectsOverlap(rect, pane)) continue;
2401
+ const data = entry.node.data as FlowNodeData | undefined;
2402
+ const out = this._edgesByNode.get(entry.node.id) ?? [];
2403
+ const degree = out.length;
2404
+ items.push({
2405
+ id: entry.node.id,
2406
+ rect,
2407
+ role: 'listitem',
2408
+ name: data?.label ?? entry.node.id,
2409
+ description:
2410
+ degree === 0
2411
+ ? (data?.description ?? undefined)
2412
+ : `${data?.description ? `${data.description}. ` : ''}${degree} connection${degree === 1 ? '' : 's'}`,
2413
+ states: { selected: entry.node.selected ?? false },
2414
+ });
2415
+ }
2416
+ return items;
2417
+ }
2418
+
2419
+ /**
2420
+ * An AT activated a node: select it, the way a click would — but by id
2421
+ * rather than by synthesizing a pointer at its rect, which would also
2422
+ * arm a drag.
2423
+ */
2424
+ override a11ySceneAction(id: string, action: A11ySceneAction): boolean {
2425
+ if (action !== 'activate') return false;
2426
+ if (!this._byId.has(id)) return false;
2427
+ this._select({ kind: 'node', id }, false);
2428
+ return true;
2429
+ }
2430
+
2431
+ // --- keyboard ------------------------------------------------------------
2432
+
2433
+ override defaultKeyDown(ev: KeyboardEvent): void {
2434
+ if (this.props.disabled) return;
2435
+ this._gestureSync = true;
2436
+ try {
2437
+ this._keyDispatch(ev);
2438
+ } finally {
2439
+ this._gestureSync = false;
2440
+ }
2441
+ }
2442
+
2443
+ private _keyDispatch(ev: KeyboardEvent): void {
2444
+ this._sync();
2445
+ const { keysym } = ev;
2446
+
2447
+ if (ev.ctrlKey) {
2448
+ const letter = ctrlChordLetter(ev);
2449
+ if (letter === keysymOf('a')) {
2450
+ this._selectAll();
2451
+ ev.preventDefault();
2452
+ }
2453
+ return;
2454
+ }
2455
+
2456
+ if (
2457
+ (keysym === XK_DELETE || keysym === XK_BACKSPACE) &&
2458
+ this._bool('deleteOnKey', true)
2459
+ ) {
2460
+ this._deleteSelection();
2461
+ ev.preventDefault();
2462
+ return;
2463
+ }
2464
+ if (keysym === XK_ESCAPE) {
2465
+ if (this._gesture) {
2466
+ this._gesture = null;
2467
+ this._dragTo = null;
2468
+ this._resizeTo = null;
2469
+ this._repaint();
2470
+ } else {
2471
+ this._select(null, false);
2472
+ }
2473
+ ev.preventDefault();
2474
+ return;
2475
+ }
2476
+ if (keysym === XK_HOME || ev.key === '0') {
2477
+ this.fitView();
2478
+ ev.preventDefault();
2479
+ return;
2480
+ }
2481
+ if (ev.key === '+' || ev.key === '=') {
2482
+ this.zoomIn();
2483
+ ev.preventDefault();
2484
+ return;
2485
+ }
2486
+ if (ev.key === '-' || ev.key === '_') {
2487
+ this.zoomOut();
2488
+ ev.preventDefault();
2489
+ return;
2490
+ }
2491
+
2492
+ const step = ev.shiftKey ? 10 : 1;
2493
+ let dx = 0;
2494
+ let dy = 0;
2495
+ if (keysym === XK_LEFT) dx = -step;
2496
+ else if (keysym === XK_RIGHT) dx = step;
2497
+ else if (keysym === XK_UP) dy = -step;
2498
+ else if (keysym === XK_DOWN) dy = step;
2499
+ if (dx === 0 && dy === 0) return;
2500
+
2501
+ const ids = this._selectedNodeIds();
2502
+ if (ids.length === 0) {
2503
+ // Nothing selected: the arrows pan, which is the only thing left for
2504
+ // them to mean and is what every canvas does.
2505
+ const v = this._viewport();
2506
+ this._applyViewport({ ...v, x: v.x - dx * 20, y: v.y - dy * 20 });
2507
+ ev.preventDefault();
2508
+ return;
2509
+ }
2510
+ this._emitNodes(
2511
+ ids.map((id) => {
2512
+ const node = this._byId.get(id)!.node;
2513
+ return {
2514
+ type: 'position' as const,
2515
+ id,
2516
+ position: { x: node.position.x + dx, y: node.position.y + dy },
2517
+ dragging: false,
2518
+ };
2519
+ }),
2520
+ );
2521
+ ev.preventDefault();
2522
+ }
2523
+
2524
+ private _selectAll(): void {
2525
+ if (!this._bool('elementsSelectable', true)) return;
2526
+ this._emitNodes(
2527
+ this._entries
2528
+ .filter((e) => !e.node.selected && !e.node.hidden)
2529
+ .map((e) => ({
2530
+ type: 'select' as const,
2531
+ id: e.node.id,
2532
+ selected: true,
2533
+ })),
2534
+ );
2535
+ this._emitEdges(
2536
+ this._edges
2537
+ .filter((e) => !e.selected && !e.hidden)
2538
+ .map((e) => ({ type: 'select' as const, id: e.id, selected: true })),
2539
+ );
2540
+ }
2541
+
2542
+ /**
2543
+ * Remove what is selected — and, with the nodes, the edges that would
2544
+ * otherwise dangle. Emitted as changes like everything else, so an app
2545
+ * that wants a confirmation step simply does not apply them.
2546
+ */
2547
+ private _deleteSelection(): void {
2548
+ const nodeIds = this._entries
2549
+ .filter((e) => e.node.selected && e.node.deletable !== false)
2550
+ .map((e) => e.node.id);
2551
+ const gone = new Set(nodeIds);
2552
+ const edgeIds = this._edges
2553
+ .filter(
2554
+ (e) =>
2555
+ e.deletable !== false &&
2556
+ (e.selected || gone.has(e.source) || gone.has(e.target)),
2557
+ )
2558
+ .map((e) => e.id);
2559
+ this._emitEdges(edgeIds.map((id) => ({ type: 'remove' as const, id })));
2560
+ this._emitNodes(nodeIds.map((id) => ({ type: 'remove' as const, id })));
2561
+ }
2562
+
2563
+ // --- lifecycle -----------------------------------------------------------
2564
+
2565
+ override applyProps(
2566
+ nextProps: Record<string, unknown>,
2567
+ prevProps: Record<string, unknown>,
2568
+ ): void {
2569
+ const before = prevProps ?? this.props;
2570
+ super.applyProps(nextProps, prevProps);
2571
+ // This used to repaint the pane on every prop change, which was honest
2572
+ // and ruinous: the component above recreates its handler props on every
2573
+ // render, so each drag step repainted the world once for the gesture and
2574
+ // once for the commit it caused. Now the commit is billed for what it
2575
+ // changed — a moved node repaints the box it moved through, an edit to
2576
+ // anything else on the visual list repaints in full, and handler churn
2577
+ // repaints nothing.
2578
+ let damage: 'full' | FlowRect | null = null;
2579
+ if (
2580
+ !shallowEqual(nextProps.nodeTypes, before.nodeTypes) ||
2581
+ !shallowEqual(nextProps.defaultEdgeOptions, before.defaultEdgeOptions)
2582
+ ) {
2583
+ this._sync();
2584
+ damage = 'full';
2585
+ } else {
2586
+ if (nextProps.edges !== before.edges) {
2587
+ damage = this._applyEdges(this._rawEdges());
2588
+ }
2589
+ if (damage !== 'full' && nextProps.nodes !== before.nodes) {
2590
+ const nodeDamage = this._applyNodes(this._nodes());
2591
+ damage =
2592
+ nodeDamage === 'full' || damage === null
2593
+ ? nodeDamage
2594
+ : nodeDamage === null
2595
+ ? damage
2596
+ : unionRects(damage, nodeDamage);
2597
+ }
2598
+ }
2599
+ // `fitView` is a one-shot: turning it on later refits, and it stays off
2600
+ // through every unrelated re-render in between.
2601
+ if (nextProps.fitView === true && before.fitView !== true) {
2602
+ this._fitPending = true;
2603
+ damage = 'full';
2604
+ }
2605
+ if (damage !== 'full') {
2606
+ for (const key of VISUAL_PROPS) {
2607
+ if (!shallowEqual(nextProps[key], before[key])) {
2608
+ damage = 'full';
2609
+ break;
2610
+ }
2611
+ }
2612
+ }
2613
+ if (damage === 'full') this._repaint('props');
2614
+ else if (damage) this.invalidate(false, damage, 'props');
2615
+ // Core re-reads the scene for aria-prop commits; a `nodes`/`edges`
2616
+ // change is invisible to it, so the re-read is asked for by name.
2617
+ // Free when no assistive technology is listening.
2618
+ if (damage) this.notifyA11ySceneChanged();
2619
+ }
2620
+
2621
+ override destroySubtree(): void {
2622
+ this._stopAnimation();
2623
+ this._dropGridTile();
2624
+ super.destroySubtree();
2625
+ }
2626
+
2627
+ private _startAnimation(): void {
2628
+ if (this._animTimer != null) return;
2629
+ this._animTimer =
2630
+ timers.setInterval?.(() => {
2631
+ this._dashPhase += ANIMATION_SPEED;
2632
+ // the box the last paint saw animated edges in, not the pane: a
2633
+ // marching dash should not cost a full grid repaint per tick
2634
+ this.invalidate(false, this._animBox ?? this.abs, 'animation');
2635
+ }, ANIMATION_MS) ?? null;
2636
+ }
2637
+
2638
+ private _stopAnimation(): void {
2639
+ if (this._animTimer == null) return;
2640
+ timers.clearInterval?.(this._animTimer);
2641
+ this._animTimer = null;
2642
+ }
2643
+
2644
+ // --- painting ------------------------------------------------------------
2645
+
2646
+ override paint(ctx: Context2D): void {
2647
+ // What this pass is repainting. The renderer paints each damage rect as
2648
+ // its own clipped pass; content outside it survives on the window, so
2649
+ // everything we skip here is content the last frame already drew — the
2650
+ // window's backing is the composition cache, and this rect is the dirty
2651
+ // state. Null means a full pass.
2652
+ const clip = this.paintDamage();
2653
+
2654
+ // The pane's own border and background need redrawing only when the
2655
+ // pass reaches them: a drag deep inside the pane should not restroke a
2656
+ // rounded border whose mask is the size of the pane.
2657
+ const sNum = (v: unknown): number => (typeof v === 'number' ? v : 0);
2658
+ const ring =
2659
+ sNum(this.style.borderWidth) + sNum(this.style.borderRadius) + 2;
2660
+ const inner = inflateRect(this.abs, -ring);
2661
+ const insideInner =
2662
+ clip != null &&
2663
+ inner.width > 0 &&
2664
+ inner.height > 0 &&
2665
+ clip.x >= inner.x &&
2666
+ clip.y >= inner.y &&
2667
+ clip.x + clip.width <= inner.x + inner.width &&
2668
+ clip.y + clip.height <= inner.y + inner.height;
2669
+ // background, border and the node's own box
2670
+ if (!insideInner) super.paint(ctx);
2671
+
2672
+ if (!this._visible()) return;
2673
+ const painter = createPainter(ctx, this._textOptions());
2674
+ if (!painter) return; // a backend with no path API: geometry only
2675
+
2676
+ this._sync();
2677
+ this._painting = true;
2678
+ if (this._fitPending) {
2679
+ if (clip) {
2680
+ // a partial pass cannot show a refit — everything outside its rect
2681
+ // would keep the old viewport — so keep the flag and come back full
2682
+ this._repaint('scroll');
2683
+ } else {
2684
+ // Deferred to the first paint that has a size: `fitView` is asked
2685
+ // for before layout has run, and framing a graph in a zero-sized
2686
+ // pane is not an answer.
2687
+ this._fitPending = false;
2688
+ this._fit(this._prop<FitViewOptions>('fitViewOptions'));
2689
+ }
2690
+ }
2691
+ this._frameClip = this._fitPending ? this._pane() : clip;
2692
+
2693
+ const palette = this._palette();
2694
+ const { x, y, width, height } = this._pane();
2695
+ painter.save();
2696
+ // `Node.paint` clips *children*, and this element has none — its drawing
2697
+ // happens after `super.paint` returned, outside any clip of its own. The
2698
+ // clip is the content box rather than `abs` because the border has been
2699
+ // stroked already, inside the box, and drawing over it would leave a
2700
+ // `borderWidth: 1` looking like half of one.
2701
+ const radius = Math.max(
2702
+ 0,
2703
+ ((this.style.borderRadius as number | undefined) ?? 0) -
2704
+ ((this.style.borderWidth as number | undefined) ?? 0),
2705
+ );
2706
+ // The rounded clip only when this pass can actually reach a corner: a
2707
+ // non-rectangular clip forfeits ntk's rounded-box fast path for every
2708
+ // fill under it, which multiplies a rounded *pane* into a per-card
2709
+ // trapezoid pass. A keystroke's or a drag's damage is interior almost
2710
+ // always, and an interior pass under a plain rect clip cannot touch
2711
+ // the corners it is being protected from.
2712
+ const nearCorner =
2713
+ radius > 0 &&
2714
+ (!this._frameClip ||
2715
+ [
2716
+ { x, y },
2717
+ { x: x + width - radius, y },
2718
+ { x, y: y + height - radius },
2719
+ { x: x + width - radius, y: y + height - radius },
2720
+ ].some((corner) =>
2721
+ rectsOverlap(
2722
+ { x: corner.x, y: corner.y, width: radius, height: radius },
2723
+ this._frameClip!,
2724
+ ),
2725
+ ));
2726
+ painter.clipRect(x, y, width, height, nearCorner ? radius : 0);
2727
+ // The fill and the grid draw only the region this pass repaints — on a
2728
+ // full pass that is the pane, and on a drag it is the sliver that moved.
2729
+ const region = this._frameClip
2730
+ ? intersectRects({ x, y, width, height }, this._frameClip)
2731
+ : { x, y, width, height };
2732
+ if (region) {
2733
+ // The style's own colour if it set one — `super.paint` already filled
2734
+ // it, and repeating it costs one rectangle and keeps the two agreeing.
2735
+ painter.rect(region.x, region.y, region.width, region.height, 0, {
2736
+ fill:
2737
+ (this.style.backgroundColor as string | undefined) ??
2738
+ palette.background,
2739
+ });
2740
+ this._paintGrid(painter, palette, region);
2741
+ }
2742
+
2743
+ const animated = this._paintEdges(painter, palette);
2744
+ this._paintNodes(painter, palette);
2745
+ this._paintConnection(painter, palette);
2746
+ this._paintSelectionBox(painter, palette);
2747
+ this._paintMiniMap(painter, palette);
2748
+ this._paintControls(painter, palette);
2749
+ painter.restore();
2750
+ this._painting = false;
2751
+ this._frameClip = null;
2752
+
2753
+ // The timer exists only while something on screen needs it.
2754
+ if (animated) this._startAnimation();
2755
+ else this._stopAnimation();
2756
+
2757
+ if (!this._sceneAnnounced) {
2758
+ // The first paint with a size is the first moment the items have
2759
+ // rects; before it the scene is honestly empty.
2760
+ this._sceneAnnounced = true;
2761
+ this.notifyA11ySceneChanged();
2762
+ }
2763
+
2764
+ // Last, and outside the drawing: this is what tells the React half where
2765
+ // to put the node bodies it mounts, and it is answered from the geometry
2766
+ // this frame just used — the same numbers, never a second derivation.
2767
+ this._emitBodies();
2768
+ }
2769
+
2770
+ /**
2771
+ * Where every mounted node body belongs, relative to the pane's own
2772
+ * top-left. Sent only when the list changed, so a repaint that moved
2773
+ * nothing does not re-render React — but a pan does, once per frame,
2774
+ * which is the cost `render` is documented to carry.
2775
+ */
2776
+ private _emitBodies(): void {
2777
+ const notify =
2778
+ this._prop<(bodies: readonly NodeBodyRect[], sync: boolean) => void>(
2779
+ 'onNodeBodies',
2780
+ );
2781
+ if (!notify) return;
2782
+ const v = this._viewport();
2783
+ const pane = this._pane();
2784
+ const bodies: NodeBodyRect[] = [];
2785
+ for (const entry of this._order) {
2786
+ if (entry.node.hidden || !this._mounted(entry)) continue;
2787
+ const rect = this._screenRect(entry);
2788
+ if (!rectsOverlap(rect, pane)) continue;
2789
+ const header = this._headerHeight(entry) * v.zoom;
2790
+ const inset = NODE_BODY_INSET * v.zoom;
2791
+ const width = rect.width - inset * 2;
2792
+ const height = rect.height - header - inset;
2793
+ if (width <= 1 || height <= 1) continue;
2794
+ bodies.push({
2795
+ id: entry.node.id,
2796
+ // pane-relative, because that is what an absolutely positioned box
2797
+ // beside the pane is laid out against
2798
+ x: Math.round(rect.x + inset - pane.x),
2799
+ y: Math.round(rect.y + header - pane.y),
2800
+ width: Math.round(width),
2801
+ height: Math.round(height),
2802
+ zoom: v.zoom,
2803
+ selected: entry.node.selected ?? false,
2804
+ });
2805
+ }
2806
+ const key = bodies
2807
+ .map((b) => `${b.id}:${b.x},${b.y},${b.width},${b.height},${b.selected}`)
2808
+ .join('|');
2809
+ if (key === this._bodiesKey) return;
2810
+ this._bodiesKey = key;
2811
+ notify(bodies, this._gestureSync);
2812
+ }
2813
+
2814
+ /** The grid, drawn only inside `region` — the pass's damage on a partial
2815
+ * repaint, the pane on a full one. Alignment stays anchored to the
2816
+ * viewport origin, so the region never changes where a dot falls. */
2817
+ private _paintGrid(
2818
+ painter: FlowPainter,
2819
+ palette: FlowPalette,
2820
+ region: FlowRect,
2821
+ ): void {
2822
+ const options = normalizeBackground(
2823
+ this.props.background as BackgroundOptions | string | boolean | undefined,
2824
+ );
2825
+ if (options.variant === 'none') return;
2826
+ const v = this._viewport();
2827
+ let step = options.gap * v.zoom;
2828
+ if (!(step > 0)) return;
2829
+ // Doubling rather than clamping keeps the grid *aligned* to the graph
2830
+ // while zooming out: every visible line is still a real one.
2831
+ while (step < MIN_GRID_PX) step *= 2;
2832
+ const color = options.color ?? palette.grid;
2833
+ const pane = this._pane();
2834
+ const { x, y, width, height } = region;
2835
+ const originX = pane.x + v.x;
2836
+ const originY = pane.y + v.y;
2837
+ if (this._paintGridPattern(painter, options, step, color, region)) return;
2838
+ const firstX = originX + Math.ceil((x - originX) / step) * step;
2839
+ const firstY = originY + Math.ceil((y - originY) / step) * step;
2840
+
2841
+ if (options.variant === 'lines') {
2842
+ const runs: XYPosition[][] = [];
2843
+ for (let gx = firstX; gx <= x + width; gx += step) {
2844
+ runs.push([
2845
+ { x: gx, y },
2846
+ { x: gx, y: y + height },
2847
+ ]);
2848
+ }
2849
+ for (let gy = firstY; gy <= y + height; gy += step) {
2850
+ runs.push([
2851
+ { x, y: gy },
2852
+ { x: x + width, y: gy },
2853
+ ]);
2854
+ }
2855
+ painter.strokeRuns(runs, { stroke: color, lineWidth: 1 });
2856
+ return;
2857
+ }
2858
+
2859
+ if (options.variant === 'cross') {
2860
+ const arm = Math.max(2, options.size * 3 * v.zoom);
2861
+ const runs: XYPosition[][] = [];
2862
+ for (let gx = firstX; gx <= x + width; gx += step) {
2863
+ for (let gy = firstY; gy <= y + height; gy += step) {
2864
+ runs.push([
2865
+ { x: gx - arm, y: gy },
2866
+ { x: gx + arm, y: gy },
2867
+ ]);
2868
+ runs.push([
2869
+ { x: gx, y: gy - arm },
2870
+ { x: gx, y: gy + arm },
2871
+ ]);
2872
+ }
2873
+ }
2874
+ painter.strokeRuns(runs, { stroke: color, lineWidth: 1 });
2875
+ return;
2876
+ }
2877
+
2878
+ const centres: XYPosition[] = [];
2879
+ for (let gx = firstX; gx <= x + width; gx += step) {
2880
+ for (let gy = firstY; gy <= y + height; gy += step) {
2881
+ centres.push({ x: gx, y: gy });
2882
+ }
2883
+ }
2884
+ painter.dots(
2885
+ centres,
2886
+ Math.max(1, Math.round(options.size * 2 * v.zoom)),
2887
+ color,
2888
+ );
2889
+ }
2890
+
2891
+ /**
2892
+ * The grid as one repeating fill (ntk#263): a step-sized tile drawn once,
2893
+ * a `createPattern('repeat')` over it, and one composite for the whole
2894
+ * region — where the runs path pays a region-sized coverage mask.
2895
+ *
2896
+ * Two decisions with reasons:
2897
+ *
2898
+ * - **Integral device steps only.** The tile is a pixmap, so its size is
2899
+ * whole pixels; at a fractional step the pattern would drift against
2900
+ * the graph's own coordinates — against `snapToGrid`, against node
2901
+ * positions — by a fraction per tile, and a grid that slides under the
2902
+ * content it grids is worse than a slower exact one.
2903
+ * - **The phase lives in the tile, not in a picture transform.** An
2904
+ * untransformed repeat is anchored to the window origin, so the grid's
2905
+ * alignment is baked in by drawing the mark at `origin mod tile` — and
2906
+ * re-baking when the origin moves. A `setTransform` translate says the
2907
+ * same thing in one request, but a *transformed* repeat forfeits the
2908
+ * server's untransformed fast path — on the in-process test server
2909
+ * that was ~180 ms a frame of per-pixel arithmetic, and re-rendering a
2910
+ * 24px tile is a handful of requests on any server.
2911
+ */
2912
+ private _paintGridPattern(
2913
+ painter: FlowPainter,
2914
+ options: { variant: BackgroundVariant; size: number },
2915
+ step: number,
2916
+ color: string,
2917
+ region: FlowRect,
2918
+ ): boolean {
2919
+ const tileSize = Math.round(step);
2920
+ if (Math.abs(step - tileSize) > 0.01 || tileSize < 2) return false;
2921
+ const raw = painter.raw as {
2922
+ createPattern?: (source: unknown, repetition: string) => PatternLike;
2923
+ fillStyle?: unknown;
2924
+ fillRect?(x: number, y: number, w: number, h: number): void;
2925
+ } | null;
2926
+ if (!raw || typeof raw.createPattern !== 'function') return false;
2927
+
2928
+ const v = this._viewport();
2929
+ const pane = this._pane();
2930
+ const mod = (a: number, m: number): number => ((a % m) + m) % m;
2931
+ const px = Math.round(mod(pane.x + v.x, tileSize)) % tileSize;
2932
+ const py = Math.round(mod(pane.y + v.y, tileSize)) % tileSize;
2933
+ const key = `${options.variant}|${Math.round(options.size * v.zoom * 4)}|${color}|${px},${py}`;
2934
+
2935
+ let tile = this._gridTile;
2936
+ if (!tile || tile.size !== tileSize) {
2937
+ this._dropGridTile();
2938
+ const surface = this._makeGridSurface(tileSize);
2939
+ if (!surface) return false;
2940
+ tile = {
2941
+ key: '',
2942
+ size: tileSize,
2943
+ surface,
2944
+ pattern: raw.createPattern(surface, 'repeat'),
2945
+ };
2946
+ this._gridTile = tile;
2947
+ }
2948
+ if (tile.key !== key) {
2949
+ tile.key = key;
2950
+ this._renderGridTile(tile, options, v.zoom, color, px, py);
2951
+ }
2952
+ raw.fillStyle = tile.pattern;
2953
+ raw.fillRect?.(region.x, region.y, region.width, region.height);
2954
+ return true;
2955
+ }
2956
+
2957
+ private _makeGridSurface(tileSize: number): SurfaceLike | null {
2958
+ const ctor = (ntk as unknown as { Surface?: SurfaceCtor }).Surface;
2959
+ if (typeof ctor !== 'function') return null;
2960
+ try {
2961
+ return new ctor(this.app, {
2962
+ width: tileSize,
2963
+ height: tileSize,
2964
+ format: 'argb32',
2965
+ });
2966
+ } catch {
2967
+ return null;
2968
+ }
2969
+ }
2970
+
2971
+ /** Draw one grid cell with the mark at the phase point, wrapped — a mark
2972
+ * near an edge is drawn again a tile over, so the seam never cuts it. */
2973
+ private _renderGridTile(
2974
+ tile: { size: number; surface: SurfaceLike },
2975
+ options: { variant: BackgroundVariant; size: number },
2976
+ zoom: number,
2977
+ color: string,
2978
+ px: number,
2979
+ py: number,
2980
+ ): void {
2981
+ const t = tile.size;
2982
+ tile.surface.render((ctx) => {
2983
+ ctx.clearRect?.(0, 0, t, t);
2984
+ ctx.fillStyle = color;
2985
+ for (let i = -1; i <= 1; i++) {
2986
+ for (let j = -1; j <= 1; j++) {
2987
+ const cx = px + i * t;
2988
+ const cy = py + j * t;
2989
+ if (options.variant === 'lines') {
2990
+ if (j === 0) ctx.fillRect(cx - 0.5, 0, 1, t);
2991
+ if (i === 0) ctx.fillRect(0, cy - 0.5, t, 1);
2992
+ } else if (options.variant === 'cross') {
2993
+ const arm = Math.max(2, options.size * 3 * zoom);
2994
+ ctx.fillRect(cx - arm, cy - 0.5, arm * 2, 1);
2995
+ ctx.fillRect(cx - 0.5, cy - arm, 1, arm * 2);
2996
+ } else {
2997
+ const dot = Math.max(1, Math.round(options.size * 2 * zoom));
2998
+ ctx.fillRect(cx - dot / 2, cy - dot / 2, dot, dot);
2999
+ }
3000
+ }
3001
+ }
3002
+ });
3003
+ }
3004
+
3005
+ private _dropGridTile(): void {
3006
+ const tile = this._gridTile;
3007
+ if (!tile) return;
3008
+ tile.pattern._picture?.destroy?.();
3009
+ tile.surface.destroy?.();
3010
+ this._gridTile = null;
3011
+ }
3012
+
3013
+ /** Draws every visible edge; answers whether any of them is animated, so
3014
+ * the caller knows whether to keep a timer alive. */
3015
+ private _paintEdges(painter: FlowPainter, palette: FlowPalette): boolean {
3016
+ const v = this._viewport();
3017
+ const pane = this._pane();
3018
+ const labels = v.zoom >= LABEL_ZOOM;
3019
+ let animated = false;
3020
+ // Collect, then draw. A stroke has no fast path in ntk — every one is a
3021
+ // mask rasterized in JS, uploaded with `PutImage` and composited — so a
3022
+ // graph of seven hundred edges was seven hundred round trips and about
3023
+ // four megabytes a frame. Bucketed by the style they will be stroked
3024
+ // with, the same graph is one path per distinct pen, which for almost
3025
+ // every graph is one.
3026
+ const strokes = new StrokeBuckets();
3027
+ const markers = new Map<string, MarkerBucket>();
3028
+ const labelChips: { rect: FlowRect; radius: number; fill: string }[] = [];
3029
+ const pendingLabels: {
3030
+ text: string;
3031
+ x: number;
3032
+ y: number;
3033
+ size: number;
3034
+ color: string;
3035
+ }[] = [];
3036
+
3037
+ const clip = this._frameClip;
3038
+ let animBox: FlowRect | null = null;
3039
+ for (const edge of this._edges) {
3040
+ if (edge.hidden) continue;
3041
+ // two rejects: one from the nodes alone, one from the route it took
3042
+ const coarse = this._edgeCoarseBox(edge);
3043
+ if (!coarse || !rectsOverlap(coarse, pane)) continue;
3044
+ // Tracked before the damage skip, deliberately: whether the dash
3045
+ // timer runs is a question about the viewport, not about what this
3046
+ // particular pass repaints — deciding it after the skip is how a drag
3047
+ // in one corner would stop the dash marching in the other. The box
3048
+ // the ticks invalidate is collected below from the *drawn* geometry:
3049
+ // the coarse box carries the bezier slack, and a tick that repaints
3050
+ // slack repaints a card-sized halo of neighbours sixteen times a
3051
+ // second.
3052
+ if (edge.animated) animated = true;
3053
+ if (clip && !rectsOverlap(coarse, clip)) continue;
3054
+ const geometry = this._edgeGeometry(edge);
3055
+ if (!geometry) continue;
3056
+ if (!rectsOverlap(pathBounds(geometry.points), pane)) continue;
3057
+
3058
+ const selected = edge.selected ?? false;
3059
+ const hovered = this._hover.edgeId === edge.id;
3060
+ const stroke =
3061
+ edge.style?.stroke ??
3062
+ (selected
3063
+ ? palette.edgeSelected
3064
+ : hovered
3065
+ ? palette.text
3066
+ : palette.edge);
3067
+ const lineWidth = Math.max(
3068
+ 1,
3069
+ (edge.style?.strokeWidth ?? (selected ? 2 : 1.5)) * v.zoom,
3070
+ );
3071
+ // `markerEnd` left out means an arrow: a directed graph whose edges do
3072
+ // not say which way they point is a set of lines. `null` opts out.
3073
+ const markerEnd = normalizeMarker(
3074
+ edge.markerEnd === undefined ? 'arrowclosed' : edge.markerEnd,
3075
+ );
3076
+ const markerStart = normalizeMarker(edge.markerStart);
3077
+
3078
+ // The tip stops short of the handle rather than at it: the handle dot
3079
+ // is drawn *over* the edges, with the nodes, so an arrow aimed at the
3080
+ // handle's centre is an arrow mostly hidden under a white circle.
3081
+ const inset = v.zoom >= HANDLE_ZOOM ? this._handleRadius() + 1 : 1;
3082
+ const last = geometry.points[geometry.points.length - 1];
3083
+ const outAngle = endAngle(geometry.points);
3084
+ const endTip = {
3085
+ x: last.x - Math.cos(outAngle) * inset,
3086
+ y: last.y - Math.sin(outAngle) * inset,
3087
+ };
3088
+ let points = geometry.points;
3089
+ if (markerEnd) {
3090
+ // and the stroke stops behind the head, so a filled triangle is a
3091
+ // triangle rather than a triangle with a line through it
3092
+ points = trimEnd(
3093
+ points,
3094
+ (markerEnd.size ?? DEFAULT_MARKER_SIZE) * v.zoom * 0.8 + inset,
3095
+ );
3096
+ }
3097
+ const dash =
3098
+ edge.style?.dash ?? (edge.animated ? DEFAULT_DASH : undefined);
3099
+ if (edge.animated) {
3100
+ const tight = inflateRect(pathBounds(geometry.points), CULL_MARGIN);
3101
+ animBox = animBox ? unionRects(animBox, tight) : tight;
3102
+ }
3103
+ strokes
3104
+ .bucket(
3105
+ stroke,
3106
+ lineWidth,
3107
+ dash,
3108
+ edge.animated ? this._dashPhase : 0,
3109
+ v.zoom,
3110
+ )
3111
+ .push(points);
3112
+
3113
+ if (markerEnd) {
3114
+ this._collectMarker(
3115
+ markers,
3116
+ endTip,
3117
+ outAngle,
3118
+ markerEnd.type,
3119
+ markerEnd.color ?? stroke,
3120
+ (markerEnd.size ?? DEFAULT_MARKER_SIZE) * v.zoom,
3121
+ lineWidth,
3122
+ );
3123
+ }
3124
+ if (markerStart) {
3125
+ const inAngle = startAngle(geometry.points);
3126
+ this._collectMarker(
3127
+ markers,
3128
+ {
3129
+ x: geometry.points[0].x - Math.cos(inAngle) * inset,
3130
+ y: geometry.points[0].y - Math.sin(inAngle) * inset,
3131
+ },
3132
+ inAngle,
3133
+ markerStart.type,
3134
+ markerStart.color ?? stroke,
3135
+ (markerStart.size ?? DEFAULT_MARKER_SIZE) * v.zoom,
3136
+ lineWidth,
3137
+ );
3138
+ }
3139
+
3140
+ if (labels && edge.label) {
3141
+ const at = pointAtFraction(geometry.points, 0.5);
3142
+ const size = Math.max(8, 11 * v.zoom);
3143
+ const metrics = painter.measureText(edge.label, { size });
3144
+ const padX = 5 * v.zoom;
3145
+ const padY = 2 * v.zoom;
3146
+ // The chip is collected with the rest; the text is not, because a
3147
+ // glyph run is already one request and nothing is gained by holding
3148
+ // it. Both still land above every edge, which is the point of the
3149
+ // chip.
3150
+ labelChips.push({
3151
+ rect: {
3152
+ x: Math.round(at.x - metrics.width / 2 - padX),
3153
+ y: Math.round(at.y - metrics.height / 2 - padY),
3154
+ width: Math.round(metrics.width + padX * 2),
3155
+ height: Math.round(metrics.height + padY * 2),
3156
+ },
3157
+ radius: Math.max(2, Math.round(3 * v.zoom)),
3158
+ fill: edge.style?.labelBackground ?? tint(palette.background, 0.92),
3159
+ });
3160
+ pendingLabels.push({
3161
+ text: edge.label,
3162
+ x: at.x,
3163
+ y: at.y,
3164
+ size,
3165
+ color: edge.style?.labelColor ?? palette.text,
3166
+ });
3167
+ }
3168
+ }
3169
+
3170
+ strokes.paint(painter);
3171
+ for (const bucket of markers.values()) {
3172
+ // the same threshold, for the same reason: a handful of arrowheads
3173
+ // scattered over the pane is cheaper drawn as a handful
3174
+ if (bucket.filled.length >= MARKER_BATCH_MIN) {
3175
+ painter.polygons(bucket.filled, { fill: bucket.color });
3176
+ } else {
3177
+ for (const head of bucket.filled) {
3178
+ painter.polygon(head, { fill: bucket.color });
3179
+ }
3180
+ }
3181
+ if (bucket.open.length >= MARKER_BATCH_MIN) {
3182
+ painter.strokeRuns(bucket.open, {
3183
+ stroke: bucket.color,
3184
+ lineWidth: bucket.lineWidth,
3185
+ });
3186
+ } else {
3187
+ for (const head of bucket.open) {
3188
+ painter.strokeRuns([head], {
3189
+ stroke: bucket.color,
3190
+ lineWidth: bucket.lineWidth,
3191
+ });
3192
+ }
3193
+ }
3194
+ }
3195
+ for (const chip of labelChips) {
3196
+ painter.rect(
3197
+ chip.rect.x,
3198
+ chip.rect.y,
3199
+ chip.rect.width,
3200
+ chip.rect.height,
3201
+ chip.radius,
3202
+ { fill: chip.fill },
3203
+ );
3204
+ }
3205
+ for (const l of pendingLabels) {
3206
+ painter.text(l.text, l.x, l.y, {
3207
+ size: l.size,
3208
+ color: l.color,
3209
+ align: 'center',
3210
+ baseline: 'middle',
3211
+ });
3212
+ }
3213
+ // A pass that drew an animated edge knows exactly where its dash is; a
3214
+ // pass that culled them all keeps the previous box — the endpoints did
3215
+ // not move, or the move's own damage would have redrawn them here.
3216
+ if (animBox) this._animBox = animBox;
3217
+ return animated;
3218
+ }
3219
+
3220
+ /** An arrowhead's three or four points, added to the pile for its colour
3221
+ * rather than drawn — see {@link StrokeBuckets} for why. */
3222
+ private _collectMarker(
3223
+ markers: Map<string, MarkerBucket>,
3224
+ at: XYPosition,
3225
+ angle: number,
3226
+ type: 'arrow' | 'arrowclosed',
3227
+ color: string,
3228
+ size: number,
3229
+ lineWidth: number,
3230
+ ): void {
3231
+ const spread = 0.42; // radians off the shaft — a ~24° half-angle
3232
+ const back = {
3233
+ x: at.x - Math.cos(angle) * size,
3234
+ y: at.y - Math.sin(angle) * size,
3235
+ };
3236
+ const left = {
3237
+ x: at.x - Math.cos(angle - spread) * size,
3238
+ y: at.y - Math.sin(angle - spread) * size,
3239
+ };
3240
+ const right = {
3241
+ x: at.x - Math.cos(angle + spread) * size,
3242
+ y: at.y - Math.sin(angle + spread) * size,
3243
+ };
3244
+ const key = `${color}|${lineWidth}`;
3245
+ let bucket = markers.get(key);
3246
+ if (!bucket) {
3247
+ bucket = { color, lineWidth, filled: [], open: [] };
3248
+ markers.set(key, bucket);
3249
+ }
3250
+ if (type === 'arrowclosed') bucket.filled.push([at, left, back, right]);
3251
+ else bucket.open.push([left, at, right]);
3252
+ }
3253
+
3254
+ private _paintNodes(painter: FlowPainter, palette: FlowPalette): void {
3255
+ const dragging = this._dragTo;
3256
+ const order: NodeEntry[] = [];
3257
+ const deferred: NodeEntry[] = [];
3258
+ for (const entry of this._order) {
3259
+ // a dragged node comes to the top
3260
+ if (dragging?.has(entry.node.id)) deferred.push(entry);
3261
+ else order.push(entry);
3262
+ }
3263
+ order.push(...deferred);
3264
+
3265
+ // Nodes are drawn one at a time, in z-order, and both halves of that
3266
+ // were measured rather than assumed — see `_paintHandles` for why
3267
+ // batching a card or a handle costs more than it saves.
3268
+ for (const entry of order) this._paintNode(painter, palette, entry);
3269
+ }
3270
+
3271
+ private _paintNode(
3272
+ painter: FlowPainter,
3273
+ palette: FlowPalette,
3274
+ entry: NodeEntry,
3275
+ ): void {
3276
+ if (entry.node.hidden) return;
3277
+ const rect = this._screenRect(entry);
3278
+ if (!rectsOverlap(rect, this._pane())) return;
3279
+ // inflated by the margin its handles and grips can ink outside the box —
3280
+ // the same margin every invalidate grew by, so the two agree
3281
+ const clip = this._frameClip;
3282
+ if (clip && !rectsOverlap(inflateRect(rect, CULL_MARGIN), clip)) return;
3283
+ const v = this._viewport();
3284
+ const selected = entry.node.selected ?? false;
3285
+ const hovered = this._hover.nodeId === entry.node.id;
3286
+ const handles = this._handlesOf(entry).map((anchor) => {
3287
+ const s = this._toScreen(anchor);
3288
+ return { ...anchor, x: s.x, y: s.y };
3289
+ });
3290
+
3291
+ if (entry.type?.paint) {
3292
+ // Never batched: a type that draws its own body is drawing whatever it
3293
+ // likes, in an order only it knows.
3294
+ entry.type.paint({
3295
+ node: entry.node,
3296
+ rect,
3297
+ zoom: v.zoom,
3298
+ selected,
3299
+ hovered,
3300
+ palette,
3301
+ painter,
3302
+ handles,
3303
+ });
3304
+ } else {
3305
+ this._paintCardShape(painter, palette, entry, rect, selected, hovered);
3306
+ this._paintCardInk(painter, palette, entry, rect);
3307
+ }
3308
+
3309
+ if (this._connectable(entry) && (v.zoom >= HANDLE_ZOOM || hovered)) {
3310
+ this._paintHandles(painter, palette, handles);
3311
+ }
3312
+ this._paintGrips(painter, palette, entry, rect);
3313
+ }
3314
+
3315
+ private _mounted(entry: NodeEntry): boolean {
3316
+ return entry.type?.render != null && this._viewport().zoom >= RENDER_ZOOM;
3317
+ }
3318
+
3319
+ private _paintGrips(
3320
+ painter: FlowPainter,
3321
+ palette: FlowPalette,
3322
+ entry: NodeEntry,
3323
+ rect: FlowRect,
3324
+ ): void {
3325
+ const grips = this._grips(entry);
3326
+ if (grips.length === 0) return;
3327
+ const size = Math.max(4, RESIZE_GRIP * 2 * this._viewport().zoom);
3328
+ for (const dir of grips) {
3329
+ const at = gripPoint(rect, dir);
3330
+ painter.rect(at.x - size / 2, at.y - size / 2, size, size, 1, {
3331
+ fill: palette.nodeBackground,
3332
+ stroke: palette.accent,
3333
+ lineWidth: 1.5,
3334
+ });
3335
+ }
3336
+ }
3337
+
3338
+ /** The built-in node: a card with a label, an optional second line and an
3339
+ * optional accent stripe down its leading edge. */
3340
+ /** The card itself: one rounded box, and the accent stripe if it has one.
3341
+ * With a `batch` the box joins the pile for its appearance instead of
3342
+ * being drawn on its own. */
3343
+ private _paintCardShape(
3344
+ painter: FlowPainter,
3345
+ palette: FlowPalette,
3346
+ entry: NodeEntry,
3347
+ rect: FlowRect,
3348
+ selected: boolean,
3349
+ hovered: boolean,
3350
+ ): void {
3351
+ const v = this._viewport();
3352
+ const style = entry.node.style;
3353
+ const radius = Math.round((style?.borderRadius ?? 6) * v.zoom);
3354
+ const border = selected
3355
+ ? palette.accent
3356
+ : hovered
3357
+ ? tint(palette.accent, 0.55)
3358
+ : (style?.borderColor ?? palette.nodeBorder);
3359
+ const options: ShapeOptions = {
3360
+ fill: style?.background ?? palette.nodeBackground,
3361
+ stroke: border,
3362
+ lineWidth: Math.max(
3363
+ 1,
3364
+ Math.round((style?.borderWidth ?? (selected ? 2 : 1)) * v.zoom),
3365
+ ),
3366
+ };
3367
+ painter.rect(rect.x, rect.y, rect.width, rect.height, radius, options);
3368
+
3369
+ if (style?.accent) {
3370
+ // Inset past the rounded corners instead of clipped to them: a
3371
+ // non-rectangular clip forfeits ntk's rounded-box fast path for every
3372
+ // fill under it — measured as a pixmap create/free and a trapezoid
3373
+ // pass per card per repaint.
3374
+ painter.rect(
3375
+ rect.x + Math.max(1, v.zoom),
3376
+ rect.y + radius,
3377
+ Math.max(2, 3 * v.zoom),
3378
+ rect.height - radius * 2,
3379
+ 0,
3380
+ { fill: style.accent },
3381
+ );
3382
+ }
3383
+ }
3384
+
3385
+ /** The label and its second line — the half of a card that has to come
3386
+ * after every card when they are batched. */
3387
+ private _paintCardInk(
3388
+ painter: FlowPainter,
3389
+ palette: FlowPalette,
3390
+ entry: NodeEntry,
3391
+ rect: FlowRect,
3392
+ ): void {
3393
+ const v = this._viewport();
3394
+ if (v.zoom < LABEL_ZOOM) return;
3395
+ const style = entry.node.style;
3396
+ const header = this._mounted(entry) ? this._headerHeight(entry) : 0;
3397
+ const data = entry.node.data as FlowNodeData | undefined;
3398
+ const label = data?.label ?? entry.node.id;
3399
+ const description = data?.description;
3400
+ const color = style?.color ?? palette.text;
3401
+ const padX = NODE_PAD_X * v.zoom;
3402
+ const centre = rect.x + rect.width / 2;
3403
+ const maxWidth = Math.max(8, rect.width - padX * 2);
3404
+ const showDescription = Boolean(description) && v.zoom >= DESC_ZOOM;
3405
+
3406
+ if (header > 0) {
3407
+ // The title bar of a node whose body is somebody else's: left-aligned,
3408
+ // with a hairline under it so the strip reads as the thing to grab.
3409
+ const band = header * v.zoom;
3410
+ painter.text(label, rect.x + padX, rect.y + band / 2, {
3411
+ size: NODE_LABEL_SIZE * v.zoom,
3412
+ weight: 'bold',
3413
+ color,
3414
+ baseline: 'middle',
3415
+ maxWidth,
3416
+ });
3417
+ painter.strokeRuns(
3418
+ [
3419
+ [
3420
+ { x: rect.x + 1, y: rect.y + band },
3421
+ { x: rect.x + rect.width - 1, y: rect.y + band },
3422
+ ],
3423
+ ],
3424
+ { stroke: palette.nodeBorder, lineWidth: 1 },
3425
+ );
3426
+ return;
3427
+ }
3428
+
3429
+ if (!showDescription) {
3430
+ painter.text(label, centre, rect.y + rect.height / 2, {
3431
+ size: NODE_LABEL_SIZE * v.zoom,
3432
+ weight: 'bold',
3433
+ color,
3434
+ align: 'center',
3435
+ baseline: 'middle',
3436
+ maxWidth,
3437
+ });
3438
+ return;
3439
+ }
3440
+ const top = rect.y + NODE_PAD_Y * v.zoom;
3441
+ painter.text(label, centre, top, {
3442
+ size: NODE_LABEL_SIZE * v.zoom,
3443
+ weight: 'bold',
3444
+ color,
3445
+ align: 'center',
3446
+ maxWidth,
3447
+ });
3448
+ painter.text(description!, centre, top + NODE_LABEL_SIZE * 1.35 * v.zoom, {
3449
+ size: NODE_DESC_SIZE * v.zoom,
3450
+ color: palette.dim,
3451
+ align: 'center',
3452
+ maxWidth,
3453
+ });
3454
+ }
3455
+
3456
+ /**
3457
+ * Handles are drawn one disc at a time, and that is the measured answer
3458
+ * rather than the obvious one.
3459
+ *
3460
+ * Batching them into a single path halved the requests and made the frame
3461
+ * *slower*: a path's mask is its bounding box, so forty dots scattered
3462
+ * across the pane rasterize to a paneful of mask — three quarters of a
3463
+ * megabyte — where forty small ones cost a kilobyte each. Batching pays
3464
+ * for the edges because an edge already spans that box; it does not pay
3465
+ * for anything small and scattered.
3466
+ */
3467
+ private _paintHandles(
3468
+ painter: FlowPainter,
3469
+ palette: FlowPalette,
3470
+ handles: readonly HandleAnchor[],
3471
+ ): void {
3472
+ const v = this._viewport();
3473
+ const radius = this._handleRadius();
3474
+ const gesture = this._gesture;
3475
+ const connecting = gesture?.kind === 'connect' ? gesture : null;
3476
+ const lineWidth = Math.max(1, 1.5 * v.zoom);
3477
+ for (const anchor of handles) {
3478
+ const active =
3479
+ sameHandle(this._hover.handle, anchor) ||
3480
+ sameHandle(connecting?.to ?? null, anchor) ||
3481
+ sameHandle(connecting?.from ?? null, anchor);
3482
+ const options: ShapeOptions = {
3483
+ fill: active ? palette.accent : palette.nodeBackground,
3484
+ stroke: active ? palette.accent : palette.handle,
3485
+ lineWidth,
3486
+ };
3487
+ painter.circle(
3488
+ anchor.x,
3489
+ anchor.y,
3490
+ active ? radius * 1.4 : radius,
3491
+ options,
3492
+ );
3493
+ if (anchor.label && v.zoom >= DESC_ZOOM) {
3494
+ const outside = anchor.position === 'left' || anchor.position === 'top';
3495
+ painter.text(
3496
+ anchor.label,
3497
+ anchor.x +
3498
+ (anchor.position === 'left'
3499
+ ? -radius - 4 * v.zoom
3500
+ : radius + 4 * v.zoom),
3501
+ anchor.y,
3502
+ {
3503
+ size: 10 * v.zoom,
3504
+ color: palette.dim,
3505
+ align: outside ? 'right' : 'left',
3506
+ baseline: 'middle',
3507
+ },
3508
+ );
3509
+ }
3510
+ }
3511
+ }
3512
+
3513
+ private _paintConnection(painter: FlowPainter, palette: FlowPalette): void {
3514
+ const gesture = this._gesture;
3515
+ if (gesture?.kind !== 'connect') return;
3516
+ const v = this._viewport();
3517
+ const target = gesture.to ?? gesture.pointer;
3518
+ const to = this._toScreen(target);
3519
+ const points = this._connectionPath(gesture);
3520
+ const invalid = gesture.to != null && !gesture.valid;
3521
+ painter.polyline(points, {
3522
+ stroke: invalid ? palette.dim : palette.accent,
3523
+ lineWidth: Math.max(1.5, 2 * v.zoom),
3524
+ dash: gesture.to && gesture.valid ? undefined : [5 * v.zoom, 4 * v.zoom],
3525
+ });
3526
+ painter.circle(to.x, to.y, this._handleRadius(), {
3527
+ fill: invalid ? palette.dim : palette.accent,
3528
+ });
3529
+ }
3530
+
3531
+ private _paintSelectionBox(painter: FlowPainter, palette: FlowPalette): void {
3532
+ const gesture = this._gesture;
3533
+ if (gesture?.kind !== 'select') return;
3534
+ const x = Math.min(gesture.startX, gesture.x);
3535
+ const y = Math.min(gesture.startY, gesture.y);
3536
+ painter.rect(
3537
+ x,
3538
+ y,
3539
+ Math.abs(gesture.x - gesture.startX),
3540
+ Math.abs(gesture.y - gesture.startY),
3541
+ 2,
3542
+ { fill: palette.selection, stroke: palette.accent, lineWidth: 1 },
3543
+ );
3544
+ }
3545
+
3546
+ private _paintMiniMap(painter: FlowPainter, palette: FlowPalette): void {
3547
+ const opts = this._miniMapOptions();
3548
+ if (!opts) return;
3549
+ // The panel cull comes before `_miniMap()`, which walks every node to
3550
+ // find the graph's bounds — during a drag that walk per pass would cost
3551
+ // more than the panel it skips. The dot for a mid-drag node goes stale
3552
+ // until the release repaints in full; that is the trade, and it is
3553
+ // deliberate.
3554
+ const clip = this._frameClip;
3555
+ if (clip) {
3556
+ const quick = this._corner(
3557
+ opts.position,
3558
+ opts.width ?? MINIMAP_W,
3559
+ opts.height ?? MINIMAP_H,
3560
+ 'bottom-right',
3561
+ );
3562
+ if (!rectsOverlap(quick, clip)) return;
3563
+ }
3564
+ const map = this._miniMap();
3565
+ if (!map) return;
3566
+ const { panel, options } = map;
3567
+ painter.rect(panel.x, panel.y, panel.width, panel.height, 4, {
3568
+ fill: tint(palette.surface, 0.92),
3569
+ stroke: palette.surfaceBorder,
3570
+ lineWidth: 1,
3571
+ });
3572
+ painter.save();
3573
+ painter.clipRect(panel.x, panel.y, panel.width, panel.height, 4);
3574
+
3575
+ const nodeColor = options.nodeColor;
3576
+ for (const entry of this._entries) {
3577
+ if (entry.node.hidden) continue;
3578
+ const rect = this.rectOf(entry);
3579
+ const at = this._miniToScreen(map, rect);
3580
+ const fill =
3581
+ typeof nodeColor === 'function'
3582
+ ? nodeColor(entry.node as FlowNode<never>)
3583
+ : (nodeColor ??
3584
+ (entry.node.selected ? palette.accent : palette.nodeBorder));
3585
+ painter.rect(
3586
+ at.x,
3587
+ at.y,
3588
+ Math.max(1, rect.width * map.scale),
3589
+ Math.max(1, rect.height * map.scale),
3590
+ 0,
3591
+ { fill },
3592
+ );
3593
+ }
3594
+
3595
+ // The viewport, as an outline over a wash on everything outside it —
3596
+ // cheaper than four mask rectangles and reads the same.
3597
+ const v = this._viewport();
3598
+ const paneRect = this._pane();
3599
+ const view = this._miniToScreen(map, {
3600
+ x: -v.x / v.zoom,
3601
+ y: -v.y / v.zoom,
3602
+ });
3603
+ painter.rect(
3604
+ view.x,
3605
+ view.y,
3606
+ (paneRect.width / v.zoom) * map.scale,
3607
+ (paneRect.height / v.zoom) * map.scale,
3608
+ 2,
3609
+ {
3610
+ fill: options.maskColor ?? tint(palette.accent, 0.1),
3611
+ stroke: palette.accent,
3612
+ lineWidth: 1,
3613
+ },
3614
+ );
3615
+ painter.restore();
3616
+ }
3617
+
3618
+ private _paintControls(painter: FlowPainter, palette: FlowPalette): void {
3619
+ const buttons = this._controlButtons();
3620
+ if (buttons.length === 0) return;
3621
+ const clip = this._frameClip;
3622
+ if (clip && !buttons.some((b) => rectsOverlap(b.rect, clip))) return;
3623
+ const first = buttons[0].rect;
3624
+ const last = buttons[buttons.length - 1].rect;
3625
+ painter.rect(
3626
+ first.x,
3627
+ first.y,
3628
+ first.width,
3629
+ last.y + last.height - first.y,
3630
+ 4,
3631
+ {
3632
+ fill: tint(palette.surface, 0.94),
3633
+ stroke: palette.surfaceBorder,
3634
+ lineWidth: 1,
3635
+ },
3636
+ );
3637
+ for (let i = 0; i < buttons.length; i++) {
3638
+ const { rect, action } = buttons[i];
3639
+ if (i > 0) {
3640
+ painter.strokeRuns(
3641
+ [
3642
+ [
3643
+ { x: rect.x + 4, y: rect.y },
3644
+ { x: rect.x + rect.width - 4, y: rect.y },
3645
+ ],
3646
+ ],
3647
+ { stroke: palette.surfaceBorder, lineWidth: 1 },
3648
+ );
3649
+ }
3650
+ const cx = rect.x + rect.width / 2;
3651
+ const cy = rect.y + rect.height / 2;
3652
+ const arm = 5;
3653
+ const runs: XYPosition[][] = [];
3654
+ if (action === 'in' || action === 'out') {
3655
+ runs.push([
3656
+ { x: cx - arm, y: cy },
3657
+ { x: cx + arm, y: cy },
3658
+ ]);
3659
+ if (action === 'in') {
3660
+ runs.push([
3661
+ { x: cx, y: cy - arm },
3662
+ { x: cx, y: cy + arm },
3663
+ ]);
3664
+ }
3665
+ } else {
3666
+ // a frame with its sides opened out: "fit what is there into this"
3667
+ const a = 5;
3668
+ runs.push(
3669
+ [
3670
+ { x: cx - a, y: cy - a + 3 },
3671
+ { x: cx - a, y: cy - a },
3672
+ { x: cx - a + 3, y: cy - a },
3673
+ ],
3674
+ [
3675
+ { x: cx + a - 3, y: cy - a },
3676
+ { x: cx + a, y: cy - a },
3677
+ { x: cx + a, y: cy - a + 3 },
3678
+ ],
3679
+ [
3680
+ { x: cx + a, y: cy + a - 3 },
3681
+ { x: cx + a, y: cy + a },
3682
+ { x: cx + a - 3, y: cy + a },
3683
+ ],
3684
+ [
3685
+ { x: cx - a + 3, y: cy + a },
3686
+ { x: cx - a, y: cy + a },
3687
+ { x: cx - a, y: cy + a - 3 },
3688
+ ],
3689
+ );
3690
+ }
3691
+ painter.strokeRuns(runs, { stroke: palette.text, lineWidth: 1.5 });
3692
+ }
3693
+ }
3694
+ }