ngwr 14.7.0 → 14.9.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/README.md +66 -332
- package/alert/styles/_index.scss +10 -0
- package/avatar/styles/_index.scss +18 -7
- package/badge/styles/_index.scss +7 -0
- package/badge/styles/_tag.scss +7 -1
- package/bar-chart/styles/_index.scss +1 -0
- package/button/styles/_index.scss +6 -0
- package/calendar-heatmap/styles/_index.scss +1 -0
- package/cascader/styles/_rules.scss +87 -15
- package/checkbox/styles/_index.scss +4 -0
- package/command-palette/styles/_index.scss +1 -1
- package/date-picker/styles/_index.scss +30 -0
- package/density/styles/_index.scss +4 -2
- package/divider/styles/_index.scss +2 -0
- package/donut-chart/styles/_index.scss +10 -0
- package/dropdown/styles/_index.scss +67 -5
- package/editor/styles/_index.scss +441 -0
- package/event-calendar/styles/_index.scss +209 -35
- package/fesm2022/ngwr-action-sheet-testing.mjs.map +1 -1
- package/fesm2022/ngwr-action-sheet.mjs +3 -3
- package/fesm2022/ngwr-action-sheet.mjs.map +1 -1
- package/fesm2022/ngwr-affix.mjs +3 -3
- package/fesm2022/ngwr-affix.mjs.map +1 -1
- package/fesm2022/ngwr-alert.mjs +3 -3
- package/fesm2022/ngwr-alert.mjs.map +1 -1
- package/fesm2022/ngwr-anchor.mjs +3 -3
- package/fesm2022/ngwr-anchor.mjs.map +1 -1
- package/fesm2022/ngwr-aurora.mjs +3 -3
- package/fesm2022/ngwr-aurora.mjs.map +1 -1
- package/fesm2022/ngwr-avatar.mjs +3 -3
- package/fesm2022/ngwr-avatar.mjs.map +1 -1
- package/fesm2022/ngwr-back-top.mjs +3 -3
- package/fesm2022/ngwr-back-top.mjs.map +1 -1
- package/fesm2022/ngwr-badge.mjs +28 -13
- package/fesm2022/ngwr-badge.mjs.map +1 -1
- package/fesm2022/ngwr-bar-chart-testing.mjs.map +1 -1
- package/fesm2022/ngwr-bar-chart.mjs +31 -6
- package/fesm2022/ngwr-bar-chart.mjs.map +1 -1
- package/fesm2022/ngwr-blur-text-testing.mjs.map +1 -1
- package/fesm2022/ngwr-blur-text.mjs +3 -3
- package/fesm2022/ngwr-blur-text.mjs.map +1 -1
- package/fesm2022/ngwr-border-glow-testing.mjs.map +1 -1
- package/fesm2022/ngwr-border-glow.mjs +3 -3
- package/fesm2022/ngwr-border-glow.mjs.map +1 -1
- package/fesm2022/ngwr-breadcrumbs.mjs +6 -6
- package/fesm2022/ngwr-breadcrumbs.mjs.map +1 -1
- package/fesm2022/ngwr-burger.mjs +3 -3
- package/fesm2022/ngwr-burger.mjs.map +1 -1
- package/fesm2022/ngwr-button-testing.mjs.map +1 -1
- package/fesm2022/ngwr-button.mjs +6 -6
- package/fesm2022/ngwr-button.mjs.map +1 -1
- package/fesm2022/ngwr-calendar-heatmap-testing.mjs.map +1 -1
- package/fesm2022/ngwr-calendar-heatmap.mjs +61 -6
- package/fesm2022/ngwr-calendar-heatmap.mjs.map +1 -1
- package/fesm2022/ngwr-calendar-testing.mjs.map +1 -1
- package/fesm2022/ngwr-calendar.mjs +3 -3
- package/fesm2022/ngwr-calendar.mjs.map +1 -1
- package/fesm2022/ngwr-card.mjs +9 -9
- package/fesm2022/ngwr-card.mjs.map +1 -1
- package/fesm2022/ngwr-carousel-testing.mjs.map +1 -1
- package/fesm2022/ngwr-carousel.mjs +6 -6
- package/fesm2022/ngwr-carousel.mjs.map +1 -1
- package/fesm2022/ngwr-cascader-testing.mjs.map +1 -1
- package/fesm2022/ngwr-cascader.mjs +4 -4
- package/fesm2022/ngwr-cascader.mjs.map +1 -1
- package/fesm2022/ngwr-checkbox-testing.mjs.map +1 -1
- package/fesm2022/ngwr-checkbox.mjs +18 -8
- package/fesm2022/ngwr-checkbox.mjs.map +1 -1
- package/fesm2022/ngwr-circular-text-testing.mjs.map +1 -1
- package/fesm2022/ngwr-circular-text.mjs +3 -3
- package/fesm2022/ngwr-circular-text.mjs.map +1 -1
- package/fesm2022/ngwr-click-spark.mjs +3 -3
- package/fesm2022/ngwr-click-spark.mjs.map +1 -1
- package/fesm2022/ngwr-clipboard.mjs +3 -3
- package/fesm2022/ngwr-clipboard.mjs.map +1 -1
- package/fesm2022/ngwr-collapse-testing.mjs.map +1 -1
- package/fesm2022/ngwr-collapse.mjs +9 -9
- package/fesm2022/ngwr-collapse.mjs.map +1 -1
- package/fesm2022/ngwr-color-picker-testing.mjs.map +1 -1
- package/fesm2022/ngwr-color-picker.mjs +6 -6
- package/fesm2022/ngwr-color-picker.mjs.map +1 -1
- package/fesm2022/ngwr-command-palette-testing.mjs.map +1 -1
- package/fesm2022/ngwr-command-palette.mjs +3 -3
- package/fesm2022/ngwr-command-palette.mjs.map +1 -1
- package/fesm2022/ngwr-compare.mjs +3 -3
- package/fesm2022/ngwr-compare.mjs.map +1 -1
- package/fesm2022/ngwr-confetti.mjs +3 -3
- package/fesm2022/ngwr-confetti.mjs.map +1 -1
- package/fesm2022/ngwr-config.mjs.map +1 -1
- package/fesm2022/ngwr-context-menu-testing.mjs.map +1 -1
- package/fesm2022/ngwr-context-menu.mjs +12 -12
- package/fesm2022/ngwr-context-menu.mjs.map +1 -1
- package/fesm2022/ngwr-cookie.mjs +3 -3
- package/fesm2022/ngwr-cookie.mjs.map +1 -1
- package/fesm2022/ngwr-counter.mjs +6 -6
- package/fesm2022/ngwr-counter.mjs.map +1 -1
- package/fesm2022/ngwr-date-adapters-fns.mjs +3 -3
- package/fesm2022/ngwr-date-adapters-fns.mjs.map +1 -1
- package/fesm2022/ngwr-date-adapters-luxon.mjs +3 -3
- package/fesm2022/ngwr-date-adapters-luxon.mjs.map +1 -1
- package/fesm2022/ngwr-date-picker-testing.mjs.map +1 -1
- package/fesm2022/ngwr-date-picker.mjs +721 -62
- package/fesm2022/ngwr-date-picker.mjs.map +1 -1
- package/fesm2022/ngwr-date.mjs +3 -3
- package/fesm2022/ngwr-date.mjs.map +1 -1
- package/fesm2022/ngwr-decrypt-text-testing.mjs.map +1 -1
- package/fesm2022/ngwr-decrypt-text.mjs +3 -3
- package/fesm2022/ngwr-decrypt-text.mjs.map +1 -1
- package/fesm2022/ngwr-density.mjs +6 -6
- package/fesm2022/ngwr-density.mjs.map +1 -1
- package/fesm2022/ngwr-descriptions.mjs +6 -6
- package/fesm2022/ngwr-descriptions.mjs.map +1 -1
- package/fesm2022/ngwr-dialog-testing.mjs.map +1 -1
- package/fesm2022/ngwr-dialog.mjs +15 -15
- package/fesm2022/ngwr-dialog.mjs.map +1 -1
- package/fesm2022/ngwr-directives.mjs +12 -12
- package/fesm2022/ngwr-directives.mjs.map +1 -1
- package/fesm2022/ngwr-divider.mjs +3 -3
- package/fesm2022/ngwr-divider.mjs.map +1 -1
- package/fesm2022/ngwr-donut-chart-testing.mjs.map +1 -1
- package/fesm2022/ngwr-donut-chart.mjs +48 -6
- package/fesm2022/ngwr-donut-chart.mjs.map +1 -1
- package/fesm2022/ngwr-drag-drop.mjs +6 -6
- package/fesm2022/ngwr-drag-drop.mjs.map +1 -1
- package/fesm2022/ngwr-drawer-testing.mjs.map +1 -1
- package/fesm2022/ngwr-drawer.mjs +18 -18
- package/fesm2022/ngwr-drawer.mjs.map +1 -1
- package/fesm2022/ngwr-dropdown-testing.mjs.map +1 -1
- package/fesm2022/ngwr-dropdown.mjs +174 -25
- package/fesm2022/ngwr-dropdown.mjs.map +1 -1
- package/fesm2022/ngwr-editor-testing.mjs +568 -0
- package/fesm2022/ngwr-editor-testing.mjs.map +1 -0
- package/fesm2022/ngwr-editor.mjs +2601 -0
- package/fesm2022/ngwr-editor.mjs.map +1 -0
- package/fesm2022/ngwr-empty.mjs +3 -3
- package/fesm2022/ngwr-empty.mjs.map +1 -1
- package/fesm2022/ngwr-event-calendar-testing.mjs.map +1 -1
- package/fesm2022/ngwr-event-calendar.mjs +14 -7
- package/fesm2022/ngwr-event-calendar.mjs.map +1 -1
- package/fesm2022/ngwr-falling-text-testing.mjs.map +1 -1
- package/fesm2022/ngwr-falling-text.mjs +3 -3
- package/fesm2022/ngwr-falling-text.mjs.map +1 -1
- package/fesm2022/ngwr-file-upload-testing.mjs.map +1 -1
- package/fesm2022/ngwr-file-upload.mjs +3 -3
- package/fesm2022/ngwr-file-upload.mjs.map +1 -1
- package/fesm2022/ngwr-form-testing.mjs.map +1 -1
- package/fesm2022/ngwr-form.mjs +61 -10
- package/fesm2022/ngwr-form.mjs.map +1 -1
- package/fesm2022/ngwr-fuzzy-text-testing.mjs.map +1 -1
- package/fesm2022/ngwr-fuzzy-text.mjs +3 -3
- package/fesm2022/ngwr-fuzzy-text.mjs.map +1 -1
- package/fesm2022/ngwr-gauge-testing.mjs.map +1 -1
- package/fesm2022/ngwr-gauge.mjs +35 -6
- package/fesm2022/ngwr-gauge.mjs.map +1 -1
- package/fesm2022/ngwr-glitch-text-testing.mjs.map +1 -1
- package/fesm2022/ngwr-glitch-text.mjs +3 -3
- package/fesm2022/ngwr-glitch-text.mjs.map +1 -1
- package/fesm2022/ngwr-gradient-text-testing.mjs.map +1 -1
- package/fesm2022/ngwr-gradient-text.mjs +3 -3
- package/fesm2022/ngwr-gradient-text.mjs.map +1 -1
- package/fesm2022/ngwr-graph-testing.mjs.map +1 -1
- package/fesm2022/ngwr-graph.mjs +6 -6
- package/fesm2022/ngwr-graph.mjs.map +1 -1
- package/fesm2022/ngwr-hotkey.mjs +6 -6
- package/fesm2022/ngwr-hotkey.mjs.map +1 -1
- package/fesm2022/ngwr-i18n-ar.mjs +31 -0
- package/fesm2022/ngwr-i18n-ar.mjs.map +1 -1
- package/fesm2022/ngwr-i18n-cs.mjs +31 -0
- package/fesm2022/ngwr-i18n-cs.mjs.map +1 -1
- package/fesm2022/ngwr-i18n-de.mjs +31 -0
- package/fesm2022/ngwr-i18n-de.mjs.map +1 -1
- package/fesm2022/ngwr-i18n-en.mjs +39 -0
- package/fesm2022/ngwr-i18n-en.mjs.map +1 -1
- package/fesm2022/ngwr-i18n-es.mjs +31 -0
- package/fesm2022/ngwr-i18n-es.mjs.map +1 -1
- package/fesm2022/ngwr-i18n-fr.mjs +31 -0
- package/fesm2022/ngwr-i18n-fr.mjs.map +1 -1
- package/fesm2022/ngwr-i18n-he.mjs +31 -0
- package/fesm2022/ngwr-i18n-he.mjs.map +1 -1
- package/fesm2022/ngwr-i18n-hi.mjs +31 -0
- package/fesm2022/ngwr-i18n-hi.mjs.map +1 -1
- package/fesm2022/ngwr-i18n-id.mjs +31 -0
- package/fesm2022/ngwr-i18n-id.mjs.map +1 -1
- package/fesm2022/ngwr-i18n-it.mjs +31 -0
- package/fesm2022/ngwr-i18n-it.mjs.map +1 -1
- package/fesm2022/ngwr-i18n-ja.mjs +31 -0
- package/fesm2022/ngwr-i18n-ja.mjs.map +1 -1
- package/fesm2022/ngwr-i18n-ko.mjs +31 -0
- package/fesm2022/ngwr-i18n-ko.mjs.map +1 -1
- package/fesm2022/ngwr-i18n-nl.mjs +31 -0
- package/fesm2022/ngwr-i18n-nl.mjs.map +1 -1
- package/fesm2022/ngwr-i18n-pl.mjs +31 -0
- package/fesm2022/ngwr-i18n-pl.mjs.map +1 -1
- package/fesm2022/ngwr-i18n-pt.mjs +31 -0
- package/fesm2022/ngwr-i18n-pt.mjs.map +1 -1
- package/fesm2022/ngwr-i18n-ru.mjs +31 -0
- package/fesm2022/ngwr-i18n-ru.mjs.map +1 -1
- package/fesm2022/ngwr-i18n-sv.mjs +31 -0
- package/fesm2022/ngwr-i18n-sv.mjs.map +1 -1
- package/fesm2022/ngwr-i18n-tr.mjs +31 -0
- package/fesm2022/ngwr-i18n-tr.mjs.map +1 -1
- package/fesm2022/ngwr-i18n-uk.mjs +31 -0
- package/fesm2022/ngwr-i18n-uk.mjs.map +1 -1
- package/fesm2022/ngwr-i18n-vi.mjs +31 -0
- package/fesm2022/ngwr-i18n-vi.mjs.map +1 -1
- package/fesm2022/ngwr-i18n-zh-TW.mjs +31 -0
- package/fesm2022/ngwr-i18n-zh-TW.mjs.map +1 -1
- package/fesm2022/ngwr-i18n-zh.mjs +31 -0
- package/fesm2022/ngwr-i18n-zh.mjs.map +1 -1
- package/fesm2022/ngwr-i18n.mjs +9 -9
- package/fesm2022/ngwr-i18n.mjs.map +1 -1
- package/fesm2022/ngwr-icon-adapters-feather.mjs.map +1 -1
- package/fesm2022/ngwr-icon-adapters-lucide.mjs.map +1 -1
- package/fesm2022/ngwr-icon.mjs +6 -6
- package/fesm2022/ngwr-icon.mjs.map +1 -1
- package/fesm2022/ngwr-image-cropper-testing.mjs.map +1 -1
- package/fesm2022/ngwr-image-cropper.mjs +3 -3
- package/fesm2022/ngwr-image-cropper.mjs.map +1 -1
- package/fesm2022/ngwr-input-number-testing.mjs.map +1 -1
- package/fesm2022/ngwr-input-number.mjs +15 -3
- package/fesm2022/ngwr-input-number.mjs.map +1 -1
- package/fesm2022/ngwr-input-otp-testing.mjs.map +1 -1
- package/fesm2022/ngwr-input-otp.mjs +3 -3
- package/fesm2022/ngwr-input-otp.mjs.map +1 -1
- package/fesm2022/ngwr-input-testing.mjs.map +1 -1
- package/fesm2022/ngwr-input.mjs +15 -15
- package/fesm2022/ngwr-input.mjs.map +1 -1
- package/fesm2022/ngwr-keyboard.mjs +3 -3
- package/fesm2022/ngwr-keyboard.mjs.map +1 -1
- package/fesm2022/ngwr-knob-testing.mjs.map +1 -1
- package/fesm2022/ngwr-knob.mjs +3 -3
- package/fesm2022/ngwr-knob.mjs.map +1 -1
- package/fesm2022/ngwr-layout.mjs +15 -15
- package/fesm2022/ngwr-layout.mjs.map +1 -1
- package/fesm2022/ngwr-lightbox-testing.mjs.map +1 -1
- package/fesm2022/ngwr-lightbox.mjs +3 -3
- package/fesm2022/ngwr-lightbox.mjs.map +1 -1
- package/fesm2022/ngwr-line-chart-testing.mjs.map +1 -1
- package/fesm2022/ngwr-line-chart.mjs +18 -7
- package/fesm2022/ngwr-line-chart.mjs.map +1 -1
- package/fesm2022/ngwr-list.mjs +6 -6
- package/fesm2022/ngwr-list.mjs.map +1 -1
- package/fesm2022/ngwr-loading-bar-router.mjs.map +1 -1
- package/fesm2022/ngwr-loading-bar.mjs +6 -6
- package/fesm2022/ngwr-loading-bar.mjs.map +1 -1
- package/fesm2022/ngwr-markdown-testing.mjs.map +1 -1
- package/fesm2022/ngwr-markdown.mjs +798 -17
- package/fesm2022/ngwr-markdown.mjs.map +1 -1
- package/fesm2022/ngwr-marquee-testing.mjs.map +1 -1
- package/fesm2022/ngwr-marquee.mjs +3 -3
- package/fesm2022/ngwr-marquee.mjs.map +1 -1
- package/fesm2022/ngwr-media.mjs +3 -3
- package/fesm2022/ngwr-media.mjs.map +1 -1
- package/fesm2022/ngwr-mention-testing.mjs.map +1 -1
- package/fesm2022/ngwr-mention.mjs +6 -6
- package/fesm2022/ngwr-mention.mjs.map +1 -1
- package/fesm2022/ngwr-meta.mjs +6 -6
- package/fesm2022/ngwr-meta.mjs.map +1 -1
- package/fesm2022/ngwr-meter-group-testing.mjs +7 -3
- package/fesm2022/ngwr-meter-group-testing.mjs.map +1 -1
- package/fesm2022/ngwr-meter-group.mjs +26 -6
- package/fesm2022/ngwr-meter-group.mjs.map +1 -1
- package/fesm2022/ngwr-overlay.mjs +6 -6
- package/fesm2022/ngwr-overlay.mjs.map +1 -1
- package/fesm2022/ngwr-page-header.mjs +13 -5
- package/fesm2022/ngwr-page-header.mjs.map +1 -1
- package/fesm2022/ngwr-pagination-testing.mjs.map +1 -1
- package/fesm2022/ngwr-pagination.mjs +3 -3
- package/fesm2022/ngwr-pagination.mjs.map +1 -1
- package/fesm2022/ngwr-pipes.mjs +21 -21
- package/fesm2022/ngwr-pipes.mjs.map +1 -1
- package/fesm2022/ngwr-platform.mjs +9 -9
- package/fesm2022/ngwr-platform.mjs.map +1 -1
- package/fesm2022/ngwr-popconfirm-testing.mjs.map +1 -1
- package/fesm2022/ngwr-popconfirm.mjs +6 -6
- package/fesm2022/ngwr-popconfirm.mjs.map +1 -1
- package/fesm2022/ngwr-popover-testing.mjs.map +1 -1
- package/fesm2022/ngwr-popover.mjs +578 -16
- package/fesm2022/ngwr-popover.mjs.map +1 -1
- package/fesm2022/ngwr-progress.mjs +3 -3
- package/fesm2022/ngwr-progress.mjs.map +1 -1
- package/fesm2022/ngwr-pull-to-refresh.mjs +3 -3
- package/fesm2022/ngwr-pull-to-refresh.mjs.map +1 -1
- package/fesm2022/ngwr-qr.mjs +3 -3
- package/fesm2022/ngwr-qr.mjs.map +1 -1
- package/fesm2022/ngwr-radio-testing.mjs.map +1 -1
- package/fesm2022/ngwr-radio.mjs +6 -6
- package/fesm2022/ngwr-radio.mjs.map +1 -1
- package/fesm2022/ngwr-rating-testing.mjs.map +1 -1
- package/fesm2022/ngwr-rating.mjs +3 -3
- package/fesm2022/ngwr-rating.mjs.map +1 -1
- package/fesm2022/ngwr-result.mjs +12 -12
- package/fesm2022/ngwr-result.mjs.map +1 -1
- package/fesm2022/ngwr-rotating-text-testing.mjs.map +1 -1
- package/fesm2022/ngwr-rotating-text.mjs +3 -3
- package/fesm2022/ngwr-rotating-text.mjs.map +1 -1
- package/fesm2022/ngwr-schema-form.mjs +3 -3
- package/fesm2022/ngwr-schema-form.mjs.map +1 -1
- package/fesm2022/ngwr-scroll.mjs +3 -3
- package/fesm2022/ngwr-scroll.mjs.map +1 -1
- package/fesm2022/ngwr-segmented-testing.mjs.map +1 -1
- package/fesm2022/ngwr-segmented.mjs +3 -3
- package/fesm2022/ngwr-segmented.mjs.map +1 -1
- package/fesm2022/ngwr-select-testing.mjs.map +1 -1
- package/fesm2022/ngwr-select.mjs +12 -12
- package/fesm2022/ngwr-select.mjs.map +1 -1
- package/fesm2022/ngwr-shiny-text-testing.mjs.map +1 -1
- package/fesm2022/ngwr-shiny-text.mjs +6 -6
- package/fesm2022/ngwr-shiny-text.mjs.map +1 -1
- package/fesm2022/ngwr-sidebar.mjs +10 -3
- package/fesm2022/ngwr-sidebar.mjs.map +1 -1
- package/fesm2022/ngwr-skeleton.mjs +3 -3
- package/fesm2022/ngwr-skeleton.mjs.map +1 -1
- package/fesm2022/ngwr-slider-testing.mjs.map +1 -1
- package/fesm2022/ngwr-slider.mjs +3 -3
- package/fesm2022/ngwr-slider.mjs.map +1 -1
- package/fesm2022/ngwr-sparkline-testing.mjs.map +1 -1
- package/fesm2022/ngwr-sparkline.mjs +84 -10
- package/fesm2022/ngwr-sparkline.mjs.map +1 -1
- package/fesm2022/ngwr-speed-dial-testing.mjs.map +1 -1
- package/fesm2022/ngwr-speed-dial.mjs +3 -3
- package/fesm2022/ngwr-speed-dial.mjs.map +1 -1
- package/fesm2022/ngwr-spinner.mjs +3 -3
- package/fesm2022/ngwr-spinner.mjs.map +1 -1
- package/fesm2022/ngwr-splash-cursor-testing.mjs.map +1 -1
- package/fesm2022/ngwr-splash-cursor.mjs +3 -3
- package/fesm2022/ngwr-splash-cursor.mjs.map +1 -1
- package/fesm2022/ngwr-split-text-testing.mjs.map +1 -1
- package/fesm2022/ngwr-split-text.mjs +3 -3
- package/fesm2022/ngwr-split-text.mjs.map +1 -1
- package/fesm2022/ngwr-splitter-testing.mjs.map +1 -1
- package/fesm2022/ngwr-splitter.mjs +3 -3
- package/fesm2022/ngwr-splitter.mjs.map +1 -1
- package/fesm2022/ngwr-spotlight-card-testing.mjs.map +1 -1
- package/fesm2022/ngwr-spotlight-card.mjs +6 -6
- package/fesm2022/ngwr-spotlight-card.mjs.map +1 -1
- package/fesm2022/ngwr-squircle.mjs +6 -6
- package/fesm2022/ngwr-squircle.mjs.map +1 -1
- package/fesm2022/ngwr-star-border-testing.mjs.map +1 -1
- package/fesm2022/ngwr-star-border.mjs +3 -3
- package/fesm2022/ngwr-star-border.mjs.map +1 -1
- package/fesm2022/ngwr-statistic.mjs +9 -9
- package/fesm2022/ngwr-statistic.mjs.map +1 -1
- package/fesm2022/ngwr-stepper-testing.mjs.map +1 -1
- package/fesm2022/ngwr-stepper.mjs +6 -6
- package/fesm2022/ngwr-stepper.mjs.map +1 -1
- package/fesm2022/ngwr-storage.mjs +3 -3
- package/fesm2022/ngwr-storage.mjs.map +1 -1
- package/fesm2022/ngwr-switch-testing.mjs.map +1 -1
- package/fesm2022/ngwr-switch.mjs +3 -3
- package/fesm2022/ngwr-switch.mjs.map +1 -1
- package/fesm2022/ngwr-table-testing.mjs.map +1 -1
- package/fesm2022/ngwr-table.mjs +18 -18
- package/fesm2022/ngwr-table.mjs.map +1 -1
- package/fesm2022/ngwr-tabs-router.mjs +3 -3
- package/fesm2022/ngwr-tabs-router.mjs.map +1 -1
- package/fesm2022/ngwr-tabs-testing.mjs.map +1 -1
- package/fesm2022/ngwr-tabs.mjs +6 -6
- package/fesm2022/ngwr-tabs.mjs.map +1 -1
- package/fesm2022/ngwr-textarea-testing.mjs.map +1 -1
- package/fesm2022/ngwr-textarea.mjs +3 -3
- package/fesm2022/ngwr-textarea.mjs.map +1 -1
- package/fesm2022/ngwr-theme.mjs +3 -3
- package/fesm2022/ngwr-theme.mjs.map +1 -1
- package/fesm2022/ngwr-tilt-card-testing.mjs.map +1 -1
- package/fesm2022/ngwr-tilt-card.mjs +6 -6
- package/fesm2022/ngwr-tilt-card.mjs.map +1 -1
- package/fesm2022/ngwr-timeline.mjs +6 -6
- package/fesm2022/ngwr-timeline.mjs.map +1 -1
- package/fesm2022/ngwr-toast-testing.mjs.map +1 -1
- package/fesm2022/ngwr-toast.mjs +9 -9
- package/fesm2022/ngwr-toast.mjs.map +1 -1
- package/fesm2022/ngwr-toolbar.mjs +10 -3
- package/fesm2022/ngwr-toolbar.mjs.map +1 -1
- package/fesm2022/ngwr-tour-testing.mjs.map +1 -1
- package/fesm2022/ngwr-tour.mjs +6 -6
- package/fesm2022/ngwr-tour.mjs.map +1 -1
- package/fesm2022/ngwr-transfer-testing.mjs.map +1 -1
- package/fesm2022/ngwr-transfer.mjs +3 -3
- package/fesm2022/ngwr-transfer.mjs.map +1 -1
- package/fesm2022/ngwr-tree-testing.mjs.map +1 -1
- package/fesm2022/ngwr-tree.mjs +3 -3
- package/fesm2022/ngwr-tree.mjs.map +1 -1
- package/fesm2022/ngwr-typewriter-testing.mjs.map +1 -1
- package/fesm2022/ngwr-typewriter.mjs +3 -3
- package/fesm2022/ngwr-typewriter.mjs.map +1 -1
- package/fesm2022/ngwr-typography.mjs +7 -3
- package/fesm2022/ngwr-typography.mjs.map +1 -1
- package/fesm2022/ngwr-utils.mjs.map +1 -1
- package/fesm2022/ngwr-validators.mjs.map +1 -1
- package/fesm2022/ngwr-version.mjs +1 -1
- package/fesm2022/ngwr-version.mjs.map +1 -1
- package/fesm2022/ngwr-virtual-scroll.mjs +3 -3
- package/fesm2022/ngwr-virtual-scroll.mjs.map +1 -1
- package/fesm2022/ngwr-waves-testing.mjs.map +1 -1
- package/fesm2022/ngwr-waves.mjs +3 -3
- package/fesm2022/ngwr-waves.mjs.map +1 -1
- package/fesm2022/ngwr-window-testing.mjs.map +1 -1
- package/fesm2022/ngwr-window.mjs +12 -12
- package/fesm2022/ngwr-window.mjs.map +1 -1
- package/form/styles/_index.scss +16 -1
- package/gauge/styles/_index.scss +1 -0
- package/i18n/ar.json +32 -1
- package/i18n/cs.json +32 -1
- package/i18n/de.json +32 -1
- package/i18n/en.json +32 -1
- package/i18n/es.json +32 -1
- package/i18n/fr.json +32 -1
- package/i18n/he.json +32 -1
- package/i18n/hi.json +32 -1
- package/i18n/id.json +32 -1
- package/i18n/it.json +32 -1
- package/i18n/ja.json +32 -1
- package/i18n/ko.json +32 -1
- package/i18n/nl.json +32 -1
- package/i18n/pl.json +32 -1
- package/i18n/pt.json +32 -1
- package/i18n/ru.json +32 -1
- package/i18n/sv.json +32 -1
- package/i18n/tr.json +32 -1
- package/i18n/uk.json +32 -1
- package/i18n/vi.json +32 -1
- package/i18n/zh-TW.json +32 -1
- package/i18n/zh.json +32 -1
- package/input/styles/_index.scss +24 -1
- package/input-number/styles/_index.scss +69 -0
- package/input-otp/styles/_index.scss +5 -5
- package/line-chart/styles/_index.scss +30 -2
- package/llms-full.txt +14 -4
- package/llms.txt +188 -132
- package/mcp/providers.js +2 -2
- package/mcp/server.js +1 -1
- package/meter-group/styles/_index.scss +1 -0
- package/overlay/styles/_arrow.scss +30 -2
- package/package.json +43 -2
- package/page-header/styles/_index.scss +37 -1
- package/pagination/styles/_index.scss +9 -5
- package/popover/styles/_index.scss +73 -5
- package/progress/styles/_index.scss +4 -0
- package/radio/styles/_index.scss +4 -1
- package/schematics/use/symbol-map.json +1 -0
- package/select/styles/_index.scss +51 -12
- package/sidebar/styles/_index.scss +44 -2
- package/skeleton/styles/_index.scss +3 -0
- package/skills/ngwr/SKILL.md +5 -5
- package/skills/ngwr/references/catalog.md +2 -0
- package/skills/ngwr/references/setup.md +3 -3
- package/sparkline/styles/_index.scss +19 -0
- package/styles.scss +1 -0
- package/table/styles/_index.scss +7 -0
- package/textarea/styles/_index.scss +12 -3
- package/theme/styles/_colors.scss +39 -13
- package/theme/styles/_contrast.scss +9 -7
- package/toast/styles/_index.scss +4 -0
- package/toolbar/styles/_index.scss +35 -1
- package/tree/styles/_index.scss +46 -7
- package/types/ngwr-badge.d.ts +22 -7
- package/types/ngwr-bar-chart.d.ts +19 -1
- package/types/ngwr-calendar-heatmap.d.ts +29 -1
- package/types/ngwr-checkbox.d.ts +2 -0
- package/types/ngwr-config.d.ts +12 -0
- package/types/ngwr-date-picker.d.ts +308 -43
- package/types/ngwr-donut-chart.d.ts +24 -1
- package/types/ngwr-dropdown.d.ts +22 -2
- package/types/ngwr-editor-testing.d.ts +378 -0
- package/types/ngwr-editor.d.ts +449 -0
- package/types/ngwr-event-calendar.d.ts +8 -0
- package/types/ngwr-form.d.ts +35 -5
- package/types/ngwr-gauge.d.ts +20 -1
- package/types/ngwr-input-number.d.ts +7 -0
- package/types/ngwr-line-chart.d.ts +9 -1
- package/types/ngwr-markdown.d.ts +61 -2
- package/types/ngwr-meter-group-testing.d.ts +3 -2
- package/types/ngwr-meter-group.d.ts +14 -1
- package/types/ngwr-page-header.d.ts +10 -2
- package/types/ngwr-popover.d.ts +154 -3
- package/types/ngwr-sidebar.d.ts +7 -0
- package/types/ngwr-sparkline.d.ts +43 -1
- package/types/ngwr-toolbar.d.ts +7 -0
- package/types/ngwr-typography.d.ts +4 -0
- package/types/ngwr-version.d.ts +1 -1
- package/typography/styles/_index.scss +16 -0
|
@@ -0,0 +1,2601 @@
|
|
|
1
|
+
import { Directionality } from '@angular/cdk/bidi';
|
|
2
|
+
import { coerceBooleanProperty } from '@angular/cdk/coercion';
|
|
3
|
+
import { DOCUMENT, isPlatformBrowser, NgTemplateOutlet } from '@angular/common';
|
|
4
|
+
import * as i0 from '@angular/core';
|
|
5
|
+
import { signal, computed, model, output, input, inject, Injector, DestroyRef, ElementRef, PLATFORM_ID, viewChild, viewChildren, isDevMode, effect, untracked, afterRenderEffect, afterNextRender, ViewEncapsulation, Component } from '@angular/core';
|
|
6
|
+
import { WrButton } from 'ngwr/button';
|
|
7
|
+
import { useConfigValue } from 'ngwr/config';
|
|
8
|
+
import { useFormFieldAria, WR_FORM_FIELD } from 'ngwr/form';
|
|
9
|
+
import { useI18nText, readI18nText, useI18nFormatter } from 'ngwr/i18n';
|
|
10
|
+
import { WrInput } from 'ngwr/input';
|
|
11
|
+
import { parseMarkdown, serializeMarkdown, safeMarkdownUrl } from 'ngwr/markdown';
|
|
12
|
+
import { WrPlatform } from 'ngwr/platform';
|
|
13
|
+
import { WrPopover } from 'ngwr/popover';
|
|
14
|
+
import { isComposing } from 'ngwr/utils';
|
|
15
|
+
import { TextSelection, Plugin, EditorState } from 'prosemirror-state';
|
|
16
|
+
import { EditorView, DecorationSet, Decoration } from 'prosemirror-view';
|
|
17
|
+
import { DOMParser, DOMSerializer, Fragment, Schema } from 'prosemirror-model';
|
|
18
|
+
import { toggleMark, setBlockType, wrapIn, chainCommands, exitCode, baseKeymap } from 'prosemirror-commands';
|
|
19
|
+
import { redo, undo, history } from 'prosemirror-history';
|
|
20
|
+
import { undoInputRule, inputRules, textblockTypeInputRule, wrappingInputRule } from 'prosemirror-inputrules';
|
|
21
|
+
import { keymap } from 'prosemirror-keymap';
|
|
22
|
+
import { wrapInList, liftListItem, splitListItem, sinkListItem } from 'prosemirror-schema-list';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @license
|
|
26
|
+
*
|
|
27
|
+
* Use of this source code is governed by an MIT-style license that can be
|
|
28
|
+
* found in the LICENSE file at https://github.com/thekhegay/ngwr/blob/main/LICENSE
|
|
29
|
+
*/
|
|
30
|
+
/** Past this many nested elements the value is refused unread — see {@link readHtml}. */
|
|
31
|
+
const MAX_DOM_DEPTH = 512;
|
|
32
|
+
/** What the HTML parser collapses and strips, written where a space has to survive it. */
|
|
33
|
+
const NBSP = '\u00a0';
|
|
34
|
+
/** One answer per window, `null` where there is no policy to use. */
|
|
35
|
+
const policies = new WeakMap();
|
|
36
|
+
/**
|
|
37
|
+
* An HTML string wrapped for a `trusted-types` page, decided once per window and
|
|
38
|
+
* only where the API exists.
|
|
39
|
+
*
|
|
40
|
+
* A template's `innerHTML` is a Trusted Types sink like any other, and under
|
|
41
|
+
* `require-trusted-types-for 'script'` a plain string throws. Pass-through is
|
|
42
|
+
* sound HERE and nowhere else: the string lands in an inert template, and what
|
|
43
|
+
* leaves it is rebuilt node by node from the schema's allowlist — the same
|
|
44
|
+
* argument ProseMirror makes for its own clipboard policy.
|
|
45
|
+
*
|
|
46
|
+
* The page's default policy comes first, as it does in ProseMirror's clipboard
|
|
47
|
+
* code; then one named `ngwr-editor`. Creating that one THROWS on a page whose
|
|
48
|
+
* `trusted-types` allowlist does not name it, and uncaught that refused every
|
|
49
|
+
* HTML value on the page — so a refusal falls back to the plain string, which
|
|
50
|
+
* such a page refuses at the sink instead, as a value the editor could not read.
|
|
51
|
+
* An app with an allowlist and no default policy adds `ngwr-editor` (and
|
|
52
|
+
* ProseMirror's own `ProseMirrorClipboard`, for paste).
|
|
53
|
+
*/
|
|
54
|
+
function trusted(html, view) {
|
|
55
|
+
const types = view?.trustedTypes;
|
|
56
|
+
if (!view || !types)
|
|
57
|
+
return html;
|
|
58
|
+
let policy = policies.get(view);
|
|
59
|
+
if (policy === undefined) {
|
|
60
|
+
policy = types.defaultPolicy ?? null;
|
|
61
|
+
if (!policy) {
|
|
62
|
+
try {
|
|
63
|
+
policy = types.createPolicy('ngwr-editor', { createHTML: input => input });
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
policy = null;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
policies.set(view, policy);
|
|
70
|
+
}
|
|
71
|
+
return (policy ? policy.createHTML(html) : html);
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* An untrusted HTML string as a checked document, or a thrown `RangeError`.
|
|
75
|
+
*
|
|
76
|
+
* Parsed into a `<template>` of the INJECTED document, never into a live node:
|
|
77
|
+
* a template's content has no browsing context, so a `<script>` does not run, an
|
|
78
|
+
* `<img onerror>` never loads and no handler fires. It is also the one inert
|
|
79
|
+
* parser the server has — domino ships no `DOMParser`. From there the schema's
|
|
80
|
+
* parse rules rebuild the document from an allowlist, so an attribute they do
|
|
81
|
+
* not name never reaches it, and a link or image the URL policy refuses keeps its
|
|
82
|
+
* text and loses the URL.
|
|
83
|
+
*
|
|
84
|
+
* Depth is checked before ProseMirror sees the tree: its parser recurses, a
|
|
85
|
+
* browser's HTML parser caps nesting on its own and neither jsdom nor domino
|
|
86
|
+
* does.
|
|
87
|
+
*/
|
|
88
|
+
function readHtml(html, document, schema) {
|
|
89
|
+
const template = document.createElement('template');
|
|
90
|
+
template.innerHTML = trusted(html, document.defaultView);
|
|
91
|
+
if (depthOf(template.content) > MAX_DOM_DEPTH)
|
|
92
|
+
throw new RangeError('<wr-editor>: the HTML value nests too deeply');
|
|
93
|
+
const doc = DOMParser.fromSchema(schema).parse(template.content);
|
|
94
|
+
doc.check();
|
|
95
|
+
return doc;
|
|
96
|
+
}
|
|
97
|
+
function depthOf(root) {
|
|
98
|
+
let deepest = 0;
|
|
99
|
+
const stack = [];
|
|
100
|
+
for (let child = root.firstElementChild; child; child = child.nextElementSibling)
|
|
101
|
+
stack.push([child, 1]);
|
|
102
|
+
while (stack.length) {
|
|
103
|
+
const [element, depth] = stack.pop();
|
|
104
|
+
if (depth > deepest)
|
|
105
|
+
deepest = depth;
|
|
106
|
+
if (deepest > MAX_DOM_DEPTH)
|
|
107
|
+
return deepest;
|
|
108
|
+
for (let child = element.firstElementChild; child; child = child.nextElementSibling)
|
|
109
|
+
stack.push([child, depth + 1]);
|
|
110
|
+
}
|
|
111
|
+
return deepest;
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* A document as HTML, serialized in a document of its own.
|
|
115
|
+
*
|
|
116
|
+
* Never the live one: an `<img>` created by the page's document starts loading
|
|
117
|
+
* the moment its `src` is set, attached or not. On the server the injected
|
|
118
|
+
* document is domino's and loads nothing, which is also why the fallback is safe
|
|
119
|
+
* where `createHTMLDocument` is missing.
|
|
120
|
+
*
|
|
121
|
+
* Spaces are written the way {@link keepSpaces} says, so the editor's own output
|
|
122
|
+
* reads back as the text it was written from.
|
|
123
|
+
*/
|
|
124
|
+
function writeHtml(doc, document) {
|
|
125
|
+
const off = document.implementation?.createHTMLDocument?.('') ?? document;
|
|
126
|
+
const container = off.createElement('div');
|
|
127
|
+
const content = withKeptSpaces(doc).content;
|
|
128
|
+
container.appendChild(DOMSerializer.fromSchema(doc.type.schema).serializeFragment(content, { document: off }));
|
|
129
|
+
return container.innerHTML;
|
|
130
|
+
}
|
|
131
|
+
function withKeptSpaces(node) {
|
|
132
|
+
if (node.isTextblock)
|
|
133
|
+
return keepSpaces(node);
|
|
134
|
+
if (node.isLeaf)
|
|
135
|
+
return node;
|
|
136
|
+
const children = [];
|
|
137
|
+
let changed = false;
|
|
138
|
+
node.forEach(child => {
|
|
139
|
+
const next = withKeptSpaces(child);
|
|
140
|
+
changed ||= next !== child;
|
|
141
|
+
children.push(next);
|
|
142
|
+
});
|
|
143
|
+
return changed ? node.copy(Fragment.from(children)) : node;
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* A textblock whose spaces survive the HTML parser: every space it would collapse
|
|
147
|
+
* or strip is written as a no-break space.
|
|
148
|
+
*
|
|
149
|
+
* The surface draws spaces as typed (`white-space: break-spaces`), but the parser
|
|
150
|
+
* that reads a value back — ProseMirror's, and a browser's rendering of the same
|
|
151
|
+
* HTML — collapses a run into one and drops one at the start of a block, after a
|
|
152
|
+
* line break and at the end. So a space stays a space only where a character
|
|
153
|
+
* other than a space comes before it and anything but the block's end comes
|
|
154
|
+
* after; the rest alternate as no-break spaces, the way CKEditor writes them. A
|
|
155
|
+
* run that crosses a mark boundary is still one run. A code block is read with
|
|
156
|
+
* its whitespace kept, and left alone.
|
|
157
|
+
*/
|
|
158
|
+
function keepSpaces(block) {
|
|
159
|
+
if (block.type.spec.code || !block.textContent.includes(' '))
|
|
160
|
+
return block;
|
|
161
|
+
const children = [];
|
|
162
|
+
let before = 'start';
|
|
163
|
+
let changed = false;
|
|
164
|
+
block.forEach((child, _offset, index) => {
|
|
165
|
+
if (!child.isText) {
|
|
166
|
+
children.push(child);
|
|
167
|
+
before = child.type.name === 'hard_break' ? 'start' : 'other';
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
const text = child.text;
|
|
171
|
+
const last = index === block.childCount - 1;
|
|
172
|
+
let out = '';
|
|
173
|
+
for (let i = 0; i < text.length; i++) {
|
|
174
|
+
const char = text[i];
|
|
175
|
+
if (char !== ' ') {
|
|
176
|
+
out += char;
|
|
177
|
+
before = 'other';
|
|
178
|
+
}
|
|
179
|
+
else if (before === 'other' && !(last && i === text.length - 1)) {
|
|
180
|
+
out += ' ';
|
|
181
|
+
before = 'space';
|
|
182
|
+
}
|
|
183
|
+
else {
|
|
184
|
+
out += NBSP;
|
|
185
|
+
before = 'other';
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
changed ||= out !== text;
|
|
189
|
+
children.push(out === text ? child : block.type.schema.text(out, child.marks));
|
|
190
|
+
});
|
|
191
|
+
return changed ? block.copy(Fragment.from(children)) : block;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* @license
|
|
196
|
+
*
|
|
197
|
+
* Use of this source code is governed by an MIT-style license that can be
|
|
198
|
+
* found in the LICENSE file at https://github.com/thekhegay/ngwr/blob/main/LICENSE
|
|
199
|
+
*/
|
|
200
|
+
/**
|
|
201
|
+
* How deep a JSON value may nest before it is refused unread.
|
|
202
|
+
*
|
|
203
|
+
* Not the editing limit — that is `MAX_NESTING`, counted in quotes and list
|
|
204
|
+
* items. This one is the stack's: `Node.fromJSON` and `check()` both recurse,
|
|
205
|
+
* and about three thousand nested blocks overflow them. A document the editor
|
|
206
|
+
* itself can produce is nowhere near it (eleven levels of list is under forty
|
|
207
|
+
* JSON levels), so anything past it was not written by an editor.
|
|
208
|
+
*/
|
|
209
|
+
const MAX_JSON_DEPTH = 256;
|
|
210
|
+
/**
|
|
211
|
+
* An untrusted JSON value as a checked document, or a thrown `RangeError`.
|
|
212
|
+
*
|
|
213
|
+
* Both steps are needed. `nodeFromJSON` refuses an unknown node or mark, an
|
|
214
|
+
* empty text node and an attribute its validator rejects — the URL policy
|
|
215
|
+
* among them — but does NOT check content expressions; `check()` does, and
|
|
216
|
+
* re-runs the validators besides. The shape pass in front keeps a malformed or
|
|
217
|
+
* hostile value from reaching either: a non-object, a `text` that is not a
|
|
218
|
+
* string, or nesting past {@link MAX_JSON_DEPTH}.
|
|
219
|
+
*/
|
|
220
|
+
function readJson(value, schema) {
|
|
221
|
+
assertShape(value, 0);
|
|
222
|
+
const doc = schema.nodeFromJSON(value);
|
|
223
|
+
if (doc.type !== schema.topNodeType)
|
|
224
|
+
throw new RangeError('<wr-editor>: a JSON value is a `doc` node');
|
|
225
|
+
doc.check();
|
|
226
|
+
return doc;
|
|
227
|
+
}
|
|
228
|
+
function assertShape(value, depth) {
|
|
229
|
+
if (depth > MAX_JSON_DEPTH)
|
|
230
|
+
throw new RangeError('<wr-editor>: the JSON value nests too deeply');
|
|
231
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
232
|
+
throw new RangeError('<wr-editor>: every JSON node is an object');
|
|
233
|
+
}
|
|
234
|
+
const node = value;
|
|
235
|
+
if (typeof node['type'] !== 'string')
|
|
236
|
+
throw new RangeError('<wr-editor>: every JSON node has a string `type`');
|
|
237
|
+
if (node['text'] !== undefined && typeof node['text'] !== 'string') {
|
|
238
|
+
throw new RangeError('<wr-editor>: `text` is a string');
|
|
239
|
+
}
|
|
240
|
+
for (const key of ['attrs', 'marks', 'content']) {
|
|
241
|
+
const child = node[key];
|
|
242
|
+
if (child === undefined)
|
|
243
|
+
continue;
|
|
244
|
+
if (key === 'attrs') {
|
|
245
|
+
if (typeof child !== 'object' || child === null || Array.isArray(child)) {
|
|
246
|
+
throw new RangeError('<wr-editor>: `attrs` is an object');
|
|
247
|
+
}
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
if (!Array.isArray(child))
|
|
251
|
+
throw new RangeError(`<wr-editor>: \`${key}\` is an array`);
|
|
252
|
+
for (const item of child)
|
|
253
|
+
assertShape(item, depth + 1);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* @license
|
|
259
|
+
*
|
|
260
|
+
* Use of this source code is governed by an MIT-style license that can be
|
|
261
|
+
* found in the LICENSE file at https://github.com/thekhegay/ngwr/blob/main/LICENSE
|
|
262
|
+
*/
|
|
263
|
+
/**
|
|
264
|
+
* Markdown to and from the editor's document JSON — both walkers pure, with no
|
|
265
|
+
* ProseMirror import, so they run under SSR and in a spec with no DOM.
|
|
266
|
+
*
|
|
267
|
+
* The reading half never passes `streaming: true`: that mode withholds a
|
|
268
|
+
* trailing `**` or `[` as a half-typed fragment, which in an editor would delete
|
|
269
|
+
* what the user actually wrote.
|
|
270
|
+
*/
|
|
271
|
+
// ---------------------------------------------------------------------------
|
|
272
|
+
// Markdown → document
|
|
273
|
+
// ---------------------------------------------------------------------------
|
|
274
|
+
/** A markdown string as the editor's document JSON. */
|
|
275
|
+
function markdownToJson(source) {
|
|
276
|
+
const content = blocksToJson(parseMarkdown(source));
|
|
277
|
+
return { type: 'doc', content: content.length ? content : [{ type: 'paragraph' }] };
|
|
278
|
+
}
|
|
279
|
+
function blocksToJson(blocks) {
|
|
280
|
+
return blocks.map(blockToJson);
|
|
281
|
+
}
|
|
282
|
+
function blockToJson(block) {
|
|
283
|
+
switch (block.kind) {
|
|
284
|
+
case 'paragraph':
|
|
285
|
+
return textblock('paragraph', undefined, block.inlines);
|
|
286
|
+
case 'heading':
|
|
287
|
+
// The schema stops at three; h4–h6 keep being headings rather than turning
|
|
288
|
+
// into paragraphs, the same clamp the HTML path applies.
|
|
289
|
+
return textblock('heading', { level: Math.min(block.level, 3) }, block.inlines);
|
|
290
|
+
case 'code':
|
|
291
|
+
// An empty text node is refused by ProseMirror, so an empty fence has no
|
|
292
|
+
// content. The info word goes through the schema's own rule, as it does on
|
|
293
|
+
// the HTML path: the parser keeps a word of any length, the schema refuses
|
|
294
|
+
// one past 64 characters, and passed on unchanged it refused the whole
|
|
295
|
+
// document over one fence tagged with a URL.
|
|
296
|
+
return {
|
|
297
|
+
type: 'code_block',
|
|
298
|
+
attrs: { language: languageValue(block.language) },
|
|
299
|
+
...(block.code ? { content: [{ type: 'text', text: block.code }] } : {}),
|
|
300
|
+
};
|
|
301
|
+
case 'quote': {
|
|
302
|
+
const children = blocksToJson(block.children);
|
|
303
|
+
return { type: 'blockquote', content: children.length ? children : [{ type: 'paragraph' }] };
|
|
304
|
+
}
|
|
305
|
+
case 'list':
|
|
306
|
+
return {
|
|
307
|
+
type: block.ordered ? 'ordered_list' : 'bullet_list',
|
|
308
|
+
attrs: block.ordered ? { order: block.start, tight: block.tight } : { tight: block.tight },
|
|
309
|
+
content: block.items.map(itemToJson),
|
|
310
|
+
};
|
|
311
|
+
case 'table':
|
|
312
|
+
return tableToJson(block.head, block.rows, block.align);
|
|
313
|
+
case 'rule':
|
|
314
|
+
return { type: 'horizontal_rule' };
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
/**
|
|
318
|
+
* A list item. Its content has to open with a paragraph — the shape
|
|
319
|
+
* `prosemirror-schema-list`'s commands are written for — so an item that starts
|
|
320
|
+
* with a code block, a quote or a nested list gets an empty paragraph in front,
|
|
321
|
+
* which {@link jsonToMarkdown} drops again on the way out.
|
|
322
|
+
*/
|
|
323
|
+
function itemToJson(item) {
|
|
324
|
+
const children = blocksToJson(item.children);
|
|
325
|
+
if (children[0]?.type !== 'paragraph')
|
|
326
|
+
children.unshift({ type: 'paragraph' });
|
|
327
|
+
return { type: 'list_item', attrs: { checked: item.checked }, content: children };
|
|
328
|
+
}
|
|
329
|
+
function tableToJson(head, rows, align) {
|
|
330
|
+
const row = (cells, type) => ({
|
|
331
|
+
type: 'table_row',
|
|
332
|
+
content: cells.map((cell, index) => ({
|
|
333
|
+
type,
|
|
334
|
+
attrs: { align: align[index] ?? null },
|
|
335
|
+
content: [textblock('paragraph', undefined, cell.inlines)],
|
|
336
|
+
})),
|
|
337
|
+
});
|
|
338
|
+
return { type: 'table', content: [row(head, 'table_header'), ...rows.map(cells => row(cells, 'table_cell'))] };
|
|
339
|
+
}
|
|
340
|
+
function textblock(type, attrs, inlines) {
|
|
341
|
+
const content = inlinesToJson(inlines);
|
|
342
|
+
return { type, ...(attrs ? { attrs } : {}), ...(content.length ? { content } : {}) };
|
|
343
|
+
}
|
|
344
|
+
/**
|
|
345
|
+
* Nested inline nodes flattened into leaves that each carry the marks collected
|
|
346
|
+
* on the way down — the shape ProseMirror holds.
|
|
347
|
+
*
|
|
348
|
+
* A mark is added once however often it nests (`**a **b** c**`): a mark set with
|
|
349
|
+
* the same mark twice is invalid to ProseMirror, and the whole value would be
|
|
350
|
+
* refused over it. Adjacent leaves with the same marks are joined, because
|
|
351
|
+
* `Node.fromJSON` does not join them and ProseMirror assumes it never sees two.
|
|
352
|
+
*/
|
|
353
|
+
function inlinesToJson(inlines) {
|
|
354
|
+
const out = [];
|
|
355
|
+
collect(inlines, [], out);
|
|
356
|
+
return out;
|
|
357
|
+
}
|
|
358
|
+
function collect(nodes, marks, out) {
|
|
359
|
+
for (const node of nodes) {
|
|
360
|
+
switch (node.kind) {
|
|
361
|
+
case 'text':
|
|
362
|
+
// A soft break and the indentation around it render as one space; kept
|
|
363
|
+
// as `\n`, the editor's `pre-wrap` would draw a line break the markdown
|
|
364
|
+
// never had.
|
|
365
|
+
pushText(out, node.value.replace(/\s*\n\s*/g, ' '), marks);
|
|
366
|
+
break;
|
|
367
|
+
case 'code':
|
|
368
|
+
pushText(out, node.value, withMark(marks, { type: 'code' }));
|
|
369
|
+
break;
|
|
370
|
+
case 'break':
|
|
371
|
+
out.push(withMarks({ type: 'hard_break' }, marks));
|
|
372
|
+
break;
|
|
373
|
+
case 'image':
|
|
374
|
+
out.push(withMarks({ type: 'image', attrs: { src: node.src, alt: node.alt, title: node.title } }, marks.filter(mark => mark.type === 'link')));
|
|
375
|
+
break;
|
|
376
|
+
case 'link':
|
|
377
|
+
collect(node.children, withMark(marks, { type: 'link', attrs: { href: node.href, title: node.title } }), out);
|
|
378
|
+
break;
|
|
379
|
+
case 'strong':
|
|
380
|
+
collect(node.children, withMark(marks, { type: 'strong' }), out);
|
|
381
|
+
break;
|
|
382
|
+
case 'em':
|
|
383
|
+
collect(node.children, withMark(marks, { type: 'em' }), out);
|
|
384
|
+
break;
|
|
385
|
+
case 'del':
|
|
386
|
+
collect(node.children, withMark(marks, { type: 'strike' }), out);
|
|
387
|
+
break;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
function withMark(marks, mark) {
|
|
392
|
+
// The outer one wins — for a link that is the one the reader would follow.
|
|
393
|
+
return marks.some(existing => existing.type === mark.type) ? marks : [...marks, mark];
|
|
394
|
+
}
|
|
395
|
+
function withMarks(node, marks) {
|
|
396
|
+
return marks.length ? { ...node, marks } : node;
|
|
397
|
+
}
|
|
398
|
+
function pushText(out, text, marks) {
|
|
399
|
+
if (!text)
|
|
400
|
+
return;
|
|
401
|
+
const last = out[out.length - 1];
|
|
402
|
+
if (last?.type === 'text' && sameMarks(last.marks ?? [], marks)) {
|
|
403
|
+
out[out.length - 1] = { ...last, text: last.text + text };
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
out.push(withMarks({ type: 'text', text }, marks));
|
|
407
|
+
}
|
|
408
|
+
function sameMarks(a, b) {
|
|
409
|
+
if (a.length !== b.length)
|
|
410
|
+
return false;
|
|
411
|
+
const key = (mark) => `${mark.type}:${JSON.stringify(mark.attrs ?? null)}`;
|
|
412
|
+
const left = a.map(key).sort();
|
|
413
|
+
const right = b.map(key).sort();
|
|
414
|
+
return left.every((value, index) => value === right[index]);
|
|
415
|
+
}
|
|
416
|
+
// ---------------------------------------------------------------------------
|
|
417
|
+
// Document → markdown
|
|
418
|
+
// ---------------------------------------------------------------------------
|
|
419
|
+
/**
|
|
420
|
+
* The editor's document JSON as markdown, through `serializeMarkdown` — so the
|
|
421
|
+
* editor writes exactly the dialect `<wr-markdown>` renders.
|
|
422
|
+
*
|
|
423
|
+
* Underline has no markdown spelling and is dropped (in `markdown` format the
|
|
424
|
+
* schema does not carry it in the first place). Everything else the schema holds
|
|
425
|
+
* has a markdown form; what the serializer normalises is listed on
|
|
426
|
+
* `serializeMarkdown` itself.
|
|
427
|
+
*/
|
|
428
|
+
function jsonToMarkdown(doc) {
|
|
429
|
+
return serializeMarkdown(childrenToBlocks(doc));
|
|
430
|
+
}
|
|
431
|
+
function childrenToBlocks(node) {
|
|
432
|
+
return (node.content ?? []).flatMap(jsonToBlock);
|
|
433
|
+
}
|
|
434
|
+
function jsonToBlock(node) {
|
|
435
|
+
const attrs = node.attrs ?? {};
|
|
436
|
+
switch (node.type) {
|
|
437
|
+
case 'paragraph':
|
|
438
|
+
return [{ kind: 'paragraph', inlines: jsonToInlines(node.content ?? []) }];
|
|
439
|
+
case 'heading':
|
|
440
|
+
return [
|
|
441
|
+
{
|
|
442
|
+
kind: 'heading',
|
|
443
|
+
level: headingLevel(attrs['level']),
|
|
444
|
+
inlines: jsonToInlines(node.content ?? []),
|
|
445
|
+
id: '',
|
|
446
|
+
},
|
|
447
|
+
];
|
|
448
|
+
case 'code_block':
|
|
449
|
+
return [
|
|
450
|
+
{
|
|
451
|
+
kind: 'code',
|
|
452
|
+
language: typeof attrs['language'] === 'string' ? attrs['language'] : null,
|
|
453
|
+
code: (node.content ?? []).map(child => child.text ?? '').join(''),
|
|
454
|
+
closed: true,
|
|
455
|
+
},
|
|
456
|
+
];
|
|
457
|
+
case 'blockquote':
|
|
458
|
+
return [{ kind: 'quote', children: childrenToBlocks(node) }];
|
|
459
|
+
case 'bullet_list':
|
|
460
|
+
case 'ordered_list':
|
|
461
|
+
return [
|
|
462
|
+
{
|
|
463
|
+
kind: 'list',
|
|
464
|
+
ordered: node.type === 'ordered_list',
|
|
465
|
+
start: node.type === 'ordered_list' && typeof attrs['order'] === 'number' ? attrs['order'] : 1,
|
|
466
|
+
tight: attrs['tight'] !== false,
|
|
467
|
+
items: (node.content ?? []).map(jsonToItem),
|
|
468
|
+
},
|
|
469
|
+
];
|
|
470
|
+
case 'table':
|
|
471
|
+
return tableBlock(node);
|
|
472
|
+
case 'horizontal_rule':
|
|
473
|
+
return [{ kind: 'rule' }];
|
|
474
|
+
default:
|
|
475
|
+
return [];
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
function headingLevel(value) {
|
|
479
|
+
return value === 2 || value === 3 ? value : 1;
|
|
480
|
+
}
|
|
481
|
+
function jsonToItem(item) {
|
|
482
|
+
const content = item.content ?? [];
|
|
483
|
+
// The filler paragraph `itemToJson` put in front of a non-paragraph block.
|
|
484
|
+
const filler = content.length > 1 && content[0].type === 'paragraph' && !(content[0].content ?? []).length;
|
|
485
|
+
const checked = item.attrs?.['checked'];
|
|
486
|
+
return {
|
|
487
|
+
children: (filler ? content.slice(1) : content).flatMap(jsonToBlock),
|
|
488
|
+
checked: typeof checked === 'boolean' ? checked : null,
|
|
489
|
+
};
|
|
490
|
+
}
|
|
491
|
+
/**
|
|
492
|
+
* A table as markdown, every row as wide as the widest one.
|
|
493
|
+
*
|
|
494
|
+
* A markdown table is as wide as its header, and `serializeMarkdown` fits every
|
|
495
|
+
* row to it — but a document can hold a header narrower than the rows below it:
|
|
496
|
+
* a paste whose merged header came across one cell short, since `colspan` is not
|
|
497
|
+
* read. Fitted to that header, the cells past it were on screen and never saved.
|
|
498
|
+
* So the header grows empty cells instead, which markdown can carry.
|
|
499
|
+
*/
|
|
500
|
+
function tableBlock(table) {
|
|
501
|
+
const rows = (table.content ?? []).map(row => row.content ?? []);
|
|
502
|
+
const width = rows.reduce((widest, cells) => Math.max(widest, cells.length), 0);
|
|
503
|
+
const [first, ...body] = rows;
|
|
504
|
+
if (!first?.length)
|
|
505
|
+
return [];
|
|
506
|
+
const head = Array.from({ length: width }, (_, index) => first[index]);
|
|
507
|
+
const cellOf = (cell) => ({
|
|
508
|
+
// Markdown has one line per cell: a cell's paragraphs join with a space.
|
|
509
|
+
inlines: (cell?.content ?? []).flatMap((paragraph, index) => [
|
|
510
|
+
...(index ? [{ kind: 'text', value: ' ' }] : []),
|
|
511
|
+
...jsonToInlines(paragraph.content ?? []),
|
|
512
|
+
]),
|
|
513
|
+
});
|
|
514
|
+
const align = head.map(cell => {
|
|
515
|
+
const value = cell?.attrs?.['align'];
|
|
516
|
+
return value === 'start' || value === 'center' || value === 'end' ? value : null;
|
|
517
|
+
});
|
|
518
|
+
return [
|
|
519
|
+
{
|
|
520
|
+
kind: 'table',
|
|
521
|
+
head: head.map(cellOf),
|
|
522
|
+
rows: body.map(cells => head.map((_, index) => cellOf(cells[index]))),
|
|
523
|
+
align,
|
|
524
|
+
},
|
|
525
|
+
];
|
|
526
|
+
}
|
|
527
|
+
/**
|
|
528
|
+
* Leaves back into an inline tree, each wrapped in its own marks.
|
|
529
|
+
*
|
|
530
|
+
* Deliberately naive: `serializeMarkdown` flattens whatever tree it is given to
|
|
531
|
+
* the same leaves-with-mark-sets and regroups them in its one canonical nesting,
|
|
532
|
+
* so wrapping every leaf separately writes exactly what a hand-merged tree would.
|
|
533
|
+
*/
|
|
534
|
+
function jsonToInlines(nodes) {
|
|
535
|
+
return nodes.flatMap((node) => {
|
|
536
|
+
const marks = node.marks ?? [];
|
|
537
|
+
switch (node.type) {
|
|
538
|
+
case 'text': {
|
|
539
|
+
const code = marks.some(mark => mark.type === 'code');
|
|
540
|
+
const text = node.text ?? '';
|
|
541
|
+
return text ? [wrap(code ? { kind: 'code', value: text } : { kind: 'text', value: text }, marks)] : [];
|
|
542
|
+
}
|
|
543
|
+
case 'hard_break':
|
|
544
|
+
return [wrap({ kind: 'break' }, marks)];
|
|
545
|
+
case 'image': {
|
|
546
|
+
const attrs = node.attrs ?? {};
|
|
547
|
+
return [
|
|
548
|
+
wrap({
|
|
549
|
+
kind: 'image',
|
|
550
|
+
src: stringOf(attrs['src']),
|
|
551
|
+
alt: stringOf(attrs['alt']),
|
|
552
|
+
title: typeof attrs['title'] === 'string' ? attrs['title'] : null,
|
|
553
|
+
}, marks),
|
|
554
|
+
];
|
|
555
|
+
}
|
|
556
|
+
default:
|
|
557
|
+
return [];
|
|
558
|
+
}
|
|
559
|
+
});
|
|
560
|
+
}
|
|
561
|
+
function stringOf(value) {
|
|
562
|
+
return typeof value === 'string' ? value : '';
|
|
563
|
+
}
|
|
564
|
+
// ---------------------------------------------------------------------------
|
|
565
|
+
// Shared with the schema
|
|
566
|
+
// ---------------------------------------------------------------------------
|
|
567
|
+
/**
|
|
568
|
+
* A fence's info word: what markdown keeps, and nothing that could break out of
|
|
569
|
+
* it — or `null`. The one rule for a code block's language on every path in,
|
|
570
|
+
* which is why it lives here, where the markdown walker needs it without
|
|
571
|
+
* importing ProseMirror.
|
|
572
|
+
*/
|
|
573
|
+
function languageValue(raw) {
|
|
574
|
+
const word = (raw ?? '').trim().split(/\s+/)[0].toLowerCase();
|
|
575
|
+
return word && word.length <= 64 ? word : null;
|
|
576
|
+
}
|
|
577
|
+
function wrap(leaf, marks) {
|
|
578
|
+
let node = leaf;
|
|
579
|
+
for (const mark of marks) {
|
|
580
|
+
switch (mark.type) {
|
|
581
|
+
case 'strong':
|
|
582
|
+
node = { kind: 'strong', children: [node] };
|
|
583
|
+
break;
|
|
584
|
+
case 'em':
|
|
585
|
+
node = { kind: 'em', children: [node] };
|
|
586
|
+
break;
|
|
587
|
+
case 'strike':
|
|
588
|
+
node = { kind: 'del', children: [node] };
|
|
589
|
+
break;
|
|
590
|
+
case 'link': {
|
|
591
|
+
const attrs = mark.attrs ?? {};
|
|
592
|
+
node = {
|
|
593
|
+
kind: 'link',
|
|
594
|
+
href: stringOf(attrs['href']),
|
|
595
|
+
title: typeof attrs['title'] === 'string' ? attrs['title'] : null,
|
|
596
|
+
children: [node],
|
|
597
|
+
};
|
|
598
|
+
break;
|
|
599
|
+
}
|
|
600
|
+
// `code` is the leaf itself, and `underline` has no markdown form.
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
return node;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
/**
|
|
607
|
+
* @license
|
|
608
|
+
*
|
|
609
|
+
* Use of this source code is governed by an MIT-style license that can be
|
|
610
|
+
* found in the LICENSE file at https://github.com/thekhegay/ngwr/blob/main/LICENSE
|
|
611
|
+
*/
|
|
612
|
+
/**
|
|
613
|
+
* How deep blockquotes and list items may nest: the deepest document markdown
|
|
614
|
+
* reads back with its structure. `parseMarkdown` parses twelve block levels and
|
|
615
|
+
* keeps whatever sits at the twelfth as literal text, so the CONTENT of a twelfth
|
|
616
|
+
* quote or item would come back flattened — a heading in there reads back as a
|
|
617
|
+
* paragraph saying `## …`. Each quote and each list item is one level, as it is
|
|
618
|
+
* there.
|
|
619
|
+
*/
|
|
620
|
+
const MAX_NESTING = 11;
|
|
621
|
+
/** The `align` values a table cell may carry — `ngwr/markdown`'s own vocabulary. */
|
|
622
|
+
const ALIGNS = new Set([null, 'start', 'center', 'end']);
|
|
623
|
+
/** The largest ordinal the markdown parser reads back (`\d{1,9}`). */
|
|
624
|
+
const MAX_ORDER = 999999999;
|
|
625
|
+
/**
|
|
626
|
+
* The one URL policy of all three formats, as a schema rule.
|
|
627
|
+
*
|
|
628
|
+
* `safeMarkdownUrl` is what `parseMarkdown` already applies, so the markdown path
|
|
629
|
+
* never produces a refused URL. The HTML path asks it in `getAttrs`, where a
|
|
630
|
+
* refusal drops the link and keeps its text. And the JSON path — plus every
|
|
631
|
+
* attribute ProseMirror validates since view 1.42.3, clipboard slice context
|
|
632
|
+
* included — hits these validators, which THROW: a JSON value holding a refused
|
|
633
|
+
* URL is refused as a whole, the same as one holding an unknown node.
|
|
634
|
+
*/
|
|
635
|
+
function requireUrl(kind) {
|
|
636
|
+
return (value) => {
|
|
637
|
+
if (typeof value !== 'string' || safeMarkdownUrl(value, kind) !== value) {
|
|
638
|
+
throw new RangeError(`<wr-editor>: refused ${kind} URL`);
|
|
639
|
+
}
|
|
640
|
+
};
|
|
641
|
+
}
|
|
642
|
+
function nullableString(value) {
|
|
643
|
+
if (value !== null && typeof value !== 'string')
|
|
644
|
+
throw new RangeError('<wr-editor>: expected a string or null');
|
|
645
|
+
}
|
|
646
|
+
/**
|
|
647
|
+
* The attributes of an `<a>` a pasted or bound document may keep: the href, and
|
|
648
|
+
* only when the policy allows it. `false` drops the mark and keeps the text —
|
|
649
|
+
* which is why this refuses rather than rewrites.
|
|
650
|
+
*/
|
|
651
|
+
function linkAttrs(dom) {
|
|
652
|
+
const href = safeMarkdownUrl(dom.getAttribute('href') ?? '', 'link');
|
|
653
|
+
return href ? { href, title: dom.getAttribute('title') } : false;
|
|
654
|
+
}
|
|
655
|
+
function imageAttrs(dom) {
|
|
656
|
+
const src = safeMarkdownUrl(dom.getAttribute('src') ?? '', 'image');
|
|
657
|
+
return src ? { src, alt: dom.getAttribute('alt') ?? '', title: dom.getAttribute('title') } : false;
|
|
658
|
+
}
|
|
659
|
+
/**
|
|
660
|
+
* A list item's task state, from this schema's own `data-checked` or from the
|
|
661
|
+
* `<input type="checkbox">` GitHub-style renderers put first in the item.
|
|
662
|
+
*/
|
|
663
|
+
function checkedOf(dom) {
|
|
664
|
+
const data = dom.getAttribute('data-checked');
|
|
665
|
+
if (data === 'true' || data === 'false')
|
|
666
|
+
return data === 'true';
|
|
667
|
+
for (let child = dom.firstChild; child; child = child.nextSibling) {
|
|
668
|
+
if (child.nodeType === 3 && !(child.textContent ?? '').trim())
|
|
669
|
+
continue;
|
|
670
|
+
if (child.nodeName !== 'INPUT')
|
|
671
|
+
return null;
|
|
672
|
+
const input = child;
|
|
673
|
+
return (input.getAttribute('type') ?? '').toLowerCase() === 'checkbox' ? input.hasAttribute('checked') : null;
|
|
674
|
+
}
|
|
675
|
+
return null;
|
|
676
|
+
}
|
|
677
|
+
function alignOf(dom) {
|
|
678
|
+
// The first one that says anything: an element with no inline style reports
|
|
679
|
+
// `''` for it, not null, so a nullish fallback would stop there.
|
|
680
|
+
const raw = [dom.getAttribute('data-align'), dom.style?.textAlign, dom.getAttribute('align')].find(Boolean) ?? '';
|
|
681
|
+
const word = raw.toLowerCase();
|
|
682
|
+
if (word === 'center')
|
|
683
|
+
return 'center';
|
|
684
|
+
if (word === 'start' || word === 'left')
|
|
685
|
+
return 'start';
|
|
686
|
+
if (word === 'end' || word === 'right')
|
|
687
|
+
return 'end';
|
|
688
|
+
return null;
|
|
689
|
+
}
|
|
690
|
+
function languageOf(dom) {
|
|
691
|
+
const code = dom.querySelector('code');
|
|
692
|
+
const named = dom.getAttribute('data-language') ?? code?.getAttribute('data-language');
|
|
693
|
+
const fromClass = /(?:^|\s)language-(\S+)/.exec(code?.getAttribute('class') ?? '')?.[1];
|
|
694
|
+
return languageValue(named ?? fromClass ?? null);
|
|
695
|
+
}
|
|
696
|
+
function orderOf(dom) {
|
|
697
|
+
const start = Number.parseInt(dom.getAttribute('start') ?? '', 10);
|
|
698
|
+
return Number.isFinite(start) ? Math.min(Math.max(start, 0), MAX_ORDER) : 1;
|
|
699
|
+
}
|
|
700
|
+
const nodes = {
|
|
701
|
+
doc: { content: 'block+' },
|
|
702
|
+
paragraph: {
|
|
703
|
+
group: 'block',
|
|
704
|
+
content: 'inline*',
|
|
705
|
+
parseDOM: [{ tag: 'p' }],
|
|
706
|
+
toDOM: () => ['p', 0],
|
|
707
|
+
},
|
|
708
|
+
heading: {
|
|
709
|
+
group: 'block',
|
|
710
|
+
content: 'inline*',
|
|
711
|
+
defining: true,
|
|
712
|
+
attrs: {
|
|
713
|
+
level: {
|
|
714
|
+
default: 1,
|
|
715
|
+
// A FUNCTION, not `'number'`: the level becomes a tag name in `toDOM`,
|
|
716
|
+
// and a type check alone accepts `{ level: 9 }`.
|
|
717
|
+
validate: (value) => {
|
|
718
|
+
if (value !== 1 && value !== 2 && value !== 3)
|
|
719
|
+
throw new RangeError('<wr-editor>: heading level is 1–3');
|
|
720
|
+
},
|
|
721
|
+
},
|
|
722
|
+
},
|
|
723
|
+
// h4–h6 clamp to h3 rather than falling through to a paragraph, which would
|
|
724
|
+
// lose the fact that the line was a heading at all.
|
|
725
|
+
parseDOM: [
|
|
726
|
+
{ tag: 'h1', attrs: { level: 1 } },
|
|
727
|
+
{ tag: 'h2', attrs: { level: 2 } },
|
|
728
|
+
{ tag: 'h3', attrs: { level: 3 } },
|
|
729
|
+
{ tag: 'h4', attrs: { level: 3 } },
|
|
730
|
+
{ tag: 'h5', attrs: { level: 3 } },
|
|
731
|
+
{ tag: 'h6', attrs: { level: 3 } },
|
|
732
|
+
],
|
|
733
|
+
toDOM: node => [`h${Math.min(Math.max(Number(node.attrs['level']) || 1, 1), 3)}`, 0],
|
|
734
|
+
},
|
|
735
|
+
blockquote: {
|
|
736
|
+
group: 'block',
|
|
737
|
+
content: 'block+',
|
|
738
|
+
defining: true,
|
|
739
|
+
parseDOM: [{ tag: 'blockquote' }],
|
|
740
|
+
toDOM: () => ['blockquote', 0],
|
|
741
|
+
},
|
|
742
|
+
code_block: {
|
|
743
|
+
group: 'block',
|
|
744
|
+
content: 'text*',
|
|
745
|
+
marks: '',
|
|
746
|
+
code: true,
|
|
747
|
+
defining: true,
|
|
748
|
+
attrs: {
|
|
749
|
+
language: {
|
|
750
|
+
default: null,
|
|
751
|
+
validate: (value) => {
|
|
752
|
+
if (value !== null && (typeof value !== 'string' || languageValue(value) !== value)) {
|
|
753
|
+
throw new RangeError('<wr-editor>: a code language is one lowercase word');
|
|
754
|
+
}
|
|
755
|
+
},
|
|
756
|
+
},
|
|
757
|
+
},
|
|
758
|
+
parseDOM: [{ tag: 'pre', preserveWhitespace: 'full', getAttrs: dom => ({ language: languageOf(dom) }) }],
|
|
759
|
+
toDOM: node => {
|
|
760
|
+
const language = node.attrs['language'];
|
|
761
|
+
return ['pre', ['code', language ? { class: `language-${language}`, 'data-language': language } : {}, 0]];
|
|
762
|
+
},
|
|
763
|
+
},
|
|
764
|
+
horizontal_rule: {
|
|
765
|
+
group: 'block',
|
|
766
|
+
parseDOM: [{ tag: 'hr' }],
|
|
767
|
+
toDOM: () => ['hr'],
|
|
768
|
+
},
|
|
769
|
+
bullet_list: {
|
|
770
|
+
group: 'block',
|
|
771
|
+
content: 'list_item+',
|
|
772
|
+
attrs: { tight: { default: true, validate: 'boolean' } },
|
|
773
|
+
parseDOM: [{ tag: 'ul' }],
|
|
774
|
+
toDOM: () => ['ul', 0],
|
|
775
|
+
},
|
|
776
|
+
ordered_list: {
|
|
777
|
+
group: 'block',
|
|
778
|
+
content: 'list_item+',
|
|
779
|
+
attrs: {
|
|
780
|
+
order: {
|
|
781
|
+
default: 1,
|
|
782
|
+
validate: (value) => {
|
|
783
|
+
if (!Number.isInteger(value) || value < 0 || value > MAX_ORDER) {
|
|
784
|
+
throw new RangeError('<wr-editor>: a list starts at a whole number');
|
|
785
|
+
}
|
|
786
|
+
},
|
|
787
|
+
},
|
|
788
|
+
tight: { default: true, validate: 'boolean' },
|
|
789
|
+
},
|
|
790
|
+
parseDOM: [{ tag: 'ol', getAttrs: dom => ({ order: orderOf(dom) }) }],
|
|
791
|
+
toDOM: node => (node.attrs['order'] === 1 ? ['ol', 0] : ['ol', { start: String(node.attrs['order']) }, 0]),
|
|
792
|
+
},
|
|
793
|
+
list_item: {
|
|
794
|
+
content: 'paragraph block*',
|
|
795
|
+
defining: true,
|
|
796
|
+
attrs: {
|
|
797
|
+
checked: {
|
|
798
|
+
default: null,
|
|
799
|
+
validate: (value) => {
|
|
800
|
+
if (value !== null && typeof value !== 'boolean')
|
|
801
|
+
throw new RangeError('<wr-editor>: checked is a boolean');
|
|
802
|
+
},
|
|
803
|
+
},
|
|
804
|
+
},
|
|
805
|
+
parseDOM: [{ tag: 'li', getAttrs: dom => ({ checked: checkedOf(dom) }) }],
|
|
806
|
+
toDOM: node => {
|
|
807
|
+
const checked = node.attrs['checked'];
|
|
808
|
+
return checked === null ? ['li', 0] : ['li', { 'data-checked': String(checked) }, 0];
|
|
809
|
+
},
|
|
810
|
+
},
|
|
811
|
+
table: {
|
|
812
|
+
group: 'block',
|
|
813
|
+
content: 'table_row+',
|
|
814
|
+
isolating: true,
|
|
815
|
+
parseDOM: [{ tag: 'table' }],
|
|
816
|
+
toDOM: () => ['table', ['tbody', 0]],
|
|
817
|
+
},
|
|
818
|
+
table_row: {
|
|
819
|
+
content: '(table_header | table_cell)+',
|
|
820
|
+
parseDOM: [{ tag: 'tr' }],
|
|
821
|
+
toDOM: () => ['tr', 0],
|
|
822
|
+
},
|
|
823
|
+
table_header: cell('th'),
|
|
824
|
+
table_cell: cell('td'),
|
|
825
|
+
text: { group: 'inline' },
|
|
826
|
+
image: {
|
|
827
|
+
group: 'inline',
|
|
828
|
+
inline: true,
|
|
829
|
+
draggable: true,
|
|
830
|
+
attrs: {
|
|
831
|
+
src: { validate: requireUrl('image') },
|
|
832
|
+
alt: { default: '', validate: 'string' },
|
|
833
|
+
title: { default: null, validate: nullableString },
|
|
834
|
+
},
|
|
835
|
+
parseDOM: [{ tag: 'img[src]', getAttrs: imageAttrs }],
|
|
836
|
+
toDOM: node => {
|
|
837
|
+
// Re-asked here: validators do not run on `type.create()`, so a node built
|
|
838
|
+
// in code never passed through them.
|
|
839
|
+
const src = safeMarkdownUrl(String(node.attrs['src'] ?? ''), 'image');
|
|
840
|
+
const title = node.attrs['title'];
|
|
841
|
+
return ['img', { src: src ?? '', alt: String(node.attrs['alt'] ?? ''), ...(title ? { title } : {}) }];
|
|
842
|
+
},
|
|
843
|
+
},
|
|
844
|
+
hard_break: {
|
|
845
|
+
group: 'inline',
|
|
846
|
+
inline: true,
|
|
847
|
+
selectable: false,
|
|
848
|
+
linebreakReplacement: true,
|
|
849
|
+
parseDOM: [{ tag: 'br' }],
|
|
850
|
+
toDOM: () => ['br'],
|
|
851
|
+
},
|
|
852
|
+
};
|
|
853
|
+
function cell(tag) {
|
|
854
|
+
return {
|
|
855
|
+
content: 'paragraph+',
|
|
856
|
+
isolating: true,
|
|
857
|
+
attrs: {
|
|
858
|
+
align: {
|
|
859
|
+
default: null,
|
|
860
|
+
validate: (value) => {
|
|
861
|
+
if (!ALIGNS.has(value))
|
|
862
|
+
throw new RangeError('<wr-editor>: align is start, center or end');
|
|
863
|
+
},
|
|
864
|
+
},
|
|
865
|
+
},
|
|
866
|
+
parseDOM: [{ tag, getAttrs: dom => ({ align: alignOf(dom) }) }],
|
|
867
|
+
// The value is one of three words — checked by the validator, and again
|
|
868
|
+
// here — so writing it into `style` cannot carry anything the input chose.
|
|
869
|
+
toDOM: node => {
|
|
870
|
+
const align = node.attrs['align'];
|
|
871
|
+
return ALIGNS.has(align) && align ? [tag, { style: `text-align: ${align}` }, 0] : [tag, 0];
|
|
872
|
+
},
|
|
873
|
+
};
|
|
874
|
+
}
|
|
875
|
+
/**
|
|
876
|
+
* Marks in nesting order, outermost first — which is also how `toDOM` nests
|
|
877
|
+
* them in the HTML this writes.
|
|
878
|
+
*/
|
|
879
|
+
function marks(underline) {
|
|
880
|
+
return {
|
|
881
|
+
link: {
|
|
882
|
+
inclusive: false,
|
|
883
|
+
attrs: {
|
|
884
|
+
href: { validate: requireUrl('link') },
|
|
885
|
+
title: { default: null, validate: nullableString },
|
|
886
|
+
},
|
|
887
|
+
parseDOM: [{ tag: 'a[href]', getAttrs: linkAttrs }],
|
|
888
|
+
toDOM: (mark) => {
|
|
889
|
+
const href = safeMarkdownUrl(String(mark.attrs['href'] ?? ''), 'link');
|
|
890
|
+
if (!href)
|
|
891
|
+
return ['span', 0];
|
|
892
|
+
const title = mark.attrs['title'];
|
|
893
|
+
return ['a', { href, ...(title ? { title } : {}), rel: 'noopener noreferrer nofollow' }, 0];
|
|
894
|
+
},
|
|
895
|
+
},
|
|
896
|
+
strong: {
|
|
897
|
+
parseDOM: [
|
|
898
|
+
{ tag: 'strong' },
|
|
899
|
+
// Google Docs wraps a whole paste in `<b style="font-weight: normal">`.
|
|
900
|
+
{ tag: 'b', getAttrs: dom => dom.style.fontWeight !== 'normal' && null },
|
|
901
|
+
{ style: 'font-weight=400', clearMark: mark => mark.type.name === 'strong' },
|
|
902
|
+
{ style: 'font-weight', getAttrs: value => /^(bold(er)?|[5-9]\d{2,})$/.test(value) && null },
|
|
903
|
+
],
|
|
904
|
+
toDOM: () => ['strong', 0],
|
|
905
|
+
},
|
|
906
|
+
em: {
|
|
907
|
+
parseDOM: [
|
|
908
|
+
{ tag: 'i' },
|
|
909
|
+
{ tag: 'em' },
|
|
910
|
+
{ style: 'font-style=italic' },
|
|
911
|
+
{ style: 'font-style=normal', clearMark: mark => mark.type.name === 'em' },
|
|
912
|
+
],
|
|
913
|
+
toDOM: () => ['em', 0],
|
|
914
|
+
},
|
|
915
|
+
...(underline
|
|
916
|
+
? {
|
|
917
|
+
underline: {
|
|
918
|
+
parseDOM: [
|
|
919
|
+
{ tag: 'u' },
|
|
920
|
+
{ style: 'text-decoration=underline' },
|
|
921
|
+
{ style: 'text-decoration-line=underline' },
|
|
922
|
+
],
|
|
923
|
+
toDOM: () => ['u', 0],
|
|
924
|
+
},
|
|
925
|
+
}
|
|
926
|
+
: {}),
|
|
927
|
+
strike: {
|
|
928
|
+
parseDOM: [
|
|
929
|
+
{ tag: 's' },
|
|
930
|
+
{ tag: 'del' },
|
|
931
|
+
{ tag: 'strike' },
|
|
932
|
+
{ style: 'text-decoration=line-through' },
|
|
933
|
+
{ style: 'text-decoration-line=line-through' },
|
|
934
|
+
],
|
|
935
|
+
toDOM: () => ['s', 0],
|
|
936
|
+
},
|
|
937
|
+
code: {
|
|
938
|
+
code: true,
|
|
939
|
+
parseDOM: [{ tag: 'code' }],
|
|
940
|
+
toDOM: () => ['code', 0],
|
|
941
|
+
},
|
|
942
|
+
};
|
|
943
|
+
}
|
|
944
|
+
let full = null;
|
|
945
|
+
let markdown = null;
|
|
946
|
+
/**
|
|
947
|
+
* The schema for a format, built on first use and shared after that.
|
|
948
|
+
*
|
|
949
|
+
* Markdown gets its own, one mark short: it has no underline, and a mark the
|
|
950
|
+
* value cannot carry would paint on screen and vanish on save — from a paste,
|
|
951
|
+
* where no toolbar button could have kept it out.
|
|
952
|
+
*/
|
|
953
|
+
function editorSchema(format) {
|
|
954
|
+
if (format === 'markdown')
|
|
955
|
+
return (markdown ??= new Schema({ nodes, marks: marks(false) }));
|
|
956
|
+
return (full ??= new Schema({ nodes, marks: marks(true) }));
|
|
957
|
+
}
|
|
958
|
+
/** How many blockquotes and list items deep the document's deepest block sits. */
|
|
959
|
+
function nestingOf(doc) {
|
|
960
|
+
let deepest = 0;
|
|
961
|
+
const walk = (node, depth) => {
|
|
962
|
+
node.forEach(child => {
|
|
963
|
+
if (child.isTextblock || child.isLeaf)
|
|
964
|
+
return;
|
|
965
|
+
const next = child.type.name === 'blockquote' || child.type.name === 'list_item' ? depth + 1 : depth;
|
|
966
|
+
if (next > deepest)
|
|
967
|
+
deepest = next;
|
|
968
|
+
walk(child, next);
|
|
969
|
+
});
|
|
970
|
+
};
|
|
971
|
+
walk(doc, 0);
|
|
972
|
+
return deepest;
|
|
973
|
+
}
|
|
974
|
+
/**
|
|
975
|
+
* A document that is one empty textblock — the shape the placeholder is drawn
|
|
976
|
+
* on. Which textblock is open: an empty heading shows the hint as well.
|
|
977
|
+
*
|
|
978
|
+
* Not the test for "nothing written"; that is {@link isBlankDoc}.
|
|
979
|
+
*/
|
|
980
|
+
function isEmptyDoc(doc) {
|
|
981
|
+
if (doc.childCount !== 1)
|
|
982
|
+
return false;
|
|
983
|
+
const only = doc.firstChild;
|
|
984
|
+
return only.isTextblock && only.childCount === 0;
|
|
985
|
+
}
|
|
986
|
+
/**
|
|
987
|
+
* A document with nothing written in it: no text but whitespace, and no leaf
|
|
988
|
+
* but line breaks. An image or a rule is content in its own right; structure
|
|
989
|
+
* alone is not.
|
|
990
|
+
*
|
|
991
|
+
* Decided by content rather than by shape, because every one of these looks
|
|
992
|
+
* empty and markdown already wrote them as `''`: Enter on an empty editor (two
|
|
993
|
+
* empty paragraphs), a lone Shift-Enter, a few spaces, and a list, a quote or a
|
|
994
|
+
* heading chosen on an empty editor. Written as anything else, `required()`
|
|
995
|
+
* passed in `html` and `json` over an editor with nothing in it.
|
|
996
|
+
*/
|
|
997
|
+
function isBlankDoc(doc) {
|
|
998
|
+
let written = false;
|
|
999
|
+
doc.descendants(node => {
|
|
1000
|
+
if (node.isText ? /\S/.test(node.text) : node.isLeaf && node.type.name !== 'hard_break')
|
|
1001
|
+
written = true;
|
|
1002
|
+
return !written;
|
|
1003
|
+
});
|
|
1004
|
+
return !written;
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
/**
|
|
1008
|
+
* @license
|
|
1009
|
+
*
|
|
1010
|
+
* Use of this source code is governed by an MIT-style license that can be
|
|
1011
|
+
* found in the LICENSE file at https://github.com/thekhegay/ngwr/blob/main/LICENSE
|
|
1012
|
+
*/
|
|
1013
|
+
/**
|
|
1014
|
+
* The three formats meeting in one place. JSON is the pivot: markdown is walked
|
|
1015
|
+
* into it, HTML is parsed by the schema's own rules, and a bound JSON value is
|
|
1016
|
+
* validated like any other untrusted input.
|
|
1017
|
+
*/
|
|
1018
|
+
/**
|
|
1019
|
+
* A bound value as a document in `schema`, or a thrown error naming why it was
|
|
1020
|
+
* refused. `null`, `undefined` and `''` are the empty document in every format.
|
|
1021
|
+
*
|
|
1022
|
+
* Throwing is the whole contract: the caller keeps the document it already has,
|
|
1023
|
+
* which is the rule `wr-input-number` and `wr-date-picker` follow for input they
|
|
1024
|
+
* cannot read. Nothing here ever returns a "repaired" document to write back.
|
|
1025
|
+
*/
|
|
1026
|
+
function readValue(value, format, schema, document) {
|
|
1027
|
+
if (value === null || value === undefined || value === '')
|
|
1028
|
+
return schema.topNodeType.createAndFill();
|
|
1029
|
+
switch (format) {
|
|
1030
|
+
case 'json':
|
|
1031
|
+
if (typeof value === 'string')
|
|
1032
|
+
throw new RangeError('<wr-editor>: `format="json"` takes an object, not a string');
|
|
1033
|
+
return readJson(value, schema);
|
|
1034
|
+
case 'markdown':
|
|
1035
|
+
if (typeof value !== 'string')
|
|
1036
|
+
throw new RangeError('<wr-editor>: `format="markdown"` takes a string');
|
|
1037
|
+
return readJson(markdownToJson(value), schema);
|
|
1038
|
+
case 'html':
|
|
1039
|
+
if (typeof value !== 'string')
|
|
1040
|
+
throw new RangeError('<wr-editor>: `format="html"` takes a string');
|
|
1041
|
+
return readHtml(value, document, schema);
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
/**
|
|
1045
|
+
* A document in the bound format — `''` or `null` when nothing is written in it,
|
|
1046
|
+
* the same in all three: see {@link isBlankDoc}.
|
|
1047
|
+
*/
|
|
1048
|
+
function writeValue(doc, format, document) {
|
|
1049
|
+
if (isBlankDoc(doc))
|
|
1050
|
+
return format === 'json' ? null : '';
|
|
1051
|
+
switch (format) {
|
|
1052
|
+
case 'json':
|
|
1053
|
+
return doc.toJSON();
|
|
1054
|
+
case 'markdown':
|
|
1055
|
+
return jsonToMarkdown(doc.toJSON());
|
|
1056
|
+
case 'html':
|
|
1057
|
+
return writeHtml(doc, document);
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
/**
|
|
1062
|
+
* @license
|
|
1063
|
+
*
|
|
1064
|
+
* Use of this source code is governed by an MIT-style license that can be
|
|
1065
|
+
* found in the LICENSE file at https://github.com/thekhegay/ngwr/blob/main/LICENSE
|
|
1066
|
+
*/
|
|
1067
|
+
/**
|
|
1068
|
+
* The keyboard half of each tool — the keymap below binds exactly these, so a
|
|
1069
|
+
* hint can never name a shortcut that does nothing.
|
|
1070
|
+
*
|
|
1071
|
+
* Where a convention exists it is followed: Google Docs for the block types
|
|
1072
|
+
* (Mod-Alt-0…3, Mod-Shift-7/8), and Tiptap / Notion for the three it does not
|
|
1073
|
+
* cover (Mod-Shift-S strikethrough, Mod-E inline code, Mod-Shift-B quote,
|
|
1074
|
+
* Mod-Alt-C code block). The horizontal rule has none. Tab is deliberately not
|
|
1075
|
+
* bound — indenting with it would be a keyboard trap (WCAG 2.1.2), so list items
|
|
1076
|
+
* move with Mod-] and Mod-[ — and neither is Escape, which `<wr-dialog>` needs.
|
|
1077
|
+
*
|
|
1078
|
+
* Where a layout types `[` and `]` with AltGr or Option (German, French, the
|
|
1079
|
+
* Nordic and Polish layouts), the bracket chords are also read with that Alt
|
|
1080
|
+
* held — see {@link bracketKeys} — since nothing else indents a list item. The
|
|
1081
|
+
* Mod-Alt chords are NOT widened the same way: on Windows, Ctrl+Alt is AltGr, and
|
|
1082
|
+
* on those layouts AltGr+0 types `}` and AltGr+C types `ć`. Taking the chord by
|
|
1083
|
+
* physical key would make those characters impossible to type in the editor, so
|
|
1084
|
+
* there the key types its character and the toolbar sets the block type.
|
|
1085
|
+
*/
|
|
1086
|
+
const SHORTCUTS = {
|
|
1087
|
+
bold: { key: 'B' },
|
|
1088
|
+
italic: { key: 'I' },
|
|
1089
|
+
underline: { key: 'U' },
|
|
1090
|
+
strike: { key: 'S', shift: true },
|
|
1091
|
+
code: { key: 'E' },
|
|
1092
|
+
paragraph: { key: '0', alt: true },
|
|
1093
|
+
heading1: { key: '1', alt: true },
|
|
1094
|
+
heading2: { key: '2', alt: true },
|
|
1095
|
+
heading3: { key: '3', alt: true },
|
|
1096
|
+
bulletList: { key: '8', shift: true },
|
|
1097
|
+
orderedList: { key: '7', shift: true },
|
|
1098
|
+
blockquote: { key: 'B', shift: true },
|
|
1099
|
+
codeBlock: { key: 'C', alt: true },
|
|
1100
|
+
link: { key: 'K' },
|
|
1101
|
+
undo: { key: 'Z' },
|
|
1102
|
+
redo: { key: 'Z', shift: true },
|
|
1103
|
+
};
|
|
1104
|
+
/**
|
|
1105
|
+
* The tools a click toggles — the ones that carry `aria-pressed`. Not `link`,
|
|
1106
|
+
* which opens a panel: a popup button that also claimed to be pressed would say
|
|
1107
|
+
* two contradictory things.
|
|
1108
|
+
*/
|
|
1109
|
+
const TOGGLES = new Set([
|
|
1110
|
+
'bold',
|
|
1111
|
+
'italic',
|
|
1112
|
+
'underline',
|
|
1113
|
+
'strike',
|
|
1114
|
+
'code',
|
|
1115
|
+
'paragraph',
|
|
1116
|
+
'heading1',
|
|
1117
|
+
'heading2',
|
|
1118
|
+
'heading3',
|
|
1119
|
+
'bulletList',
|
|
1120
|
+
'orderedList',
|
|
1121
|
+
'blockquote',
|
|
1122
|
+
'codeBlock',
|
|
1123
|
+
]);
|
|
1124
|
+
// ---------------------------------------------------------------------------
|
|
1125
|
+
// State
|
|
1126
|
+
// ---------------------------------------------------------------------------
|
|
1127
|
+
function markActive(state, type) {
|
|
1128
|
+
if (!type)
|
|
1129
|
+
return false;
|
|
1130
|
+
const { empty, from, to, $from } = state.selection;
|
|
1131
|
+
if (empty)
|
|
1132
|
+
return !!type.isInSet(state.storedMarks ?? $from.marks());
|
|
1133
|
+
return state.doc.rangeHasMark(from, to, type);
|
|
1134
|
+
}
|
|
1135
|
+
/**
|
|
1136
|
+
* Whether every textblock the selection touches is `type` — asked of the blocks
|
|
1137
|
+
* themselves rather than of `$from.parent`, which is the DOCUMENT under a
|
|
1138
|
+
* select-all.
|
|
1139
|
+
*/
|
|
1140
|
+
function parentIs(state, type, attrs) {
|
|
1141
|
+
const { empty, from, to, $from } = state.selection;
|
|
1142
|
+
if (empty)
|
|
1143
|
+
return $from.parent.hasMarkup(type, attrs);
|
|
1144
|
+
let found = false;
|
|
1145
|
+
let all = true;
|
|
1146
|
+
state.doc.nodesBetween(from, to, node => {
|
|
1147
|
+
if (!node.isTextblock)
|
|
1148
|
+
return true;
|
|
1149
|
+
found = true;
|
|
1150
|
+
if (!node.hasMarkup(type, attrs))
|
|
1151
|
+
all = false;
|
|
1152
|
+
return false;
|
|
1153
|
+
});
|
|
1154
|
+
return found && all;
|
|
1155
|
+
}
|
|
1156
|
+
/** The nearest list around the selection, with the position it starts at. */
|
|
1157
|
+
function nearestList(state) {
|
|
1158
|
+
const { $from } = state.selection;
|
|
1159
|
+
for (let depth = $from.depth; depth > 0; depth--) {
|
|
1160
|
+
const node = $from.node(depth);
|
|
1161
|
+
if (node.type.name === 'bullet_list' || node.type.name === 'ordered_list') {
|
|
1162
|
+
return { type: node.type, pos: $from.before(depth) };
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
1165
|
+
return null;
|
|
1166
|
+
}
|
|
1167
|
+
function insideNode(state, type) {
|
|
1168
|
+
const { $from } = state.selection;
|
|
1169
|
+
for (let depth = $from.depth; depth > 0; depth--)
|
|
1170
|
+
if ($from.node(depth).type === type)
|
|
1171
|
+
return true;
|
|
1172
|
+
return false;
|
|
1173
|
+
}
|
|
1174
|
+
/** Whether a tool's state is "on" at the selection — what `aria-pressed` reports. */
|
|
1175
|
+
function toolActive(tool, state) {
|
|
1176
|
+
const { marks, nodes } = state.schema;
|
|
1177
|
+
switch (tool) {
|
|
1178
|
+
case 'bold':
|
|
1179
|
+
return markActive(state, marks['strong']);
|
|
1180
|
+
case 'italic':
|
|
1181
|
+
return markActive(state, marks['em']);
|
|
1182
|
+
case 'underline':
|
|
1183
|
+
return markActive(state, marks['underline']);
|
|
1184
|
+
case 'strike':
|
|
1185
|
+
return markActive(state, marks['strike']);
|
|
1186
|
+
case 'code':
|
|
1187
|
+
return markActive(state, marks['code']);
|
|
1188
|
+
case 'link':
|
|
1189
|
+
return linkRange(state) !== null;
|
|
1190
|
+
case 'paragraph':
|
|
1191
|
+
return parentIs(state, nodes['paragraph']);
|
|
1192
|
+
case 'heading1':
|
|
1193
|
+
return parentIs(state, nodes['heading'], { level: 1 });
|
|
1194
|
+
case 'heading2':
|
|
1195
|
+
return parentIs(state, nodes['heading'], { level: 2 });
|
|
1196
|
+
case 'heading3':
|
|
1197
|
+
return parentIs(state, nodes['heading'], { level: 3 });
|
|
1198
|
+
case 'codeBlock':
|
|
1199
|
+
return parentIs(state, nodes['code_block']);
|
|
1200
|
+
case 'bulletList':
|
|
1201
|
+
return nearestList(state)?.type === nodes['bullet_list'];
|
|
1202
|
+
case 'orderedList':
|
|
1203
|
+
return nearestList(state)?.type === nodes['ordered_list'];
|
|
1204
|
+
case 'blockquote':
|
|
1205
|
+
return insideNode(state, nodes['blockquote']);
|
|
1206
|
+
default:
|
|
1207
|
+
return false;
|
|
1208
|
+
}
|
|
1209
|
+
}
|
|
1210
|
+
// ---------------------------------------------------------------------------
|
|
1211
|
+
// Commands
|
|
1212
|
+
// ---------------------------------------------------------------------------
|
|
1213
|
+
/** What a tool does, or `null` for one the schema cannot carry (underline in markdown). */
|
|
1214
|
+
function toolCommand(tool, schema) {
|
|
1215
|
+
const { marks, nodes } = schema;
|
|
1216
|
+
const mark = (type) => (type ? toggleMark(type) : null);
|
|
1217
|
+
const heading = (level) => toggleBlock(nodes['heading'], nodes['paragraph'], { level });
|
|
1218
|
+
switch (tool) {
|
|
1219
|
+
case 'bold':
|
|
1220
|
+
return mark(marks['strong']);
|
|
1221
|
+
case 'italic':
|
|
1222
|
+
return mark(marks['em']);
|
|
1223
|
+
case 'underline':
|
|
1224
|
+
return mark(marks['underline']);
|
|
1225
|
+
case 'strike':
|
|
1226
|
+
return mark(marks['strike']);
|
|
1227
|
+
case 'code':
|
|
1228
|
+
return mark(marks['code']);
|
|
1229
|
+
// The link tool opens a panel; `setLink` is its command.
|
|
1230
|
+
case 'link':
|
|
1231
|
+
return toggleMark(marks['link'], { href: '#' });
|
|
1232
|
+
case 'paragraph':
|
|
1233
|
+
return setBlockType(nodes['paragraph']);
|
|
1234
|
+
case 'heading1':
|
|
1235
|
+
return heading(1);
|
|
1236
|
+
case 'heading2':
|
|
1237
|
+
return heading(2);
|
|
1238
|
+
case 'heading3':
|
|
1239
|
+
return heading(3);
|
|
1240
|
+
case 'codeBlock':
|
|
1241
|
+
return toggleBlock(nodes['code_block'], nodes['paragraph']);
|
|
1242
|
+
case 'bulletList':
|
|
1243
|
+
return toggleList(nodes['bullet_list'], nodes['list_item']);
|
|
1244
|
+
case 'orderedList':
|
|
1245
|
+
return toggleList(nodes['ordered_list'], nodes['list_item']);
|
|
1246
|
+
case 'blockquote':
|
|
1247
|
+
return toggleQuote(nodes['blockquote']);
|
|
1248
|
+
case 'horizontalRule':
|
|
1249
|
+
return insertRule(nodes['horizontal_rule']);
|
|
1250
|
+
case 'undo':
|
|
1251
|
+
return undo;
|
|
1252
|
+
case 'redo':
|
|
1253
|
+
return redo;
|
|
1254
|
+
}
|
|
1255
|
+
}
|
|
1256
|
+
/** A block type that switches back to a paragraph when it is already on. */
|
|
1257
|
+
function toggleBlock(type, paragraph, attrs) {
|
|
1258
|
+
return (state, dispatch) => parentIs(state, type, attrs)
|
|
1259
|
+
? setBlockType(paragraph)(state, dispatch)
|
|
1260
|
+
: setBlockType(type, attrs)(state, dispatch);
|
|
1261
|
+
}
|
|
1262
|
+
/**
|
|
1263
|
+
* A list toggle: out of this kind of list, over to it from the other kind, or
|
|
1264
|
+
* into a new one.
|
|
1265
|
+
*/
|
|
1266
|
+
function toggleList(type, item) {
|
|
1267
|
+
return (state, dispatch) => {
|
|
1268
|
+
const list = nearestList(state);
|
|
1269
|
+
if (!list)
|
|
1270
|
+
return wrapInList(type)(state, dispatch);
|
|
1271
|
+
if (list.type === type)
|
|
1272
|
+
return liftListItem(item)(state, dispatch);
|
|
1273
|
+
if (dispatch) {
|
|
1274
|
+
const tight = state.doc.nodeAt(list.pos)?.attrs['tight'] !== false;
|
|
1275
|
+
const attrs = type.name === 'ordered_list' ? { order: 1, tight } : { tight };
|
|
1276
|
+
dispatch(state.tr.setNodeMarkup(list.pos, type, attrs).scrollIntoView());
|
|
1277
|
+
}
|
|
1278
|
+
return true;
|
|
1279
|
+
};
|
|
1280
|
+
}
|
|
1281
|
+
/**
|
|
1282
|
+
* Into a quote, or out of the nearest one.
|
|
1283
|
+
*
|
|
1284
|
+
* Out is a hand-rolled lift rather than `prosemirror-commands`' `lift`, which
|
|
1285
|
+
* lifts out of whatever the selection's parent is — inside a quoted list that is
|
|
1286
|
+
* the list item, not the quote. The target depth is the quote's own parent; a
|
|
1287
|
+
* lift that does not fit there throws, and the throw is the "cannot".
|
|
1288
|
+
*/
|
|
1289
|
+
function toggleQuote(quote) {
|
|
1290
|
+
return (state, dispatch) => {
|
|
1291
|
+
const { $from, $to } = state.selection;
|
|
1292
|
+
const range = insideNode(state, quote) ? $from.blockRange($to, node => node.type === quote) : null;
|
|
1293
|
+
if (!range)
|
|
1294
|
+
return wrapIn(quote)(state, dispatch);
|
|
1295
|
+
try {
|
|
1296
|
+
const tr = state.tr.lift(range, range.depth - 1);
|
|
1297
|
+
if (dispatch)
|
|
1298
|
+
dispatch(tr.scrollIntoView());
|
|
1299
|
+
return true;
|
|
1300
|
+
}
|
|
1301
|
+
catch {
|
|
1302
|
+
return false;
|
|
1303
|
+
}
|
|
1304
|
+
};
|
|
1305
|
+
}
|
|
1306
|
+
/**
|
|
1307
|
+
* A horizontal rule at the selection, where one fits without leaving an
|
|
1308
|
+
* isolating node — a table cell holds paragraphs only, and splitting the table
|
|
1309
|
+
* around a rule is not what anyone clicking the button meant.
|
|
1310
|
+
*/
|
|
1311
|
+
function insertRule(rule) {
|
|
1312
|
+
return (state, dispatch) => {
|
|
1313
|
+
const { $from } = state.selection;
|
|
1314
|
+
let fits = false;
|
|
1315
|
+
for (let depth = $from.depth; depth >= 0; depth--) {
|
|
1316
|
+
const node = $from.node(depth);
|
|
1317
|
+
const index = $from.index(depth);
|
|
1318
|
+
if (node.canReplaceWith(index, index, rule)) {
|
|
1319
|
+
fits = true;
|
|
1320
|
+
break;
|
|
1321
|
+
}
|
|
1322
|
+
if (node.type.spec.isolating)
|
|
1323
|
+
break;
|
|
1324
|
+
}
|
|
1325
|
+
if (!fits)
|
|
1326
|
+
return false;
|
|
1327
|
+
if (dispatch)
|
|
1328
|
+
dispatch(state.tr.replaceSelectionWith(rule.create()).scrollIntoView());
|
|
1329
|
+
return true;
|
|
1330
|
+
};
|
|
1331
|
+
}
|
|
1332
|
+
// ---------------------------------------------------------------------------
|
|
1333
|
+
// Tables
|
|
1334
|
+
// ---------------------------------------------------------------------------
|
|
1335
|
+
/** The table around the selection, as a depth of `$from`, or `-1`. */
|
|
1336
|
+
function tableDepth(state) {
|
|
1337
|
+
const { $from } = state.selection;
|
|
1338
|
+
for (let depth = $from.depth; depth > 0; depth--)
|
|
1339
|
+
if ($from.node(depth).type.name === 'table')
|
|
1340
|
+
return depth;
|
|
1341
|
+
return -1;
|
|
1342
|
+
}
|
|
1343
|
+
/**
|
|
1344
|
+
* An empty paragraph beside the table at `depth`, with the caret in it — the one
|
|
1345
|
+
* way to a line the keyboard cannot otherwise reach. There is no gap cursor here,
|
|
1346
|
+
* and a cell holds paragraphs, so Enter only ever splits inside it.
|
|
1347
|
+
*/
|
|
1348
|
+
function paragraphBeside(state, depth, side, dispatch) {
|
|
1349
|
+
const { $from } = state.selection;
|
|
1350
|
+
const paragraph = state.schema.nodes['paragraph'];
|
|
1351
|
+
const parent = $from.node(depth - 1);
|
|
1352
|
+
const index = $from.index(depth - 1) + (side === 'after' ? 1 : 0);
|
|
1353
|
+
if (!parent.canReplaceWith(index, index, paragraph))
|
|
1354
|
+
return false;
|
|
1355
|
+
if (dispatch) {
|
|
1356
|
+
const pos = side === 'after' ? $from.after(depth) : $from.before(depth);
|
|
1357
|
+
const tr = state.tr.insert(pos, paragraph.create());
|
|
1358
|
+
dispatch(tr.setSelection(TextSelection.create(tr.doc, pos + 1)).scrollIntoView());
|
|
1359
|
+
}
|
|
1360
|
+
return true;
|
|
1361
|
+
}
|
|
1362
|
+
/** Mod-Enter in a table: out of it, onto a new line below — what `exitCode` does for a code block. */
|
|
1363
|
+
const exitTable = (state, dispatch) => {
|
|
1364
|
+
const depth = tableDepth(state);
|
|
1365
|
+
return depth > 0 && paragraphBeside(state, depth, 'after', dispatch);
|
|
1366
|
+
};
|
|
1367
|
+
/**
|
|
1368
|
+
* The arrow key off the edge of a table nothing can be reached past: ArrowDown on
|
|
1369
|
+
* the last line of its last row with no text anywhere after it, ArrowUp on the
|
|
1370
|
+
* first line of its first row with none before. Anywhere else the key moves the
|
|
1371
|
+
* caret as it always does. Up and down only — left and right swap ends under
|
|
1372
|
+
* `dir="rtl"`, and the vertical pair means the same thing in both directions.
|
|
1373
|
+
*/
|
|
1374
|
+
function leaveTable(side) {
|
|
1375
|
+
return (state, dispatch, view) => {
|
|
1376
|
+
if (view && !view.editable)
|
|
1377
|
+
return false;
|
|
1378
|
+
const depth = tableDepth(state);
|
|
1379
|
+
const { $from, empty } = state.selection;
|
|
1380
|
+
if (depth < 0 || !empty)
|
|
1381
|
+
return false;
|
|
1382
|
+
// table > row > cell > paragraph: the row's place in the table, and the
|
|
1383
|
+
// paragraph's in its cell.
|
|
1384
|
+
const table = $from.node(depth);
|
|
1385
|
+
const cell = $from.node(depth + 2);
|
|
1386
|
+
const row = $from.index(depth);
|
|
1387
|
+
const block = $from.index(depth + 2);
|
|
1388
|
+
const edge = side === 'after' ? row === table.childCount - 1 && block === cell.childCount - 1 : row === 0 && block === 0;
|
|
1389
|
+
if (!edge)
|
|
1390
|
+
return false;
|
|
1391
|
+
const onEdgeLine = view
|
|
1392
|
+
? view.endOfTextblock(side === 'after' ? 'down' : 'up')
|
|
1393
|
+
: $from.parentOffset === (side === 'after' ? $from.parent.content.size : 0);
|
|
1394
|
+
if (!onEdgeLine)
|
|
1395
|
+
return false;
|
|
1396
|
+
const outside = state.doc.resolve(side === 'after' ? $from.after(depth) : $from.before(depth));
|
|
1397
|
+
if (TextSelection.findFrom(outside, side === 'after' ? 1 : -1, true))
|
|
1398
|
+
return false;
|
|
1399
|
+
return paragraphBeside(state, depth, side, dispatch);
|
|
1400
|
+
};
|
|
1401
|
+
}
|
|
1402
|
+
function insertHardBreak(state, dispatch) {
|
|
1403
|
+
const type = state.schema.nodes['hard_break'];
|
|
1404
|
+
const { $from } = state.selection;
|
|
1405
|
+
if (!$from.parent.canReplaceWith($from.index(), $from.index(), type))
|
|
1406
|
+
return false;
|
|
1407
|
+
if (dispatch)
|
|
1408
|
+
dispatch(state.tr.replaceSelectionWith(type.create()).scrollIntoView());
|
|
1409
|
+
return true;
|
|
1410
|
+
}
|
|
1411
|
+
// ---------------------------------------------------------------------------
|
|
1412
|
+
// Links
|
|
1413
|
+
// ---------------------------------------------------------------------------
|
|
1414
|
+
/**
|
|
1415
|
+
* The run of text carrying one link around the selection — the whole link, when
|
|
1416
|
+
* the caret merely sits in it — or `null` when there is none.
|
|
1417
|
+
*/
|
|
1418
|
+
function linkRange(state) {
|
|
1419
|
+
const type = state.schema.marks['link'];
|
|
1420
|
+
const { empty, from, to, $from } = state.selection;
|
|
1421
|
+
if (!empty) {
|
|
1422
|
+
let found = null;
|
|
1423
|
+
state.doc.nodesBetween(from, to, node => {
|
|
1424
|
+
found ??= type.isInSet(node.marks) ?? null;
|
|
1425
|
+
});
|
|
1426
|
+
return found ? { from, to, mark: found } : null;
|
|
1427
|
+
}
|
|
1428
|
+
return markAround($from, type);
|
|
1429
|
+
}
|
|
1430
|
+
function markAround($pos, type) {
|
|
1431
|
+
const parent = $pos.parent;
|
|
1432
|
+
const start = $pos.start();
|
|
1433
|
+
const offsets = [];
|
|
1434
|
+
parent.forEach((_child, offset) => offsets.push(offset));
|
|
1435
|
+
// The child the caret is in — or, at a boundary, the one before it and then
|
|
1436
|
+
// the one after, since a non-inclusive mark does not report itself at its end.
|
|
1437
|
+
const index = $pos.index();
|
|
1438
|
+
const candidates = ($pos.textOffset > 0 ? [index] : [index - 1, index]).filter(i => i >= 0 && i < parent.childCount);
|
|
1439
|
+
for (const at of candidates) {
|
|
1440
|
+
const mark = type.isInSet(parent.child(at).marks);
|
|
1441
|
+
if (!mark)
|
|
1442
|
+
continue;
|
|
1443
|
+
let first = at;
|
|
1444
|
+
let last = at;
|
|
1445
|
+
while (first > 0 && mark.isInSet(parent.child(first - 1).marks))
|
|
1446
|
+
first--;
|
|
1447
|
+
while (last < parent.childCount - 1 && mark.isInSet(parent.child(last + 1).marks))
|
|
1448
|
+
last++;
|
|
1449
|
+
return { from: start + offsets[first], to: start + offsets[last] + parent.child(last).nodeSize, mark };
|
|
1450
|
+
}
|
|
1451
|
+
return null;
|
|
1452
|
+
}
|
|
1453
|
+
/**
|
|
1454
|
+
* Link the selection to `href` — replacing the link it sits in when the caret
|
|
1455
|
+
* is inside one, and inserting the address as its own text when nothing is
|
|
1456
|
+
* selected at all. `href` has already passed the URL policy.
|
|
1457
|
+
*/
|
|
1458
|
+
function setLink(href) {
|
|
1459
|
+
return (state, dispatch) => {
|
|
1460
|
+
const type = state.schema.marks['link'];
|
|
1461
|
+
const { empty, from, to } = state.selection;
|
|
1462
|
+
const existing = linkRange(state);
|
|
1463
|
+
const title = existing?.mark.attrs['title'] ?? null;
|
|
1464
|
+
const mark = type.create({ href, title });
|
|
1465
|
+
if (!toggleMark(type, { href })(state))
|
|
1466
|
+
return false;
|
|
1467
|
+
if (!dispatch)
|
|
1468
|
+
return true;
|
|
1469
|
+
const tr = state.tr;
|
|
1470
|
+
if (!empty)
|
|
1471
|
+
tr.removeMark(from, to, type).addMark(from, to, mark);
|
|
1472
|
+
else if (existing)
|
|
1473
|
+
tr.removeMark(existing.from, existing.to, type).addMark(existing.from, existing.to, mark);
|
|
1474
|
+
else
|
|
1475
|
+
tr.insertText(href, from).addMark(from, from + href.length, mark);
|
|
1476
|
+
dispatch(tr.scrollIntoView());
|
|
1477
|
+
return true;
|
|
1478
|
+
};
|
|
1479
|
+
}
|
|
1480
|
+
/** Remove the link the selection is in or covers. */
|
|
1481
|
+
const removeLink = (state, dispatch) => {
|
|
1482
|
+
const range = linkRange(state);
|
|
1483
|
+
if (!range)
|
|
1484
|
+
return false;
|
|
1485
|
+
if (dispatch)
|
|
1486
|
+
dispatch(state.tr.removeMark(range.from, range.to, state.schema.marks['link']).scrollIntoView());
|
|
1487
|
+
return true;
|
|
1488
|
+
};
|
|
1489
|
+
// ---------------------------------------------------------------------------
|
|
1490
|
+
// Plugins
|
|
1491
|
+
// ---------------------------------------------------------------------------
|
|
1492
|
+
/**
|
|
1493
|
+
* The editor's plugin stack, in precedence order: its own keys, history keys,
|
|
1494
|
+
* ProseMirror's base keymap, then history and the markdown-style input rules.
|
|
1495
|
+
*
|
|
1496
|
+
* `canEdit` is asked for every document-changing transaction, because
|
|
1497
|
+
* `view.dispatch()` does not consult `editable` — a toolbar click or a plugin
|
|
1498
|
+
* would otherwise change a read-only document. Outside writes never pass here:
|
|
1499
|
+
* they replace the state wholesale.
|
|
1500
|
+
*
|
|
1501
|
+
* The input rules are the block shortcuts only. The typographic ones (smart
|
|
1502
|
+
* quotes, the em dash, the ellipsis) are left out on purpose: in a form control
|
|
1503
|
+
* they silently rewrite what someone typed into a name, a code or a reference.
|
|
1504
|
+
*/
|
|
1505
|
+
function editorPlugins(schema, options) {
|
|
1506
|
+
const { nodes } = schema;
|
|
1507
|
+
const keys = {};
|
|
1508
|
+
const bind = (combo, command) => {
|
|
1509
|
+
if (!command)
|
|
1510
|
+
return;
|
|
1511
|
+
keys[combo] = command;
|
|
1512
|
+
// `event.key` is upper case under Shift or Caps Lock, and ProseMirror
|
|
1513
|
+
// matches on it; bound twice, the way `prosemirror-example-setup` does.
|
|
1514
|
+
const last = combo.slice(combo.lastIndexOf('-') + 1);
|
|
1515
|
+
if (/^[a-z]$/.test(last))
|
|
1516
|
+
keys[combo.slice(0, -1) + last.toUpperCase()] = command;
|
|
1517
|
+
};
|
|
1518
|
+
for (const [tool, shortcut] of Object.entries(SHORTCUTS)) {
|
|
1519
|
+
if (tool === 'link' || tool === 'undo' || tool === 'redo')
|
|
1520
|
+
continue;
|
|
1521
|
+
bind(comboOf(shortcut), toolCommand(tool, schema));
|
|
1522
|
+
}
|
|
1523
|
+
bind('Mod-z', undo);
|
|
1524
|
+
bind('Mod-Shift-z', redo);
|
|
1525
|
+
if (!options.apple)
|
|
1526
|
+
bind('Mod-y', redo);
|
|
1527
|
+
keys['Shift-Enter'] = chainCommands(exitCode, insertHardBreak);
|
|
1528
|
+
keys['Mod-Enter'] = chainCommands(exitCode, exitTable);
|
|
1529
|
+
keys['Enter'] = splitListItem(nodes['list_item']);
|
|
1530
|
+
keys['Mod-['] = liftListItem(nodes['list_item']);
|
|
1531
|
+
keys['Mod-]'] = sinkListItem(nodes['list_item']);
|
|
1532
|
+
keys['ArrowDown'] = leaveTable('after');
|
|
1533
|
+
keys['ArrowUp'] = leaveTable('before');
|
|
1534
|
+
keys['Backspace'] = undoInputRule;
|
|
1535
|
+
return [
|
|
1536
|
+
new Plugin({
|
|
1537
|
+
filterTransaction: (tr, state) => {
|
|
1538
|
+
if (!tr.docChanged)
|
|
1539
|
+
return true;
|
|
1540
|
+
if (!options.canEdit())
|
|
1541
|
+
return false;
|
|
1542
|
+
// `MAX_NESTING` is the deepest markdown reads back with its structure. A
|
|
1543
|
+
// document that already nests deeper (a bound HTML or JSON value) is kept
|
|
1544
|
+
// as it is; what is refused is an edit that makes it deeper still.
|
|
1545
|
+
const depth = nestingOf(tr.doc);
|
|
1546
|
+
return depth <= MAX_NESTING || depth <= nestingOf(state.doc);
|
|
1547
|
+
},
|
|
1548
|
+
}),
|
|
1549
|
+
bracketKeys(nodes['list_item'], options.apple),
|
|
1550
|
+
keymap(keys),
|
|
1551
|
+
keymap(baseKeymap),
|
|
1552
|
+
history(),
|
|
1553
|
+
inputRules({
|
|
1554
|
+
rules: [
|
|
1555
|
+
textblockTypeInputRule(/^(#{1,3})\s$/, nodes['heading'], match => ({ level: match[1].length })),
|
|
1556
|
+
wrappingInputRule(/^\s*>\s$/, nodes['blockquote']),
|
|
1557
|
+
wrappingInputRule(/^\s*([-+*])\s$/, nodes['bullet_list']),
|
|
1558
|
+
wrappingInputRule(/^(\d{1,9})\.\s$/, nodes['ordered_list'], match => ({ order: Number(match[1]) }), (match, node) => node.childCount + node.attrs['order'] === Number(match[1])),
|
|
1559
|
+
textblockTypeInputRule(/^```$/, nodes['code_block']),
|
|
1560
|
+
],
|
|
1561
|
+
}),
|
|
1562
|
+
];
|
|
1563
|
+
}
|
|
1564
|
+
/**
|
|
1565
|
+
* Mod-] and Mod-[ as they are TYPED on a layout that needs AltGr or Option for a
|
|
1566
|
+
* bracket: Windows German reports AltGr+8 as Ctrl+Alt with `key` `[`, macOS
|
|
1567
|
+
* German reports Option+5 under ⌘ as ⌘+Alt with `key` `[`. The keymap matches on
|
|
1568
|
+
* the full modifier set, so neither reaches `Mod-[`, and indenting a list item has
|
|
1569
|
+
* no other path — Tab is not bound and the toolbar has no indent tool.
|
|
1570
|
+
*
|
|
1571
|
+
* Matched on the character, never on `event.code`: the physical key at `]` is `+`
|
|
1572
|
+
* on a German keyboard, and Ctrl and `+` is the browser's zoom.
|
|
1573
|
+
*/
|
|
1574
|
+
function bracketKeys(item, apple) {
|
|
1575
|
+
const lift = liftListItem(item);
|
|
1576
|
+
const sink = sinkListItem(item);
|
|
1577
|
+
return new Plugin({
|
|
1578
|
+
props: {
|
|
1579
|
+
handleKeyDown: (view, event) => {
|
|
1580
|
+
if (!event.altKey || event.shiftKey || (event.key !== '[' && event.key !== ']'))
|
|
1581
|
+
return false;
|
|
1582
|
+
const mod = apple ? event.metaKey && !event.ctrlKey : event.ctrlKey && !event.metaKey;
|
|
1583
|
+
return mod && (event.key === ']' ? sink : lift)(view.state, view.dispatch);
|
|
1584
|
+
},
|
|
1585
|
+
},
|
|
1586
|
+
});
|
|
1587
|
+
}
|
|
1588
|
+
/** A shortcut in ProseMirror's key-name syntax. */
|
|
1589
|
+
function comboOf(shortcut) {
|
|
1590
|
+
return `Mod-${shortcut.alt ? 'Alt-' : ''}${shortcut.shift ? 'Shift-' : ''}${shortcut.key.toLowerCase()}`;
|
|
1591
|
+
}
|
|
1592
|
+
|
|
1593
|
+
/**
|
|
1594
|
+
* @license
|
|
1595
|
+
*
|
|
1596
|
+
* Use of this source code is governed by an MIT-style license that can be
|
|
1597
|
+
* found in the LICENSE file at https://github.com/thekhegay/ngwr/blob/main/LICENSE
|
|
1598
|
+
*/
|
|
1599
|
+
/**
|
|
1600
|
+
* Toolbar glyphs as path data, drawn inline so the editor needs no
|
|
1601
|
+
* `provideWrIcons()` — the same reason `<wr-textarea>` inlines its grip.
|
|
1602
|
+
*
|
|
1603
|
+
* Lucide's (ISC), on its 24-unit grid with a 2-unit stroke; `line` elements are
|
|
1604
|
+
* rewritten as the equivalent paths so one `<path>` loop draws every icon.
|
|
1605
|
+
*/
|
|
1606
|
+
const TOOL_ICONS = {
|
|
1607
|
+
bold: ['M6 12h9a4 4 0 0 1 0 8H7a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1h7a4 4 0 0 1 0 8'],
|
|
1608
|
+
italic: ['M19 4h-9', 'M14 20H5', 'M15 4 9 20'],
|
|
1609
|
+
underline: ['M6 4v6a6 6 0 0 0 12 0V4', 'M4 20h16'],
|
|
1610
|
+
strike: ['M16 4H9a3 3 0 0 0-2.83 4', 'M14 12a4 4 0 0 1 0 8H6', 'M4 12h16'],
|
|
1611
|
+
code: ['m16 18 6-6-6-6', 'm8 6-6 6 6 6'],
|
|
1612
|
+
paragraph: ['M13 4v16', 'M17 4v16', 'M19 4H9.5a4.5 4.5 0 0 0 0 9H13'],
|
|
1613
|
+
heading1: ['M4 12h8', 'M4 18V6', 'M12 18V6', 'm17 12 3-2v8'],
|
|
1614
|
+
heading2: ['M4 12h8', 'M4 18V6', 'M12 18V6', 'M21 18h-4c0-4 4-3 4-6 0-1.5-2-2.5-4-1'],
|
|
1615
|
+
heading3: [
|
|
1616
|
+
'M4 12h8',
|
|
1617
|
+
'M4 18V6',
|
|
1618
|
+
'M12 18V6',
|
|
1619
|
+
'M17.5 10.5c1.7-1 3.5 0 3.5 1.5a2 2 0 0 1-2 2',
|
|
1620
|
+
'M17 17.5c2 1.5 4 .3 4-1.5a2 2 0 0 0-2-2',
|
|
1621
|
+
],
|
|
1622
|
+
bulletList: ['M3 5h.01', 'M3 12h.01', 'M3 19h.01', 'M8 5h13', 'M8 12h13', 'M8 19h13'],
|
|
1623
|
+
orderedList: [
|
|
1624
|
+
'M11 5h10',
|
|
1625
|
+
'M11 12h10',
|
|
1626
|
+
'M11 19h10',
|
|
1627
|
+
'M4 4h1v5',
|
|
1628
|
+
'M4 9h2',
|
|
1629
|
+
'M6.5 20H3.4c0-1 2.6-1.925 2.6-3.5a1.5 1.5 0 0 0-2.6-1.02',
|
|
1630
|
+
],
|
|
1631
|
+
blockquote: ['M17 5H3', 'M21 12H8', 'M21 19H8', 'M3 12v7'],
|
|
1632
|
+
codeBlock: [
|
|
1633
|
+
'm10 9-3 3 3 3',
|
|
1634
|
+
'm14 15 3-3-3-3',
|
|
1635
|
+
'M5 3h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2z',
|
|
1636
|
+
],
|
|
1637
|
+
link: [
|
|
1638
|
+
'M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71',
|
|
1639
|
+
'M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71',
|
|
1640
|
+
],
|
|
1641
|
+
horizontalRule: ['M5 12h14'],
|
|
1642
|
+
undo: ['M9 14 4 9l5-5', 'M4 9h10.5a5.5 5.5 0 0 1 5.5 5.5a5.5 5.5 0 0 1-5.5 5.5H11'],
|
|
1643
|
+
redo: ['m15 14 5-5-5-5', 'M20 9H9.5A5.5 5.5 0 0 0 4 14.5A5.5 5.5 0 0 0 9.5 20H13'],
|
|
1644
|
+
};
|
|
1645
|
+
|
|
1646
|
+
/**
|
|
1647
|
+
* @license
|
|
1648
|
+
*
|
|
1649
|
+
* Use of this source code is governed by an MIT-style license that can be
|
|
1650
|
+
* found in the LICENSE file at https://github.com/thekhegay/ngwr/blob/main/LICENSE
|
|
1651
|
+
*/
|
|
1652
|
+
let nextId$1 = 0;
|
|
1653
|
+
/**
|
|
1654
|
+
* The field the link panel's URL input belongs to — provided as `WR_FORM_FIELD`
|
|
1655
|
+
* for the editor's whole subtree.
|
|
1656
|
+
*
|
|
1657
|
+
* It does two jobs at once. It is the SHIELD every composite control needs: the
|
|
1658
|
+
* editor is the control a `<wr-form-field>` wraps, and without a nearer provider
|
|
1659
|
+
* the `[wrInput]` in its link panel would adopt the outer field's id, take over
|
|
1660
|
+
* its label and announce its error as its own. And it is the input's own field:
|
|
1661
|
+
* `[wrInput]` binds `id`, `aria-invalid` and `aria-describedby` from whatever
|
|
1662
|
+
* field it finds, so answering here is how a refused URL reaches a screen reader
|
|
1663
|
+
* without binding attributes the directive already owns.
|
|
1664
|
+
*
|
|
1665
|
+
* @internal
|
|
1666
|
+
*/
|
|
1667
|
+
class WrEditorLinkField {
|
|
1668
|
+
id = `wr-editor-link-${nextId$1++}`;
|
|
1669
|
+
/** The URL just submitted was refused by the link policy. */
|
|
1670
|
+
invalid = signal(false, /* @ts-ignore */
|
|
1671
|
+
...(ngDevMode ? [{ debugName: "invalid" }] : /* istanbul ignore next */ []));
|
|
1672
|
+
controlId = signal(`${this.id}-url`).asReadonly();
|
|
1673
|
+
errorId = `${this.id}-error`;
|
|
1674
|
+
panelId = `${this.id}-panel`;
|
|
1675
|
+
errorKeys = computed(() => (this.invalid() ? ['url'] : []), /* @ts-ignore */
|
|
1676
|
+
...(ngDevMode ? [{ debugName: "errorKeys" }] : /* istanbul ignore next */ []));
|
|
1677
|
+
describedBy = computed(() => (this.invalid() ? this.errorId : null), /* @ts-ignore */
|
|
1678
|
+
...(ngDevMode ? [{ debugName: "describedBy" }] : /* istanbul ignore next */ []));
|
|
1679
|
+
labelId = signal(null).asReadonly();
|
|
1680
|
+
adoptControlId() {
|
|
1681
|
+
// The panel's input never carries an id of its own.
|
|
1682
|
+
}
|
|
1683
|
+
}
|
|
1684
|
+
|
|
1685
|
+
/**
|
|
1686
|
+
* @license
|
|
1687
|
+
*
|
|
1688
|
+
* Use of this source code is governed by an MIT-style license that can be
|
|
1689
|
+
* found in the LICENSE file at https://github.com/thekhegay/ngwr/blob/main/LICENSE
|
|
1690
|
+
*/
|
|
1691
|
+
/**
|
|
1692
|
+
* List items as the editor draws them, which differs from the HTML it writes
|
|
1693
|
+
* only for a TASK item.
|
|
1694
|
+
*
|
|
1695
|
+
* A task item's box is presentational, with its state as text beside it —
|
|
1696
|
+
* exactly what `<wr-markdown>` renders. A real `<input type="checkbox">` would be
|
|
1697
|
+
* an unlabelled form control inside the text, and making it operable would
|
|
1698
|
+
* promise an interaction v1 does not have. The box and the words sit outside the
|
|
1699
|
+
* editable content, so ProseMirror neither edits nor serializes them; the words
|
|
1700
|
+
* come from the catalog, which is why this is a node view the component builds
|
|
1701
|
+
* rather than a schema `toDOM` (the schema is shared and has no catalog).
|
|
1702
|
+
*/
|
|
1703
|
+
function listItemView(labels) {
|
|
1704
|
+
return (node, view) => new WrListItemView(node, view.dom.ownerDocument, labels);
|
|
1705
|
+
}
|
|
1706
|
+
class WrListItemView {
|
|
1707
|
+
dom;
|
|
1708
|
+
contentDOM;
|
|
1709
|
+
node;
|
|
1710
|
+
constructor(node, document, labels) {
|
|
1711
|
+
this.node = node;
|
|
1712
|
+
this.dom = document.createElement('li');
|
|
1713
|
+
const checked = node.attrs['checked'];
|
|
1714
|
+
if (checked === null) {
|
|
1715
|
+
this.contentDOM = this.dom;
|
|
1716
|
+
return;
|
|
1717
|
+
}
|
|
1718
|
+
this.dom.className = 'wr-editor__item wr-editor__item--task';
|
|
1719
|
+
this.dom.setAttribute('data-checked', String(checked));
|
|
1720
|
+
const box = document.createElement('span');
|
|
1721
|
+
box.className = checked ? 'wr-editor__task wr-editor__task--checked' : 'wr-editor__task';
|
|
1722
|
+
box.contentEditable = 'false';
|
|
1723
|
+
box.setAttribute('aria-hidden', 'true');
|
|
1724
|
+
const state = document.createElement('span');
|
|
1725
|
+
state.className = 'wr-editor__sr-only';
|
|
1726
|
+
state.contentEditable = 'false';
|
|
1727
|
+
state.textContent = checked ? labels.done : labels.todo;
|
|
1728
|
+
this.contentDOM = document.createElement('div');
|
|
1729
|
+
this.contentDOM.className = 'wr-editor__task-body';
|
|
1730
|
+
this.dom.append(box, state, this.contentDOM);
|
|
1731
|
+
}
|
|
1732
|
+
update(node) {
|
|
1733
|
+
// A task item becoming a plain one (or the reverse, or toggled) is drawn
|
|
1734
|
+
// again from scratch; there is no in-place way to add or drop the box.
|
|
1735
|
+
if (node.type !== this.node.type || node.attrs['checked'] !== this.node.attrs['checked'])
|
|
1736
|
+
return false;
|
|
1737
|
+
this.node = node;
|
|
1738
|
+
return true;
|
|
1739
|
+
}
|
|
1740
|
+
ignoreMutation(mutation) {
|
|
1741
|
+
if (mutation.type === 'selection' || this.contentDOM === this.dom)
|
|
1742
|
+
return false;
|
|
1743
|
+
return !this.contentDOM.contains(mutation.target);
|
|
1744
|
+
}
|
|
1745
|
+
}
|
|
1746
|
+
|
|
1747
|
+
/**
|
|
1748
|
+
* @license
|
|
1749
|
+
*
|
|
1750
|
+
* Use of this source code is governed by an MIT-style license that can be
|
|
1751
|
+
* found in the LICENSE file at https://github.com/thekhegay/ngwr/blob/main/LICENSE
|
|
1752
|
+
*/
|
|
1753
|
+
/**
|
|
1754
|
+
* The toolbar `<wr-editor>` draws when `toolbar` is not bound — every tool, in
|
|
1755
|
+
* five groups. Exported so a toolbar can be built from it rather than retyped:
|
|
1756
|
+
*
|
|
1757
|
+
* ```ts
|
|
1758
|
+
* readonly tools = WR_EDITOR_TOOLBAR.filter(tool => tool !== 'code');
|
|
1759
|
+
* ```
|
|
1760
|
+
*
|
|
1761
|
+
* `underline` is dropped in `markdown` format whether it is listed or not.
|
|
1762
|
+
*/
|
|
1763
|
+
const WR_EDITOR_TOOLBAR = [
|
|
1764
|
+
'bold',
|
|
1765
|
+
'italic',
|
|
1766
|
+
'underline',
|
|
1767
|
+
'strike',
|
|
1768
|
+
'code',
|
|
1769
|
+
'|',
|
|
1770
|
+
'paragraph',
|
|
1771
|
+
'heading1',
|
|
1772
|
+
'heading2',
|
|
1773
|
+
'heading3',
|
|
1774
|
+
'|',
|
|
1775
|
+
'bulletList',
|
|
1776
|
+
'orderedList',
|
|
1777
|
+
'blockquote',
|
|
1778
|
+
'codeBlock',
|
|
1779
|
+
'|',
|
|
1780
|
+
'link',
|
|
1781
|
+
'horizontalRule',
|
|
1782
|
+
'|',
|
|
1783
|
+
'undo',
|
|
1784
|
+
'redo',
|
|
1785
|
+
];
|
|
1786
|
+
|
|
1787
|
+
/**
|
|
1788
|
+
* @license
|
|
1789
|
+
*
|
|
1790
|
+
* Use of this source code is governed by an MIT-style license that can be
|
|
1791
|
+
* found in the LICENSE file at https://github.com/thekhegay/ngwr/blob/main/LICENSE
|
|
1792
|
+
*/
|
|
1793
|
+
let nextId = 0;
|
|
1794
|
+
function warn(message) {
|
|
1795
|
+
// eslint-disable-next-line no-console -- dev-mode validation
|
|
1796
|
+
console.warn(`[NGWR] <wr-editor>: ${message}`);
|
|
1797
|
+
}
|
|
1798
|
+
/** A document's JSON with every mark the target schema does not know dropped. */
|
|
1799
|
+
function withoutMarks(node, known) {
|
|
1800
|
+
return {
|
|
1801
|
+
...node,
|
|
1802
|
+
...(node.marks ? { marks: node.marks.filter(mark => mark.type in known) } : {}),
|
|
1803
|
+
...(node.content ? { content: node.content.map(child => withoutMarks(child, known)) } : {}),
|
|
1804
|
+
};
|
|
1805
|
+
}
|
|
1806
|
+
/**
|
|
1807
|
+
* Rich-text editor on ProseMirror, with a toolbar, a link panel, keyboard
|
|
1808
|
+
* shortcuts and markdown-style block shortcuts (`# `, `> `, `- `, `1. `,
|
|
1809
|
+
* ` ``` `).
|
|
1810
|
+
*
|
|
1811
|
+
* A signal-forms native control: it implements `FormValueControl<WrEditorValue>`,
|
|
1812
|
+
* so `[formField]` binds straight to its `value` model — no
|
|
1813
|
+
* `ControlValueAccessor` in between. `[(value)]` works standalone.
|
|
1814
|
+
*
|
|
1815
|
+
* The value is an HTML string, a markdown string or the document as JSON, chosen
|
|
1816
|
+
* by `format`. Whatever arrives is treated as untrusted and rebuilt from the
|
|
1817
|
+
* editor's schema, so only what the schema allows survives, and links and images
|
|
1818
|
+
* go through `safeMarkdownUrl` in all three. A value the editor cannot read is
|
|
1819
|
+
* refused — the document it already shows is kept, a warning names the reason in
|
|
1820
|
+
* dev mode, and nothing is written back. Until a value it CAN read arrives the
|
|
1821
|
+
* editor is read-only and carries `wr-editor--refused`: the model still holds the
|
|
1822
|
+
* refused value, and an edit would write the document on screen over it.
|
|
1823
|
+
*
|
|
1824
|
+
* Headings stop at three levels: an h4–h6 read from HTML or markdown becomes an
|
|
1825
|
+
* h3 rather than a paragraph. Underline exists in `html` and `json` only —
|
|
1826
|
+
* markdown cannot spell it, so in that format the tool, the shortcut and the mark
|
|
1827
|
+
* itself are absent.
|
|
1828
|
+
*
|
|
1829
|
+
* ProseMirror is an OPTIONAL peer dependency: only an app that imports
|
|
1830
|
+
* `ngwr/editor` installs `prosemirror-model`, `-state`, `-view`, `-commands`,
|
|
1831
|
+
* `-keymap`, `-history`, `-schema-list` and `-inputrules`.
|
|
1832
|
+
*
|
|
1833
|
+
* @example
|
|
1834
|
+
* ```html
|
|
1835
|
+
* <!-- signal forms -->
|
|
1836
|
+
* <wr-editor format="markdown" placeholder="Describe the change" [formField]="form.body" />
|
|
1837
|
+
*
|
|
1838
|
+
* <!-- standalone two-way binding -->
|
|
1839
|
+
* <wr-editor [(value)]="html" />
|
|
1840
|
+
* ```
|
|
1841
|
+
*
|
|
1842
|
+
* @see https://ngwr.dev/reference/components/editor
|
|
1843
|
+
*/
|
|
1844
|
+
class WrEditor {
|
|
1845
|
+
/**
|
|
1846
|
+
* The document. A string in `html` and `markdown` format, a
|
|
1847
|
+
* {@link WrEditorJson} tree in `json`; `''` / `null` when nothing is written.
|
|
1848
|
+
* Bound by `[formField]`, or two-way via `[(value)]`.
|
|
1849
|
+
*/
|
|
1850
|
+
value = model(null, /* @ts-ignore */
|
|
1851
|
+
...(ngDevMode ? [{ debugName: "value" }] : /* istanbul ignore next */ []));
|
|
1852
|
+
/** Emitted on blur so a bound field can mark itself touched. */
|
|
1853
|
+
touch = output();
|
|
1854
|
+
/**
|
|
1855
|
+
* Disable the editor and its toolbar. Bound automatically from the field's
|
|
1856
|
+
* disabled state when used with `[formField]`.
|
|
1857
|
+
*
|
|
1858
|
+
* @default false
|
|
1859
|
+
*/
|
|
1860
|
+
disabled = input(false, { ...(ngDevMode ? { debugName: "disabled" } : /* istanbul ignore next */ {}), transform: coerceBooleanProperty });
|
|
1861
|
+
/**
|
|
1862
|
+
* Refuse edits while the text stays focusable, selectable and announced.
|
|
1863
|
+
* Bound automatically from the field's readonly state when used with
|
|
1864
|
+
* `[formField]`.
|
|
1865
|
+
*
|
|
1866
|
+
* The surface keeps its tab stop and reports `aria-readonly`; typing, paste,
|
|
1867
|
+
* drop, the shortcuts and every toolbar command are refused. The toolbar goes
|
|
1868
|
+
* inert, since none of its tools can apply.
|
|
1869
|
+
*
|
|
1870
|
+
* @default false
|
|
1871
|
+
*/
|
|
1872
|
+
readonly = input(false, { ...(ngDevMode ? { debugName: "readonly" } : /* istanbul ignore next */ {}), transform: coerceBooleanProperty });
|
|
1873
|
+
/**
|
|
1874
|
+
* Hint shown while the document is empty, and exposed as `aria-placeholder`.
|
|
1875
|
+
*
|
|
1876
|
+
* @default ''
|
|
1877
|
+
*/
|
|
1878
|
+
placeholder = input('', /* @ts-ignore */
|
|
1879
|
+
...(ngDevMode ? [{ debugName: "placeholder" }] : /* istanbul ignore next */ []));
|
|
1880
|
+
/**
|
|
1881
|
+
* Accessible name of the text surface. Falls back to the surrounding
|
|
1882
|
+
* `<wr-form-field>`'s label, then to `editor.label`.
|
|
1883
|
+
*/
|
|
1884
|
+
ariaLabel = input(null, /* @ts-ignore */
|
|
1885
|
+
...(ngDevMode ? [{ debugName: "ariaLabel" }] : /* istanbul ignore next */ []));
|
|
1886
|
+
/**
|
|
1887
|
+
* What `value` holds — see {@link WrEditorFormat}. Unset, it falls back to
|
|
1888
|
+
* `provideWrConfig({ editor: { format } })`. Changing it re-reads `value` in
|
|
1889
|
+
* the new format.
|
|
1890
|
+
*
|
|
1891
|
+
* @default 'html'
|
|
1892
|
+
*/
|
|
1893
|
+
format = input(null, /* @ts-ignore */
|
|
1894
|
+
...(ngDevMode ? [{ debugName: "format" }] : /* istanbul ignore next */ []));
|
|
1895
|
+
// `null` as the input's own default, not `'html'`, so "the template said
|
|
1896
|
+
// nothing" stays distinguishable from "the template said html" and an app-wide
|
|
1897
|
+
// default can apply.
|
|
1898
|
+
resolvedFormat = useConfigValue(this.format, c => c.editor?.format, 'html');
|
|
1899
|
+
/**
|
|
1900
|
+
* The toolbar's tools, in order, with `'|'` between groups; `false` hides it.
|
|
1901
|
+
* `underline` is left out in `markdown` format, which cannot store it.
|
|
1902
|
+
*
|
|
1903
|
+
* @default WR_EDITOR_TOOLBAR
|
|
1904
|
+
*/
|
|
1905
|
+
toolbar = input(WR_EDITOR_TOOLBAR, /* @ts-ignore */
|
|
1906
|
+
...(ngDevMode ? [{ debugName: "toolbar" }] : /* istanbul ignore next */ []));
|
|
1907
|
+
document = inject(DOCUMENT);
|
|
1908
|
+
injector = inject(Injector);
|
|
1909
|
+
destroyRef = inject(DestroyRef);
|
|
1910
|
+
host = inject(ElementRef);
|
|
1911
|
+
platform = inject(WrPlatform);
|
|
1912
|
+
isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
|
|
1913
|
+
/**
|
|
1914
|
+
* Ambient reading direction, read by the toolbar's arrow keys. Optional so a
|
|
1915
|
+
* bare `TestBed` needs no provider; `null` means LTR.
|
|
1916
|
+
*/
|
|
1917
|
+
dir = inject(Directionality, { optional: true });
|
|
1918
|
+
/** @internal */
|
|
1919
|
+
linkField = inject(WrEditorLinkField, { self: true });
|
|
1920
|
+
/** The surrounding `<wr-form-field>`'s error state, read past the editor's own shield. @internal */
|
|
1921
|
+
fieldAria = useFormFieldAria({ skipSelf: true });
|
|
1922
|
+
resolvedAriaLabel = useI18nText(this.ariaLabel, 'editor.label', 'Rich text');
|
|
1923
|
+
/**
|
|
1924
|
+
* The field's label names the surface, when there is one — a
|
|
1925
|
+
* `contenteditable` is not labelable, so the field's `<label for>` cannot
|
|
1926
|
+
* reach it. An explicit `ariaLabel` still wins over both.
|
|
1927
|
+
*/
|
|
1928
|
+
labelledBy = computed(() => (this.ariaLabel() ? null : this.fieldAria.labelledBy()), /* @ts-ignore */
|
|
1929
|
+
...(ngDevMode ? [{ debugName: "labelledBy" }] : /* istanbul ignore next */ []));
|
|
1930
|
+
/** `null` while the field's label carries the name. */
|
|
1931
|
+
accessibleName = computed(() => (this.labelledBy() ? null : this.resolvedAriaLabel()), /* @ts-ignore */
|
|
1932
|
+
...(ngDevMode ? [{ debugName: "accessibleName" }] : /* istanbul ignore next */ []));
|
|
1933
|
+
surfaceId = `wr-editor-${nextId++}`;
|
|
1934
|
+
/** ProseMirror owns the surface from here on; until then the preview stands in. */
|
|
1935
|
+
mounted = signal(false, /* @ts-ignore */
|
|
1936
|
+
...(ngDevMode ? [{ debugName: "mounted" }] : /* istanbul ignore next */ []));
|
|
1937
|
+
focused = signal(false, /* @ts-ignore */
|
|
1938
|
+
...(ngDevMode ? [{ debugName: "focused" }] : /* istanbul ignore next */ []));
|
|
1939
|
+
editorState = signal(null, /* @ts-ignore */
|
|
1940
|
+
...(ngDevMode ? [{ debugName: "editorState" }] : /* istanbul ignore next */ []));
|
|
1941
|
+
/**
|
|
1942
|
+
* Whether `value` holds something the editor could not read, so the document
|
|
1943
|
+
* on screen is not the one the model holds — empty at mount, or the previous
|
|
1944
|
+
* record after a write. Edits are refused until a readable value arrives:
|
|
1945
|
+
* the first keystroke would otherwise replace the stored value with whatever
|
|
1946
|
+
* is on screen, and the only sign of it was a dev-mode warning.
|
|
1947
|
+
*/
|
|
1948
|
+
refused = signal(false, /* @ts-ignore */
|
|
1949
|
+
...(ngDevMode ? [{ debugName: "refused" }] : /* istanbul ignore next */ []));
|
|
1950
|
+
/** Read-only by the input, or because the value was refused. @internal */
|
|
1951
|
+
locked = computed(() => this.readonly() || this.refused(), /* @ts-ignore */
|
|
1952
|
+
...(ngDevMode ? [{ debugName: "locked" }] : /* istanbul ignore next */ []));
|
|
1953
|
+
/** Apple keyboards name the modifier ⌘. Read in the browser only — see `mount()`. */
|
|
1954
|
+
apple = signal(false, /* @ts-ignore */
|
|
1955
|
+
...(ngDevMode ? [{ debugName: "apple" }] : /* istanbul ignore next */ []));
|
|
1956
|
+
surface = viewChild.required('surface', /* @ts-ignore */
|
|
1957
|
+
...(ngDevMode ? [{ debugName: "surface" }] : /* istanbul ignore next */ []));
|
|
1958
|
+
// `read`, because `#tool` sits on a `wr-btn` and would otherwise resolve to the component.
|
|
1959
|
+
toolButtons = viewChildren('tool', { ...(ngDevMode ? { debugName: "toolButtons" } : /* istanbul ignore next */ {}), read: ElementRef });
|
|
1960
|
+
linkPopover = viewChild('linkPopover', /* @ts-ignore */
|
|
1961
|
+
...(ngDevMode ? [{ debugName: "linkPopover" }] : /* istanbul ignore next */ []));
|
|
1962
|
+
view = null;
|
|
1963
|
+
/** The format the live document was read in, which is the format it is written in. */
|
|
1964
|
+
viewFormat = 'html';
|
|
1965
|
+
/**
|
|
1966
|
+
* The value the live document was last read from or written as. A write from
|
|
1967
|
+
* outside that equals it is the document already on screen — an echo of the
|
|
1968
|
+
* editor's own emit, or a consumer writing back what it was handed — and must
|
|
1969
|
+
* neither re-parse nor reset the selection.
|
|
1970
|
+
*/
|
|
1971
|
+
lastSerialized = undefined;
|
|
1972
|
+
linkToSurface = false;
|
|
1973
|
+
// Labels
|
|
1974
|
+
toolbarLabel = readI18nText('editor.toolbar', 'Formatting');
|
|
1975
|
+
linkUrlLabel = readI18nText('editor.linkUrl', 'Link address');
|
|
1976
|
+
linkApplyLabel = readI18nText('editor.linkApply', 'Apply');
|
|
1977
|
+
linkRemoveLabel = readI18nText('editor.linkRemove', 'Remove link');
|
|
1978
|
+
linkInvalidLabel = readI18nText('editor.linkInvalid', 'This address cannot be used as a link.');
|
|
1979
|
+
taskDoneLabel = readI18nText('editor.taskDone', 'Done:');
|
|
1980
|
+
taskTodoLabel = readI18nText('editor.taskTodo', 'To do:');
|
|
1981
|
+
keyCtrl = readI18nText('editor.keyCtrl', 'Ctrl');
|
|
1982
|
+
keyShift = readI18nText('editor.keyShift', 'Shift');
|
|
1983
|
+
keyAlt = readI18nText('editor.keyAlt', 'Alt');
|
|
1984
|
+
headingLabel = useI18nFormatter('editor.heading', 'Heading {{level}}');
|
|
1985
|
+
shortcutLabel = useI18nFormatter('editor.shortcut', '{{label}} ({{keys}})');
|
|
1986
|
+
toolLabels = {
|
|
1987
|
+
bold: readI18nText('editor.bold', 'Bold'),
|
|
1988
|
+
italic: readI18nText('editor.italic', 'Italic'),
|
|
1989
|
+
underline: readI18nText('editor.underline', 'Underline'),
|
|
1990
|
+
strike: readI18nText('editor.strike', 'Strikethrough'),
|
|
1991
|
+
code: readI18nText('editor.code', 'Inline code'),
|
|
1992
|
+
paragraph: readI18nText('editor.paragraph', 'Paragraph'),
|
|
1993
|
+
heading1: computed(() => this.headingLabel({ level: 1 })),
|
|
1994
|
+
heading2: computed(() => this.headingLabel({ level: 2 })),
|
|
1995
|
+
heading3: computed(() => this.headingLabel({ level: 3 })),
|
|
1996
|
+
bulletList: readI18nText('editor.bulletList', 'Bulleted list'),
|
|
1997
|
+
orderedList: readI18nText('editor.orderedList', 'Numbered list'),
|
|
1998
|
+
blockquote: readI18nText('editor.blockquote', 'Quote'),
|
|
1999
|
+
codeBlock: readI18nText('editor.codeBlock', 'Code block'),
|
|
2000
|
+
link: readI18nText('editor.link', 'Insert link'),
|
|
2001
|
+
horizontalRule: readI18nText('editor.horizontalRule', 'Horizontal line'),
|
|
2002
|
+
undo: readI18nText('editor.undo', 'Undo'),
|
|
2003
|
+
redo: readI18nText('editor.redo', 'Redo'),
|
|
2004
|
+
};
|
|
2005
|
+
// Toolbar
|
|
2006
|
+
/**
|
|
2007
|
+
* The bound tools as drawn: underline dropped where the format cannot store
|
|
2008
|
+
* it, and separators only BETWEEN two groups — a leading, trailing or doubled
|
|
2009
|
+
* one would be left behind by that filter or by a hand-built list.
|
|
2010
|
+
*/
|
|
2011
|
+
tools = computed(() => {
|
|
2012
|
+
const bound = this.toolbar();
|
|
2013
|
+
if (!bound)
|
|
2014
|
+
return [];
|
|
2015
|
+
const markdown = this.resolvedFormat() === 'markdown';
|
|
2016
|
+
const out = [];
|
|
2017
|
+
for (const tool of bound) {
|
|
2018
|
+
if (markdown && tool === 'underline')
|
|
2019
|
+
continue;
|
|
2020
|
+
if (tool === '|' && (out.length === 0 || out[out.length - 1] === '|'))
|
|
2021
|
+
continue;
|
|
2022
|
+
if (tool !== '|' && out.includes(tool))
|
|
2023
|
+
continue;
|
|
2024
|
+
out.push(tool);
|
|
2025
|
+
}
|
|
2026
|
+
if (out[out.length - 1] === '|')
|
|
2027
|
+
out.pop();
|
|
2028
|
+
return out;
|
|
2029
|
+
}, /* @ts-ignore */
|
|
2030
|
+
...(ngDevMode ? [{ debugName: "tools" }] : /* istanbul ignore next */ []));
|
|
2031
|
+
/** The tools that are buttons, in order — what the roving tab stop moves over. */
|
|
2032
|
+
buttons = computed(() => this.tools().filter((tool) => tool !== '|'), /* @ts-ignore */
|
|
2033
|
+
...(ngDevMode ? [{ debugName: "buttons" }] : /* istanbul ignore next */ []));
|
|
2034
|
+
/** Each drawn entry's index among the buttons, `-1` for a separator. @internal */
|
|
2035
|
+
buttonIndex = computed(() => {
|
|
2036
|
+
let index = 0;
|
|
2037
|
+
return this.tools().map(tool => (tool === '|' ? -1 : index++));
|
|
2038
|
+
}, /* @ts-ignore */
|
|
2039
|
+
...(ngDevMode ? [{ debugName: "buttonIndex" }] : /* istanbul ignore next */ []));
|
|
2040
|
+
commands = computed(() => {
|
|
2041
|
+
const schema = editorSchema(this.resolvedFormat());
|
|
2042
|
+
return new Map(this.buttons().map(tool => [tool, toolCommand(tool, schema)]));
|
|
2043
|
+
}, /* @ts-ignore */
|
|
2044
|
+
...(ngDevMode ? [{ debugName: "commands" }] : /* istanbul ignore next */ []));
|
|
2045
|
+
/** Every button's pressed and available state at the current selection. @internal */
|
|
2046
|
+
toolStates = computed(() => {
|
|
2047
|
+
const state = this.editorState();
|
|
2048
|
+
const editable = this.canEdit();
|
|
2049
|
+
const commands = this.commands();
|
|
2050
|
+
return new Map(this.buttons().map(tool => {
|
|
2051
|
+
const command = commands.get(tool);
|
|
2052
|
+
return [
|
|
2053
|
+
tool,
|
|
2054
|
+
{
|
|
2055
|
+
tool,
|
|
2056
|
+
active: !!state && toolActive(tool, state),
|
|
2057
|
+
available: !!state && editable && !!command && command(state),
|
|
2058
|
+
},
|
|
2059
|
+
];
|
|
2060
|
+
}));
|
|
2061
|
+
}, /* @ts-ignore */
|
|
2062
|
+
...(ngDevMode ? [{ debugName: "toolStates" }] : /* istanbul ignore next */ []));
|
|
2063
|
+
rovingIndex = signal(0, /* @ts-ignore */
|
|
2064
|
+
...(ngDevMode ? [{ debugName: "rovingIndex" }] : /* istanbul ignore next */ []));
|
|
2065
|
+
/**
|
|
2066
|
+
* The one button in the tab order. It follows the user, and moves off a tool
|
|
2067
|
+
* that stopped applying so the toolbar never loses its tab stop — unless NO
|
|
2068
|
+
* tool applies (read-only, disabled), where it stays put, because there is
|
|
2069
|
+
* nothing to reach.
|
|
2070
|
+
*/
|
|
2071
|
+
tabStop = computed(() => {
|
|
2072
|
+
const buttons = this.buttons();
|
|
2073
|
+
const states = this.toolStates();
|
|
2074
|
+
const current = Math.min(this.rovingIndex(), Math.max(buttons.length - 1, 0));
|
|
2075
|
+
if (states.get(buttons[current])?.available)
|
|
2076
|
+
return current;
|
|
2077
|
+
const first = buttons.findIndex(tool => states.get(tool)?.available);
|
|
2078
|
+
return first >= 0 ? first : current;
|
|
2079
|
+
}, /* @ts-ignore */
|
|
2080
|
+
...(ngDevMode ? [{ debugName: "tabStop" }] : /* istanbul ignore next */ []));
|
|
2081
|
+
/** Whether the live document is empty — pre-mount, whether the value is. */
|
|
2082
|
+
empty = computed(() => {
|
|
2083
|
+
const state = this.editorState();
|
|
2084
|
+
if (state)
|
|
2085
|
+
return isEmptyDoc(state.doc);
|
|
2086
|
+
const value = this.value();
|
|
2087
|
+
return value === null || value === undefined || value === '';
|
|
2088
|
+
}, /* @ts-ignore */
|
|
2089
|
+
...(ngDevMode ? [{ debugName: "empty" }] : /* istanbul ignore next */ []));
|
|
2090
|
+
/** The task-item words, handed to ProseMirror through its node views. */
|
|
2091
|
+
nodeViews = computed(() => ({
|
|
2092
|
+
list_item: listItemView({ done: this.taskDoneLabel(), todo: this.taskTodoLabel() }),
|
|
2093
|
+
}), /* @ts-ignore */
|
|
2094
|
+
...(ngDevMode ? [{ debugName: "nodeViews" }] : /* istanbul ignore next */ []));
|
|
2095
|
+
ariaPlaceholder = computed(() => (this.placeholder() && this.empty() ? this.placeholder() : null), /* @ts-ignore */
|
|
2096
|
+
...(ngDevMode ? [{ debugName: "ariaPlaceholder" }] : /* istanbul ignore next */ []));
|
|
2097
|
+
// Link panel
|
|
2098
|
+
linkDraft = signal('', /* @ts-ignore */
|
|
2099
|
+
...(ngDevMode ? [{ debugName: "linkDraft" }] : /* istanbul ignore next */ []));
|
|
2100
|
+
/** Whether the selection is in a link — what the panel's remove button needs. */
|
|
2101
|
+
linkActive = computed(() => this.toolStates().get('link')?.active ?? false, /* @ts-ignore */
|
|
2102
|
+
...(ngDevMode ? [{ debugName: "linkActive" }] : /* istanbul ignore next */ []));
|
|
2103
|
+
/**
|
|
2104
|
+
* The value rendered by Angular until ProseMirror takes over: on the server,
|
|
2105
|
+
* and for the first browser frame. Read from the doc JSON, so it shows exactly
|
|
2106
|
+
* what the schema kept — the same sanitised document the editor will mount.
|
|
2107
|
+
*/
|
|
2108
|
+
preview = computed(() => {
|
|
2109
|
+
if (this.mounted())
|
|
2110
|
+
return null;
|
|
2111
|
+
const format = this.resolvedFormat();
|
|
2112
|
+
try {
|
|
2113
|
+
return readValue(this.value(), format, editorSchema(format), this.document).toJSON();
|
|
2114
|
+
}
|
|
2115
|
+
catch (error) {
|
|
2116
|
+
// The browser warns once, when it mounts; the server has no mount to wait for.
|
|
2117
|
+
if (!this.isBrowser && isDevMode())
|
|
2118
|
+
this.warnRefused(format, error);
|
|
2119
|
+
return null;
|
|
2120
|
+
}
|
|
2121
|
+
}, /* @ts-ignore */
|
|
2122
|
+
...(ngDevMode ? [{ debugName: "preview" }] : /* istanbul ignore next */ []));
|
|
2123
|
+
classes = computed(() => {
|
|
2124
|
+
const parts = ['wr-editor'];
|
|
2125
|
+
if (this.focused())
|
|
2126
|
+
parts.push('wr-editor--focused');
|
|
2127
|
+
if (this.disabled())
|
|
2128
|
+
parts.push('wr-editor--disabled');
|
|
2129
|
+
else if (this.locked())
|
|
2130
|
+
parts.push('wr-editor--readonly');
|
|
2131
|
+
if (this.refused())
|
|
2132
|
+
parts.push('wr-editor--refused');
|
|
2133
|
+
return parts.join(' ');
|
|
2134
|
+
}, /* @ts-ignore */
|
|
2135
|
+
...(ngDevMode ? [{ debugName: "classes" }] : /* istanbul ignore next */ []));
|
|
2136
|
+
constructor() {
|
|
2137
|
+
// Outside writes: a form reset, a server load, a patch. Skipped when the
|
|
2138
|
+
// value is the document already on screen — see `lastSerialized`.
|
|
2139
|
+
effect(() => {
|
|
2140
|
+
const value = this.value();
|
|
2141
|
+
const format = this.resolvedFormat();
|
|
2142
|
+
untracked(() => this.sync(value, format));
|
|
2143
|
+
});
|
|
2144
|
+
// `editable` and the placeholder are functions ProseMirror asks on every
|
|
2145
|
+
// update, so poking it is enough to make it ask again.
|
|
2146
|
+
effect(() => {
|
|
2147
|
+
this.readonly();
|
|
2148
|
+
this.disabled();
|
|
2149
|
+
this.refused();
|
|
2150
|
+
this.placeholder();
|
|
2151
|
+
untracked(() => this.view?.setProps({}));
|
|
2152
|
+
});
|
|
2153
|
+
// The task-item words live in node views, which ProseMirror only redraws
|
|
2154
|
+
// when the `nodeViews` prop itself changes.
|
|
2155
|
+
effect(() => {
|
|
2156
|
+
const nodeViews = this.nodeViews();
|
|
2157
|
+
untracked(() => this.view?.setProps({ nodeViews }));
|
|
2158
|
+
});
|
|
2159
|
+
// The roving tab stop is written to the DOM rather than bound: `wr-btn`
|
|
2160
|
+
// binds `[attr.tabindex]` on its host (`null` for a native button), and a
|
|
2161
|
+
// host binding runs after the template's and removes the attribute on the
|
|
2162
|
+
// first pass. Nothing rewrites it after that, so a write after render sticks.
|
|
2163
|
+
afterRenderEffect({
|
|
2164
|
+
write: () => {
|
|
2165
|
+
const stop = this.tabStop();
|
|
2166
|
+
this.toolButtons().forEach((ref, index) => (ref.nativeElement.tabIndex = index === stop ? 0 : -1));
|
|
2167
|
+
},
|
|
2168
|
+
});
|
|
2169
|
+
// `afterNextRender` alone is not the guard: it keys on the global server
|
|
2170
|
+
// flag, not on `PLATFORM_ID`, so a platform test would still run it.
|
|
2171
|
+
if (!this.isBrowser)
|
|
2172
|
+
return;
|
|
2173
|
+
afterNextRender(() => this.mount());
|
|
2174
|
+
}
|
|
2175
|
+
/** Focus the text surface — what Signal Forms calls to focus this control. */
|
|
2176
|
+
focus(options) {
|
|
2177
|
+
const view = this.view;
|
|
2178
|
+
// ProseMirror's own `focus()` is a no-op while the view is not editable,
|
|
2179
|
+
// and read-only has to stay focusable.
|
|
2180
|
+
if (view?.editable)
|
|
2181
|
+
view.focus();
|
|
2182
|
+
else
|
|
2183
|
+
this.surface().nativeElement.focus(options);
|
|
2184
|
+
}
|
|
2185
|
+
// Template handlers
|
|
2186
|
+
label(tool) {
|
|
2187
|
+
return this.toolLabels[tool]();
|
|
2188
|
+
}
|
|
2189
|
+
icon(tool) {
|
|
2190
|
+
return TOOL_ICONS[tool];
|
|
2191
|
+
}
|
|
2192
|
+
isToggle(tool) {
|
|
2193
|
+
return TOGGLES.has(tool);
|
|
2194
|
+
}
|
|
2195
|
+
/** The tooltip: the label, with the shortcut when the tool has one. */
|
|
2196
|
+
tooltip(tool) {
|
|
2197
|
+
const shortcut = SHORTCUTS[tool];
|
|
2198
|
+
if (!shortcut)
|
|
2199
|
+
return this.label(tool);
|
|
2200
|
+
const keys = this.apple()
|
|
2201
|
+
? `${shortcut.alt ? '⌥' : ''}${shortcut.shift ? '⇧' : ''}⌘${shortcut.key}`
|
|
2202
|
+
: [
|
|
2203
|
+
this.keyCtrl(),
|
|
2204
|
+
...(shortcut.alt ? [this.keyAlt()] : []),
|
|
2205
|
+
...(shortcut.shift ? [this.keyShift()] : []),
|
|
2206
|
+
shortcut.key,
|
|
2207
|
+
].join('+');
|
|
2208
|
+
return this.shortcutLabel({ label: this.label(tool), keys });
|
|
2209
|
+
}
|
|
2210
|
+
/** `aria-keyshortcuts` — key names from the ARIA spec, never localised. */
|
|
2211
|
+
keyShortcuts(tool) {
|
|
2212
|
+
const shortcut = SHORTCUTS[tool];
|
|
2213
|
+
if (!shortcut)
|
|
2214
|
+
return null;
|
|
2215
|
+
const mod = this.apple() ? 'Meta' : 'Control';
|
|
2216
|
+
const combo = `${mod}+${shortcut.alt ? 'Alt+' : ''}${shortcut.shift ? 'Shift+' : ''}${shortcut.key}`;
|
|
2217
|
+
return tool === 'redo' && !this.apple() ? `${combo} Control+Y` : combo;
|
|
2218
|
+
}
|
|
2219
|
+
state(tool) {
|
|
2220
|
+
return this.toolStates().get(tool);
|
|
2221
|
+
}
|
|
2222
|
+
/**
|
|
2223
|
+
* A toolbar button was activated.
|
|
2224
|
+
*
|
|
2225
|
+
* By pointer, focus never left the text (`mousedown` is cancelled), so the
|
|
2226
|
+
* caret is put back where the command left it. By keyboard, focus stays on the
|
|
2227
|
+
* button, as the toolbar pattern asks — unless the tool no longer applies,
|
|
2228
|
+
* which disables it: a disabled button drops its focus on `<body>`, so the text
|
|
2229
|
+
* takes it first.
|
|
2230
|
+
*/
|
|
2231
|
+
onToolClick(tool, event) {
|
|
2232
|
+
const view = this.view;
|
|
2233
|
+
const command = this.commands().get(tool);
|
|
2234
|
+
if (!view || !command || !this.canEdit())
|
|
2235
|
+
return;
|
|
2236
|
+
command(view.state, view.dispatch, view);
|
|
2237
|
+
if (event.detail !== 0 || !this.state(tool)?.available)
|
|
2238
|
+
view.focus();
|
|
2239
|
+
}
|
|
2240
|
+
onToolFocus(index) {
|
|
2241
|
+
this.rovingIndex.set(index);
|
|
2242
|
+
}
|
|
2243
|
+
/** The APG toolbar keys: the arrows rove and wrap, Home and End jump. */
|
|
2244
|
+
onToolbarKeydown(event) {
|
|
2245
|
+
if (isComposing(event) || event.altKey || event.ctrlKey || event.metaKey)
|
|
2246
|
+
return;
|
|
2247
|
+
const buttons = this.toolButtons().map(ref => ref.nativeElement);
|
|
2248
|
+
const current = buttons.indexOf(event.target);
|
|
2249
|
+
if (current < 0)
|
|
2250
|
+
return;
|
|
2251
|
+
const reachable = buttons.map((button, index) => (button.disabled ? -1 : index)).filter(index => index >= 0);
|
|
2252
|
+
if (!reachable.length)
|
|
2253
|
+
return;
|
|
2254
|
+
const rtl = this.dir?.value === 'rtl';
|
|
2255
|
+
const at = reachable.indexOf(current);
|
|
2256
|
+
let next;
|
|
2257
|
+
switch (event.key) {
|
|
2258
|
+
case 'ArrowRight':
|
|
2259
|
+
next = reachable[(at + (rtl ? -1 : 1) + reachable.length) % reachable.length];
|
|
2260
|
+
break;
|
|
2261
|
+
case 'ArrowLeft':
|
|
2262
|
+
next = reachable[(at + (rtl ? 1 : -1) + reachable.length) % reachable.length];
|
|
2263
|
+
break;
|
|
2264
|
+
case 'Home':
|
|
2265
|
+
next = reachable[0];
|
|
2266
|
+
break;
|
|
2267
|
+
case 'End':
|
|
2268
|
+
next = reachable[reachable.length - 1];
|
|
2269
|
+
break;
|
|
2270
|
+
default:
|
|
2271
|
+
return;
|
|
2272
|
+
}
|
|
2273
|
+
event.preventDefault();
|
|
2274
|
+
this.rovingIndex.set(next);
|
|
2275
|
+
buttons[next].focus();
|
|
2276
|
+
}
|
|
2277
|
+
/** `touch` once focus leaves the whole control — not when it moves between its own parts. */
|
|
2278
|
+
onFocusOut(event) {
|
|
2279
|
+
const next = event.relatedTarget;
|
|
2280
|
+
if (next && this.withinControl(next))
|
|
2281
|
+
return;
|
|
2282
|
+
const fromPanel = this.inLinkPanel(event.target);
|
|
2283
|
+
// Chromium fires a `focusout` with no `relatedTarget` when the focused
|
|
2284
|
+
// element is REMOVED — the link panel closing with focus inside it, or its
|
|
2285
|
+
// remove button leaving — and the popover hands focus back only after that.
|
|
2286
|
+
// It does the same when Tab runs off the end of the document, which is where
|
|
2287
|
+
// the overlay container sits. So where focus went is decided once it has
|
|
2288
|
+
// settled, or Escape on the panel marked the field touched with focus never
|
|
2289
|
+
// leaving the control.
|
|
2290
|
+
if (!next && fromPanel) {
|
|
2291
|
+
afterNextRender(() => {
|
|
2292
|
+
const active = this.document.activeElement;
|
|
2293
|
+
if (active && this.withinControl(active))
|
|
2294
|
+
return;
|
|
2295
|
+
this.leaveLinkPanel();
|
|
2296
|
+
this.touch.emit();
|
|
2297
|
+
}, { injector: this.injector });
|
|
2298
|
+
return;
|
|
2299
|
+
}
|
|
2300
|
+
if (next && fromPanel)
|
|
2301
|
+
this.leaveLinkPanel();
|
|
2302
|
+
this.touch.emit();
|
|
2303
|
+
}
|
|
2304
|
+
onLinkOpened() {
|
|
2305
|
+
const state = this.view?.state;
|
|
2306
|
+
const range = state ? linkRange(state) : null;
|
|
2307
|
+
this.linkDraft.set(range ? String(range.mark.attrs['href'] ?? '') : '');
|
|
2308
|
+
this.linkField.invalid.set(false);
|
|
2309
|
+
afterNextRender(() => this.focusLinkInput(), { injector: this.injector });
|
|
2310
|
+
}
|
|
2311
|
+
onLinkClosed() {
|
|
2312
|
+
// Back to the text when the panel was opened from it (Mod-K) or when a link
|
|
2313
|
+
// was just applied; otherwise the popover has already handed focus back to
|
|
2314
|
+
// the toolbar button, which is where Escape should leave it.
|
|
2315
|
+
//
|
|
2316
|
+
// Only while focus is where the popover left it — on the link button — or
|
|
2317
|
+
// nowhere. A click elsewhere on the page closes the panel too, and by then
|
|
2318
|
+
// it has put focus where the user wanted it; taking it back sent their next
|
|
2319
|
+
// keystrokes into this editor.
|
|
2320
|
+
const toSurface = this.linkToSurface;
|
|
2321
|
+
this.linkToSurface = false;
|
|
2322
|
+
this.linkField.invalid.set(false);
|
|
2323
|
+
if (!toSurface)
|
|
2324
|
+
return;
|
|
2325
|
+
const active = this.document.activeElement;
|
|
2326
|
+
const linkButton = this.toolButtons()[this.buttons().indexOf('link')]?.nativeElement;
|
|
2327
|
+
if (!active || active === this.document.body || active === linkButton)
|
|
2328
|
+
this.view?.focus();
|
|
2329
|
+
}
|
|
2330
|
+
onLinkInput(event) {
|
|
2331
|
+
this.linkDraft.set(event.target.value);
|
|
2332
|
+
this.linkField.invalid.set(false);
|
|
2333
|
+
}
|
|
2334
|
+
applyLink(event) {
|
|
2335
|
+
event.preventDefault();
|
|
2336
|
+
const view = this.view;
|
|
2337
|
+
if (!view || !this.canEdit())
|
|
2338
|
+
return;
|
|
2339
|
+
const draft = this.linkDraft().trim();
|
|
2340
|
+
// An emptied field on an existing link means "no link".
|
|
2341
|
+
if (!draft && this.linkActive()) {
|
|
2342
|
+
this.removeLink();
|
|
2343
|
+
return;
|
|
2344
|
+
}
|
|
2345
|
+
const href = draft ? safeMarkdownUrl(draft, 'link') : null;
|
|
2346
|
+
if (!href) {
|
|
2347
|
+
this.linkField.invalid.set(true);
|
|
2348
|
+
this.focusLinkInput();
|
|
2349
|
+
return;
|
|
2350
|
+
}
|
|
2351
|
+
if (!setLink(href)(view.state, view.dispatch))
|
|
2352
|
+
return;
|
|
2353
|
+
this.linkToSurface = true;
|
|
2354
|
+
this.linkPopover()?.close();
|
|
2355
|
+
}
|
|
2356
|
+
removeLink() {
|
|
2357
|
+
const view = this.view;
|
|
2358
|
+
if (!view || !this.canEdit())
|
|
2359
|
+
return;
|
|
2360
|
+
removeLink(view.state, view.dispatch);
|
|
2361
|
+
this.linkToSurface = true;
|
|
2362
|
+
this.linkPopover()?.close();
|
|
2363
|
+
}
|
|
2364
|
+
// Preview helpers — the template's typed view of untyped JSON.
|
|
2365
|
+
nodesOf(raw) {
|
|
2366
|
+
return Array.isArray(raw) ? raw : [];
|
|
2367
|
+
}
|
|
2368
|
+
nodeOf(raw) {
|
|
2369
|
+
return raw;
|
|
2370
|
+
}
|
|
2371
|
+
markAt(node, depth) {
|
|
2372
|
+
return node.marks?.[depth] ?? null;
|
|
2373
|
+
}
|
|
2374
|
+
attr(node, name) {
|
|
2375
|
+
return node.attrs?.[name] ?? null;
|
|
2376
|
+
}
|
|
2377
|
+
text(node, name) {
|
|
2378
|
+
const value = node.attrs?.[name];
|
|
2379
|
+
return typeof value === 'string' ? value : null;
|
|
2380
|
+
}
|
|
2381
|
+
textOf(node) {
|
|
2382
|
+
return (node.content ?? []).map(child => child.text ?? '').join('');
|
|
2383
|
+
}
|
|
2384
|
+
// Internals
|
|
2385
|
+
/** Whether an edit may happen at all — not while read-only, disabled or refused. */
|
|
2386
|
+
canEdit() {
|
|
2387
|
+
return !this.readonly() && !this.disabled() && !this.refused();
|
|
2388
|
+
}
|
|
2389
|
+
mount() {
|
|
2390
|
+
this.apple.set((this.platform.userAgent ?? '').toLowerCase().includes('mac'));
|
|
2391
|
+
const format = untracked(this.resolvedFormat);
|
|
2392
|
+
const value = untracked(this.value);
|
|
2393
|
+
const schema = editorSchema(format);
|
|
2394
|
+
let doc;
|
|
2395
|
+
try {
|
|
2396
|
+
doc = readValue(value, format, schema, this.document);
|
|
2397
|
+
}
|
|
2398
|
+
catch (error) {
|
|
2399
|
+
if (isDevMode())
|
|
2400
|
+
this.warnRefused(format, error);
|
|
2401
|
+
doc = schema.topNodeType.createAndFill();
|
|
2402
|
+
// An empty document over a stored value: see `refused`.
|
|
2403
|
+
this.refused.set(true);
|
|
2404
|
+
}
|
|
2405
|
+
const state = this.createState(doc);
|
|
2406
|
+
this.view = new EditorView({ mount: this.surface().nativeElement }, {
|
|
2407
|
+
state,
|
|
2408
|
+
editable: () => untracked(() => this.canEdit()),
|
|
2409
|
+
dispatchTransaction: tr => this.dispatch(tr),
|
|
2410
|
+
decorations: current => this.placeholderDecorations(current),
|
|
2411
|
+
nodeViews: untracked(this.nodeViews),
|
|
2412
|
+
handleKeyDown: (_view, event) => this.onSurfaceKeydown(event),
|
|
2413
|
+
});
|
|
2414
|
+
this.viewFormat = format;
|
|
2415
|
+
this.lastSerialized = value;
|
|
2416
|
+
this.editorState.set(state);
|
|
2417
|
+
this.mounted.set(true);
|
|
2418
|
+
this.destroyRef.onDestroy(() => {
|
|
2419
|
+
this.view?.destroy();
|
|
2420
|
+
this.view = null;
|
|
2421
|
+
});
|
|
2422
|
+
}
|
|
2423
|
+
createState(doc) {
|
|
2424
|
+
const current = this.view?.state;
|
|
2425
|
+
const plugins = current?.schema === doc.type.schema
|
|
2426
|
+
? current.plugins
|
|
2427
|
+
: editorPlugins(doc.type.schema, {
|
|
2428
|
+
apple: untracked(this.apple),
|
|
2429
|
+
canEdit: () => untracked(() => this.canEdit()),
|
|
2430
|
+
});
|
|
2431
|
+
return EditorState.create({ doc, plugins });
|
|
2432
|
+
}
|
|
2433
|
+
dispatch(tr) {
|
|
2434
|
+
const view = this.view;
|
|
2435
|
+
if (!view)
|
|
2436
|
+
return;
|
|
2437
|
+
const previous = view.state;
|
|
2438
|
+
const next = previous.apply(tr);
|
|
2439
|
+
view.updateState(next);
|
|
2440
|
+
this.editorState.set(next);
|
|
2441
|
+
// A refused transaction leaves the document as it was even though it asked
|
|
2442
|
+
// to change it; that is not an edit, and must not reach the model.
|
|
2443
|
+
if (next.doc !== previous.doc)
|
|
2444
|
+
this.emit(next.doc);
|
|
2445
|
+
}
|
|
2446
|
+
emit(doc) {
|
|
2447
|
+
const out = writeValue(doc, this.viewFormat, this.document);
|
|
2448
|
+
if (out === this.lastSerialized)
|
|
2449
|
+
return;
|
|
2450
|
+
this.lastSerialized = out;
|
|
2451
|
+
this.value.set(out);
|
|
2452
|
+
}
|
|
2453
|
+
/**
|
|
2454
|
+
* Bring an outside write into the view.
|
|
2455
|
+
*
|
|
2456
|
+
* Compared as DOCUMENTS, never as strings: `<b>` comes back as `<strong>`, so a
|
|
2457
|
+
* normalised string differs from what the consumer wrote while the documents
|
|
2458
|
+
* are equal — and an equal document is left alone, selection and history
|
|
2459
|
+
* included. A different one replaces the state wholesale, history with it,
|
|
2460
|
+
* which is right for a form reset or a server load: undo back into content the
|
|
2461
|
+
* app replaced would be wrong. The normalised form is never written back —
|
|
2462
|
+
* that would mark the field dirty on load. A write that arrives mid-composition
|
|
2463
|
+
* (IME) replaces the composition too: the model is the source of truth.
|
|
2464
|
+
*/
|
|
2465
|
+
sync(value, format) {
|
|
2466
|
+
const view = this.view;
|
|
2467
|
+
if (!view)
|
|
2468
|
+
return;
|
|
2469
|
+
if (format === this.viewFormat && value === this.lastSerialized)
|
|
2470
|
+
return;
|
|
2471
|
+
const schema = editorSchema(format);
|
|
2472
|
+
let doc;
|
|
2473
|
+
try {
|
|
2474
|
+
doc = readValue(value, format, schema, this.document);
|
|
2475
|
+
}
|
|
2476
|
+
catch (error) {
|
|
2477
|
+
if (isDevMode())
|
|
2478
|
+
this.warnRefused(format, error);
|
|
2479
|
+
// Only the format moved, and the screen still shows this very value read
|
|
2480
|
+
// in the old one: carried across, an edit converts it and loses nothing.
|
|
2481
|
+
// Any other refusal leaves the model holding what the screen does not show.
|
|
2482
|
+
const carried = format !== this.viewFormat && value === this.lastSerialized && !this.refused();
|
|
2483
|
+
if (format !== this.viewFormat)
|
|
2484
|
+
this.switchFormat(format, schema);
|
|
2485
|
+
this.refused.set(!carried);
|
|
2486
|
+
return;
|
|
2487
|
+
}
|
|
2488
|
+
this.refused.set(false);
|
|
2489
|
+
this.lastSerialized = value;
|
|
2490
|
+
if (format === this.viewFormat && doc.eq(view.state.doc))
|
|
2491
|
+
return;
|
|
2492
|
+
this.viewFormat = format;
|
|
2493
|
+
this.replaceState(doc);
|
|
2494
|
+
}
|
|
2495
|
+
/**
|
|
2496
|
+
* A new format whose value could not be read: keep the document, moved into
|
|
2497
|
+
* the new format's schema — markdown's lacks underline, so that mark is
|
|
2498
|
+
* dropped on the way. What is written from here on is in the new format.
|
|
2499
|
+
*/
|
|
2500
|
+
switchFormat(format, schema) {
|
|
2501
|
+
const view = this.view;
|
|
2502
|
+
this.viewFormat = format;
|
|
2503
|
+
if (view.state.schema === schema)
|
|
2504
|
+
return;
|
|
2505
|
+
let doc;
|
|
2506
|
+
try {
|
|
2507
|
+
doc = schema.nodeFromJSON(withoutMarks(view.state.doc.toJSON(), schema.marks));
|
|
2508
|
+
doc.check();
|
|
2509
|
+
}
|
|
2510
|
+
catch {
|
|
2511
|
+
doc = schema.topNodeType.createAndFill();
|
|
2512
|
+
}
|
|
2513
|
+
this.replaceState(doc);
|
|
2514
|
+
}
|
|
2515
|
+
replaceState(doc) {
|
|
2516
|
+
const state = this.createState(doc);
|
|
2517
|
+
this.view.updateState(state);
|
|
2518
|
+
this.editorState.set(state);
|
|
2519
|
+
}
|
|
2520
|
+
placeholderDecorations(state) {
|
|
2521
|
+
const text = untracked(this.placeholder);
|
|
2522
|
+
if (!text || !isEmptyDoc(state.doc))
|
|
2523
|
+
return null;
|
|
2524
|
+
const first = state.doc.firstChild;
|
|
2525
|
+
return DecorationSet.create(state.doc, [
|
|
2526
|
+
Decoration.node(0, first.nodeSize, { class: 'wr-editor__placeholder', 'data-placeholder': text }),
|
|
2527
|
+
]);
|
|
2528
|
+
}
|
|
2529
|
+
/**
|
|
2530
|
+
* Mod-K opens the link panel. Its propagation stops here: `<wr-command-palette>`
|
|
2531
|
+
* binds the same chord on the document, and `WrHotkey` deliberately does not
|
|
2532
|
+
* skip an event another listener already handled.
|
|
2533
|
+
*/
|
|
2534
|
+
onSurfaceKeydown(event) {
|
|
2535
|
+
if (isComposing(event) || event.altKey || event.shiftKey)
|
|
2536
|
+
return false;
|
|
2537
|
+
const mod = untracked(this.apple) ? event.metaKey && !event.ctrlKey : event.ctrlKey && !event.metaKey;
|
|
2538
|
+
if (!mod || (event.key.toLowerCase() !== 'k' && event.code !== 'KeyK'))
|
|
2539
|
+
return false;
|
|
2540
|
+
const popover = this.linkPopover();
|
|
2541
|
+
if (!popover || !untracked(this.toolStates).get('link')?.available)
|
|
2542
|
+
return false;
|
|
2543
|
+
event.stopPropagation();
|
|
2544
|
+
this.linkToSurface = true;
|
|
2545
|
+
popover.open();
|
|
2546
|
+
return true;
|
|
2547
|
+
}
|
|
2548
|
+
focusLinkInput() {
|
|
2549
|
+
const input = this.document.getElementById(this.linkField.controlId());
|
|
2550
|
+
input?.focus();
|
|
2551
|
+
input?.select();
|
|
2552
|
+
}
|
|
2553
|
+
/**
|
|
2554
|
+
* Focus left the link panel for somewhere outside the control — Tab, Shift+Tab,
|
|
2555
|
+
* a click elsewhere. The panel is not a focus trap, and left open it stays
|
|
2556
|
+
* anchored to a control the user has left, whose next close would then pull
|
|
2557
|
+
* focus back into the text. Closed with focus left where it went.
|
|
2558
|
+
*/
|
|
2559
|
+
leaveLinkPanel() {
|
|
2560
|
+
this.linkToSurface = false;
|
|
2561
|
+
this.linkPopover()?.close();
|
|
2562
|
+
}
|
|
2563
|
+
/** Whether a node is part of this control: the host, or its link panel. */
|
|
2564
|
+
withinControl(node) {
|
|
2565
|
+
return this.host.nativeElement.contains(node) || this.inLinkPanel(node);
|
|
2566
|
+
}
|
|
2567
|
+
inLinkPanel(node) {
|
|
2568
|
+
const panel = this.document.getElementById(this.linkField.panelId);
|
|
2569
|
+
if (!panel)
|
|
2570
|
+
return false;
|
|
2571
|
+
// The popover focuses its own pane before the input takes over.
|
|
2572
|
+
return panel.contains(node) || node === panel.closest('.cdk-overlay-pane');
|
|
2573
|
+
}
|
|
2574
|
+
warnRefused(format, error) {
|
|
2575
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
2576
|
+
warn(`the bound value could not be read as ${format} and was ignored — the editor keeps the document it had and ` +
|
|
2577
|
+
`refuses edits until it is given a value it can read, so the stored one is not overwritten. ${reason}`);
|
|
2578
|
+
}
|
|
2579
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.7", ngImport: i0, type: WrEditor, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
2580
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.1.7", type: WrEditor, isStandalone: true, selector: "wr-editor", inputs: { value: { classPropertyName: "value", publicName: "value", isSignal: true, isRequired: false, transformFunction: null }, disabled: { classPropertyName: "disabled", publicName: "disabled", isSignal: true, isRequired: false, transformFunction: null }, readonly: { classPropertyName: "readonly", publicName: "readonly", isSignal: true, isRequired: false, transformFunction: null }, placeholder: { classPropertyName: "placeholder", publicName: "placeholder", isSignal: true, isRequired: false, transformFunction: null }, ariaLabel: { classPropertyName: "ariaLabel", publicName: "ariaLabel", isSignal: true, isRequired: false, transformFunction: null }, format: { classPropertyName: "format", publicName: "format", isSignal: true, isRequired: false, transformFunction: null }, toolbar: { classPropertyName: "toolbar", publicName: "toolbar", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { value: "valueChange", touch: "touch" }, host: { listeners: { "focusout": "onFocusOut($event)" }, properties: { "class": "classes()" } }, providers: [
|
|
2581
|
+
{ provide: WrEditorLinkField, useFactory: () => new WrEditorLinkField() },
|
|
2582
|
+
{ provide: WR_FORM_FIELD, useExisting: WrEditorLinkField },
|
|
2583
|
+
], viewQueries: [{ propertyName: "surface", first: true, predicate: ["surface"], descendants: true, isSignal: true }, { propertyName: "toolButtons", predicate: ["tool"], descendants: true, read: ElementRef, isSignal: true }, { propertyName: "linkPopover", first: true, predicate: ["linkPopover"], descendants: true, isSignal: true }], ngImport: i0, template: "@if (tools().length) {\n <div class=\"wr-editor__toolbar\" role=\"toolbar\" [attr.aria-label]=\"toolbarLabel()\" [attr.aria-controls]=\"surfaceId\">\n @for (item of tools(); track $index; let n = $index) {\n @if (item === '|') {\n <span class=\"wr-editor__separator\" aria-hidden=\"true\"></span>\n } @else if (item === 'link') {\n <!-- The link button hosts the link panel, so it carries the popover's own\n `aria-haspopup` / `aria-expanded` / `aria-controls` \u2014 and no tooltip,\n since one element cannot host `[wrPopover]` twice; its name and\n `aria-keyshortcuts` still say what the others' tooltips say. No\n `aria-pressed` either: a button that opens a popup is not a toggle,\n and the panel's remove button is where \"in a link\" is announced. -->\n <button\n #tool\n #linkPopover=\"wrPopover\"\n wr-btn\n type=\"button\"\n class=\"wr-editor__tool\"\n [class.wr-editor__tool--active]=\"state(item)?.active\"\n [disabled]=\"!state(item)?.available\"\n [attr.aria-label]=\"label(item)\"\n [attr.aria-keyshortcuts]=\"keyShortcuts(item)\"\n [wrPopover]=\"linkPanel\"\n [ariaLabel]=\"label(item)\"\n (opened)=\"onLinkOpened()\"\n (closed)=\"onLinkClosed()\"\n (focus)=\"onToolFocus(buttonIndex()[n])\"\n (keydown)=\"onToolbarKeydown($event)\"\n (mousedown)=\"$event.preventDefault()\"\n >\n <svg\n class=\"wr-editor__icon\"\n xmlns=\"http://www.w3.org/2000/svg\"\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n stroke-width=\"2\"\n stroke-linecap=\"round\"\n stroke-linejoin=\"round\"\n aria-hidden=\"true\"\n >\n @for (d of icon(item); track $index) {\n <path [attr.d]=\"d\" />\n }\n </svg>\n </button>\n } @else {\n <button\n #tool\n wr-btn\n type=\"button\"\n class=\"wr-editor__tool\"\n mode=\"tooltip\"\n [class.wr-editor__tool--active]=\"state(item)?.active\"\n [disabled]=\"!state(item)?.available\"\n [attr.aria-pressed]=\"isToggle(item) ? (state(item)?.active ? 'true' : 'false') : null\"\n [attr.aria-label]=\"label(item)\"\n [attr.aria-keyshortcuts]=\"keyShortcuts(item)\"\n [wrPopover]=\"tooltip(item)\"\n (click)=\"onToolClick(item, $event)\"\n (focus)=\"onToolFocus(buttonIndex()[n])\"\n (keydown)=\"onToolbarKeydown($event)\"\n (mousedown)=\"$event.preventDefault()\"\n >\n <svg\n class=\"wr-editor__icon\"\n xmlns=\"http://www.w3.org/2000/svg\"\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n stroke-width=\"2\"\n stroke-linecap=\"round\"\n stroke-linejoin=\"round\"\n aria-hidden=\"true\"\n >\n @for (d of icon(item); track $index) {\n <path [attr.d]=\"d\" />\n }\n </svg>\n </button>\n }\n }\n </div>\n}\n\n<!--\n The static preview: what Angular renders until ProseMirror mounts \u2014 on the\n server, and for the first browser frame. It is a SIBLING of the mount surface,\n never inside it: `{ mount }` replaces the element's children, and nodes Angular\n owns would be deleted out from under the next change detection.\n-->\n@if (preview(); as doc) {\n <div class=\"wr-editor__preview wr-editor__content\">\n <ng-container [ngTemplateOutlet]=\"previewBlocks\" [ngTemplateOutletContext]=\"{ $implicit: doc.content }\" />\n </div>\n}\n\n<!--\n The mount surface. Angular binds every attribute it needs, so the prerendered\n page and the synchronous first render already carry the role, the name and the\n field's error state; ProseMirror adds only its own class, `contenteditable` and\n `translate`, and never touches an attribute it did not set. No Angular child\n ever lives in here.\n-->\n<div\n #surface\n class=\"wr-editor__surface wr-editor__content\"\n role=\"textbox\"\n aria-multiline=\"true\"\n [id]=\"surfaceId\"\n [hidden]=\"!mounted()\"\n [attr.aria-label]=\"accessibleName()\"\n [attr.aria-labelledby]=\"labelledBy()\"\n [attr.aria-describedby]=\"fieldAria.describedBy()\"\n [attr.aria-invalid]=\"fieldAria.ariaInvalid()\"\n [attr.aria-readonly]=\"locked() && !disabled() ? 'true' : null\"\n [attr.aria-disabled]=\"disabled() ? 'true' : null\"\n [attr.aria-placeholder]=\"ariaPlaceholder()\"\n [attr.tabindex]=\"locked() && !disabled() ? 0 : null\"\n (focus)=\"focused.set(true)\"\n (blur)=\"focused.set(false)\"\n></div>\n\n<!--\n The popover styles its panel as `.wr-popover-overlay > *`, which is this form,\n so the form stays a plain block and the layout lives one level in.\n-->\n<ng-template #linkPanel>\n <form\n class=\"wr-editor-link\"\n novalidate\n [id]=\"linkField.panelId\"\n (submit)=\"applyLink($event)\"\n (focusout)=\"onFocusOut($event)\"\n >\n <div class=\"wr-editor-link__body\">\n <label class=\"wr-editor-link__label\" [attr.for]=\"linkField.controlId()\">{{ linkUrlLabel() }}</label>\n <input\n wrInput\n class=\"wr-editor-link__input\"\n type=\"text\"\n inputmode=\"url\"\n autocomplete=\"url\"\n spellcheck=\"false\"\n [value]=\"linkDraft()\"\n (input)=\"onLinkInput($event)\"\n />\n @if (linkField.invalid()) {\n <p class=\"wr-editor-link__error\" role=\"alert\" [id]=\"linkField.errorId\">{{ linkInvalidLabel() }}</p>\n }\n <div class=\"wr-editor-link__actions\">\n @if (linkActive()) {\n <button wr-btn type=\"button\" class=\"wr-editor-link__remove\" (click)=\"removeLink()\">\n {{ linkRemoveLabel() }}\n </button>\n }\n <button wr-btn type=\"submit\" color=\"primary\" class=\"wr-editor-link__apply\">{{ linkApplyLabel() }}</button>\n </div>\n </div>\n </form>\n</ng-template>\n\n<!--\n Recursion runs through `ngTemplateOutlet`, as in `<wr-markdown>`, so the preview\n is exactly the document with no wrapper between a `<ul>` and its `<li>`.\n-->\n<ng-template #previewBlocks let-raw>\n @for (node of nodesOf(raw); track $index) {\n @switch (node.type) {\n @case ('paragraph') {\n <p [class.wr-editor__placeholder]=\"ariaPlaceholder() !== null\" [attr.data-placeholder]=\"ariaPlaceholder()\">\n <ng-container [ngTemplateOutlet]=\"previewInline\" [ngTemplateOutletContext]=\"{ $implicit: node.content }\" />\n </p>\n }\n @case ('heading') {\n @switch (attr(node, 'level')) {\n @case (2) {\n <h2>\n <ng-container\n [ngTemplateOutlet]=\"previewInline\"\n [ngTemplateOutletContext]=\"{ $implicit: node.content }\"\n />\n </h2>\n }\n @case (3) {\n <h3>\n <ng-container\n [ngTemplateOutlet]=\"previewInline\"\n [ngTemplateOutletContext]=\"{ $implicit: node.content }\"\n />\n </h3>\n }\n @default {\n <h1>\n <ng-container\n [ngTemplateOutlet]=\"previewInline\"\n [ngTemplateOutletContext]=\"{ $implicit: node.content }\"\n />\n </h1>\n }\n }\n }\n @case ('blockquote') {\n <blockquote>\n <ng-container [ngTemplateOutlet]=\"previewBlocks\" [ngTemplateOutletContext]=\"{ $implicit: node.content }\" />\n </blockquote>\n }\n @case ('code_block') {\n <pre><code>{{ textOf(node) }}</code></pre>\n }\n @case ('horizontal_rule') {\n <hr />\n }\n @case ('bullet_list') {\n <ul>\n <ng-container [ngTemplateOutlet]=\"previewBlocks\" [ngTemplateOutletContext]=\"{ $implicit: node.content }\" />\n </ul>\n }\n @case ('ordered_list') {\n <ol [attr.start]=\"attr(node, 'order') === 1 ? null : attr(node, 'order')\">\n <ng-container [ngTemplateOutlet]=\"previewBlocks\" [ngTemplateOutletContext]=\"{ $implicit: node.content }\" />\n </ol>\n }\n @case ('list_item') {\n @let checked = attr(node, 'checked');\n @if (checked === null) {\n <li>\n <ng-container [ngTemplateOutlet]=\"previewBlocks\" [ngTemplateOutletContext]=\"{ $implicit: node.content }\" />\n </li>\n } @else {\n <li class=\"wr-editor__item wr-editor__item--task\" [attr.data-checked]=\"checked\">\n <span class=\"wr-editor__task\" [class.wr-editor__task--checked]=\"checked === true\" aria-hidden=\"true\"></span>\n <span class=\"wr-editor__sr-only\">{{ checked === true ? taskDoneLabel() : taskTodoLabel() }}</span>\n <div class=\"wr-editor__task-body\">\n <ng-container\n [ngTemplateOutlet]=\"previewBlocks\"\n [ngTemplateOutletContext]=\"{ $implicit: node.content }\"\n />\n </div>\n </li>\n }\n }\n @case ('table') {\n <table>\n <tbody>\n <ng-container [ngTemplateOutlet]=\"previewBlocks\" [ngTemplateOutletContext]=\"{ $implicit: node.content }\" />\n </tbody>\n </table>\n }\n @case ('table_row') {\n <tr>\n <ng-container [ngTemplateOutlet]=\"previewBlocks\" [ngTemplateOutletContext]=\"{ $implicit: node.content }\" />\n </tr>\n }\n @case ('table_header') {\n <th [style.text-align]=\"text(node, 'align')\">\n <ng-container [ngTemplateOutlet]=\"previewBlocks\" [ngTemplateOutletContext]=\"{ $implicit: node.content }\" />\n </th>\n }\n @case ('table_cell') {\n <td [style.text-align]=\"text(node, 'align')\">\n <ng-container [ngTemplateOutlet]=\"previewBlocks\" [ngTemplateOutletContext]=\"{ $implicit: node.content }\" />\n </td>\n }\n }\n }\n</ng-template>\n\n<ng-template #previewInline let-raw>\n @for (node of nodesOf(raw); track $index) {\n <ng-container [ngTemplateOutlet]=\"previewMarked\" [ngTemplateOutletContext]=\"{ $implicit: node, depth: 0 }\" />\n }\n</ng-template>\n\n<!-- One mark per level, outermost first \u2014 the order the schema ranks them in. -->\n<ng-template #previewMarked let-raw let-depth=\"depth\">\n @let node = nodeOf(raw);\n @let mark = markAt(node, depth);\n @if (mark) {\n @switch (mark.type) {\n @case ('link') {\n <a [href]=\"text(mark, 'href')\" [attr.title]=\"text(mark, 'title')\" rel=\"noopener noreferrer nofollow\"\n ><ng-container\n [ngTemplateOutlet]=\"previewMarked\"\n [ngTemplateOutletContext]=\"{ $implicit: node, depth: depth + 1 }\"\n /></a>\n }\n @case ('strong') {\n <strong\n ><ng-container\n [ngTemplateOutlet]=\"previewMarked\"\n [ngTemplateOutletContext]=\"{ $implicit: node, depth: depth + 1 }\"\n /></strong>\n }\n @case ('em') {\n <em\n ><ng-container\n [ngTemplateOutlet]=\"previewMarked\"\n [ngTemplateOutletContext]=\"{ $implicit: node, depth: depth + 1 }\"\n /></em>\n }\n @case ('underline') {\n <u\n ><ng-container\n [ngTemplateOutlet]=\"previewMarked\"\n [ngTemplateOutletContext]=\"{ $implicit: node, depth: depth + 1 }\"\n /></u>\n }\n @case ('strike') {\n <s\n ><ng-container\n [ngTemplateOutlet]=\"previewMarked\"\n [ngTemplateOutletContext]=\"{ $implicit: node, depth: depth + 1 }\"\n /></s>\n }\n @case ('code') {\n <code\n ><ng-container\n [ngTemplateOutlet]=\"previewMarked\"\n [ngTemplateOutletContext]=\"{ $implicit: node, depth: depth + 1 }\"\n /></code>\n }\n @default {\n <ng-container\n [ngTemplateOutlet]=\"previewMarked\"\n [ngTemplateOutletContext]=\"{ $implicit: node, depth: depth + 1 }\"\n />\n }\n }\n } @else {\n @switch (node.type) {\n @case ('text') {\n <ng-container>{{ node.text }}</ng-container>\n }\n @case ('hard_break') {\n <br />\n }\n @case ('image') {\n <img [src]=\"text(node, 'src')\" [alt]=\"text(node, 'alt') ?? ''\" [attr.title]=\"text(node, 'title')\" />\n }\n }\n }\n</ng-template>\n", dependencies: [{ kind: "directive", type: NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }, { kind: "component", type: WrButton, selector: "wr-btn, button[wr-btn], a[wr-btn]", inputs: ["color", "size", "shape", "icon", "iconPosition", "disabled", "outlined", "block", "loading", "disabledWhenLoading"] }, { kind: "directive", type: WrInput, selector: "input[wrInput], textarea[wrInput]", inputs: ["size", "rounded"] }, { kind: "directive", type: WrPopover, selector: "[wrPopover]", inputs: ["wrPopover", "mode", "trigger", "position", "showDelay", "hideDelay", "responsive", "ariaLabel"], outputs: ["opened", "closed"], exportAs: ["wrPopover"] }], encapsulation: i0.ViewEncapsulation.None });
|
|
2584
|
+
}
|
|
2585
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.7", ngImport: i0, type: WrEditor, decorators: [{
|
|
2586
|
+
type: Component,
|
|
2587
|
+
args: [{ selector: 'wr-editor', encapsulation: ViewEncapsulation.None, host: {
|
|
2588
|
+
'[class]': 'classes()',
|
|
2589
|
+
'(focusout)': 'onFocusOut($event)',
|
|
2590
|
+
}, providers: [
|
|
2591
|
+
{ provide: WrEditorLinkField, useFactory: () => new WrEditorLinkField() },
|
|
2592
|
+
{ provide: WR_FORM_FIELD, useExisting: WrEditorLinkField },
|
|
2593
|
+
], imports: [NgTemplateOutlet, WrButton, WrInput, WrPopover], template: "@if (tools().length) {\n <div class=\"wr-editor__toolbar\" role=\"toolbar\" [attr.aria-label]=\"toolbarLabel()\" [attr.aria-controls]=\"surfaceId\">\n @for (item of tools(); track $index; let n = $index) {\n @if (item === '|') {\n <span class=\"wr-editor__separator\" aria-hidden=\"true\"></span>\n } @else if (item === 'link') {\n <!-- The link button hosts the link panel, so it carries the popover's own\n `aria-haspopup` / `aria-expanded` / `aria-controls` \u2014 and no tooltip,\n since one element cannot host `[wrPopover]` twice; its name and\n `aria-keyshortcuts` still say what the others' tooltips say. No\n `aria-pressed` either: a button that opens a popup is not a toggle,\n and the panel's remove button is where \"in a link\" is announced. -->\n <button\n #tool\n #linkPopover=\"wrPopover\"\n wr-btn\n type=\"button\"\n class=\"wr-editor__tool\"\n [class.wr-editor__tool--active]=\"state(item)?.active\"\n [disabled]=\"!state(item)?.available\"\n [attr.aria-label]=\"label(item)\"\n [attr.aria-keyshortcuts]=\"keyShortcuts(item)\"\n [wrPopover]=\"linkPanel\"\n [ariaLabel]=\"label(item)\"\n (opened)=\"onLinkOpened()\"\n (closed)=\"onLinkClosed()\"\n (focus)=\"onToolFocus(buttonIndex()[n])\"\n (keydown)=\"onToolbarKeydown($event)\"\n (mousedown)=\"$event.preventDefault()\"\n >\n <svg\n class=\"wr-editor__icon\"\n xmlns=\"http://www.w3.org/2000/svg\"\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n stroke-width=\"2\"\n stroke-linecap=\"round\"\n stroke-linejoin=\"round\"\n aria-hidden=\"true\"\n >\n @for (d of icon(item); track $index) {\n <path [attr.d]=\"d\" />\n }\n </svg>\n </button>\n } @else {\n <button\n #tool\n wr-btn\n type=\"button\"\n class=\"wr-editor__tool\"\n mode=\"tooltip\"\n [class.wr-editor__tool--active]=\"state(item)?.active\"\n [disabled]=\"!state(item)?.available\"\n [attr.aria-pressed]=\"isToggle(item) ? (state(item)?.active ? 'true' : 'false') : null\"\n [attr.aria-label]=\"label(item)\"\n [attr.aria-keyshortcuts]=\"keyShortcuts(item)\"\n [wrPopover]=\"tooltip(item)\"\n (click)=\"onToolClick(item, $event)\"\n (focus)=\"onToolFocus(buttonIndex()[n])\"\n (keydown)=\"onToolbarKeydown($event)\"\n (mousedown)=\"$event.preventDefault()\"\n >\n <svg\n class=\"wr-editor__icon\"\n xmlns=\"http://www.w3.org/2000/svg\"\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n stroke-width=\"2\"\n stroke-linecap=\"round\"\n stroke-linejoin=\"round\"\n aria-hidden=\"true\"\n >\n @for (d of icon(item); track $index) {\n <path [attr.d]=\"d\" />\n }\n </svg>\n </button>\n }\n }\n </div>\n}\n\n<!--\n The static preview: what Angular renders until ProseMirror mounts \u2014 on the\n server, and for the first browser frame. It is a SIBLING of the mount surface,\n never inside it: `{ mount }` replaces the element's children, and nodes Angular\n owns would be deleted out from under the next change detection.\n-->\n@if (preview(); as doc) {\n <div class=\"wr-editor__preview wr-editor__content\">\n <ng-container [ngTemplateOutlet]=\"previewBlocks\" [ngTemplateOutletContext]=\"{ $implicit: doc.content }\" />\n </div>\n}\n\n<!--\n The mount surface. Angular binds every attribute it needs, so the prerendered\n page and the synchronous first render already carry the role, the name and the\n field's error state; ProseMirror adds only its own class, `contenteditable` and\n `translate`, and never touches an attribute it did not set. No Angular child\n ever lives in here.\n-->\n<div\n #surface\n class=\"wr-editor__surface wr-editor__content\"\n role=\"textbox\"\n aria-multiline=\"true\"\n [id]=\"surfaceId\"\n [hidden]=\"!mounted()\"\n [attr.aria-label]=\"accessibleName()\"\n [attr.aria-labelledby]=\"labelledBy()\"\n [attr.aria-describedby]=\"fieldAria.describedBy()\"\n [attr.aria-invalid]=\"fieldAria.ariaInvalid()\"\n [attr.aria-readonly]=\"locked() && !disabled() ? 'true' : null\"\n [attr.aria-disabled]=\"disabled() ? 'true' : null\"\n [attr.aria-placeholder]=\"ariaPlaceholder()\"\n [attr.tabindex]=\"locked() && !disabled() ? 0 : null\"\n (focus)=\"focused.set(true)\"\n (blur)=\"focused.set(false)\"\n></div>\n\n<!--\n The popover styles its panel as `.wr-popover-overlay > *`, which is this form,\n so the form stays a plain block and the layout lives one level in.\n-->\n<ng-template #linkPanel>\n <form\n class=\"wr-editor-link\"\n novalidate\n [id]=\"linkField.panelId\"\n (submit)=\"applyLink($event)\"\n (focusout)=\"onFocusOut($event)\"\n >\n <div class=\"wr-editor-link__body\">\n <label class=\"wr-editor-link__label\" [attr.for]=\"linkField.controlId()\">{{ linkUrlLabel() }}</label>\n <input\n wrInput\n class=\"wr-editor-link__input\"\n type=\"text\"\n inputmode=\"url\"\n autocomplete=\"url\"\n spellcheck=\"false\"\n [value]=\"linkDraft()\"\n (input)=\"onLinkInput($event)\"\n />\n @if (linkField.invalid()) {\n <p class=\"wr-editor-link__error\" role=\"alert\" [id]=\"linkField.errorId\">{{ linkInvalidLabel() }}</p>\n }\n <div class=\"wr-editor-link__actions\">\n @if (linkActive()) {\n <button wr-btn type=\"button\" class=\"wr-editor-link__remove\" (click)=\"removeLink()\">\n {{ linkRemoveLabel() }}\n </button>\n }\n <button wr-btn type=\"submit\" color=\"primary\" class=\"wr-editor-link__apply\">{{ linkApplyLabel() }}</button>\n </div>\n </div>\n </form>\n</ng-template>\n\n<!--\n Recursion runs through `ngTemplateOutlet`, as in `<wr-markdown>`, so the preview\n is exactly the document with no wrapper between a `<ul>` and its `<li>`.\n-->\n<ng-template #previewBlocks let-raw>\n @for (node of nodesOf(raw); track $index) {\n @switch (node.type) {\n @case ('paragraph') {\n <p [class.wr-editor__placeholder]=\"ariaPlaceholder() !== null\" [attr.data-placeholder]=\"ariaPlaceholder()\">\n <ng-container [ngTemplateOutlet]=\"previewInline\" [ngTemplateOutletContext]=\"{ $implicit: node.content }\" />\n </p>\n }\n @case ('heading') {\n @switch (attr(node, 'level')) {\n @case (2) {\n <h2>\n <ng-container\n [ngTemplateOutlet]=\"previewInline\"\n [ngTemplateOutletContext]=\"{ $implicit: node.content }\"\n />\n </h2>\n }\n @case (3) {\n <h3>\n <ng-container\n [ngTemplateOutlet]=\"previewInline\"\n [ngTemplateOutletContext]=\"{ $implicit: node.content }\"\n />\n </h3>\n }\n @default {\n <h1>\n <ng-container\n [ngTemplateOutlet]=\"previewInline\"\n [ngTemplateOutletContext]=\"{ $implicit: node.content }\"\n />\n </h1>\n }\n }\n }\n @case ('blockquote') {\n <blockquote>\n <ng-container [ngTemplateOutlet]=\"previewBlocks\" [ngTemplateOutletContext]=\"{ $implicit: node.content }\" />\n </blockquote>\n }\n @case ('code_block') {\n <pre><code>{{ textOf(node) }}</code></pre>\n }\n @case ('horizontal_rule') {\n <hr />\n }\n @case ('bullet_list') {\n <ul>\n <ng-container [ngTemplateOutlet]=\"previewBlocks\" [ngTemplateOutletContext]=\"{ $implicit: node.content }\" />\n </ul>\n }\n @case ('ordered_list') {\n <ol [attr.start]=\"attr(node, 'order') === 1 ? null : attr(node, 'order')\">\n <ng-container [ngTemplateOutlet]=\"previewBlocks\" [ngTemplateOutletContext]=\"{ $implicit: node.content }\" />\n </ol>\n }\n @case ('list_item') {\n @let checked = attr(node, 'checked');\n @if (checked === null) {\n <li>\n <ng-container [ngTemplateOutlet]=\"previewBlocks\" [ngTemplateOutletContext]=\"{ $implicit: node.content }\" />\n </li>\n } @else {\n <li class=\"wr-editor__item wr-editor__item--task\" [attr.data-checked]=\"checked\">\n <span class=\"wr-editor__task\" [class.wr-editor__task--checked]=\"checked === true\" aria-hidden=\"true\"></span>\n <span class=\"wr-editor__sr-only\">{{ checked === true ? taskDoneLabel() : taskTodoLabel() }}</span>\n <div class=\"wr-editor__task-body\">\n <ng-container\n [ngTemplateOutlet]=\"previewBlocks\"\n [ngTemplateOutletContext]=\"{ $implicit: node.content }\"\n />\n </div>\n </li>\n }\n }\n @case ('table') {\n <table>\n <tbody>\n <ng-container [ngTemplateOutlet]=\"previewBlocks\" [ngTemplateOutletContext]=\"{ $implicit: node.content }\" />\n </tbody>\n </table>\n }\n @case ('table_row') {\n <tr>\n <ng-container [ngTemplateOutlet]=\"previewBlocks\" [ngTemplateOutletContext]=\"{ $implicit: node.content }\" />\n </tr>\n }\n @case ('table_header') {\n <th [style.text-align]=\"text(node, 'align')\">\n <ng-container [ngTemplateOutlet]=\"previewBlocks\" [ngTemplateOutletContext]=\"{ $implicit: node.content }\" />\n </th>\n }\n @case ('table_cell') {\n <td [style.text-align]=\"text(node, 'align')\">\n <ng-container [ngTemplateOutlet]=\"previewBlocks\" [ngTemplateOutletContext]=\"{ $implicit: node.content }\" />\n </td>\n }\n }\n }\n</ng-template>\n\n<ng-template #previewInline let-raw>\n @for (node of nodesOf(raw); track $index) {\n <ng-container [ngTemplateOutlet]=\"previewMarked\" [ngTemplateOutletContext]=\"{ $implicit: node, depth: 0 }\" />\n }\n</ng-template>\n\n<!-- One mark per level, outermost first \u2014 the order the schema ranks them in. -->\n<ng-template #previewMarked let-raw let-depth=\"depth\">\n @let node = nodeOf(raw);\n @let mark = markAt(node, depth);\n @if (mark) {\n @switch (mark.type) {\n @case ('link') {\n <a [href]=\"text(mark, 'href')\" [attr.title]=\"text(mark, 'title')\" rel=\"noopener noreferrer nofollow\"\n ><ng-container\n [ngTemplateOutlet]=\"previewMarked\"\n [ngTemplateOutletContext]=\"{ $implicit: node, depth: depth + 1 }\"\n /></a>\n }\n @case ('strong') {\n <strong\n ><ng-container\n [ngTemplateOutlet]=\"previewMarked\"\n [ngTemplateOutletContext]=\"{ $implicit: node, depth: depth + 1 }\"\n /></strong>\n }\n @case ('em') {\n <em\n ><ng-container\n [ngTemplateOutlet]=\"previewMarked\"\n [ngTemplateOutletContext]=\"{ $implicit: node, depth: depth + 1 }\"\n /></em>\n }\n @case ('underline') {\n <u\n ><ng-container\n [ngTemplateOutlet]=\"previewMarked\"\n [ngTemplateOutletContext]=\"{ $implicit: node, depth: depth + 1 }\"\n /></u>\n }\n @case ('strike') {\n <s\n ><ng-container\n [ngTemplateOutlet]=\"previewMarked\"\n [ngTemplateOutletContext]=\"{ $implicit: node, depth: depth + 1 }\"\n /></s>\n }\n @case ('code') {\n <code\n ><ng-container\n [ngTemplateOutlet]=\"previewMarked\"\n [ngTemplateOutletContext]=\"{ $implicit: node, depth: depth + 1 }\"\n /></code>\n }\n @default {\n <ng-container\n [ngTemplateOutlet]=\"previewMarked\"\n [ngTemplateOutletContext]=\"{ $implicit: node, depth: depth + 1 }\"\n />\n }\n }\n } @else {\n @switch (node.type) {\n @case ('text') {\n <ng-container>{{ node.text }}</ng-container>\n }\n @case ('hard_break') {\n <br />\n }\n @case ('image') {\n <img [src]=\"text(node, 'src')\" [alt]=\"text(node, 'alt') ?? ''\" [attr.title]=\"text(node, 'title')\" />\n }\n }\n }\n</ng-template>\n" }]
|
|
2594
|
+
}], ctorParameters: () => [], propDecorators: { value: [{ type: i0.Input, args: [{ isSignal: true, alias: "value", required: false }] }, { type: i0.Output, args: ["valueChange"] }], touch: [{ type: i0.Output, args: ["touch"] }], disabled: [{ type: i0.Input, args: [{ isSignal: true, alias: "disabled", required: false }] }], readonly: [{ type: i0.Input, args: [{ isSignal: true, alias: "readonly", required: false }] }], placeholder: [{ type: i0.Input, args: [{ isSignal: true, alias: "placeholder", required: false }] }], ariaLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "ariaLabel", required: false }] }], format: [{ type: i0.Input, args: [{ isSignal: true, alias: "format", required: false }] }], toolbar: [{ type: i0.Input, args: [{ isSignal: true, alias: "toolbar", required: false }] }], surface: [{ type: i0.ViewChild, args: ['surface', { isSignal: true }] }], toolButtons: [{ type: i0.ViewChildren, args: ['tool', { ...{ read: ElementRef }, isSignal: true }] }], linkPopover: [{ type: i0.ViewChild, args: ['linkPopover', { isSignal: true }] }] } });
|
|
2595
|
+
|
|
2596
|
+
/**
|
|
2597
|
+
* Generated bundle index. Do not edit.
|
|
2598
|
+
*/
|
|
2599
|
+
|
|
2600
|
+
export { WR_EDITOR_TOOLBAR, WrEditor };
|
|
2601
|
+
//# sourceMappingURL=ngwr-editor.mjs.map
|