@jackuait/blok 0.4.1 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (397) hide show
  1. package/README.md +136 -17
  2. package/codemod/README.md +16 -0
  3. package/codemod/migrate-editorjs-to-blok.js +859 -92
  4. package/codemod/test.js +682 -77
  5. package/dist/blok.mjs +5 -2
  6. package/dist/chunks/blok-BjgH1REI.mjs +12838 -0
  7. package/dist/chunks/i18next-CugVlwWp.mjs +1292 -0
  8. package/dist/chunks/i18next-loader-DfiUa_gd.mjs +43 -0
  9. package/dist/{index-CBkApZKo.mjs → chunks/index-5m5JWNey.mjs} +2 -2
  10. package/dist/chunks/inline-tool-convert-Bx5BVd8I.mjs +1988 -0
  11. package/dist/chunks/messages-2434tVOK.mjs +47 -0
  12. package/dist/chunks/messages-3DcCwXMF.mjs +47 -0
  13. package/dist/chunks/messages-4kMwVAKY.mjs +47 -0
  14. package/dist/chunks/messages-57uL5htT.mjs +47 -0
  15. package/dist/chunks/messages-76-iJV9Q.mjs +47 -0
  16. package/dist/chunks/messages-8p86Eyf2.mjs +47 -0
  17. package/dist/chunks/messages-BBX0p0Pi.mjs +47 -0
  18. package/dist/chunks/messages-BCm2eudQ.mjs +47 -0
  19. package/dist/chunks/messages-BFiUomgG.mjs +47 -0
  20. package/dist/chunks/messages-BIPNHHAV.mjs +47 -0
  21. package/dist/chunks/messages-BUlwu9mo.mjs +47 -0
  22. package/dist/chunks/messages-BX-DPa-z.mjs +47 -0
  23. package/dist/chunks/messages-BextV3Qh.mjs +47 -0
  24. package/dist/chunks/messages-BiPSFlUG.mjs +47 -0
  25. package/dist/chunks/messages-BiXe9G-O.mjs +47 -0
  26. package/dist/chunks/messages-Bl5z_Igo.mjs +47 -0
  27. package/dist/chunks/messages-BnsE97ku.mjs +47 -0
  28. package/dist/chunks/messages-BoO8gsVD.mjs +47 -0
  29. package/dist/chunks/messages-BqWaOGMn.mjs +47 -0
  30. package/dist/chunks/messages-BqkL2_Ro.mjs +47 -0
  31. package/dist/chunks/messages-BvCkXKX-.mjs +47 -0
  32. package/dist/chunks/messages-C6tbPLoj.mjs +47 -0
  33. package/dist/chunks/messages-CA6T3-gQ.mjs +47 -0
  34. package/dist/chunks/messages-CFFPFdWP.mjs +47 -0
  35. package/dist/chunks/messages-CFrKE-TN.mjs +47 -0
  36. package/dist/chunks/messages-CHz8VlG-.mjs +47 -0
  37. package/dist/chunks/messages-CLixzySl.mjs +47 -0
  38. package/dist/chunks/messages-CV7OM_qk.mjs +47 -0
  39. package/dist/chunks/messages-CXHt3eCC.mjs +47 -0
  40. package/dist/chunks/messages-CbmsBrB0.mjs +47 -0
  41. package/dist/chunks/messages-Ceo1KtFx.mjs +47 -0
  42. package/dist/chunks/messages-Cm0LJLtB.mjs +47 -0
  43. package/dist/chunks/messages-CmymP_Ar.mjs +47 -0
  44. package/dist/chunks/messages-D0ohMB5H.mjs +47 -0
  45. package/dist/chunks/messages-D3GrDwXh.mjs +47 -0
  46. package/dist/chunks/messages-D3vTzIpL.mjs +47 -0
  47. package/dist/chunks/messages-D5WeksbV.mjs +47 -0
  48. package/dist/chunks/messages-DGaab4EP.mjs +47 -0
  49. package/dist/chunks/messages-DKha57ZU.mjs +47 -0
  50. package/dist/chunks/messages-DOaujgMW.mjs +47 -0
  51. package/dist/chunks/messages-DVbPLd_0.mjs +47 -0
  52. package/dist/chunks/messages-D_FCyfW6.mjs +47 -0
  53. package/dist/chunks/messages-Dd5iZN3c.mjs +47 -0
  54. package/dist/chunks/messages-DehM7135.mjs +47 -0
  55. package/dist/chunks/messages-Dg1OHftD.mjs +47 -0
  56. package/dist/chunks/messages-Di6Flq-b.mjs +47 -0
  57. package/dist/chunks/messages-Dqhhex6e.mjs +47 -0
  58. package/dist/chunks/messages-DueVe0F1.mjs +47 -0
  59. package/dist/chunks/messages-Dx3eFwI0.mjs +47 -0
  60. package/dist/chunks/messages-FOtiUoKl.mjs +47 -0
  61. package/dist/chunks/messages-FTOZNhRD.mjs +47 -0
  62. package/dist/chunks/messages-IQxGfQIV.mjs +47 -0
  63. package/dist/chunks/messages-JF2fzCkK.mjs +47 -0
  64. package/dist/chunks/messages-MOGl7I5v.mjs +47 -0
  65. package/dist/chunks/messages-QgYhPL-3.mjs +47 -0
  66. package/dist/chunks/messages-WYWIbQwo.mjs +47 -0
  67. package/dist/chunks/messages-a6A_LgDv.mjs +47 -0
  68. package/dist/chunks/messages-bSf31LJi.mjs +47 -0
  69. package/dist/chunks/messages-diGozhTn.mjs +47 -0
  70. package/dist/chunks/messages-er-kd-VO.mjs +47 -0
  71. package/dist/chunks/messages-ez3w5NBn.mjs +47 -0
  72. package/dist/chunks/messages-f3uXjegd.mjs +47 -0
  73. package/dist/chunks/messages-ohwI1UGv.mjs +47 -0
  74. package/dist/chunks/messages-p9BZJaFV.mjs +47 -0
  75. package/dist/chunks/messages-qIQ4L4rw.mjs +47 -0
  76. package/dist/chunks/messages-qWkXPggi.mjs +47 -0
  77. package/dist/chunks/messages-w5foGze_.mjs +47 -0
  78. package/dist/full.mjs +50 -0
  79. package/dist/locales.mjs +227 -0
  80. package/dist/messages-2434tVOK.mjs +47 -0
  81. package/dist/messages-3DcCwXMF.mjs +47 -0
  82. package/dist/messages-4kMwVAKY.mjs +47 -0
  83. package/dist/messages-57uL5htT.mjs +47 -0
  84. package/dist/messages-76-iJV9Q.mjs +47 -0
  85. package/dist/messages-8p86Eyf2.mjs +47 -0
  86. package/dist/messages-BBX0p0Pi.mjs +47 -0
  87. package/dist/messages-BCm2eudQ.mjs +47 -0
  88. package/dist/messages-BFiUomgG.mjs +47 -0
  89. package/dist/messages-BIPNHHAV.mjs +47 -0
  90. package/dist/messages-BUlwu9mo.mjs +47 -0
  91. package/dist/messages-BX-DPa-z.mjs +47 -0
  92. package/dist/messages-BextV3Qh.mjs +47 -0
  93. package/dist/messages-BiPSFlUG.mjs +47 -0
  94. package/dist/messages-BiXe9G-O.mjs +47 -0
  95. package/dist/messages-Bl5z_Igo.mjs +47 -0
  96. package/dist/messages-BnsE97ku.mjs +47 -0
  97. package/dist/messages-BoO8gsVD.mjs +47 -0
  98. package/dist/messages-BqWaOGMn.mjs +47 -0
  99. package/dist/messages-BqkL2_Ro.mjs +47 -0
  100. package/dist/messages-BvCkXKX-.mjs +47 -0
  101. package/dist/messages-C6tbPLoj.mjs +47 -0
  102. package/dist/messages-CA6T3-gQ.mjs +47 -0
  103. package/dist/messages-CFFPFdWP.mjs +47 -0
  104. package/dist/messages-CFrKE-TN.mjs +47 -0
  105. package/dist/messages-CHz8VlG-.mjs +47 -0
  106. package/dist/messages-CLixzySl.mjs +47 -0
  107. package/dist/messages-CV7OM_qk.mjs +47 -0
  108. package/dist/messages-CXHt3eCC.mjs +47 -0
  109. package/dist/messages-CbmsBrB0.mjs +47 -0
  110. package/dist/messages-Ceo1KtFx.mjs +47 -0
  111. package/dist/messages-Cm0LJLtB.mjs +47 -0
  112. package/dist/messages-CmymP_Ar.mjs +47 -0
  113. package/dist/messages-D0ohMB5H.mjs +47 -0
  114. package/dist/messages-D3GrDwXh.mjs +47 -0
  115. package/dist/messages-D3vTzIpL.mjs +47 -0
  116. package/dist/messages-D5WeksbV.mjs +47 -0
  117. package/dist/messages-DGaab4EP.mjs +47 -0
  118. package/dist/messages-DKha57ZU.mjs +47 -0
  119. package/dist/messages-DOaujgMW.mjs +47 -0
  120. package/dist/messages-DVbPLd_0.mjs +47 -0
  121. package/dist/messages-D_FCyfW6.mjs +47 -0
  122. package/dist/messages-Dd5iZN3c.mjs +47 -0
  123. package/dist/messages-DehM7135.mjs +47 -0
  124. package/dist/messages-Dg1OHftD.mjs +47 -0
  125. package/dist/messages-Di6Flq-b.mjs +47 -0
  126. package/dist/messages-Dqhhex6e.mjs +47 -0
  127. package/dist/messages-DueVe0F1.mjs +47 -0
  128. package/dist/messages-Dx3eFwI0.mjs +47 -0
  129. package/dist/messages-FOtiUoKl.mjs +47 -0
  130. package/dist/messages-FTOZNhRD.mjs +47 -0
  131. package/dist/messages-IQxGfQIV.mjs +47 -0
  132. package/dist/messages-JF2fzCkK.mjs +47 -0
  133. package/dist/messages-MOGl7I5v.mjs +47 -0
  134. package/dist/messages-QgYhPL-3.mjs +47 -0
  135. package/dist/messages-WYWIbQwo.mjs +47 -0
  136. package/dist/messages-a6A_LgDv.mjs +47 -0
  137. package/dist/messages-bSf31LJi.mjs +47 -0
  138. package/dist/messages-diGozhTn.mjs +47 -0
  139. package/dist/messages-er-kd-VO.mjs +47 -0
  140. package/dist/messages-ez3w5NBn.mjs +47 -0
  141. package/dist/messages-f3uXjegd.mjs +47 -0
  142. package/dist/messages-ohwI1UGv.mjs +47 -0
  143. package/dist/messages-p9BZJaFV.mjs +47 -0
  144. package/dist/messages-qIQ4L4rw.mjs +47 -0
  145. package/dist/messages-qWkXPggi.mjs +47 -0
  146. package/dist/messages-w5foGze_.mjs +47 -0
  147. package/dist/tools.mjs +3073 -0
  148. package/dist/vendor.LICENSE.txt +59 -156
  149. package/package.json +47 -15
  150. package/src/blok.ts +267 -0
  151. package/src/components/__module.ts +139 -0
  152. package/src/components/block/api.ts +155 -0
  153. package/src/components/block/index.ts +1427 -0
  154. package/src/components/block-tunes/block-tune-delete.ts +51 -0
  155. package/src/components/blocks.ts +338 -0
  156. package/src/components/constants/data-attributes.ts +342 -0
  157. package/src/components/constants.ts +76 -0
  158. package/src/components/core.ts +392 -0
  159. package/src/components/dom.ts +773 -0
  160. package/src/components/domIterator.ts +189 -0
  161. package/src/components/errors/critical.ts +5 -0
  162. package/src/components/events/BlockChanged.ts +16 -0
  163. package/src/components/events/BlockHovered.ts +21 -0
  164. package/src/components/events/BlockSettingsClosed.ts +12 -0
  165. package/src/components/events/BlockSettingsOpened.ts +12 -0
  166. package/src/components/events/BlokMobileLayoutToggled.ts +15 -0
  167. package/src/components/events/FakeCursorAboutToBeToggled.ts +17 -0
  168. package/src/components/events/FakeCursorHaveBeenSet.ts +17 -0
  169. package/src/components/events/HistoryStateChanged.ts +19 -0
  170. package/src/components/events/RedactorDomChanged.ts +14 -0
  171. package/src/components/events/index.ts +46 -0
  172. package/src/components/flipper.ts +481 -0
  173. package/src/components/i18n/i18next-loader.ts +84 -0
  174. package/src/components/i18n/lightweight-i18n.ts +86 -0
  175. package/src/components/i18n/locales/TRANSLATION_GUIDELINES.md +113 -0
  176. package/src/components/i18n/locales/am/messages.json +44 -0
  177. package/src/components/i18n/locales/ar/messages.json +44 -0
  178. package/src/components/i18n/locales/az/messages.json +44 -0
  179. package/src/components/i18n/locales/bg/messages.json +44 -0
  180. package/src/components/i18n/locales/bn/messages.json +44 -0
  181. package/src/components/i18n/locales/bs/messages.json +44 -0
  182. package/src/components/i18n/locales/cs/messages.json +44 -0
  183. package/src/components/i18n/locales/da/messages.json +44 -0
  184. package/src/components/i18n/locales/de/messages.json +44 -0
  185. package/src/components/i18n/locales/dv/messages.json +44 -0
  186. package/src/components/i18n/locales/el/messages.json +44 -0
  187. package/src/components/i18n/locales/en/messages.json +44 -0
  188. package/src/components/i18n/locales/es/messages.json +44 -0
  189. package/src/components/i18n/locales/et/messages.json +44 -0
  190. package/src/components/i18n/locales/fa/messages.json +44 -0
  191. package/src/components/i18n/locales/fi/messages.json +44 -0
  192. package/src/components/i18n/locales/fil/messages.json +44 -0
  193. package/src/components/i18n/locales/fr/messages.json +44 -0
  194. package/src/components/i18n/locales/gu/messages.json +44 -0
  195. package/src/components/i18n/locales/he/messages.json +44 -0
  196. package/src/components/i18n/locales/hi/messages.json +44 -0
  197. package/src/components/i18n/locales/hr/messages.json +44 -0
  198. package/src/components/i18n/locales/hu/messages.json +44 -0
  199. package/src/components/i18n/locales/hy/messages.json +44 -0
  200. package/src/components/i18n/locales/id/messages.json +44 -0
  201. package/src/components/i18n/locales/index.ts +225 -0
  202. package/src/components/i18n/locales/it/messages.json +44 -0
  203. package/src/components/i18n/locales/ja/messages.json +44 -0
  204. package/src/components/i18n/locales/ka/messages.json +44 -0
  205. package/src/components/i18n/locales/km/messages.json +44 -0
  206. package/src/components/i18n/locales/kn/messages.json +44 -0
  207. package/src/components/i18n/locales/ko/messages.json +44 -0
  208. package/src/components/i18n/locales/ku/messages.json +44 -0
  209. package/src/components/i18n/locales/lo/messages.json +44 -0
  210. package/src/components/i18n/locales/lt/messages.json +44 -0
  211. package/src/components/i18n/locales/lv/messages.json +44 -0
  212. package/src/components/i18n/locales/mk/messages.json +44 -0
  213. package/src/components/i18n/locales/ml/messages.json +44 -0
  214. package/src/components/i18n/locales/mn/messages.json +44 -0
  215. package/src/components/i18n/locales/mr/messages.json +44 -0
  216. package/src/components/i18n/locales/ms/messages.json +44 -0
  217. package/src/components/i18n/locales/my/messages.json +44 -0
  218. package/src/components/i18n/locales/ne/messages.json +44 -0
  219. package/src/components/i18n/locales/nl/messages.json +44 -0
  220. package/src/components/i18n/locales/no/messages.json +44 -0
  221. package/src/components/i18n/locales/pa/messages.json +44 -0
  222. package/src/components/i18n/locales/pl/messages.json +44 -0
  223. package/src/components/i18n/locales/ps/messages.json +44 -0
  224. package/src/components/i18n/locales/pt/messages.json +44 -0
  225. package/src/components/i18n/locales/ro/messages.json +44 -0
  226. package/src/components/i18n/locales/ru/messages.json +44 -0
  227. package/src/components/i18n/locales/sd/messages.json +44 -0
  228. package/src/components/i18n/locales/si/messages.json +44 -0
  229. package/src/components/i18n/locales/sk/messages.json +44 -0
  230. package/src/components/i18n/locales/sl/messages.json +44 -0
  231. package/src/components/i18n/locales/sq/messages.json +44 -0
  232. package/src/components/i18n/locales/sr/messages.json +44 -0
  233. package/src/components/i18n/locales/sv/messages.json +44 -0
  234. package/src/components/i18n/locales/sw/messages.json +44 -0
  235. package/src/components/i18n/locales/ta/messages.json +44 -0
  236. package/src/components/i18n/locales/te/messages.json +44 -0
  237. package/src/components/i18n/locales/th/messages.json +44 -0
  238. package/src/components/i18n/locales/tr/messages.json +44 -0
  239. package/src/components/i18n/locales/ug/messages.json +44 -0
  240. package/src/components/i18n/locales/uk/messages.json +44 -0
  241. package/src/components/i18n/locales/ur/messages.json +44 -0
  242. package/src/components/i18n/locales/vi/messages.json +44 -0
  243. package/src/components/i18n/locales/yi/messages.json +44 -0
  244. package/src/components/i18n/locales/zh/messages.json +44 -0
  245. package/src/components/icons/index.ts +242 -0
  246. package/src/components/inline-tools/inline-tool-bold.ts +2213 -0
  247. package/src/components/inline-tools/inline-tool-convert.ts +141 -0
  248. package/src/components/inline-tools/inline-tool-italic.ts +500 -0
  249. package/src/components/inline-tools/inline-tool-link.ts +539 -0
  250. package/src/components/modules/api/blocks.ts +363 -0
  251. package/src/components/modules/api/caret.ts +125 -0
  252. package/src/components/modules/api/events.ts +51 -0
  253. package/src/components/modules/api/history.ts +73 -0
  254. package/src/components/modules/api/i18n.ts +33 -0
  255. package/src/components/modules/api/index.ts +39 -0
  256. package/src/components/modules/api/inlineToolbar.ts +33 -0
  257. package/src/components/modules/api/listeners.ts +56 -0
  258. package/src/components/modules/api/notifier.ts +46 -0
  259. package/src/components/modules/api/readonly.ts +39 -0
  260. package/src/components/modules/api/sanitizer.ts +30 -0
  261. package/src/components/modules/api/saver.ts +52 -0
  262. package/src/components/modules/api/selection.ts +48 -0
  263. package/src/components/modules/api/styles.ts +72 -0
  264. package/src/components/modules/api/toolbar.ts +79 -0
  265. package/src/components/modules/api/tools.ts +16 -0
  266. package/src/components/modules/api/tooltip.ts +67 -0
  267. package/src/components/modules/api/ui.ts +36 -0
  268. package/src/components/modules/blockEvents.ts +1375 -0
  269. package/src/components/modules/blockManager.ts +1348 -0
  270. package/src/components/modules/blockSelection.ts +708 -0
  271. package/src/components/modules/caret.ts +853 -0
  272. package/src/components/modules/crossBlockSelection.ts +329 -0
  273. package/src/components/modules/dragManager.ts +1141 -0
  274. package/src/components/modules/history.ts +1098 -0
  275. package/src/components/modules/i18n.ts +325 -0
  276. package/src/components/modules/index.ts +139 -0
  277. package/src/components/modules/modificationsObserver.ts +147 -0
  278. package/src/components/modules/paste.ts +1092 -0
  279. package/src/components/modules/readonly.ts +136 -0
  280. package/src/components/modules/rectangleSelection.ts +668 -0
  281. package/src/components/modules/renderer.ts +155 -0
  282. package/src/components/modules/saver.ts +283 -0
  283. package/src/components/modules/toolbar/blockSettings.ts +776 -0
  284. package/src/components/modules/toolbar/index.ts +1311 -0
  285. package/src/components/modules/toolbar/inline.ts +956 -0
  286. package/src/components/modules/tools.ts +589 -0
  287. package/src/components/modules/ui.ts +1179 -0
  288. package/src/components/polyfills.ts +113 -0
  289. package/src/components/selection.ts +1189 -0
  290. package/src/components/tools/base.ts +274 -0
  291. package/src/components/tools/block.ts +291 -0
  292. package/src/components/tools/collection.ts +67 -0
  293. package/src/components/tools/factory.ts +85 -0
  294. package/src/components/tools/inline.ts +71 -0
  295. package/src/components/tools/tune.ts +33 -0
  296. package/src/components/ui/toolbox.ts +497 -0
  297. package/src/components/utils/announcer.ts +205 -0
  298. package/src/components/utils/api.ts +20 -0
  299. package/src/components/utils/bem.ts +26 -0
  300. package/src/components/utils/blocks.ts +284 -0
  301. package/src/components/utils/caret.ts +1067 -0
  302. package/src/components/utils/data-model-transform.ts +382 -0
  303. package/src/components/utils/events.ts +117 -0
  304. package/src/components/utils/keyboard.ts +60 -0
  305. package/src/components/utils/listeners.ts +296 -0
  306. package/src/components/utils/mutations.ts +39 -0
  307. package/src/components/utils/notifier/draw.ts +190 -0
  308. package/src/components/utils/notifier/index.ts +66 -0
  309. package/src/components/utils/notifier/types.ts +1 -0
  310. package/src/components/utils/notifier.ts +77 -0
  311. package/src/components/utils/placeholder.ts +140 -0
  312. package/src/components/utils/popover/components/hint/hint.const.ts +10 -0
  313. package/src/components/utils/popover/components/hint/hint.ts +46 -0
  314. package/src/components/utils/popover/components/hint/index.ts +6 -0
  315. package/src/components/utils/popover/components/popover-header/index.ts +2 -0
  316. package/src/components/utils/popover/components/popover-header/popover-header.const.ts +8 -0
  317. package/src/components/utils/popover/components/popover-header/popover-header.ts +80 -0
  318. package/src/components/utils/popover/components/popover-header/popover-header.types.ts +14 -0
  319. package/src/components/utils/popover/components/popover-item/index.ts +13 -0
  320. package/src/components/utils/popover/components/popover-item/popover-item-default/popover-item-default.const.ts +50 -0
  321. package/src/components/utils/popover/components/popover-item/popover-item-default/popover-item-default.ts +666 -0
  322. package/src/components/utils/popover/components/popover-item/popover-item-html/popover-item-html.const.ts +14 -0
  323. package/src/components/utils/popover/components/popover-item/popover-item-html/popover-item-html.ts +136 -0
  324. package/src/components/utils/popover/components/popover-item/popover-item-separator/popover-item-separator.const.ts +20 -0
  325. package/src/components/utils/popover/components/popover-item/popover-item-separator/popover-item-separator.ts +117 -0
  326. package/src/components/utils/popover/components/popover-item/popover-item.ts +187 -0
  327. package/src/components/utils/popover/components/search-input/index.ts +2 -0
  328. package/src/components/utils/popover/components/search-input/search-input.const.ts +8 -0
  329. package/src/components/utils/popover/components/search-input/search-input.ts +181 -0
  330. package/src/components/utils/popover/components/search-input/search-input.types.ts +30 -0
  331. package/src/components/utils/popover/index.ts +13 -0
  332. package/src/components/utils/popover/popover-abstract.ts +448 -0
  333. package/src/components/utils/popover/popover-desktop.ts +643 -0
  334. package/src/components/utils/popover/popover-inline.ts +338 -0
  335. package/src/components/utils/popover/popover-mobile.ts +201 -0
  336. package/src/components/utils/popover/popover.const.ts +81 -0
  337. package/src/components/utils/popover/utils/popover-states-history.ts +72 -0
  338. package/src/components/utils/promise-queue.ts +43 -0
  339. package/src/components/utils/sanitizer.ts +537 -0
  340. package/src/components/utils/scroll-locker.ts +87 -0
  341. package/src/components/utils/shortcut.ts +231 -0
  342. package/src/components/utils/shortcuts.ts +113 -0
  343. package/src/components/utils/tools.ts +105 -0
  344. package/src/components/utils/tooltip.ts +642 -0
  345. package/src/components/utils/tw.ts +241 -0
  346. package/src/components/utils.ts +1081 -0
  347. package/src/env.d.ts +13 -0
  348. package/src/full.ts +69 -0
  349. package/src/locales.ts +51 -0
  350. package/src/stories/Block.stories.ts +498 -0
  351. package/src/stories/EditorModes.stories.ts +505 -0
  352. package/src/stories/Header.stories.ts +137 -0
  353. package/src/stories/InlineToolbar.stories.ts +498 -0
  354. package/src/stories/List.stories.ts +259 -0
  355. package/src/stories/Notifier.stories.ts +340 -0
  356. package/src/stories/Paragraph.stories.ts +112 -0
  357. package/src/stories/Placeholder.stories.ts +319 -0
  358. package/src/stories/Popover.stories.ts +844 -0
  359. package/src/stories/Selection.stories.ts +250 -0
  360. package/src/stories/StubBlock.stories.ts +156 -0
  361. package/src/stories/Toolbar.stories.ts +223 -0
  362. package/src/stories/Toolbox.stories.ts +166 -0
  363. package/src/stories/Tooltip.stories.ts +198 -0
  364. package/src/stories/helpers.ts +463 -0
  365. package/src/styles/main.css +123 -0
  366. package/src/tools/header/index.ts +570 -0
  367. package/src/tools/index.ts +38 -0
  368. package/src/tools/list/index.ts +1803 -0
  369. package/src/tools/paragraph/index.ts +411 -0
  370. package/src/tools/stub/index.ts +107 -0
  371. package/src/types-internal/blok-modules.d.ts +87 -0
  372. package/src/types-internal/html-janitor.d.ts +28 -0
  373. package/src/types-internal/module-config.d.ts +11 -0
  374. package/src/variants/all-locales.ts +155 -0
  375. package/src/variants/blok-maximum.ts +20 -0
  376. package/src/variants/blok-minimum.ts +243 -0
  377. package/types/api/blocks.d.ts +1 -1
  378. package/types/api/i18n.d.ts +5 -3
  379. package/types/api/selection.d.ts +6 -0
  380. package/types/api/styles.d.ts +0 -5
  381. package/types/configs/blok-config.d.ts +21 -0
  382. package/types/configs/i18n-config.d.ts +52 -2
  383. package/types/configs/i18n-dictionary.d.ts +16 -90
  384. package/types/data-attributes.d.ts +169 -0
  385. package/types/data-formats/output-data.d.ts +15 -0
  386. package/types/full.d.ts +80 -0
  387. package/types/index.d.ts +9 -24
  388. package/types/locales.d.ts +59 -0
  389. package/types/tools/adapters/inline-tool-adapter.d.ts +10 -0
  390. package/types/tools/block-tool.d.ts +9 -0
  391. package/types/tools/list.d.ts +25 -18
  392. package/types/tools/tool-settings.d.ts +8 -1
  393. package/types/tools/tool.d.ts +6 -0
  394. package/types/tools-entry.d.ts +49 -0
  395. package/types/utils/popover/popover-item.d.ts +0 -5
  396. package/dist/blok-BwPfU8ro.mjs +0 -21510
  397. package/dist/blok.umd.js +0 -198
@@ -0,0 +1,1067 @@
1
+ import { Dom as $, isCollapsedWhitespaces } from '../dom';
2
+
3
+ const NBSP_CHAR = '\u00A0';
4
+
5
+ const whitespaceFollowingRemovedEmptyInline = new WeakSet<Text>();
6
+
7
+ /**
8
+ * Returns TextNode containing a caret and a caret offset in it
9
+ * Returns null if there is no caret set
10
+ *
11
+ * Handles a case when focusNode is an ElementNode and focusOffset is a child index,
12
+ * returns child node with focusOffset index as a new focusNode
13
+ */
14
+ export const getCaretNodeAndOffset = (): [Node | null, number] => {
15
+ const selection = window.getSelection();
16
+
17
+ if (selection === null) {
18
+ return [null, 0];
19
+ }
20
+
21
+ const initialFocusNode = selection.focusNode;
22
+ const initialFocusOffset = selection.focusOffset;
23
+
24
+ if (initialFocusNode === null) {
25
+ return [null, 0];
26
+ }
27
+
28
+ /**
29
+ * Case when focusNode is an Element (or Document). In this case, focusOffset is a child index.
30
+ * We need to return child with focusOffset index as a new focusNode.
31
+ *
32
+ * <div>|hello</div> <---- Selection references to <div> instead of text node
33
+ *
34
+ *
35
+ */
36
+ if (initialFocusNode.nodeType === Node.TEXT_NODE || initialFocusNode.childNodes.length === 0) {
37
+ return [initialFocusNode, initialFocusOffset];
38
+ }
39
+
40
+ /**
41
+ * In normal cases, focusOffset is a child index.
42
+ */
43
+ const regularChild = initialFocusNode.childNodes[initialFocusOffset];
44
+
45
+ if (regularChild !== undefined) {
46
+ return [regularChild, 0];
47
+ }
48
+
49
+ /**
50
+ * But in Firefox, focusOffset can be 1 with the single child.
51
+ */
52
+ const fallbackChild = initialFocusNode.childNodes[initialFocusOffset - 1] ?? null;
53
+ const textContent = fallbackChild?.textContent ?? null;
54
+
55
+ return [fallbackChild, textContent !== null ? textContent.length : 0];
56
+ };
57
+
58
+ const isElementVisuallyEmpty = (element: Element): boolean => {
59
+ if (!(element instanceof HTMLElement)) {
60
+ return false;
61
+ }
62
+
63
+ if ($.isSingleTag(element) || $.isNativeInput(element)) {
64
+ return false;
65
+ }
66
+
67
+ if (element.childNodes.length === 0) {
68
+ return true;
69
+ }
70
+
71
+ const textContent = element.textContent ?? '';
72
+
73
+ if (textContent.includes(NBSP_CHAR)) {
74
+ return false;
75
+ }
76
+
77
+ if (!isCollapsedWhitespaces(textContent)) {
78
+ return false;
79
+ }
80
+
81
+ return Array.from(element.children).every((child) => {
82
+ return isElementVisuallyEmpty(child);
83
+ });
84
+ };
85
+
86
+ const inlineRemovalObserver = typeof window !== 'undefined' && typeof window.MutationObserver !== 'undefined'
87
+ ? new window.MutationObserver((records) => {
88
+ for (const record of records) {
89
+ const referenceNextSibling = record.nextSibling;
90
+
91
+ record.removedNodes.forEach((node) => {
92
+ if (!(node instanceof Element)) {
93
+ return;
94
+ }
95
+
96
+ if (!isElementVisuallyEmpty(node)) {
97
+ return;
98
+ }
99
+
100
+ const candidate = referenceNextSibling instanceof Text ? referenceNextSibling : null;
101
+
102
+ if (candidate === null) {
103
+ return;
104
+ }
105
+
106
+ if (!candidate.isConnected) {
107
+ return;
108
+ }
109
+
110
+ const parentElement = candidate.parentElement;
111
+
112
+ if (!(parentElement?.isContentEditable ?? false)) {
113
+ return;
114
+ }
115
+
116
+ const firstChar = candidate.textContent?.[0] ?? null;
117
+ const isWhitespace = firstChar === NBSP_CHAR || firstChar === ' ';
118
+
119
+ if (!isWhitespace) {
120
+ return;
121
+ }
122
+
123
+ whitespaceFollowingRemovedEmptyInline.add(candidate);
124
+ });
125
+ }
126
+ })
127
+ : null;
128
+
129
+ const observedDocuments = new WeakSet<Document>();
130
+
131
+ const ensureInlineRemovalObserver = (doc: Document): void => {
132
+ if (inlineRemovalObserver === null || observedDocuments.has(doc)) {
133
+ return;
134
+ }
135
+
136
+ const startObserving = (): void => {
137
+ if (doc.body === null) {
138
+ return;
139
+ }
140
+
141
+ inlineRemovalObserver.observe(doc.body, {
142
+ childList: true,
143
+ subtree: true,
144
+ });
145
+ observedDocuments.add(doc);
146
+ };
147
+
148
+ if (doc.readyState === 'loading') {
149
+ doc.addEventListener('DOMContentLoaded', startObserving, { once: true });
150
+ } else {
151
+ startObserving();
152
+ }
153
+ };
154
+
155
+ if (typeof window !== 'undefined' && typeof window.document !== 'undefined') {
156
+ ensureInlineRemovalObserver(window.document);
157
+ }
158
+
159
+ export const findNbspAfterEmptyInline = (root: HTMLElement): { node: Text; offset: number } | null => {
160
+ ensureInlineRemovalObserver(root.ownerDocument);
161
+
162
+ const [caretNode, caretOffset] = getCaretNodeAndOffset();
163
+
164
+ if (caretNode === null || !root.contains(caretNode)) {
165
+ return null;
166
+ }
167
+
168
+ if (caretNode.nodeType === Node.TEXT_NODE && caretOffset < ((caretNode.textContent ?? '').length)) {
169
+ return null;
170
+ }
171
+
172
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
173
+
174
+ walker.currentNode = caretNode;
175
+
176
+ for (; ;) {
177
+ const nextTextNode = walker.nextNode() as Text | null;
178
+
179
+ if (nextTextNode === null) {
180
+ return null;
181
+ }
182
+
183
+ const textContent = nextTextNode.textContent ?? '';
184
+
185
+ if (textContent.length === 0) {
186
+ continue;
187
+ }
188
+
189
+ const firstChar = textContent[0];
190
+ const isTargetWhitespace = firstChar === NBSP_CHAR || firstChar === ' ';
191
+
192
+ if (!isTargetWhitespace) {
193
+ return null;
194
+ }
195
+
196
+ if (nextTextNode === caretNode) {
197
+ return null;
198
+ }
199
+
200
+ const pathRange = document.createRange();
201
+
202
+ try {
203
+ pathRange.setStart(caretNode, caretOffset);
204
+ pathRange.setEnd(nextTextNode, 0);
205
+ } catch (_error) {
206
+ return null;
207
+ }
208
+
209
+ const betweenFragment = pathRange.cloneContents();
210
+ const container = document.createElement('div');
211
+
212
+ container.appendChild(betweenFragment);
213
+
214
+ const hasEmptyElementBetween = Array.from(container.querySelectorAll('*')).some((element) => {
215
+ const text = element.textContent ?? '';
216
+
217
+ return text.length === 0 || isCollapsedWhitespaces(text);
218
+ });
219
+
220
+ const wasEmptyInlineRemoved = whitespaceFollowingRemovedEmptyInline.has(nextTextNode);
221
+
222
+ if (!hasEmptyElementBetween && !wasEmptyInlineRemoved) {
223
+ continue;
224
+ }
225
+
226
+ if (wasEmptyInlineRemoved) {
227
+ whitespaceFollowingRemovedEmptyInline.delete(nextTextNode);
228
+ }
229
+
230
+ return {
231
+ node: nextTextNode,
232
+ offset: 0,
233
+ };
234
+ }
235
+ };
236
+
237
+ /**
238
+ * Checks content at left or right of the passed node for emptiness.
239
+ * @param contenteditable - The contenteditable element containing the nodes.
240
+ * @param fromNode - The starting node to check from.
241
+ * @param offsetInsideNode - The offset inside the starting node.
242
+ * @param direction - The direction to check ('left' or 'right').
243
+ * @returns true if adjacent content is empty, false otherwise.
244
+ */
245
+ export const checkContenteditableSliceForEmptiness = (contenteditable: HTMLElement, fromNode: Node, offsetInsideNode: number, direction: 'left' | 'right'): boolean => {
246
+ const range = document.createRange();
247
+
248
+ /**
249
+ * In case of "left":
250
+ * Set range from the start of the contenteditable to the passed offset
251
+ */
252
+ if (direction === 'left') {
253
+ range.selectNodeContents(contenteditable);
254
+ range.setEnd(fromNode, offsetInsideNode);
255
+
256
+ /**
257
+ * In case of "right":
258
+ * Set range from the passed offset to the end of the contenteditable
259
+ */
260
+ } else {
261
+ range.selectNodeContents(contenteditable);
262
+ range.setStart(fromNode, offsetInsideNode);
263
+ }
264
+
265
+ /**
266
+ * Clone the range's content and check its text content
267
+ */
268
+ const clonedContent = range.cloneContents();
269
+ const tempDiv = document.createElement('div');
270
+
271
+ tempDiv.appendChild(clonedContent);
272
+
273
+ const textContent = tempDiv.textContent || '';
274
+
275
+ /**
276
+ * Check if we have any tags in the slice
277
+ * We should not ignore them to allow navigation inside (e.g. empty bold tag)
278
+ */
279
+ const hasSignificantTags = tempDiv.querySelectorAll('img, br, hr, input, area, base, col, embed, link, meta, param, source, track, wbr').length > 0;
280
+
281
+ if (hasSignificantTags) {
282
+ return false;
283
+ }
284
+
285
+ /**
286
+ * Check if there is a non-breaking space,
287
+ * since textContent can replace it with a space
288
+ */
289
+ const hasNbsp = textContent.includes('\u00A0') || tempDiv.innerHTML.includes('&nbsp;') || range.toString().includes('\u00A0');
290
+
291
+ /**
292
+ * Check if we have NBSP in the text node itself (if fromNode is text node)
293
+ * This avoids issues with range.toString() normalization
294
+ */
295
+ const isNBSPInTextNode = fromNode.nodeType === Node.TEXT_NODE &&
296
+ (direction === 'left'
297
+ ? (fromNode.textContent || '').slice(0, offsetInsideNode)
298
+ : (fromNode.textContent || '').slice(offsetInsideNode)
299
+ ).includes('\u00A0');
300
+
301
+ if (hasNbsp || isNBSPInTextNode) {
302
+ return false;
303
+ }
304
+
305
+ /**
306
+ * Check for visual width
307
+ * This helps to detect &nbsp; that might be converted to regular space in textContent but still renders with width
308
+ */
309
+ tempDiv.style.position = 'absolute';
310
+ tempDiv.style.visibility = 'hidden';
311
+ tempDiv.style.height = 'auto';
312
+ tempDiv.style.width = 'auto';
313
+ tempDiv.style.whiteSpace = window.getComputedStyle(contenteditable).whiteSpace;
314
+
315
+ document.body.appendChild(tempDiv);
316
+ const width = tempDiv.getBoundingClientRect().width;
317
+
318
+ document.body.removeChild(tempDiv);
319
+
320
+ if (width > 0) {
321
+ return false;
322
+ }
323
+
324
+ /**
325
+ * In HTML there are two types of whitespaces:
326
+ * - visible (&nbsp;)
327
+ * - invisible (trailing spaces, tabs, etc.)
328
+ *
329
+ * If text contains only invisible whitespaces, it is considered to be empty
330
+ */
331
+ if (!isCollapsedWhitespaces(textContent)) {
332
+ return false;
333
+ }
334
+
335
+ const style = window.getComputedStyle(contenteditable);
336
+ const isPre = style.whiteSpace.startsWith('pre');
337
+
338
+ if (isPre && textContent.length > 0) {
339
+ return false;
340
+ }
341
+
342
+ return true;
343
+ };
344
+
345
+ /**
346
+ * Checks if caret is at the start of the passed input
347
+ *
348
+ * Cases:
349
+ * Native input:
350
+ * - if offset is 0, caret is at the start
351
+ * Contenteditable:
352
+ * - caret at the first text node and offset is 0 — caret is at the start
353
+ * - caret not at the first text node — we need to check left siblings for emptiness
354
+ * - caret offset > 0, but all left part is visible (nbsp) — caret is not at the start
355
+ * - caret offset > 0, but all left part is invisible (whitespaces) — caret is at the start
356
+ * @param input - input where caret should be checked
357
+ */
358
+ export const isCaretAtStartOfInput = (input: HTMLElement): boolean => {
359
+ const firstNode = $.getDeepestNode(input);
360
+
361
+ if (firstNode === null || $.isEmpty(input)) {
362
+ return true;
363
+ }
364
+
365
+ /**
366
+ * In case of native input, we simply check if offset is 0
367
+ */
368
+ if ($.isNativeInput(firstNode)) {
369
+ return (firstNode as HTMLInputElement).selectionEnd === 0;
370
+ }
371
+
372
+ if ($.isEmpty(input)) {
373
+ return true;
374
+ }
375
+
376
+ const [caretNode, caretOffset] = getCaretNodeAndOffset();
377
+
378
+ /**
379
+ * If there is no selection, caret is not at the start
380
+ */
381
+ if (caretNode === null) {
382
+ return false;
383
+ }
384
+
385
+ /**
386
+ * If caret is inside a nested tag (e.g. <b>), we should let browser handle the navigation
387
+ * to exit the tag first, before moving to the previous block.
388
+ */
389
+ const selection = window.getSelection();
390
+ const focusNode = selection?.focusNode ?? null;
391
+
392
+ if (focusNode !== null && focusNode !== input && !(focusNode.nodeType === Node.TEXT_NODE && focusNode.parentNode === input)) {
393
+ return false;
394
+ }
395
+
396
+ /**
397
+ * If there is nothing visible to the left of the caret, it is considered to be at the start
398
+ */
399
+ return checkContenteditableSliceForEmptiness(input, caretNode, caretOffset, 'left');
400
+ };
401
+
402
+ /**
403
+ * Checks if caret is at the end of the passed input
404
+ *
405
+ * Cases:
406
+ * Native input:
407
+ * - if offset is equal to value length, caret is at the end
408
+ * Contenteditable:
409
+ * - caret at the last text node and offset is equal to text length — caret is at the end
410
+ * - caret not at the last text node — we need to check right siblings for emptiness
411
+ * - caret offset < text length, but all right part is visible (nbsp) — caret is at the end
412
+ * - caret offset < text length, but all right part is invisible (whitespaces) — caret is at the end
413
+ * @param input - input where caret should be checked
414
+ */
415
+ export const isCaretAtEndOfInput = (input: HTMLElement): boolean => {
416
+ const lastNode = $.getDeepestNode(input, true);
417
+
418
+ if (lastNode === null) {
419
+ return true;
420
+ }
421
+
422
+ /**
423
+ * In case of native input, we simply check if offset is equal to value length
424
+ */
425
+ if ($.isNativeInput(lastNode)) {
426
+ return (lastNode as HTMLInputElement).selectionEnd === (lastNode as HTMLInputElement).value.length;
427
+ }
428
+
429
+ const [caretNode, caretOffset] = getCaretNodeAndOffset();
430
+
431
+ /**
432
+ * If there is no selection, caret is not at the end
433
+ */
434
+ if (caretNode === null) {
435
+ return false;
436
+ }
437
+
438
+ /**
439
+ * If there is nothing visible to the right of the caret, it is considered to be at the end
440
+ */
441
+ return checkContenteditableSliceForEmptiness(input, caretNode, caretOffset, 'right');
442
+ };
443
+
444
+ /**
445
+ * Gets a valid DOMRect for the caret position.
446
+ * Falls back to container element rect or input rect if the caret rect has no dimensions.
447
+ */
448
+ const getValidCaretRect = (range: Range, input: HTMLElement): DOMRect => {
449
+ const caretRect = range.getBoundingClientRect();
450
+
451
+ if (caretRect.height !== 0 || caretRect.top !== 0) {
452
+ return caretRect;
453
+ }
454
+
455
+ const container = range.startContainer;
456
+ const element = container.nodeType === Node.ELEMENT_NODE
457
+ ? container as HTMLElement
458
+ : container.parentElement;
459
+
460
+ if (!element) {
461
+ return input.getBoundingClientRect();
462
+ }
463
+
464
+ const elementRect = element.getBoundingClientRect();
465
+
466
+ if (elementRect.height !== 0 || elementRect.top !== 0) {
467
+ return elementRect;
468
+ }
469
+
470
+ return input.getBoundingClientRect();
471
+ };
472
+
473
+ /**
474
+ * Checks if the caret is at the first (top) line of a multi-line input.
475
+ * This is used for Notion-style navigation where Arrow Up should only
476
+ * move to the previous block when the caret can't move up within the current block.
477
+ *
478
+ * @param input - the contenteditable element or native input to check
479
+ * @returns true if caret is at the first line (or input is single-line)
480
+ */
481
+ export const isCaretAtFirstLine = (input: HTMLElement): boolean => {
482
+ /**
483
+ * For single-line native inputs, always return true
484
+ */
485
+ if ($.isNativeInput(input) && input.tagName === 'INPUT') {
486
+ return true;
487
+ }
488
+
489
+ /**
490
+ * For textarea, check if cursor is before the first newline
491
+ */
492
+ if ($.isNativeInput(input)) {
493
+ const nativeInput = input as HTMLTextAreaElement;
494
+ const selectionStart = nativeInput.selectionStart ?? 0;
495
+ const textBeforeCursor = nativeInput.value.substring(0, selectionStart);
496
+
497
+ return !textBeforeCursor.includes('\n');
498
+ }
499
+
500
+ const selection = window.getSelection();
501
+
502
+ if (!selection || selection.rangeCount === 0) {
503
+ return true;
504
+ }
505
+
506
+ const range = selection.getRangeAt(0);
507
+
508
+ /**
509
+ * Get a valid caret rect, with fallbacks for zero-dimension rects
510
+ */
511
+ const caretRect = getValidCaretRect(range, input);
512
+
513
+ /**
514
+ * Get the first line's position by creating a range at the start of the input
515
+ */
516
+ const firstNode = $.getDeepestNode(input, false);
517
+
518
+ if (!firstNode) {
519
+ return true;
520
+ }
521
+
522
+ const firstLineRange = document.createRange();
523
+
524
+ try {
525
+ firstLineRange.setStart(firstNode, 0);
526
+ firstLineRange.setEnd(firstNode, 0);
527
+ } catch {
528
+ return true;
529
+ }
530
+
531
+ const firstLineRect = firstLineRange.getBoundingClientRect();
532
+
533
+ /**
534
+ * If the first line rect has no dimensions, fall back to input's top
535
+ */
536
+ if (firstLineRect.height === 0 && firstLineRect.top === 0) {
537
+ const inputRect = input.getBoundingClientRect();
538
+
539
+ /**
540
+ * Consider caret at first line if it's within the first line height from top
541
+ * Use a threshold based on typical line height
542
+ */
543
+ const lineHeight = parseFloat(window.getComputedStyle(input).lineHeight) || 20;
544
+
545
+ return caretRect.top < inputRect.top + lineHeight;
546
+ }
547
+
548
+ /**
549
+ * Compare the vertical positions - if caret is on the same line as the first character,
550
+ * they should have similar top values (within a small threshold for rounding)
551
+ */
552
+ const threshold = 5; // pixels tolerance for line comparison
553
+
554
+ return Math.abs(caretRect.top - firstLineRect.top) < threshold;
555
+ };
556
+
557
+ /**
558
+ * Checks if the caret is at the last (bottom) line of a multi-line input.
559
+ * This is used for Notion-style navigation where Arrow Down should only
560
+ * move to the next block when the caret can't move down within the current block.
561
+ *
562
+ * @param input - the contenteditable element or native input to check
563
+ * @returns true if caret is at the last line (or input is single-line)
564
+ */
565
+ export const isCaretAtLastLine = (input: HTMLElement): boolean => {
566
+ /**
567
+ * For single-line native inputs, always return true
568
+ */
569
+ if ($.isNativeInput(input) && input.tagName === 'INPUT') {
570
+ return true;
571
+ }
572
+
573
+ /**
574
+ * For textarea, check if cursor is after the last newline
575
+ */
576
+ if ($.isNativeInput(input)) {
577
+ const nativeInput = input as HTMLTextAreaElement;
578
+ const selectionEnd = nativeInput.selectionEnd ?? nativeInput.value.length;
579
+ const textAfterCursor = nativeInput.value.substring(selectionEnd);
580
+
581
+ return !textAfterCursor.includes('\n');
582
+ }
583
+
584
+ const selection = window.getSelection();
585
+
586
+ if (!selection || selection.rangeCount === 0) {
587
+ return true;
588
+ }
589
+
590
+ const range = selection.getRangeAt(0);
591
+
592
+ /**
593
+ * Get a valid caret rect, with fallbacks for zero-dimension rects
594
+ */
595
+ const caretRect = getValidCaretRect(range, input);
596
+
597
+ /**
598
+ * Get the last line's position by creating a range at the end of the input
599
+ */
600
+ const lastNode = $.getDeepestNode(input, true);
601
+
602
+ if (!lastNode) {
603
+ return true;
604
+ }
605
+
606
+ const lastLineRange = document.createRange();
607
+ const lastNodeLength = $.getContentLength(lastNode);
608
+
609
+ try {
610
+ lastLineRange.setStart(lastNode, lastNodeLength);
611
+ lastLineRange.setEnd(lastNode, lastNodeLength);
612
+ } catch {
613
+ return true;
614
+ }
615
+
616
+ const lastLineRect = lastLineRange.getBoundingClientRect();
617
+
618
+ /**
619
+ * If the last line rect has no dimensions, fall back to input's bottom
620
+ */
621
+ if (lastLineRect.height === 0 && lastLineRect.bottom === 0) {
622
+ const inputRect = input.getBoundingClientRect();
623
+
624
+ /**
625
+ * Consider caret at last line if it's within the last line height from bottom
626
+ * Use a threshold based on typical line height
627
+ */
628
+ const lineHeight = parseFloat(window.getComputedStyle(input).lineHeight) || 20;
629
+
630
+ return caretRect.bottom > inputRect.bottom - lineHeight;
631
+ }
632
+
633
+ /**
634
+ * Compare the vertical positions - if caret is on the same line as the last character,
635
+ * they should have similar bottom values (within a small threshold for rounding)
636
+ */
637
+ const threshold = 5; // pixels tolerance for line comparison
638
+
639
+ return Math.abs(caretRect.bottom - lastLineRect.bottom) < threshold;
640
+ };
641
+
642
+ /**
643
+ * Set focus to contenteditable or native input element
644
+ * @param element - element where to set focus
645
+ * @param atStart - where to set focus: at the start or at the end
646
+ */
647
+ export const focus = (element: HTMLElement, atStart = true): void => {
648
+ /** If element is native input */
649
+ if ($.isNativeInput(element)) {
650
+ element.focus();
651
+ const position = atStart ? 0 : element.value.length;
652
+
653
+ element.setSelectionRange(position, position);
654
+
655
+ return;
656
+ }
657
+
658
+ /**
659
+ * Focus the contenteditable element to ensure caret is visible.
660
+ * Without focus, the selection range can be set but the caret won't be visible.
661
+ */
662
+ element.focus();
663
+
664
+ const range = document.createRange();
665
+ const selection = window.getSelection();
666
+
667
+ if (!selection) {
668
+ return;
669
+ }
670
+
671
+ /**
672
+ * Helper function to create a new text node and set the caret
673
+ * @param parent - parent element to append the text node
674
+ * @param prepend - should the text node be prepended or appended
675
+ */
676
+ const createAndFocusTextNode = (parent: Node, prepend = false): void => {
677
+ const textNode = document.createTextNode('');
678
+
679
+ if (prepend) {
680
+ parent.insertBefore(textNode, parent.firstChild);
681
+ } else {
682
+ parent.appendChild(textNode);
683
+ }
684
+ range.setStart(textNode, 0);
685
+ range.setEnd(textNode, 0);
686
+ };
687
+
688
+ /**
689
+ * Find deepest text node in the given direction
690
+ * @param node - starting node
691
+ * @param toStart - search direction
692
+ */
693
+ const findTextNode = (node: ChildNode | null, toStart: boolean): ChildNode | null => {
694
+ if (node === null) {
695
+ return null;
696
+ }
697
+
698
+ if (node.nodeType === Node.TEXT_NODE) {
699
+ return node;
700
+ }
701
+
702
+ const nextChild = toStart ? node.firstChild : node.lastChild;
703
+
704
+ return findTextNode(nextChild, toStart);
705
+ };
706
+
707
+ /**
708
+ * We need to set focus at start/end to the text node inside an element
709
+ */
710
+ const childNodes = element.childNodes;
711
+ const initialNode: ChildNode | null = atStart ? childNodes[0] ?? null : childNodes[childNodes.length - 1] ?? null;
712
+ const nodeToFocus = findTextNode(initialNode, atStart);
713
+
714
+ /**
715
+ * If the element is empty, create a text node and place the caret at the start
716
+ */
717
+ if (initialNode === null) {
718
+ createAndFocusTextNode(element);
719
+ selection.removeAllRanges();
720
+ selection.addRange(range);
721
+
722
+ return;
723
+ }
724
+
725
+ /**
726
+ * If no text node is found, create one and set focus
727
+ */
728
+ if (nodeToFocus === null || nodeToFocus.nodeType !== Node.TEXT_NODE) {
729
+ createAndFocusTextNode(element, atStart);
730
+ selection.removeAllRanges();
731
+ selection.addRange(range);
732
+
733
+ return;
734
+ }
735
+
736
+ /**
737
+ * If a text node is found, place the caret
738
+ */
739
+ const length = nodeToFocus.textContent?.length ?? 0;
740
+ const position = atStart ? 0 : length;
741
+
742
+ range.setStart(nodeToFocus, position);
743
+ range.setEnd(nodeToFocus, position);
744
+
745
+ selection.removeAllRanges();
746
+ selection.addRange(range);
747
+ };
748
+
749
+ /**
750
+ * Gets the current caret's X coordinate (horizontal position).
751
+ * Used for Notion-style navigation to preserve horizontal position when moving between blocks.
752
+ * @returns The X coordinate of the caret, or null if no selection exists
753
+ */
754
+ export const getCaretXPosition = (): number | null => {
755
+ const selection = window.getSelection();
756
+
757
+ if (!selection || selection.rangeCount === 0) {
758
+ return null;
759
+ }
760
+
761
+ const range = selection.getRangeAt(0);
762
+ const rect = range.getBoundingClientRect();
763
+
764
+ /**
765
+ * If the range has valid dimensions, return the left position
766
+ */
767
+ const hasValidDimensions = rect.width !== 0 || rect.height !== 0 || rect.x !== 0;
768
+
769
+ if (hasValidDimensions) {
770
+ return rect.left;
771
+ }
772
+
773
+ /**
774
+ * If the range has no dimensions (e.g., collapsed at start of empty element),
775
+ * try to get position from the container element
776
+ */
777
+ const container = range.startContainer;
778
+ const element = container.nodeType === Node.ELEMENT_NODE
779
+ ? container as HTMLElement
780
+ : container.parentElement;
781
+
782
+ if (!element) {
783
+ return null;
784
+ }
785
+
786
+ const elementRect = element.getBoundingClientRect();
787
+
788
+ return elementRect.left;
789
+ };
790
+
791
+ /**
792
+ * Sets the caret position in an element at the closest position to the target X coordinate.
793
+ * This is used for Notion-style navigation to preserve horizontal position when moving between blocks.
794
+ * @param element - The contenteditable element or native input to set caret in
795
+ * @param targetX - The target X coordinate to match
796
+ * @param atFirstLine - If true, place caret on the first line; if false, place on the last line
797
+ */
798
+ export const setCaretAtXPosition = (element: HTMLElement, targetX: number, atFirstLine: boolean): void => {
799
+ /**
800
+ * For native inputs, we need to find the character position that best matches the X coordinate
801
+ */
802
+ if ($.isNativeInput(element)) {
803
+ setCaretAtXPositionInNativeInput(element as HTMLInputElement | HTMLTextAreaElement, targetX, atFirstLine);
804
+
805
+ return;
806
+ }
807
+
808
+ setCaretAtXPositionInContentEditable(element, targetX, atFirstLine);
809
+ };
810
+
811
+ /**
812
+ * Sets caret position in a native input element at the closest position to target X
813
+ */
814
+ const setCaretAtXPositionInNativeInput = (
815
+ input: HTMLInputElement | HTMLTextAreaElement,
816
+ targetX: number,
817
+ atFirstLine: boolean
818
+ ): void => {
819
+ input.focus();
820
+
821
+ const value = input.value;
822
+
823
+ if (value.length === 0) {
824
+ input.setSelectionRange(0, 0);
825
+
826
+ return;
827
+ }
828
+
829
+ /**
830
+ * For textareas with multiple lines, find the target line first
831
+ */
832
+ if (input.tagName === 'TEXTAREA') {
833
+ const lines = value.split('\n');
834
+ const targetLineIndex = atFirstLine ? 0 : lines.length - 1;
835
+ const charOffset = lines.slice(0, targetLineIndex).reduce((acc, line) => acc + line.length + 1, 0);
836
+
837
+ const lineStart = charOffset;
838
+ const lineEnd = charOffset + lines[targetLineIndex].length;
839
+
840
+ /**
841
+ * Binary search to find the best position within the line
842
+ */
843
+ const bestPosition = findBestPositionInRange(input, lineStart, lineEnd, targetX);
844
+
845
+ input.setSelectionRange(bestPosition, bestPosition);
846
+
847
+ return;
848
+ }
849
+
850
+ /**
851
+ * For single-line inputs, search the entire value
852
+ */
853
+ const bestPosition = findBestPositionInRange(input, 0, value.length, targetX);
854
+
855
+ input.setSelectionRange(bestPosition, bestPosition);
856
+ };
857
+
858
+ /**
859
+ * Binary search to find the character position closest to target X in a native input
860
+ */
861
+ const findBestPositionInRange = (
862
+ input: HTMLInputElement | HTMLTextAreaElement,
863
+ start: number,
864
+ end: number,
865
+ targetX: number
866
+ ): number => {
867
+ /**
868
+ * Create a temporary span to measure character positions
869
+ * This is a workaround since native inputs don't expose character positions directly
870
+ */
871
+ const inputRect = input.getBoundingClientRect();
872
+ const style = window.getComputedStyle(input);
873
+ const paddingLeft = parseFloat(style.paddingLeft) || 0;
874
+
875
+ /**
876
+ * For native inputs, we approximate position based on character width
877
+ * This is not perfect but provides reasonable behavior
878
+ */
879
+ const relativeX = targetX - inputRect.left - paddingLeft;
880
+
881
+ if (relativeX <= 0) {
882
+ return start;
883
+ }
884
+
885
+ /**
886
+ * Estimate character width and find approximate position
887
+ */
888
+ const text = input.value.substring(start, end);
889
+ const fontSize = parseFloat(style.fontSize) || 16;
890
+ const avgCharWidth = fontSize * 0.6; // Approximate average character width
891
+
892
+ const estimatedPosition = Math.round(relativeX / avgCharWidth);
893
+ const clampedPosition = Math.min(Math.max(estimatedPosition, 0), text.length);
894
+
895
+ return start + clampedPosition;
896
+ };
897
+
898
+ /**
899
+ * Sets caret position in a contenteditable element at the closest position to target X
900
+ */
901
+ const setCaretAtXPositionInContentEditable = (
902
+ element: HTMLElement,
903
+ targetX: number,
904
+ atFirstLine: boolean
905
+ ): void => {
906
+ const selection = window.getSelection();
907
+
908
+ if (!selection) {
909
+ return;
910
+ }
911
+
912
+ /**
913
+ * Focus the element first to ensure it can receive a selection.
914
+ */
915
+ element.focus();
916
+
917
+ /**
918
+ * Get the target line's Y position
919
+ */
920
+ const targetNode = atFirstLine
921
+ ? $.getDeepestNode(element, false)
922
+ : $.getDeepestNode(element, true);
923
+
924
+ if (!targetNode) {
925
+ setSelectionToElement(element, selection, atFirstLine);
926
+
927
+ return;
928
+ }
929
+
930
+ /**
931
+ * Use document.caretPositionFromPoint or document.caretRangeFromPoint
932
+ * to find the position closest to the target X coordinate
933
+ */
934
+ const targetY = getTargetYPosition(element, targetNode, atFirstLine);
935
+
936
+ if (targetY === null) {
937
+ setSelectionToElement(element, selection, atFirstLine);
938
+
939
+ return;
940
+ }
941
+
942
+ /**
943
+ * Try to use caretPositionFromPoint (standard) or caretRangeFromPoint (WebKit)
944
+ */
945
+ const caretPosition = getCaretPositionFromPoint(targetX, targetY);
946
+
947
+ /**
948
+ * Verify that the returned caret position is actually inside the target element.
949
+ * In Firefox, caretPositionFromPoint can return nodes outside the element
950
+ * (e.g., sibling elements like list markers) when the X coordinate is at the edge.
951
+ */
952
+ if (caretPosition && element.contains(caretPosition.node)) {
953
+ const range = document.createRange();
954
+
955
+ try {
956
+ range.setStart(caretPosition.node, caretPosition.offset);
957
+ range.setEnd(caretPosition.node, caretPosition.offset);
958
+ selection.removeAllRanges();
959
+ selection.addRange(range);
960
+
961
+ return;
962
+ } catch {
963
+ // Fall through to fallback
964
+ }
965
+ }
966
+
967
+ /**
968
+ * Fallback: set selection to start or end of element
969
+ */
970
+ setSelectionToElement(element, selection, atFirstLine);
971
+ };
972
+
973
+ /**
974
+ * Sets selection to the start or end of an element.
975
+ * This is a cross-browser compatible way to position the caret.
976
+ */
977
+ const setSelectionToElement = (
978
+ element: HTMLElement,
979
+ _selection: Selection,
980
+ atFirstLine: boolean
981
+ ): void => {
982
+ /**
983
+ * Firefox and WebKit require the element to have focus before
984
+ * a selection can be set on it. We must also get a fresh Selection
985
+ * object AFTER focusing, as the pre-focus Selection may not work
986
+ * with the newly focused element in Firefox.
987
+ */
988
+ element.focus();
989
+
990
+ const freshSelection = window.getSelection();
991
+
992
+ if (!freshSelection) {
993
+ return;
994
+ }
995
+
996
+ const targetNode = atFirstLine
997
+ ? $.getDeepestNode(element, false)
998
+ : $.getDeepestNode(element, true);
999
+
1000
+ if (!targetNode) {
1001
+ return;
1002
+ }
1003
+
1004
+ const range = document.createRange();
1005
+ const offset = atFirstLine ? 0 : $.getContentLength(targetNode);
1006
+
1007
+ try {
1008
+ range.setStart(targetNode, offset);
1009
+ range.setEnd(targetNode, offset);
1010
+ freshSelection.removeAllRanges();
1011
+ freshSelection.addRange(range);
1012
+ } catch {
1013
+ // If setting range fails, use the focus utility which handles edge cases
1014
+ focus(element, atFirstLine);
1015
+ }
1016
+ };
1017
+
1018
+ /**
1019
+ * Gets the Y coordinate for the target line (first or last)
1020
+ */
1021
+ const getTargetYPosition = (element: HTMLElement, targetNode: Node, atFirstLine: boolean): number | null => {
1022
+ const range = document.createRange();
1023
+
1024
+ try {
1025
+ if (atFirstLine) {
1026
+ range.setStart(targetNode, 0);
1027
+ range.setEnd(targetNode, 0);
1028
+ } else {
1029
+ const length = $.getContentLength(targetNode);
1030
+
1031
+ range.setStart(targetNode, length);
1032
+ range.setEnd(targetNode, length);
1033
+ }
1034
+
1035
+ const rect = range.getBoundingClientRect();
1036
+
1037
+ if (rect.height === 0 && rect.top === 0) {
1038
+ const elementRect = element.getBoundingClientRect();
1039
+
1040
+ return atFirstLine ? elementRect.top + 10 : elementRect.bottom - 10;
1041
+ }
1042
+
1043
+ /**
1044
+ * Return the vertical center of the line
1045
+ */
1046
+ return rect.top + rect.height / 2;
1047
+ } catch {
1048
+ return null;
1049
+ }
1050
+ };
1051
+
1052
+ /**
1053
+ * Gets caret position from screen coordinates using browser APIs.
1054
+ * Uses the standard caretPositionFromPoint API which is now widely supported.
1055
+ */
1056
+ const getCaretPositionFromPoint = (x: number, y: number): { node: Node; offset: number } | null => {
1057
+ const caretPosition = document.caretPositionFromPoint(x, y);
1058
+
1059
+ if (caretPosition === null) {
1060
+ return null;
1061
+ }
1062
+
1063
+ return {
1064
+ node: caretPosition.offsetNode,
1065
+ offset: caretPosition.offset,
1066
+ };
1067
+ };