@refraction-ui/astro 0.4.2 → 0.4.4
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/dist/accordion/index.ts +1 -0
- package/dist/ai/ai-manager.ts +121 -0
- package/dist/ai/index.ts +23 -0
- package/dist/ai/mock-providers.ts +68 -0
- package/dist/ai/sanitizer.ts +174 -0
- package/dist/ai/tts-manager.ts +80 -0
- package/dist/ai/types.ts +72 -0
- package/dist/animated-text/animated-text.styles.ts +28 -0
- package/dist/animated-text/animated-text.ts +83 -0
- package/dist/animated-text/index.ts +15 -0
- package/dist/app-shell/app-shell.ts +252 -0
- package/dist/app-shell/auth-shell.ts +84 -0
- package/dist/app-shell/index.ts +22 -0
- package/dist/app-shell/page-shell.ts +125 -0
- package/dist/astro-accordion/Button.astro +1 -1
- package/dist/astro-ai/index.ts +1 -1
- package/dist/astro-animated-text/AnimatedText.astro +1 -1
- package/dist/astro-animated-text/TypewriterText.astro +1 -1
- package/dist/astro-app-shell/AppShell.astro +1 -1
- package/dist/astro-app-shell/AuthShell.astro +1 -1
- package/dist/astro-app-shell/AuthShellCard.astro +1 -1
- package/dist/astro-app-shell/PageShell.astro +1 -1
- package/dist/astro-app-shell/PageShellSection.astro +1 -1
- package/dist/astro-app-shell/index.ts +1 -1
- package/dist/astro-auth/index.ts +2 -2
- package/dist/astro-avatar/Avatar.astro +1 -1
- package/dist/astro-avatar/AvatarFallback.astro +1 -1
- package/dist/astro-avatar/AvatarImage.astro +1 -1
- package/dist/astro-avatar-group/AvatarGroup.astro +1 -1
- package/dist/astro-badge/Badge.astro +1 -1
- package/dist/astro-bottom-nav/BottomNav.astro +1 -1
- package/dist/astro-breadcrumbs/Breadcrumbs.astro +1 -1
- package/dist/astro-button/Button.astro +1 -1
- package/dist/astro-calendar/Calendar.astro +1 -1
- package/dist/astro-card/Card.astro +1 -1
- package/dist/astro-card/CardContent.astro +1 -1
- package/dist/astro-card/CardDescription.astro +1 -1
- package/dist/astro-card/CardFooter.astro +1 -1
- package/dist/astro-card/CardHeader.astro +1 -1
- package/dist/astro-card/CardTitle.astro +1 -1
- package/dist/astro-card-grid/CardGrid.astro +1 -1
- package/dist/astro-carousel/Button.astro +1 -1
- package/dist/astro-charts/Bars.astro +1 -1
- package/dist/astro-charts/Chart.astro +1 -1
- package/dist/astro-charts/Circles.astro +1 -1
- package/dist/astro-charts/Histogram.astro +1 -1
- package/dist/astro-charts/Line.astro +1 -1
- package/dist/astro-charts/PieChart.astro +1 -1
- package/dist/astro-charts/ScatterPlot.astro +1 -1
- package/dist/astro-charts/index.ts +1 -1
- package/dist/astro-checkbox/Checkbox.astro +1 -1
- package/dist/astro-code-editor/CodeEditor.astro +1 -1
- package/dist/astro-collapsible/Collapsible.astro +1 -1
- package/dist/astro-collapsible/CollapsibleContent.astro +1 -1
- package/dist/astro-command/Command.astro +1 -1
- package/dist/astro-command/CommandGroup.astro +1 -1
- package/dist/astro-command/CommandInput.astro +1 -1
- package/dist/astro-command/CommandItem.astro +1 -1
- package/dist/astro-command-input/CommandInput.astro +1 -1
- package/dist/astro-content-protection/ContentProtection.astro +1 -1
- package/dist/astro-data-table/DataTable.astro +1 -1
- package/dist/astro-date-picker/DatePicker.astro +1 -1
- package/dist/astro-device-frame/DeviceFrame.astro +1 -1
- package/dist/astro-dialog/DialogContent.astro +1 -1
- package/dist/astro-dialog/DialogOverlay.astro +1 -1
- package/dist/astro-diff-viewer/DiffViewer.astro +1 -1
- package/dist/astro-dropdown-menu/DropdownMenuContent.astro +1 -1
- package/dist/astro-dropdown-menu/DropdownMenuItem.astro +1 -1
- package/dist/astro-emoji-picker/EmojiPicker.astro +1 -1
- package/dist/astro-feedback-dialog/FeedbackDialog.astro +1 -1
- package/dist/astro-file-upload/FileUpload.astro +1 -1
- package/dist/astro-footer/Footer.astro +1 -1
- package/dist/astro-inline-editor/InlineEditor.astro +1 -1
- package/dist/astro-input/Input.astro +1 -1
- package/dist/astro-input-group/InputGroup.astro +1 -1
- package/dist/astro-input-group/InputGroupAddon.astro +1 -1
- package/dist/astro-input-group/InputGroupButton.astro +1 -1
- package/dist/astro-install-prompt/InstallPrompt.astro +1 -1
- package/dist/astro-keyboard-shortcut/ShortcutBadge.astro +1 -1
- package/dist/astro-language-selector/LanguageSelector.astro +1 -1
- package/dist/astro-link-card/LinkCard.astro +1 -1
- package/dist/astro-markdown-renderer/MarkdownRenderer.astro +1 -1
- package/dist/astro-mobile-nav/MobileNav.astro +1 -1
- package/dist/astro-mobile-nav/MobileNavLink.astro +1 -1
- package/dist/astro-mobile-nav/MobileNavTrigger.astro +1 -1
- package/dist/astro-navbar/Navbar.astro +1 -1
- package/dist/astro-otp-input/OtpInput.astro +1 -1
- package/dist/astro-payment/Payment.astro +1 -1
- package/dist/astro-popover/PopoverContent.astro +1 -1
- package/dist/astro-presence-indicator/PresenceIndicator.astro +1 -1
- package/dist/astro-progress-display/BadgeDisplay.astro +1 -1
- package/dist/astro-progress-display/ProgressBar.astro +1 -1
- package/dist/astro-progress-display/StatsGrid.astro +1 -1
- package/dist/astro-radio/RadioGroup.astro +1 -1
- package/dist/astro-radio/RadioItem.astro +1 -1
- package/dist/astro-reaction-bar/ReactionBar.astro +1 -1
- package/dist/astro-resizable-layout/ResizableDivider.astro +1 -1
- package/dist/astro-resizable-layout/ResizableLayout.astro +1 -1
- package/dist/astro-resizable-layout/ResizablePane.astro +1 -1
- package/dist/astro-resizable-layout/index.ts +1 -1
- package/dist/astro-rich-editor/index.ts +2 -2
- package/dist/astro-search-bar/SearchBar.astro +1 -1
- package/dist/astro-search-bar/SearchResults.astro +1 -1
- package/dist/astro-select/Select.astro +1 -1
- package/dist/astro-select/SelectContent.astro +1 -1
- package/dist/astro-select/SelectItem.astro +1 -1
- package/dist/astro-select/SelectTrigger.astro +1 -1
- package/dist/astro-sidebar/Sidebar.astro +1 -1
- package/dist/astro-skeleton/Skeleton.astro +1 -1
- package/dist/astro-slide-viewer/SlideViewer.astro +1 -1
- package/dist/astro-status-indicator/StatusIndicator.astro +1 -1
- package/dist/astro-switch/Switch.astro +1 -1
- package/dist/astro-table-of-contents/TableOfContents.astro +1 -1
- package/dist/astro-tabs/Tabs.astro +1 -1
- package/dist/astro-tabs/TabsList.astro +1 -1
- package/dist/astro-tabs/TabsTrigger.astro +1 -1
- package/dist/astro-textarea/Textarea.astro +1 -1
- package/dist/astro-theme/ThemeScript.astro +1 -1
- package/dist/astro-thread-view/ThreadView.astro +1 -1
- package/dist/astro-toast/Toast.astro +1 -1
- package/dist/astro-tooltip/TooltipContent.astro +1 -1
- package/dist/astro-version-selector/VersionSelector.astro +1 -1
- package/dist/astro-video-player/VideoPlayer.astro +1 -1
- package/dist/auth/adapters/mock.ts +74 -0
- package/dist/auth/auth-machine.ts +120 -0
- package/dist/auth/index.ts +22 -0
- package/dist/auth/rbac.ts +44 -0
- package/dist/auth/types.ts +44 -0
- package/dist/avatar/avatar.styles.ts +47 -0
- package/dist/avatar/avatar.ts +73 -0
- package/dist/avatar/index.ts +15 -0
- package/dist/avatar-group/avatar-group.styles.ts +63 -0
- package/dist/avatar-group/avatar-group.ts +106 -0
- package/dist/avatar-group/index.ts +18 -0
- package/dist/badge/badge.styles.ts +24 -0
- package/dist/badge/badge.ts +48 -0
- package/dist/badge/index.ts +10 -0
- package/dist/bottom-nav/bottom-nav.styles.ts +18 -0
- package/dist/bottom-nav/bottom-nav.ts +33 -0
- package/dist/bottom-nav/index.ts +2 -0
- package/dist/breadcrumbs/breadcrumbs.styles.ts +20 -0
- package/dist/breadcrumbs/breadcrumbs.ts +72 -0
- package/dist/breadcrumbs/index.ts +8 -0
- package/dist/button/button.styles.ts +38 -0
- package/dist/button/button.ts +64 -0
- package/dist/button/index.ts +10 -0
- package/dist/calendar/calendar.styles.ts +21 -0
- package/dist/calendar/calendar.ts +202 -0
- package/dist/calendar/index.ts +9 -0
- package/dist/callout/callout.ts +1 -0
- package/dist/callout/index.ts +1 -0
- package/dist/card/card.styles.ts +55 -0
- package/dist/card/card.ts +45 -0
- package/dist/card/index.ts +20 -0
- package/dist/card-grid/card-grid.ts +9 -0
- package/dist/card-grid/index.ts +1 -0
- package/dist/carousel/index.ts +1 -0
- package/dist/charts/axis.ts +50 -0
- package/dist/charts/chart.ts +73 -0
- package/dist/charts/colorblind.ts +100 -0
- package/dist/charts/data.ts +72 -0
- package/dist/charts/geometry.ts +54 -0
- package/dist/charts/index.ts +61 -0
- package/dist/charts/scales.ts +151 -0
- package/dist/checkbox/checkbox.styles.ts +33 -0
- package/dist/checkbox/checkbox.ts +73 -0
- package/dist/checkbox/index.ts +10 -0
- package/dist/code-block/code-block.ts +1 -0
- package/dist/code-block/index.ts +1 -0
- package/dist/code-editor/code-editor.styles.ts +32 -0
- package/dist/code-editor/code-editor.ts +140 -0
- package/dist/code-editor/index.ts +9 -0
- package/dist/collapsible/collapsible.styles.ts +14 -0
- package/dist/collapsible/collapsible.ts +93 -0
- package/dist/collapsible/index.ts +7 -0
- package/dist/command/command.styles.ts +27 -0
- package/dist/command/command.ts +179 -0
- package/dist/command/index.ts +14 -0
- package/dist/command-input/command-input.ts +176 -0
- package/dist/command-input/index.ts +1 -0
- package/dist/content-protection/content-protection.styles.ts +9 -0
- package/dist/content-protection/content-protection.ts +68 -0
- package/dist/content-protection/index.ts +8 -0
- package/dist/data-table/data-table.styles.ts +55 -0
- package/dist/data-table/data-table.ts +183 -0
- package/dist/data-table/index.ts +15 -0
- package/dist/date-picker/date-picker.styles.ts +31 -0
- package/dist/date-picker/date-picker.ts +333 -0
- package/dist/date-picker/index.ts +18 -0
- package/dist/device-frame/device-frame.styles.ts +21 -0
- package/dist/device-frame/device-frame.ts +112 -0
- package/dist/device-frame/index.ts +10 -0
- package/dist/dialog/dialog.styles.ts +8 -0
- package/dist/dialog/dialog.ts +133 -0
- package/dist/dialog/index.ts +8 -0
- package/dist/diff-viewer/diff-viewer.styles.ts +124 -0
- package/dist/diff-viewer/diff-viewer.ts +255 -0
- package/dist/diff-viewer/index.ts +20 -0
- package/dist/dropdown-menu/dropdown-menu.styles.ts +9 -0
- package/dist/dropdown-menu/dropdown-menu.ts +139 -0
- package/dist/dropdown-menu/index.ts +9 -0
- package/dist/emoji-picker/emoji-data.ts +282 -0
- package/dist/emoji-picker/emoji-picker.styles.ts +32 -0
- package/dist/emoji-picker/emoji-picker.ts +165 -0
- package/dist/emoji-picker/index.ts +25 -0
- package/dist/feedback-dialog/feedback-dialog.styles.ts +15 -0
- package/dist/feedback-dialog/feedback-dialog.ts +147 -0
- package/dist/feedback-dialog/index.ts +11 -0
- package/dist/file-upload/file-upload.styles.ts +30 -0
- package/dist/file-upload/file-upload.ts +260 -0
- package/dist/file-upload/index.ts +19 -0
- package/dist/footer/footer.styles.ts +5 -0
- package/dist/footer/footer.ts +32 -0
- package/dist/footer/index.ts +9 -0
- package/dist/index.ts +1 -1
- package/dist/inline-editor/index.ts +13 -0
- package/dist/inline-editor/inline-editor.styles.ts +42 -0
- package/dist/inline-editor/inline-editor.ts +96 -0
- package/dist/input/index.ts +9 -0
- package/dist/input/input.styles.ts +15 -0
- package/dist/input/input.ts +84 -0
- package/dist/input-group/index.ts +13 -0
- package/dist/input-group/input-group.styles.ts +50 -0
- package/dist/input-group/input-group.ts +44 -0
- package/dist/install-prompt/index.ts +9 -0
- package/dist/install-prompt/install-prompt.styles.ts +5 -0
- package/dist/install-prompt/install-prompt.ts +68 -0
- package/dist/keyboard-shortcut/index.ts +12 -0
- package/dist/keyboard-shortcut/keyboard-shortcut.styles.ts +8 -0
- package/dist/keyboard-shortcut/keyboard-shortcut.ts +153 -0
- package/dist/language-selector/index.ts +8 -0
- package/dist/language-selector/language-selector.styles.ts +28 -0
- package/dist/language-selector/language-selector.ts +149 -0
- package/dist/link-card/index.ts +1 -0
- package/dist/link-card/link-card.ts +9 -0
- package/dist/markdown-renderer/index.ts +8 -0
- package/dist/markdown-renderer/markdown-renderer.styles.ts +31 -0
- package/dist/markdown-renderer/markdown-renderer.ts +251 -0
- package/dist/mobile-nav/index.ts +14 -0
- package/dist/mobile-nav/mobile-nav.styles.ts +39 -0
- package/dist/mobile-nav/mobile-nav.ts +74 -0
- package/dist/navbar/index.ts +9 -0
- package/dist/navbar/navbar.styles.ts +29 -0
- package/dist/navbar/navbar.ts +39 -0
- package/dist/otp-input/index.ts +13 -0
- package/dist/otp-input/otp-input.styles.ts +51 -0
- package/dist/otp-input/otp-input.ts +198 -0
- package/dist/pagination/index.ts +1 -0
- package/dist/payment/index.ts +1 -0
- package/dist/payment/payment.ts +16 -0
- package/dist/popover/index.ts +7 -0
- package/dist/popover/popover.styles.ts +16 -0
- package/dist/popover/popover.ts +89 -0
- package/dist/presence-indicator/index.ts +14 -0
- package/dist/presence-indicator/presence-indicator.styles.ts +29 -0
- package/dist/presence-indicator/presence-indicator.ts +63 -0
- package/dist/progress-display/index.ts +15 -0
- package/dist/progress-display/progress-display.styles.ts +72 -0
- package/dist/progress-display/progress-display.ts +60 -0
- package/dist/radio/index.ts +8 -0
- package/dist/radio/radio.styles.ts +34 -0
- package/dist/radio/radio.ts +70 -0
- package/dist/reaction-bar/index.ts +14 -0
- package/dist/reaction-bar/reaction-bar.styles.ts +23 -0
- package/dist/reaction-bar/reaction-bar.ts +87 -0
- package/dist/resizable-layout/index.ts +12 -0
- package/dist/resizable-layout/resizable-layout.styles.ts +50 -0
- package/dist/resizable-layout/resizable-layout.ts +139 -0
- package/dist/rich-editor/editor.ts +342 -0
- package/dist/rich-editor/emoji.ts +175 -0
- package/dist/rich-editor/history.ts +79 -0
- package/dist/rich-editor/index.ts +106 -0
- package/dist/rich-editor/markdown-shortcuts.ts +248 -0
- package/dist/rich-editor/mentions.ts +162 -0
- package/dist/rich-editor/model.ts +206 -0
- package/dist/rich-editor/operations.ts +772 -0
- package/dist/rich-editor/selection.ts +157 -0
- package/dist/rich-editor/serialization.ts +711 -0
- package/dist/rich-editor/slash-commands.ts +212 -0
- package/dist/search-bar/index.ts +7 -0
- package/dist/search-bar/search-bar.styles.ts +29 -0
- package/dist/search-bar/search-bar.ts +130 -0
- package/dist/select/index.ts +13 -0
- package/dist/select/select.styles.ts +52 -0
- package/dist/select/select.ts +152 -0
- package/dist/sidebar/index.ts +9 -0
- package/dist/sidebar/sidebar.styles.ts +27 -0
- package/dist/sidebar/sidebar.ts +59 -0
- package/dist/skeleton/index.ts +8 -0
- package/dist/skeleton/skeleton.styles.ts +16 -0
- package/dist/skeleton/skeleton.ts +39 -0
- package/dist/skip-to-content/index.ts +1 -0
- package/dist/skip-to-content/skip-to-content.ts +1 -0
- package/dist/slide-viewer/index.ts +16 -0
- package/dist/slide-viewer/slide-viewer.styles.ts +57 -0
- package/dist/slide-viewer/slide-viewer.ts +152 -0
- package/dist/slider/index.ts +1 -0
- package/dist/slider/slider.ts +2 -0
- package/dist/status-indicator/index.ts +15 -0
- package/dist/status-indicator/status-indicator.styles.ts +41 -0
- package/dist/status-indicator/status-indicator.ts +66 -0
- package/dist/steps/index.ts +1 -0
- package/dist/steps/steps.ts +1 -0
- package/dist/switch/index.ts +7 -0
- package/dist/switch/switch.styles.ts +55 -0
- package/dist/switch/switch.ts +63 -0
- package/dist/table-of-contents/index.ts +1 -0
- package/dist/table-of-contents/table-of-contents.ts +37 -0
- package/dist/tabs/index.ts +8 -0
- package/dist/tabs/tabs.styles.ts +9 -0
- package/dist/tabs/tabs.ts +133 -0
- package/dist/textarea/index.ts +7 -0
- package/dist/textarea/textarea.styles.ts +15 -0
- package/dist/textarea/textarea.ts +59 -0
- package/dist/theme/dom-adapters.ts +60 -0
- package/dist/theme/index.ts +18 -0
- package/dist/theme/theme-machine.ts +130 -0
- package/dist/theme/theme-script.ts +18 -0
- package/dist/thread-view/index.ts +26 -0
- package/dist/thread-view/thread-view.styles.ts +35 -0
- package/dist/thread-view/thread-view.ts +176 -0
- package/dist/toast/index.ts +13 -0
- package/dist/toast/toast.styles.ts +19 -0
- package/dist/toast/toast.ts +203 -0
- package/dist/tooltip/index.ts +7 -0
- package/dist/tooltip/tooltip.styles.ts +16 -0
- package/dist/tooltip/tooltip.ts +109 -0
- package/dist/version-selector/index.ts +12 -0
- package/dist/version-selector/version-selector.styles.ts +41 -0
- package/dist/version-selector/version-selector.ts +136 -0
- package/dist/video-player/index.ts +12 -0
- package/dist/video-player/video-player.styles.ts +42 -0
- package/dist/video-player/video-player.ts +100 -0
- package/package.json +1 -1
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Markdown shortcuts — auto-convert markdown syntax as the user types.
|
|
3
|
+
*
|
|
4
|
+
* Block shortcuts trigger at the start of a block (e.g. "# " -> heading).
|
|
5
|
+
* Inline shortcuts trigger when a closing marker completes a pair (e.g. "**text**" -> bold).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { Document, Block, Mark, MarkType, BlockType } from './model.js'
|
|
9
|
+
import type { Selection } from './selection.js'
|
|
10
|
+
import type { EditorState } from './operations.js'
|
|
11
|
+
import {
|
|
12
|
+
findBlockById,
|
|
13
|
+
getBlockText,
|
|
14
|
+
createTextSegment,
|
|
15
|
+
normalizeContent,
|
|
16
|
+
cloneDocument,
|
|
17
|
+
} from './model.js'
|
|
18
|
+
import { createCollapsedSelection, isCollapsed } from './selection.js'
|
|
19
|
+
import { changeBlockType } from './operations.js'
|
|
20
|
+
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
// Block shortcuts
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
interface BlockShortcut {
|
|
26
|
+
pattern: RegExp
|
|
27
|
+
type: BlockType
|
|
28
|
+
meta?: Record<string, unknown>
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const BLOCK_SHORTCUTS: BlockShortcut[] = [
|
|
32
|
+
{ pattern: /^### $/, type: 'heading', meta: { level: 3 } },
|
|
33
|
+
{ pattern: /^## $/, type: 'heading', meta: { level: 2 } },
|
|
34
|
+
{ pattern: /^# $/, type: 'heading', meta: { level: 1 } },
|
|
35
|
+
{ pattern: /^- $/, type: 'list-item', meta: { listType: 'bullet' } },
|
|
36
|
+
{ pattern: /^\* $/, type: 'list-item', meta: { listType: 'bullet' } },
|
|
37
|
+
{ pattern: /^1\. $/, type: 'list-item', meta: { listType: 'ordered' } },
|
|
38
|
+
{ pattern: /^> $/, type: 'blockquote' },
|
|
39
|
+
{ pattern: /^```$/, type: 'code-block' },
|
|
40
|
+
{ pattern: /^---$/, type: 'divider' },
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
function tryBlockShortcut(
|
|
44
|
+
doc: Document,
|
|
45
|
+
sel: Selection,
|
|
46
|
+
char: string,
|
|
47
|
+
): { doc: Document; sel: Selection; consumed: boolean } | null {
|
|
48
|
+
if (!isCollapsed(sel)) return null
|
|
49
|
+
|
|
50
|
+
const block = findBlockById(doc, sel.anchor.blockId)
|
|
51
|
+
if (!block || block.type !== 'paragraph') return null
|
|
52
|
+
|
|
53
|
+
const text = getBlockText(block)
|
|
54
|
+
const textBeforeCursor = text.slice(0, sel.anchor.offset) + char
|
|
55
|
+
|
|
56
|
+
for (const shortcut of BLOCK_SHORTCUTS) {
|
|
57
|
+
if (shortcut.pattern.test(textBeforeCursor)) {
|
|
58
|
+
const d = cloneDocument(doc)
|
|
59
|
+
const b = d.blocks.find((bl) => bl.id === block.id)!
|
|
60
|
+
|
|
61
|
+
// Clear the shortcut text
|
|
62
|
+
b.content = [createTextSegment('')]
|
|
63
|
+
|
|
64
|
+
// Special case for code-block — the ``` doesn't need the trailing space
|
|
65
|
+
// and divider --- is also self-contained
|
|
66
|
+
b.type = shortcut.type
|
|
67
|
+
if (shortcut.meta) {
|
|
68
|
+
b.meta = { ...shortcut.meta }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (shortcut.type === 'divider') {
|
|
72
|
+
b.content = [createTextSegment('')]
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return {
|
|
76
|
+
doc: d,
|
|
77
|
+
sel: createCollapsedSelection(b.id, 0),
|
|
78
|
+
consumed: true,
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return null
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
// Inline shortcuts
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
|
|
90
|
+
interface InlineShortcut {
|
|
91
|
+
openMarker: string
|
|
92
|
+
closeMarker: string
|
|
93
|
+
markType: MarkType
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const INLINE_SHORTCUTS: InlineShortcut[] = [
|
|
97
|
+
{ openMarker: '**', closeMarker: '**', markType: 'bold' },
|
|
98
|
+
{ openMarker: '__', closeMarker: '__', markType: 'bold' },
|
|
99
|
+
{ openMarker: '*', closeMarker: '*', markType: 'italic' },
|
|
100
|
+
{ openMarker: '_', closeMarker: '_', markType: 'italic' },
|
|
101
|
+
{ openMarker: '~~', closeMarker: '~~', markType: 'strikethrough' },
|
|
102
|
+
{ openMarker: '`', closeMarker: '`', markType: 'code' },
|
|
103
|
+
]
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Try to match an inline shortcut like **bold** or *italic*.
|
|
107
|
+
* We look at the text before the cursor + the new char, and see if it
|
|
108
|
+
* completes a matched pair of markers with content between them.
|
|
109
|
+
*/
|
|
110
|
+
function tryInlineShortcut(
|
|
111
|
+
doc: Document,
|
|
112
|
+
sel: Selection,
|
|
113
|
+
char: string,
|
|
114
|
+
): { doc: Document; sel: Selection; consumed: boolean } | null {
|
|
115
|
+
if (!isCollapsed(sel)) return null
|
|
116
|
+
|
|
117
|
+
const block = findBlockById(doc, sel.anchor.blockId)
|
|
118
|
+
if (!block) return null
|
|
119
|
+
|
|
120
|
+
const text = getBlockText(block)
|
|
121
|
+
const textWithChar = text.slice(0, sel.anchor.offset) + char
|
|
122
|
+
|
|
123
|
+
// Check each inline shortcut — longer markers first (already sorted)
|
|
124
|
+
for (const shortcut of INLINE_SHORTCUTS) {
|
|
125
|
+
const { openMarker, closeMarker, markType } = shortcut
|
|
126
|
+
|
|
127
|
+
// Check if textWithChar ends with the close marker
|
|
128
|
+
if (!textWithChar.endsWith(closeMarker)) continue
|
|
129
|
+
|
|
130
|
+
// Find the matching open marker before the close marker
|
|
131
|
+
const beforeClose = textWithChar.slice(0, textWithChar.length - closeMarker.length)
|
|
132
|
+
|
|
133
|
+
// For single-char markers like * and _, we need to be careful not to
|
|
134
|
+
// confuse with the double-char ** and __ markers.
|
|
135
|
+
// The open marker should not be preceded by the same character (to avoid ** matching as two *).
|
|
136
|
+
const openIdx = beforeClose.lastIndexOf(openMarker)
|
|
137
|
+
if (openIdx === -1) continue
|
|
138
|
+
|
|
139
|
+
// Make sure there's content between the markers
|
|
140
|
+
const content = beforeClose.slice(openIdx + openMarker.length)
|
|
141
|
+
if (content.length === 0) continue
|
|
142
|
+
|
|
143
|
+
// For single-char markers, check that the char before the open marker isn't the same
|
|
144
|
+
// (to avoid **text** being matched by the * shortcut on the inner *)
|
|
145
|
+
if (openMarker.length === 1 && openIdx > 0 && beforeClose[openIdx - 1] === openMarker) continue
|
|
146
|
+
|
|
147
|
+
// We have a valid match! Replace the markers with marked content.
|
|
148
|
+
const d = cloneDocument(doc)
|
|
149
|
+
const b = d.blocks.find((bl) => bl.id === block.id)!
|
|
150
|
+
|
|
151
|
+
const beforeMarkerText = text.slice(0, openIdx)
|
|
152
|
+
const afterCursorText = text.slice(sel.anchor.offset)
|
|
153
|
+
|
|
154
|
+
const mark: Mark = { type: markType }
|
|
155
|
+
const segments = [
|
|
156
|
+
...(beforeMarkerText ? [createTextSegment(beforeMarkerText)] : []),
|
|
157
|
+
createTextSegment(content, [mark]),
|
|
158
|
+
...(afterCursorText ? [createTextSegment(afterCursorText)] : []),
|
|
159
|
+
]
|
|
160
|
+
|
|
161
|
+
b.content = normalizeContent(segments)
|
|
162
|
+
|
|
163
|
+
const newOffset = openIdx + content.length
|
|
164
|
+
return {
|
|
165
|
+
doc: d,
|
|
166
|
+
sel: createCollapsedSelection(b.id, newOffset),
|
|
167
|
+
consumed: true,
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return null
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ---------------------------------------------------------------------------
|
|
175
|
+
// Link shortcut: [text](url)
|
|
176
|
+
// ---------------------------------------------------------------------------
|
|
177
|
+
function tryLinkShortcut(
|
|
178
|
+
doc: Document,
|
|
179
|
+
sel: Selection,
|
|
180
|
+
char: string,
|
|
181
|
+
): { doc: Document; sel: Selection; consumed: boolean } | null {
|
|
182
|
+
if (!isCollapsed(sel) || char !== ')') return null
|
|
183
|
+
|
|
184
|
+
const block = findBlockById(doc, sel.anchor.blockId)
|
|
185
|
+
if (!block) return null
|
|
186
|
+
|
|
187
|
+
const text = getBlockText(block)
|
|
188
|
+
const textWithChar = text.slice(0, sel.anchor.offset) + char
|
|
189
|
+
|
|
190
|
+
// Look for [text](url) pattern ending at cursor
|
|
191
|
+
const match = textWithChar.match(/\[([^\]]+)\]\(([^)]+)\)$/)
|
|
192
|
+
if (!match) return null
|
|
193
|
+
|
|
194
|
+
const fullMatch = match[0]
|
|
195
|
+
const linkText = match[1]
|
|
196
|
+
const linkUrl = match[2]
|
|
197
|
+
|
|
198
|
+
const d = cloneDocument(doc)
|
|
199
|
+
const b = d.blocks.find((bl) => bl.id === block.id)!
|
|
200
|
+
|
|
201
|
+
const matchStart = textWithChar.length - fullMatch.length
|
|
202
|
+
const beforeText = text.slice(0, matchStart)
|
|
203
|
+
const afterText = text.slice(sel.anchor.offset)
|
|
204
|
+
|
|
205
|
+
const mark: Mark = { type: 'link', attrs: { href: linkUrl } }
|
|
206
|
+
const segments = [
|
|
207
|
+
...(beforeText ? [createTextSegment(beforeText)] : []),
|
|
208
|
+
createTextSegment(linkText, [mark]),
|
|
209
|
+
...(afterText ? [createTextSegment(afterText)] : []),
|
|
210
|
+
]
|
|
211
|
+
|
|
212
|
+
b.content = normalizeContent(segments)
|
|
213
|
+
|
|
214
|
+
const newOffset = matchStart + linkText.length
|
|
215
|
+
return {
|
|
216
|
+
doc: d,
|
|
217
|
+
sel: createCollapsedSelection(b.id, newOffset),
|
|
218
|
+
consumed: true,
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// ---------------------------------------------------------------------------
|
|
223
|
+
// Main entry point
|
|
224
|
+
// ---------------------------------------------------------------------------
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Process a character input and check if it triggers a markdown shortcut.
|
|
228
|
+
* Returns { consumed: true } if a shortcut was applied, { consumed: false } otherwise.
|
|
229
|
+
*/
|
|
230
|
+
export function processMarkdownShortcut(
|
|
231
|
+
doc: Document,
|
|
232
|
+
sel: Selection,
|
|
233
|
+
char: string,
|
|
234
|
+
): { doc: Document; sel: Selection; consumed: boolean } {
|
|
235
|
+
// Try block shortcuts first (they require a space or specific ending)
|
|
236
|
+
const blockResult = tryBlockShortcut(doc, sel, char)
|
|
237
|
+
if (blockResult) return blockResult
|
|
238
|
+
|
|
239
|
+
// Try link shortcut
|
|
240
|
+
const linkResult = tryLinkShortcut(doc, sel, char)
|
|
241
|
+
if (linkResult) return linkResult
|
|
242
|
+
|
|
243
|
+
// Try inline shortcuts
|
|
244
|
+
const inlineResult = tryInlineShortcut(doc, sel, char)
|
|
245
|
+
if (inlineResult) return inlineResult
|
|
246
|
+
|
|
247
|
+
return { doc, sel, consumed: false }
|
|
248
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mention system — trigger on "@" to show a searchable menu of users/channels/teams.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { Document } from './model.js'
|
|
6
|
+
import type { Selection } from './selection.js'
|
|
7
|
+
import type { MentionSegment } from './model.js'
|
|
8
|
+
import { findBlockById, getBlockText, createMentionSegment } from './model.js'
|
|
9
|
+
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
// Types
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
|
|
14
|
+
export interface MentionOption {
|
|
15
|
+
id: string
|
|
16
|
+
label: string
|
|
17
|
+
avatar?: string
|
|
18
|
+
type: 'user' | 'channel' | 'team'
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface MentionMenu {
|
|
22
|
+
isOpen: boolean
|
|
23
|
+
query: string
|
|
24
|
+
filteredOptions: MentionOption[]
|
|
25
|
+
selectedIndex: number
|
|
26
|
+
open(): void
|
|
27
|
+
close(): void
|
|
28
|
+
search(query: string): void
|
|
29
|
+
selectNext(): void
|
|
30
|
+
selectPrevious(): void
|
|
31
|
+
select(option: MentionOption): { mention: MentionSegment }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
// Fuzzy matching
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
function fuzzyMatch(text: string, query: string): boolean {
|
|
39
|
+
const t = text.toLowerCase()
|
|
40
|
+
const q = query.toLowerCase()
|
|
41
|
+
if (q === '') return true
|
|
42
|
+
if (t.includes(q)) return true
|
|
43
|
+
|
|
44
|
+
// Simple fuzzy: all characters of query appear in order in text
|
|
45
|
+
let qi = 0
|
|
46
|
+
for (let ti = 0; ti < t.length && qi < q.length; ti++) {
|
|
47
|
+
if (t[ti] === q[qi]) qi++
|
|
48
|
+
}
|
|
49
|
+
return qi === q.length
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function matchScore(option: MentionOption, query: string): number {
|
|
53
|
+
const q = query.toLowerCase()
|
|
54
|
+
const label = option.label.toLowerCase()
|
|
55
|
+
|
|
56
|
+
if (label === q) return 100
|
|
57
|
+
if (label.startsWith(q)) return 90
|
|
58
|
+
if (label.includes(q)) return 80
|
|
59
|
+
// Fuzzy match gets lower score
|
|
60
|
+
return 50
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
// Menu factory
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
export function createMentionMenu(options: MentionOption[]): MentionMenu {
|
|
68
|
+
const state = {
|
|
69
|
+
isOpen: false,
|
|
70
|
+
query: '',
|
|
71
|
+
filteredOptions: [] as MentionOption[],
|
|
72
|
+
selectedIndex: 0,
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function updateFiltered() {
|
|
76
|
+
if (state.query === '') {
|
|
77
|
+
state.filteredOptions = [...options]
|
|
78
|
+
} else {
|
|
79
|
+
state.filteredOptions = options
|
|
80
|
+
.filter((opt) => fuzzyMatch(opt.label, state.query) || fuzzyMatch(opt.id, state.query))
|
|
81
|
+
.sort((a, b) => matchScore(b, state.query) - matchScore(a, state.query))
|
|
82
|
+
}
|
|
83
|
+
if (state.selectedIndex >= state.filteredOptions.length) {
|
|
84
|
+
state.selectedIndex = Math.max(0, state.filteredOptions.length - 1)
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const menu: MentionMenu = {
|
|
89
|
+
get isOpen() { return state.isOpen },
|
|
90
|
+
get query() { return state.query },
|
|
91
|
+
get filteredOptions() { return state.filteredOptions },
|
|
92
|
+
get selectedIndex() { return state.selectedIndex },
|
|
93
|
+
|
|
94
|
+
open() {
|
|
95
|
+
state.isOpen = true
|
|
96
|
+
state.query = ''
|
|
97
|
+
state.selectedIndex = 0
|
|
98
|
+
updateFiltered()
|
|
99
|
+
},
|
|
100
|
+
|
|
101
|
+
close() {
|
|
102
|
+
state.isOpen = false
|
|
103
|
+
state.query = ''
|
|
104
|
+
state.filteredOptions = []
|
|
105
|
+
state.selectedIndex = 0
|
|
106
|
+
},
|
|
107
|
+
|
|
108
|
+
search(query: string) {
|
|
109
|
+
state.query = query
|
|
110
|
+
updateFiltered()
|
|
111
|
+
},
|
|
112
|
+
|
|
113
|
+
selectNext() {
|
|
114
|
+
if (state.filteredOptions.length === 0) return
|
|
115
|
+
state.selectedIndex = (state.selectedIndex + 1) % state.filteredOptions.length
|
|
116
|
+
},
|
|
117
|
+
|
|
118
|
+
selectPrevious() {
|
|
119
|
+
if (state.filteredOptions.length === 0) return
|
|
120
|
+
state.selectedIndex =
|
|
121
|
+
(state.selectedIndex - 1 + state.filteredOptions.length) % state.filteredOptions.length
|
|
122
|
+
},
|
|
123
|
+
|
|
124
|
+
select(option: MentionOption): { mention: MentionSegment } {
|
|
125
|
+
menu.close()
|
|
126
|
+
return {
|
|
127
|
+
mention: createMentionSegment(option.id, `@${option.label}`),
|
|
128
|
+
}
|
|
129
|
+
},
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return menu
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Detect whether the user has typed "@" that should trigger the mention menu.
|
|
137
|
+
*/
|
|
138
|
+
export function detectMentionTrigger(
|
|
139
|
+
doc: Document,
|
|
140
|
+
sel: Selection,
|
|
141
|
+
): { triggered: boolean; query: string } {
|
|
142
|
+
const block = findBlockById(doc, sel.anchor.blockId)
|
|
143
|
+
if (!block) return { triggered: false, query: '' }
|
|
144
|
+
|
|
145
|
+
const text = getBlockText(block)
|
|
146
|
+
const textBeforeCursor = text.slice(0, sel.anchor.offset)
|
|
147
|
+
|
|
148
|
+
// Look for "@" preceded by start-of-text or a space
|
|
149
|
+
const atIdx = textBeforeCursor.lastIndexOf('@')
|
|
150
|
+
if (atIdx === -1) return { triggered: false, query: '' }
|
|
151
|
+
|
|
152
|
+
// The @ must be at position 0 or preceded by a space
|
|
153
|
+
if (atIdx > 0 && textBeforeCursor[atIdx - 1] !== ' ') {
|
|
154
|
+
return { triggered: false, query: '' }
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const query = textBeforeCursor.slice(atIdx + 1)
|
|
158
|
+
// If query contains spaces, the mention trigger is no longer active
|
|
159
|
+
if (query.includes(' ')) return { triggered: false, query: '' }
|
|
160
|
+
|
|
161
|
+
return { triggered: true, query }
|
|
162
|
+
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Core data model for the rich text editor.
|
|
3
|
+
* Document is a tree of blocks, each containing inline content segments.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// Block types
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
export type BlockType =
|
|
10
|
+
| 'paragraph'
|
|
11
|
+
| 'heading'
|
|
12
|
+
| 'blockquote'
|
|
13
|
+
| 'code-block'
|
|
14
|
+
| 'list-item'
|
|
15
|
+
| 'divider'
|
|
16
|
+
| 'image'
|
|
17
|
+
|
|
18
|
+
export type HeadingLevel = 1 | 2 | 3
|
|
19
|
+
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// Inline content
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
export type MarkType = 'bold' | 'italic' | 'strikethrough' | 'code' | 'underline' | 'link'
|
|
24
|
+
|
|
25
|
+
export interface Mark {
|
|
26
|
+
type: MarkType
|
|
27
|
+
attrs?: Record<string, string>
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface TextSegment {
|
|
31
|
+
type: 'text'
|
|
32
|
+
text: string
|
|
33
|
+
marks: Mark[]
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface MentionSegment {
|
|
37
|
+
type: 'mention'
|
|
38
|
+
id: string
|
|
39
|
+
label: string
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface EmojiSegment {
|
|
43
|
+
type: 'emoji'
|
|
44
|
+
shortcode: string
|
|
45
|
+
unicode: string
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export type InlineContent = TextSegment | MentionSegment | EmojiSegment
|
|
49
|
+
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
// Block
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
export interface Block {
|
|
54
|
+
id: string
|
|
55
|
+
type: BlockType
|
|
56
|
+
content: InlineContent[]
|
|
57
|
+
meta?: Record<string, unknown>
|
|
58
|
+
indent?: number
|
|
59
|
+
children?: Block[]
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
// Document
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
export interface Document {
|
|
66
|
+
blocks: Block[]
|
|
67
|
+
version: number
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ---------------------------------------------------------------------------
|
|
71
|
+
// Helpers
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
let _idCounter = 0
|
|
74
|
+
|
|
75
|
+
export function generateBlockId(): string {
|
|
76
|
+
_idCounter++
|
|
77
|
+
return `block-${_idCounter}`
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function resetBlockIdCounter(): void {
|
|
81
|
+
_idCounter = 0
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function createTextSegment(text: string, marks: Mark[] = []): TextSegment {
|
|
85
|
+
return { type: 'text', text, marks }
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function createMentionSegment(id: string, label: string): MentionSegment {
|
|
89
|
+
return { type: 'mention', id, label }
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function createEmojiSegment(shortcode: string, unicode: string): EmojiSegment {
|
|
93
|
+
return { type: 'emoji', shortcode, unicode }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function createBlock(
|
|
97
|
+
type: BlockType = 'paragraph',
|
|
98
|
+
content: InlineContent[] = [],
|
|
99
|
+
meta?: Record<string, unknown>,
|
|
100
|
+
): Block {
|
|
101
|
+
return {
|
|
102
|
+
id: generateBlockId(),
|
|
103
|
+
type,
|
|
104
|
+
content: content.length === 0 ? [createTextSegment('')] : content,
|
|
105
|
+
...(meta ? { meta } : {}),
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function createDocument(blocks?: Block[]): Document {
|
|
110
|
+
return {
|
|
111
|
+
blocks: blocks && blocks.length > 0 ? blocks : [createBlock('paragraph')],
|
|
112
|
+
version: 1,
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Get the full plain text length of a block's inline content. */
|
|
117
|
+
export function getBlockTextLength(block: Block): number {
|
|
118
|
+
let len = 0
|
|
119
|
+
for (const seg of block.content) {
|
|
120
|
+
if (seg.type === 'text') {
|
|
121
|
+
len += seg.text.length
|
|
122
|
+
} else if (seg.type === 'mention') {
|
|
123
|
+
len += seg.label.length
|
|
124
|
+
} else if (seg.type === 'emoji') {
|
|
125
|
+
len += seg.unicode.length
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return len
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Get the full plain text of a block's inline content. */
|
|
132
|
+
export function getBlockText(block: Block): string {
|
|
133
|
+
let text = ''
|
|
134
|
+
for (const seg of block.content) {
|
|
135
|
+
if (seg.type === 'text') {
|
|
136
|
+
text += seg.text
|
|
137
|
+
} else if (seg.type === 'mention') {
|
|
138
|
+
text += seg.label
|
|
139
|
+
} else if (seg.type === 'emoji') {
|
|
140
|
+
text += seg.unicode
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return text
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Find a block by its id. */
|
|
147
|
+
export function findBlockById(doc: Document, blockId: string): Block | undefined {
|
|
148
|
+
for (const block of doc.blocks) {
|
|
149
|
+
if (block.id === blockId) return block
|
|
150
|
+
if (block.children) {
|
|
151
|
+
for (const child of block.children) {
|
|
152
|
+
if (child.id === blockId) return child
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return undefined
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Find the index of a block by its id. Returns -1 if not found at top level. */
|
|
160
|
+
export function findBlockIndex(doc: Document, blockId: string): number {
|
|
161
|
+
return doc.blocks.findIndex((b) => b.id === blockId)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Deep clone a document. */
|
|
165
|
+
export function cloneDocument(doc: Document): Document {
|
|
166
|
+
return JSON.parse(JSON.stringify(doc)) as Document
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Deep clone a block. */
|
|
170
|
+
export function cloneBlock(block: Block): Block {
|
|
171
|
+
return JSON.parse(JSON.stringify(block)) as Block
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Check that marks are equal (order-insensitive). */
|
|
175
|
+
export function marksEqual(a: Mark[], b: Mark[]): boolean {
|
|
176
|
+
if (a.length !== b.length) return false
|
|
177
|
+
for (const markA of a) {
|
|
178
|
+
const found = b.find(
|
|
179
|
+
(markB) =>
|
|
180
|
+
markB.type === markA.type &&
|
|
181
|
+
JSON.stringify(markB.attrs ?? {}) === JSON.stringify(markA.attrs ?? {}),
|
|
182
|
+
)
|
|
183
|
+
if (!found) return false
|
|
184
|
+
}
|
|
185
|
+
return true
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Normalize a block's inline content — merge adjacent text segments with identical marks. */
|
|
189
|
+
export function normalizeContent(content: InlineContent[]): InlineContent[] {
|
|
190
|
+
const result: InlineContent[] = []
|
|
191
|
+
for (const seg of content) {
|
|
192
|
+
if (seg.type === 'text' && seg.text === '' && content.length > 1) {
|
|
193
|
+
continue // skip empty text segments unless it's the only one
|
|
194
|
+
}
|
|
195
|
+
const last = result[result.length - 1]
|
|
196
|
+
if (seg.type === 'text' && last && last.type === 'text' && marksEqual(last.marks, seg.marks)) {
|
|
197
|
+
last.text += seg.text
|
|
198
|
+
} else {
|
|
199
|
+
result.push({ ...seg, ...(seg.type === 'text' ? { marks: [...seg.marks] } : {}) })
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
if (result.length === 0) {
|
|
203
|
+
return [createTextSegment('')]
|
|
204
|
+
}
|
|
205
|
+
return result
|
|
206
|
+
}
|