@jackuait/blok 0.4.1-beta.5 → 0.4.1-beta.6

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 (400) hide show
  1. package/README.md +136 -17
  2. package/codemod/README.md +16 -0
  3. package/codemod/migrate-editorjs-to-blok.js +868 -92
  4. package/codemod/test.js +682 -77
  5. package/dist/blok.mjs +5 -2
  6. package/dist/chunks/blok-B5qs7C5l.mjs +12838 -0
  7. package/dist/chunks/i18next-CugVlwWp.mjs +1292 -0
  8. package/dist/chunks/i18next-loader-CTrK3HzG.mjs +43 -0
  9. package/dist/{index-Cl_5rkKS.mjs → chunks/index-DDpzQn-0.mjs} +2 -2
  10. package/dist/chunks/inline-tool-convert-RBcopmCh.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 +48 -16
  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 -12
  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/header.d.ts +18 -0
  392. package/types/tools/index.d.ts +1 -0
  393. package/types/tools/list.d.ts +91 -0
  394. package/types/tools/paragraph.d.ts +71 -0
  395. package/types/tools/tool-settings.d.ts +16 -2
  396. package/types/tools/tool.d.ts +6 -0
  397. package/types/tools-entry.d.ts +49 -0
  398. package/types/utils/popover/popover-item.d.ts +0 -5
  399. package/dist/blok-DvN73wsH.mjs +0 -19922
  400. package/dist/blok.umd.js +0 -166
@@ -0,0 +1,2213 @@
1
+ import type { InlineTool, SanitizerConfig } from '../../../types';
2
+ import { IconBold } from '../icons';
3
+ import type { MenuConfig } from '../../../types/tools';
4
+ import { DATA_ATTR, createSelector } from '../constants';
5
+
6
+ /**
7
+ * Bold Tool
8
+ *
9
+ * Inline Toolbar Tool
10
+ *
11
+ * Makes selected text bolder
12
+ */
13
+ export class BoldInlineTool implements InlineTool {
14
+ /**
15
+ * Specifies Tool as Inline Toolbar Tool
16
+ * @returns {boolean}
17
+ */
18
+ public static isInline = true;
19
+
20
+ /**
21
+ * Title for the Inline Tool
22
+ */
23
+ public static title = 'Bold';
24
+
25
+ /**
26
+ * Translation key for i18n
27
+ */
28
+ public static titleKey = 'bold';
29
+
30
+ /**
31
+ * Sanitizer Rule
32
+ * Leave <strong> tags
33
+ * @returns {object}
34
+ */
35
+ public static get sanitize(): SanitizerConfig {
36
+ return {
37
+ strong: {},
38
+ b: {},
39
+ } as SanitizerConfig;
40
+ }
41
+
42
+ /**
43
+ * Normalize any remaining legacy <b> tags within the blok wrapper
44
+ */
45
+ private static normalizeAllBoldTags(): void {
46
+ if (typeof document === 'undefined') {
47
+ return;
48
+ }
49
+
50
+ const selector = `${createSelector(DATA_ATTR.interface)} b, ${createSelector(DATA_ATTR.editor)} b`;
51
+
52
+ document.querySelectorAll(selector).forEach((boldNode) => {
53
+ BoldInlineTool.ensureStrongElement(boldNode as HTMLElement);
54
+ });
55
+ }
56
+
57
+ /**
58
+ * Normalize bold tags within a mutated node if it belongs to the blok
59
+ * @param node - The node affected by mutation
60
+ */
61
+ private static normalizeBoldInNode(node: Node): void {
62
+ const element = node.nodeType === Node.ELEMENT_NODE
63
+ ? node as Element
64
+ : node.parentElement;
65
+
66
+ if (!element || typeof element.closest !== 'function') {
67
+ return;
68
+ }
69
+
70
+ const blokRoot = element.closest(`${createSelector(DATA_ATTR.interface)}, ${createSelector(DATA_ATTR.editor)}`);
71
+
72
+ if (!blokRoot) {
73
+ return;
74
+ }
75
+
76
+ if (element.tagName === 'B') {
77
+ BoldInlineTool.ensureStrongElement(element as HTMLElement);
78
+ }
79
+
80
+ element.querySelectorAll?.('b').forEach((boldNode) => {
81
+ BoldInlineTool.ensureStrongElement(boldNode as HTMLElement);
82
+ });
83
+ }
84
+
85
+ private static shortcutListenerRegistered = false;
86
+ private static selectionListenerRegistered = false;
87
+ private static inputListenerRegistered = false;
88
+ private static beforeInputListenerRegistered = false;
89
+ private static readonly globalListenersInitialized = BoldInlineTool.initializeGlobalListeners();
90
+ private static readonly collapsedExitRecords = new Set<{
91
+ boundary: Text;
92
+ boldElement: HTMLElement;
93
+ allowedLength: number;
94
+ hasLeadingSpace: boolean;
95
+ hasTypedContent: boolean;
96
+ leadingWhitespace: string;
97
+ }>();
98
+ private static markerSequence = 0;
99
+ private static mutationObserver?: MutationObserver;
100
+ private static isProcessingMutation = false;
101
+ private static readonly DATA_ATTR_COLLAPSED_LENGTH = 'data-blok-bold-collapsed-length';
102
+ private static readonly DATA_ATTR_COLLAPSED_ACTIVE = 'data-blok-bold-collapsed-active';
103
+ private static readonly DATA_ATTR_PREV_LENGTH = 'data-blok-bold-prev-length';
104
+ private static readonly DATA_ATTR_LEADING_WHITESPACE = 'data-blok-bold-leading-ws';
105
+ private static readonly instances = new Set<BoldInlineTool>();
106
+ private static readonly pendingBoundaryCaretAdjustments = new WeakSet<Text>();
107
+
108
+ /**
109
+ *
110
+ */
111
+ constructor() {
112
+ if (typeof document === 'undefined') {
113
+ return;
114
+ }
115
+
116
+ BoldInlineTool.instances.add(this);
117
+
118
+ BoldInlineTool.initializeGlobalListeners();
119
+ }
120
+
121
+ /**
122
+ * Ensure global event listeners are registered once per document
123
+ */
124
+ private static initializeGlobalListeners(): boolean {
125
+ if (typeof document === 'undefined') {
126
+ return false;
127
+ }
128
+
129
+ if (!BoldInlineTool.shortcutListenerRegistered) {
130
+ document.addEventListener('keydown', BoldInlineTool.handleShortcut, true);
131
+ BoldInlineTool.shortcutListenerRegistered = true;
132
+ }
133
+
134
+ if (!BoldInlineTool.selectionListenerRegistered) {
135
+ document.addEventListener('selectionchange', BoldInlineTool.handleGlobalSelectionChange, true);
136
+ BoldInlineTool.selectionListenerRegistered = true;
137
+ }
138
+
139
+ if (!BoldInlineTool.inputListenerRegistered) {
140
+ document.addEventListener('input', BoldInlineTool.handleGlobalInput, true);
141
+ BoldInlineTool.inputListenerRegistered = true;
142
+ }
143
+
144
+ if (!BoldInlineTool.beforeInputListenerRegistered) {
145
+ document.addEventListener('beforeinput', BoldInlineTool.handleBeforeInput, true);
146
+ BoldInlineTool.beforeInputListenerRegistered = true;
147
+ }
148
+
149
+ BoldInlineTool.ensureMutationObserver();
150
+
151
+ return true;
152
+ }
153
+
154
+ /**
155
+ * Ensure that text typed after exiting a collapsed bold selection stays outside of the bold element
156
+ */
157
+ private static maintainCollapsedExitState(): void {
158
+ if (typeof document === 'undefined') {
159
+ return;
160
+ }
161
+
162
+ for (const record of Array.from(BoldInlineTool.collapsedExitRecords)) {
163
+ const resolved = BoldInlineTool.resolveBoundary(record);
164
+
165
+ if (!resolved) {
166
+ BoldInlineTool.collapsedExitRecords.delete(record);
167
+
168
+ continue;
169
+ }
170
+
171
+ record.boundary = resolved.boundary;
172
+ record.boldElement = resolved.boldElement;
173
+
174
+ const boundary = resolved.boundary;
175
+ const boldElement = resolved.boldElement;
176
+ const allowedLength = record.allowedLength;
177
+ const currentText = boldElement.textContent ?? '';
178
+
179
+ if (currentText.length > allowedLength) {
180
+ const preserved = currentText.slice(0, allowedLength);
181
+ const extra = currentText.slice(allowedLength);
182
+
183
+ boldElement.textContent = preserved;
184
+ boundary.textContent = (boundary.textContent ?? '') + extra;
185
+ }
186
+
187
+ const boundaryContent = boundary.textContent ?? '';
188
+
189
+ if (boundaryContent.length > 1 && boundaryContent.startsWith('\u200B')) {
190
+ boundary.textContent = boundaryContent.slice(1);
191
+ }
192
+
193
+ const selection = window.getSelection();
194
+
195
+ BoldInlineTool.ensureCaretAtBoundary(selection, boundary);
196
+ BoldInlineTool.scheduleBoundaryCaretAdjustment(boundary);
197
+
198
+ const boundaryText = boundary.textContent ?? '';
199
+ const sanitizedBoundary = boundaryText.replace(/\u200B/g, '');
200
+ const leadingMatch = sanitizedBoundary.match(/^\s+/);
201
+ const containsTypedContent = /\S/.test(sanitizedBoundary);
202
+ const selectionStartsWithZws = boundaryText.startsWith('\u200B');
203
+
204
+ if (leadingMatch) {
205
+ record.hasLeadingSpace = true;
206
+ record.leadingWhitespace = leadingMatch[0];
207
+ }
208
+
209
+ if (containsTypedContent) {
210
+ record.hasTypedContent = true;
211
+ }
212
+
213
+ const boundaryHasVisibleLeading = /^\s/.test(sanitizedBoundary);
214
+ const meetsDeletionCriteria = record.hasTypedContent && !selectionStartsWithZws && (boldElement.textContent ?? '').length <= allowedLength;
215
+ const shouldRestoreLeadingSpace = record.hasLeadingSpace && record.hasTypedContent && !boundaryHasVisibleLeading;
216
+
217
+ if (meetsDeletionCriteria && shouldRestoreLeadingSpace) {
218
+ const trimmedActual = boundaryText.replace(/^[\u200B\s]+/, '');
219
+ const leadingWhitespace = record.leadingWhitespace || ' ';
220
+
221
+ boundary.textContent = `${leadingWhitespace}${trimmedActual}`;
222
+ BoldInlineTool.ensureCaretAtBoundary(selection, boundary);
223
+ }
224
+
225
+ if (meetsDeletionCriteria) {
226
+ BoldInlineTool.collapsedExitRecords.delete(record);
227
+ }
228
+ }
229
+ }
230
+
231
+ /**
232
+ * Ensure the caret remains at the end of the boundary text node when exiting bold
233
+ * @param selection - Current document selection
234
+ * @param boundary - Text node following the bold element
235
+ */
236
+ private static ensureCaretAtBoundary(selection: Selection | null, boundary: Text): void {
237
+ if (!selection || !selection.isCollapsed) {
238
+ return;
239
+ }
240
+
241
+ BoldInlineTool.setCaretToBoundaryEnd(selection, boundary);
242
+ }
243
+
244
+ /**
245
+ * Ensure the caret remains at the end of the boundary text node after the current microtask queue is flushed
246
+ * @param boundary - Boundary text node that should keep the caret at its end
247
+ */
248
+ private static scheduleBoundaryCaretAdjustment(boundary: Text): void {
249
+ if (BoldInlineTool.pendingBoundaryCaretAdjustments.has(boundary)) {
250
+ return;
251
+ }
252
+
253
+ BoldInlineTool.pendingBoundaryCaretAdjustments.add(boundary);
254
+
255
+ setTimeout(() => {
256
+ BoldInlineTool.pendingBoundaryCaretAdjustments.delete(boundary);
257
+
258
+ const ownerDocument = boundary.ownerDocument ?? (typeof document !== 'undefined' ? document : null);
259
+
260
+ if (!ownerDocument) {
261
+ return;
262
+ }
263
+
264
+ const selection = ownerDocument.getSelection();
265
+
266
+ if (!selection || !selection.isCollapsed || selection.anchorNode !== boundary) {
267
+ return;
268
+ }
269
+
270
+ const targetOffset = boundary.textContent?.length ?? 0;
271
+
272
+ if (selection.anchorOffset === targetOffset) {
273
+ return;
274
+ }
275
+
276
+ BoldInlineTool.setCaret(selection, boundary, targetOffset);
277
+ }, 0);
278
+ }
279
+
280
+ /**
281
+ * Ensure there is a text node immediately following the provided bold element.
282
+ * Creates one when necessary.
283
+ * @param boldElement - Bold element that precedes the boundary
284
+ * @returns The text node following the bold element or null if it cannot be created
285
+ */
286
+ private static ensureTextNodeAfter(boldElement: HTMLElement): Text | null {
287
+ const existingNext = boldElement.nextSibling;
288
+
289
+ if (existingNext?.nodeType === Node.TEXT_NODE) {
290
+ return existingNext as Text;
291
+ }
292
+
293
+ const parent = boldElement.parentNode;
294
+
295
+ if (!parent) {
296
+ return null;
297
+ }
298
+
299
+ const documentRef = boldElement.ownerDocument ?? (typeof document !== 'undefined' ? document : null);
300
+
301
+ if (!documentRef) {
302
+ return null;
303
+ }
304
+
305
+ const newNode = documentRef.createTextNode('');
306
+
307
+ parent.insertBefore(newNode, existingNext);
308
+
309
+ return newNode;
310
+ }
311
+
312
+ /**
313
+ * Resolve the boundary text node tracked for a collapsed exit record.
314
+ * @param record - Collapsed exit tracking record
315
+ * @returns The aligned boundary text node or null when it cannot be determined
316
+ */
317
+ private static resolveBoundary(record: { boundary: Text; boldElement: HTMLElement }): { boundary: Text; boldElement: HTMLElement } | null {
318
+ if (!record.boldElement.isConnected) {
319
+ return null;
320
+ }
321
+
322
+ const strong = BoldInlineTool.ensureStrongElement(record.boldElement);
323
+ const boundary = record.boundary;
324
+ const isAligned = boundary.isConnected && boundary.previousSibling === strong;
325
+ const resolvedBoundary = isAligned ? boundary : BoldInlineTool.ensureTextNodeAfter(strong);
326
+
327
+ if (!resolvedBoundary) {
328
+ return null;
329
+ }
330
+
331
+ return {
332
+ boundary: resolvedBoundary,
333
+ boldElement: strong,
334
+ };
335
+ }
336
+
337
+ /**
338
+ * Move caret to the end of the provided boundary text node
339
+ * @param selection - Current selection to update
340
+ * @param boundary - Boundary text node that hosts the caret
341
+ */
342
+ private static setCaretToBoundaryEnd(selection: Selection, boundary: Text): void {
343
+ const range = document.createRange();
344
+ const caretOffset = boundary.textContent?.length ?? 0;
345
+
346
+ range.setStart(boundary, caretOffset);
347
+ range.collapse(true);
348
+
349
+ selection.removeAllRanges();
350
+ selection.addRange(range);
351
+ }
352
+
353
+ /**
354
+ * Recursively check if a node or any of its parents is a bold tag (<strong>)
355
+ * @param node - The node to check
356
+ */
357
+ private static hasBoldParent(node: Node | null): boolean {
358
+ if (!node) {
359
+ return false;
360
+ }
361
+
362
+ if (node.nodeType === Node.ELEMENT_NODE && BoldInlineTool.isBoldTag(node as Element)) {
363
+ return true;
364
+ }
365
+
366
+ return BoldInlineTool.hasBoldParent(node.parentNode);
367
+ }
368
+
369
+ /**
370
+ * Recursively find a bold element (<strong>) in the parent chain
371
+ * @param node - The node to start searching from
372
+ */
373
+ private static findBoldElement(node: Node | null): HTMLElement | null {
374
+ if (!node) {
375
+ return null;
376
+ }
377
+
378
+ if (node.nodeType === Node.ELEMENT_NODE && BoldInlineTool.isBoldTag(node as Element)) {
379
+ return BoldInlineTool.ensureStrongElement(node as HTMLElement);
380
+ }
381
+
382
+ return BoldInlineTool.findBoldElement(node.parentNode);
383
+ }
384
+
385
+ /**
386
+ * Check if an element is a bold tag (<strong> for conversion)
387
+ * @param node - The element to check
388
+ */
389
+ private static isBoldTag(node: Element): boolean {
390
+ const tag = node.tagName;
391
+
392
+ return tag === 'B' || tag === 'STRONG';
393
+ }
394
+
395
+ /**
396
+ * Ensure an element is a <strong> tag, converting from <b> if needed
397
+ * @param element - The element to ensure is a strong tag
398
+ */
399
+ private static ensureStrongElement(element: HTMLElement): HTMLElement {
400
+ if (element.tagName === 'STRONG') {
401
+ return element;
402
+ }
403
+
404
+ const strong = document.createElement('strong');
405
+
406
+ if (element.hasAttributes()) {
407
+ Array.from(element.attributes).forEach((attr) => {
408
+ strong.setAttribute(attr.name, attr.value);
409
+ });
410
+ }
411
+
412
+ while (element.firstChild) {
413
+ strong.appendChild(element.firstChild);
414
+ }
415
+
416
+ element.replaceWith(strong);
417
+
418
+ return strong;
419
+ }
420
+
421
+ /**
422
+ * Merge two strong elements by moving children from right to left
423
+ * @param left - The left strong element to merge into
424
+ * @param right - The right strong element to merge from
425
+ */
426
+ private static mergeStrongNodes(left: HTMLElement, right: HTMLElement): HTMLElement {
427
+ const leftStrong = BoldInlineTool.ensureStrongElement(left);
428
+ const rightStrong = BoldInlineTool.ensureStrongElement(right);
429
+
430
+ while (rightStrong.firstChild) {
431
+ leftStrong.appendChild(rightStrong.firstChild);
432
+ }
433
+
434
+ rightStrong.remove();
435
+
436
+ return leftStrong;
437
+ }
438
+
439
+ /**
440
+ * Create button for Inline Toolbar
441
+ */
442
+ public render(): MenuConfig {
443
+ return {
444
+ icon: IconBold,
445
+ name: 'bold',
446
+ onActivate: () => {
447
+ this.toggleBold();
448
+ },
449
+ isActive: () => {
450
+ const selection = window.getSelection();
451
+
452
+ return selection ? this.isSelectionVisuallyBold(selection) : false;
453
+ },
454
+ };
455
+ }
456
+
457
+ /**
458
+ * Apply or remove bold formatting using modern Selection API
459
+ */
460
+ private toggleBold(): void {
461
+ const selection = window.getSelection();
462
+
463
+ if (!selection || selection.rangeCount === 0) {
464
+ return;
465
+ }
466
+
467
+ const range = selection.getRangeAt(0);
468
+
469
+ if (range.collapsed) {
470
+ this.toggleCollapsedSelection();
471
+
472
+ return;
473
+ }
474
+
475
+ // Check if selection is visually bold (ignoring whitespace) to match button state
476
+ // If visually bold, unwrap; otherwise wrap
477
+ const shouldUnwrap = this.isRangeBold(range, { ignoreWhitespace: true });
478
+
479
+ if (shouldUnwrap) {
480
+ this.unwrapBoldTags(range);
481
+ } else {
482
+ this.wrapWithBold(range);
483
+ }
484
+ }
485
+
486
+ /**
487
+ * Check if current selection is within a bold tag (<strong>)
488
+ * @param selection - The Selection object to check
489
+ */
490
+ private isSelectionVisuallyBold(selection: Selection): boolean {
491
+ if (!selection || selection.rangeCount === 0) {
492
+ return false;
493
+ }
494
+
495
+ const range = selection.getRangeAt(0);
496
+
497
+ return this.isRangeBold(range, { ignoreWhitespace: true });
498
+ }
499
+
500
+ /**
501
+ * Wrap selection with <strong> tag
502
+ * @param range - The Range object containing the selection to wrap
503
+ */
504
+ private wrapWithBold(range: Range): void {
505
+ const html = this.getRangeHtmlWithoutBold(range);
506
+ const insertedRange = this.replaceRangeWithHtml(range, `<strong>${html}</strong>`);
507
+ const selection = window.getSelection();
508
+
509
+ if (selection && insertedRange) {
510
+ selection.removeAllRanges();
511
+ selection.addRange(insertedRange);
512
+ }
513
+
514
+ BoldInlineTool.normalizeAllBoldTags();
515
+
516
+ /**
517
+ * Find the bold element from the inserted range.
518
+ * After insertion, selection.focusNode may point to the parent container (e.g., DIV)
519
+ * rather than inside the <strong> element, so we need to look at the range's
520
+ * startContainer or commonAncestorContainer to find the bold element.
521
+ */
522
+ const boldElement = this.findBoldElementFromRangeOrSelection(insertedRange, selection);
523
+
524
+ if (!boldElement) {
525
+ /**
526
+ * Even if we can't find the bold element, we should still notify selection change
527
+ * to update the toolbar button state based on the current selection.
528
+ */
529
+ this.notifySelectionChange();
530
+
531
+ return;
532
+ }
533
+
534
+ const merged = this.mergeAdjacentBold(boldElement);
535
+
536
+ this.normalizeWhitespaceAround(merged);
537
+
538
+ this.selectElementContents(merged);
539
+ BoldInlineTool.normalizeBoldTagsWithinBlok(window.getSelection());
540
+ BoldInlineTool.replaceNbspInBlock(window.getSelection());
541
+ this.notifySelectionChange();
542
+ }
543
+
544
+ /**
545
+ * Remove bold tags (<strong>) while preserving content
546
+ * @param range - The Range object containing the selection to unwrap
547
+ */
548
+ private unwrapBoldTags(range: Range): void {
549
+ const boldAncestors = this.collectBoldAncestors(range);
550
+ const selection = window.getSelection();
551
+
552
+ if (!selection) {
553
+ return;
554
+ }
555
+
556
+ const marker = document.createElement('span');
557
+ const fragment = range.extractContents();
558
+
559
+ marker.setAttribute('data-blok-bold-marker', `unwrap-${BoldInlineTool.markerSequence++}`);
560
+ marker.appendChild(fragment);
561
+ this.removeNestedBold(marker);
562
+
563
+ range.insertNode(marker);
564
+
565
+ const markerRange = document.createRange();
566
+
567
+ markerRange.selectNodeContents(marker);
568
+ selection.removeAllRanges();
569
+ selection.addRange(markerRange);
570
+
571
+ for (; ;) {
572
+ const currentBold = BoldInlineTool.findBoldElement(marker);
573
+
574
+ if (!currentBold) {
575
+ break;
576
+ }
577
+
578
+ this.moveMarkerOutOfBold(marker, currentBold);
579
+ }
580
+
581
+ const firstChild = marker.firstChild;
582
+ const lastChild = marker.lastChild;
583
+
584
+ this.unwrapElement(marker);
585
+
586
+ const finalRange = firstChild && lastChild ? (() => {
587
+ const newRange = document.createRange();
588
+
589
+ newRange.setStartBefore(firstChild);
590
+ newRange.setEndAfter(lastChild);
591
+
592
+ selection.removeAllRanges();
593
+ selection.addRange(newRange);
594
+
595
+ return newRange;
596
+ })() : undefined;
597
+
598
+ if (!finalRange) {
599
+ selection.removeAllRanges();
600
+ }
601
+
602
+ this.replaceNbspWithinRange(finalRange);
603
+ BoldInlineTool.normalizeBoldTagsWithinBlok(selection);
604
+ BoldInlineTool.replaceNbspInBlock(selection);
605
+ BoldInlineTool.removeEmptyBoldElements(selection);
606
+
607
+ boldAncestors.forEach((element) => {
608
+ if (BoldInlineTool.isElementEmpty(element)) {
609
+ element.remove();
610
+ }
611
+ });
612
+
613
+ this.notifySelectionChange();
614
+ }
615
+
616
+ /**
617
+ * Replace the current range contents with provided HTML snippet
618
+ * @param range - Range to replace
619
+ * @param html - HTML string to insert
620
+ * @returns range spanning inserted content
621
+ */
622
+ private replaceRangeWithHtml(range: Range, html: string): Range | undefined {
623
+ const fragment = BoldInlineTool.createFragmentFromHtml(html);
624
+ const firstInserted = fragment.firstChild ?? null;
625
+ const lastInserted = fragment.lastChild ?? null;
626
+
627
+ range.deleteContents();
628
+
629
+ if (!firstInserted || !lastInserted) {
630
+ return;
631
+ }
632
+
633
+ range.insertNode(fragment);
634
+
635
+ const newRange = document.createRange();
636
+
637
+ newRange.setStartBefore(firstInserted);
638
+ newRange.setEndAfter(lastInserted);
639
+
640
+ return newRange;
641
+ }
642
+
643
+ /**
644
+ * Move a temporary marker element outside of a bold ancestor while preserving content order
645
+ * @param marker - Marker element wrapping the selection contents
646
+ * @param boldElement - Bold ancestor containing the marker
647
+ */
648
+ private moveMarkerOutOfBold(marker: HTMLElement, boldElement: HTMLElement): void {
649
+ const parent = boldElement.parentNode;
650
+
651
+ if (!parent) {
652
+ return;
653
+ }
654
+
655
+ Array.from(boldElement.childNodes).forEach((node) => {
656
+ if (node.nodeType === Node.TEXT_NODE && (node.textContent ?? '').length === 0) {
657
+ node.remove();
658
+ }
659
+ });
660
+
661
+ const isOnlyChild = boldElement.childNodes.length === 1 && boldElement.firstChild === marker;
662
+
663
+ if (isOnlyChild) {
664
+ boldElement.replaceWith(marker);
665
+
666
+ return;
667
+ }
668
+
669
+ const isFirstChild = boldElement.firstChild === marker;
670
+
671
+ if (isFirstChild) {
672
+ parent.insertBefore(marker, boldElement);
673
+
674
+ return;
675
+ }
676
+
677
+ const isLastChild = boldElement.lastChild === marker;
678
+
679
+ if (isLastChild) {
680
+ parent.insertBefore(marker, boldElement.nextSibling);
681
+
682
+ return;
683
+ }
684
+
685
+ const trailingClone = boldElement.cloneNode(false) as HTMLElement;
686
+
687
+ while (marker.nextSibling) {
688
+ trailingClone.appendChild(marker.nextSibling);
689
+ }
690
+
691
+ parent.insertBefore(trailingClone, boldElement.nextSibling);
692
+ parent.insertBefore(marker, trailingClone);
693
+ }
694
+
695
+ /**
696
+ * Select all contents of an element
697
+ * @param element - The element whose contents should be selected
698
+ */
699
+ private selectElementContents(element: HTMLElement): void {
700
+ const selection = window.getSelection();
701
+
702
+ if (!selection) {
703
+ return;
704
+ }
705
+
706
+ const newRange = document.createRange();
707
+
708
+ newRange.selectNodeContents(element);
709
+
710
+ selection.removeAllRanges();
711
+ selection.addRange(newRange);
712
+ }
713
+
714
+ /**
715
+ * Shortcut for bold tool
716
+ */
717
+ public static shortcut = 'CMD+B';
718
+
719
+ /**
720
+ * Check if a range contains bold text
721
+ * @param range - The range to check
722
+ * @param options - Options for checking bold status
723
+ * @param options.ignoreWhitespace - Whether to ignore whitespace-only text nodes
724
+ */
725
+ private isRangeBold(range: Range, options: { ignoreWhitespace: boolean }): boolean {
726
+ if (range.collapsed) {
727
+ return Boolean(BoldInlineTool.findBoldElement(range.startContainer));
728
+ }
729
+
730
+ const walker = document.createTreeWalker(
731
+ range.commonAncestorContainer,
732
+ NodeFilter.SHOW_TEXT,
733
+ {
734
+ acceptNode: (node) => {
735
+ try {
736
+ return range.intersectsNode(node) ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT;
737
+ } catch (_error) {
738
+ /**
739
+ * Safari might throw if node is detached from DOM.
740
+ * In that case, fall back to manual comparison by wrapping node into a range.
741
+ */
742
+ const nodeRange = document.createRange();
743
+
744
+ nodeRange.selectNodeContents(node);
745
+
746
+ const startsBeforeEnd = range.compareBoundaryPoints(Range.END_TO_START, nodeRange) > 0;
747
+ const endsAfterStart = range.compareBoundaryPoints(Range.START_TO_END, nodeRange) < 0;
748
+
749
+ return (startsBeforeEnd && endsAfterStart) ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT;
750
+ }
751
+ },
752
+ }
753
+ );
754
+
755
+ const textNodes: Text[] = [];
756
+
757
+ while (walker.nextNode()) {
758
+ const textNode = walker.currentNode as Text;
759
+ const value = textNode.textContent ?? '';
760
+
761
+ if (options.ignoreWhitespace && value.trim().length === 0) {
762
+ continue;
763
+ }
764
+
765
+ if (value.length === 0) {
766
+ continue;
767
+ }
768
+
769
+ textNodes.push(textNode);
770
+ }
771
+
772
+ if (textNodes.length === 0) {
773
+ return Boolean(BoldInlineTool.findBoldElement(range.startContainer));
774
+ }
775
+
776
+ return textNodes.every((textNode) => BoldInlineTool.hasBoldParent(textNode));
777
+ }
778
+
779
+ /**
780
+ * Remove nested bold tags from a root node
781
+ * @param root - The root node to process
782
+ */
783
+ private removeNestedBold(root: ParentNode): void {
784
+ const boldNodes = root.querySelectorAll?.('b,strong');
785
+
786
+ if (!boldNodes) {
787
+ return;
788
+ }
789
+
790
+ boldNodes.forEach((node) => {
791
+ this.unwrapElement(node);
792
+ });
793
+ }
794
+
795
+ /**
796
+ * Unwrap an element by moving its children to the parent
797
+ * @param element - The element to unwrap
798
+ */
799
+ private unwrapElement(element: Element): void {
800
+ const parent = element.parentNode;
801
+
802
+ if (!parent) {
803
+ element.remove();
804
+
805
+ return;
806
+ }
807
+
808
+ while (element.firstChild) {
809
+ parent.insertBefore(element.firstChild, element);
810
+ }
811
+
812
+ parent.removeChild(element);
813
+ }
814
+
815
+ /**
816
+ * Find bold element from an inserted range or fall back to selection
817
+ * @param insertedRange - Range spanning inserted content
818
+ * @param selection - Current selection as fallback
819
+ */
820
+ private findBoldElementFromRangeOrSelection(insertedRange: Range | undefined, selection: Selection | null): HTMLElement | null {
821
+ if (!insertedRange) {
822
+ return selection ? BoldInlineTool.findBoldElement(selection.focusNode) : null;
823
+ }
824
+
825
+ const fromStart = BoldInlineTool.findBoldElement(insertedRange.startContainer);
826
+
827
+ if (fromStart) {
828
+ return fromStart;
829
+ }
830
+
831
+ const fromAncestor = BoldInlineTool.findBoldElement(insertedRange.commonAncestorContainer);
832
+
833
+ if (fromAncestor) {
834
+ return fromAncestor;
835
+ }
836
+
837
+ const isStartContainerBold = insertedRange.startContainer.nodeType === Node.ELEMENT_NODE &&
838
+ BoldInlineTool.isBoldTag(insertedRange.startContainer as Element);
839
+
840
+ return isStartContainerBold ? insertedRange.startContainer as HTMLElement : null;
841
+ }
842
+
843
+ /**
844
+ * Merge adjacent bold elements into a single element
845
+ * @param element - The bold element to merge with adjacent elements
846
+ */
847
+ private mergeAdjacentBold(element: HTMLElement): HTMLElement {
848
+ const initialTarget = BoldInlineTool.ensureStrongElement(element);
849
+
850
+ const previous = initialTarget.previousSibling;
851
+ const targetAfterPrevious = previous && previous.nodeType === Node.ELEMENT_NODE && BoldInlineTool.isBoldTag(previous as Element)
852
+ ? BoldInlineTool.mergeStrongNodes(previous as HTMLElement, initialTarget)
853
+ : initialTarget;
854
+
855
+ const next = targetAfterPrevious.nextSibling;
856
+ const finalTarget = next && next.nodeType === Node.ELEMENT_NODE && BoldInlineTool.isBoldTag(next as Element)
857
+ ? BoldInlineTool.mergeStrongNodes(targetAfterPrevious, next as HTMLElement)
858
+ : targetAfterPrevious;
859
+
860
+ return finalTarget;
861
+ }
862
+
863
+ /**
864
+ * Toggle bold formatting for a collapsed selection (caret position)
865
+ * Exits bold if caret is inside a bold element, otherwise starts a new bold element
866
+ */
867
+ private toggleCollapsedSelection(): void {
868
+ const selection = window.getSelection();
869
+
870
+ if (!selection || selection.rangeCount === 0) {
871
+ return;
872
+ }
873
+
874
+ const range = selection.getRangeAt(0);
875
+ const insideBold = BoldInlineTool.findBoldElement(range.startContainer);
876
+
877
+ const updatedRange = (() => {
878
+ if (insideBold && insideBold.getAttribute(BoldInlineTool.DATA_ATTR_COLLAPSED_ACTIVE) !== 'true') {
879
+ return BoldInlineTool.exitCollapsedBold(selection, insideBold);
880
+ }
881
+
882
+ const boundaryBold = insideBold ?? BoldInlineTool.getBoundaryBold(range);
883
+
884
+ return boundaryBold
885
+ ? BoldInlineTool.exitCollapsedBold(selection, boundaryBold)
886
+ : this.startCollapsedBold(range);
887
+ })();
888
+
889
+ document.dispatchEvent(new Event('selectionchange'));
890
+
891
+ if (updatedRange) {
892
+ selection.removeAllRanges();
893
+ selection.addRange(updatedRange);
894
+ }
895
+
896
+ BoldInlineTool.normalizeBoldTagsWithinBlok(selection);
897
+ BoldInlineTool.replaceNbspInBlock(selection);
898
+ BoldInlineTool.removeEmptyBoldElements(selection);
899
+ this.notifySelectionChange();
900
+ }
901
+
902
+ /**
903
+ * Insert a bold wrapper at the caret so newly typed text becomes bold
904
+ * @param range - Current collapsed range
905
+ */
906
+ private startCollapsedBold(range: Range): Range | undefined {
907
+ if (!range.collapsed) {
908
+ return;
909
+ }
910
+
911
+ const strong = document.createElement('strong');
912
+ const textNode = document.createTextNode('');
913
+
914
+ strong.appendChild(textNode);
915
+ strong.setAttribute(BoldInlineTool.DATA_ATTR_COLLAPSED_ACTIVE, 'true');
916
+
917
+ const container = range.startContainer;
918
+ const offset = range.startOffset;
919
+
920
+ const insertionSucceeded = (() => {
921
+ if (container.nodeType === Node.TEXT_NODE) {
922
+ return this.insertCollapsedBoldIntoText(container as Text, strong, offset);
923
+ }
924
+
925
+ if (container.nodeType === Node.ELEMENT_NODE) {
926
+ this.insertCollapsedBoldIntoElement(container as Element, strong, offset);
927
+
928
+ return true;
929
+ }
930
+
931
+ return false;
932
+ })();
933
+
934
+ if (!insertionSucceeded) {
935
+ return;
936
+ }
937
+
938
+ const selection = window.getSelection();
939
+ const newRange = document.createRange();
940
+
941
+ newRange.setStart(textNode, 0);
942
+ newRange.collapse(true);
943
+
944
+ const merged = this.mergeAdjacentBold(strong);
945
+
946
+ BoldInlineTool.normalizeBoldTagsWithinBlok(selection);
947
+ BoldInlineTool.replaceNbspInBlock(selection);
948
+ BoldInlineTool.removeEmptyBoldElements(selection);
949
+
950
+ if (selection) {
951
+ selection.removeAllRanges();
952
+ selection.addRange(newRange);
953
+ }
954
+
955
+ this.notifySelectionChange();
956
+
957
+ return merged.firstChild instanceof Text ? (() => {
958
+ const caretRange = document.createRange();
959
+
960
+ caretRange.setStart(merged.firstChild, merged.firstChild.textContent?.length ?? 0);
961
+ caretRange.collapse(true);
962
+
963
+ return caretRange;
964
+ })() : newRange;
965
+ }
966
+
967
+ /**
968
+ * Insert a collapsed bold wrapper when the caret resides inside a text node
969
+ * @param text - Text node containing the caret
970
+ * @param strong - Strong element to insert
971
+ * @param offset - Caret offset within the text node
972
+ * @returns true when insertion succeeded
973
+ */
974
+ private insertCollapsedBoldIntoText(text: Text, strong: HTMLElement, offset: number): boolean {
975
+ const textNode = text;
976
+ const parent = textNode.parentNode;
977
+
978
+ if (!parent) {
979
+ return false;
980
+ }
981
+
982
+ const content = textNode.textContent ?? '';
983
+ const before = content.slice(0, offset);
984
+ const after = content.slice(offset);
985
+
986
+ textNode.textContent = before;
987
+
988
+ const afterNode = after.length ? document.createTextNode(after) : null;
989
+
990
+ if (afterNode) {
991
+ parent.insertBefore(afterNode, textNode.nextSibling);
992
+ }
993
+
994
+ parent.insertBefore(strong, afterNode ?? textNode.nextSibling);
995
+ strong.setAttribute(BoldInlineTool.DATA_ATTR_PREV_LENGTH, before.length.toString());
996
+
997
+ return true;
998
+ }
999
+
1000
+ /**
1001
+ * Insert a collapsed bold wrapper directly into an element container
1002
+ * @param element - Container element
1003
+ * @param strong - Strong element to insert
1004
+ * @param offset - Index at which to insert the strong element
1005
+ */
1006
+ private insertCollapsedBoldIntoElement(element: Element, strong: HTMLElement, offset: number): void {
1007
+ const referenceNode = element.childNodes[offset] ?? null;
1008
+
1009
+ element.insertBefore(strong, referenceNode);
1010
+ strong.setAttribute(BoldInlineTool.DATA_ATTR_PREV_LENGTH, '0');
1011
+ }
1012
+
1013
+ /**
1014
+ * Check if an element is empty (has no text content)
1015
+ * @param element - The element to check
1016
+ */
1017
+ private static isElementEmpty(element: HTMLElement): boolean {
1018
+ return (element.textContent ?? '').length === 0;
1019
+ }
1020
+
1021
+ /**
1022
+ *
1023
+ */
1024
+ private notifySelectionChange(): void {
1025
+ BoldInlineTool.enforceCollapsedBoldLengths(window.getSelection());
1026
+ document.dispatchEvent(new Event('selectionchange'));
1027
+ this.updateToolbarButtonState();
1028
+ }
1029
+
1030
+ /**
1031
+ * Ensure inline toolbar button reflects the actual bold state after programmatic toggles
1032
+ */
1033
+ private updateToolbarButtonState(): void {
1034
+ const selection = window.getSelection();
1035
+
1036
+ if (!selection) {
1037
+ return;
1038
+ }
1039
+
1040
+ const anchor = selection.anchorNode;
1041
+ const anchorElement = anchor?.nodeType === Node.ELEMENT_NODE ? anchor as Element : anchor?.parentElement;
1042
+ const blokWrapper = anchorElement?.closest(createSelector(DATA_ATTR.editor));
1043
+
1044
+ if (!blokWrapper) {
1045
+ return;
1046
+ }
1047
+
1048
+ const toolbar = blokWrapper.querySelector('[data-blok-testid=inline-toolbar]');
1049
+ if (!(toolbar instanceof HTMLElement)) {
1050
+ return;
1051
+ }
1052
+
1053
+ const button = toolbar.querySelector('[data-blok-item-name="bold"]');
1054
+
1055
+ if (!(button instanceof HTMLElement)) {
1056
+ return;
1057
+ }
1058
+
1059
+ const isActive = this.isSelectionVisuallyBold(selection);
1060
+
1061
+ if (isActive) {
1062
+ button.setAttribute('data-blok-popover-item-active', 'true');
1063
+ } else {
1064
+ button.removeAttribute('data-blok-popover-item-active');
1065
+ }
1066
+ }
1067
+
1068
+ /**
1069
+ * Normalize whitespace around a bold element
1070
+ * @param element - The bold element to normalize whitespace around
1071
+ */
1072
+ private normalizeWhitespaceAround(element: HTMLElement): void {
1073
+ BoldInlineTool.replaceNbspWithSpace(element.previousSibling);
1074
+ BoldInlineTool.replaceNbspWithSpace(element.nextSibling);
1075
+ }
1076
+
1077
+ /**
1078
+ * Replace non-breaking spaces with regular spaces in a text node
1079
+ * @param node - The text node to process
1080
+ */
1081
+ private static replaceNbspWithSpace(node: Node | null): void {
1082
+ if (!node || node.nodeType !== Node.TEXT_NODE) {
1083
+ return;
1084
+ }
1085
+
1086
+ const textNode = node as Text;
1087
+ const text = textNode.textContent ?? '';
1088
+
1089
+ if (!text.includes('\u00A0')) {
1090
+ return;
1091
+ }
1092
+
1093
+ textNode.textContent = text.replace(/\u00A0/g, ' ');
1094
+ }
1095
+
1096
+ /**
1097
+ * Restore a selection range from marker elements
1098
+ * @param markerId - The ID of the markers used to mark the selection
1099
+ */
1100
+ private restoreSelectionFromMarkers(markerId: string): Range | undefined {
1101
+ const startMarker = document.querySelector(`[data-blok-bold-marker="${markerId}-start"]`);
1102
+ const endMarker = document.querySelector(`[data-blok-bold-marker="${markerId}-end"]`);
1103
+
1104
+ if (!startMarker || !endMarker) {
1105
+ startMarker?.remove();
1106
+ endMarker?.remove();
1107
+
1108
+ return;
1109
+ }
1110
+
1111
+ const selection = window.getSelection();
1112
+
1113
+ if (!selection) {
1114
+ startMarker.remove();
1115
+ endMarker.remove();
1116
+
1117
+ return;
1118
+ }
1119
+
1120
+ const newRange = document.createRange();
1121
+
1122
+ newRange.setStartAfter(startMarker);
1123
+ newRange.setEndBefore(endMarker);
1124
+
1125
+ selection.removeAllRanges();
1126
+ selection.addRange(newRange);
1127
+
1128
+ startMarker.remove();
1129
+ endMarker.remove();
1130
+
1131
+ return newRange;
1132
+ }
1133
+
1134
+ /**
1135
+ * Replace non-breaking spaces with regular spaces within a range
1136
+ * @param range - The range to process
1137
+ */
1138
+ private replaceNbspWithinRange(range?: Range): void {
1139
+ if (!range) {
1140
+ return;
1141
+ }
1142
+
1143
+ const walker = document.createTreeWalker(
1144
+ range.commonAncestorContainer,
1145
+ NodeFilter.SHOW_TEXT,
1146
+ {
1147
+ acceptNode: (node) => {
1148
+ try {
1149
+ return range.intersectsNode(node) ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT;
1150
+ } catch (_error) {
1151
+ const nodeRange = document.createRange();
1152
+
1153
+ nodeRange.selectNodeContents(node);
1154
+
1155
+ const startsBeforeEnd = range.compareBoundaryPoints(Range.END_TO_START, nodeRange) > 0;
1156
+ const endsAfterStart = range.compareBoundaryPoints(Range.START_TO_END, nodeRange) < 0;
1157
+
1158
+ return (startsBeforeEnd && endsAfterStart) ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT;
1159
+ }
1160
+ },
1161
+ }
1162
+ );
1163
+
1164
+ while (walker.nextNode()) {
1165
+ BoldInlineTool.replaceNbspWithSpace(walker.currentNode);
1166
+ }
1167
+ }
1168
+
1169
+ /**
1170
+ * Normalize all bold tags within the blok to <strong> tags
1171
+ * Converts any legacy <b> tags to <strong> tags
1172
+ * @param selection - The current selection to determine the blok context
1173
+ */
1174
+ private static normalizeBoldTagsWithinBlok(selection: Selection | null): void {
1175
+ const node = selection?.anchorNode ?? selection?.focusNode;
1176
+
1177
+ if (!node) {
1178
+ return;
1179
+ }
1180
+
1181
+ const element = node.nodeType === Node.ELEMENT_NODE ? node as Element : node.parentElement;
1182
+ const root = element?.closest(createSelector(DATA_ATTR.editor));
1183
+
1184
+ if (!root) {
1185
+ return;
1186
+ }
1187
+
1188
+ // Convert any legacy <b> tags to <strong> tags
1189
+ root.querySelectorAll('b').forEach((boldNode) => {
1190
+ BoldInlineTool.ensureStrongElement(boldNode as HTMLElement);
1191
+ });
1192
+ }
1193
+
1194
+ /**
1195
+ * Replace non-breaking spaces with regular spaces in the block containing the selection
1196
+ * @param selection - The current selection to determine the block context
1197
+ */
1198
+ private static replaceNbspInBlock(selection: Selection | null): void {
1199
+ const node = selection?.anchorNode ?? selection?.focusNode;
1200
+
1201
+ if (!node) {
1202
+ return;
1203
+ }
1204
+
1205
+ const element = node.nodeType === Node.ELEMENT_NODE ? node as Element : node.parentElement;
1206
+ const block = element?.closest('[data-blok-component="paragraph"]');
1207
+
1208
+ if (!block) {
1209
+ return;
1210
+ }
1211
+
1212
+ const walker = document.createTreeWalker(block, NodeFilter.SHOW_TEXT);
1213
+
1214
+ while (walker.nextNode()) {
1215
+ BoldInlineTool.replaceNbspWithSpace(walker.currentNode);
1216
+ }
1217
+
1218
+ block.querySelectorAll('b').forEach((boldNode) => {
1219
+ BoldInlineTool.ensureStrongElement(boldNode as HTMLElement);
1220
+ });
1221
+ }
1222
+
1223
+ /**
1224
+ * Remove empty bold elements within the current block
1225
+ * @param selection - The current selection to determine the block context
1226
+ */
1227
+ private static removeEmptyBoldElements(selection: Selection | null): void {
1228
+ const node = selection?.anchorNode ?? selection?.focusNode;
1229
+
1230
+ if (!node) {
1231
+ return;
1232
+ }
1233
+
1234
+ const element = node.nodeType === Node.ELEMENT_NODE ? node as Element : node.parentElement;
1235
+ const block = element?.closest('[data-blok-component="paragraph"]');
1236
+
1237
+ if (!block) {
1238
+ return;
1239
+ }
1240
+
1241
+ const focusNode = selection?.focusNode ?? null;
1242
+
1243
+ block.querySelectorAll('strong').forEach((strong) => {
1244
+ const isCollapsedPlaceholder = strong.getAttribute(BoldInlineTool.DATA_ATTR_COLLAPSED_ACTIVE) === 'true';
1245
+ const hasTrackedLength = strong.hasAttribute(BoldInlineTool.DATA_ATTR_COLLAPSED_LENGTH);
1246
+
1247
+ if (isCollapsedPlaceholder || hasTrackedLength) {
1248
+ return;
1249
+ }
1250
+
1251
+ if ((strong.textContent ?? '').length === 0 && !BoldInlineTool.isNodeWithin(focusNode, strong)) {
1252
+ strong.remove();
1253
+ }
1254
+ });
1255
+ }
1256
+
1257
+ /**
1258
+ * Ensure collapsed bold placeholders absorb newly typed text
1259
+ * @param selection - The current selection to determine the blok context
1260
+ */
1261
+ private static synchronizeCollapsedBold(selection: Selection | null): void {
1262
+ const node = selection?.anchorNode ?? selection?.focusNode;
1263
+ const element = node && node.nodeType === Node.ELEMENT_NODE ? node as Element : node?.parentElement;
1264
+ const root = element?.closest(createSelector(DATA_ATTR.editor)) ?? element?.ownerDocument;
1265
+
1266
+ if (!root) {
1267
+ return;
1268
+ }
1269
+
1270
+ const selector = `strong[${BoldInlineTool.DATA_ATTR_COLLAPSED_ACTIVE}="true"]`;
1271
+
1272
+ root.querySelectorAll<HTMLElement>(selector).forEach((boldElement) => {
1273
+ const prevLengthAttr = boldElement.getAttribute(BoldInlineTool.DATA_ATTR_PREV_LENGTH);
1274
+ const prevNode = boldElement.previousSibling;
1275
+
1276
+ if (!prevLengthAttr || !prevNode || prevNode.nodeType !== Node.TEXT_NODE) {
1277
+ return;
1278
+ }
1279
+
1280
+ const prevLength = Number(prevLengthAttr);
1281
+
1282
+ if (!Number.isFinite(prevLength)) {
1283
+ return;
1284
+ }
1285
+
1286
+ const prevTextNode = prevNode as Text;
1287
+ const prevText = prevTextNode.textContent ?? '';
1288
+
1289
+ if (prevText.length <= prevLength) {
1290
+ return;
1291
+ }
1292
+
1293
+ const preserved = prevText.slice(0, prevLength);
1294
+ const extra = prevText.slice(prevLength);
1295
+
1296
+ prevTextNode.textContent = preserved;
1297
+
1298
+ const leadingMatch = extra.match(/^[\u00A0\s]+/);
1299
+
1300
+ if (leadingMatch && !boldElement.hasAttribute(BoldInlineTool.DATA_ATTR_LEADING_WHITESPACE)) {
1301
+ boldElement.setAttribute(BoldInlineTool.DATA_ATTR_LEADING_WHITESPACE, leadingMatch[0]);
1302
+ }
1303
+
1304
+ if (extra.length === 0) {
1305
+ return;
1306
+ }
1307
+
1308
+ const existingContent = boldElement.textContent ?? '';
1309
+ const newContent = existingContent + extra;
1310
+ const storedLeading = boldElement.getAttribute(BoldInlineTool.DATA_ATTR_LEADING_WHITESPACE) ?? '';
1311
+ const shouldPrefixLeading = storedLeading.length > 0 && existingContent.length === 0 && !newContent.startsWith(storedLeading);
1312
+ const adjustedContent = shouldPrefixLeading ? storedLeading + newContent : newContent;
1313
+ const updatedTextNode = document.createTextNode(adjustedContent);
1314
+
1315
+ while (boldElement.firstChild) {
1316
+ boldElement.removeChild(boldElement.firstChild);
1317
+ }
1318
+
1319
+ boldElement.appendChild(updatedTextNode);
1320
+
1321
+ if (!selection?.isCollapsed || !BoldInlineTool.isNodeWithin(selection.focusNode, prevTextNode)) {
1322
+ return;
1323
+ }
1324
+
1325
+ const newRange = document.createRange();
1326
+ const caretOffset = updatedTextNode.textContent?.length ?? 0;
1327
+
1328
+ newRange.setStart(updatedTextNode, caretOffset);
1329
+ newRange.collapse(true);
1330
+
1331
+ selection.removeAllRanges();
1332
+ selection.addRange(newRange);
1333
+ });
1334
+ }
1335
+
1336
+ /**
1337
+ * Ensure caret is positioned after boundary bold elements when toggling collapsed selections
1338
+ * @param selection - Current selection
1339
+ */
1340
+ private static moveCaretAfterBoundaryBold(selection: Selection): void {
1341
+ if (!selection.rangeCount) {
1342
+ return;
1343
+ }
1344
+
1345
+ const range = selection.getRangeAt(0);
1346
+
1347
+ if (!range.collapsed) {
1348
+ return;
1349
+ }
1350
+
1351
+ const activePlaceholder = BoldInlineTool.findBoldElement(range.startContainer);
1352
+
1353
+ if (activePlaceholder?.getAttribute(BoldInlineTool.DATA_ATTR_COLLAPSED_ACTIVE) === 'true') {
1354
+ return;
1355
+ }
1356
+
1357
+ if (BoldInlineTool.moveCaretFromElementContainer(selection, range)) {
1358
+ return;
1359
+ }
1360
+
1361
+ BoldInlineTool.moveCaretFromTextContainer(selection, range);
1362
+ }
1363
+
1364
+ /**
1365
+ * Locate a bold element adjacent to a collapsed range
1366
+ * @param range - Range to inspect
1367
+ */
1368
+ private static getAdjacentBold(range: Range): HTMLElement | null {
1369
+ const container = range.startContainer;
1370
+
1371
+ if (container.nodeType === Node.TEXT_NODE) {
1372
+ return BoldInlineTool.getBoldAdjacentToText(range, container as Text);
1373
+ }
1374
+
1375
+ if (container.nodeType === Node.ELEMENT_NODE) {
1376
+ return BoldInlineTool.getBoldAdjacentToElement(range, container as Element);
1377
+ }
1378
+
1379
+ return null;
1380
+ }
1381
+
1382
+ /**
1383
+ * Get bold element adjacent to a text node container
1384
+ * @param range - Current collapsed range
1385
+ * @param textNode - Text node hosting the caret
1386
+ */
1387
+ private static getBoldAdjacentToText(range: Range, textNode: Text): HTMLElement | null {
1388
+ const textLength = textNode.textContent?.length ?? 0;
1389
+ const previous = textNode.previousSibling;
1390
+
1391
+ if (range.startOffset === 0 && BoldInlineTool.isBoldElement(previous)) {
1392
+ return previous as HTMLElement;
1393
+ }
1394
+
1395
+ if (range.startOffset !== textLength) {
1396
+ return null;
1397
+ }
1398
+
1399
+ const next = textNode.nextSibling;
1400
+
1401
+ return BoldInlineTool.isBoldElement(next) ? next as HTMLElement : null;
1402
+ }
1403
+
1404
+ /**
1405
+ * Get bold element adjacent to an element container
1406
+ * @param range - Current collapsed range
1407
+ * @param element - Element containing the caret
1408
+ */
1409
+ private static getBoldAdjacentToElement(range: Range, element: Element): HTMLElement | null {
1410
+ const previous = range.startOffset > 0 ? element.childNodes[range.startOffset - 1] ?? null : null;
1411
+
1412
+ if (BoldInlineTool.isBoldElement(previous)) {
1413
+ return previous as HTMLElement;
1414
+ }
1415
+
1416
+ const next = element.childNodes[range.startOffset] ?? null;
1417
+
1418
+ return BoldInlineTool.isBoldElement(next) ? next as HTMLElement : null;
1419
+ }
1420
+
1421
+ /**
1422
+ * Exit collapsed bold state when caret no longer resides within bold content
1423
+ * @param selection - Current selection
1424
+ * @param range - Collapsed range after toggling bold
1425
+ */
1426
+ private static exitCollapsedIfNeeded(selection: Selection, range: Range): void {
1427
+ const insideBold = Boolean(BoldInlineTool.findBoldElement(range.startContainer));
1428
+
1429
+ if (insideBold) {
1430
+ return;
1431
+ }
1432
+
1433
+ const boundaryBold = BoldInlineTool.getBoundaryBold(range) ?? BoldInlineTool.getAdjacentBold(range);
1434
+
1435
+ if (!boundaryBold) {
1436
+ return;
1437
+ }
1438
+
1439
+ const caretRange = BoldInlineTool.exitCollapsedBold(selection, boundaryBold);
1440
+
1441
+ if (!caretRange) {
1442
+ return;
1443
+ }
1444
+
1445
+ selection.removeAllRanges();
1446
+ selection.addRange(caretRange);
1447
+ }
1448
+
1449
+ /**
1450
+ * Adjust caret when selection container is an element adjacent to bold content
1451
+ * @param selection - Current selection
1452
+ * @param range - Collapsed range to inspect
1453
+ * @returns true when caret position was updated
1454
+ */
1455
+ private static moveCaretFromElementContainer(selection: Selection, range: Range): boolean {
1456
+ if (range.startContainer.nodeType !== Node.ELEMENT_NODE) {
1457
+ return false;
1458
+ }
1459
+
1460
+ const element = range.startContainer as Element;
1461
+ const movedAfterPrevious = BoldInlineTool.moveCaretAfterPreviousBold(selection, element, range.startOffset);
1462
+
1463
+ if (movedAfterPrevious) {
1464
+ return true;
1465
+ }
1466
+
1467
+ return BoldInlineTool.moveCaretBeforeNextBold(selection, element, range.startOffset);
1468
+ }
1469
+
1470
+ /**
1471
+ * Move caret after the bold node that precedes the caret when possible
1472
+ * @param selection - Current selection
1473
+ * @param element - Container element
1474
+ * @param offset - Caret offset within the container
1475
+ */
1476
+ private static moveCaretAfterPreviousBold(selection: Selection, element: Element, offset: number): boolean {
1477
+ const beforeNode = offset > 0 ? element.childNodes[offset - 1] ?? null : null;
1478
+
1479
+ if (!BoldInlineTool.isBoldElement(beforeNode)) {
1480
+ return false;
1481
+ }
1482
+
1483
+ const textNode = BoldInlineTool.ensureFollowingTextNode(beforeNode as Element, beforeNode.nextSibling);
1484
+
1485
+ if (!textNode) {
1486
+ return false;
1487
+ }
1488
+
1489
+ const textOffset = textNode.textContent?.length ?? 0;
1490
+
1491
+ BoldInlineTool.setCaret(selection, textNode, textOffset);
1492
+
1493
+ return true;
1494
+ }
1495
+
1496
+ /**
1497
+ * Move caret before the bold node that follows the caret, ensuring there's a text node to receive input
1498
+ * @param selection - Current selection
1499
+ * @param element - Container element
1500
+ * @param offset - Caret offset within the container
1501
+ */
1502
+ private static moveCaretBeforeNextBold(selection: Selection, element: Element, offset: number): boolean {
1503
+ const nextNode = element.childNodes[offset] ?? null;
1504
+
1505
+ if (!BoldInlineTool.isBoldElement(nextNode)) {
1506
+ return false;
1507
+ }
1508
+
1509
+ const textNode = BoldInlineTool.ensureFollowingTextNode(nextNode as Element, nextNode.nextSibling);
1510
+
1511
+ if (!textNode) {
1512
+ BoldInlineTool.setCaretAfterNode(selection, nextNode);
1513
+
1514
+ return true;
1515
+ }
1516
+
1517
+ BoldInlineTool.setCaret(selection, textNode, 0);
1518
+
1519
+ return true;
1520
+ }
1521
+
1522
+ /**
1523
+ * Adjust caret when selection container is a text node adjacent to bold content
1524
+ * @param selection - Current selection
1525
+ * @param range - Collapsed range to inspect
1526
+ */
1527
+ private static moveCaretFromTextContainer(selection: Selection, range: Range): void {
1528
+ if (range.startContainer.nodeType !== Node.TEXT_NODE) {
1529
+ return;
1530
+ }
1531
+
1532
+ const textNode = range.startContainer as Text;
1533
+ const previousSibling = textNode.previousSibling;
1534
+ const textContent = textNode.textContent ?? '';
1535
+ const startsWithWhitespace = /^\s/.test(textContent);
1536
+
1537
+ if (
1538
+ range.startOffset === 0 &&
1539
+ BoldInlineTool.isBoldElement(previousSibling) &&
1540
+ (textContent.length === 0 || startsWithWhitespace)
1541
+ ) {
1542
+ BoldInlineTool.setCaret(selection, textNode, textContent.length);
1543
+
1544
+ return;
1545
+ }
1546
+
1547
+ const boldElement = BoldInlineTool.findBoldElement(textNode);
1548
+
1549
+ if (!boldElement || range.startOffset !== (textNode.textContent?.length ?? 0)) {
1550
+ return;
1551
+ }
1552
+
1553
+ const textNodeAfter = BoldInlineTool.ensureFollowingTextNode(boldElement, boldElement.nextSibling);
1554
+
1555
+ if (textNodeAfter) {
1556
+ BoldInlineTool.setCaret(selection, textNodeAfter, 0);
1557
+
1558
+ return;
1559
+ }
1560
+
1561
+ BoldInlineTool.setCaretAfterNode(selection, boldElement);
1562
+ }
1563
+
1564
+ /**
1565
+ * Ensure caret is positioned at the end of a collapsed boundary text node before the browser processes a printable keydown
1566
+ * @param event - Keydown event fired before browser input handling
1567
+ */
1568
+ private static guardCollapsedBoundaryKeydown(event: KeyboardEvent): void {
1569
+ if (event.defaultPrevented || event.metaKey || event.ctrlKey || event.altKey) {
1570
+ return;
1571
+ }
1572
+
1573
+ const key = event.key;
1574
+
1575
+ if (key.length !== 1) {
1576
+ return;
1577
+ }
1578
+
1579
+ const selection = window.getSelection();
1580
+
1581
+ if (!selection || !selection.isCollapsed || selection.rangeCount === 0) {
1582
+ return;
1583
+ }
1584
+
1585
+ const range = selection.getRangeAt(0);
1586
+
1587
+ if (range.startContainer.nodeType !== Node.TEXT_NODE) {
1588
+ return;
1589
+ }
1590
+
1591
+ const textNode = range.startContainer as Text;
1592
+ const textContent = textNode.textContent ?? '';
1593
+
1594
+ if (textContent.length === 0 || range.startOffset !== 0) {
1595
+ return;
1596
+ }
1597
+
1598
+ const previousSibling = textNode.previousSibling;
1599
+
1600
+ if (!BoldInlineTool.isBoldElement(previousSibling)) {
1601
+ return;
1602
+ }
1603
+
1604
+ if (!/^\s/.test(textContent)) {
1605
+ return;
1606
+ }
1607
+
1608
+ BoldInlineTool.setCaret(selection, textNode, textContent.length);
1609
+ }
1610
+
1611
+ /**
1612
+ * Determine whether a node is a bold element (<strong>/<b>)
1613
+ * @param node - Node to inspect
1614
+ */
1615
+ private static isBoldElement(node: Node | null): node is Element {
1616
+ return Boolean(node && node.nodeType === Node.ELEMENT_NODE && BoldInlineTool.isBoldTag(node as Element));
1617
+ }
1618
+
1619
+ /**
1620
+ * Place caret at the provided offset within a text node
1621
+ * @param selection - Current selection
1622
+ * @param node - Target text node
1623
+ * @param offset - Offset within the text node
1624
+ */
1625
+ private static setCaret(selection: Selection, node: Text, offset: number): void {
1626
+ const newRange = document.createRange();
1627
+
1628
+ newRange.setStart(node, offset);
1629
+ newRange.collapse(true);
1630
+
1631
+ selection.removeAllRanges();
1632
+ selection.addRange(newRange);
1633
+ }
1634
+
1635
+ /**
1636
+ * Position caret immediately after the provided node
1637
+ * @param selection - Current selection
1638
+ * @param node - Reference node
1639
+ */
1640
+ private static setCaretAfterNode(selection: Selection, node: Node | null): void {
1641
+ if (!node) {
1642
+ return;
1643
+ }
1644
+
1645
+ const newRange = document.createRange();
1646
+
1647
+ newRange.setStartAfter(node);
1648
+ newRange.collapse(true);
1649
+
1650
+ selection.removeAllRanges();
1651
+ selection.addRange(newRange);
1652
+ }
1653
+
1654
+ /**
1655
+ * Ensure there is a text node immediately following a bold element to accept new input
1656
+ * @param boldElement - Bold element after which text should be inserted
1657
+ * @param referenceNode - Node that currently follows the bold element
1658
+ */
1659
+ private static ensureFollowingTextNode(boldElement: Element, referenceNode: Node | null): Text | null {
1660
+ const parent = boldElement.parentNode;
1661
+
1662
+ if (!parent) {
1663
+ return null;
1664
+ }
1665
+
1666
+ if (referenceNode && referenceNode.nodeType === Node.TEXT_NODE) {
1667
+ return referenceNode as Text;
1668
+ }
1669
+
1670
+ const textNode = document.createTextNode('');
1671
+
1672
+ parent.insertBefore(textNode, referenceNode);
1673
+
1674
+ return textNode;
1675
+ }
1676
+
1677
+ /**
1678
+ * Enforce length limits on collapsed bold elements
1679
+ * @param selection - The current selection to determine the blok context
1680
+ */
1681
+ private static enforceCollapsedBoldLengths(selection: Selection | null): void {
1682
+ const node = selection?.anchorNode ?? selection?.focusNode;
1683
+
1684
+ if (!node) {
1685
+ return;
1686
+ }
1687
+
1688
+ const element = node.nodeType === Node.ELEMENT_NODE ? node as Element : node.parentElement;
1689
+ const root = element?.closest(createSelector(DATA_ATTR.editor));
1690
+
1691
+ if (!root) {
1692
+ return;
1693
+ }
1694
+
1695
+ const tracked = root.querySelectorAll<HTMLElement>(`strong[${BoldInlineTool.DATA_ATTR_COLLAPSED_LENGTH}]`);
1696
+
1697
+ tracked.forEach((boldElement) => {
1698
+ const boldEl = boldElement;
1699
+ const lengthAttr = boldEl.getAttribute(BoldInlineTool.DATA_ATTR_COLLAPSED_LENGTH);
1700
+
1701
+ if (!lengthAttr) {
1702
+ return;
1703
+ }
1704
+
1705
+ const allowedLength = Number(lengthAttr);
1706
+ const currentText = boldEl.textContent ?? '';
1707
+
1708
+ if (!Number.isFinite(allowedLength)) {
1709
+ return;
1710
+ }
1711
+
1712
+ const shouldRemoveCurrentLength = currentText.length > allowedLength;
1713
+ const newTextNodeAfterSplit = shouldRemoveCurrentLength
1714
+ ? BoldInlineTool.splitCollapsedBoldText(boldEl, allowedLength, currentText)
1715
+ : null;
1716
+
1717
+ const prevLengthAttr = boldEl.getAttribute(BoldInlineTool.DATA_ATTR_PREV_LENGTH);
1718
+ const prevLength = prevLengthAttr ? Number(prevLengthAttr) : NaN;
1719
+ const prevNode = boldEl.previousSibling;
1720
+ const previousTextNode = prevNode?.nodeType === Node.TEXT_NODE ? prevNode as Text : null;
1721
+ const prevText = previousTextNode?.textContent ?? '';
1722
+ const shouldRemovePrevLength = Boolean(
1723
+ prevLengthAttr &&
1724
+ Number.isFinite(prevLength) &&
1725
+ previousTextNode &&
1726
+ prevText.length > prevLength
1727
+ );
1728
+
1729
+ if (shouldRemovePrevLength && previousTextNode) {
1730
+ const preservedPrev = prevText.slice(0, prevLength);
1731
+ const extraPrev = prevText.slice(prevLength);
1732
+
1733
+ previousTextNode.textContent = preservedPrev;
1734
+ const extraNode = document.createTextNode(extraPrev);
1735
+
1736
+ boldEl.parentNode?.insertBefore(extraNode, boldEl.nextSibling);
1737
+ }
1738
+
1739
+ if (shouldRemovePrevLength) {
1740
+ boldEl.removeAttribute(BoldInlineTool.DATA_ATTR_PREV_LENGTH);
1741
+ }
1742
+
1743
+ if (selection?.isCollapsed && newTextNodeAfterSplit && BoldInlineTool.isNodeWithin(selection.focusNode, boldEl)) {
1744
+ const caretRange = document.createRange();
1745
+ const caretOffset = newTextNodeAfterSplit.textContent?.length ?? 0;
1746
+
1747
+ caretRange.setStart(newTextNodeAfterSplit, caretOffset);
1748
+ caretRange.collapse(true);
1749
+
1750
+ selection.removeAllRanges();
1751
+ selection.addRange(caretRange);
1752
+ }
1753
+
1754
+ if (shouldRemoveCurrentLength) {
1755
+ boldEl.removeAttribute(BoldInlineTool.DATA_ATTR_COLLAPSED_LENGTH);
1756
+ }
1757
+ });
1758
+ }
1759
+
1760
+ /**
1761
+ * Split text content exceeding the allowed collapsed bold length and move the excess outside
1762
+ * @param boldEl - Bold element hosting the collapsed selection
1763
+ * @param allowedLength - Maximum allowed length for the collapsed bold
1764
+ * @param currentText - Current text content inside the bold element
1765
+ */
1766
+ private static splitCollapsedBoldText(boldEl: HTMLElement, allowedLength: number, currentText: string): Text | null {
1767
+ const targetBoldElement = boldEl;
1768
+ const parent = targetBoldElement.parentNode;
1769
+
1770
+ if (!parent) {
1771
+ return null;
1772
+ }
1773
+
1774
+ const preserved = currentText.slice(0, allowedLength);
1775
+ const extra = currentText.slice(allowedLength);
1776
+
1777
+ targetBoldElement.textContent = preserved;
1778
+
1779
+ const textNode = document.createTextNode(extra);
1780
+
1781
+ parent.insertBefore(textNode, targetBoldElement.nextSibling);
1782
+
1783
+ return textNode;
1784
+ }
1785
+
1786
+ /**
1787
+ * Check if a node is within the provided container
1788
+ * @param target - Node to test
1789
+ * @param container - Potential ancestor container
1790
+ */
1791
+ private static isNodeWithin(target: Node | null, container: Node): boolean {
1792
+ if (!target) {
1793
+ return false;
1794
+ }
1795
+
1796
+ return target === container || container.contains(target);
1797
+ }
1798
+
1799
+ /**
1800
+ *
1801
+ */
1802
+ private static handleGlobalSelectionChange(): void {
1803
+ BoldInlineTool.refreshSelectionState('selectionchange');
1804
+ }
1805
+
1806
+ /**
1807
+ *
1808
+ */
1809
+ private static handleGlobalInput(): void {
1810
+ BoldInlineTool.refreshSelectionState('input');
1811
+ }
1812
+
1813
+ /**
1814
+ * Normalize selection state after blok input or selection updates
1815
+ * @param source - The event source triggering the refresh
1816
+ */
1817
+ private static refreshSelectionState(source: 'selectionchange' | 'input'): void {
1818
+ const selection = window.getSelection();
1819
+
1820
+ BoldInlineTool.enforceCollapsedBoldLengths(selection);
1821
+ BoldInlineTool.maintainCollapsedExitState();
1822
+ BoldInlineTool.synchronizeCollapsedBold(selection);
1823
+ BoldInlineTool.normalizeBoldTagsWithinBlok(selection);
1824
+ BoldInlineTool.removeEmptyBoldElements(selection);
1825
+
1826
+ if (source === 'input' && selection) {
1827
+ BoldInlineTool.moveCaretAfterBoundaryBold(selection);
1828
+ }
1829
+
1830
+ BoldInlineTool.normalizeAllBoldTags();
1831
+ }
1832
+
1833
+ /**
1834
+ * Ensure mutation observer is registered to convert legacy <b> tags
1835
+ */
1836
+ private static ensureMutationObserver(): void {
1837
+ if (typeof MutationObserver === 'undefined') {
1838
+ return;
1839
+ }
1840
+
1841
+ if (BoldInlineTool.mutationObserver) {
1842
+ return;
1843
+ }
1844
+
1845
+ const observer = new MutationObserver((mutations) => {
1846
+ if (BoldInlineTool.isProcessingMutation) {
1847
+ return;
1848
+ }
1849
+
1850
+ BoldInlineTool.isProcessingMutation = true;
1851
+
1852
+ try {
1853
+ mutations.forEach((mutation) => {
1854
+ mutation.addedNodes.forEach((node) => {
1855
+ BoldInlineTool.normalizeBoldInNode(node);
1856
+ });
1857
+
1858
+ if (mutation.type === 'characterData' && mutation.target) {
1859
+ BoldInlineTool.normalizeBoldInNode(mutation.target);
1860
+ }
1861
+ });
1862
+ } finally {
1863
+ BoldInlineTool.isProcessingMutation = false;
1864
+ }
1865
+ });
1866
+
1867
+ observer.observe(document.body, {
1868
+ subtree: true,
1869
+ childList: true,
1870
+ characterData: true,
1871
+ });
1872
+
1873
+ BoldInlineTool.mutationObserver = observer;
1874
+ }
1875
+
1876
+ /**
1877
+ * Prevent the browser's native bold command to avoid <b> wrappers
1878
+ * @param event - BeforeInput event fired by the browser
1879
+ */
1880
+ private static handleBeforeInput(event: InputEvent): void {
1881
+ if (event.inputType !== 'formatBold') {
1882
+ return;
1883
+ }
1884
+
1885
+ const selection = window.getSelection();
1886
+ const isSelectionInside = Boolean(selection && BoldInlineTool.isSelectionInsideBlok(selection));
1887
+ const isTargetInside = BoldInlineTool.isEventTargetInsideBlok(event.target);
1888
+
1889
+ if (!isSelectionInside && !isTargetInside) {
1890
+ return;
1891
+ }
1892
+
1893
+ event.preventDefault();
1894
+ event.stopPropagation();
1895
+ event.stopImmediatePropagation();
1896
+
1897
+ BoldInlineTool.normalizeAllBoldTags();
1898
+ }
1899
+
1900
+ /**
1901
+ * Attempt to toggle bold via the browser's native command
1902
+ * @param selection - Current selection
1903
+ */
1904
+ /**
1905
+ * Exit a collapsed bold selection by moving the caret outside the bold element
1906
+ * @param selection - The current selection
1907
+ * @param boldElement - The bold element to exit from
1908
+ */
1909
+ private static exitCollapsedBold(selection: Selection, boldElement: HTMLElement): Range | undefined {
1910
+ const normalizedBold = BoldInlineTool.ensureStrongElement(boldElement);
1911
+ const parent = normalizedBold.parentNode;
1912
+
1913
+ if (!parent) {
1914
+ return;
1915
+ }
1916
+
1917
+ if (BoldInlineTool.isElementEmpty(normalizedBold)) {
1918
+ return BoldInlineTool.removeEmptyBoldElement(selection, normalizedBold, parent);
1919
+ }
1920
+
1921
+ return BoldInlineTool.exitCollapsedBoldWithContent(selection, normalizedBold, parent);
1922
+ }
1923
+
1924
+ /**
1925
+ * Remove an empty bold element and place the caret before its position
1926
+ * @param selection - Current selection
1927
+ * @param boldElement - Bold element to remove
1928
+ * @param parent - Parent node that hosts the bold element
1929
+ */
1930
+ private static removeEmptyBoldElement(selection: Selection, boldElement: HTMLElement, parent: ParentNode): Range {
1931
+ const newRange = document.createRange();
1932
+
1933
+ newRange.setStartBefore(boldElement);
1934
+ newRange.collapse(true);
1935
+
1936
+ parent.removeChild(boldElement);
1937
+
1938
+ selection.removeAllRanges();
1939
+ selection.addRange(newRange);
1940
+
1941
+ return newRange;
1942
+ }
1943
+
1944
+ /**
1945
+ * Exit a collapsed bold state when the bold element still contains text
1946
+ * @param selection - Current selection
1947
+ * @param boldElement - Bold element currently wrapping the caret
1948
+ * @param parent - Parent node that hosts the bold element
1949
+ */
1950
+ private static exitCollapsedBoldWithContent(selection: Selection, boldElement: HTMLElement, parent: ParentNode): Range {
1951
+ boldElement.setAttribute(BoldInlineTool.DATA_ATTR_COLLAPSED_LENGTH, (boldElement.textContent?.length ?? 0).toString());
1952
+ boldElement.removeAttribute(BoldInlineTool.DATA_ATTR_PREV_LENGTH);
1953
+ boldElement.removeAttribute(BoldInlineTool.DATA_ATTR_COLLAPSED_ACTIVE);
1954
+ boldElement.removeAttribute(BoldInlineTool.DATA_ATTR_LEADING_WHITESPACE);
1955
+
1956
+ const initialNextSibling = boldElement.nextSibling;
1957
+ const needsNewNode = !initialNextSibling || initialNextSibling.nodeType !== Node.TEXT_NODE;
1958
+ const newNode = needsNewNode ? document.createTextNode('\u200B') : null;
1959
+
1960
+ if (newNode) {
1961
+ parent.insertBefore(newNode, initialNextSibling);
1962
+ }
1963
+
1964
+ const boundary = (newNode ?? initialNextSibling) as Text;
1965
+
1966
+ if (!needsNewNode && (boundary.textContent ?? '').length === 0) {
1967
+ boundary.textContent = '\u200B';
1968
+ }
1969
+
1970
+ const newRange = document.createRange();
1971
+ const boundaryContent = boundary.textContent ?? '';
1972
+ const caretOffset = boundaryContent.startsWith('\u200B') ? 1 : 0;
1973
+
1974
+ newRange.setStart(boundary, caretOffset);
1975
+ newRange.collapse(true);
1976
+
1977
+ selection.removeAllRanges();
1978
+ selection.addRange(newRange);
1979
+
1980
+ const trackedBold = BoldInlineTool.ensureStrongElement(boldElement);
1981
+
1982
+ BoldInlineTool.collapsedExitRecords.add({
1983
+ boundary,
1984
+ boldElement: trackedBold,
1985
+ allowedLength: trackedBold.textContent?.length ?? 0,
1986
+ hasLeadingSpace: false,
1987
+ hasTypedContent: false,
1988
+ leadingWhitespace: '',
1989
+ });
1990
+
1991
+ return newRange;
1992
+ }
1993
+
1994
+ /**
1995
+ * Get a bold element at the boundary of a collapsed range
1996
+ * @param range - The collapsed range to check
1997
+ */
1998
+ private static getBoundaryBold(range: Range): HTMLElement | null {
1999
+ const container = range.startContainer;
2000
+
2001
+ if (container.nodeType === Node.TEXT_NODE) {
2002
+ return BoldInlineTool.getBoundaryBoldForText(range, container as Text);
2003
+ }
2004
+
2005
+ if (container.nodeType === Node.ELEMENT_NODE) {
2006
+ return BoldInlineTool.getBoundaryBoldForElement(range, container as Element);
2007
+ }
2008
+
2009
+ return null;
2010
+ }
2011
+
2012
+ /**
2013
+ * Get boundary bold when caret resides inside a text node
2014
+ * @param range - Collapsed range
2015
+ * @param textNode - Text container
2016
+ */
2017
+ private static getBoundaryBoldForText(range: Range, textNode: Text): HTMLElement | null {
2018
+ const length = textNode.textContent?.length ?? 0;
2019
+
2020
+ if (range.startOffset === length) {
2021
+ return BoldInlineTool.findBoldElement(textNode);
2022
+ }
2023
+
2024
+ if (range.startOffset !== 0) {
2025
+ return null;
2026
+ }
2027
+
2028
+ const previous = textNode.previousSibling;
2029
+
2030
+ return BoldInlineTool.isBoldElement(previous) ? previous as HTMLElement : null;
2031
+ }
2032
+
2033
+ /**
2034
+ * Get boundary bold when caret container is an element
2035
+ * @param range - Collapsed range
2036
+ * @param element - Element container
2037
+ */
2038
+ private static getBoundaryBoldForElement(range: Range, element: Element): HTMLElement | null {
2039
+ if (range.startOffset <= 0) {
2040
+ return null;
2041
+ }
2042
+
2043
+ const previous = element.childNodes[range.startOffset - 1];
2044
+
2045
+ return BoldInlineTool.isBoldElement(previous) ? previous as HTMLElement : null;
2046
+ }
2047
+
2048
+ /**
2049
+ * Handle keyboard shortcut for bold when selection is collapsed
2050
+ * @param event - The keyboard event
2051
+ */
2052
+ private static handleShortcut(event: KeyboardEvent): void {
2053
+ BoldInlineTool.guardCollapsedBoundaryKeydown(event);
2054
+
2055
+ if (!BoldInlineTool.isBoldShortcut(event)) {
2056
+ return;
2057
+ }
2058
+
2059
+ const selection = window.getSelection();
2060
+
2061
+ if (!selection || !selection.rangeCount || !BoldInlineTool.isSelectionInsideBlok(selection)) {
2062
+ return;
2063
+ }
2064
+
2065
+ const instance = BoldInlineTool.instances.values().next().value ?? new BoldInlineTool();
2066
+
2067
+ if (!instance) {
2068
+ return;
2069
+ }
2070
+
2071
+ event.preventDefault();
2072
+ event.stopPropagation();
2073
+ event.stopImmediatePropagation();
2074
+
2075
+ instance.toggleBold();
2076
+ }
2077
+
2078
+ /**
2079
+ * Check if a keyboard event is the bold shortcut (Cmd/Ctrl+B)
2080
+ * @param event - The keyboard event to check
2081
+ */
2082
+ private static isBoldShortcut(event: KeyboardEvent): boolean {
2083
+ const userAgent = typeof navigator !== 'undefined' ? navigator.userAgent.toLowerCase() : '';
2084
+ const isMac = userAgent.includes('mac');
2085
+ const primaryModifier = isMac ? event.metaKey : event.ctrlKey;
2086
+
2087
+ if (!primaryModifier || event.altKey) {
2088
+ return false;
2089
+ }
2090
+
2091
+ return event.key.toLowerCase() === 'b';
2092
+ }
2093
+
2094
+ /**
2095
+ * Check if a selection is inside the blok
2096
+ * @param selection - The selection to check
2097
+ */
2098
+ private static isSelectionInsideBlok(selection: Selection): boolean {
2099
+ const anchor = selection.anchorNode;
2100
+
2101
+ if (!anchor) {
2102
+ return false;
2103
+ }
2104
+
2105
+ const element = anchor.nodeType === Node.ELEMENT_NODE ? anchor as Element : anchor.parentElement;
2106
+
2107
+ return Boolean(element?.closest(createSelector(DATA_ATTR.editor)));
2108
+ }
2109
+
2110
+ /**
2111
+ * Check if an event target resides inside the blok wrapper
2112
+ * @param target - Event target to inspect
2113
+ */
2114
+ private static isEventTargetInsideBlok(target: EventTarget | null): boolean {
2115
+ if (!target || typeof Node === 'undefined') {
2116
+ return false;
2117
+ }
2118
+
2119
+ if (target instanceof Element) {
2120
+ return Boolean(target.closest(createSelector(DATA_ATTR.editor)));
2121
+ }
2122
+
2123
+ if (target instanceof Text) {
2124
+ return Boolean(target.parentElement?.closest(createSelector(DATA_ATTR.editor)));
2125
+ }
2126
+
2127
+ if (typeof ShadowRoot !== 'undefined' && target instanceof ShadowRoot) {
2128
+ return BoldInlineTool.isEventTargetInsideBlok(target.host);
2129
+ }
2130
+
2131
+ if (!(target instanceof Node)) {
2132
+ return false;
2133
+ }
2134
+
2135
+ const parentNode = target.parentNode;
2136
+
2137
+ if (!parentNode) {
2138
+ return false;
2139
+ }
2140
+
2141
+ if (parentNode instanceof Element) {
2142
+ return Boolean(parentNode.closest(createSelector(DATA_ATTR.editor)));
2143
+ }
2144
+
2145
+ return BoldInlineTool.isEventTargetInsideBlok(parentNode);
2146
+ }
2147
+
2148
+ /**
2149
+ * Get HTML content of a range with bold tags removed
2150
+ * @param range - The range to extract HTML from
2151
+ */
2152
+ private getRangeHtmlWithoutBold(range: Range): string {
2153
+ const contents = range.cloneContents();
2154
+
2155
+ this.removeNestedBold(contents);
2156
+
2157
+ const container = document.createElement('div');
2158
+
2159
+ container.appendChild(contents);
2160
+
2161
+ return container.innerHTML;
2162
+ }
2163
+
2164
+ /**
2165
+ * Convert an HTML snippet to a document fragment
2166
+ * @param html - HTML string to convert
2167
+ */
2168
+ private static createFragmentFromHtml(html: string): DocumentFragment {
2169
+ const template = document.createElement('template');
2170
+
2171
+ template.innerHTML = html;
2172
+
2173
+ return template.content;
2174
+ }
2175
+
2176
+ /**
2177
+ * Collect all bold ancestor elements within a range
2178
+ * @param range - The range to search for bold ancestors
2179
+ */
2180
+ private collectBoldAncestors(range: Range): HTMLElement[] {
2181
+ const ancestors = new Set<HTMLElement>();
2182
+ const walker = document.createTreeWalker(
2183
+ range.commonAncestorContainer,
2184
+ NodeFilter.SHOW_TEXT,
2185
+ {
2186
+ acceptNode: (node) => {
2187
+ try {
2188
+ return range.intersectsNode(node) ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT;
2189
+ } catch (_error) {
2190
+ const nodeRange = document.createRange();
2191
+
2192
+ nodeRange.selectNodeContents(node);
2193
+
2194
+ const startsBeforeEnd = range.compareBoundaryPoints(Range.END_TO_START, nodeRange) > 0;
2195
+ const endsAfterStart = range.compareBoundaryPoints(Range.START_TO_END, nodeRange) < 0;
2196
+
2197
+ return (startsBeforeEnd && endsAfterStart) ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT;
2198
+ }
2199
+ },
2200
+ }
2201
+ );
2202
+
2203
+ while (walker.nextNode()) {
2204
+ const boldElement = BoldInlineTool.findBoldElement(walker.currentNode);
2205
+
2206
+ if (boldElement) {
2207
+ ancestors.add(boldElement);
2208
+ }
2209
+ }
2210
+
2211
+ return Array.from(ancestors);
2212
+ }
2213
+ }