@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.
- package/README.md +136 -17
- package/codemod/README.md +16 -0
- package/codemod/migrate-editorjs-to-blok.js +868 -92
- package/codemod/test.js +682 -77
- package/dist/blok.mjs +5 -2
- package/dist/chunks/blok-B5qs7C5l.mjs +12838 -0
- package/dist/chunks/i18next-CugVlwWp.mjs +1292 -0
- package/dist/chunks/i18next-loader-CTrK3HzG.mjs +43 -0
- package/dist/{index-Cl_5rkKS.mjs → chunks/index-DDpzQn-0.mjs} +2 -2
- package/dist/chunks/inline-tool-convert-RBcopmCh.mjs +1988 -0
- package/dist/chunks/messages-2434tVOK.mjs +47 -0
- package/dist/chunks/messages-3DcCwXMF.mjs +47 -0
- package/dist/chunks/messages-4kMwVAKY.mjs +47 -0
- package/dist/chunks/messages-57uL5htT.mjs +47 -0
- package/dist/chunks/messages-76-iJV9Q.mjs +47 -0
- package/dist/chunks/messages-8p86Eyf2.mjs +47 -0
- package/dist/chunks/messages-BBX0p0Pi.mjs +47 -0
- package/dist/chunks/messages-BCm2eudQ.mjs +47 -0
- package/dist/chunks/messages-BFiUomgG.mjs +47 -0
- package/dist/chunks/messages-BIPNHHAV.mjs +47 -0
- package/dist/chunks/messages-BUlwu9mo.mjs +47 -0
- package/dist/chunks/messages-BX-DPa-z.mjs +47 -0
- package/dist/chunks/messages-BextV3Qh.mjs +47 -0
- package/dist/chunks/messages-BiPSFlUG.mjs +47 -0
- package/dist/chunks/messages-BiXe9G-O.mjs +47 -0
- package/dist/chunks/messages-Bl5z_Igo.mjs +47 -0
- package/dist/chunks/messages-BnsE97ku.mjs +47 -0
- package/dist/chunks/messages-BoO8gsVD.mjs +47 -0
- package/dist/chunks/messages-BqWaOGMn.mjs +47 -0
- package/dist/chunks/messages-BqkL2_Ro.mjs +47 -0
- package/dist/chunks/messages-BvCkXKX-.mjs +47 -0
- package/dist/chunks/messages-C6tbPLoj.mjs +47 -0
- package/dist/chunks/messages-CA6T3-gQ.mjs +47 -0
- package/dist/chunks/messages-CFFPFdWP.mjs +47 -0
- package/dist/chunks/messages-CFrKE-TN.mjs +47 -0
- package/dist/chunks/messages-CHz8VlG-.mjs +47 -0
- package/dist/chunks/messages-CLixzySl.mjs +47 -0
- package/dist/chunks/messages-CV7OM_qk.mjs +47 -0
- package/dist/chunks/messages-CXHt3eCC.mjs +47 -0
- package/dist/chunks/messages-CbmsBrB0.mjs +47 -0
- package/dist/chunks/messages-Ceo1KtFx.mjs +47 -0
- package/dist/chunks/messages-Cm0LJLtB.mjs +47 -0
- package/dist/chunks/messages-CmymP_Ar.mjs +47 -0
- package/dist/chunks/messages-D0ohMB5H.mjs +47 -0
- package/dist/chunks/messages-D3GrDwXh.mjs +47 -0
- package/dist/chunks/messages-D3vTzIpL.mjs +47 -0
- package/dist/chunks/messages-D5WeksbV.mjs +47 -0
- package/dist/chunks/messages-DGaab4EP.mjs +47 -0
- package/dist/chunks/messages-DKha57ZU.mjs +47 -0
- package/dist/chunks/messages-DOaujgMW.mjs +47 -0
- package/dist/chunks/messages-DVbPLd_0.mjs +47 -0
- package/dist/chunks/messages-D_FCyfW6.mjs +47 -0
- package/dist/chunks/messages-Dd5iZN3c.mjs +47 -0
- package/dist/chunks/messages-DehM7135.mjs +47 -0
- package/dist/chunks/messages-Dg1OHftD.mjs +47 -0
- package/dist/chunks/messages-Di6Flq-b.mjs +47 -0
- package/dist/chunks/messages-Dqhhex6e.mjs +47 -0
- package/dist/chunks/messages-DueVe0F1.mjs +47 -0
- package/dist/chunks/messages-Dx3eFwI0.mjs +47 -0
- package/dist/chunks/messages-FOtiUoKl.mjs +47 -0
- package/dist/chunks/messages-FTOZNhRD.mjs +47 -0
- package/dist/chunks/messages-IQxGfQIV.mjs +47 -0
- package/dist/chunks/messages-JF2fzCkK.mjs +47 -0
- package/dist/chunks/messages-MOGl7I5v.mjs +47 -0
- package/dist/chunks/messages-QgYhPL-3.mjs +47 -0
- package/dist/chunks/messages-WYWIbQwo.mjs +47 -0
- package/dist/chunks/messages-a6A_LgDv.mjs +47 -0
- package/dist/chunks/messages-bSf31LJi.mjs +47 -0
- package/dist/chunks/messages-diGozhTn.mjs +47 -0
- package/dist/chunks/messages-er-kd-VO.mjs +47 -0
- package/dist/chunks/messages-ez3w5NBn.mjs +47 -0
- package/dist/chunks/messages-f3uXjegd.mjs +47 -0
- package/dist/chunks/messages-ohwI1UGv.mjs +47 -0
- package/dist/chunks/messages-p9BZJaFV.mjs +47 -0
- package/dist/chunks/messages-qIQ4L4rw.mjs +47 -0
- package/dist/chunks/messages-qWkXPggi.mjs +47 -0
- package/dist/chunks/messages-w5foGze_.mjs +47 -0
- package/dist/full.mjs +50 -0
- package/dist/locales.mjs +227 -0
- package/dist/messages-2434tVOK.mjs +47 -0
- package/dist/messages-3DcCwXMF.mjs +47 -0
- package/dist/messages-4kMwVAKY.mjs +47 -0
- package/dist/messages-57uL5htT.mjs +47 -0
- package/dist/messages-76-iJV9Q.mjs +47 -0
- package/dist/messages-8p86Eyf2.mjs +47 -0
- package/dist/messages-BBX0p0Pi.mjs +47 -0
- package/dist/messages-BCm2eudQ.mjs +47 -0
- package/dist/messages-BFiUomgG.mjs +47 -0
- package/dist/messages-BIPNHHAV.mjs +47 -0
- package/dist/messages-BUlwu9mo.mjs +47 -0
- package/dist/messages-BX-DPa-z.mjs +47 -0
- package/dist/messages-BextV3Qh.mjs +47 -0
- package/dist/messages-BiPSFlUG.mjs +47 -0
- package/dist/messages-BiXe9G-O.mjs +47 -0
- package/dist/messages-Bl5z_Igo.mjs +47 -0
- package/dist/messages-BnsE97ku.mjs +47 -0
- package/dist/messages-BoO8gsVD.mjs +47 -0
- package/dist/messages-BqWaOGMn.mjs +47 -0
- package/dist/messages-BqkL2_Ro.mjs +47 -0
- package/dist/messages-BvCkXKX-.mjs +47 -0
- package/dist/messages-C6tbPLoj.mjs +47 -0
- package/dist/messages-CA6T3-gQ.mjs +47 -0
- package/dist/messages-CFFPFdWP.mjs +47 -0
- package/dist/messages-CFrKE-TN.mjs +47 -0
- package/dist/messages-CHz8VlG-.mjs +47 -0
- package/dist/messages-CLixzySl.mjs +47 -0
- package/dist/messages-CV7OM_qk.mjs +47 -0
- package/dist/messages-CXHt3eCC.mjs +47 -0
- package/dist/messages-CbmsBrB0.mjs +47 -0
- package/dist/messages-Ceo1KtFx.mjs +47 -0
- package/dist/messages-Cm0LJLtB.mjs +47 -0
- package/dist/messages-CmymP_Ar.mjs +47 -0
- package/dist/messages-D0ohMB5H.mjs +47 -0
- package/dist/messages-D3GrDwXh.mjs +47 -0
- package/dist/messages-D3vTzIpL.mjs +47 -0
- package/dist/messages-D5WeksbV.mjs +47 -0
- package/dist/messages-DGaab4EP.mjs +47 -0
- package/dist/messages-DKha57ZU.mjs +47 -0
- package/dist/messages-DOaujgMW.mjs +47 -0
- package/dist/messages-DVbPLd_0.mjs +47 -0
- package/dist/messages-D_FCyfW6.mjs +47 -0
- package/dist/messages-Dd5iZN3c.mjs +47 -0
- package/dist/messages-DehM7135.mjs +47 -0
- package/dist/messages-Dg1OHftD.mjs +47 -0
- package/dist/messages-Di6Flq-b.mjs +47 -0
- package/dist/messages-Dqhhex6e.mjs +47 -0
- package/dist/messages-DueVe0F1.mjs +47 -0
- package/dist/messages-Dx3eFwI0.mjs +47 -0
- package/dist/messages-FOtiUoKl.mjs +47 -0
- package/dist/messages-FTOZNhRD.mjs +47 -0
- package/dist/messages-IQxGfQIV.mjs +47 -0
- package/dist/messages-JF2fzCkK.mjs +47 -0
- package/dist/messages-MOGl7I5v.mjs +47 -0
- package/dist/messages-QgYhPL-3.mjs +47 -0
- package/dist/messages-WYWIbQwo.mjs +47 -0
- package/dist/messages-a6A_LgDv.mjs +47 -0
- package/dist/messages-bSf31LJi.mjs +47 -0
- package/dist/messages-diGozhTn.mjs +47 -0
- package/dist/messages-er-kd-VO.mjs +47 -0
- package/dist/messages-ez3w5NBn.mjs +47 -0
- package/dist/messages-f3uXjegd.mjs +47 -0
- package/dist/messages-ohwI1UGv.mjs +47 -0
- package/dist/messages-p9BZJaFV.mjs +47 -0
- package/dist/messages-qIQ4L4rw.mjs +47 -0
- package/dist/messages-qWkXPggi.mjs +47 -0
- package/dist/messages-w5foGze_.mjs +47 -0
- package/dist/tools.mjs +3073 -0
- package/dist/vendor.LICENSE.txt +59 -156
- package/package.json +48 -16
- package/src/blok.ts +267 -0
- package/src/components/__module.ts +139 -0
- package/src/components/block/api.ts +155 -0
- package/src/components/block/index.ts +1427 -0
- package/src/components/block-tunes/block-tune-delete.ts +51 -0
- package/src/components/blocks.ts +338 -0
- package/src/components/constants/data-attributes.ts +342 -0
- package/src/components/constants.ts +76 -0
- package/src/components/core.ts +392 -0
- package/src/components/dom.ts +773 -0
- package/src/components/domIterator.ts +189 -0
- package/src/components/errors/critical.ts +5 -0
- package/src/components/events/BlockChanged.ts +16 -0
- package/src/components/events/BlockHovered.ts +21 -0
- package/src/components/events/BlockSettingsClosed.ts +12 -0
- package/src/components/events/BlockSettingsOpened.ts +12 -0
- package/src/components/events/BlokMobileLayoutToggled.ts +15 -0
- package/src/components/events/FakeCursorAboutToBeToggled.ts +17 -0
- package/src/components/events/FakeCursorHaveBeenSet.ts +17 -0
- package/src/components/events/HistoryStateChanged.ts +19 -0
- package/src/components/events/RedactorDomChanged.ts +14 -0
- package/src/components/events/index.ts +46 -0
- package/src/components/flipper.ts +481 -0
- package/src/components/i18n/i18next-loader.ts +84 -0
- package/src/components/i18n/lightweight-i18n.ts +86 -0
- package/src/components/i18n/locales/TRANSLATION_GUIDELINES.md +113 -0
- package/src/components/i18n/locales/am/messages.json +44 -0
- package/src/components/i18n/locales/ar/messages.json +44 -0
- package/src/components/i18n/locales/az/messages.json +44 -0
- package/src/components/i18n/locales/bg/messages.json +44 -0
- package/src/components/i18n/locales/bn/messages.json +44 -0
- package/src/components/i18n/locales/bs/messages.json +44 -0
- package/src/components/i18n/locales/cs/messages.json +44 -0
- package/src/components/i18n/locales/da/messages.json +44 -0
- package/src/components/i18n/locales/de/messages.json +44 -0
- package/src/components/i18n/locales/dv/messages.json +44 -0
- package/src/components/i18n/locales/el/messages.json +44 -0
- package/src/components/i18n/locales/en/messages.json +44 -0
- package/src/components/i18n/locales/es/messages.json +44 -0
- package/src/components/i18n/locales/et/messages.json +44 -0
- package/src/components/i18n/locales/fa/messages.json +44 -0
- package/src/components/i18n/locales/fi/messages.json +44 -0
- package/src/components/i18n/locales/fil/messages.json +44 -0
- package/src/components/i18n/locales/fr/messages.json +44 -0
- package/src/components/i18n/locales/gu/messages.json +44 -0
- package/src/components/i18n/locales/he/messages.json +44 -0
- package/src/components/i18n/locales/hi/messages.json +44 -0
- package/src/components/i18n/locales/hr/messages.json +44 -0
- package/src/components/i18n/locales/hu/messages.json +44 -0
- package/src/components/i18n/locales/hy/messages.json +44 -0
- package/src/components/i18n/locales/id/messages.json +44 -0
- package/src/components/i18n/locales/index.ts +225 -0
- package/src/components/i18n/locales/it/messages.json +44 -0
- package/src/components/i18n/locales/ja/messages.json +44 -0
- package/src/components/i18n/locales/ka/messages.json +44 -0
- package/src/components/i18n/locales/km/messages.json +44 -0
- package/src/components/i18n/locales/kn/messages.json +44 -0
- package/src/components/i18n/locales/ko/messages.json +44 -0
- package/src/components/i18n/locales/ku/messages.json +44 -0
- package/src/components/i18n/locales/lo/messages.json +44 -0
- package/src/components/i18n/locales/lt/messages.json +44 -0
- package/src/components/i18n/locales/lv/messages.json +44 -0
- package/src/components/i18n/locales/mk/messages.json +44 -0
- package/src/components/i18n/locales/ml/messages.json +44 -0
- package/src/components/i18n/locales/mn/messages.json +44 -0
- package/src/components/i18n/locales/mr/messages.json +44 -0
- package/src/components/i18n/locales/ms/messages.json +44 -0
- package/src/components/i18n/locales/my/messages.json +44 -0
- package/src/components/i18n/locales/ne/messages.json +44 -0
- package/src/components/i18n/locales/nl/messages.json +44 -0
- package/src/components/i18n/locales/no/messages.json +44 -0
- package/src/components/i18n/locales/pa/messages.json +44 -0
- package/src/components/i18n/locales/pl/messages.json +44 -0
- package/src/components/i18n/locales/ps/messages.json +44 -0
- package/src/components/i18n/locales/pt/messages.json +44 -0
- package/src/components/i18n/locales/ro/messages.json +44 -0
- package/src/components/i18n/locales/ru/messages.json +44 -0
- package/src/components/i18n/locales/sd/messages.json +44 -0
- package/src/components/i18n/locales/si/messages.json +44 -0
- package/src/components/i18n/locales/sk/messages.json +44 -0
- package/src/components/i18n/locales/sl/messages.json +44 -0
- package/src/components/i18n/locales/sq/messages.json +44 -0
- package/src/components/i18n/locales/sr/messages.json +44 -0
- package/src/components/i18n/locales/sv/messages.json +44 -0
- package/src/components/i18n/locales/sw/messages.json +44 -0
- package/src/components/i18n/locales/ta/messages.json +44 -0
- package/src/components/i18n/locales/te/messages.json +44 -0
- package/src/components/i18n/locales/th/messages.json +44 -0
- package/src/components/i18n/locales/tr/messages.json +44 -0
- package/src/components/i18n/locales/ug/messages.json +44 -0
- package/src/components/i18n/locales/uk/messages.json +44 -0
- package/src/components/i18n/locales/ur/messages.json +44 -0
- package/src/components/i18n/locales/vi/messages.json +44 -0
- package/src/components/i18n/locales/yi/messages.json +44 -0
- package/src/components/i18n/locales/zh/messages.json +44 -0
- package/src/components/icons/index.ts +242 -0
- package/src/components/inline-tools/inline-tool-bold.ts +2213 -0
- package/src/components/inline-tools/inline-tool-convert.ts +141 -0
- package/src/components/inline-tools/inline-tool-italic.ts +500 -0
- package/src/components/inline-tools/inline-tool-link.ts +539 -0
- package/src/components/modules/api/blocks.ts +363 -0
- package/src/components/modules/api/caret.ts +125 -0
- package/src/components/modules/api/events.ts +51 -0
- package/src/components/modules/api/history.ts +73 -0
- package/src/components/modules/api/i18n.ts +33 -0
- package/src/components/modules/api/index.ts +39 -0
- package/src/components/modules/api/inlineToolbar.ts +33 -0
- package/src/components/modules/api/listeners.ts +56 -0
- package/src/components/modules/api/notifier.ts +46 -0
- package/src/components/modules/api/readonly.ts +39 -0
- package/src/components/modules/api/sanitizer.ts +30 -0
- package/src/components/modules/api/saver.ts +52 -0
- package/src/components/modules/api/selection.ts +48 -0
- package/src/components/modules/api/styles.ts +72 -0
- package/src/components/modules/api/toolbar.ts +79 -0
- package/src/components/modules/api/tools.ts +16 -0
- package/src/components/modules/api/tooltip.ts +67 -0
- package/src/components/modules/api/ui.ts +36 -0
- package/src/components/modules/blockEvents.ts +1375 -0
- package/src/components/modules/blockManager.ts +1348 -0
- package/src/components/modules/blockSelection.ts +708 -0
- package/src/components/modules/caret.ts +853 -0
- package/src/components/modules/crossBlockSelection.ts +329 -0
- package/src/components/modules/dragManager.ts +1141 -0
- package/src/components/modules/history.ts +1098 -0
- package/src/components/modules/i18n.ts +325 -0
- package/src/components/modules/index.ts +139 -0
- package/src/components/modules/modificationsObserver.ts +147 -0
- package/src/components/modules/paste.ts +1092 -0
- package/src/components/modules/readonly.ts +136 -0
- package/src/components/modules/rectangleSelection.ts +668 -0
- package/src/components/modules/renderer.ts +155 -0
- package/src/components/modules/saver.ts +283 -0
- package/src/components/modules/toolbar/blockSettings.ts +776 -0
- package/src/components/modules/toolbar/index.ts +1311 -0
- package/src/components/modules/toolbar/inline.ts +956 -0
- package/src/components/modules/tools.ts +589 -0
- package/src/components/modules/ui.ts +1179 -0
- package/src/components/polyfills.ts +113 -0
- package/src/components/selection.ts +1189 -0
- package/src/components/tools/base.ts +274 -0
- package/src/components/tools/block.ts +291 -0
- package/src/components/tools/collection.ts +67 -0
- package/src/components/tools/factory.ts +85 -0
- package/src/components/tools/inline.ts +71 -0
- package/src/components/tools/tune.ts +33 -0
- package/src/components/ui/toolbox.ts +497 -0
- package/src/components/utils/announcer.ts +205 -0
- package/src/components/utils/api.ts +20 -0
- package/src/components/utils/bem.ts +26 -0
- package/src/components/utils/blocks.ts +284 -0
- package/src/components/utils/caret.ts +1067 -0
- package/src/components/utils/data-model-transform.ts +382 -0
- package/src/components/utils/events.ts +117 -0
- package/src/components/utils/keyboard.ts +60 -0
- package/src/components/utils/listeners.ts +296 -0
- package/src/components/utils/mutations.ts +39 -0
- package/src/components/utils/notifier/draw.ts +190 -0
- package/src/components/utils/notifier/index.ts +66 -0
- package/src/components/utils/notifier/types.ts +1 -0
- package/src/components/utils/notifier.ts +77 -0
- package/src/components/utils/placeholder.ts +140 -0
- package/src/components/utils/popover/components/hint/hint.const.ts +10 -0
- package/src/components/utils/popover/components/hint/hint.ts +46 -0
- package/src/components/utils/popover/components/hint/index.ts +6 -0
- package/src/components/utils/popover/components/popover-header/index.ts +2 -0
- package/src/components/utils/popover/components/popover-header/popover-header.const.ts +8 -0
- package/src/components/utils/popover/components/popover-header/popover-header.ts +80 -0
- package/src/components/utils/popover/components/popover-header/popover-header.types.ts +14 -0
- package/src/components/utils/popover/components/popover-item/index.ts +13 -0
- package/src/components/utils/popover/components/popover-item/popover-item-default/popover-item-default.const.ts +50 -0
- package/src/components/utils/popover/components/popover-item/popover-item-default/popover-item-default.ts +666 -0
- package/src/components/utils/popover/components/popover-item/popover-item-html/popover-item-html.const.ts +14 -0
- package/src/components/utils/popover/components/popover-item/popover-item-html/popover-item-html.ts +136 -0
- package/src/components/utils/popover/components/popover-item/popover-item-separator/popover-item-separator.const.ts +20 -0
- package/src/components/utils/popover/components/popover-item/popover-item-separator/popover-item-separator.ts +117 -0
- package/src/components/utils/popover/components/popover-item/popover-item.ts +187 -0
- package/src/components/utils/popover/components/search-input/index.ts +2 -0
- package/src/components/utils/popover/components/search-input/search-input.const.ts +8 -0
- package/src/components/utils/popover/components/search-input/search-input.ts +181 -0
- package/src/components/utils/popover/components/search-input/search-input.types.ts +30 -0
- package/src/components/utils/popover/index.ts +13 -0
- package/src/components/utils/popover/popover-abstract.ts +448 -0
- package/src/components/utils/popover/popover-desktop.ts +643 -0
- package/src/components/utils/popover/popover-inline.ts +338 -0
- package/src/components/utils/popover/popover-mobile.ts +201 -0
- package/src/components/utils/popover/popover.const.ts +81 -0
- package/src/components/utils/popover/utils/popover-states-history.ts +72 -0
- package/src/components/utils/promise-queue.ts +43 -0
- package/src/components/utils/sanitizer.ts +537 -0
- package/src/components/utils/scroll-locker.ts +87 -0
- package/src/components/utils/shortcut.ts +231 -0
- package/src/components/utils/shortcuts.ts +113 -0
- package/src/components/utils/tools.ts +105 -0
- package/src/components/utils/tooltip.ts +642 -0
- package/src/components/utils/tw.ts +241 -0
- package/src/components/utils.ts +1081 -0
- package/src/env.d.ts +13 -0
- package/src/full.ts +69 -0
- package/src/locales.ts +51 -0
- package/src/stories/Block.stories.ts +498 -0
- package/src/stories/EditorModes.stories.ts +505 -0
- package/src/stories/Header.stories.ts +137 -0
- package/src/stories/InlineToolbar.stories.ts +498 -0
- package/src/stories/List.stories.ts +259 -0
- package/src/stories/Notifier.stories.ts +340 -0
- package/src/stories/Paragraph.stories.ts +112 -0
- package/src/stories/Placeholder.stories.ts +319 -0
- package/src/stories/Popover.stories.ts +844 -0
- package/src/stories/Selection.stories.ts +250 -0
- package/src/stories/StubBlock.stories.ts +156 -0
- package/src/stories/Toolbar.stories.ts +223 -0
- package/src/stories/Toolbox.stories.ts +166 -0
- package/src/stories/Tooltip.stories.ts +198 -0
- package/src/stories/helpers.ts +463 -0
- package/src/styles/main.css +123 -0
- package/src/tools/header/index.ts +570 -0
- package/src/tools/index.ts +38 -0
- package/src/tools/list/index.ts +1803 -0
- package/src/tools/paragraph/index.ts +411 -0
- package/src/tools/stub/index.ts +107 -0
- package/src/types-internal/blok-modules.d.ts +87 -0
- package/src/types-internal/html-janitor.d.ts +28 -0
- package/src/types-internal/module-config.d.ts +11 -0
- package/src/variants/all-locales.ts +155 -0
- package/src/variants/blok-maximum.ts +20 -0
- package/src/variants/blok-minimum.ts +243 -0
- package/types/api/blocks.d.ts +1 -1
- package/types/api/i18n.d.ts +5 -3
- package/types/api/selection.d.ts +6 -0
- package/types/api/styles.d.ts +0 -5
- package/types/configs/blok-config.d.ts +21 -0
- package/types/configs/i18n-config.d.ts +52 -2
- package/types/configs/i18n-dictionary.d.ts +16 -90
- package/types/data-attributes.d.ts +169 -0
- package/types/data-formats/output-data.d.ts +15 -0
- package/types/full.d.ts +80 -0
- package/types/index.d.ts +9 -12
- package/types/locales.d.ts +59 -0
- package/types/tools/adapters/inline-tool-adapter.d.ts +10 -0
- package/types/tools/block-tool.d.ts +9 -0
- package/types/tools/header.d.ts +18 -0
- package/types/tools/index.d.ts +1 -0
- package/types/tools/list.d.ts +91 -0
- package/types/tools/paragraph.d.ts +71 -0
- package/types/tools/tool-settings.d.ts +16 -2
- package/types/tools/tool.d.ts +6 -0
- package/types/tools-entry.d.ts +49 -0
- package/types/utils/popover/popover-item.d.ts +0 -5
- package/dist/blok-DvN73wsH.mjs +0 -19922
- 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
|
+
}
|