@jackuait/blok 0.4.1 → 0.4.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +136 -17
- package/codemod/README.md +16 -0
- package/codemod/migrate-editorjs-to-blok.js +859 -92
- package/codemod/test.js +682 -77
- package/dist/blok.mjs +5 -2
- package/dist/chunks/blok-BjgH1REI.mjs +12838 -0
- package/dist/chunks/i18next-CugVlwWp.mjs +1292 -0
- package/dist/chunks/i18next-loader-DfiUa_gd.mjs +43 -0
- package/dist/{index-CBkApZKo.mjs → chunks/index-5m5JWNey.mjs} +2 -2
- package/dist/chunks/inline-tool-convert-Bx5BVd8I.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 +47 -15
- 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 -24
- 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/list.d.ts +25 -18
- package/types/tools/tool-settings.d.ts +8 -1
- 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-BwPfU8ro.mjs +0 -21510
- package/dist/blok.umd.js +0 -198
|
@@ -0,0 +1,1803 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ListItem Tool for the Blok Editor
|
|
3
|
+
* Represents a single list item in a hierarchical structure (Notion-like)
|
|
4
|
+
*
|
|
5
|
+
* @license MIT
|
|
6
|
+
*/
|
|
7
|
+
import { IconListBulleted, IconListNumbered, IconListChecklist } from '../../components/icons';
|
|
8
|
+
import { twMerge } from '../../components/utils/tw';
|
|
9
|
+
import { DATA_ATTR } from '../../components/constants';
|
|
10
|
+
import { PLACEHOLDER_CLASSES, setupPlaceholder } from '../../components/utils/placeholder';
|
|
11
|
+
import { stripFakeBackgroundElements } from '../../components/utils';
|
|
12
|
+
import type {
|
|
13
|
+
API,
|
|
14
|
+
BlockTool,
|
|
15
|
+
BlockToolConstructorOptions,
|
|
16
|
+
BlockToolData,
|
|
17
|
+
PasteEvent,
|
|
18
|
+
ToolboxConfig,
|
|
19
|
+
ConversionConfig,
|
|
20
|
+
SanitizerConfig,
|
|
21
|
+
PasteConfig,
|
|
22
|
+
} from '../../../types';
|
|
23
|
+
import type { MenuConfig } from '../../../types/tools/menu-config';
|
|
24
|
+
import type { MoveEvent } from '../../../types/tools/hook-events';
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* List item styles
|
|
28
|
+
*/
|
|
29
|
+
export type ListItemStyle = 'unordered' | 'ordered' | 'checklist';
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Tool's input and output data format
|
|
33
|
+
*/
|
|
34
|
+
export interface ListItemData extends BlockToolData {
|
|
35
|
+
/** Item text content (can include HTML) */
|
|
36
|
+
text: string;
|
|
37
|
+
/** List style: unordered, ordered, or checklist */
|
|
38
|
+
style: ListItemStyle;
|
|
39
|
+
/** Checked state for checklist items */
|
|
40
|
+
checked?: boolean;
|
|
41
|
+
/** Starting number for ordered lists (only applies to root items) */
|
|
42
|
+
start?: number;
|
|
43
|
+
/** Nesting depth level (0 = root, 1 = first indent, etc.) */
|
|
44
|
+
depth?: number;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Tool's config from Editor
|
|
49
|
+
*/
|
|
50
|
+
export interface ListItemConfig {
|
|
51
|
+
/** Default list style */
|
|
52
|
+
defaultStyle?: ListItemStyle;
|
|
53
|
+
/**
|
|
54
|
+
* Available list styles for the settings menu.
|
|
55
|
+
* When specified, only these styles will be available in the block settings dropdown.
|
|
56
|
+
*/
|
|
57
|
+
styles?: ListItemStyle[];
|
|
58
|
+
/**
|
|
59
|
+
* List styles to show in the toolbox.
|
|
60
|
+
* When specified, only these list types will appear as separate entries in the toolbox.
|
|
61
|
+
* If not specified, all list types (unordered, ordered, checklist) will be shown.
|
|
62
|
+
*
|
|
63
|
+
* @example
|
|
64
|
+
* // Show only bulleted and numbered lists in toolbox
|
|
65
|
+
* toolboxStyles: ['unordered', 'ordered']
|
|
66
|
+
*
|
|
67
|
+
* @example
|
|
68
|
+
* // Show only checklist in toolbox
|
|
69
|
+
* toolboxStyles: ['checklist']
|
|
70
|
+
*/
|
|
71
|
+
toolboxStyles?: ListItemStyle[];
|
|
72
|
+
/**
|
|
73
|
+
* Custom color for list items.
|
|
74
|
+
* Accepts any valid CSS color value (hex, rgb, hsl, named colors, etc.)
|
|
75
|
+
*
|
|
76
|
+
* @example
|
|
77
|
+
* // Set list items to a hex color
|
|
78
|
+
* itemColor: '#3b82f6'
|
|
79
|
+
*
|
|
80
|
+
* @example
|
|
81
|
+
* // Set list items to an rgb color
|
|
82
|
+
* itemColor: 'rgb(59, 130, 246)'
|
|
83
|
+
*/
|
|
84
|
+
itemColor?: string;
|
|
85
|
+
/**
|
|
86
|
+
* Custom font size for list items.
|
|
87
|
+
* Accepts any valid CSS font-size value (px, rem, em, etc.)
|
|
88
|
+
*
|
|
89
|
+
* @example
|
|
90
|
+
* // Set list items to 18px
|
|
91
|
+
* itemSize: '18px'
|
|
92
|
+
*
|
|
93
|
+
* @example
|
|
94
|
+
* // Set list items to 1.25rem
|
|
95
|
+
* itemSize: '1.25rem'
|
|
96
|
+
*/
|
|
97
|
+
itemSize?: string;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Style configuration
|
|
102
|
+
*/
|
|
103
|
+
interface StyleConfig {
|
|
104
|
+
style: ListItemStyle;
|
|
105
|
+
name: string;
|
|
106
|
+
icon: string;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* ListItem block for the Blok Editor.
|
|
111
|
+
* Represents a single list item that can have children (nested items).
|
|
112
|
+
*/
|
|
113
|
+
export class ListItem implements BlockTool {
|
|
114
|
+
private api: API;
|
|
115
|
+
private readOnly: boolean;
|
|
116
|
+
private _settings: ListItemConfig;
|
|
117
|
+
private _data: ListItemData;
|
|
118
|
+
private _element: HTMLElement | null = null;
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Block instance properties for hierarchy
|
|
122
|
+
*/
|
|
123
|
+
private blockId?: string;
|
|
124
|
+
private parentId?: string | null;
|
|
125
|
+
private contentIds?: string[];
|
|
126
|
+
|
|
127
|
+
private static readonly BASE_STYLES = 'outline-none';
|
|
128
|
+
private static readonly ITEM_STYLES = 'outline-none py-0.5 pl-0.5 leading-[1.6em]';
|
|
129
|
+
private static readonly CHECKLIST_ITEM_STYLES = 'flex items-start py-0.5 pl-0.5';
|
|
130
|
+
private static readonly CHECKBOX_STYLES = 'mt-1 w-4 mr-2 h-4 cursor-pointer accent-current';
|
|
131
|
+
|
|
132
|
+
private static readonly STYLE_CONFIGS: StyleConfig[] = [
|
|
133
|
+
{ style: 'unordered', name: 'bulletedList', icon: IconListBulleted },
|
|
134
|
+
{ style: 'ordered', name: 'numberedList', icon: IconListNumbered },
|
|
135
|
+
{ style: 'checklist', name: 'todoList', icon: IconListChecklist },
|
|
136
|
+
];
|
|
137
|
+
|
|
138
|
+
constructor({ data, config, api, readOnly, block }: BlockToolConstructorOptions<ListItemData, ListItemConfig>) {
|
|
139
|
+
this.api = api;
|
|
140
|
+
this.readOnly = readOnly;
|
|
141
|
+
this._settings = config || {};
|
|
142
|
+
this._data = this.normalizeData(data);
|
|
143
|
+
|
|
144
|
+
// Store block hierarchy info
|
|
145
|
+
if (block) {
|
|
146
|
+
this.blockId = block.id;
|
|
147
|
+
// Note: parent and content are available on the block
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Only ordered lists need to listen for block removals to renumber
|
|
151
|
+
if (this._data.style === 'ordered') {
|
|
152
|
+
this.api.events.on('block changed', this.handleBlockChanged);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Handler for block change events.
|
|
158
|
+
* When any block is removed, trigger renumbering of ordered list items.
|
|
159
|
+
* Uses a static flag to deduplicate multiple calls in the same frame.
|
|
160
|
+
*/
|
|
161
|
+
private handleBlockChanged = (data: unknown): void => {
|
|
162
|
+
const payload = data as { event?: { type?: string } } | undefined;
|
|
163
|
+
|
|
164
|
+
if (payload?.event?.type !== 'block-removed') {
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Deduplicate: only schedule one update per frame across all instances
|
|
169
|
+
if (ListItem.pendingMarkerUpdate) {
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
ListItem.pendingMarkerUpdate = true;
|
|
174
|
+
requestAnimationFrame(() => {
|
|
175
|
+
ListItem.pendingMarkerUpdate = false;
|
|
176
|
+
this.updateAllOrderedListMarkers();
|
|
177
|
+
});
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Static flag to deduplicate marker updates across all ListItem instances.
|
|
182
|
+
* Prevents redundant updates when multiple list items respond to the same event.
|
|
183
|
+
*/
|
|
184
|
+
private static pendingMarkerUpdate = false;
|
|
185
|
+
|
|
186
|
+
sanitize?: SanitizerConfig | undefined;
|
|
187
|
+
|
|
188
|
+
private normalizeData(data: ListItemData | Record<string, never>): ListItemData {
|
|
189
|
+
const defaultStyle = this._settings.defaultStyle || 'unordered';
|
|
190
|
+
|
|
191
|
+
if (!data || typeof data !== 'object') {
|
|
192
|
+
return {
|
|
193
|
+
text: '',
|
|
194
|
+
style: defaultStyle,
|
|
195
|
+
checked: false,
|
|
196
|
+
depth: 0,
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return {
|
|
201
|
+
text: data.text || '',
|
|
202
|
+
style: data.style || defaultStyle,
|
|
203
|
+
checked: Boolean(data.checked),
|
|
204
|
+
depth: data.depth ?? 0,
|
|
205
|
+
...(data.start !== undefined && data.start !== 1 ? { start: data.start } : {}),
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
private get currentStyleConfig(): StyleConfig {
|
|
210
|
+
return ListItem.STYLE_CONFIGS.find(s => s.style === this._data.style) || ListItem.STYLE_CONFIGS[0];
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
private get availableStyles(): StyleConfig[] {
|
|
214
|
+
const configuredStyles = this._settings.styles;
|
|
215
|
+
if (!configuredStyles || configuredStyles.length === 0) {
|
|
216
|
+
return ListItem.STYLE_CONFIGS;
|
|
217
|
+
}
|
|
218
|
+
return ListItem.STYLE_CONFIGS.filter(s => configuredStyles.includes(s.style));
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
private get itemColor(): string | undefined {
|
|
222
|
+
return this._settings.itemColor;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
private get itemSize(): string | undefined {
|
|
226
|
+
return this._settings.itemSize;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
private static readonly DEFAULT_PLACEHOLDER = 'List';
|
|
230
|
+
|
|
231
|
+
private get placeholder(): string {
|
|
232
|
+
return this.api.i18n.t(ListItem.DEFAULT_PLACEHOLDER);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
private applyItemStyles(element: HTMLElement): void {
|
|
236
|
+
const styleUpdates = element.style;
|
|
237
|
+
|
|
238
|
+
if (this.itemColor) {
|
|
239
|
+
styleUpdates.color = this.itemColor;
|
|
240
|
+
}
|
|
241
|
+
if (this.itemSize) {
|
|
242
|
+
styleUpdates.fontSize = this.itemSize;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
private setupItemPlaceholder(element: HTMLElement): void {
|
|
247
|
+
if (this.readOnly) {
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
setupPlaceholder(element, this.placeholder);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
public render(): HTMLElement {
|
|
254
|
+
this._element = this.createItemElement();
|
|
255
|
+
return this._element;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Called after block content is added to the page.
|
|
260
|
+
* Updates the marker with the correct index now that we know our position,
|
|
261
|
+
* and also updates all sibling list items since their indices may have changed.
|
|
262
|
+
*/
|
|
263
|
+
public rendered(): void {
|
|
264
|
+
this.updateMarkersAfterPositionChange();
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Called after block was moved.
|
|
269
|
+
* Validates and adjusts depth to follow list formation rules,
|
|
270
|
+
* then updates the marker to reflect the new position.
|
|
271
|
+
*/
|
|
272
|
+
public moved(event: MoveEvent): void {
|
|
273
|
+
this.validateAndAdjustDepthAfterMove(event.detail.toIndex);
|
|
274
|
+
this.updateMarkersAfterPositionChange();
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Updates this block's marker and all sibling ordered list markers.
|
|
279
|
+
* Called after this block's position may have changed (rendered, moved).
|
|
280
|
+
*/
|
|
281
|
+
private updateMarkersAfterPositionChange(): void {
|
|
282
|
+
if (this._data.style !== 'ordered' || !this._element) {
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Update this block's marker
|
|
287
|
+
this.updateMarker();
|
|
288
|
+
|
|
289
|
+
// Update all sibling ordered list items since their indices may have changed
|
|
290
|
+
this.updateSiblingListMarkers();
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Validates and adjusts the depth of this list item after a drag-and-drop move.
|
|
295
|
+
* Ensures the depth follows list formation rules:
|
|
296
|
+
* 1. First item (index 0) must be at depth 0
|
|
297
|
+
* 2. Item depth cannot exceed previousItem.depth + 1
|
|
298
|
+
* 3. When dropped between nested items, adopt the sibling's depth
|
|
299
|
+
*
|
|
300
|
+
* @param newIndex - The new index where the block was moved to
|
|
301
|
+
*/
|
|
302
|
+
private validateAndAdjustDepthAfterMove(newIndex: number): void {
|
|
303
|
+
const currentDepth = this.getDepth();
|
|
304
|
+
const maxAllowedDepth = this.calculateMaxAllowedDepth(newIndex);
|
|
305
|
+
const targetDepth = this.calculateTargetDepthForPosition(newIndex, maxAllowedDepth);
|
|
306
|
+
|
|
307
|
+
if (currentDepth !== targetDepth) {
|
|
308
|
+
this.adjustDepthTo(targetDepth);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Calculates the target depth for a list item dropped at the given index.
|
|
314
|
+
* When dropping into a nested context, the item should match the sibling's depth.
|
|
315
|
+
*
|
|
316
|
+
* @param blockIndex - The index where the block was dropped
|
|
317
|
+
* @param maxAllowedDepth - The maximum allowed depth at this position
|
|
318
|
+
* @returns The target depth for the dropped item
|
|
319
|
+
*/
|
|
320
|
+
private calculateTargetDepthForPosition(blockIndex: number, maxAllowedDepth: number): number {
|
|
321
|
+
const currentDepth = this.getDepth();
|
|
322
|
+
|
|
323
|
+
// If current depth exceeds max, cap it
|
|
324
|
+
if (currentDepth > maxAllowedDepth) {
|
|
325
|
+
return maxAllowedDepth;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// Check if we're inserting before a list item (next block)
|
|
329
|
+
const nextBlock = this.api.blocks.getBlockByIndex(blockIndex + 1);
|
|
330
|
+
const nextIsListItem = nextBlock && nextBlock.name === ListItem.TOOL_NAME;
|
|
331
|
+
const nextBlockDepth = nextIsListItem ? this.getBlockDepth(nextBlock) : 0;
|
|
332
|
+
|
|
333
|
+
// If next block is a deeper list item, match its depth (become a sibling)
|
|
334
|
+
// This prevents breaking list structure by inserting a shallower item
|
|
335
|
+
const shouldMatchNextDepth = nextIsListItem
|
|
336
|
+
&& nextBlockDepth > currentDepth
|
|
337
|
+
&& nextBlockDepth <= maxAllowedDepth;
|
|
338
|
+
|
|
339
|
+
if (shouldMatchNextDepth) {
|
|
340
|
+
return nextBlockDepth;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// Check if previous block is a list item at a deeper level
|
|
344
|
+
const previousBlock = blockIndex > 0 ? this.api.blocks.getBlockByIndex(blockIndex - 1) : null;
|
|
345
|
+
const previousIsListItem = previousBlock && previousBlock.name === ListItem.TOOL_NAME;
|
|
346
|
+
const previousBlockDepth = previousIsListItem ? this.getBlockDepth(previousBlock) : 0;
|
|
347
|
+
|
|
348
|
+
// If previous block is deeper and there's no next list item to guide us,
|
|
349
|
+
// match the previous block's depth (append as sibling in the nested list)
|
|
350
|
+
const shouldMatchPreviousDepth = previousIsListItem
|
|
351
|
+
&& !nextIsListItem
|
|
352
|
+
&& previousBlockDepth > currentDepth
|
|
353
|
+
&& previousBlockDepth <= maxAllowedDepth;
|
|
354
|
+
|
|
355
|
+
if (shouldMatchPreviousDepth) {
|
|
356
|
+
return previousBlockDepth;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
return currentDepth;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Calculates the maximum allowed depth for a list item at the given index.
|
|
364
|
+
*
|
|
365
|
+
* Rules:
|
|
366
|
+
* 1. First item (index 0) must be at depth 0
|
|
367
|
+
* 2. For other items: maxDepth = previousListItem.depth + 1
|
|
368
|
+
* 3. If previous block is not a list item, maxDepth = 0
|
|
369
|
+
*
|
|
370
|
+
* @param blockIndex - The index of the block
|
|
371
|
+
* @returns The maximum allowed depth (0 or more)
|
|
372
|
+
*/
|
|
373
|
+
private calculateMaxAllowedDepth(blockIndex: number): number {
|
|
374
|
+
// First item must be at depth 0
|
|
375
|
+
if (blockIndex === 0) {
|
|
376
|
+
return 0;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
const previousBlock = this.api.blocks.getBlockByIndex(blockIndex - 1);
|
|
380
|
+
|
|
381
|
+
// If previous block doesn't exist or isn't a list item, max depth is 0
|
|
382
|
+
if (!previousBlock || previousBlock.name !== ListItem.TOOL_NAME) {
|
|
383
|
+
return 0;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// Max depth is previous item's depth + 1
|
|
387
|
+
const previousBlockDepth = this.getBlockDepth(previousBlock);
|
|
388
|
+
|
|
389
|
+
return previousBlockDepth + 1;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Adjusts the depth of this list item to the specified value.
|
|
394
|
+
* Updates internal data and the DOM element's indentation.
|
|
395
|
+
*
|
|
396
|
+
* @param newDepth - The new depth value
|
|
397
|
+
*/
|
|
398
|
+
private adjustDepthTo(newDepth: number): void {
|
|
399
|
+
this._data.depth = newDepth;
|
|
400
|
+
|
|
401
|
+
// Update the data-list-depth attribute on the wrapper
|
|
402
|
+
if (this._element) {
|
|
403
|
+
this._element.setAttribute('data-list-depth', String(newDepth));
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// Update DOM element's indentation
|
|
407
|
+
const listItemEl = this._element?.querySelector('[role="listitem"]');
|
|
408
|
+
|
|
409
|
+
if (listItemEl instanceof HTMLElement) {
|
|
410
|
+
listItemEl.style.marginLeft = newDepth > 0
|
|
411
|
+
? `${newDepth * ListItem.INDENT_PER_LEVEL}px`
|
|
412
|
+
: '';
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* Called when this block is about to be removed.
|
|
418
|
+
* Updates sibling ordered list markers to renumber correctly after removal.
|
|
419
|
+
*/
|
|
420
|
+
public removed(): void {
|
|
421
|
+
if (this._data.style !== 'ordered') {
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// Unsubscribe from block change events to prevent memory leaks
|
|
426
|
+
this.api.events.off('block changed', this.handleBlockChanged);
|
|
427
|
+
|
|
428
|
+
// Schedule marker update for next frame, after DOM has been updated
|
|
429
|
+
// Note: This is still needed because when THIS list item is removed,
|
|
430
|
+
// handleBlockChanged won't be called on this instance (it's being destroyed)
|
|
431
|
+
requestAnimationFrame(() => {
|
|
432
|
+
this.updateAllOrderedListMarkers();
|
|
433
|
+
});
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* Update markers on all ordered list items in the editor.
|
|
438
|
+
* Called when a list item is removed to ensure correct renumbering.
|
|
439
|
+
*/
|
|
440
|
+
private updateAllOrderedListMarkers(): void {
|
|
441
|
+
const blocksCount = this.api.blocks.getBlocksCount();
|
|
442
|
+
|
|
443
|
+
Array.from({ length: blocksCount }, (_, i) => i).forEach(i => {
|
|
444
|
+
const block = this.api.blocks.getBlockByIndex(i);
|
|
445
|
+
if (!block || block.name !== ListItem.TOOL_NAME) {
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
const blockHolder = block.holder;
|
|
450
|
+
const listItemEl = blockHolder?.querySelector('[data-list-style="ordered"]');
|
|
451
|
+
if (!listItemEl) {
|
|
452
|
+
return; // Not an ordered list
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
this.updateBlockMarker(block);
|
|
456
|
+
});
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* Update marker if this is an ordered list item.
|
|
461
|
+
*/
|
|
462
|
+
private updateMarkerIfOrdered(): void {
|
|
463
|
+
if (this._data.style !== 'ordered' || !this._element) {
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
this.updateMarker();
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* Update the marker element with the correct index.
|
|
472
|
+
* Called after the block is rendered and positioned.
|
|
473
|
+
*/
|
|
474
|
+
private updateMarker(): void {
|
|
475
|
+
const marker = this._element?.querySelector('[data-list-marker]');
|
|
476
|
+
if (!marker) {
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
const depth = this.getDepth();
|
|
481
|
+
const siblingIndex = this.getSiblingIndex();
|
|
482
|
+
const markerText = this.getOrderedMarkerText(siblingIndex, depth);
|
|
483
|
+
marker.textContent = markerText;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/**
|
|
487
|
+
* Update markers on all sibling ordered list items.
|
|
488
|
+
* Called when this block is moved to ensure all list numbers are correct.
|
|
489
|
+
* Respects style boundaries - only updates items with the same style.
|
|
490
|
+
*/
|
|
491
|
+
private updateSiblingListMarkers(): void {
|
|
492
|
+
const currentBlockIndex = this.blockId
|
|
493
|
+
? this.api.blocks.getBlockIndex(this.blockId) ?? this.api.blocks.getCurrentBlockIndex()
|
|
494
|
+
: this.api.blocks.getCurrentBlockIndex();
|
|
495
|
+
|
|
496
|
+
const currentDepth = this.getDepth();
|
|
497
|
+
const currentStyle = this._data.style;
|
|
498
|
+
const blocksCount = this.api.blocks.getBlocksCount();
|
|
499
|
+
|
|
500
|
+
// Find the start of this list group by walking backwards (respecting style boundaries)
|
|
501
|
+
const groupStartIndex = this.findListGroupStartIndex(currentBlockIndex, currentDepth, currentStyle);
|
|
502
|
+
|
|
503
|
+
// Update all ordered list items from groupStartIndex forward at this depth (respecting style boundaries)
|
|
504
|
+
this.updateMarkersInRange(groupStartIndex, blocksCount, currentBlockIndex, currentDepth, currentStyle);
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* Find the starting index of a list group by walking backwards.
|
|
509
|
+
* Stops at style boundaries at the same depth (when encountering a different list style).
|
|
510
|
+
* Items at deeper depths are skipped regardless of their style.
|
|
511
|
+
*/
|
|
512
|
+
private findListGroupStartIndex(currentBlockIndex: number, currentDepth: number, currentStyle?: ListItemStyle): number {
|
|
513
|
+
const findStart = (index: number, startIndex: number): number => {
|
|
514
|
+
if (index < 0) {
|
|
515
|
+
return startIndex;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
const block = this.api.blocks.getBlockByIndex(index);
|
|
519
|
+
if (!block || block.name !== ListItem.TOOL_NAME) {
|
|
520
|
+
return startIndex;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
const blockDepth = this.getBlockDepth(block);
|
|
524
|
+
if (blockDepth < currentDepth) {
|
|
525
|
+
return startIndex; // Hit a parent, stop
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// If at deeper depth, skip it and continue (ignore style at deeper depths)
|
|
529
|
+
if (blockDepth > currentDepth) {
|
|
530
|
+
return findStart(index - 1, startIndex);
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// At same depth - check style boundary if currentStyle is provided
|
|
534
|
+
const blockStyle = this.getBlockStyle(block);
|
|
535
|
+
if (currentStyle !== undefined && blockStyle !== currentStyle) {
|
|
536
|
+
return startIndex; // Style boundary at same depth - treat as separate list
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
// Same depth and same style - update startIndex and continue
|
|
540
|
+
return findStart(index - 1, index);
|
|
541
|
+
};
|
|
542
|
+
|
|
543
|
+
return findStart(currentBlockIndex - 1, currentBlockIndex);
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
/**
|
|
547
|
+
* Update markers for all list items in a range at the given depth.
|
|
548
|
+
* Stops at style boundaries at the same depth (when encountering a different list style).
|
|
549
|
+
* Items at deeper depths are skipped regardless of their style.
|
|
550
|
+
*/
|
|
551
|
+
private updateMarkersInRange(
|
|
552
|
+
startIndex: number,
|
|
553
|
+
endIndex: number,
|
|
554
|
+
skipIndex: number,
|
|
555
|
+
targetDepth: number,
|
|
556
|
+
targetStyle?: ListItemStyle
|
|
557
|
+
): void {
|
|
558
|
+
const processBlock = (index: number): void => {
|
|
559
|
+
if (index >= endIndex) {
|
|
560
|
+
return;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
if (index === skipIndex) {
|
|
564
|
+
processBlock(index + 1);
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
const block = this.api.blocks.getBlockByIndex(index);
|
|
569
|
+
if (!block || block.name !== ListItem.TOOL_NAME) {
|
|
570
|
+
return; // Stop when we hit a non-list block
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
const blockDepth = this.getBlockDepth(block);
|
|
574
|
+
if (blockDepth < targetDepth) {
|
|
575
|
+
return; // Hit a parent, stop searching forward
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
// If at deeper depth, skip it and continue (ignore style at deeper depths)
|
|
579
|
+
if (blockDepth > targetDepth) {
|
|
580
|
+
processBlock(index + 1);
|
|
581
|
+
return;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
// At same depth - check style boundary if targetStyle is provided
|
|
585
|
+
const blockStyle = this.getBlockStyle(block);
|
|
586
|
+
if (targetStyle !== undefined && blockStyle !== targetStyle) {
|
|
587
|
+
return; // Style boundary at same depth - stop updating
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
// Same depth and same style - update marker and continue
|
|
591
|
+
this.updateBlockMarker(block);
|
|
592
|
+
|
|
593
|
+
processBlock(index + 1);
|
|
594
|
+
};
|
|
595
|
+
|
|
596
|
+
processBlock(startIndex);
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
/**
|
|
600
|
+
* Get the depth of a block by reading from its DOM
|
|
601
|
+
*/
|
|
602
|
+
private getBlockDepth(block: ReturnType<typeof this.api.blocks.getBlockByIndex>): number {
|
|
603
|
+
if (!block) {
|
|
604
|
+
return 0;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
const blockHolder = block.holder;
|
|
608
|
+
const listItemEl = blockHolder?.querySelector('[role="listitem"]');
|
|
609
|
+
const styleAttr = listItemEl?.getAttribute('style');
|
|
610
|
+
|
|
611
|
+
const marginMatch = styleAttr?.match(/margin-left:\s*(\d+)px/);
|
|
612
|
+
return marginMatch ? Math.round(parseInt(marginMatch[1], 10) / ListItem.INDENT_PER_LEVEL) : 0;
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
/**
|
|
616
|
+
* Get the style of a block by reading from its DOM
|
|
617
|
+
*/
|
|
618
|
+
private getBlockStyle(block: ReturnType<typeof this.api.blocks.getBlockByIndex>): ListItemStyle | null {
|
|
619
|
+
if (!block) {
|
|
620
|
+
return null;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
const blockHolder = block.holder;
|
|
624
|
+
const listItemEl = blockHolder?.querySelector('[data-list-style]');
|
|
625
|
+
const style = listItemEl?.getAttribute('data-list-style');
|
|
626
|
+
|
|
627
|
+
return (style as ListItemStyle) || null;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
/**
|
|
631
|
+
* Update the marker of a specific block by finding its marker element and recalculating
|
|
632
|
+
*/
|
|
633
|
+
private updateBlockMarker(block: ReturnType<typeof this.api.blocks.getBlockByIndex>): void {
|
|
634
|
+
if (!block) {
|
|
635
|
+
return;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
const blockHolder = block.holder;
|
|
639
|
+
const listItemEl = blockHolder?.querySelector('[data-list-style="ordered"]');
|
|
640
|
+
if (!listItemEl) {
|
|
641
|
+
return; // Not an ordered list
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
const marker = listItemEl.querySelector('[data-list-marker]');
|
|
645
|
+
if (!marker) {
|
|
646
|
+
return;
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
// Calculate the correct index for this block
|
|
650
|
+
const blockIndex = this.api.blocks.getBlockIndex(block.id);
|
|
651
|
+
if (blockIndex === undefined || blockIndex === null) {
|
|
652
|
+
return;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
const blockDepth = this.getBlockDepth(block);
|
|
656
|
+
const blockStyle = this.getBlockStyle(block) || 'ordered';
|
|
657
|
+
const siblingIndex = this.countPrecedingSiblingsAtDepth(blockIndex, blockDepth, blockStyle);
|
|
658
|
+
|
|
659
|
+
// Get the start value for this list group
|
|
660
|
+
const startValue = this.getListStartValueForBlock(blockIndex, blockDepth, siblingIndex, blockStyle);
|
|
661
|
+
const actualNumber = startValue + siblingIndex;
|
|
662
|
+
const markerText = this.formatOrderedMarker(actualNumber, blockDepth);
|
|
663
|
+
|
|
664
|
+
marker.textContent = markerText;
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
/**
|
|
668
|
+
* Format an ordered list marker based on the number and depth
|
|
669
|
+
*/
|
|
670
|
+
private formatOrderedMarker(number: number, depth: number): string {
|
|
671
|
+
const style = depth % 3;
|
|
672
|
+
|
|
673
|
+
if (style === 1) {
|
|
674
|
+
return `${this.numberToLowerAlpha(number)}.`;
|
|
675
|
+
}
|
|
676
|
+
if (style === 2) {
|
|
677
|
+
return `${this.numberToLowerRoman(number)}.`;
|
|
678
|
+
}
|
|
679
|
+
return `${number}.`;
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
/**
|
|
683
|
+
* Count preceding list items at the same depth and style for a given block index
|
|
684
|
+
*/
|
|
685
|
+
private countPrecedingSiblingsAtDepth(blockIndex: number, targetDepth: number, targetStyle?: ListItemStyle): number {
|
|
686
|
+
if (blockIndex <= 0) {
|
|
687
|
+
return 0;
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
return this.countPrecedingListItemsAtDepthFromIndex(blockIndex - 1, targetDepth, targetStyle);
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
/**
|
|
694
|
+
* Recursively count preceding list items at the given depth and style starting from index.
|
|
695
|
+
* Stops at style boundaries at the same depth (when encountering a different list style).
|
|
696
|
+
* Items at deeper depths are skipped regardless of their style.
|
|
697
|
+
*/
|
|
698
|
+
private countPrecedingListItemsAtDepthFromIndex(index: number, targetDepth: number, targetStyle?: ListItemStyle): number {
|
|
699
|
+
if (index < 0) {
|
|
700
|
+
return 0;
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
const block = this.api.blocks.getBlockByIndex(index);
|
|
704
|
+
if (!block || block.name !== ListItem.TOOL_NAME) {
|
|
705
|
+
return 0;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
const blockDepth = this.getBlockDepth(block);
|
|
709
|
+
|
|
710
|
+
if (blockDepth < targetDepth) {
|
|
711
|
+
return 0; // Hit a parent
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
// If at deeper depth, skip it and continue (ignore style at deeper depths)
|
|
715
|
+
if (blockDepth > targetDepth) {
|
|
716
|
+
return this.countPrecedingListItemsAtDepthFromIndex(index - 1, targetDepth, targetStyle);
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
// At same depth - check style boundary if targetStyle is provided
|
|
720
|
+
const blockStyle = this.getBlockStyle(block);
|
|
721
|
+
if (targetStyle !== undefined && blockStyle !== targetStyle) {
|
|
722
|
+
return 0; // Style boundary at same depth - treat as separate list
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
// Same depth and same style (or no style check) - count it and continue
|
|
726
|
+
return 1 + this.countPrecedingListItemsAtDepthFromIndex(index - 1, targetDepth, targetStyle);
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
/**
|
|
730
|
+
* Get the list start value for a block at a given index and depth
|
|
731
|
+
*/
|
|
732
|
+
private getListStartValueForBlock(blockIndex: number, targetDepth: number, siblingIndex: number, targetStyle?: ListItemStyle): number {
|
|
733
|
+
if (siblingIndex === 0) {
|
|
734
|
+
return this.getBlockStartValue(blockIndex);
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
// Find the first item in this list group
|
|
738
|
+
const firstItemIndex = this.findFirstListItemIndexFromBlock(blockIndex - 1, targetDepth, siblingIndex, targetStyle);
|
|
739
|
+
if (firstItemIndex === null) {
|
|
740
|
+
return 1;
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
return this.getBlockStartValue(firstItemIndex);
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
/**
|
|
747
|
+
* Get the start value from a block's data-list-start attribute
|
|
748
|
+
*/
|
|
749
|
+
private getBlockStartValue(blockIndex: number): number {
|
|
750
|
+
const block = this.api.blocks.getBlockByIndex(blockIndex);
|
|
751
|
+
if (!block) {
|
|
752
|
+
return 1;
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
const blockHolder = block.holder;
|
|
756
|
+
const listItemEl = blockHolder?.querySelector('[data-list-style]');
|
|
757
|
+
const startAttr = listItemEl?.getAttribute('data-list-start');
|
|
758
|
+
return startAttr ? parseInt(startAttr, 10) : 1;
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
/**
|
|
762
|
+
* Find the first list item in a consecutive group.
|
|
763
|
+
* Stops at style boundaries at the same depth (when encountering a different list style).
|
|
764
|
+
* Items at deeper depths are skipped regardless of their style.
|
|
765
|
+
*/
|
|
766
|
+
private findFirstListItemIndexFromBlock(index: number, targetDepth: number, remainingCount: number, targetStyle?: ListItemStyle): number | null {
|
|
767
|
+
if (index < 0 || remainingCount <= 0) {
|
|
768
|
+
return index + 1;
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
const block = this.api.blocks.getBlockByIndex(index);
|
|
772
|
+
if (!block || block.name !== ListItem.TOOL_NAME) {
|
|
773
|
+
return index + 1;
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
const blockDepth = this.getBlockDepth(block);
|
|
777
|
+
|
|
778
|
+
if (blockDepth < targetDepth) {
|
|
779
|
+
return index + 1;
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
// If at deeper depth, skip it and continue (ignore style at deeper depths)
|
|
783
|
+
if (blockDepth > targetDepth) {
|
|
784
|
+
return this.findFirstListItemIndexFromBlock(index - 1, targetDepth, remainingCount, targetStyle);
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
// At same depth - check style boundary if targetStyle is provided
|
|
788
|
+
const blockStyle = this.getBlockStyle(block);
|
|
789
|
+
if (targetStyle !== undefined && blockStyle !== targetStyle) {
|
|
790
|
+
return index + 1; // Style boundary at same depth - treat as separate list
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
// Same depth and same style - decrement count and continue
|
|
794
|
+
return this.findFirstListItemIndexFromBlock(index - 1, targetDepth, remainingCount - 1, targetStyle);
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
private createItemElement(): HTMLElement {
|
|
798
|
+
const { style } = this._data;
|
|
799
|
+
|
|
800
|
+
const wrapper = document.createElement('div');
|
|
801
|
+
wrapper.className = ListItem.BASE_STYLES;
|
|
802
|
+
wrapper.setAttribute(DATA_ATTR.tool, ListItem.TOOL_NAME);
|
|
803
|
+
wrapper.setAttribute('data-list-style', style);
|
|
804
|
+
wrapper.setAttribute('data-list-depth', String(this.getDepth()));
|
|
805
|
+
|
|
806
|
+
// Store start value as data attribute for sibling items to read
|
|
807
|
+
if (this._data.start !== undefined && this._data.start !== 1) {
|
|
808
|
+
wrapper.setAttribute('data-list-start', String(this._data.start));
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
const itemContent = style === 'checklist'
|
|
812
|
+
? this.createChecklistContent()
|
|
813
|
+
: this.createStandardContent();
|
|
814
|
+
|
|
815
|
+
wrapper.appendChild(itemContent);
|
|
816
|
+
|
|
817
|
+
if (!this.readOnly) {
|
|
818
|
+
wrapper.addEventListener('keydown', this.handleKeyDown.bind(this));
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
return wrapper;
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
/**
|
|
825
|
+
* Indentation padding per depth level in pixels
|
|
826
|
+
*/
|
|
827
|
+
private static readonly INDENT_PER_LEVEL = 24;
|
|
828
|
+
|
|
829
|
+
private createStandardContent(): HTMLElement {
|
|
830
|
+
const item = document.createElement('div');
|
|
831
|
+
item.setAttribute('role', 'listitem');
|
|
832
|
+
item.className = twMerge(ListItem.ITEM_STYLES, 'flex', ...PLACEHOLDER_CLASSES);
|
|
833
|
+
this.applyItemStyles(item);
|
|
834
|
+
|
|
835
|
+
// Apply indentation based on depth
|
|
836
|
+
const depth = this.getDepth();
|
|
837
|
+
if (depth > 0) {
|
|
838
|
+
item.style.marginLeft = `${depth * ListItem.INDENT_PER_LEVEL}px`;
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
// Create marker element (will be updated in rendered() with correct index)
|
|
842
|
+
const marker = this.createListMarker();
|
|
843
|
+
marker.setAttribute('data-list-marker', 'true');
|
|
844
|
+
item.appendChild(marker);
|
|
845
|
+
|
|
846
|
+
// Create content container
|
|
847
|
+
const contentContainer = document.createElement('div');
|
|
848
|
+
contentContainer.className = twMerge('flex-1 min-w-0 outline-none', ...PLACEHOLDER_CLASSES);
|
|
849
|
+
contentContainer.contentEditable = this.readOnly ? 'false' : 'true';
|
|
850
|
+
contentContainer.innerHTML = this._data.text;
|
|
851
|
+
this.setupItemPlaceholder(contentContainer);
|
|
852
|
+
|
|
853
|
+
item.appendChild(contentContainer);
|
|
854
|
+
return item;
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
private createChecklistContent(): HTMLElement {
|
|
858
|
+
const wrapper = document.createElement('div');
|
|
859
|
+
wrapper.setAttribute('role', 'listitem');
|
|
860
|
+
wrapper.className = ListItem.CHECKLIST_ITEM_STYLES;
|
|
861
|
+
this.applyItemStyles(wrapper);
|
|
862
|
+
|
|
863
|
+
// Apply indentation based on depth
|
|
864
|
+
const depth = this.getDepth();
|
|
865
|
+
if (depth > 0) {
|
|
866
|
+
wrapper.style.marginLeft = `${depth * ListItem.INDENT_PER_LEVEL}px`;
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
const checkbox = document.createElement('input');
|
|
870
|
+
checkbox.type = 'checkbox';
|
|
871
|
+
checkbox.className = ListItem.CHECKBOX_STYLES;
|
|
872
|
+
checkbox.checked = Boolean(this._data.checked);
|
|
873
|
+
checkbox.disabled = this.readOnly;
|
|
874
|
+
|
|
875
|
+
const content = document.createElement('div');
|
|
876
|
+
content.className = twMerge(
|
|
877
|
+
'flex-1 outline-none leading-[1.6em]',
|
|
878
|
+
this._data.checked ? 'line-through opacity-60' : '',
|
|
879
|
+
...PLACEHOLDER_CLASSES
|
|
880
|
+
);
|
|
881
|
+
content.contentEditable = this.readOnly ? 'false' : 'true';
|
|
882
|
+
content.innerHTML = this._data.text;
|
|
883
|
+
this.setupItemPlaceholder(content);
|
|
884
|
+
|
|
885
|
+
if (!this.readOnly) {
|
|
886
|
+
checkbox.addEventListener('change', () => {
|
|
887
|
+
this._data.checked = checkbox.checked;
|
|
888
|
+
content.classList.toggle('line-through', checkbox.checked);
|
|
889
|
+
content.classList.toggle('opacity-60', checkbox.checked);
|
|
890
|
+
});
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
wrapper.appendChild(checkbox);
|
|
894
|
+
wrapper.appendChild(content);
|
|
895
|
+
return wrapper;
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
/**
|
|
899
|
+
* Create the marker element (bullet or number) for a list item
|
|
900
|
+
*/
|
|
901
|
+
private createListMarker(): HTMLElement {
|
|
902
|
+
const marker = document.createElement('span');
|
|
903
|
+
marker.className = 'flex-shrink-0 select-none';
|
|
904
|
+
marker.setAttribute('aria-hidden', 'true');
|
|
905
|
+
marker.contentEditable = 'false';
|
|
906
|
+
|
|
907
|
+
// Get depth from block's parent chain (will be computed by the UI)
|
|
908
|
+
const depth = this.getDepth();
|
|
909
|
+
|
|
910
|
+
if (this._data.style === 'ordered') {
|
|
911
|
+
// Calculate the index of this item among consecutive ordered list siblings
|
|
912
|
+
const siblingIndex = this.getSiblingIndex();
|
|
913
|
+
const markerText = this.getOrderedMarkerText(siblingIndex, depth);
|
|
914
|
+
marker.textContent = markerText;
|
|
915
|
+
marker.className = twMerge(marker.className, 'text-right');
|
|
916
|
+
marker.style.paddingRight = '11px';
|
|
917
|
+
marker.style.minWidth = 'fit-content';
|
|
918
|
+
} else {
|
|
919
|
+
const bulletChar = this.getBulletCharacter(depth);
|
|
920
|
+
marker.textContent = bulletChar;
|
|
921
|
+
marker.className = twMerge(marker.className, 'w-6 text-center flex justify-center');
|
|
922
|
+
marker.style.paddingLeft = '1px';
|
|
923
|
+
marker.style.paddingRight = '13px';
|
|
924
|
+
marker.style.fontSize = '24px';
|
|
925
|
+
marker.style.fontFamily = 'Arial';
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
return marker;
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
/**
|
|
932
|
+
* Calculate the index of this ListItem among consecutive siblings with the same style.
|
|
933
|
+
* This is used to determine the correct number for ordered lists.
|
|
934
|
+
*/
|
|
935
|
+
private getSiblingIndex(): number {
|
|
936
|
+
// Try to get the current block's index using its ID, fallback to getCurrentBlockIndex
|
|
937
|
+
const currentBlockIndex = this.blockId
|
|
938
|
+
? this.api.blocks.getBlockIndex(this.blockId) ?? this.api.blocks.getCurrentBlockIndex()
|
|
939
|
+
: this.api.blocks.getCurrentBlockIndex();
|
|
940
|
+
|
|
941
|
+
// If we're the first block or blocks API isn't available, return 0
|
|
942
|
+
if (currentBlockIndex <= 0) {
|
|
943
|
+
return 0;
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
const currentDepth = this.getDepth();
|
|
947
|
+
|
|
948
|
+
// Count consecutive preceding listItem blocks at the same depth
|
|
949
|
+
return this.countPrecedingListItemsAtDepth(currentBlockIndex - 1, currentDepth);
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
/**
|
|
953
|
+
* The tool name used when registering this tool with Blok.
|
|
954
|
+
* Used to identify list blocks when counting siblings.
|
|
955
|
+
*/
|
|
956
|
+
private static readonly TOOL_NAME = 'list';
|
|
957
|
+
|
|
958
|
+
/**
|
|
959
|
+
* Recursively count consecutive preceding list blocks at the same depth and style.
|
|
960
|
+
* Stops when encountering a block that's not a list, a list at a shallower depth (parent),
|
|
961
|
+
* or a list with a different style at the same depth (treating style changes as list boundaries).
|
|
962
|
+
* Items at deeper depths are skipped regardless of their style.
|
|
963
|
+
*/
|
|
964
|
+
private countPrecedingListItemsAtDepth(index: number, targetDepth: number): number {
|
|
965
|
+
if (index < 0) {
|
|
966
|
+
return 0;
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
const block = this.api.blocks.getBlockByIndex(index);
|
|
970
|
+
if (!block || block.name !== ListItem.TOOL_NAME) {
|
|
971
|
+
return 0;
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
// We need to get the block's data to check its depth and style
|
|
975
|
+
// Since we can't directly access another block's tool data,
|
|
976
|
+
// we'll check via the DOM for the depth and style attributes
|
|
977
|
+
const blockHolder = block.holder;
|
|
978
|
+
const listItemEl = blockHolder?.querySelector('[data-list-style]');
|
|
979
|
+
|
|
980
|
+
const depthAttr = listItemEl?.querySelector('[role="listitem"]')?.getAttribute('style');
|
|
981
|
+
|
|
982
|
+
// Calculate depth from margin (marginLeft = depth * 24px)
|
|
983
|
+
const marginMatch = depthAttr?.match(/margin-left:\s*(\d+)px/);
|
|
984
|
+
const blockDepth = marginMatch ? Math.round(parseInt(marginMatch[1], 10) / ListItem.INDENT_PER_LEVEL) : 0;
|
|
985
|
+
|
|
986
|
+
// If this block is at a shallower depth, it's a "parent" - stop counting
|
|
987
|
+
if (blockDepth < targetDepth) {
|
|
988
|
+
return 0;
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
// If at deeper depth, skip it and continue checking (ignore style at deeper depths)
|
|
992
|
+
if (blockDepth > targetDepth) {
|
|
993
|
+
return this.countPrecedingListItemsAtDepth(index - 1, targetDepth);
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
// At same depth - check style boundary
|
|
997
|
+
const blockStyle = listItemEl?.getAttribute('data-list-style');
|
|
998
|
+
if (blockStyle !== this._data.style) {
|
|
999
|
+
return 0; // Style boundary at same depth - treat as separate list
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
// Same depth and same style - count it and continue
|
|
1003
|
+
return 1 + this.countPrecedingListItemsAtDepth(index - 1, targetDepth);
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
/**
|
|
1007
|
+
* Get the depth of this item in the hierarchy (0 = root level)
|
|
1008
|
+
*/
|
|
1009
|
+
private getDepth(): number {
|
|
1010
|
+
return this._data.depth ?? 0;
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
/**
|
|
1014
|
+
* Get the appropriate bullet character based on nesting depth
|
|
1015
|
+
*/
|
|
1016
|
+
private getBulletCharacter(depth: number): string {
|
|
1017
|
+
const bullets = ['•', '◦', '▪'];
|
|
1018
|
+
return bullets[depth % bullets.length];
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
/**
|
|
1022
|
+
* Get the ordered list marker text based on depth and index
|
|
1023
|
+
*/
|
|
1024
|
+
private getOrderedMarkerText(index: number, depth: number): string {
|
|
1025
|
+
// Get the start value from the first item in this list group
|
|
1026
|
+
const startValue = this.getListStartValue(index, depth);
|
|
1027
|
+
const actualNumber = startValue + index;
|
|
1028
|
+
const style = depth % 3;
|
|
1029
|
+
|
|
1030
|
+
switch (style) {
|
|
1031
|
+
case 0:
|
|
1032
|
+
return `${actualNumber}.`;
|
|
1033
|
+
case 1:
|
|
1034
|
+
return `${this.numberToLowerAlpha(actualNumber)}.`;
|
|
1035
|
+
case 2:
|
|
1036
|
+
return `${this.numberToLowerRoman(actualNumber)}.`;
|
|
1037
|
+
default:
|
|
1038
|
+
return `${actualNumber}.`;
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
/**
|
|
1043
|
+
* Get the starting number for this list group.
|
|
1044
|
+
* Looks up the first item in the consecutive list group to find its start value.
|
|
1045
|
+
*/
|
|
1046
|
+
private getListStartValue(siblingIndex: number, targetDepth: number): number {
|
|
1047
|
+
// If this is the first item (siblingIndex === 0), use our own start value
|
|
1048
|
+
if (siblingIndex === 0) {
|
|
1049
|
+
return this._data.start ?? 1;
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
// Find the first item in this list group by walking back siblingIndex blocks
|
|
1053
|
+
const currentBlockIndex = this.blockId
|
|
1054
|
+
? this.api.blocks.getBlockIndex(this.blockId) ?? this.api.blocks.getCurrentBlockIndex()
|
|
1055
|
+
: this.api.blocks.getCurrentBlockIndex();
|
|
1056
|
+
|
|
1057
|
+
const firstItemIndex = this.findFirstListItemIndex(currentBlockIndex - 1, targetDepth, siblingIndex);
|
|
1058
|
+
if (firstItemIndex === null) {
|
|
1059
|
+
return 1;
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
const firstBlock = this.api.blocks.getBlockByIndex(firstItemIndex);
|
|
1063
|
+
if (!firstBlock) {
|
|
1064
|
+
return 1;
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
// Get the start value from the first block's data attribute
|
|
1068
|
+
const blockHolder = firstBlock.holder;
|
|
1069
|
+
const listItemEl = blockHolder?.querySelector('[data-list-style]');
|
|
1070
|
+
const startAttr = listItemEl?.getAttribute('data-list-start');
|
|
1071
|
+
|
|
1072
|
+
return startAttr ? parseInt(startAttr, 10) : 1;
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
/**
|
|
1076
|
+
* Find the index of the first list item in this consecutive group.
|
|
1077
|
+
* Walks backwards through the blocks counting items at the same depth and style.
|
|
1078
|
+
* Stops at style boundaries at the same depth (when encountering a different list style).
|
|
1079
|
+
* Items at deeper depths are skipped regardless of their style.
|
|
1080
|
+
*/
|
|
1081
|
+
private findFirstListItemIndex(index: number, targetDepth: number, remainingCount: number): number | null {
|
|
1082
|
+
if (index < 0 || remainingCount <= 0) {
|
|
1083
|
+
return index + 1;
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
const block = this.api.blocks.getBlockByIndex(index);
|
|
1087
|
+
if (!block || block.name !== ListItem.TOOL_NAME) {
|
|
1088
|
+
return index + 1;
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
const blockHolder = block.holder;
|
|
1092
|
+
const listItemEl = blockHolder?.querySelector('[data-list-style]');
|
|
1093
|
+
|
|
1094
|
+
const depthAttr = listItemEl?.querySelector('[role="listitem"]')?.getAttribute('style');
|
|
1095
|
+
|
|
1096
|
+
const marginMatch = depthAttr?.match(/margin-left:\s*(\d+)px/);
|
|
1097
|
+
const blockDepth = marginMatch ? Math.round(parseInt(marginMatch[1], 10) / ListItem.INDENT_PER_LEVEL) : 0;
|
|
1098
|
+
|
|
1099
|
+
// If this block is at a shallower depth, we've reached the boundary
|
|
1100
|
+
if (blockDepth < targetDepth) {
|
|
1101
|
+
return index + 1;
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
// If at deeper depth, skip it and continue checking (ignore style at deeper depths)
|
|
1105
|
+
if (blockDepth > targetDepth) {
|
|
1106
|
+
return this.findFirstListItemIndex(index - 1, targetDepth, remainingCount);
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
// At same depth - check style boundary
|
|
1110
|
+
const blockStyle = listItemEl?.getAttribute('data-list-style');
|
|
1111
|
+
if (blockStyle !== this._data.style) {
|
|
1112
|
+
return index + 1; // Style boundary at same depth - treat as separate list
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
// Same depth and same style - decrement count and continue
|
|
1116
|
+
return this.findFirstListItemIndex(index - 1, targetDepth, remainingCount - 1);
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
private numberToLowerAlpha(num: number): string {
|
|
1120
|
+
const convertRecursive = (n: number): string => {
|
|
1121
|
+
if (n <= 0) return '';
|
|
1122
|
+
const adjusted = n - 1;
|
|
1123
|
+
return convertRecursive(Math.floor(adjusted / 26)) + String.fromCharCode(97 + (adjusted % 26));
|
|
1124
|
+
};
|
|
1125
|
+
return convertRecursive(num);
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
private numberToLowerRoman(num: number): string {
|
|
1129
|
+
const romanNumerals: [number, string][] = [
|
|
1130
|
+
[1000, 'm'], [900, 'cm'], [500, 'd'], [400, 'cd'],
|
|
1131
|
+
[100, 'c'], [90, 'xc'], [50, 'l'], [40, 'xl'],
|
|
1132
|
+
[10, 'x'], [9, 'ix'], [5, 'v'], [4, 'iv'], [1, 'i']
|
|
1133
|
+
];
|
|
1134
|
+
|
|
1135
|
+
const convertRecursive = (remaining: number, idx: number): string => {
|
|
1136
|
+
if (remaining <= 0 || idx >= romanNumerals.length) return '';
|
|
1137
|
+
const [value, numeral] = romanNumerals[idx];
|
|
1138
|
+
if (remaining >= value) {
|
|
1139
|
+
return numeral + convertRecursive(remaining - value, idx);
|
|
1140
|
+
}
|
|
1141
|
+
return convertRecursive(remaining, idx + 1);
|
|
1142
|
+
};
|
|
1143
|
+
|
|
1144
|
+
return convertRecursive(num, 0);
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
private handleKeyDown(event: KeyboardEvent): void {
|
|
1148
|
+
if (event.key === 'Enter' && !event.shiftKey) {
|
|
1149
|
+
event.preventDefault();
|
|
1150
|
+
void this.handleEnter();
|
|
1151
|
+
return;
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
if (event.key === 'Backspace') {
|
|
1155
|
+
void this.handleBackspace(event);
|
|
1156
|
+
return;
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
if (event.key === 'Tab' && event.shiftKey) {
|
|
1160
|
+
event.preventDefault();
|
|
1161
|
+
void this.handleOutdent();
|
|
1162
|
+
return;
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
if (event.key === 'Tab') {
|
|
1166
|
+
event.preventDefault();
|
|
1167
|
+
void this.handleIndent();
|
|
1168
|
+
}
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
private async handleEnter(): Promise<void> {
|
|
1172
|
+
const selection = window.getSelection();
|
|
1173
|
+
if (!selection || !this._element) return;
|
|
1174
|
+
|
|
1175
|
+
const contentEl = this.getContentElement();
|
|
1176
|
+
if (!contentEl) return;
|
|
1177
|
+
|
|
1178
|
+
const currentContent = contentEl.innerHTML.trim();
|
|
1179
|
+
|
|
1180
|
+
// If current item is empty, handle based on depth
|
|
1181
|
+
if (currentContent === '' || currentContent === '<br>') {
|
|
1182
|
+
await this.exitListOrOutdent();
|
|
1183
|
+
return;
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
// Split content and create new block
|
|
1187
|
+
const range = selection.getRangeAt(0);
|
|
1188
|
+
const { beforeContent, afterContent } = this.splitContentAtCursor(contentEl, range);
|
|
1189
|
+
|
|
1190
|
+
// Update current block with before content
|
|
1191
|
+
contentEl.innerHTML = beforeContent;
|
|
1192
|
+
this._data.text = beforeContent;
|
|
1193
|
+
|
|
1194
|
+
// Insert new list block after this one, preserving the depth
|
|
1195
|
+
const currentBlockIndex = this.api.blocks.getCurrentBlockIndex();
|
|
1196
|
+
const newBlock = this.api.blocks.insert(ListItem.TOOL_NAME, {
|
|
1197
|
+
text: afterContent,
|
|
1198
|
+
style: this._data.style,
|
|
1199
|
+
checked: false,
|
|
1200
|
+
depth: this._data.depth,
|
|
1201
|
+
}, undefined, currentBlockIndex + 1, true);
|
|
1202
|
+
|
|
1203
|
+
// Set caret to the start of the new block's content element
|
|
1204
|
+
this.setCaretToBlockContent(newBlock, 'start');
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
private async exitListOrOutdent(): Promise<void> {
|
|
1208
|
+
const currentBlockIndex = this.api.blocks.getCurrentBlockIndex();
|
|
1209
|
+
const currentDepth = this.getDepth();
|
|
1210
|
+
|
|
1211
|
+
// If nested, outdent instead of exiting
|
|
1212
|
+
if (currentDepth > 0) {
|
|
1213
|
+
await this.handleOutdent();
|
|
1214
|
+
return;
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
// At root level, convert to paragraph
|
|
1218
|
+
await this.api.blocks.delete(currentBlockIndex);
|
|
1219
|
+
const newBlock = this.api.blocks.insert('paragraph', { text: '' }, undefined, currentBlockIndex, true);
|
|
1220
|
+
this.setCaretToBlockContent(newBlock, 'start');
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
private async handleBackspace(event: KeyboardEvent): Promise<void> {
|
|
1224
|
+
const selection = window.getSelection();
|
|
1225
|
+
if (!selection || !this._element) return;
|
|
1226
|
+
|
|
1227
|
+
const range = selection.getRangeAt(0);
|
|
1228
|
+
const contentEl = this.getContentElement();
|
|
1229
|
+
if (!contentEl) return;
|
|
1230
|
+
|
|
1231
|
+
// Sync current content from DOM before any deletion happens
|
|
1232
|
+
// This is critical for preserving data when whole content is selected
|
|
1233
|
+
this.syncContentFromDOM();
|
|
1234
|
+
|
|
1235
|
+
const currentBlockIndex = this.api.blocks.getCurrentBlockIndex();
|
|
1236
|
+
const currentContent = this._data.text;
|
|
1237
|
+
const currentDepth = this.getDepth();
|
|
1238
|
+
|
|
1239
|
+
// Check if entire content is selected
|
|
1240
|
+
const isEntireContentSelected = this.isEntireContentSelected(contentEl, range);
|
|
1241
|
+
|
|
1242
|
+
// Handle case when entire content is selected and deleted
|
|
1243
|
+
// Just clear the content and show placeholder - don't delete the block
|
|
1244
|
+
if (isEntireContentSelected && !selection.isCollapsed) {
|
|
1245
|
+
event.preventDefault();
|
|
1246
|
+
|
|
1247
|
+
// Clear the content and update data
|
|
1248
|
+
contentEl.innerHTML = '';
|
|
1249
|
+
this._data.text = '';
|
|
1250
|
+
|
|
1251
|
+
// Set caret to the now-empty content element
|
|
1252
|
+
const newRange = document.createRange();
|
|
1253
|
+
newRange.setStart(contentEl, 0);
|
|
1254
|
+
newRange.collapse(true);
|
|
1255
|
+
selection.removeAllRanges();
|
|
1256
|
+
selection.addRange(newRange);
|
|
1257
|
+
|
|
1258
|
+
return;
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
// Only handle at start of content for non-selection cases
|
|
1262
|
+
if (!this.isAtStart(contentEl, range)) return;
|
|
1263
|
+
|
|
1264
|
+
event.preventDefault();
|
|
1265
|
+
|
|
1266
|
+
// Convert to paragraph (preserving indentation for nested items)
|
|
1267
|
+
await this.api.blocks.delete(currentBlockIndex);
|
|
1268
|
+
const newBlock = this.api.blocks.insert(
|
|
1269
|
+
'paragraph',
|
|
1270
|
+
{ text: currentContent },
|
|
1271
|
+
undefined,
|
|
1272
|
+
currentBlockIndex,
|
|
1273
|
+
true
|
|
1274
|
+
);
|
|
1275
|
+
|
|
1276
|
+
// Apply indentation to the new paragraph if the list item was nested
|
|
1277
|
+
if (currentDepth > 0) {
|
|
1278
|
+
requestAnimationFrame(() => {
|
|
1279
|
+
const holder = newBlock.holder;
|
|
1280
|
+
if (holder) {
|
|
1281
|
+
holder.style.marginLeft = `${currentDepth * ListItem.INDENT_PER_LEVEL}px`;
|
|
1282
|
+
holder.setAttribute('data-blok-depth', String(currentDepth));
|
|
1283
|
+
}
|
|
1284
|
+
});
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
this.setCaretToBlockContent(newBlock, 'start');
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
/**
|
|
1291
|
+
* Collect all text nodes from an element
|
|
1292
|
+
* @param node - Node to collect text nodes from
|
|
1293
|
+
* @returns Array of text nodes
|
|
1294
|
+
*/
|
|
1295
|
+
private collectTextNodes(node: Node): Text[] {
|
|
1296
|
+
if (node.nodeType === Node.TEXT_NODE) {
|
|
1297
|
+
return [node as Text];
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
if (!node.hasChildNodes?.()) {
|
|
1301
|
+
return [];
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
return Array.from(node.childNodes).flatMap((child) => this.collectTextNodes(child));
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
/**
|
|
1308
|
+
* Find the text node and offset for a given character position
|
|
1309
|
+
* @param textNodes - Array of text nodes to search through
|
|
1310
|
+
* @param targetPosition - Character position to find
|
|
1311
|
+
* @returns Object with node and offset, or null if not found
|
|
1312
|
+
*/
|
|
1313
|
+
private findCaretPosition(textNodes: Text[], targetPosition: number): { node: Text; offset: number } | null {
|
|
1314
|
+
const result = textNodes.reduce<{ found: boolean; charCount: number; node: Text | null; offset: number }>(
|
|
1315
|
+
(acc, node) => {
|
|
1316
|
+
if (acc.found) return acc;
|
|
1317
|
+
|
|
1318
|
+
const nodeLength = node.textContent?.length ?? 0;
|
|
1319
|
+
if (acc.charCount + nodeLength >= targetPosition) {
|
|
1320
|
+
return {
|
|
1321
|
+
found: true,
|
|
1322
|
+
charCount: acc.charCount,
|
|
1323
|
+
node,
|
|
1324
|
+
offset: targetPosition - acc.charCount,
|
|
1325
|
+
};
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
return {
|
|
1329
|
+
...acc,
|
|
1330
|
+
charCount: acc.charCount + nodeLength,
|
|
1331
|
+
};
|
|
1332
|
+
},
|
|
1333
|
+
{ found: false, charCount: 0, node: null, offset: 0 }
|
|
1334
|
+
);
|
|
1335
|
+
|
|
1336
|
+
return result.node ? { node: result.node, offset: result.offset } : null;
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1339
|
+
/**
|
|
1340
|
+
* Sync the current DOM content to the data model
|
|
1341
|
+
*/
|
|
1342
|
+
private syncContentFromDOM(): void {
|
|
1343
|
+
const contentEl = this.getContentElement();
|
|
1344
|
+
if (contentEl) {
|
|
1345
|
+
this._data.text = contentEl.innerHTML;
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
// For checklist, also sync the checked state
|
|
1349
|
+
if (this._data.style !== 'checklist') {
|
|
1350
|
+
return;
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1353
|
+
const checkbox = this._element?.querySelector('input[type="checkbox"]') as HTMLInputElement;
|
|
1354
|
+
if (checkbox) {
|
|
1355
|
+
this._data.checked = checkbox.checked;
|
|
1356
|
+
}
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
/**
|
|
1360
|
+
* Get the depth of the parent list item by walking backwards through preceding items.
|
|
1361
|
+
* A parent is the first preceding list item with a depth less than the current item.
|
|
1362
|
+
* @param blockIndex - The index of the current block
|
|
1363
|
+
* @returns The parent's depth, or -1 if no parent exists (at root level)
|
|
1364
|
+
*/
|
|
1365
|
+
private getParentDepth(blockIndex: number): number {
|
|
1366
|
+
const currentDepth = this.getDepth();
|
|
1367
|
+
|
|
1368
|
+
const findParentDepth = (index: number): number => {
|
|
1369
|
+
if (index < 0) {
|
|
1370
|
+
return -1;
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1373
|
+
const block = this.api.blocks.getBlockByIndex(index);
|
|
1374
|
+
if (!block || block.name !== ListItem.TOOL_NAME) {
|
|
1375
|
+
// Hit a non-list block, no parent in this list
|
|
1376
|
+
return -1;
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
const blockDepth = this.getBlockDepth(block);
|
|
1380
|
+
if (blockDepth < currentDepth) {
|
|
1381
|
+
// Found a parent (shallower depth)
|
|
1382
|
+
return blockDepth;
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
return findParentDepth(index - 1);
|
|
1386
|
+
};
|
|
1387
|
+
|
|
1388
|
+
return findParentDepth(blockIndex - 1);
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1391
|
+
private async handleIndent(): Promise<void> {
|
|
1392
|
+
const currentBlockIndex = this.api.blocks.getCurrentBlockIndex();
|
|
1393
|
+
if (currentBlockIndex === 0) return;
|
|
1394
|
+
|
|
1395
|
+
const previousBlock = this.api.blocks.getBlockByIndex(currentBlockIndex - 1);
|
|
1396
|
+
if (!previousBlock || previousBlock.name !== ListItem.TOOL_NAME) return;
|
|
1397
|
+
|
|
1398
|
+
const currentDepth = this.getDepth();
|
|
1399
|
+
const previousBlockDepth = this.getBlockDepth(previousBlock);
|
|
1400
|
+
|
|
1401
|
+
// Can only indent to at most one level deeper than the previous item
|
|
1402
|
+
// This ensures proper parent-child hierarchy
|
|
1403
|
+
if (currentDepth > previousBlockDepth) return;
|
|
1404
|
+
|
|
1405
|
+
// Sync current content before updating
|
|
1406
|
+
this.syncContentFromDOM();
|
|
1407
|
+
|
|
1408
|
+
// Increase depth by 1
|
|
1409
|
+
const newDepth = currentDepth + 1;
|
|
1410
|
+
this._data.depth = newDepth;
|
|
1411
|
+
|
|
1412
|
+
// Update the block data and re-render
|
|
1413
|
+
const updatedBlock = await this.api.blocks.update(this.blockId || '', {
|
|
1414
|
+
...this._data,
|
|
1415
|
+
depth: newDepth,
|
|
1416
|
+
});
|
|
1417
|
+
|
|
1418
|
+
// Restore focus to the updated block after DOM has been updated
|
|
1419
|
+
this.setCaretToBlockContent(updatedBlock);
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1422
|
+
private async handleOutdent(): Promise<void> {
|
|
1423
|
+
const currentDepth = this.getDepth();
|
|
1424
|
+
|
|
1425
|
+
// Can't outdent if already at root level
|
|
1426
|
+
if (currentDepth === 0) return;
|
|
1427
|
+
|
|
1428
|
+
// Sync current content before updating
|
|
1429
|
+
this.syncContentFromDOM();
|
|
1430
|
+
|
|
1431
|
+
// Decrease depth by 1
|
|
1432
|
+
const newDepth = currentDepth - 1;
|
|
1433
|
+
this._data.depth = newDepth;
|
|
1434
|
+
|
|
1435
|
+
// Update the block data and re-render
|
|
1436
|
+
const updatedBlock = await this.api.blocks.update(this.blockId || '', {
|
|
1437
|
+
...this._data,
|
|
1438
|
+
depth: newDepth,
|
|
1439
|
+
});
|
|
1440
|
+
|
|
1441
|
+
// Restore focus to the updated block after DOM has been updated
|
|
1442
|
+
this.setCaretToBlockContent(updatedBlock);
|
|
1443
|
+
}
|
|
1444
|
+
|
|
1445
|
+
private getContentElement(): HTMLElement | null {
|
|
1446
|
+
if (!this._element) return null;
|
|
1447
|
+
|
|
1448
|
+
if (this._data.style === 'checklist') {
|
|
1449
|
+
return this._element.querySelector('[contenteditable]') as HTMLElement;
|
|
1450
|
+
}
|
|
1451
|
+
|
|
1452
|
+
const contentContainer = this._element.querySelector('div.flex-1') as HTMLElement;
|
|
1453
|
+
return contentContainer;
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
/**
|
|
1457
|
+
* Sets caret to the content element of a block after ensuring DOM is ready.
|
|
1458
|
+
* Uses requestAnimationFrame to wait for the browser to process DOM updates.
|
|
1459
|
+
* @param block - BlockAPI to set caret to
|
|
1460
|
+
* @param position - 'start' or 'end' position (defaults to 'end')
|
|
1461
|
+
*/
|
|
1462
|
+
private setCaretToBlockContent(block: ReturnType<typeof this.api.blocks.insert>, position: 'start' | 'end' = 'end'): void {
|
|
1463
|
+
// Use requestAnimationFrame to ensure DOM has been updated
|
|
1464
|
+
requestAnimationFrame(() => {
|
|
1465
|
+
const holder = block.holder;
|
|
1466
|
+
if (!holder) return;
|
|
1467
|
+
|
|
1468
|
+
// Find the contenteditable element within the new block
|
|
1469
|
+
const contentEl = holder.querySelector('[contenteditable="true"]') as HTMLElement;
|
|
1470
|
+
if (!contentEl) {
|
|
1471
|
+
// Fallback to setToBlock if no content element found
|
|
1472
|
+
this.api.caret.setToBlock(block, position);
|
|
1473
|
+
return;
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1476
|
+
// Focus the content element and set caret position
|
|
1477
|
+
contentEl.focus();
|
|
1478
|
+
|
|
1479
|
+
const selection = window.getSelection();
|
|
1480
|
+
if (!selection) return;
|
|
1481
|
+
|
|
1482
|
+
const range = document.createRange();
|
|
1483
|
+
|
|
1484
|
+
if (position === 'start') {
|
|
1485
|
+
range.setStart(contentEl, 0);
|
|
1486
|
+
range.collapse(true);
|
|
1487
|
+
} else {
|
|
1488
|
+
// Set to end of content
|
|
1489
|
+
range.selectNodeContents(contentEl);
|
|
1490
|
+
range.collapse(false);
|
|
1491
|
+
}
|
|
1492
|
+
|
|
1493
|
+
selection.removeAllRanges();
|
|
1494
|
+
selection.addRange(range);
|
|
1495
|
+
});
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1498
|
+
private isAtStart(element: HTMLElement, range: Range): boolean {
|
|
1499
|
+
const preCaretRange = document.createRange();
|
|
1500
|
+
preCaretRange.selectNodeContents(element);
|
|
1501
|
+
preCaretRange.setEnd(range.startContainer, range.startOffset);
|
|
1502
|
+
return preCaretRange.toString().length === 0;
|
|
1503
|
+
}
|
|
1504
|
+
|
|
1505
|
+
/**
|
|
1506
|
+
* Check if the entire content of an element is selected
|
|
1507
|
+
* @param element - The content element to check
|
|
1508
|
+
* @param range - The current selection range
|
|
1509
|
+
* @returns true if the entire content is selected
|
|
1510
|
+
*/
|
|
1511
|
+
private isEntireContentSelected(element: HTMLElement, range: Range): boolean {
|
|
1512
|
+
// Check if selection starts at the beginning
|
|
1513
|
+
const preCaretRange = document.createRange();
|
|
1514
|
+
preCaretRange.selectNodeContents(element);
|
|
1515
|
+
preCaretRange.setEnd(range.startContainer, range.startOffset);
|
|
1516
|
+
const isAtStart = preCaretRange.toString().length === 0;
|
|
1517
|
+
|
|
1518
|
+
// Check if selection ends at the end
|
|
1519
|
+
const postCaretRange = document.createRange();
|
|
1520
|
+
postCaretRange.selectNodeContents(element);
|
|
1521
|
+
postCaretRange.setStart(range.endContainer, range.endOffset);
|
|
1522
|
+
const isAtEnd = postCaretRange.toString().length === 0;
|
|
1523
|
+
|
|
1524
|
+
return isAtStart && isAtEnd;
|
|
1525
|
+
}
|
|
1526
|
+
|
|
1527
|
+
private splitContentAtCursor(contentEl: HTMLElement, range: Range): { beforeContent: string; afterContent: string } {
|
|
1528
|
+
const beforeRange = document.createRange();
|
|
1529
|
+
beforeRange.setStart(contentEl, 0);
|
|
1530
|
+
beforeRange.setEnd(range.startContainer, range.startOffset);
|
|
1531
|
+
|
|
1532
|
+
const afterRange = document.createRange();
|
|
1533
|
+
afterRange.setStart(range.endContainer, range.endOffset);
|
|
1534
|
+
afterRange.setEndAfter(contentEl.lastChild || contentEl);
|
|
1535
|
+
|
|
1536
|
+
return {
|
|
1537
|
+
beforeContent: this.getFragmentHTML(beforeRange.cloneContents()),
|
|
1538
|
+
afterContent: this.getFragmentHTML(afterRange.cloneContents()),
|
|
1539
|
+
};
|
|
1540
|
+
}
|
|
1541
|
+
|
|
1542
|
+
private getFragmentHTML(fragment: DocumentFragment): string {
|
|
1543
|
+
const div = document.createElement('div');
|
|
1544
|
+
div.appendChild(fragment);
|
|
1545
|
+
return div.innerHTML;
|
|
1546
|
+
}
|
|
1547
|
+
|
|
1548
|
+
public renderSettings(): MenuConfig {
|
|
1549
|
+
return this.availableStyles.map(styleConfig => ({
|
|
1550
|
+
icon: styleConfig.icon,
|
|
1551
|
+
label: this.api.i18n.t(`toolNames.${styleConfig.name}`),
|
|
1552
|
+
onActivate: (): void => this.setStyle(styleConfig.style),
|
|
1553
|
+
closeOnActivate: true,
|
|
1554
|
+
isActive: this._data.style === styleConfig.style,
|
|
1555
|
+
}));
|
|
1556
|
+
}
|
|
1557
|
+
|
|
1558
|
+
private setStyle(style: ListItemStyle): void {
|
|
1559
|
+
const previousStyle = this._data.style;
|
|
1560
|
+
this._data.style = style;
|
|
1561
|
+
this.rerender();
|
|
1562
|
+
|
|
1563
|
+
// If style changed, update all ordered list markers since style boundaries have changed
|
|
1564
|
+
if (previousStyle !== style) {
|
|
1565
|
+
requestAnimationFrame(() => {
|
|
1566
|
+
this.updateAllOrderedListMarkers();
|
|
1567
|
+
});
|
|
1568
|
+
}
|
|
1569
|
+
}
|
|
1570
|
+
|
|
1571
|
+
private rerender(): void {
|
|
1572
|
+
if (!this._element) return;
|
|
1573
|
+
|
|
1574
|
+
const parent = this._element.parentNode;
|
|
1575
|
+
if (!parent) return;
|
|
1576
|
+
|
|
1577
|
+
const newElement = this.createItemElement();
|
|
1578
|
+
parent.replaceChild(newElement, this._element);
|
|
1579
|
+
this._element = newElement;
|
|
1580
|
+
}
|
|
1581
|
+
|
|
1582
|
+
public validate(blockData: ListItemData): boolean {
|
|
1583
|
+
// List items can be empty (unlike paragraphs)
|
|
1584
|
+
return typeof blockData.text === 'string';
|
|
1585
|
+
}
|
|
1586
|
+
|
|
1587
|
+
public save(): ListItemData {
|
|
1588
|
+
if (!this._element) return this._data;
|
|
1589
|
+
|
|
1590
|
+
const contentEl = this.getContentElement();
|
|
1591
|
+
const text = contentEl ? stripFakeBackgroundElements(contentEl.innerHTML) : this._data.text;
|
|
1592
|
+
|
|
1593
|
+
const result: ListItemData = {
|
|
1594
|
+
text,
|
|
1595
|
+
style: this._data.style,
|
|
1596
|
+
checked: this._data.checked,
|
|
1597
|
+
};
|
|
1598
|
+
|
|
1599
|
+
if (this._data.start !== undefined && this._data.start !== 1) {
|
|
1600
|
+
result.start = this._data.start;
|
|
1601
|
+
}
|
|
1602
|
+
|
|
1603
|
+
if (this._data.depth !== undefined && this._data.depth > 0) {
|
|
1604
|
+
result.depth = this._data.depth;
|
|
1605
|
+
}
|
|
1606
|
+
|
|
1607
|
+
return result;
|
|
1608
|
+
}
|
|
1609
|
+
|
|
1610
|
+
public merge(data: ListItemData): void {
|
|
1611
|
+
if (!this._element) {
|
|
1612
|
+
return;
|
|
1613
|
+
}
|
|
1614
|
+
|
|
1615
|
+
this._data.text += data.text;
|
|
1616
|
+
|
|
1617
|
+
const contentEl = this.getContentElement();
|
|
1618
|
+
if (contentEl && data.text) {
|
|
1619
|
+
const fragment = this.parseHtml(data.text);
|
|
1620
|
+
contentEl.appendChild(fragment);
|
|
1621
|
+
contentEl.normalize();
|
|
1622
|
+
}
|
|
1623
|
+
}
|
|
1624
|
+
|
|
1625
|
+
/**
|
|
1626
|
+
* Parse HTML string into a DocumentFragment
|
|
1627
|
+
* @param html - HTML string to parse
|
|
1628
|
+
* @returns DocumentFragment with parsed nodes
|
|
1629
|
+
*/
|
|
1630
|
+
private parseHtml(html: string): DocumentFragment {
|
|
1631
|
+
const wrapper = document.createElement('div');
|
|
1632
|
+
wrapper.innerHTML = html.trim();
|
|
1633
|
+
|
|
1634
|
+
const fragment = document.createDocumentFragment();
|
|
1635
|
+
fragment.append(...Array.from(wrapper.childNodes));
|
|
1636
|
+
|
|
1637
|
+
return fragment;
|
|
1638
|
+
}
|
|
1639
|
+
|
|
1640
|
+
public static get conversionConfig(): ConversionConfig {
|
|
1641
|
+
return {
|
|
1642
|
+
export: (data: ListItemData): string => {
|
|
1643
|
+
return data.text;
|
|
1644
|
+
},
|
|
1645
|
+
import: (content: string): ListItemData => {
|
|
1646
|
+
return {
|
|
1647
|
+
text: content,
|
|
1648
|
+
style: 'unordered',
|
|
1649
|
+
checked: false,
|
|
1650
|
+
};
|
|
1651
|
+
},
|
|
1652
|
+
};
|
|
1653
|
+
}
|
|
1654
|
+
|
|
1655
|
+
public static get sanitize(): SanitizerConfig {
|
|
1656
|
+
return {
|
|
1657
|
+
text: {
|
|
1658
|
+
br: true,
|
|
1659
|
+
a: true,
|
|
1660
|
+
b: true,
|
|
1661
|
+
i: true,
|
|
1662
|
+
mark: true,
|
|
1663
|
+
},
|
|
1664
|
+
};
|
|
1665
|
+
}
|
|
1666
|
+
|
|
1667
|
+
public static get pasteConfig(): PasteConfig {
|
|
1668
|
+
return { tags: ['LI'] };
|
|
1669
|
+
}
|
|
1670
|
+
|
|
1671
|
+
public onPaste(event: PasteEvent): void {
|
|
1672
|
+
const detail = event.detail;
|
|
1673
|
+
if (!('data' in detail)) return;
|
|
1674
|
+
|
|
1675
|
+
const content = detail.data as HTMLElement;
|
|
1676
|
+
const text = content.innerHTML || content.textContent || '';
|
|
1677
|
+
|
|
1678
|
+
// Check for checked state if checklist
|
|
1679
|
+
const checkbox = content.querySelector('input[type="checkbox"]') as HTMLInputElement;
|
|
1680
|
+
const checked = checkbox?.checked || false;
|
|
1681
|
+
|
|
1682
|
+
this._data = {
|
|
1683
|
+
text,
|
|
1684
|
+
style: this.detectStyleFromPastedContent(content),
|
|
1685
|
+
checked,
|
|
1686
|
+
};
|
|
1687
|
+
|
|
1688
|
+
this.rerender();
|
|
1689
|
+
}
|
|
1690
|
+
|
|
1691
|
+
/**
|
|
1692
|
+
* Detect list style from pasted content based on parent element
|
|
1693
|
+
*/
|
|
1694
|
+
private detectStyleFromPastedContent(content: HTMLElement): ListItemStyle {
|
|
1695
|
+
const parentList = content.parentElement;
|
|
1696
|
+
if (!parentList) return this._data.style;
|
|
1697
|
+
|
|
1698
|
+
if (parentList.tagName === 'OL') return 'ordered';
|
|
1699
|
+
if (parentList.tagName !== 'UL') return this._data.style;
|
|
1700
|
+
|
|
1701
|
+
// Check for checkbox inputs to detect checklist
|
|
1702
|
+
const hasCheckbox = content.querySelector('input[type="checkbox"]');
|
|
1703
|
+
return hasCheckbox ? 'checklist' : 'unordered';
|
|
1704
|
+
}
|
|
1705
|
+
|
|
1706
|
+
public static get isReadOnlySupported(): boolean {
|
|
1707
|
+
return true;
|
|
1708
|
+
}
|
|
1709
|
+
|
|
1710
|
+
/**
|
|
1711
|
+
* Returns the horizontal offset of the content at the hovered element.
|
|
1712
|
+
* Used by the toolbar to position itself closer to nested list items.
|
|
1713
|
+
*
|
|
1714
|
+
* @param hoveredElement - The element that is currently being hovered
|
|
1715
|
+
* @returns Object with left offset in pixels based on the list item's depth
|
|
1716
|
+
*/
|
|
1717
|
+
public getContentOffset(hoveredElement: Element): { left: number } | undefined {
|
|
1718
|
+
// First try: find listitem in ancestors (when hovering content)
|
|
1719
|
+
// Second try: find listitem in descendants (when hovering wrapper)
|
|
1720
|
+
const listItemEl = hoveredElement.closest('[role="listitem"]')
|
|
1721
|
+
?? hoveredElement.querySelector('[role="listitem"]');
|
|
1722
|
+
|
|
1723
|
+
const marginLeftOffset = this.getMarginLeftFromElement(listItemEl);
|
|
1724
|
+
|
|
1725
|
+
if (marginLeftOffset !== undefined) {
|
|
1726
|
+
return marginLeftOffset;
|
|
1727
|
+
}
|
|
1728
|
+
|
|
1729
|
+
// Fallback: use data-list-depth from wrapper
|
|
1730
|
+
return this.getOffsetFromDepthAttribute(hoveredElement);
|
|
1731
|
+
}
|
|
1732
|
+
|
|
1733
|
+
/**
|
|
1734
|
+
* Extracts the margin-left value from an element's inline style
|
|
1735
|
+
* @param element - The element to extract margin-left from
|
|
1736
|
+
* @returns Object with left offset if valid margin-left found, undefined otherwise
|
|
1737
|
+
*/
|
|
1738
|
+
private getMarginLeftFromElement(element: Element | null): { left: number } | undefined {
|
|
1739
|
+
if (!element) {
|
|
1740
|
+
return undefined;
|
|
1741
|
+
}
|
|
1742
|
+
|
|
1743
|
+
const style = element.getAttribute('style') || '';
|
|
1744
|
+
const marginMatch = style.match(/margin-left:\s*(\d+)px/);
|
|
1745
|
+
|
|
1746
|
+
if (!marginMatch) {
|
|
1747
|
+
return undefined;
|
|
1748
|
+
}
|
|
1749
|
+
|
|
1750
|
+
const marginLeft = parseInt(marginMatch[1], 10);
|
|
1751
|
+
|
|
1752
|
+
return marginLeft > 0 ? { left: marginLeft } : undefined;
|
|
1753
|
+
}
|
|
1754
|
+
|
|
1755
|
+
/**
|
|
1756
|
+
* Gets the offset from the data-list-depth attribute
|
|
1757
|
+
* @param hoveredElement - The element to start searching from
|
|
1758
|
+
* @returns Object with left offset based on depth, undefined if depth is 0 or not found
|
|
1759
|
+
*/
|
|
1760
|
+
private getOffsetFromDepthAttribute(hoveredElement: Element): { left: number } | undefined {
|
|
1761
|
+
const wrapper = hoveredElement.closest('[data-list-depth]');
|
|
1762
|
+
|
|
1763
|
+
if (!wrapper) {
|
|
1764
|
+
return undefined;
|
|
1765
|
+
}
|
|
1766
|
+
|
|
1767
|
+
const depthAttr = wrapper.getAttribute('data-list-depth');
|
|
1768
|
+
|
|
1769
|
+
if (depthAttr === null) {
|
|
1770
|
+
return undefined;
|
|
1771
|
+
}
|
|
1772
|
+
|
|
1773
|
+
const depth = parseInt(depthAttr, 10);
|
|
1774
|
+
|
|
1775
|
+
return depth > 0 ? { left: depth * ListItem.INDENT_PER_LEVEL } : undefined;
|
|
1776
|
+
}
|
|
1777
|
+
|
|
1778
|
+
public static get toolbox(): ToolboxConfig {
|
|
1779
|
+
return [
|
|
1780
|
+
{
|
|
1781
|
+
icon: IconListBulleted,
|
|
1782
|
+
title: 'Bulleted list',
|
|
1783
|
+
titleKey: 'bulletedList',
|
|
1784
|
+
data: { style: 'unordered' },
|
|
1785
|
+
name: 'bulleted-list',
|
|
1786
|
+
},
|
|
1787
|
+
{
|
|
1788
|
+
icon: IconListNumbered,
|
|
1789
|
+
title: 'Numbered list',
|
|
1790
|
+
titleKey: 'numberedList',
|
|
1791
|
+
data: { style: 'ordered' },
|
|
1792
|
+
name: 'numbered-list',
|
|
1793
|
+
},
|
|
1794
|
+
{
|
|
1795
|
+
icon: IconListChecklist,
|
|
1796
|
+
title: 'To-do list',
|
|
1797
|
+
titleKey: 'todoList',
|
|
1798
|
+
data: { style: 'checklist' },
|
|
1799
|
+
name: 'check-list',
|
|
1800
|
+
},
|
|
1801
|
+
];
|
|
1802
|
+
}
|
|
1803
|
+
}
|