@shelamkoff/rector 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/NOTICE +17 -0
- package/README.md +23 -0
- package/THIRD_PARTY_LICENSES.txt +298 -0
- package/dist/I18n.d.ts +1 -0
- package/dist/core/Block.d.ts +110 -0
- package/dist/core/Block.js +390 -0
- package/dist/core/BlockAnimator.d.ts +39 -0
- package/dist/core/BlockAnimator.js +106 -0
- package/dist/core/BlockManager.d.ts +163 -0
- package/dist/core/BlockManager.js +640 -0
- package/dist/core/BlockOperations.d.ts +67 -0
- package/dist/core/BlockOperations.js +238 -0
- package/dist/core/ChangeNotifier.d.ts +15 -0
- package/dist/core/ChangeNotifier.js +55 -0
- package/dist/core/CommandDispatcher.d.ts +52 -0
- package/dist/core/CommandDispatcher.js +208 -0
- package/dist/core/CrossBlockSelection.d.ts +30 -0
- package/dist/core/CrossBlockSelection.js +74 -0
- package/dist/core/Diagnostics.d.ts +23 -0
- package/dist/core/Diagnostics.js +62 -0
- package/dist/core/DocumentSchema.d.ts +29 -0
- package/dist/core/DocumentSchema.js +147 -0
- package/dist/core/DocumentSnapshotStore.d.ts +34 -0
- package/dist/core/DocumentSnapshotStore.js +153 -0
- package/dist/core/DragManager.d.ts +17 -0
- package/dist/core/DragManager.js +223 -0
- package/dist/core/EditorFacade.d.ts +154 -0
- package/dist/core/EditorFacade.js +361 -0
- package/dist/core/EditorHolderOwnership.d.ts +12 -0
- package/dist/core/EditorHolderOwnership.js +28 -0
- package/dist/core/I18n.d.ts +147 -0
- package/dist/core/I18n.js +296 -0
- package/dist/core/InlinePatternMatcher.d.ts +26 -0
- package/dist/core/InlinePatternMatcher.js +267 -0
- package/dist/core/InlinePluginRegistry.d.ts +55 -0
- package/dist/core/InlinePluginRegistry.js +128 -0
- package/dist/core/KeyboardManager.d.ts +17 -0
- package/dist/core/KeyboardManager.js +231 -0
- package/dist/core/LifecycleScope.d.ts +19 -0
- package/dist/core/LifecycleScope.js +41 -0
- package/dist/core/MouseSelectionManager.d.ts +30 -0
- package/dist/core/MouseSelectionManager.js +307 -0
- package/dist/core/PluginOwnership.d.ts +9 -0
- package/dist/core/PluginOwnership.js +40 -0
- package/dist/core/PopupManager.d.ts +50 -0
- package/dist/core/PopupManager.js +183 -0
- package/dist/core/PreservedBlockPlugin.d.ts +11 -0
- package/dist/core/PreservedBlockPlugin.js +42 -0
- package/dist/core/PublicEditorApi.d.ts +82 -0
- package/dist/core/PublicEditorApi.js +158 -0
- package/dist/core/SelectionManager.d.ts +50 -0
- package/dist/core/SelectionManager.js +205 -0
- package/dist/core/ShortcutRegistry.d.ts +29 -0
- package/dist/core/ShortcutRegistry.js +106 -0
- package/dist/core/SlashCommands.d.ts +34 -0
- package/dist/core/SlashCommands.js +483 -0
- package/dist/core/StyleInjector.d.ts +1 -0
- package/dist/core/StyleInjector.js +1 -0
- package/dist/core/Tooltip.d.ts +17 -0
- package/dist/core/Tooltip.js +60 -0
- package/dist/core/TriggerManager.d.ts +17 -0
- package/dist/core/TriggerManager.js +159 -0
- package/dist/core/TypeSelector.d.ts +31 -0
- package/dist/core/TypeSelector.js +464 -0
- package/dist/core/UndoManager.d.ts +72 -0
- package/dist/core/UndoManager.js +383 -0
- package/dist/core/block-settings-menu/BlockActions.d.ts +81 -0
- package/dist/core/block-settings-menu/BlockActions.js +233 -0
- package/dist/core/block-settings-menu/BlockSettingsMenu.d.ts +46 -0
- package/dist/core/block-settings-menu/BlockSettingsMenu.js +205 -0
- package/dist/core/block-settings-menu/MenuBuilder.d.ts +43 -0
- package/dist/core/block-settings-menu/MenuBuilder.js +203 -0
- package/dist/core/block-settings-menu/MenuPositioner.d.ts +22 -0
- package/dist/core/block-settings-menu/MenuPositioner.js +79 -0
- package/dist/core/clipboard/Clipboard.d.ts +47 -0
- package/dist/core/clipboard/Clipboard.js +653 -0
- package/dist/core/clipboard/CrossBlockEditor.d.ts +43 -0
- package/dist/core/clipboard/CrossBlockEditor.js +198 -0
- package/dist/core/clipboard/PasteRouter.d.ts +27 -0
- package/dist/core/clipboard/PasteRouter.js +86 -0
- package/dist/core/clipboard/pasteInsert.d.ts +35 -0
- package/dist/core/clipboard/pasteInsert.js +215 -0
- package/dist/core/clipboard/pasteUtils.d.ts +25 -0
- package/dist/core/clipboard/pasteUtils.js +98 -0
- package/dist/core/config.d.ts +77 -0
- package/dist/core/config.js +100 -0
- package/dist/core/constants.d.ts +23 -0
- package/dist/core/constants.js +30 -0
- package/dist/core/crossBlockConvert.d.ts +38 -0
- package/dist/core/crossBlockConvert.js +253 -0
- package/dist/core/dom.d.ts +35 -0
- package/dist/core/dom.js +65 -0
- package/dist/core/editorEvents.d.ts +89 -0
- package/dist/core/editorEvents.js +60 -0
- package/dist/core/hydrateInlinePlugins.d.ts +9 -0
- package/dist/core/hydrateInlinePlugins.js +36 -0
- package/dist/core/icons.d.ts +10 -0
- package/dist/core/icons.js +21 -0
- package/dist/core/index.d.ts +59 -0
- package/dist/core/index.js +703 -0
- package/dist/core/inline-toolbar/ActionsPanel.d.ts +61 -0
- package/dist/core/inline-toolbar/ActionsPanel.js +132 -0
- package/dist/core/inline-toolbar/InlinePositioner.d.ts +20 -0
- package/dist/core/inline-toolbar/InlinePositioner.js +63 -0
- package/dist/core/inline-toolbar/InlineToolbar.d.ts +45 -0
- package/dist/core/inline-toolbar/InlineToolbar.js +456 -0
- package/dist/core/inline-toolbar/PluginControlsSlot.d.ts +52 -0
- package/dist/core/inline-toolbar/PluginControlsSlot.js +118 -0
- package/dist/core/inline-toolbar/SelectionTracker.d.ts +63 -0
- package/dist/core/inline-toolbar/SelectionTracker.js +173 -0
- package/dist/core/inlinePluginInsert.d.ts +13 -0
- package/dist/core/inlinePluginInsert.js +56 -0
- package/dist/core/locale/en.d.ts +35 -0
- package/dist/core/locale/en.js +37 -0
- package/dist/core/locale/ru.d.ts +35 -0
- package/dist/core/locale/ru.js +37 -0
- package/dist/core/menuKeyboardNav.d.ts +15 -0
- package/dist/core/menuKeyboardNav.js +52 -0
- package/dist/core/sanitize.d.ts +2 -0
- package/dist/core/sanitize.js +6 -0
- package/dist/core/splitConvert.d.ts +46 -0
- package/dist/core/splitConvert.js +190 -0
- package/dist/core/textOffset.d.ts +46 -0
- package/dist/core/textOffset.js +180 -0
- package/dist/core/themes/dark.css +30 -0
- package/dist/core/themes/light.css +33 -0
- package/dist/core/themes/variables.css +1170 -0
- package/dist/core/toolbar/OffcanvasRoot.d.ts +37 -0
- package/dist/core/toolbar/OffcanvasRoot.js +127 -0
- package/dist/core/toolbar/Toolbar.d.ts +70 -0
- package/dist/core/toolbar/Toolbar.js +432 -0
- package/dist/core/toolbar/ToolbarPositioner.d.ts +60 -0
- package/dist/core/toolbar/ToolbarPositioner.js +188 -0
- package/dist/core/toolbar/ToolboxBuilder.d.ts +44 -0
- package/dist/core/toolbar/ToolboxBuilder.js +260 -0
- package/dist/core/types.d.ts +747 -0
- package/dist/core/uid.d.ts +6 -0
- package/dist/core/uid.js +29 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/inline-plugins/README.md +35 -0
- package/dist/inline-plugins/README.ru.md +35 -0
- package/dist/inline-plugins/color/README.md +61 -0
- package/dist/inline-plugins/color/README.ru.md +61 -0
- package/dist/inline-plugins/color.d.ts +8 -0
- package/dist/inline-plugins/color.js +184 -0
- package/dist/inline-plugins/locale/en.d.ts +7 -0
- package/dist/inline-plugins/locale/en.js +6 -0
- package/dist/inline-plugins/locale/ru.d.ts +7 -0
- package/dist/inline-plugins/locale/ru.js +6 -0
- package/dist/inline-plugins/mention/README.md +97 -0
- package/dist/inline-plugins/mention/README.ru.md +97 -0
- package/dist/inline-plugins/mention/index.d.ts +110 -0
- package/dist/inline-plugins/mention/index.js +1897 -0
- package/dist/inline-plugins/mention/styles.css +183 -0
- package/dist/inline-plugins/mention/widget.d.ts +16 -0
- package/dist/inline-plugins/mention/widget.js +75 -0
- package/dist/inline-tools/align.d.ts +14 -0
- package/dist/inline-tools/align.js +120 -0
- package/dist/inline-tools/bold.d.ts +8 -0
- package/dist/inline-tools/bold.js +12 -0
- package/dist/inline-tools/caseTransform.d.ts +8 -0
- package/dist/inline-tools/caseTransform.js +87 -0
- package/dist/inline-tools/clearFormatting.d.ts +8 -0
- package/dist/inline-tools/clearFormatting.js +74 -0
- package/dist/inline-tools/code.d.ts +8 -0
- package/dist/inline-tools/code.js +12 -0
- package/dist/inline-tools/colorPicker.d.ts +7 -0
- package/dist/inline-tools/colorPicker.js +271 -0
- package/dist/inline-tools/defaults.d.ts +10 -0
- package/dist/inline-tools/defaults.js +57 -0
- package/dist/inline-tools/fontSize.d.ts +7 -0
- package/dist/inline-tools/fontSize.js +524 -0
- package/dist/inline-tools/italic.d.ts +8 -0
- package/dist/inline-tools/italic.js +12 -0
- package/dist/inline-tools/link.d.ts +12 -0
- package/dist/inline-tools/link.js +251 -0
- package/dist/inline-tools/marker.d.ts +8 -0
- package/dist/inline-tools/marker.js +12 -0
- package/dist/inline-tools/scriptTool.d.ts +11 -0
- package/dist/inline-tools/scriptTool.js +149 -0
- package/dist/inline-tools/strikethrough.d.ts +8 -0
- package/dist/inline-tools/strikethrough.js +12 -0
- package/dist/inline-tools/utils.d.ts +220 -0
- package/dist/inline-tools/utils.js +992 -0
- package/dist/locale/en.d.ts +311 -0
- package/dist/locale/en.js +52 -0
- package/dist/locale/ru.d.ts +315 -0
- package/dist/locale/ru.js +52 -0
- package/dist/plugins/BlockPluginAbstract.d.ts +82 -0
- package/dist/plugins/BlockPluginAbstract.js +111 -0
- package/dist/plugins/README.md +44 -0
- package/dist/plugins/README.ru.md +40 -0
- package/dist/plugins/async.d.ts +35 -0
- package/dist/plugins/async.js +79 -0
- package/dist/plugins/attaches/README.md +86 -0
- package/dist/plugins/attaches/README.ru.md +87 -0
- package/dist/plugins/attaches/attaches.css +374 -0
- package/dist/plugins/attaches/index.d.ts +133 -0
- package/dist/plugins/attaches/index.js +921 -0
- package/dist/plugins/attaches/locale/en.d.ts +23 -0
- package/dist/plugins/attaches/locale/en.js +19 -0
- package/dist/plugins/attaches/locale/keys.d.ts +5 -0
- package/dist/plugins/attaches/locale/ru.d.ts +25 -0
- package/dist/plugins/attaches/locale/ru.js +19 -0
- package/dist/plugins/carousel/README.md +108 -0
- package/dist/plugins/carousel/README.ru.md +109 -0
- package/dist/plugins/carousel/carousel.css +198 -0
- package/dist/plugins/carousel/icons.d.ts +12 -0
- package/dist/plugins/carousel/icons.js +13 -0
- package/dist/plugins/carousel/index.d.ts +137 -0
- package/dist/plugins/carousel/index.js +859 -0
- package/dist/plugins/carousel/locale/en.d.ts +41 -0
- package/dist/plugins/carousel/locale/en.js +40 -0
- package/dist/plugins/carousel/locale/keys.d.ts +2 -0
- package/dist/plugins/carousel/locale/ru.d.ts +41 -0
- package/dist/plugins/carousel/locale/ru.js +40 -0
- package/dist/plugins/carousel/state.d.ts +29 -0
- package/dist/plugins/carousel/state.js +42 -0
- package/dist/plugins/checklist/README.md +54 -0
- package/dist/plugins/checklist/README.ru.md +55 -0
- package/dist/plugins/checklist/checklist.css +80 -0
- package/dist/plugins/checklist/index.d.ts +75 -0
- package/dist/plugins/checklist/index.js +407 -0
- package/dist/plugins/checklist/locale/en.d.ts +5 -0
- package/dist/plugins/checklist/locale/en.js +4 -0
- package/dist/plugins/checklist/locale/keys.d.ts +5 -0
- package/dist/plugins/checklist/locale/ru.d.ts +5 -0
- package/dist/plugins/checklist/locale/ru.js +4 -0
- package/dist/plugins/checklist/mapTextFields.d.ts +1 -0
- package/dist/plugins/checklist/mapTextFields.js +1 -0
- package/dist/plugins/code/README.md +59 -0
- package/dist/plugins/code/README.ru.md +60 -0
- package/dist/plugins/code/code.css +360 -0
- package/dist/plugins/code/index.d.ts +109 -0
- package/dist/plugins/code/index.js +1012 -0
- package/dist/plugins/code/locale/en.d.ts +11 -0
- package/dist/plugins/code/locale/en.js +10 -0
- package/dist/plugins/code/locale/keys.d.ts +5 -0
- package/dist/plugins/code/locale/ru.d.ts +11 -0
- package/dist/plugins/code/locale/ru.js +10 -0
- package/dist/plugins/columns/README.md +55 -0
- package/dist/plugins/columns/README.ru.md +56 -0
- package/dist/plugins/columns/columns.css +103 -0
- package/dist/plugins/columns/index.d.ts +58 -0
- package/dist/plugins/columns/index.js +236 -0
- package/dist/plugins/columns/locale/en.d.ts +6 -0
- package/dist/plugins/columns/locale/en.js +5 -0
- package/dist/plugins/columns/locale/keys.d.ts +5 -0
- package/dist/plugins/columns/locale/ru.d.ts +6 -0
- package/dist/plugins/columns/locale/ru.js +5 -0
- package/dist/plugins/delimiter/README.md +48 -0
- package/dist/plugins/delimiter/README.ru.md +49 -0
- package/dist/plugins/delimiter/delimiter.css +22 -0
- package/dist/plugins/delimiter/index.d.ts +38 -0
- package/dist/plugins/delimiter/index.js +69 -0
- package/dist/plugins/delimiter/locale/en.d.ts +4 -0
- package/dist/plugins/delimiter/locale/en.js +3 -0
- package/dist/plugins/delimiter/locale/keys.d.ts +5 -0
- package/dist/plugins/delimiter/locale/ru.d.ts +4 -0
- package/dist/plugins/delimiter/locale/ru.js +3 -0
- package/dist/plugins/embed/README.md +81 -0
- package/dist/plugins/embed/README.ru.md +82 -0
- package/dist/plugins/embed/embed.css +558 -0
- package/dist/plugins/embed/index.d.ts +271 -0
- package/dist/plugins/embed/index.js +967 -0
- package/dist/plugins/embed/locale/en.d.ts +20 -0
- package/dist/plugins/embed/locale/en.js +19 -0
- package/dist/plugins/embed/locale/keys.d.ts +5 -0
- package/dist/plugins/embed/locale/ru.d.ts +20 -0
- package/dist/plugins/embed/locale/ru.js +19 -0
- package/dist/plugins/embed/player.d.ts +1 -0
- package/dist/plugins/embed/player.js +1 -0
- package/dist/plugins/gallery/README.md +92 -0
- package/dist/plugins/gallery/README.ru.md +93 -0
- package/dist/plugins/gallery/css.d.ts +52 -0
- package/dist/plugins/gallery/css.js +67 -0
- package/dist/plugins/gallery/gallery.css +860 -0
- package/dist/plugins/gallery/icons.d.ts +13 -0
- package/dist/plugins/gallery/icons.js +45 -0
- package/dist/plugins/gallery/index.d.ts +126 -0
- package/dist/plugins/gallery/index.js +355 -0
- package/dist/plugins/gallery/layout.d.ts +35 -0
- package/dist/plugins/gallery/layout.js +79 -0
- package/dist/plugins/gallery/locale/en.d.ts +36 -0
- package/dist/plugins/gallery/locale/en.js +35 -0
- package/dist/plugins/gallery/locale/keys.d.ts +5 -0
- package/dist/plugins/gallery/locale/ru.d.ts +36 -0
- package/dist/plugins/gallery/locale/ru.js +35 -0
- package/dist/plugins/gallery/settings.d.ts +25 -0
- package/dist/plugins/gallery/settings.js +199 -0
- package/dist/plugins/gallery/slot.d.ts +73 -0
- package/dist/plugins/gallery/slot.js +260 -0
- package/dist/plugins/gallery/state.d.ts +67 -0
- package/dist/plugins/gallery/state.js +133 -0
- package/dist/plugins/gallery/styles.d.ts +10 -0
- package/dist/plugins/gallery/styles.js +24 -0
- package/dist/plugins/gallery/uploader.d.ts +31 -0
- package/dist/plugins/gallery/uploader.js +111 -0
- package/dist/plugins/gallery/view-empty.d.ts +41 -0
- package/dist/plugins/gallery/view-empty.js +56 -0
- package/dist/plugins/gallery/view-filled.d.ts +55 -0
- package/dist/plugins/gallery/view-filled.js +353 -0
- package/dist/plugins/heading/HeadingLevelSelect.d.ts +16 -0
- package/dist/plugins/heading/HeadingLevelSelect.js +183 -0
- package/dist/plugins/heading/README.md +58 -0
- package/dist/plugins/heading/README.ru.md +59 -0
- package/dist/plugins/heading/heading.css +76 -0
- package/dist/plugins/heading/index.d.ts +124 -0
- package/dist/plugins/heading/index.js +273 -0
- package/dist/plugins/heading/locale/en.d.ts +11 -0
- package/dist/plugins/heading/locale/en.js +10 -0
- package/dist/plugins/heading/locale/keys.d.ts +5 -0
- package/dist/plugins/heading/locale/ru.d.ts +11 -0
- package/dist/plugins/heading/locale/ru.js +10 -0
- package/dist/plugins/heading/mapTextFields.d.ts +1 -0
- package/dist/plugins/heading/mapTextFields.js +1 -0
- package/dist/plugins/image/README.md +93 -0
- package/dist/plugins/image/README.ru.md +94 -0
- package/dist/plugins/image/css.d.ts +47 -0
- package/dist/plugins/image/css.js +47 -0
- package/dist/plugins/image/icons.d.ts +13 -0
- package/dist/plugins/image/icons.js +26 -0
- package/dist/plugins/image/image.css +559 -0
- package/dist/plugins/image/index.d.ts +129 -0
- package/dist/plugins/image/index.js +346 -0
- package/dist/plugins/image/locale/en.d.ts +39 -0
- package/dist/plugins/image/locale/en.js +38 -0
- package/dist/plugins/image/locale/keys.d.ts +5 -0
- package/dist/plugins/image/locale/ru.d.ts +39 -0
- package/dist/plugins/image/locale/ru.js +38 -0
- package/dist/plugins/image/settings.d.ts +23 -0
- package/dist/plugins/image/settings.js +347 -0
- package/dist/plugins/image/state.d.ts +86 -0
- package/dist/plugins/image/state.js +151 -0
- package/dist/plugins/image/styles.d.ts +21 -0
- package/dist/plugins/image/styles.js +58 -0
- package/dist/plugins/image/uploader.d.ts +31 -0
- package/dist/plugins/image/uploader.js +92 -0
- package/dist/plugins/image/view-empty.d.ts +44 -0
- package/dist/plugins/image/view-empty.js +61 -0
- package/dist/plugins/image/view-filled.d.ts +49 -0
- package/dist/plugins/image/view-filled.js +282 -0
- package/dist/plugins/index.d.ts +21 -0
- package/dist/plugins/index.js +22 -0
- package/dist/plugins/link-preview/README.md +69 -0
- package/dist/plugins/link-preview/README.ru.md +70 -0
- package/dist/plugins/link-preview/index.d.ts +169 -0
- package/dist/plugins/link-preview/index.js +698 -0
- package/dist/plugins/link-preview/link-preview.css +254 -0
- package/dist/plugins/link-preview/locale/en.d.ts +14 -0
- package/dist/plugins/link-preview/locale/en.js +13 -0
- package/dist/plugins/link-preview/locale/keys.d.ts +5 -0
- package/dist/plugins/link-preview/locale/ru.d.ts +14 -0
- package/dist/plugins/link-preview/locale/ru.js +13 -0
- package/dist/plugins/list/README.md +59 -0
- package/dist/plugins/list/README.ru.md +60 -0
- package/dist/plugins/list/index.d.ts +134 -0
- package/dist/plugins/list/index.js +566 -0
- package/dist/plugins/list/list.css +57 -0
- package/dist/plugins/list/locale/en.d.ts +5 -0
- package/dist/plugins/list/locale/en.js +4 -0
- package/dist/plugins/list/locale/keys.d.ts +5 -0
- package/dist/plugins/list/locale/ru.d.ts +5 -0
- package/dist/plugins/list/locale/ru.js +4 -0
- package/dist/plugins/list/mapTextFields.d.ts +1 -0
- package/dist/plugins/list/mapTextFields.js +1 -0
- package/dist/plugins/paragraph/README.md +57 -0
- package/dist/plugins/paragraph/README.ru.md +58 -0
- package/dist/plugins/paragraph/index.d.ts +112 -0
- package/dist/plugins/paragraph/index.js +158 -0
- package/dist/plugins/paragraph/locale/en.d.ts +5 -0
- package/dist/plugins/paragraph/locale/en.js +4 -0
- package/dist/plugins/paragraph/locale/keys.d.ts +5 -0
- package/dist/plugins/paragraph/locale/ru.d.ts +5 -0
- package/dist/plugins/paragraph/locale/ru.js +4 -0
- package/dist/plugins/paragraph/mapTextFields.d.ts +1 -0
- package/dist/plugins/paragraph/mapTextFields.js +1 -0
- package/dist/plugins/paragraph/paragraph.css +56 -0
- package/dist/plugins/person/README.md +72 -0
- package/dist/plugins/person/README.ru.md +73 -0
- package/dist/plugins/person/index.d.ts +189 -0
- package/dist/plugins/person/index.js +777 -0
- package/dist/plugins/person/locale/en.d.ts +18 -0
- package/dist/plugins/person/locale/en.js +17 -0
- package/dist/plugins/person/locale/keys.d.ts +5 -0
- package/dist/plugins/person/locale/ru.d.ts +18 -0
- package/dist/plugins/person/locale/ru.js +17 -0
- package/dist/plugins/person/person.css +431 -0
- package/dist/plugins/person/socialResolver.d.ts +18 -0
- package/dist/plugins/person/socialResolver.js +69 -0
- package/dist/plugins/poll/README.md +96 -0
- package/dist/plugins/poll/README.ru.md +97 -0
- package/dist/plugins/poll/index.d.ts +134 -0
- package/dist/plugins/poll/index.js +825 -0
- package/dist/plugins/poll/locale/en.d.ts +23 -0
- package/dist/plugins/poll/locale/en.js +22 -0
- package/dist/plugins/poll/locale/keys.d.ts +5 -0
- package/dist/plugins/poll/locale/ru.d.ts +23 -0
- package/dist/plugins/poll/locale/ru.js +22 -0
- package/dist/plugins/poll/poll.css +400 -0
- package/dist/plugins/quote/README.md +55 -0
- package/dist/plugins/quote/README.ru.md +56 -0
- package/dist/plugins/quote/index.d.ts +81 -0
- package/dist/plugins/quote/index.js +165 -0
- package/dist/plugins/quote/locale/en.d.ts +6 -0
- package/dist/plugins/quote/locale/en.js +5 -0
- package/dist/plugins/quote/locale/keys.d.ts +5 -0
- package/dist/plugins/quote/locale/ru.d.ts +6 -0
- package/dist/plugins/quote/locale/ru.js +5 -0
- package/dist/plugins/quote/mapTextFields.d.ts +1 -0
- package/dist/plugins/quote/mapTextFields.js +1 -0
- package/dist/plugins/quote/quote.css +72 -0
- package/dist/plugins/raw/README.md +54 -0
- package/dist/plugins/raw/README.ru.md +55 -0
- package/dist/plugins/raw/index.d.ts +62 -0
- package/dist/plugins/raw/index.js +227 -0
- package/dist/plugins/raw/locale/en.d.ts +7 -0
- package/dist/plugins/raw/locale/en.js +6 -0
- package/dist/plugins/raw/locale/keys.d.ts +5 -0
- package/dist/plugins/raw/locale/ru.d.ts +7 -0
- package/dist/plugins/raw/locale/ru.js +6 -0
- package/dist/plugins/raw/raw.css +127 -0
- package/dist/plugins/shared/actionBar.d.ts +19 -0
- package/dist/plugins/shared/actionBar.js +35 -0
- package/dist/plugins/shared/dropzone.d.ts +106 -0
- package/dist/plugins/shared/dropzone.js +107 -0
- package/dist/plugins/shared/fileInput.d.ts +44 -0
- package/dist/plugins/shared/fileInput.js +62 -0
- package/dist/plugins/spoiler/README.md +55 -0
- package/dist/plugins/spoiler/README.ru.md +56 -0
- package/dist/plugins/spoiler/index.d.ts +61 -0
- package/dist/plugins/spoiler/index.js +169 -0
- package/dist/plugins/spoiler/locale/en.d.ts +7 -0
- package/dist/plugins/spoiler/locale/en.js +6 -0
- package/dist/plugins/spoiler/locale/keys.d.ts +5 -0
- package/dist/plugins/spoiler/locale/ru.d.ts +7 -0
- package/dist/plugins/spoiler/locale/ru.js +6 -0
- package/dist/plugins/spoiler/spoiler.css +101 -0
- package/dist/plugins/table/README.md +55 -0
- package/dist/plugins/table/README.ru.md +56 -0
- package/dist/plugins/table/index.d.ts +85 -0
- package/dist/plugins/table/index.js +399 -0
- package/dist/plugins/table/locale/en.d.ts +9 -0
- package/dist/plugins/table/locale/en.js +8 -0
- package/dist/plugins/table/locale/keys.d.ts +5 -0
- package/dist/plugins/table/locale/ru.d.ts +9 -0
- package/dist/plugins/table/locale/ru.js +8 -0
- package/dist/plugins/table/table.css +58 -0
- package/dist/plugins/toggle/README.md +56 -0
- package/dist/plugins/toggle/README.ru.md +57 -0
- package/dist/plugins/toggle/index.d.ts +69 -0
- package/dist/plugins/toggle/index.js +191 -0
- package/dist/plugins/toggle/locale/en.d.ts +7 -0
- package/dist/plugins/toggle/locale/en.js +6 -0
- package/dist/plugins/toggle/locale/keys.d.ts +5 -0
- package/dist/plugins/toggle/locale/ru.d.ts +7 -0
- package/dist/plugins/toggle/locale/ru.js +6 -0
- package/dist/plugins/toggle/toggle.css +101 -0
- package/dist/plugins/warning/README.md +55 -0
- package/dist/plugins/warning/README.ru.md +56 -0
- package/dist/plugins/warning/index.d.ts +66 -0
- package/dist/plugins/warning/index.js +167 -0
- package/dist/plugins/warning/locale/en.d.ts +6 -0
- package/dist/plugins/warning/locale/en.js +5 -0
- package/dist/plugins/warning/locale/keys.d.ts +5 -0
- package/dist/plugins/warning/locale/ru.d.ts +6 -0
- package/dist/plugins/warning/locale/ru.js +5 -0
- package/dist/plugins/warning/warning.css +63 -0
- package/dist/renderer/async.d.ts +1 -0
- package/dist/renderer/async.js +7 -0
- package/dist/renderer/errors.d.ts +27 -0
- package/dist/renderer/errors.js +48 -0
- package/dist/renderer/index.d.ts +82 -0
- package/dist/renderer/index.js +413 -0
- package/dist/renderer/inline.d.ts +5 -0
- package/dist/renderer/inline.js +61 -0
- package/dist/renderer/locale/en.d.ts +35 -0
- package/dist/renderer/locale/en.js +29 -0
- package/dist/renderer/locale/ru.d.ts +37 -0
- package/dist/renderer/locale/ru.js +29 -0
- package/dist/renderer/renderers/README.md +7 -0
- package/dist/renderer/renderers/README.ru.md +5 -0
- package/dist/renderer/renderers/async.d.ts +51 -0
- package/dist/renderer/renderers/async.js +99 -0
- package/dist/renderer/renderers/attaches/README.md +48 -0
- package/dist/renderer/renderers/attaches/README.ru.md +48 -0
- package/dist/renderer/renderers/attaches/index.d.ts +37 -0
- package/dist/renderer/renderers/attaches/index.js +451 -0
- package/dist/renderer/renderers/attaches/locale/en.d.ts +11 -0
- package/dist/renderer/renderers/attaches/locale/en.js +7 -0
- package/dist/renderer/renderers/attaches/locale/ru.d.ts +13 -0
- package/dist/renderer/renderers/attaches/locale/ru.js +7 -0
- package/dist/renderer/renderers/attaches/styles.css +196 -0
- package/dist/renderer/renderers/carousel/README.md +43 -0
- package/dist/renderer/renderers/carousel/README.ru.md +43 -0
- package/dist/renderer/renderers/carousel/index.d.ts +7 -0
- package/dist/renderer/renderers/carousel/index.js +110 -0
- package/dist/renderer/renderers/carousel/styles.css +7 -0
- package/dist/renderer/renderers/checklist/README.md +33 -0
- package/dist/renderer/renderers/checklist/README.ru.md +33 -0
- package/dist/renderer/renderers/checklist/index.d.ts +7 -0
- package/dist/renderer/renderers/checklist/index.js +55 -0
- package/dist/renderer/renderers/checklist/styles.css +54 -0
- package/dist/renderer/renderers/code/README.md +33 -0
- package/dist/renderer/renderers/code/README.ru.md +33 -0
- package/dist/renderer/renderers/code/index.d.ts +9 -0
- package/dist/renderer/renderers/code/index.js +137 -0
- package/dist/renderer/renderers/code/locale/en.d.ts +5 -0
- package/dist/renderer/renderers/code/locale/en.js +4 -0
- package/dist/renderer/renderers/code/locale/ru.d.ts +5 -0
- package/dist/renderer/renderers/code/locale/ru.js +4 -0
- package/dist/renderer/renderers/code/styles.css +330 -0
- package/dist/renderer/renderers/columns/README.md +33 -0
- package/dist/renderer/renderers/columns/README.ru.md +33 -0
- package/dist/renderer/renderers/columns/index.d.ts +7 -0
- package/dist/renderer/renderers/columns/index.js +53 -0
- package/dist/renderer/renderers/columns/styles.css +5 -0
- package/dist/renderer/renderers/delimiter/README.md +33 -0
- package/dist/renderer/renderers/delimiter/README.ru.md +33 -0
- package/dist/renderer/renderers/delimiter/index.d.ts +7 -0
- package/dist/renderer/renderers/delimiter/index.js +28 -0
- package/dist/renderer/renderers/delimiter/styles.css +12 -0
- package/dist/renderer/renderers/embed/README.md +35 -0
- package/dist/renderer/renderers/embed/README.ru.md +35 -0
- package/dist/renderer/renderers/embed/index.d.ts +8 -0
- package/dist/renderer/renderers/embed/index.js +66 -0
- package/dist/renderer/renderers/embed/styles.css +217 -0
- package/dist/renderer/renderers/gallery/README.md +41 -0
- package/dist/renderer/renderers/gallery/README.ru.md +41 -0
- package/dist/renderer/renderers/gallery/index.d.ts +8 -0
- package/dist/renderer/renderers/gallery/index.js +329 -0
- package/dist/renderer/renderers/gallery/styles.css +419 -0
- package/dist/renderer/renderers/heading/README.md +33 -0
- package/dist/renderer/renderers/heading/README.ru.md +33 -0
- package/dist/renderer/renderers/heading/index.d.ts +8 -0
- package/dist/renderer/renderers/heading/index.js +38 -0
- package/dist/renderer/renderers/heading/styles.css +26 -0
- package/dist/renderer/renderers/image/README.md +42 -0
- package/dist/renderer/renderers/image/README.ru.md +42 -0
- package/dist/renderer/renderers/image/index.d.ts +7 -0
- package/dist/renderer/renderers/image/index.js +93 -0
- package/dist/renderer/renderers/image/styles.css +40 -0
- package/dist/renderer/renderers/index.d.ts +47 -0
- package/dist/renderer/renderers/index.js +125 -0
- package/dist/renderer/renderers/link-preview/README.md +43 -0
- package/dist/renderer/renderers/link-preview/README.ru.md +43 -0
- package/dist/renderer/renderers/link-preview/index.d.ts +9 -0
- package/dist/renderer/renderers/link-preview/index.js +118 -0
- package/dist/renderer/renderers/link-preview/styles.css +246 -0
- package/dist/renderer/renderers/list/README.md +33 -0
- package/dist/renderer/renderers/list/README.ru.md +33 -0
- package/dist/renderer/renderers/list/index.d.ts +8 -0
- package/dist/renderer/renderers/list/index.js +42 -0
- package/dist/renderer/renderers/list/styles.css +20 -0
- package/dist/renderer/renderers/paragraph/README.md +33 -0
- package/dist/renderer/renderers/paragraph/README.ru.md +33 -0
- package/dist/renderer/renderers/paragraph/index.d.ts +7 -0
- package/dist/renderer/renderers/paragraph/index.js +35 -0
- package/dist/renderer/renderers/paragraph/styles.css +3 -0
- package/dist/renderer/renderers/person/README.md +44 -0
- package/dist/renderer/renderers/person/README.ru.md +44 -0
- package/dist/renderer/renderers/person/index.d.ts +7 -0
- package/dist/renderer/renderers/person/index.js +213 -0
- package/dist/renderer/renderers/person/locale/en.d.ts +6 -0
- package/dist/renderer/renderers/person/locale/en.js +5 -0
- package/dist/renderer/renderers/person/locale/ru.d.ts +6 -0
- package/dist/renderer/renderers/person/locale/ru.js +5 -0
- package/dist/renderer/renderers/person/styles.css +47 -0
- package/dist/renderer/renderers/poll/README.md +68 -0
- package/dist/renderer/renderers/poll/README.ru.md +68 -0
- package/dist/renderer/renderers/poll/index.d.ts +9 -0
- package/dist/renderer/renderers/poll/index.js +339 -0
- package/dist/renderer/renderers/poll/styles.css +148 -0
- package/dist/renderer/renderers/quote/README.md +33 -0
- package/dist/renderer/renderers/quote/README.ru.md +33 -0
- package/dist/renderer/renderers/quote/index.d.ts +7 -0
- package/dist/renderer/renderers/quote/index.js +59 -0
- package/dist/renderer/renderers/quote/styles.css +43 -0
- package/dist/renderer/renderers/raw/README.md +33 -0
- package/dist/renderer/renderers/raw/README.ru.md +33 -0
- package/dist/renderer/renderers/raw/index.d.ts +7 -0
- package/dist/renderer/renderers/raw/index.js +27 -0
- package/dist/renderer/renderers/spoiler/README.md +33 -0
- package/dist/renderer/renderers/spoiler/README.ru.md +33 -0
- package/dist/renderer/renderers/spoiler/index.d.ts +9 -0
- package/dist/renderer/renderers/spoiler/index.js +84 -0
- package/dist/renderer/renderers/spoiler/locale/en.d.ts +4 -0
- package/dist/renderer/renderers/spoiler/locale/en.js +3 -0
- package/dist/renderer/renderers/spoiler/locale/ru.d.ts +4 -0
- package/dist/renderer/renderers/spoiler/locale/ru.js +3 -0
- package/dist/renderer/renderers/spoiler/styles.css +61 -0
- package/dist/renderer/renderers/table/README.md +33 -0
- package/dist/renderer/renderers/table/README.ru.md +33 -0
- package/dist/renderer/renderers/table/index.d.ts +7 -0
- package/dist/renderer/renderers/table/index.js +83 -0
- package/dist/renderer/renderers/table/styles.css +34 -0
- package/dist/renderer/renderers/toggle/README.md +33 -0
- package/dist/renderer/renderers/toggle/README.ru.md +33 -0
- package/dist/renderer/renderers/toggle/index.d.ts +7 -0
- package/dist/renderer/renderers/toggle/index.js +59 -0
- package/dist/renderer/renderers/toggle/styles.css +49 -0
- package/dist/renderer/renderers/warning/README.md +33 -0
- package/dist/renderer/renderers/warning/README.ru.md +33 -0
- package/dist/renderer/renderers/warning/index.d.ts +7 -0
- package/dist/renderer/renderers/warning/index.js +62 -0
- package/dist/renderer/renderers/warning/styles.css +41 -0
- package/dist/renderer/styles/base.css +168 -0
- package/dist/renderer/types.d.ts +464 -0
- package/dist/shared/blockDataNormalizers.d.ts +14 -0
- package/dist/shared/blockDataNormalizers.js +235 -0
- package/dist/shared/blockDataValidators.d.ts +81 -0
- package/dist/shared/blockDataValidators.js +313 -0
- package/dist/shared/blockTypes.d.ts +2 -0
- package/dist/shared/blockTypes.js +26 -0
- package/dist/shared/carouselData.d.ts +34 -0
- package/dist/shared/carouselData.js +111 -0
- package/dist/shared/cloneEditorData.d.ts +11 -0
- package/dist/shared/cloneEditorData.js +20 -0
- package/dist/shared/documentTypes.d.ts +35 -0
- package/dist/shared/embedPlayer.d.ts +42 -0
- package/dist/shared/embedPlayer.js +141 -0
- package/dist/shared/fileUtils.d.ts +33 -0
- package/dist/shared/fileUtils.js +109 -0
- package/dist/shared/highlightRuntime.d.ts +38 -0
- package/dist/shared/highlightRuntime.js +75 -0
- package/dist/shared/inlineMarshal.d.ts +82 -0
- package/dist/shared/inlineMarshal.js +215 -0
- package/dist/shared/jsonData.d.ts +11 -0
- package/dist/shared/jsonData.js +53 -0
- package/dist/shared/localeTypes.d.ts +11 -0
- package/dist/shared/mapTextFields.d.ts +89 -0
- package/dist/shared/mapTextFields.js +119 -0
- package/dist/shared/pollData.d.ts +90 -0
- package/dist/shared/pollData.js +231 -0
- package/dist/shared/resolvePath.d.ts +8 -0
- package/dist/shared/resolvePath.js +10 -0
- package/dist/shared/runtime/dompurify.js +2424 -0
- package/dist/shared/runtime/highlightBundle.js +10 -0
- package/dist/shared/runtime/jszip.js +13 -0
- package/dist/shared/sanitize/allowlist.d.ts +21 -0
- package/dist/shared/sanitize/allowlist.js +40 -0
- package/dist/shared/sanitize/escapeHtml.d.ts +6 -0
- package/dist/shared/sanitize/escapeHtml.js +11 -0
- package/dist/shared/sanitize/index.d.ts +8 -0
- package/dist/shared/sanitize/index.js +8 -0
- package/dist/shared/sanitize/parseInline.d.ts +11 -0
- package/dist/shared/sanitize/parseInline.js +25 -0
- package/dist/shared/sanitize/sanitizeHtml.d.ts +8 -0
- package/dist/shared/sanitize/sanitizeHtml.js +19 -0
- package/dist/shared/sanitize/sanitizeRawHtml.d.ts +15 -0
- package/dist/shared/sanitize/sanitizeRawHtml.js +162 -0
- package/dist/shared/sanitize/sanitizeStyle.d.ts +9 -0
- package/dist/shared/sanitize/sanitizeStyle.js +39 -0
- package/dist/shared/sanitize/sanitizeUrl.d.ts +28 -0
- package/dist/shared/sanitize/sanitizeUrl.js +86 -0
- package/dist/shared/sanitize/walker.d.ts +15 -0
- package/dist/shared/sanitize/walker.js +98 -0
- package/dist/shared/styleRegistry.d.ts +4 -0
- package/dist/shared/styleRegistry.js +38 -0
- package/dist/shared/textFormat.d.ts +32 -0
- package/dist/shared/textFormat.js +48 -0
- package/dist/shared/zipRuntime.d.ts +19 -0
- package/dist/shared/zipRuntime.js +54 -0
- package/dist/styles/editor.css +3 -0
- package/dist/types.d.ts +1 -0
- package/package.json +149 -0
|
@@ -0,0 +1,1897 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* Mention inline plugin.
|
|
4
|
+
*
|
|
5
|
+
* A port of `@shelamkoff/mentionjs` adapted to Rector's InlinePlugin
|
|
6
|
+
* contract. It preserves the customization surface (searchFunction,
|
|
7
|
+
* debounceDelay, noResultsText, dropdownClass, onMentionSelect, renderItem,
|
|
8
|
+
* renderNoResults, renderLoading), same dropdown behavior (debounced search,
|
|
9
|
+
* keyboard navigation, pagination on scroll, loading/no-results states).
|
|
10
|
+
*
|
|
11
|
+
* Differences from the original package:
|
|
12
|
+
* - Trigger detection is delegated to the editor's built-in `TriggerManager`
|
|
13
|
+
* (the plugin just reacts to `onEdit` callbacks); the caret-in-span
|
|
14
|
+
* gymnastics from the original are unnecessary here.
|
|
15
|
+
* - The committed widget uses the editor's canonical inline-widget
|
|
16
|
+
* representation: `<span data-inline-plugin="mention" data-id="<widget>"
|
|
17
|
+
* data-value="<entity>" class="oe-ip oe-ip--mention">@Name</span>`.
|
|
18
|
+
* The shared sanitize allowlist preserves this through save / render.
|
|
19
|
+
*
|
|
20
|
+
* Styling lives in `./styles.css` and is acquired through the shared style
|
|
21
|
+
* registry while the owning editor is mounted.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @typedef {import('./index').MentionItem} MentionItem
|
|
26
|
+
* @typedef {import('./index').MentionSearchResult} MentionSearchResult
|
|
27
|
+
* @typedef {import('./index').MentionSearchFunction} MentionSearchFunction
|
|
28
|
+
* @typedef {import('./index').MentionRenderItem} MentionRenderItem
|
|
29
|
+
* @typedef {import('./index').MentionRenderNoResults} MentionRenderNoResults
|
|
30
|
+
* @typedef {import('./index').MentionRenderLoading} MentionRenderLoading
|
|
31
|
+
* @typedef {import('./index').MentionPluginOptions} MentionPluginOptions
|
|
32
|
+
* @typedef {import('../../core/types').InlinePlugin} InlinePlugin
|
|
33
|
+
* @typedef {import('../../core/types').InlinePluginContext} InlinePluginContext
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
import { resolvePath } from '../../shared/resolvePath.js'
|
|
37
|
+
import { injectStyleUrls } from '../../core/StyleInjector.js'
|
|
38
|
+
import { createMentionWidget } from './widget.js'
|
|
39
|
+
import { setSafeUrlAttribute } from '../../shared/sanitize/sanitizeUrl.js'
|
|
40
|
+
|
|
41
|
+
/** Absolute URL to the plugin's stylesheet. Ref-counted via `injectStyleUrls`. */
|
|
42
|
+
const STYLES_URL = resolvePath('./styles.css', import.meta.url)
|
|
43
|
+
|
|
44
|
+
const DEFAULTS = Object.freeze({
|
|
45
|
+
trigger: '@',
|
|
46
|
+
debounceDelay: 300,
|
|
47
|
+
dropdownClass: '',
|
|
48
|
+
searchFunction: null,
|
|
49
|
+
onMentionSelect: null,
|
|
50
|
+
renderItem: null,
|
|
51
|
+
renderNoResults: null,
|
|
52
|
+
renderLoading: null,
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
let dropdownSequence = 0
|
|
56
|
+
const COMBOBOX_ARIA_ATTRIBUTES = Object.freeze([
|
|
57
|
+
'aria-controls',
|
|
58
|
+
'aria-expanded',
|
|
59
|
+
'aria-haspopup',
|
|
60
|
+
'aria-autocomplete',
|
|
61
|
+
'aria-activedescendant',
|
|
62
|
+
])
|
|
63
|
+
|
|
64
|
+
function createElement(tag, className) {
|
|
65
|
+
const el = document.createElement(tag)
|
|
66
|
+
if (className) el.className = className
|
|
67
|
+
return el
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function escapeHtml(str) {
|
|
71
|
+
const entities = /** @type {Record<string, string>} */ ({
|
|
72
|
+
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
|
73
|
+
})
|
|
74
|
+
return String(str).replace(/[&<>"']/g, s => entities[s] ?? s)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Keep only search entries that satisfy the public mention-item contract.
|
|
79
|
+
* Application-specific fields remain available to custom renderers.
|
|
80
|
+
* @param {unknown} raw
|
|
81
|
+
* @returns {{ items: MentionItem[], nextPageUrl: string | null }}
|
|
82
|
+
*/
|
|
83
|
+
function normalizeSearchResult(raw) {
|
|
84
|
+
const source = Array.isArray(raw)
|
|
85
|
+
? raw
|
|
86
|
+
: (raw && typeof raw === 'object' && Array.isArray(/** @type {any} */ (raw).items)
|
|
87
|
+
? /** @type {any} */ (raw).items
|
|
88
|
+
: [])
|
|
89
|
+
const items = source.filter(item => (
|
|
90
|
+
item
|
|
91
|
+
&& typeof item === 'object'
|
|
92
|
+
&& (typeof item.id === 'string' || (typeof item.id === 'number' && Number.isFinite(item.id)))
|
|
93
|
+
&& typeof item.name === 'string'
|
|
94
|
+
))
|
|
95
|
+
const cursor = !Array.isArray(raw) && raw && typeof raw === 'object'
|
|
96
|
+
? /** @type {any} */ (raw).nextPageUrl
|
|
97
|
+
: null
|
|
98
|
+
return {
|
|
99
|
+
items,
|
|
100
|
+
nextPageUrl: typeof cursor === 'string' && cursor ? cursor : null,
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
105
|
+
// DropdownUI — pure DOM layer. No editor knowledge.
|
|
106
|
+
// Ported from @shelamkoff/mentionjs DropdownUI (1:1 behavior, new class names).
|
|
107
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
108
|
+
|
|
109
|
+
class DropdownUI {
|
|
110
|
+
/** @param {{ dropdownClass?: string, renderItem?: MentionRenderItem | null, renderNoResults?: MentionRenderNoResults | null, renderLoading?: MentionRenderLoading | null, noResultsText: string, loadingText: string }} options */
|
|
111
|
+
constructor(options) {
|
|
112
|
+
this._options = options
|
|
113
|
+
/** @type {HTMLElement | null} */
|
|
114
|
+
this._el = null
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
get el() { return this._el }
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Mount inside the owning editor so the floating surface inherits the
|
|
121
|
+
* editor's theme tokens, including consumer-defined themes and runtime
|
|
122
|
+
* class changes.
|
|
123
|
+
* @param {HTMLElement} container
|
|
124
|
+
*/
|
|
125
|
+
mount(container) {
|
|
126
|
+
if (this._el) this.destroy()
|
|
127
|
+
|
|
128
|
+
const cls = ['oe-mention-dropdown', this._options.dropdownClass]
|
|
129
|
+
.filter(Boolean)
|
|
130
|
+
.join(' ')
|
|
131
|
+
|
|
132
|
+
this._el = createElement('div', cls)
|
|
133
|
+
this._el.id = `oe-mention-listbox-${++dropdownSequence}`
|
|
134
|
+
this._el.setAttribute('role', 'listbox')
|
|
135
|
+
container.appendChild(this._el)
|
|
136
|
+
return this._el
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* @param {MentionItem[]} items
|
|
141
|
+
* @param {number} selectedIndex
|
|
142
|
+
*/
|
|
143
|
+
render(items, selectedIndex) {
|
|
144
|
+
if (!this._el) return
|
|
145
|
+
this._el.innerHTML = ''
|
|
146
|
+
|
|
147
|
+
if (items.length === 0) {
|
|
148
|
+
this._el.appendChild(this._buildNoResults())
|
|
149
|
+
return
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
items.forEach((item, index) => {
|
|
153
|
+
this._el.appendChild(this._buildItem(item, index, selectedIndex))
|
|
154
|
+
})
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* @param {MentionItem[]} newItems
|
|
159
|
+
* @param {number} startIndex
|
|
160
|
+
* @param {number} selectedIndex
|
|
161
|
+
*/
|
|
162
|
+
appendItems(newItems, startIndex, selectedIndex) {
|
|
163
|
+
if (!this._el) return
|
|
164
|
+
newItems.forEach((item, i) => {
|
|
165
|
+
this._el?.appendChild(this._buildItem(item, startIndex + i, selectedIndex))
|
|
166
|
+
})
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* @param {MentionItem} data
|
|
171
|
+
* @param {number} index
|
|
172
|
+
* @param {number} selectedIndex
|
|
173
|
+
* @returns {HTMLElement}
|
|
174
|
+
*/
|
|
175
|
+
_buildItem(data, index, selectedIndex) {
|
|
176
|
+
if (this._options.renderItem) {
|
|
177
|
+
const custom = this._options.renderItem(data, index, index === selectedIndex)
|
|
178
|
+
if (custom instanceof HTMLElement) {
|
|
179
|
+
if (!custom.classList.contains('oe-mention-item')) custom.classList.add('oe-mention-item')
|
|
180
|
+
if (index === selectedIndex) custom.classList.add('oe-mention-item--active')
|
|
181
|
+
custom.dataset.index = String(index)
|
|
182
|
+
custom.id = `${this._el?.id || 'oe-mention-listbox'}-option-${index}`
|
|
183
|
+
custom.setAttribute('role', 'option')
|
|
184
|
+
custom.setAttribute('aria-selected', String(index === selectedIndex))
|
|
185
|
+
return custom
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const el = createElement('div', 'oe-mention-item' + (index === selectedIndex ? ' oe-mention-item--active' : ''))
|
|
190
|
+
el.dataset.index = String(index)
|
|
191
|
+
el.id = `${this._el?.id || 'oe-mention-listbox'}-option-${index}`
|
|
192
|
+
el.setAttribute('role', 'option')
|
|
193
|
+
el.setAttribute('aria-selected', String(index === selectedIndex))
|
|
194
|
+
|
|
195
|
+
if (data.avatar) {
|
|
196
|
+
const img = document.createElement('img')
|
|
197
|
+
setSafeUrlAttribute(img, 'src', data.avatar, 'media')
|
|
198
|
+
img.alt = ''
|
|
199
|
+
img.className = 'oe-mention-avatar'
|
|
200
|
+
el.appendChild(img)
|
|
201
|
+
} else {
|
|
202
|
+
const placeholder = createElement('div', 'oe-mention-avatar-placeholder')
|
|
203
|
+
placeholder.textContent = (data.name || '?').charAt(0).toUpperCase()
|
|
204
|
+
el.appendChild(placeholder)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const info = createElement('div', 'oe-mention-info')
|
|
208
|
+
const name = createElement('div', 'oe-mention-name')
|
|
209
|
+
name.textContent = data.name || ''
|
|
210
|
+
info.appendChild(name)
|
|
211
|
+
|
|
212
|
+
if (data.details) {
|
|
213
|
+
const details = createElement('div', 'oe-mention-details')
|
|
214
|
+
details.textContent = data.details
|
|
215
|
+
info.appendChild(details)
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
el.appendChild(info)
|
|
219
|
+
return el
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
_buildNoResults() {
|
|
223
|
+
if (this._options.renderNoResults) {
|
|
224
|
+
const custom = this._options.renderNoResults(this._options.noResultsText)
|
|
225
|
+
if (custom instanceof HTMLElement) {
|
|
226
|
+
custom.classList.add('oe-mention-no-results')
|
|
227
|
+
custom.setAttribute('role', 'status')
|
|
228
|
+
return custom
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const el = createElement('div', 'oe-mention-item oe-mention-no-results')
|
|
233
|
+
el.setAttribute('role', 'status')
|
|
234
|
+
el.innerHTML = `
|
|
235
|
+
<div class="oe-mention-avatar-placeholder">?</div>
|
|
236
|
+
<div class="oe-mention-info">
|
|
237
|
+
<div class="oe-mention-name">${escapeHtml(this._options.noResultsText)}</div>
|
|
238
|
+
</div>
|
|
239
|
+
`
|
|
240
|
+
return el
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** @param {number} selectedIndex */
|
|
244
|
+
updateSelection(selectedIndex) {
|
|
245
|
+
if (!this._el) return
|
|
246
|
+
this._el.querySelectorAll('.oe-mention-item[data-index]').forEach((item) => {
|
|
247
|
+
const i = parseInt(/** @type {HTMLElement} */ (item).dataset.index || '-1', 10)
|
|
248
|
+
item.classList.toggle('oe-mention-item--active', i === selectedIndex)
|
|
249
|
+
item.setAttribute('aria-selected', String(i === selectedIndex))
|
|
250
|
+
})
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
scrollToActive() {
|
|
254
|
+
if (!this._el) return
|
|
255
|
+
const active = this._el.querySelector('.oe-mention-item--active')
|
|
256
|
+
if (active) active.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
showLoading() {
|
|
260
|
+
if (!this._el || this._el.querySelector('.oe-mention-loading')) return
|
|
261
|
+
|
|
262
|
+
if (this._options.renderLoading) {
|
|
263
|
+
const custom = this._options.renderLoading()
|
|
264
|
+
if (custom instanceof HTMLElement) {
|
|
265
|
+
if (!custom.classList.contains('oe-mention-loading')) custom.classList.add('oe-mention-loading')
|
|
266
|
+
custom.setAttribute('role', 'status')
|
|
267
|
+
this._el.appendChild(custom)
|
|
268
|
+
return
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const loader = createElement('div', 'oe-mention-loading')
|
|
273
|
+
loader.setAttribute('role', 'status')
|
|
274
|
+
loader.innerHTML = `
|
|
275
|
+
<div class="oe-mention-item">
|
|
276
|
+
<div class="oe-mention-info"><div class="oe-mention-name">${escapeHtml(this._options.loadingText)}</div></div>
|
|
277
|
+
</div>
|
|
278
|
+
`
|
|
279
|
+
this._el.appendChild(loader)
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
hideLoading() {
|
|
283
|
+
if (!this._el) return
|
|
284
|
+
const loader = this._el.querySelector('.oe-mention-loading')
|
|
285
|
+
if (loader) loader.remove()
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** @param {{ top: number, left: number, cursorY: number, lineHeight?: number }} p */
|
|
289
|
+
position(p) {
|
|
290
|
+
if (!this._el) return
|
|
291
|
+
requestAnimationFrame(() => this._positionRaw(p))
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** @param {{ top: number, left: number, cursorY: number, lineHeight?: number }} p */
|
|
295
|
+
positionRaw(p) {
|
|
296
|
+
if (!this._el) return
|
|
297
|
+
this._positionRaw(p)
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** @param {{ top: number, left: number, cursorY: number, lineHeight?: number }} p */
|
|
301
|
+
_positionRaw({ top, left, cursorY, lineHeight = 20 }) {
|
|
302
|
+
if (!this._el) return
|
|
303
|
+
|
|
304
|
+
this._el.style.top = top + 'px'
|
|
305
|
+
this._el.style.left = left + 'px'
|
|
306
|
+
|
|
307
|
+
const rect = this._el.getBoundingClientRect()
|
|
308
|
+
const vw = window.innerWidth
|
|
309
|
+
const vh = window.innerHeight
|
|
310
|
+
let newLeft = left
|
|
311
|
+
let newTop = top
|
|
312
|
+
|
|
313
|
+
if (rect.right > vw - 10) newLeft = Math.max(10, left - (rect.right - vw + 10))
|
|
314
|
+
if (rect.left < 10) newLeft = 10
|
|
315
|
+
|
|
316
|
+
if (rect.bottom > vh - 10 && cursorY > vh / 2) {
|
|
317
|
+
newTop = top - this._el.offsetHeight - lineHeight
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
this._el.style.top = newTop + 'px'
|
|
321
|
+
this._el.style.left = newLeft + 'px'
|
|
322
|
+
this._el.classList.add('oe-mention-dropdown--active')
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
hide() {
|
|
326
|
+
if (this._el) this._el.classList.remove('oe-mention-dropdown--active')
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
destroy() {
|
|
330
|
+
if (this._el) {
|
|
331
|
+
this._el.remove()
|
|
332
|
+
this._el = null
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
338
|
+
// Factory
|
|
339
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Create an editor-scoped mention widget plugin. The returned instance owns
|
|
343
|
+
* its dropdown and listeners after `mount()` and releases them in `destroy()`.
|
|
344
|
+
* @param {MentionPluginOptions} [options]
|
|
345
|
+
* @returns {InlinePlugin}
|
|
346
|
+
*/
|
|
347
|
+
export function createMentionPlugin(options = {}) {
|
|
348
|
+
const opts = Object.assign({}, DEFAULTS, options)
|
|
349
|
+
|
|
350
|
+
// Editor-scoped resources are acquired later by mount(). Construction is
|
|
351
|
+
// deliberately side-effect free so failed registration cannot leak them.
|
|
352
|
+
/** @type {{ destroy(): void } | null} */
|
|
353
|
+
let styleHandle = null
|
|
354
|
+
/** @type {HTMLElement | null} */
|
|
355
|
+
let rootElement = null
|
|
356
|
+
|
|
357
|
+
// handleBeforeInput is attached to the owning editor root by mount().
|
|
358
|
+
|
|
359
|
+
/** @type {import('../../core/types').IScopedI18n | null} */
|
|
360
|
+
let i18n = null
|
|
361
|
+
/**
|
|
362
|
+
* Scoped i18n lookup — the editor passes an I18n instance pre-scoped to
|
|
363
|
+
* `inlinePlugin.mention.*`, so we use short keys (`'title'`, `'noResults'`,
|
|
364
|
+
* `'loading'`). Falls back to the provided English string when no i18n
|
|
365
|
+
* instance has been injected yet.
|
|
366
|
+
* @param {string} key
|
|
367
|
+
* @param {string} fallback
|
|
368
|
+
* @returns {string}
|
|
369
|
+
*/
|
|
370
|
+
const t = (key, fallback) => {
|
|
371
|
+
if (!i18n) return fallback
|
|
372
|
+
if (!i18n.has(key)) return fallback
|
|
373
|
+
const v = i18n.t(key)
|
|
374
|
+
return (v === undefined || v === null || v === '') ? fallback : v
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Active session — either a "fresh" trigger (user just typed `@` in plain
|
|
379
|
+
* text) or an "edit" session (user re-opened a committed pill by typing /
|
|
380
|
+
* deleting inside it). Both modes share the dropdown UI, keyboard nav,
|
|
381
|
+
* search pipeline, and outside-click handling.
|
|
382
|
+
*
|
|
383
|
+
* - `mode: 'fresh'` — anchor is a text node + offset in the parent block.
|
|
384
|
+
* The typed `@query` lives inline in that text node; commit replaces
|
|
385
|
+
* the range with a new widget span.
|
|
386
|
+
* - `mode: 'edit'` — anchor is an EXISTING committed span that the user
|
|
387
|
+
* is mutating. The span itself carries the query (`span.textContent`
|
|
388
|
+
* without the leading trigger); commit rewrites the span in place.
|
|
389
|
+
*
|
|
390
|
+
* @typedef {{
|
|
391
|
+
* mode: 'fresh' | 'edit',
|
|
392
|
+
* parentEl: HTMLElement,
|
|
393
|
+
* triggerTextNode: Text | null,
|
|
394
|
+
* triggerOffset: number,
|
|
395
|
+
* span: HTMLElement | null,
|
|
396
|
+
* dropdown: DropdownUI,
|
|
397
|
+
* keydownHandler: (e: KeyboardEvent) => void,
|
|
398
|
+
* selectionChangeHandler: () => void,
|
|
399
|
+
* outsideMouseDownHandler: (e: MouseEvent) => void,
|
|
400
|
+
* dropdownClickHandler: (e: MouseEvent) => void,
|
|
401
|
+
* dropdownMouseDownHandler: (e: MouseEvent) => void,
|
|
402
|
+
* dropdownScrollHandler: () => void,
|
|
403
|
+
* scrollResizeHandler: () => void,
|
|
404
|
+
* selectedIndex: number,
|
|
405
|
+
* results: MentionItem[],
|
|
406
|
+
* nextPageUrl: string | null,
|
|
407
|
+
* isLoadingMore: boolean,
|
|
408
|
+
* currentQuery: string,
|
|
409
|
+
* debounceTimer: ReturnType<typeof setTimeout> | null,
|
|
410
|
+
* debounceReject: ((err: Error) => void) | null,
|
|
411
|
+
* searchController: AbortController | null,
|
|
412
|
+
* searchRequestId: number,
|
|
413
|
+
* ctx: InlinePluginContext,
|
|
414
|
+
* committedInThisSession: boolean,
|
|
415
|
+
* ariaState: Map<string, string | null>,
|
|
416
|
+
* }} Session
|
|
417
|
+
*/
|
|
418
|
+
|
|
419
|
+
/** @type {Session | null} */
|
|
420
|
+
let session = null
|
|
421
|
+
|
|
422
|
+
/** @param {HTMLElement} element @returns {Map<string, string | null>} */
|
|
423
|
+
function captureComboboxAria(element) {
|
|
424
|
+
return new Map(COMBOBOX_ARIA_ATTRIBUTES.map(name => [name, element.getAttribute(name)]))
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/** @param {HTMLElement} element @param {Map<string, string | null>} state */
|
|
428
|
+
function restoreComboboxAria(element, state) {
|
|
429
|
+
for (const [name, value] of state) {
|
|
430
|
+
if (value === null) element.removeAttribute(name)
|
|
431
|
+
else element.setAttribute(name, value)
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* Last-known InlinePluginContext. `onEdit` / `hydrate` stash it here so
|
|
437
|
+
* edit-mode mutations (triggered via `beforeinput` on committed pills,
|
|
438
|
+
* outside of a TriggerManager-driven flow) can still call `notifyChanged`.
|
|
439
|
+
* @type {InlinePluginContext | null}
|
|
440
|
+
*/
|
|
441
|
+
let globalCtx = null
|
|
442
|
+
|
|
443
|
+
// ─── Caret / DOM helpers (ported verbatim from mentionjs) ──────────────
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* @param {Node} node
|
|
447
|
+
* @param {number} offset
|
|
448
|
+
*/
|
|
449
|
+
function setCaretAt(node, offset) {
|
|
450
|
+
const sel = window.getSelection()
|
|
451
|
+
if (!sel) return
|
|
452
|
+
const range = document.createRange()
|
|
453
|
+
range.setStart(node, offset)
|
|
454
|
+
range.collapse(true)
|
|
455
|
+
sel.removeAllRanges()
|
|
456
|
+
sel.addRange(range)
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/** @param {Node} node */
|
|
460
|
+
function setCaretAfterNode(node) {
|
|
461
|
+
const sel = window.getSelection()
|
|
462
|
+
if (!sel) return
|
|
463
|
+
const range = document.createRange()
|
|
464
|
+
range.setStartAfter(node)
|
|
465
|
+
range.collapse(true)
|
|
466
|
+
sel.removeAllRanges()
|
|
467
|
+
sel.addRange(range)
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/** @param {Node} node */
|
|
471
|
+
function setCaretBeforeNode(node) {
|
|
472
|
+
const sel = window.getSelection()
|
|
473
|
+
if (!sel) return
|
|
474
|
+
const range = document.createRange()
|
|
475
|
+
range.setStartBefore(node)
|
|
476
|
+
range.collapse(true)
|
|
477
|
+
sel.removeAllRanges()
|
|
478
|
+
sel.addRange(range)
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/** @param {string} str @param {number} offset @returns {number} */
|
|
482
|
+
function codePointStartBefore(str, offset) {
|
|
483
|
+
const end = Math.max(0, Math.min(offset, str.length))
|
|
484
|
+
if (end >= 2) {
|
|
485
|
+
const low = str.charCodeAt(end - 1)
|
|
486
|
+
const high = str.charCodeAt(end - 2)
|
|
487
|
+
if (low >= 0xDC00 && low <= 0xDFFF && high >= 0xD800 && high <= 0xDBFF) return end - 2
|
|
488
|
+
}
|
|
489
|
+
return Math.max(0, end - 1)
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/** @param {string} str @param {number} offset @returns {number} */
|
|
493
|
+
function codePointEndAt(str, offset) {
|
|
494
|
+
const start = Math.max(0, Math.min(offset, str.length))
|
|
495
|
+
if (start + 1 < str.length) {
|
|
496
|
+
const high = str.charCodeAt(start)
|
|
497
|
+
const low = str.charCodeAt(start + 1)
|
|
498
|
+
if (high >= 0xD800 && high <= 0xDBFF && low >= 0xDC00 && low <= 0xDFFF) return start + 2
|
|
499
|
+
}
|
|
500
|
+
return Math.min(str.length, start + 1)
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/**
|
|
504
|
+
* Find the nearest committed/editing mention span ancestor of the current
|
|
505
|
+
* selection's anchor node, or `null` if the caret is not inside one.
|
|
506
|
+
* Mirrors mentionjs `_getMentionSpan`.
|
|
507
|
+
* @returns {HTMLElement | null}
|
|
508
|
+
*/
|
|
509
|
+
function findMentionSpanAtCaret() {
|
|
510
|
+
const sel = window.getSelection()
|
|
511
|
+
if (!sel || !sel.anchorNode) return null
|
|
512
|
+
const node = sel.anchorNode
|
|
513
|
+
if (node.nodeType === Node.ELEMENT_NODE) {
|
|
514
|
+
const el = /** @type {HTMLElement} */ (node)
|
|
515
|
+
if (el.matches?.('[data-inline-plugin="mention"]')) return el
|
|
516
|
+
return el.closest?.('[data-inline-plugin="mention"]') ?? null
|
|
517
|
+
}
|
|
518
|
+
const parent = node.parentElement
|
|
519
|
+
if (!parent) return null
|
|
520
|
+
return parent.closest('[data-inline-plugin="mention"]')
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/** @param {Node} el */
|
|
524
|
+
function inDOM(el) {
|
|
525
|
+
return !!el && document.contains(el)
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/**
|
|
529
|
+
* Backspace at offset 0 inside a span — delete one char from the PREVIOUS
|
|
530
|
+
* sibling (or the previous mention, which we re-activate for further
|
|
531
|
+
* editing). Mirrors mentionjs `_backspaceBeforeSpan`.
|
|
532
|
+
* @param {HTMLElement} span
|
|
533
|
+
*/
|
|
534
|
+
function backspaceBeforeSpan(span) {
|
|
535
|
+
const prev = span.previousSibling
|
|
536
|
+
if (prev && prev.nodeType === Node.TEXT_NODE && prev.textContent && prev.textContent.length > 0) {
|
|
537
|
+
prev.textContent = prev.textContent.slice(0, codePointStartBefore(prev.textContent, prev.textContent.length))
|
|
538
|
+
if (prev.textContent.length === 0) prev.parentNode?.removeChild(prev)
|
|
539
|
+
setCaretBeforeNode(span)
|
|
540
|
+
return
|
|
541
|
+
}
|
|
542
|
+
if (prev && prev.nodeType === Node.ELEMENT_NODE) {
|
|
543
|
+
const prevEl = /** @type {HTMLElement} */ (prev)
|
|
544
|
+
if (prevEl.matches('[data-inline-plugin="mention"]')) {
|
|
545
|
+
// Walk into the previous committed mention for further editing.
|
|
546
|
+
const text = prevEl.firstChild
|
|
547
|
+
if (text?.nodeType === Node.TEXT_NODE) {
|
|
548
|
+
setCaretAt(text, text.textContent?.length ?? 0)
|
|
549
|
+
}
|
|
550
|
+
openEditSessionForSpan(prevEl)
|
|
551
|
+
return
|
|
552
|
+
}
|
|
553
|
+
// Some other inline widget — remove it whole.
|
|
554
|
+
prevEl.parentNode?.removeChild(prevEl)
|
|
555
|
+
setCaretBeforeNode(span)
|
|
556
|
+
return
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/**
|
|
561
|
+
* Forward-delete at offset 0 inside a span — treat as "delete the trigger
|
|
562
|
+
* character". If span text is only the trigger, remove the whole span;
|
|
563
|
+
* otherwise unwrap into plain text minus the trigger. Mirrors
|
|
564
|
+
* mentionjs `_deleteForwardInSpan`.
|
|
565
|
+
* @param {HTMLElement} span
|
|
566
|
+
* @param {string} spanText
|
|
567
|
+
*/
|
|
568
|
+
function deleteForwardInSpan(span, spanText) {
|
|
569
|
+
if (spanText === opts.trigger) {
|
|
570
|
+
const next = span.nextSibling
|
|
571
|
+
span.parentNode?.removeChild(span)
|
|
572
|
+
if (next) setCaretBeforeNode(next)
|
|
573
|
+
} else {
|
|
574
|
+
const remaining = spanText.substring(opts.trigger.length)
|
|
575
|
+
const tn = document.createTextNode(remaining)
|
|
576
|
+
span.parentNode?.insertBefore(tn, span)
|
|
577
|
+
span.parentNode?.removeChild(span)
|
|
578
|
+
setCaretAt(tn, 0)
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
/**
|
|
583
|
+
* Forward-delete at end of a node — remove the first char of the next
|
|
584
|
+
* sibling, or drop an empty/element sibling entirely. Mirrors mentionjs
|
|
585
|
+
* `_deleteForwardAfterNode`.
|
|
586
|
+
* @param {Node} node
|
|
587
|
+
*/
|
|
588
|
+
function deleteForwardAfterNode(node) {
|
|
589
|
+
const next = node.nextSibling
|
|
590
|
+
if (!next) return
|
|
591
|
+
if (next.nodeType === Node.TEXT_NODE && next.textContent && next.textContent.length > 0) {
|
|
592
|
+
next.textContent = next.textContent.substring(codePointEndAt(next.textContent, 0))
|
|
593
|
+
if (next.textContent.length === 0) next.parentNode?.removeChild(next)
|
|
594
|
+
} else {
|
|
595
|
+
next.parentNode?.removeChild(next)
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
/**
|
|
600
|
+
* Insert a line-break at the current caret — used when Enter is pressed
|
|
601
|
+
* inside a committed span (no active search). Mirrors mentionjs
|
|
602
|
+
* `_insertBr`.
|
|
603
|
+
* @param {Selection} sel
|
|
604
|
+
*/
|
|
605
|
+
function insertBr(sel) {
|
|
606
|
+
if (!sel.rangeCount) return
|
|
607
|
+
const range = sel.getRangeAt(0)
|
|
608
|
+
range.deleteContents()
|
|
609
|
+
const br = document.createElement('br')
|
|
610
|
+
range.insertNode(br)
|
|
611
|
+
range.setStartAfter(br)
|
|
612
|
+
range.collapse(true)
|
|
613
|
+
sel.removeAllRanges()
|
|
614
|
+
sel.addRange(range)
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
// ─── Search pipeline (1:1 with mentionjs) ──────────────────────────────
|
|
618
|
+
|
|
619
|
+
/**
|
|
620
|
+
* Runs `searchFunction` with debounce + request-id cancellation.
|
|
621
|
+
* Returns the fresh items on success, or `null` if superseded / cancelled.
|
|
622
|
+
* @param {string} query
|
|
623
|
+
* @param {string | null} [nextPageUrl]
|
|
624
|
+
* @returns {Promise<MentionItem[] | null>}
|
|
625
|
+
*/
|
|
626
|
+
async function runSearch(query, nextPageUrl = null) {
|
|
627
|
+
if (!session) return null
|
|
628
|
+
|
|
629
|
+
session.searchController?.abort()
|
|
630
|
+
const controller = new AbortController()
|
|
631
|
+
session.searchController = controller
|
|
632
|
+
|
|
633
|
+
if (!nextPageUrl) {
|
|
634
|
+
session.currentQuery = query
|
|
635
|
+
if (session.debounceReject) {
|
|
636
|
+
session.debounceReject(new Error('cancelled'))
|
|
637
|
+
session.debounceReject = null
|
|
638
|
+
}
|
|
639
|
+
if (session.debounceTimer) {
|
|
640
|
+
clearTimeout(session.debounceTimer)
|
|
641
|
+
session.debounceTimer = null
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
const requestId = ++session.searchRequestId
|
|
646
|
+
const capturedSession = session
|
|
647
|
+
|
|
648
|
+
const execute = async () => {
|
|
649
|
+
if (!opts.searchFunction) return /** @type {MentionSearchResult} */ ({ items: [], nextPageUrl: null })
|
|
650
|
+
return await opts.searchFunction(query, nextPageUrl, { signal: controller.signal })
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
/** @type {MentionSearchResult | MentionItem[] | undefined} */
|
|
654
|
+
let raw
|
|
655
|
+
try {
|
|
656
|
+
if (!nextPageUrl && query.trim() !== '') {
|
|
657
|
+
raw = await new Promise((resolve, reject) => {
|
|
658
|
+
capturedSession.debounceReject = reject
|
|
659
|
+
capturedSession.debounceTimer = setTimeout(() => {
|
|
660
|
+
capturedSession.debounceReject = null
|
|
661
|
+
execute().then(resolve).catch(reject)
|
|
662
|
+
}, opts.debounceDelay ?? 300)
|
|
663
|
+
})
|
|
664
|
+
} else {
|
|
665
|
+
raw = await execute()
|
|
666
|
+
}
|
|
667
|
+
} catch (err) {
|
|
668
|
+
if (capturedSession.searchController === controller) capturedSession.searchController = null
|
|
669
|
+
if (controller.signal.aborted || /** @type {Error} */ (err)?.name === 'AbortError' || /** @type {Error} */ (err)?.message === 'cancelled') return null
|
|
670
|
+
// eslint-disable-next-line no-console
|
|
671
|
+
console.warn('[mention-plugin] search failed:', err)
|
|
672
|
+
return null
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
if (capturedSession.searchController === controller) capturedSession.searchController = null
|
|
676
|
+
if (!session || session !== capturedSession || controller.signal.aborted || requestId !== session.searchRequestId) return null
|
|
677
|
+
|
|
678
|
+
const normalized = normalizeSearchResult(raw)
|
|
679
|
+
const items = normalized.items
|
|
680
|
+
const nextUrl = normalized.nextPageUrl
|
|
681
|
+
|
|
682
|
+
session.nextPageUrl = nextUrl
|
|
683
|
+
|
|
684
|
+
if (nextPageUrl) {
|
|
685
|
+
session.results = [...session.results, ...items]
|
|
686
|
+
} else {
|
|
687
|
+
session.results = items
|
|
688
|
+
session.selectedIndex = 0
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
return items
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
/** Show / refresh the dropdown with current session results. */
|
|
695
|
+
function openOrUpdateDropdown(items) {
|
|
696
|
+
if (!session) return null
|
|
697
|
+
|
|
698
|
+
const wasMounted = !!session.dropdown.el
|
|
699
|
+
if (!wasMounted) {
|
|
700
|
+
session.dropdown.mount(rootElement || session.parentEl)
|
|
701
|
+
bindDropdownEvents()
|
|
702
|
+
bindScrollResize()
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
session.dropdown.hide()
|
|
706
|
+
session.dropdown.render(items, session.selectedIndex)
|
|
707
|
+
syncDropdownAria()
|
|
708
|
+
repositionDropdown()
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
/** Keep the owning editable element synchronized with listbox selection. */
|
|
712
|
+
function syncDropdownAria() {
|
|
713
|
+
if (!session?.dropdown.el) return
|
|
714
|
+
const parentEl = session.parentEl
|
|
715
|
+
const active = session.dropdown.el.querySelector('.oe-mention-item--active[role="option"]')
|
|
716
|
+
parentEl.setAttribute('aria-controls', session.dropdown.el.id)
|
|
717
|
+
parentEl.setAttribute('aria-expanded', 'true')
|
|
718
|
+
parentEl.setAttribute('aria-haspopup', 'listbox')
|
|
719
|
+
parentEl.setAttribute('aria-autocomplete', 'list')
|
|
720
|
+
if (active?.id) parentEl.setAttribute('aria-activedescendant', active.id)
|
|
721
|
+
else parentEl.removeAttribute('aria-activedescendant')
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
// ─── Positioning ───────────────────────────────────────────────────────
|
|
725
|
+
|
|
726
|
+
/**
|
|
727
|
+
* Build a DOMRect for the dropdown anchor. For fresh sessions, that's the
|
|
728
|
+
* range `[@triggerOffset .. currentCaret]` inside `triggerTextNode`. For
|
|
729
|
+
* edit sessions, it's the span's own bounding rect (the pill itself).
|
|
730
|
+
* @returns {DOMRect | null}
|
|
731
|
+
*/
|
|
732
|
+
function getTriggerRect() {
|
|
733
|
+
if (!session) return null
|
|
734
|
+
|
|
735
|
+
if (session.mode === 'edit') {
|
|
736
|
+
const span = session.span
|
|
737
|
+
if (!span || !inDOM(span)) return null
|
|
738
|
+
return span.getBoundingClientRect()
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
const tn = session.triggerTextNode
|
|
742
|
+
if (!tn) return null
|
|
743
|
+
|
|
744
|
+
const sel = window.getSelection()
|
|
745
|
+
if (!sel || !sel.rangeCount) return null
|
|
746
|
+
const anchorNode = sel.anchorNode
|
|
747
|
+
const anchorOffset = sel.anchorOffset
|
|
748
|
+
|
|
749
|
+
const range = document.createRange()
|
|
750
|
+
try {
|
|
751
|
+
range.setStart(tn, session.triggerOffset)
|
|
752
|
+
if (anchorNode && anchorNode === tn) {
|
|
753
|
+
range.setEnd(anchorNode, anchorOffset)
|
|
754
|
+
} else {
|
|
755
|
+
range.setEnd(tn, Math.min(session.triggerOffset + opts.trigger.length, tn.data.length))
|
|
756
|
+
}
|
|
757
|
+
} catch {
|
|
758
|
+
return null
|
|
759
|
+
}
|
|
760
|
+
return range.getBoundingClientRect()
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
function repositionDropdown() {
|
|
764
|
+
if (!session || !session.dropdown.el) return
|
|
765
|
+
const rect = getTriggerRect()
|
|
766
|
+
if (!rect) {
|
|
767
|
+
closeSession()
|
|
768
|
+
return
|
|
769
|
+
}
|
|
770
|
+
session.dropdown.position({
|
|
771
|
+
top: rect.bottom + 4,
|
|
772
|
+
left: rect.left,
|
|
773
|
+
cursorY: rect.bottom,
|
|
774
|
+
lineHeight: rect.height || 20,
|
|
775
|
+
})
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
// ─── Commit / cancel ───────────────────────────────────────────────────
|
|
779
|
+
|
|
780
|
+
/**
|
|
781
|
+
* Commit a picked item. Dispatches on `session.mode`:
|
|
782
|
+
* - 'fresh' — replace the typed `@query` text range with a new widget;
|
|
783
|
+
* - 'edit' — rewrite the already-committed span in place.
|
|
784
|
+
*
|
|
785
|
+
* Both paths run inside the owning block's mutation boundary.
|
|
786
|
+
*
|
|
787
|
+
* @param {MentionItem} data
|
|
788
|
+
*/
|
|
789
|
+
function commitMention(data) {
|
|
790
|
+
if (!session) return
|
|
791
|
+
|
|
792
|
+
// Cache everything off `session` up front — DOM mutations fire
|
|
793
|
+
// `selectionchange`, which (in spec-compliant browsers) is async, but in
|
|
794
|
+
// some engines may land synchronously and tear the session down before
|
|
795
|
+
// we get to `notifyChanged()`.
|
|
796
|
+
const ctx = session.ctx
|
|
797
|
+
const parentEl = session.parentEl
|
|
798
|
+
let committed = false
|
|
799
|
+
|
|
800
|
+
ctx.mutate(parentEl, () => {
|
|
801
|
+
if (session?.mode === 'edit') {
|
|
802
|
+
committed = commitEditMention(data, ctx)
|
|
803
|
+
} else if (session) {
|
|
804
|
+
committed = commitFreshMention(data, ctx)
|
|
805
|
+
}
|
|
806
|
+
})
|
|
807
|
+
|
|
808
|
+
if (committed && typeof opts.onMentionSelect === 'function') {
|
|
809
|
+
try { opts.onMentionSelect({ id: data.id, name: data.name }) } catch { /* noop */ }
|
|
810
|
+
}
|
|
811
|
+
closeSession()
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
/**
|
|
815
|
+
* Replace the `@query` range with a committed mention widget.
|
|
816
|
+
*
|
|
817
|
+
* Primary path: `triggerTextNode` is still in the DOM — rebuild its
|
|
818
|
+
* surrounding content using `parent.replaceChild(fragment, triggerNode)`
|
|
819
|
+
* with a DocumentFragment holding `[before-text?, widget, space,
|
|
820
|
+
* after-text?]`. More reliable than `Range.deleteContents + insertNode`
|
|
821
|
+
* when the text node ends up empty after the delete (browsers handle
|
|
822
|
+
* splitting an empty Text node inconsistently).
|
|
823
|
+
*
|
|
824
|
+
* Fallback: `triggerTextNode` has been detached (rare — would require
|
|
825
|
+
* a concurrent DOM mutation) — insert the widget at the current caret.
|
|
826
|
+
*
|
|
827
|
+
* @param {MentionItem} data
|
|
828
|
+
* @param {InlinePluginContext} _ctx
|
|
829
|
+
* @returns {boolean} Whether the widget was inserted into the document.
|
|
830
|
+
*/
|
|
831
|
+
function commitFreshMention(data, _ctx) {
|
|
832
|
+
if (!session) return false
|
|
833
|
+
const triggerNode = session.triggerTextNode
|
|
834
|
+
const triggerOffset = session.triggerOffset
|
|
835
|
+
const currentQueryLen = session.currentQuery.length
|
|
836
|
+
|
|
837
|
+
const widget = createWidget({ id: String(data.id), name: String(data.name) })
|
|
838
|
+
const space = document.createTextNode('\u00A0')
|
|
839
|
+
const sel = window.getSelection()
|
|
840
|
+
|
|
841
|
+
// ─── Primary path: triggerNode still in DOM ────────────────────────
|
|
842
|
+
if (triggerNode && inDOM(triggerNode)) {
|
|
843
|
+
// End offset defaults to `@trigger + query length`; if the caret is
|
|
844
|
+
// still anchored in triggerNode, prefer its live position.
|
|
845
|
+
let endOffset = triggerOffset + opts.trigger.length + currentQueryLen
|
|
846
|
+
if (sel && sel.rangeCount > 0 && sel.anchorNode === triggerNode
|
|
847
|
+
&& typeof sel.anchorOffset === 'number') {
|
|
848
|
+
endOffset = sel.anchorOffset
|
|
849
|
+
}
|
|
850
|
+
endOffset = Math.min(Math.max(endOffset, triggerOffset), triggerNode.data.length)
|
|
851
|
+
|
|
852
|
+
const beforeText = triggerNode.data.substring(0, triggerOffset)
|
|
853
|
+
const afterText = triggerNode.data.substring(endOffset)
|
|
854
|
+
const parent = triggerNode.parentNode
|
|
855
|
+
|
|
856
|
+
if (parent) {
|
|
857
|
+
const frag = document.createDocumentFragment()
|
|
858
|
+
if (beforeText) frag.appendChild(document.createTextNode(beforeText))
|
|
859
|
+
frag.appendChild(widget)
|
|
860
|
+
frag.appendChild(space)
|
|
861
|
+
if (afterText) frag.appendChild(document.createTextNode(afterText))
|
|
862
|
+
parent.replaceChild(frag, triggerNode)
|
|
863
|
+
setCaretAt(space, 1)
|
|
864
|
+
return true
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
// ─── Fallback: insert at current caret ─────────────────────────────
|
|
869
|
+
if (!sel || !sel.rangeCount) return false
|
|
870
|
+
const range = sel.getRangeAt(0)
|
|
871
|
+
const rangeContainer = range.commonAncestorContainer
|
|
872
|
+
if (rangeContainer !== session.parentEl && !session.parentEl.contains(rangeContainer)) return false
|
|
873
|
+
try {
|
|
874
|
+
range.insertNode(widget)
|
|
875
|
+
} catch {
|
|
876
|
+
return false
|
|
877
|
+
}
|
|
878
|
+
widget.after(space)
|
|
879
|
+
setCaretAt(space, 1)
|
|
880
|
+
return inDOM(widget)
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
/**
|
|
884
|
+
* Rewrite an already-committed span in place with the newly picked item.
|
|
885
|
+
* Mirrors mentionjs `_commitSpanMention`: updates text, removes the
|
|
886
|
+
* active/editing class, and writes the fresh data-value. Caret lands in
|
|
887
|
+
* the trailing space sibling (creating one if missing).
|
|
888
|
+
*
|
|
889
|
+
* @param {MentionItem} data
|
|
890
|
+
* @param {InlinePluginContext} _ctx
|
|
891
|
+
* @returns {boolean} Whether the existing widget was updated.
|
|
892
|
+
*/
|
|
893
|
+
function commitEditMention(data, _ctx) {
|
|
894
|
+
if (!session || !session.span) return false
|
|
895
|
+
const span = session.span
|
|
896
|
+
if (!inDOM(span)) return false
|
|
897
|
+
|
|
898
|
+
// Mark the session as having produced a real commit. The close path
|
|
899
|
+
// uses this to decide whether to leave the span intact (committed) or
|
|
900
|
+
// unwrap it to plain text (user edited but bailed — `data-value` no
|
|
901
|
+
// longer matches the displayed name, see README / the task brief).
|
|
902
|
+
session.committedInThisSession = true
|
|
903
|
+
|
|
904
|
+
span.textContent = opts.trigger + data.name
|
|
905
|
+
span.classList.remove('oe-ip--mention--editing')
|
|
906
|
+
span.setAttribute('data-value', String(data.id))
|
|
907
|
+
|
|
908
|
+
const next = span.nextSibling
|
|
909
|
+
const nextText = next && next.nodeType === Node.TEXT_NODE ? (next.textContent || '') : ''
|
|
910
|
+
const hasSpace = nextText.length > 0 && /^[\s\u00A0]/.test(nextText)
|
|
911
|
+
|
|
912
|
+
if (hasSpace && next) {
|
|
913
|
+
setCaretAt(next, 1)
|
|
914
|
+
} else {
|
|
915
|
+
const space = document.createTextNode('\u00A0')
|
|
916
|
+
span.after(space)
|
|
917
|
+
setCaretAt(space, 1)
|
|
918
|
+
}
|
|
919
|
+
return true
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
/**
|
|
923
|
+
* Release transient session resources without deciding how an edited
|
|
924
|
+
* mention should leave the document. DOM-edit branches use the returned
|
|
925
|
+
* session to remove or replace the live span themselves; ordinary cancel
|
|
926
|
+
* paths pass it to `finalizeSession`.
|
|
927
|
+
* @returns {Session | null}
|
|
928
|
+
*/
|
|
929
|
+
function detachSession() {
|
|
930
|
+
if (!session) return
|
|
931
|
+
|
|
932
|
+
// Cancel in-flight debounce / search.
|
|
933
|
+
if (session.debounceReject) {
|
|
934
|
+
session.debounceReject(new Error('cancelled'))
|
|
935
|
+
session.debounceReject = null
|
|
936
|
+
}
|
|
937
|
+
if (session.debounceTimer) {
|
|
938
|
+
clearTimeout(session.debounceTimer)
|
|
939
|
+
session.debounceTimer = null
|
|
940
|
+
}
|
|
941
|
+
session.searchController?.abort()
|
|
942
|
+
session.searchController = null
|
|
943
|
+
session.searchRequestId++
|
|
944
|
+
|
|
945
|
+
// Tear down UI + listeners.
|
|
946
|
+
restoreComboboxAria(session.parentEl, session.ariaState)
|
|
947
|
+
unbindDropdownEvents()
|
|
948
|
+
unbindScrollResize()
|
|
949
|
+
session.dropdown.destroy()
|
|
950
|
+
|
|
951
|
+
document.removeEventListener('keydown', session.keydownHandler, true)
|
|
952
|
+
document.removeEventListener('selectionchange', session.selectionChangeHandler)
|
|
953
|
+
document.removeEventListener('mousedown', session.outsideMouseDownHandler, true)
|
|
954
|
+
const closingSession = session
|
|
955
|
+
session = null
|
|
956
|
+
|
|
957
|
+
return closingSession
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
/**
|
|
961
|
+
* Apply the normal semantic result of leaving an edit session.
|
|
962
|
+
* @param {Session} closingSession
|
|
963
|
+
*/
|
|
964
|
+
function finalizeSession(closingSession) {
|
|
965
|
+
|
|
966
|
+
// Edit-mode exit handling:
|
|
967
|
+
//
|
|
968
|
+
// - If the user COMMITTED a pick (Enter / click / Tab), the span
|
|
969
|
+
// already carries a consistent `data-value` + displayed name — we
|
|
970
|
+
// just drop the editing class (commit path already did, but this
|
|
971
|
+
// is idempotent).
|
|
972
|
+
//
|
|
973
|
+
// - If the user EDITED and then bailed out (Escape / outside click /
|
|
974
|
+
// caret moved elsewhere), the span's displayed text no longer
|
|
975
|
+
// matches its `data-value`. Leaving it as a pill would bake stale
|
|
976
|
+
// identity into the block-level `inline` map. We unwrap the span
|
|
977
|
+
// into a plain text node with the same textual content — the
|
|
978
|
+
// mention effectively stops being a mention, which is the
|
|
979
|
+
// semantically correct outcome ("user started editing but didn't
|
|
980
|
+
// pick anyone").
|
|
981
|
+
if (closingSession.mode === 'edit' && closingSession.span && inDOM(closingSession.span)) {
|
|
982
|
+
const span = closingSession.span
|
|
983
|
+
if (closingSession.committedInThisSession) {
|
|
984
|
+
span.classList.remove('oe-ip--mention--editing')
|
|
985
|
+
} else {
|
|
986
|
+
const text = span.textContent || ''
|
|
987
|
+
mutateContent(closingSession.parentEl, () => {
|
|
988
|
+
const tn = document.createTextNode(text)
|
|
989
|
+
const parent = span.parentNode
|
|
990
|
+
if (!parent) return
|
|
991
|
+
parent.insertBefore(tn, span)
|
|
992
|
+
parent.removeChild(span)
|
|
993
|
+
// Place caret at end of the unwrapped text — this matches the
|
|
994
|
+
// user's likely mental position after a bail-out backspace.
|
|
995
|
+
setCaretAt(tn, text.length)
|
|
996
|
+
})
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
function closeSession() {
|
|
1002
|
+
const closingSession = detachSession()
|
|
1003
|
+
if (closingSession) finalizeSession(closingSession)
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
/**
|
|
1007
|
+
* Close only the transient UI before a `beforeinput` branch removes or
|
|
1008
|
+
* replaces the editing span in the same synchronous mutation. Running the
|
|
1009
|
+
* normal finalizer here would first unwrap the span and leave the caller
|
|
1010
|
+
* holding a detached node.
|
|
1011
|
+
* @param {HTMLElement} span
|
|
1012
|
+
*/
|
|
1013
|
+
function closeSessionForSpanReplacement(span) {
|
|
1014
|
+
const closingSession = detachSession()
|
|
1015
|
+
if (closingSession?.span === span) span.classList.remove('oe-ip--mention--editing')
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
// ─── Keyboard / mouse plumbing ─────────────────────────────────────────
|
|
1019
|
+
|
|
1020
|
+
/** @param {KeyboardEvent} e */
|
|
1021
|
+
function handleKeydown(e) {
|
|
1022
|
+
if (!session) return
|
|
1023
|
+
|
|
1024
|
+
// Only intercept keys that originated inside the block where the trigger
|
|
1025
|
+
// was typed (or inside our dropdown — click-focus never leaves the block,
|
|
1026
|
+
// but stay defensive). A global Escape or Arrow in a modal / sidebar must
|
|
1027
|
+
// not affect us.
|
|
1028
|
+
const target = /** @type {Node | null} */ (e.target)
|
|
1029
|
+
const inBlock = !!target && session.parentEl.contains(target)
|
|
1030
|
+
const inDropdown = !!target && !!session.dropdown.el?.contains(target)
|
|
1031
|
+
if (!inBlock && !inDropdown) return
|
|
1032
|
+
|
|
1033
|
+
const max = session.results.length - 1
|
|
1034
|
+
|
|
1035
|
+
if (e.key === 'Escape') {
|
|
1036
|
+
e.preventDefault()
|
|
1037
|
+
e.stopPropagation()
|
|
1038
|
+
closeSession()
|
|
1039
|
+
return
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
if (max < 0) return
|
|
1043
|
+
|
|
1044
|
+
switch (e.key) {
|
|
1045
|
+
case 'ArrowDown':
|
|
1046
|
+
e.preventDefault()
|
|
1047
|
+
e.stopPropagation()
|
|
1048
|
+
session.selectedIndex = Math.min(session.selectedIndex + 1, max)
|
|
1049
|
+
session.dropdown.updateSelection(session.selectedIndex)
|
|
1050
|
+
syncDropdownAria()
|
|
1051
|
+
session.dropdown.scrollToActive()
|
|
1052
|
+
maybeLoadMore()
|
|
1053
|
+
break
|
|
1054
|
+
case 'ArrowUp':
|
|
1055
|
+
e.preventDefault()
|
|
1056
|
+
e.stopPropagation()
|
|
1057
|
+
session.selectedIndex = Math.max(session.selectedIndex - 1, 0)
|
|
1058
|
+
session.dropdown.updateSelection(session.selectedIndex)
|
|
1059
|
+
syncDropdownAria()
|
|
1060
|
+
session.dropdown.scrollToActive()
|
|
1061
|
+
break
|
|
1062
|
+
case 'Enter':
|
|
1063
|
+
case 'Tab':
|
|
1064
|
+
if (session.results[session.selectedIndex]) {
|
|
1065
|
+
e.preventDefault()
|
|
1066
|
+
e.stopPropagation()
|
|
1067
|
+
commitMention(session.results[session.selectedIndex])
|
|
1068
|
+
}
|
|
1069
|
+
break
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
function handleSelectionChange() {
|
|
1074
|
+
if (!session) return
|
|
1075
|
+
const sel = window.getSelection()
|
|
1076
|
+
if (!sel || !sel.anchorNode) return
|
|
1077
|
+
|
|
1078
|
+
if (session.mode === 'edit') {
|
|
1079
|
+
// Edit sessions persist as long as the caret is inside the editing
|
|
1080
|
+
// span. The span itself stays in the DOM across edits, so
|
|
1081
|
+
// `span.contains(anchor)` is the right test — accounts for the caret
|
|
1082
|
+
// being in the span's text child or directly on the span element.
|
|
1083
|
+
const span = session.span
|
|
1084
|
+
if (!span || !inDOM(span)) { closeSession(); return }
|
|
1085
|
+
if (!span.contains(sel.anchorNode) && sel.anchorNode !== span) {
|
|
1086
|
+
closeSession()
|
|
1087
|
+
}
|
|
1088
|
+
return
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
// Fresh mode: caret must stay in the trigger text node at or past the
|
|
1092
|
+
// end of the trigger. `trigger` is one Unicode code point, but may span
|
|
1093
|
+
// two UTF-16 code units (for example an emoji).
|
|
1094
|
+
const tn = session.triggerTextNode
|
|
1095
|
+
if (!tn) { closeSession(); return }
|
|
1096
|
+
if (sel.anchorNode !== tn) { closeSession(); return }
|
|
1097
|
+
if (sel.anchorOffset < session.triggerOffset + opts.trigger.length) closeSession()
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
/** @param {MouseEvent} e */
|
|
1101
|
+
function handleOutsideMouseDown(e) {
|
|
1102
|
+
if (!session) return
|
|
1103
|
+
const target = /** @type {Node} */ (e.target)
|
|
1104
|
+
if (session.parentEl.contains(target)) return
|
|
1105
|
+
if (session.dropdown.el?.contains(target)) return
|
|
1106
|
+
closeSession()
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
function bindDropdownEvents() {
|
|
1110
|
+
if (!session || !session.dropdown.el) return
|
|
1111
|
+
const el = session.dropdown.el
|
|
1112
|
+
|
|
1113
|
+
session.dropdownClickHandler = (e) => {
|
|
1114
|
+
if (!session) return
|
|
1115
|
+
const target = /** @type {HTMLElement} */ (e.target)
|
|
1116
|
+
const item = target.closest('.oe-mention-item[data-index]')
|
|
1117
|
+
if (!item) return
|
|
1118
|
+
const idx = parseInt(/** @type {HTMLElement} */ (item).dataset.index || '-1', 10)
|
|
1119
|
+
const data = session.results[idx]
|
|
1120
|
+
if (data) commitMention(data)
|
|
1121
|
+
}
|
|
1122
|
+
session.dropdownMouseDownHandler = (e) => e.preventDefault() // keep caret focus
|
|
1123
|
+
session.dropdownScrollHandler = () => onDropdownScroll()
|
|
1124
|
+
|
|
1125
|
+
el.addEventListener('click', session.dropdownClickHandler)
|
|
1126
|
+
el.addEventListener('mousedown', session.dropdownMouseDownHandler)
|
|
1127
|
+
el.addEventListener('scroll', session.dropdownScrollHandler)
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
function unbindDropdownEvents() {
|
|
1131
|
+
if (!session || !session.dropdown.el) return
|
|
1132
|
+
const el = session.dropdown.el
|
|
1133
|
+
if (session.dropdownClickHandler) el.removeEventListener('click', session.dropdownClickHandler)
|
|
1134
|
+
if (session.dropdownMouseDownHandler) el.removeEventListener('mousedown', session.dropdownMouseDownHandler)
|
|
1135
|
+
if (session.dropdownScrollHandler) el.removeEventListener('scroll', session.dropdownScrollHandler)
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
function bindScrollResize() {
|
|
1139
|
+
if (!session) return
|
|
1140
|
+
session.scrollResizeHandler = () => repositionDropdown()
|
|
1141
|
+
document.addEventListener('scroll', session.scrollResizeHandler, { passive: true, capture: true })
|
|
1142
|
+
window.addEventListener('resize', session.scrollResizeHandler, { passive: true })
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
function unbindScrollResize() {
|
|
1146
|
+
if (!session || !session.scrollResizeHandler) return
|
|
1147
|
+
document.removeEventListener('scroll', session.scrollResizeHandler, true)
|
|
1148
|
+
window.removeEventListener('resize', session.scrollResizeHandler)
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
// ─── Pagination (1:1 with mentionjs _onDropdownScroll / _loadMoreResults) ─
|
|
1152
|
+
|
|
1153
|
+
function onDropdownScroll() {
|
|
1154
|
+
if (!session || !session.dropdown.el) return
|
|
1155
|
+
if (session.isLoadingMore || !session.nextPageUrl) return
|
|
1156
|
+
const { scrollTop, scrollHeight, clientHeight } = session.dropdown.el
|
|
1157
|
+
if (scrollTop + clientHeight >= scrollHeight - 10) loadMoreResults()
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
function maybeLoadMore() {
|
|
1161
|
+
if (!session) return
|
|
1162
|
+
if (
|
|
1163
|
+
session.selectedIndex >= session.results.length - 2
|
|
1164
|
+
&& session.nextPageUrl
|
|
1165
|
+
&& !session.isLoadingMore
|
|
1166
|
+
) {
|
|
1167
|
+
loadMoreResults()
|
|
1168
|
+
}
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
async function loadMoreResults() {
|
|
1172
|
+
if (!session || session.isLoadingMore || !session.nextPageUrl) return
|
|
1173
|
+
session.isLoadingMore = true
|
|
1174
|
+
session.dropdown.showLoading()
|
|
1175
|
+
try {
|
|
1176
|
+
const prevLen = session.results.length
|
|
1177
|
+
const newItems = await runSearch(session.currentQuery, session.nextPageUrl)
|
|
1178
|
+
session?.dropdown.hideLoading()
|
|
1179
|
+
if (!session || newItems === null) return
|
|
1180
|
+
session.dropdown.appendItems(newItems, prevLen, session.selectedIndex)
|
|
1181
|
+
} catch (err) {
|
|
1182
|
+
// eslint-disable-next-line no-console
|
|
1183
|
+
console.warn('[mention-plugin] load more failed:', err)
|
|
1184
|
+
session?.dropdown.hideLoading()
|
|
1185
|
+
} finally {
|
|
1186
|
+
if (session) session.isLoadingMore = false
|
|
1187
|
+
}
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
// ─── Session lifecycle ─────────────────────────────────────────────────
|
|
1191
|
+
|
|
1192
|
+
/**
|
|
1193
|
+
* Build a fresh dropdown configured with the current options/i18n.
|
|
1194
|
+
* @returns {DropdownUI}
|
|
1195
|
+
*/
|
|
1196
|
+
function createDropdown() {
|
|
1197
|
+
return new DropdownUI({
|
|
1198
|
+
dropdownClass: opts.dropdownClass,
|
|
1199
|
+
renderItem: opts.renderItem,
|
|
1200
|
+
renderNoResults: opts.renderNoResults,
|
|
1201
|
+
renderLoading: opts.renderLoading,
|
|
1202
|
+
noResultsText: opts.noResultsText ?? t('noResults', 'No results found'),
|
|
1203
|
+
loadingText: t('loading', 'Loading...'),
|
|
1204
|
+
})
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
/**
|
|
1208
|
+
* Attach the three document-level listeners shared by every session mode.
|
|
1209
|
+
* Must only be called when `session` is freshly set.
|
|
1210
|
+
*/
|
|
1211
|
+
function attachSessionListeners() {
|
|
1212
|
+
if (!session) return
|
|
1213
|
+
// keydown on document (capture) so we fire before the editor's
|
|
1214
|
+
// TriggerManager/KeyboardManager on the editor root.
|
|
1215
|
+
document.addEventListener('keydown', session.keydownHandler, true)
|
|
1216
|
+
document.addEventListener('selectionchange', session.selectionChangeHandler)
|
|
1217
|
+
document.addEventListener('mousedown', session.outsideMouseDownHandler, true)
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
/**
|
|
1221
|
+
* Create a `fresh` session — the user has just typed `@` in plain text.
|
|
1222
|
+
* @param {HTMLElement} parentEl
|
|
1223
|
+
* @param {Text} textNode
|
|
1224
|
+
* @param {number} triggerOffset
|
|
1225
|
+
* @param {InlinePluginContext} ctx
|
|
1226
|
+
*/
|
|
1227
|
+
function openFreshSession(parentEl, textNode, triggerOffset, ctx) {
|
|
1228
|
+
session = {
|
|
1229
|
+
mode: 'fresh',
|
|
1230
|
+
parentEl,
|
|
1231
|
+
triggerTextNode: textNode,
|
|
1232
|
+
triggerOffset,
|
|
1233
|
+
span: null,
|
|
1234
|
+
dropdown: createDropdown(),
|
|
1235
|
+
keydownHandler: handleKeydown,
|
|
1236
|
+
selectionChangeHandler: handleSelectionChange,
|
|
1237
|
+
outsideMouseDownHandler: handleOutsideMouseDown,
|
|
1238
|
+
dropdownClickHandler: () => {},
|
|
1239
|
+
dropdownMouseDownHandler: () => {},
|
|
1240
|
+
dropdownScrollHandler: () => {},
|
|
1241
|
+
scrollResizeHandler: () => {},
|
|
1242
|
+
selectedIndex: 0,
|
|
1243
|
+
results: [],
|
|
1244
|
+
nextPageUrl: null,
|
|
1245
|
+
isLoadingMore: false,
|
|
1246
|
+
currentQuery: '',
|
|
1247
|
+
debounceTimer: null,
|
|
1248
|
+
debounceReject: null,
|
|
1249
|
+
searchController: null,
|
|
1250
|
+
searchRequestId: 0,
|
|
1251
|
+
ctx,
|
|
1252
|
+
committedInThisSession: false,
|
|
1253
|
+
ariaState: captureComboboxAria(parentEl),
|
|
1254
|
+
}
|
|
1255
|
+
attachSessionListeners()
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
/**
|
|
1259
|
+
* Create an `edit` session — the user is mutating an already-committed
|
|
1260
|
+
* mention span (typing inside it, deleting chars, etc). The span enters
|
|
1261
|
+
* "editing" styling until the session closes.
|
|
1262
|
+
* @param {HTMLElement} span
|
|
1263
|
+
* @param {HTMLElement} parentEl
|
|
1264
|
+
* @param {InlinePluginContext} ctx
|
|
1265
|
+
*/
|
|
1266
|
+
function openEditSession(span, parentEl, ctx) {
|
|
1267
|
+
session = {
|
|
1268
|
+
mode: 'edit',
|
|
1269
|
+
parentEl,
|
|
1270
|
+
triggerTextNode: null,
|
|
1271
|
+
triggerOffset: 0,
|
|
1272
|
+
span,
|
|
1273
|
+
dropdown: createDropdown(),
|
|
1274
|
+
keydownHandler: handleKeydown,
|
|
1275
|
+
selectionChangeHandler: handleSelectionChange,
|
|
1276
|
+
outsideMouseDownHandler: handleOutsideMouseDown,
|
|
1277
|
+
dropdownClickHandler: () => {},
|
|
1278
|
+
dropdownMouseDownHandler: () => {},
|
|
1279
|
+
dropdownScrollHandler: () => {},
|
|
1280
|
+
scrollResizeHandler: () => {},
|
|
1281
|
+
selectedIndex: 0,
|
|
1282
|
+
results: [],
|
|
1283
|
+
nextPageUrl: null,
|
|
1284
|
+
isLoadingMore: false,
|
|
1285
|
+
currentQuery: '',
|
|
1286
|
+
debounceTimer: null,
|
|
1287
|
+
debounceReject: null,
|
|
1288
|
+
searchController: null,
|
|
1289
|
+
searchRequestId: 0,
|
|
1290
|
+
ctx,
|
|
1291
|
+
committedInThisSession: false,
|
|
1292
|
+
ariaState: captureComboboxAria(parentEl),
|
|
1293
|
+
}
|
|
1294
|
+
span.classList.add('oe-ip--mention--editing')
|
|
1295
|
+
// Drop the stale `data-value` the moment edit mode starts — the user
|
|
1296
|
+
// is mutating the displayed name, so the id no longer matches what
|
|
1297
|
+
// they see. If a `ChangeNotifier` save fires mid-edit (250 ms debounce
|
|
1298
|
+
// default), the plugin's `isCommitted()` hook makes the shared inline
|
|
1299
|
+
// serializer store the visible query as plain text instead of emitting
|
|
1300
|
+
// an invalid widget entry. Commit restores the attribute.
|
|
1301
|
+
span.removeAttribute('data-value')
|
|
1302
|
+
attachSessionListeners()
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
/**
|
|
1306
|
+
* If no session exists (or exists but for a different span), swap to a
|
|
1307
|
+
* fresh edit session anchored on `span`. Then kick off a search against
|
|
1308
|
+
* the current span text (minus the trigger) and refresh the dropdown.
|
|
1309
|
+
*
|
|
1310
|
+
* Called from `handleBeforeInput` whenever the user's edit inside a
|
|
1311
|
+
* committed pill changes the query.
|
|
1312
|
+
*
|
|
1313
|
+
* @param {HTMLElement} span
|
|
1314
|
+
* @param {HTMLElement} parentEl
|
|
1315
|
+
* @param {string} newQuery
|
|
1316
|
+
*/
|
|
1317
|
+
function refreshEditSession(span, parentEl, newQuery) {
|
|
1318
|
+
if (!session || session.mode !== 'edit' || session.span !== span) {
|
|
1319
|
+
if (session) closeSession()
|
|
1320
|
+
const ctx = globalCtx
|
|
1321
|
+
if (!ctx) return // cannot proceed without an editor ctx
|
|
1322
|
+
openEditSession(span, parentEl, ctx)
|
|
1323
|
+
}
|
|
1324
|
+
runSearch(newQuery).then((items) => {
|
|
1325
|
+
if (items === null) return
|
|
1326
|
+
if (!session) return
|
|
1327
|
+
if (!inDOM(span)) { closeSession(); return }
|
|
1328
|
+
openOrUpdateDropdown(items)
|
|
1329
|
+
}).catch((err) => {
|
|
1330
|
+
// eslint-disable-next-line no-console
|
|
1331
|
+
console.warn('[mention-plugin] edit search failed:', err)
|
|
1332
|
+
})
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
/**
|
|
1336
|
+
* Walk upwards from a node to find the containing block content element —
|
|
1337
|
+
* the nearest `contenteditable="true"` ancestor. Used by edit sessions
|
|
1338
|
+
* to locate the block the mention belongs to without relying on the
|
|
1339
|
+
* caller to pass it.
|
|
1340
|
+
* @param {Node} node
|
|
1341
|
+
* @returns {HTMLElement | null}
|
|
1342
|
+
*/
|
|
1343
|
+
function findContenteditableBlock(node) {
|
|
1344
|
+
let cur = node.parentElement
|
|
1345
|
+
while (cur) {
|
|
1346
|
+
if (cur.isContentEditable || cur.getAttribute?.('contenteditable') === 'true') return cur
|
|
1347
|
+
cur = cur.parentElement
|
|
1348
|
+
}
|
|
1349
|
+
return null
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
/**
|
|
1353
|
+
* Signal a content change to the editor.
|
|
1354
|
+
*
|
|
1355
|
+
* We dispatch a synthetic bubbling `input` event on the block — the
|
|
1356
|
+
* editor's `wireInputTracking` listens for this on its root element and
|
|
1357
|
+
* emits `EditorEvent.CHANGED`, which triggers `ChangeNotifier` → save.
|
|
1358
|
+
*
|
|
1359
|
+
* This is preferred over `globalCtx.notifyChanged()` because it doesn't
|
|
1360
|
+
* depend on having captured a ctx yet (safer when edit-mode mutations
|
|
1361
|
+
* fire from a pre-hydrated mention before any onEdit/hydrate call has
|
|
1362
|
+
* stashed the ctx).
|
|
1363
|
+
*
|
|
1364
|
+
* @param {HTMLElement | null} parentEl
|
|
1365
|
+
*/
|
|
1366
|
+
function notifyChange(parentEl) {
|
|
1367
|
+
if (parentEl) {
|
|
1368
|
+
parentEl.dispatchEvent(new Event('input', { bubbles: true }))
|
|
1369
|
+
} else {
|
|
1370
|
+
globalCtx?.notifyChanged()
|
|
1371
|
+
}
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
/**
|
|
1375
|
+
* Execute one mention edit as one editor history step.
|
|
1376
|
+
* Falls back to a synthetic input notification before the editor supplies
|
|
1377
|
+
* the mutation context.
|
|
1378
|
+
*
|
|
1379
|
+
* @param {HTMLElement} parentEl editable element that owns the mention
|
|
1380
|
+
* @param {() => void} operation synchronous DOM mutation
|
|
1381
|
+
* @returns {void}
|
|
1382
|
+
*/
|
|
1383
|
+
function mutateContent(parentEl, operation) {
|
|
1384
|
+
if (globalCtx) {
|
|
1385
|
+
globalCtx.mutate(parentEl, operation)
|
|
1386
|
+
} else {
|
|
1387
|
+
operation()
|
|
1388
|
+
notifyChange(parentEl)
|
|
1389
|
+
}
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
/**
|
|
1393
|
+
* Programmatically entering edit mode on a span we've stumbled into —
|
|
1394
|
+
* e.g. after `backspaceBeforeSpan` walks into a previous mention.
|
|
1395
|
+
* @param {HTMLElement} span
|
|
1396
|
+
*/
|
|
1397
|
+
function openEditSessionForSpan(span) {
|
|
1398
|
+
const parentEl = findContenteditableBlock(span) || span.parentElement
|
|
1399
|
+
if (!parentEl) return
|
|
1400
|
+
const text = span.textContent || ''
|
|
1401
|
+
const query = text.startsWith(opts.trigger) ? text.slice(opts.trigger.length) : text
|
|
1402
|
+
refreshEditSession(span, parentEl, query)
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1405
|
+
// ─── Before-input handling (port of mentionjs _handleInputInsideSpan) ──
|
|
1406
|
+
|
|
1407
|
+
/**
|
|
1408
|
+
* Intercepts `beforeinput` events when the caret is inside (or at the
|
|
1409
|
+
* edge of) a committed mention span. Drives all the char-by-char delete /
|
|
1410
|
+
* unwrap / re-search behavior — see the inline comments for per-case
|
|
1411
|
+
* logic. Mirrors mentionjs `_onBeforeInput` + `_handleInputInsideSpan`.
|
|
1412
|
+
*
|
|
1413
|
+
* @param {InputEvent} e
|
|
1414
|
+
*/
|
|
1415
|
+
function handleBeforeInput(e) {
|
|
1416
|
+
const span = findMentionSpanAtCaret()
|
|
1417
|
+
|
|
1418
|
+
// Caret NOT in a mention span → defer to the editor's normal handling
|
|
1419
|
+
// for typing, deletes, etc. Fresh-trigger `@` detection is done by
|
|
1420
|
+
// TriggerManager (separate listener), not here.
|
|
1421
|
+
if (!span || !rootElement?.contains(span)) return
|
|
1422
|
+
|
|
1423
|
+
const sel = window.getSelection()
|
|
1424
|
+
if (!sel) return
|
|
1425
|
+
|
|
1426
|
+
// Guard: span detached (edge case during heavy editing).
|
|
1427
|
+
if (!inDOM(span)) {
|
|
1428
|
+
if (session?.mode === 'edit' && session.span === span) closeSession()
|
|
1429
|
+
return
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1432
|
+
const parentEl = findContenteditableBlock(span) || span.parentElement
|
|
1433
|
+
if (!parentEl) return
|
|
1434
|
+
|
|
1435
|
+
const anchorNode = sel.anchorNode
|
|
1436
|
+
const anchorOffset = sel.anchorOffset
|
|
1437
|
+
const isEditing = session?.mode === 'edit' && session.span === span
|
|
1438
|
+
|
|
1439
|
+
const spanText = span.textContent || ''
|
|
1440
|
+
/** @type {number | null} */
|
|
1441
|
+
let cursorInText
|
|
1442
|
+
if (anchorNode && anchorNode.nodeType === Node.TEXT_NODE && anchorNode.parentElement === span) {
|
|
1443
|
+
cursorInText = anchorOffset
|
|
1444
|
+
} else if (anchorNode === span) {
|
|
1445
|
+
// Anchor is span element itself → treat offset 0 as start, 1 as end.
|
|
1446
|
+
cursorInText = anchorOffset === 0 ? 0 : spanText.length
|
|
1447
|
+
} else {
|
|
1448
|
+
cursorInText = null
|
|
1449
|
+
}
|
|
1450
|
+
|
|
1451
|
+
const isAtStart = cursorInText === 0
|
|
1452
|
+
const isAtEnd = cursorInText === spanText.length
|
|
1453
|
+
|
|
1454
|
+
// Case A — caret at start of span. Insert / delete happens OUTSIDE
|
|
1455
|
+
// the span (before it), treating the span as an opaque boundary.
|
|
1456
|
+
if (isAtStart) {
|
|
1457
|
+
e.preventDefault()
|
|
1458
|
+
if (isEditing) closeSession()
|
|
1459
|
+
|
|
1460
|
+
if (e.inputType === 'insertText' && typeof e.data === 'string') {
|
|
1461
|
+
mutateContent(parentEl, () => {
|
|
1462
|
+
const tn = document.createTextNode(e.data)
|
|
1463
|
+
span.parentNode?.insertBefore(tn, span)
|
|
1464
|
+
setCaretAt(tn, e.data.length)
|
|
1465
|
+
})
|
|
1466
|
+
} else if (e.inputType === 'deleteContentBackward') {
|
|
1467
|
+
mutateContent(parentEl, () => backspaceBeforeSpan(span))
|
|
1468
|
+
} else if (e.inputType === 'deleteContentForward') {
|
|
1469
|
+
mutateContent(parentEl, () => deleteForwardInSpan(span, spanText))
|
|
1470
|
+
}
|
|
1471
|
+
return
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
// Case B — caret at end of a committed (non-editing) span. Typing
|
|
1475
|
+
// goes to a sibling text node AFTER the span (prevents growing the
|
|
1476
|
+
// pill with arbitrary user input).
|
|
1477
|
+
if (isAtEnd && !isEditing) {
|
|
1478
|
+
if ((e.inputType === 'insertText' || e.inputType === 'insertCompositionText') && typeof e.data === 'string') {
|
|
1479
|
+
e.preventDefault()
|
|
1480
|
+
mutateContent(parentEl, () => {
|
|
1481
|
+
const next = span.nextSibling
|
|
1482
|
+
if (next && next.nodeType === Node.TEXT_NODE) {
|
|
1483
|
+
next.textContent = e.data + (next.textContent || '')
|
|
1484
|
+
setCaretAt(next, e.data.length)
|
|
1485
|
+
} else {
|
|
1486
|
+
const char = e.data === ' ' ? '\u00A0' : e.data
|
|
1487
|
+
const tn = document.createTextNode(char)
|
|
1488
|
+
span.after(tn)
|
|
1489
|
+
setCaretAt(tn, 1)
|
|
1490
|
+
}
|
|
1491
|
+
})
|
|
1492
|
+
return
|
|
1493
|
+
}
|
|
1494
|
+
if (e.inputType === 'deleteContentForward') {
|
|
1495
|
+
e.preventDefault()
|
|
1496
|
+
mutateContent(parentEl, () => deleteForwardAfterNode(span))
|
|
1497
|
+
return
|
|
1498
|
+
}
|
|
1499
|
+
}
|
|
1500
|
+
|
|
1501
|
+
// Case C — Enter / line-break inside a span.
|
|
1502
|
+
if (e.inputType === 'insertLineBreak' || e.inputType === 'insertParagraph') {
|
|
1503
|
+
// If actively editing with results → Enter commits via handleKeydown
|
|
1504
|
+
// (already bound); let the key handler run, just swallow the default.
|
|
1505
|
+
e.preventDefault()
|
|
1506
|
+
if (isEditing && session && session.results.length > 0) return
|
|
1507
|
+
// Otherwise: drop edit state (if any), place caret after span, insert <br>.
|
|
1508
|
+
// This matches mentionjs fallthrough; the editor's own Enter handling
|
|
1509
|
+
// (splitBlock) is intentionally bypassed inside a pill, because the
|
|
1510
|
+
// user was semantically editing the mention, not splitting the block.
|
|
1511
|
+
if (isEditing) closeSession()
|
|
1512
|
+
mutateContent(parentEl, () => {
|
|
1513
|
+
setCaretAfterNode(span)
|
|
1514
|
+
insertBr(window.getSelection() || sel)
|
|
1515
|
+
})
|
|
1516
|
+
return
|
|
1517
|
+
}
|
|
1518
|
+
|
|
1519
|
+
// Case D — Backspace inside the span (not at start).
|
|
1520
|
+
if (e.inputType === 'deleteContentBackward') {
|
|
1521
|
+
const offset = cursorInText ?? spanText.length
|
|
1522
|
+
|
|
1523
|
+
// Span contains ONLY the trigger → remove it entirely.
|
|
1524
|
+
if (spanText === opts.trigger) {
|
|
1525
|
+
e.preventDefault()
|
|
1526
|
+
if (isEditing) closeSessionForSpanReplacement(span)
|
|
1527
|
+
mutateContent(parentEl, () => {
|
|
1528
|
+
const prev = span.previousSibling
|
|
1529
|
+
const next = span.nextSibling
|
|
1530
|
+
span.parentNode?.removeChild(span)
|
|
1531
|
+
const range = document.createRange()
|
|
1532
|
+
if (prev && prev.nodeType === Node.TEXT_NODE) range.setStart(prev, (prev.textContent || '').length)
|
|
1533
|
+
else if (next) range.setStartBefore(next)
|
|
1534
|
+
else range.setStart(parentEl, 0)
|
|
1535
|
+
range.collapse(true)
|
|
1536
|
+
sel.removeAllRanges()
|
|
1537
|
+
sel.addRange(range)
|
|
1538
|
+
})
|
|
1539
|
+
return
|
|
1540
|
+
}
|
|
1541
|
+
|
|
1542
|
+
// Backspace immediately after the trigger deletes the trigger itself →
|
|
1543
|
+
// unwrap the span into plain text (minus the trigger).
|
|
1544
|
+
if (offset === opts.trigger.length && spanText.startsWith(opts.trigger)) {
|
|
1545
|
+
e.preventDefault()
|
|
1546
|
+
if (isEditing) closeSessionForSpanReplacement(span)
|
|
1547
|
+
mutateContent(parentEl, () => {
|
|
1548
|
+
const tn = document.createTextNode(spanText.substring(opts.trigger.length))
|
|
1549
|
+
span.parentNode?.insertBefore(tn, span)
|
|
1550
|
+
span.parentNode?.removeChild(span)
|
|
1551
|
+
setCaretAt(tn, 0)
|
|
1552
|
+
})
|
|
1553
|
+
return
|
|
1554
|
+
}
|
|
1555
|
+
|
|
1556
|
+
// Normal char deletion INSIDE the span. Shrink text, re-enter edit
|
|
1557
|
+
// mode, fire a fresh search.
|
|
1558
|
+
e.preventDefault()
|
|
1559
|
+
const newOffset = codePointStartBefore(spanText, offset)
|
|
1560
|
+
const newText = spanText.substring(0, newOffset) + spanText.substring(offset)
|
|
1561
|
+
|
|
1562
|
+
mutateContent(parentEl, () => {
|
|
1563
|
+
const tn = span.firstChild
|
|
1564
|
+
if (tn && tn.nodeType === Node.TEXT_NODE) tn.textContent = newText
|
|
1565
|
+
else span.textContent = newText
|
|
1566
|
+
setCaretAt(span.firstChild || span, newOffset)
|
|
1567
|
+
})
|
|
1568
|
+
|
|
1569
|
+
refreshEditSession(span, parentEl, newText.substring(opts.trigger.length))
|
|
1570
|
+
return
|
|
1571
|
+
}
|
|
1572
|
+
|
|
1573
|
+
// Case E — Forward-delete inside the span.
|
|
1574
|
+
if (e.inputType === 'deleteContentForward') {
|
|
1575
|
+
const offset = cursorInText ?? spanText.length
|
|
1576
|
+
|
|
1577
|
+
// At end → remove a char from the following sibling.
|
|
1578
|
+
if (offset >= spanText.length) {
|
|
1579
|
+
e.preventDefault()
|
|
1580
|
+
mutateContent(parentEl, () => deleteForwardAfterNode(span))
|
|
1581
|
+
return
|
|
1582
|
+
}
|
|
1583
|
+
|
|
1584
|
+
e.preventDefault()
|
|
1585
|
+
const newText = spanText.substring(0, offset) + spanText.substring(codePointEndAt(spanText, offset))
|
|
1586
|
+
|
|
1587
|
+
if (newText.length === 0) {
|
|
1588
|
+
if (isEditing) closeSessionForSpanReplacement(span)
|
|
1589
|
+
mutateContent(parentEl, () => {
|
|
1590
|
+
const prev = span.previousSibling
|
|
1591
|
+
const next = span.nextSibling
|
|
1592
|
+
span.parentNode?.removeChild(span)
|
|
1593
|
+
const range = document.createRange()
|
|
1594
|
+
if (next) range.setStartBefore(next)
|
|
1595
|
+
else if (prev && prev.nodeType === Node.TEXT_NODE) range.setStart(prev, (prev.textContent || '').length)
|
|
1596
|
+
else range.setStart(parentEl, 0)
|
|
1597
|
+
range.collapse(true)
|
|
1598
|
+
sel.removeAllRanges()
|
|
1599
|
+
sel.addRange(range)
|
|
1600
|
+
})
|
|
1601
|
+
return
|
|
1602
|
+
}
|
|
1603
|
+
|
|
1604
|
+
// If forward-delete ate the trigger → unwrap to plain text.
|
|
1605
|
+
if (offset === 0 && !newText.startsWith(opts.trigger)) {
|
|
1606
|
+
if (isEditing) closeSessionForSpanReplacement(span)
|
|
1607
|
+
mutateContent(parentEl, () => {
|
|
1608
|
+
const tn = document.createTextNode(newText)
|
|
1609
|
+
span.parentNode?.insertBefore(tn, span)
|
|
1610
|
+
span.parentNode?.removeChild(span)
|
|
1611
|
+
setCaretAt(tn, 0)
|
|
1612
|
+
})
|
|
1613
|
+
return
|
|
1614
|
+
}
|
|
1615
|
+
|
|
1616
|
+
mutateContent(parentEl, () => {
|
|
1617
|
+
const tn = span.firstChild
|
|
1618
|
+
if (tn && tn.nodeType === Node.TEXT_NODE) tn.textContent = newText
|
|
1619
|
+
else span.textContent = newText
|
|
1620
|
+
setCaretAt(span.firstChild || span, offset)
|
|
1621
|
+
})
|
|
1622
|
+
|
|
1623
|
+
refreshEditSession(span, parentEl, newText.substring(opts.trigger.length))
|
|
1624
|
+
return
|
|
1625
|
+
}
|
|
1626
|
+
|
|
1627
|
+
// Case F — Typing inside the span (not at start, not at end-with-committed).
|
|
1628
|
+
// Browser default inserts the char inside the span; we just re-run
|
|
1629
|
+
// the search afterwards.
|
|
1630
|
+
if ((e.inputType === 'insertText' || e.inputType === 'insertCompositionText') && typeof e.data === 'string') {
|
|
1631
|
+
const insertAt = cursorInText ?? spanText.length
|
|
1632
|
+
const newText = spanText.substring(0, insertAt) + e.data + spanText.substring(insertAt)
|
|
1633
|
+
refreshEditSession(span, parentEl, newText.substring(opts.trigger.length))
|
|
1634
|
+
// Don't preventDefault — let the browser do the actual DOM insertion.
|
|
1635
|
+
}
|
|
1636
|
+
}
|
|
1637
|
+
|
|
1638
|
+
// ─── Widget DOM (committed mention) ────────────────────────────────────
|
|
1639
|
+
|
|
1640
|
+
/**
|
|
1641
|
+
* Delegate widget DOM construction to the shared widget module so the
|
|
1642
|
+
* editor and document renderer use identical markup. The mention
|
|
1643
|
+
* widget is intentionally NOT `contentEditable="false"` — the caret can
|
|
1644
|
+
* enter it and Backspace / Delete / typing inside it work character-by-
|
|
1645
|
+
* character (matching the original `@shelamkoff/mentionjs` behavior).
|
|
1646
|
+
* `handleBeforeInput` keeps the span well-formed and re-opens the
|
|
1647
|
+
* suggestion dropdown when edits change the query.
|
|
1648
|
+
*/
|
|
1649
|
+
const sharedWidget = createMentionWidget(opts.trigger)
|
|
1650
|
+
const createWidget = sharedWidget.createWidget
|
|
1651
|
+
|
|
1652
|
+
// ─── Plugin surface ─────────────────────────────────────────────────────
|
|
1653
|
+
|
|
1654
|
+
/** @type {InlinePlugin} */
|
|
1655
|
+
const plugin = {
|
|
1656
|
+
type: 'mention',
|
|
1657
|
+
trigger: opts.trigger,
|
|
1658
|
+
get title() { return t('title', 'Mention') },
|
|
1659
|
+
|
|
1660
|
+
icon: '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="4"/><path d="M16 8v5a3 3 0 0 0 6 0v-1a10 10 0 1 0-3.92 7.94"/></svg>',
|
|
1661
|
+
|
|
1662
|
+
/** @param {import('../../core/types').IScopedI18n} _i18n */
|
|
1663
|
+
setI18n(_i18n) { i18n = _i18n },
|
|
1664
|
+
|
|
1665
|
+
/**
|
|
1666
|
+
* Acquire resources only after the plugin belongs to a successfully
|
|
1667
|
+
* constructed editor. The listener is scoped to that editor root.
|
|
1668
|
+
* @param {HTMLElement} editorRoot
|
|
1669
|
+
* @param {InlinePluginContext} ctx
|
|
1670
|
+
*/
|
|
1671
|
+
mount(editorRoot, ctx) {
|
|
1672
|
+
if (rootElement) throw new Error('Mention plugin is already mounted')
|
|
1673
|
+
rootElement = editorRoot
|
|
1674
|
+
globalCtx = ctx
|
|
1675
|
+
styleHandle = injectStyleUrls([STYLES_URL])
|
|
1676
|
+
rootElement.addEventListener('beforeinput', /** @type {EventListener} */ (handleBeforeInput), true)
|
|
1677
|
+
},
|
|
1678
|
+
|
|
1679
|
+
/**
|
|
1680
|
+
* Called by TriggerManager when `trigger` is typed at a word boundary
|
|
1681
|
+
* and again on every input while the session is active. `query` is the
|
|
1682
|
+
* text between the trigger character and the caret.
|
|
1683
|
+
*
|
|
1684
|
+
* @param {HTMLElement} parentEl
|
|
1685
|
+
* @param {string} query
|
|
1686
|
+
* @param {InlinePluginContext} ctx
|
|
1687
|
+
*/
|
|
1688
|
+
onEdit(parentEl, query, ctx) {
|
|
1689
|
+
// Stash ctx for edit-mode mutations (fired via beforeinput outside of
|
|
1690
|
+
// a TriggerManager-driven flow — see `handleBeforeInput`).
|
|
1691
|
+
globalCtx = ctx
|
|
1692
|
+
|
|
1693
|
+
// Fresh trigger — close any stale session from a different block
|
|
1694
|
+
// before opening a new one. (Guards against TriggerManager edge cases
|
|
1695
|
+
// where onEdit fires with query='' while our session is still alive.)
|
|
1696
|
+
if (session && (query === '' || session.parentEl !== parentEl)) {
|
|
1697
|
+
closeSession()
|
|
1698
|
+
}
|
|
1699
|
+
|
|
1700
|
+
// Open a fresh session on first invocation.
|
|
1701
|
+
if (!session) {
|
|
1702
|
+
const sel = window.getSelection()
|
|
1703
|
+
if (!sel || !sel.anchorNode || sel.anchorNode.nodeType !== Node.TEXT_NODE) return
|
|
1704
|
+
|
|
1705
|
+
const textNode = /** @type {Text} */ (sel.anchorNode)
|
|
1706
|
+
const triggerOffset = sel.anchorOffset - opts.trigger.length
|
|
1707
|
+
if (triggerOffset < 0 || !textNode.data.startsWith(opts.trigger, triggerOffset)) return
|
|
1708
|
+
|
|
1709
|
+
openFreshSession(parentEl, textNode, triggerOffset, ctx)
|
|
1710
|
+
}
|
|
1711
|
+
|
|
1712
|
+
// Kick off / refresh search for the new query.
|
|
1713
|
+
// `onEdit` is invoked synchronously from the editor's TriggerManager;
|
|
1714
|
+
// we fire-and-forget here and update the dropdown when results arrive.
|
|
1715
|
+
runSearch(query).then((items) => {
|
|
1716
|
+
if (items === null) return // superseded / cancelled
|
|
1717
|
+
if (!session) return // session closed mid-request
|
|
1718
|
+
openOrUpdateDropdown(items)
|
|
1719
|
+
}).catch((err) => {
|
|
1720
|
+
// eslint-disable-next-line no-console
|
|
1721
|
+
console.warn('[mention-plugin] onEdit search failed:', err)
|
|
1722
|
+
})
|
|
1723
|
+
},
|
|
1724
|
+
|
|
1725
|
+
/** Close plugin-owned UI when the editor cancels the active trigger. */
|
|
1726
|
+
onCancel() { closeSession() },
|
|
1727
|
+
|
|
1728
|
+
/**
|
|
1729
|
+
* Build a committed widget. Called by `insertInlinePluginAtCaret` for
|
|
1730
|
+
* programmatic inserts and by the marshal pipeline for load-time
|
|
1731
|
+
* rehydration. Delegates to the shared widget factory; if `id` is
|
|
1732
|
+
* provided it's preserved as the span's `data-id` (marshal load path),
|
|
1733
|
+
* otherwise a fresh one is generated (programmatic fresh insert).
|
|
1734
|
+
*
|
|
1735
|
+
* @param {Record<string, string>} data
|
|
1736
|
+
* @param {string} [id]
|
|
1737
|
+
* @returns {HTMLElement}
|
|
1738
|
+
*/
|
|
1739
|
+
createWidget(data, id) {
|
|
1740
|
+
return createWidget({ id: data.id || '', name: data.name || '' }, id)
|
|
1741
|
+
},
|
|
1742
|
+
|
|
1743
|
+
/**
|
|
1744
|
+
* Called by `hydrateInlinePlugins` for each committed widget in the DOM
|
|
1745
|
+
* when the editor loads saved content.
|
|
1746
|
+
*
|
|
1747
|
+
* The mention pill has no interactive controls (per spec — clickability
|
|
1748
|
+
* is a consumer concern) so there's nothing to wire up per widget. We
|
|
1749
|
+
* DO, however, capture the `InlinePluginContext` here so that the very
|
|
1750
|
+
* first edit action (e.g. user backspacing into a committed pill that
|
|
1751
|
+
* came from loaded content, before they ever typed `@` to trigger a
|
|
1752
|
+
* fresh session) can still call `notifyChanged()`.
|
|
1753
|
+
*
|
|
1754
|
+
* @param {HTMLElement} _element
|
|
1755
|
+
* @param {InlinePluginContext} ctx
|
|
1756
|
+
*/
|
|
1757
|
+
hydrate(_element, ctx) { globalCtx = ctx },
|
|
1758
|
+
|
|
1759
|
+
/**
|
|
1760
|
+
* Delegate to the shared widget primitive — one source of truth for
|
|
1761
|
+
* how a mention span reads back into data, used by both the editor
|
|
1762
|
+
* marshal pipeline and document renderer.
|
|
1763
|
+
* @param {HTMLElement} element
|
|
1764
|
+
* @returns {Record<string, string>}
|
|
1765
|
+
*/
|
|
1766
|
+
getData(element) {
|
|
1767
|
+
return /** @type {Record<string, string>} */ (sharedWidget.getData(element))
|
|
1768
|
+
},
|
|
1769
|
+
|
|
1770
|
+
/**
|
|
1771
|
+
* Exclude an in-progress edited pill from persistent widget data. Its
|
|
1772
|
+
* visible `@query` is retained as ordinary text by the shared serializer.
|
|
1773
|
+
* @param {HTMLElement} element
|
|
1774
|
+
* @returns {boolean}
|
|
1775
|
+
*/
|
|
1776
|
+
isCommitted(element) {
|
|
1777
|
+
return !!element.dataset.value && !element.classList.contains('oe-ip--mention--editing')
|
|
1778
|
+
},
|
|
1779
|
+
|
|
1780
|
+
/**
|
|
1781
|
+
* Programmatic insertion via the toolbox `+` button.
|
|
1782
|
+
*
|
|
1783
|
+
* Per spec: behaves IDENTICALLY to the user typing the trigger
|
|
1784
|
+
* character manually — inserts just the trigger (`@`) at the caret,
|
|
1785
|
+
* positions the caret right after it, and opens a fresh session
|
|
1786
|
+
* (matching what `TriggerManager` would do if the `@` were typed).
|
|
1787
|
+
*
|
|
1788
|
+
* We can't rely on `TriggerManager` observing a programmatic DOM
|
|
1789
|
+
* mutation, so we dispatch a synthetic `input` event on the block —
|
|
1790
|
+
* `TriggerManager`'s `#onInput` listener then activates itself, sees
|
|
1791
|
+
* the freshly-inserted `@` at caret offset - 1, and calls our plugin's
|
|
1792
|
+
* `onEdit(parentEl, '', ctx)` naturally. From there everything flows
|
|
1793
|
+
* through the normal fresh-session code path.
|
|
1794
|
+
*
|
|
1795
|
+
* @param {InlinePluginContext} ctx
|
|
1796
|
+
*/
|
|
1797
|
+
insertFresh(ctx) {
|
|
1798
|
+
globalCtx = ctx
|
|
1799
|
+
|
|
1800
|
+
// Resolve the caret's current block. Fall back to walking up from
|
|
1801
|
+
// `sel.anchorNode` — `setCaretToBlock` for an EMPTY paragraph only
|
|
1802
|
+
// calls `element.focus()` and doesn't set a selection range, so
|
|
1803
|
+
// `sel.rangeCount` may be 0. In that case we insert at the end of
|
|
1804
|
+
// whatever contenteditable is currently focused.
|
|
1805
|
+
const sel = window.getSelection()
|
|
1806
|
+
let parentEl = null
|
|
1807
|
+
if (sel && sel.rangeCount > 0) {
|
|
1808
|
+
const node = sel.anchorNode
|
|
1809
|
+
parentEl = node
|
|
1810
|
+
? (node.nodeType === Node.ELEMENT_NODE
|
|
1811
|
+
? (/** @type {HTMLElement} */ (node).isContentEditable
|
|
1812
|
+
? /** @type {HTMLElement} */ (node)
|
|
1813
|
+
: findContenteditableBlock(node))
|
|
1814
|
+
: findContenteditableBlock(node))
|
|
1815
|
+
: null
|
|
1816
|
+
}
|
|
1817
|
+
if (!parentEl && document.activeElement instanceof HTMLElement && document.activeElement.isContentEditable) {
|
|
1818
|
+
parentEl = document.activeElement
|
|
1819
|
+
}
|
|
1820
|
+
if (!parentEl) return
|
|
1821
|
+
|
|
1822
|
+
// Chrome auto-inserts a `<br>` as the sole child of an otherwise
|
|
1823
|
+
// empty contenteditable element when it gains focus. That `<br>`
|
|
1824
|
+
// plays havoc with ranges (some specs want the range anchored INSIDE
|
|
1825
|
+
// it, which is invalid — it's a void element). Strip it so we have
|
|
1826
|
+
// a clean insertion point.
|
|
1827
|
+
if (
|
|
1828
|
+
parentEl.childNodes.length === 1
|
|
1829
|
+
&& parentEl.firstChild instanceof HTMLElement
|
|
1830
|
+
&& parentEl.firstChild.tagName === 'BR'
|
|
1831
|
+
) {
|
|
1832
|
+
parentEl.removeChild(parentEl.firstChild)
|
|
1833
|
+
}
|
|
1834
|
+
|
|
1835
|
+
// Create the trigger text node and insert it at the current caret.
|
|
1836
|
+
// If we have a usable range inside parentEl, honor it; otherwise
|
|
1837
|
+
// fall back to appending at the end — matches "+" → type `@`
|
|
1838
|
+
// intent from an empty block.
|
|
1839
|
+
const tn = document.createTextNode(opts.trigger)
|
|
1840
|
+
let inserted = false
|
|
1841
|
+
if (sel && sel.rangeCount > 0) {
|
|
1842
|
+
const range = sel.getRangeAt(0)
|
|
1843
|
+
if (parentEl.contains(range.startContainer) || range.startContainer === parentEl) {
|
|
1844
|
+
try {
|
|
1845
|
+
range.deleteContents()
|
|
1846
|
+
range.insertNode(tn)
|
|
1847
|
+
inserted = true
|
|
1848
|
+
} catch {
|
|
1849
|
+
inserted = false
|
|
1850
|
+
}
|
|
1851
|
+
}
|
|
1852
|
+
}
|
|
1853
|
+
if (!inserted) {
|
|
1854
|
+
parentEl.appendChild(tn)
|
|
1855
|
+
}
|
|
1856
|
+
|
|
1857
|
+
// Caret directly after the inserted trigger.
|
|
1858
|
+
setCaretAt(tn, opts.trigger.length)
|
|
1859
|
+
|
|
1860
|
+
// Fire a synthetic `input` event so the editor's `TriggerManager`
|
|
1861
|
+
// (listening on `rootEl`) activates as if the user had typed `@`.
|
|
1862
|
+
// That listener reads the live DOM/selection — no dependency on
|
|
1863
|
+
// `event.isTrusted`. `wireInputTracking` also sees the input and
|
|
1864
|
+
// emits `CHANGED`, so there's no need to call `notifyChange` here.
|
|
1865
|
+
parentEl.dispatchEvent(new InputEvent('input', {
|
|
1866
|
+
bubbles: true,
|
|
1867
|
+
cancelable: false,
|
|
1868
|
+
inputType: 'insertText',
|
|
1869
|
+
data: opts.trigger,
|
|
1870
|
+
}))
|
|
1871
|
+
},
|
|
1872
|
+
|
|
1873
|
+
/**
|
|
1874
|
+
* Plugin-level cleanup hook. InlinePluginRegistry calls this once for the
|
|
1875
|
+
* owning editor and uses it to:
|
|
1876
|
+
* - close any open session;
|
|
1877
|
+
* - detach the editor-scoped `beforeinput` listener;
|
|
1878
|
+
* - release the shared stylesheet ref.
|
|
1879
|
+
*
|
|
1880
|
+
*/
|
|
1881
|
+
destroy() {
|
|
1882
|
+
closeSession()
|
|
1883
|
+
rootElement?.removeEventListener('beforeinput', /** @type {EventListener} */ (handleBeforeInput), true)
|
|
1884
|
+
rootElement = null
|
|
1885
|
+
styleHandle?.destroy()
|
|
1886
|
+
styleHandle = null
|
|
1887
|
+
globalCtx = null
|
|
1888
|
+
},
|
|
1889
|
+
}
|
|
1890
|
+
|
|
1891
|
+
return plugin
|
|
1892
|
+
}
|
|
1893
|
+
|
|
1894
|
+
// Re-export the renderer widget factory so consumers can pick the
|
|
1895
|
+
// minimum surface they need (renderer-only uses `createMentionWidget`,
|
|
1896
|
+
// editor uses `createMentionPlugin`).
|
|
1897
|
+
export { createMentionWidget } from './widget.js'
|