@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,772 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Editor operations — pure functions that take (doc, selection) and return new state.
|
|
3
|
+
* Every operation is immutable: documents and selections are never mutated in-place.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type {
|
|
7
|
+
Document,
|
|
8
|
+
Block,
|
|
9
|
+
InlineContent,
|
|
10
|
+
TextSegment,
|
|
11
|
+
Mark,
|
|
12
|
+
MarkType,
|
|
13
|
+
BlockType,
|
|
14
|
+
} from './model.js'
|
|
15
|
+
import {
|
|
16
|
+
cloneDocument,
|
|
17
|
+
createBlock,
|
|
18
|
+
createTextSegment,
|
|
19
|
+
findBlockById,
|
|
20
|
+
findBlockIndex,
|
|
21
|
+
getBlockText,
|
|
22
|
+
getBlockTextLength,
|
|
23
|
+
normalizeContent,
|
|
24
|
+
} from './model.js'
|
|
25
|
+
import type { Selection, Position } from './selection.js'
|
|
26
|
+
import {
|
|
27
|
+
createCollapsedSelection,
|
|
28
|
+
createPosition,
|
|
29
|
+
createSelection,
|
|
30
|
+
getSelectionEnd,
|
|
31
|
+
getSelectionStart,
|
|
32
|
+
isCollapsed,
|
|
33
|
+
isForward,
|
|
34
|
+
} from './selection.js'
|
|
35
|
+
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
// Editor state
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
export interface EditorState {
|
|
40
|
+
doc: Document
|
|
41
|
+
selection: Selection
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
// Internal helpers
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Split a block's inline content at a character offset, returning [before, after].
|
|
50
|
+
*/
|
|
51
|
+
function splitContentAt(
|
|
52
|
+
content: InlineContent[],
|
|
53
|
+
offset: number,
|
|
54
|
+
): [InlineContent[], InlineContent[]] {
|
|
55
|
+
const before: InlineContent[] = []
|
|
56
|
+
const after: InlineContent[] = []
|
|
57
|
+
let pos = 0
|
|
58
|
+
|
|
59
|
+
for (const seg of content) {
|
|
60
|
+
const segLen = seg.type === 'text' ? seg.text.length : seg.type === 'mention' ? seg.label.length : seg.type === 'emoji' ? seg.unicode.length : 0
|
|
61
|
+
|
|
62
|
+
if (pos + segLen <= offset) {
|
|
63
|
+
before.push(structuredClone(seg))
|
|
64
|
+
pos += segLen
|
|
65
|
+
} else if (pos >= offset) {
|
|
66
|
+
after.push(structuredClone(seg))
|
|
67
|
+
pos += segLen
|
|
68
|
+
} else {
|
|
69
|
+
// Split within this segment
|
|
70
|
+
const splitAt = offset - pos
|
|
71
|
+
if (seg.type === 'text') {
|
|
72
|
+
before.push(createTextSegment(seg.text.slice(0, splitAt), [...seg.marks]))
|
|
73
|
+
after.push(createTextSegment(seg.text.slice(splitAt), [...seg.marks]))
|
|
74
|
+
} else {
|
|
75
|
+
// Mention/emoji can't really be split; put whole segment in before
|
|
76
|
+
before.push(structuredClone(seg))
|
|
77
|
+
}
|
|
78
|
+
pos += segLen
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return [
|
|
83
|
+
before.length > 0 ? normalizeContent(before) : [createTextSegment('')],
|
|
84
|
+
after.length > 0 ? normalizeContent(after) : [createTextSegment('')],
|
|
85
|
+
]
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Delete a range of characters from content, returning new content.
|
|
90
|
+
*/
|
|
91
|
+
function deleteContentRange(
|
|
92
|
+
content: InlineContent[],
|
|
93
|
+
start: number,
|
|
94
|
+
end: number,
|
|
95
|
+
): InlineContent[] {
|
|
96
|
+
const [beforeAll] = splitContentAt(content, start)
|
|
97
|
+
const [, afterAll] = splitContentAt(content, end)
|
|
98
|
+
return normalizeContent([...beforeAll, ...afterAll])
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Insert text into content at a given offset, with given marks.
|
|
103
|
+
*/
|
|
104
|
+
function insertTextIntoContent(
|
|
105
|
+
content: InlineContent[],
|
|
106
|
+
offset: number,
|
|
107
|
+
text: string,
|
|
108
|
+
marks: Mark[] = [],
|
|
109
|
+
): InlineContent[] {
|
|
110
|
+
const [before, after] = splitContentAt(content, offset)
|
|
111
|
+
const newSeg = createTextSegment(text, marks)
|
|
112
|
+
return normalizeContent([...before, newSeg, ...after])
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Insert an inline content segment at a given offset.
|
|
117
|
+
*/
|
|
118
|
+
function insertSegmentIntoContent(
|
|
119
|
+
content: InlineContent[],
|
|
120
|
+
offset: number,
|
|
121
|
+
segment: InlineContent,
|
|
122
|
+
): InlineContent[] {
|
|
123
|
+
const [before, after] = splitContentAt(content, offset)
|
|
124
|
+
return normalizeContent([...before, segment, ...after])
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Get the marks at a specific offset within content.
|
|
129
|
+
*/
|
|
130
|
+
function getMarksAtOffset(content: InlineContent[], offset: number): Mark[] {
|
|
131
|
+
let pos = 0
|
|
132
|
+
for (const seg of content) {
|
|
133
|
+
const segLen = seg.type === 'text' ? seg.text.length : seg.type === 'mention' ? seg.label.length : seg.type === 'emoji' ? seg.unicode.length : 0
|
|
134
|
+
if (seg.type === 'text' && pos <= offset && offset <= pos + segLen) {
|
|
135
|
+
return [...seg.marks]
|
|
136
|
+
}
|
|
137
|
+
pos += segLen
|
|
138
|
+
}
|
|
139
|
+
return []
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ---------------------------------------------------------------------------
|
|
143
|
+
// Text operations
|
|
144
|
+
// ---------------------------------------------------------------------------
|
|
145
|
+
|
|
146
|
+
/** Insert text at the current selection. If selection is expanded, delete first. */
|
|
147
|
+
export function insertText(doc: Document, sel: Selection, text: string): EditorState {
|
|
148
|
+
let d = cloneDocument(doc)
|
|
149
|
+
let s = sel
|
|
150
|
+
|
|
151
|
+
// If there's a selection range, delete it first
|
|
152
|
+
if (!isCollapsed(s)) {
|
|
153
|
+
const result = deleteSelection(d, s)
|
|
154
|
+
d = result.doc
|
|
155
|
+
s = result.selection
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const block = findBlockById(d, s.anchor.blockId)
|
|
159
|
+
if (!block) return { doc: d, selection: s }
|
|
160
|
+
|
|
161
|
+
const marks = getMarksAtOffset(block.content, s.anchor.offset)
|
|
162
|
+
block.content = insertTextIntoContent(block.content, s.anchor.offset, text, marks)
|
|
163
|
+
const newOffset = s.anchor.offset + text.length
|
|
164
|
+
const newSel = createCollapsedSelection(block.id, newOffset)
|
|
165
|
+
|
|
166
|
+
return { doc: d, selection: newSel }
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Delete one character backward from the cursor (Backspace). */
|
|
170
|
+
export function deleteBackward(doc: Document, sel: Selection): EditorState {
|
|
171
|
+
if (!isCollapsed(sel)) {
|
|
172
|
+
return deleteSelection(doc, sel)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const d = cloneDocument(doc)
|
|
176
|
+
const block = findBlockById(d, sel.anchor.blockId)
|
|
177
|
+
if (!block) return { doc: d, selection: sel }
|
|
178
|
+
|
|
179
|
+
if (sel.anchor.offset > 0) {
|
|
180
|
+
// Delete one character within the block
|
|
181
|
+
block.content = deleteContentRange(block.content, sel.anchor.offset - 1, sel.anchor.offset)
|
|
182
|
+
return {
|
|
183
|
+
doc: d,
|
|
184
|
+
selection: createCollapsedSelection(block.id, sel.anchor.offset - 1),
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// At start of block — merge with previous block
|
|
189
|
+
return mergeBlockBackward(d, sel)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Delete one character forward from the cursor (Delete key). */
|
|
193
|
+
export function deleteForward(doc: Document, sel: Selection): EditorState {
|
|
194
|
+
if (!isCollapsed(sel)) {
|
|
195
|
+
return deleteSelection(doc, sel)
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const d = cloneDocument(doc)
|
|
199
|
+
const block = findBlockById(d, sel.anchor.blockId)
|
|
200
|
+
if (!block) return { doc: d, selection: sel }
|
|
201
|
+
|
|
202
|
+
const textLen = getBlockTextLength(block)
|
|
203
|
+
if (sel.anchor.offset < textLen) {
|
|
204
|
+
// Delete one character forward within the block
|
|
205
|
+
block.content = deleteContentRange(block.content, sel.anchor.offset, sel.anchor.offset + 1)
|
|
206
|
+
return { doc: d, selection: createCollapsedSelection(block.id, sel.anchor.offset) }
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// At end of block — merge next block into this one
|
|
210
|
+
return mergeBlockForward(d, sel)
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Delete the currently selected content. */
|
|
214
|
+
export function deleteSelection(doc: Document, sel: Selection): EditorState {
|
|
215
|
+
if (isCollapsed(sel)) return { doc: cloneDocument(doc), selection: sel }
|
|
216
|
+
|
|
217
|
+
const d = cloneDocument(doc)
|
|
218
|
+
const start = getSelectionStart(d, sel)
|
|
219
|
+
const end = getSelectionEnd(d, sel)
|
|
220
|
+
|
|
221
|
+
if (start.blockId === end.blockId) {
|
|
222
|
+
// Single block deletion
|
|
223
|
+
const block = findBlockById(d, start.blockId)!
|
|
224
|
+
block.content = deleteContentRange(block.content, start.offset, end.offset)
|
|
225
|
+
return { doc: d, selection: createCollapsedSelection(block.id, start.offset) }
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Multi-block deletion
|
|
229
|
+
const startIdx = findBlockIndex(d, start.blockId)
|
|
230
|
+
const endIdx = findBlockIndex(d, end.blockId)
|
|
231
|
+
|
|
232
|
+
const startBlock = d.blocks[startIdx]
|
|
233
|
+
const endBlock = d.blocks[endIdx]
|
|
234
|
+
|
|
235
|
+
// Keep content before start and after end, merge into start block
|
|
236
|
+
const [beforeContent] = splitContentAt(startBlock.content, start.offset)
|
|
237
|
+
const [, afterContent] = splitContentAt(endBlock.content, end.offset)
|
|
238
|
+
|
|
239
|
+
startBlock.content = normalizeContent([...beforeContent, ...afterContent])
|
|
240
|
+
|
|
241
|
+
// Remove all blocks between start and end (inclusive of end)
|
|
242
|
+
d.blocks.splice(startIdx + 1, endIdx - startIdx)
|
|
243
|
+
|
|
244
|
+
return { doc: d, selection: createCollapsedSelection(startBlock.id, start.offset) }
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// ---------------------------------------------------------------------------
|
|
248
|
+
// Block operations
|
|
249
|
+
// ---------------------------------------------------------------------------
|
|
250
|
+
|
|
251
|
+
/** Insert a new block of the given type after the current block. */
|
|
252
|
+
export function insertBlock(doc: Document, sel: Selection, type: BlockType, meta?: Record<string, unknown>): EditorState {
|
|
253
|
+
const d = cloneDocument(doc)
|
|
254
|
+
const idx = findBlockIndex(d, sel.anchor.blockId)
|
|
255
|
+
if (idx === -1) return { doc: d, selection: sel }
|
|
256
|
+
|
|
257
|
+
const newBlock = createBlock(type, [], meta)
|
|
258
|
+
d.blocks.splice(idx + 1, 0, newBlock)
|
|
259
|
+
|
|
260
|
+
return { doc: d, selection: createCollapsedSelection(newBlock.id, 0) }
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** Split the current block at the cursor position (Enter key). */
|
|
264
|
+
export function splitBlock(doc: Document, sel: Selection): EditorState {
|
|
265
|
+
let d = cloneDocument(doc)
|
|
266
|
+
let s = sel
|
|
267
|
+
|
|
268
|
+
if (!isCollapsed(s)) {
|
|
269
|
+
const result = deleteSelection(d, s)
|
|
270
|
+
d = result.doc
|
|
271
|
+
s = result.selection
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const idx = findBlockIndex(d, s.anchor.blockId)
|
|
275
|
+
if (idx === -1) return { doc: d, selection: s }
|
|
276
|
+
|
|
277
|
+
const block = d.blocks[idx]
|
|
278
|
+
|
|
279
|
+
// For dividers, just insert a new paragraph after
|
|
280
|
+
if (block.type === 'divider') {
|
|
281
|
+
const newBlock = createBlock('paragraph')
|
|
282
|
+
d.blocks.splice(idx + 1, 0, newBlock)
|
|
283
|
+
return { doc: d, selection: createCollapsedSelection(newBlock.id, 0) }
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const [beforeContent, afterContent] = splitContentAt(block.content, s.anchor.offset)
|
|
287
|
+
|
|
288
|
+
block.content = beforeContent
|
|
289
|
+
|
|
290
|
+
// New block inherits type for paragraphs and list items, otherwise becomes paragraph
|
|
291
|
+
const newType: BlockType = block.type === 'list-item' ? 'list-item' : 'paragraph'
|
|
292
|
+
const newBlock = createBlock(newType, afterContent)
|
|
293
|
+
|
|
294
|
+
d.blocks.splice(idx + 1, 0, newBlock)
|
|
295
|
+
|
|
296
|
+
return { doc: d, selection: createCollapsedSelection(newBlock.id, 0) }
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** Merge the current block backward into the previous block (Backspace at start). */
|
|
300
|
+
export function mergeBlockBackward(doc: Document, sel: Selection): EditorState {
|
|
301
|
+
const d = cloneDocument(doc)
|
|
302
|
+
const idx = findBlockIndex(d, sel.anchor.blockId)
|
|
303
|
+
|
|
304
|
+
// If it's a non-paragraph block at start, convert to paragraph first
|
|
305
|
+
if (idx <= 0) {
|
|
306
|
+
const block = d.blocks[idx]
|
|
307
|
+
if (block && block.type !== 'paragraph') {
|
|
308
|
+
block.type = 'paragraph'
|
|
309
|
+
delete block.meta
|
|
310
|
+
return { doc: d, selection: createCollapsedSelection(block.id, 0) }
|
|
311
|
+
}
|
|
312
|
+
return { doc: d, selection: sel }
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const prevBlock = d.blocks[idx - 1]
|
|
316
|
+
const currBlock = d.blocks[idx]
|
|
317
|
+
|
|
318
|
+
// If previous block is a divider, just remove it
|
|
319
|
+
if (prevBlock.type === 'divider') {
|
|
320
|
+
d.blocks.splice(idx - 1, 1)
|
|
321
|
+
return { doc: d, selection: createCollapsedSelection(currBlock.id, 0) }
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const prevLen = getBlockTextLength(prevBlock)
|
|
325
|
+
prevBlock.content = normalizeContent([...prevBlock.content, ...currBlock.content])
|
|
326
|
+
d.blocks.splice(idx, 1)
|
|
327
|
+
|
|
328
|
+
return { doc: d, selection: createCollapsedSelection(prevBlock.id, prevLen) }
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** Merge the next block forward into the current block (Delete at end). */
|
|
332
|
+
export function mergeBlockForward(doc: Document, sel: Selection): EditorState {
|
|
333
|
+
const d = cloneDocument(doc)
|
|
334
|
+
const idx = findBlockIndex(d, sel.anchor.blockId)
|
|
335
|
+
if (idx === -1 || idx >= d.blocks.length - 1) return { doc: d, selection: sel }
|
|
336
|
+
|
|
337
|
+
const currBlock = d.blocks[idx]
|
|
338
|
+
const nextBlock = d.blocks[idx + 1]
|
|
339
|
+
|
|
340
|
+
// If next block is a divider, just remove it
|
|
341
|
+
if (nextBlock.type === 'divider') {
|
|
342
|
+
d.blocks.splice(idx + 1, 1)
|
|
343
|
+
return { doc: d, selection: sel }
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const currLen = getBlockTextLength(currBlock)
|
|
347
|
+
currBlock.content = normalizeContent([...currBlock.content, ...nextBlock.content])
|
|
348
|
+
d.blocks.splice(idx + 1, 1)
|
|
349
|
+
|
|
350
|
+
return { doc: d, selection: createCollapsedSelection(currBlock.id, currLen) }
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** Change the type of the block at the cursor. */
|
|
354
|
+
export function changeBlockType(
|
|
355
|
+
doc: Document,
|
|
356
|
+
sel: Selection,
|
|
357
|
+
type: BlockType,
|
|
358
|
+
meta?: Record<string, unknown>,
|
|
359
|
+
): EditorState {
|
|
360
|
+
const d = cloneDocument(doc)
|
|
361
|
+
const block = findBlockById(d, sel.anchor.blockId)
|
|
362
|
+
if (!block) return { doc: d, selection: sel }
|
|
363
|
+
|
|
364
|
+
block.type = type
|
|
365
|
+
if (meta) {
|
|
366
|
+
block.meta = { ...block.meta, ...meta }
|
|
367
|
+
} else if (type !== block.type) {
|
|
368
|
+
// Clear meta when changing type without new meta, unless it's same type
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// Dividers have no content
|
|
372
|
+
if (type === 'divider') {
|
|
373
|
+
block.content = [createTextSegment('')]
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
return { doc: d, selection: sel }
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/** Indent a block (increase indent level). */
|
|
380
|
+
export function indentBlock(doc: Document, sel: Selection): EditorState {
|
|
381
|
+
const d = cloneDocument(doc)
|
|
382
|
+
const block = findBlockById(d, sel.anchor.blockId)
|
|
383
|
+
if (!block) return { doc: d, selection: sel }
|
|
384
|
+
|
|
385
|
+
const currentIndent = block.indent ?? 0
|
|
386
|
+
if (currentIndent < 5) {
|
|
387
|
+
block.indent = currentIndent + 1
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
return { doc: d, selection: sel }
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/** Outdent a block (decrease indent level). */
|
|
394
|
+
export function outdentBlock(doc: Document, sel: Selection): EditorState {
|
|
395
|
+
const d = cloneDocument(doc)
|
|
396
|
+
const block = findBlockById(d, sel.anchor.blockId)
|
|
397
|
+
if (!block) return { doc: d, selection: sel }
|
|
398
|
+
|
|
399
|
+
const currentIndent = block.indent ?? 0
|
|
400
|
+
if (currentIndent > 0) {
|
|
401
|
+
block.indent = currentIndent - 1
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
return { doc: d, selection: sel }
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// ---------------------------------------------------------------------------
|
|
408
|
+
// Mark operations
|
|
409
|
+
// ---------------------------------------------------------------------------
|
|
410
|
+
|
|
411
|
+
/** Check if a mark of a given type is in the marks array. */
|
|
412
|
+
function hasMarkType(marks: Mark[], type: MarkType): boolean {
|
|
413
|
+
return marks.some((m) => m.type === type)
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/** Get the active marks at the current selection. */
|
|
417
|
+
export function getActiveMarks(doc: Document, sel: Selection): Mark[] {
|
|
418
|
+
const block = findBlockById(doc, sel.anchor.blockId)
|
|
419
|
+
if (!block) return []
|
|
420
|
+
|
|
421
|
+
if (isCollapsed(sel)) {
|
|
422
|
+
// Return marks at the cursor position
|
|
423
|
+
return getMarksAtOffset(block.content, sel.anchor.offset > 0 ? sel.anchor.offset - 1 : 0)
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// For a range selection, return marks common to all characters in the range
|
|
427
|
+
const start = getSelectionStart(doc, sel)
|
|
428
|
+
const end = getSelectionEnd(doc, sel)
|
|
429
|
+
|
|
430
|
+
if (start.blockId !== end.blockId) {
|
|
431
|
+
// For simplicity across blocks, get marks from the start position
|
|
432
|
+
return getMarksAtOffset(block.content, start.offset)
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// Get all marks that apply to every character in the range
|
|
436
|
+
const allMarks: Mark[][] = []
|
|
437
|
+
let pos = 0
|
|
438
|
+
for (const seg of block.content) {
|
|
439
|
+
if (seg.type !== 'text') {
|
|
440
|
+
pos += seg.type === 'mention' ? seg.label.length : seg.type === 'emoji' ? seg.unicode.length : 0
|
|
441
|
+
continue
|
|
442
|
+
}
|
|
443
|
+
const segStart = pos
|
|
444
|
+
const segEnd = pos + seg.text.length
|
|
445
|
+
pos = segEnd
|
|
446
|
+
|
|
447
|
+
// Check if this segment overlaps with the selection
|
|
448
|
+
if (segEnd <= start.offset || segStart >= end.offset) continue
|
|
449
|
+
allMarks.push([...seg.marks])
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
if (allMarks.length === 0) return []
|
|
453
|
+
|
|
454
|
+
// Intersection of all mark arrays
|
|
455
|
+
return allMarks[0].filter((mark) => allMarks.every((marks) => hasMarkType(marks, mark.type)))
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/** Toggle a mark type on the selection. If fully applied, remove; otherwise add. */
|
|
459
|
+
export function toggleMark(doc: Document, sel: Selection, markType: MarkType, attrs?: Record<string, string>): EditorState {
|
|
460
|
+
const active = getActiveMarks(doc, sel)
|
|
461
|
+
const mark: Mark = { type: markType, ...(attrs ? { attrs } : {}) }
|
|
462
|
+
if (hasMarkType(active, markType)) {
|
|
463
|
+
return removeMark(doc, sel, markType)
|
|
464
|
+
}
|
|
465
|
+
return addMark(doc, sel, mark)
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/** Add a mark to the selected range. */
|
|
469
|
+
export function addMark(doc: Document, sel: Selection, mark: Mark): EditorState {
|
|
470
|
+
if (isCollapsed(sel)) {
|
|
471
|
+
// For collapsed selections, we can't apply marks to nothing
|
|
472
|
+
return { doc: cloneDocument(doc), selection: sel }
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
const d = cloneDocument(doc)
|
|
476
|
+
const start = getSelectionStart(d, sel)
|
|
477
|
+
const end = getSelectionEnd(d, sel)
|
|
478
|
+
|
|
479
|
+
// Apply to each block in the selection
|
|
480
|
+
const startIdx = findBlockIndex(d, start.blockId)
|
|
481
|
+
const endIdx = findBlockIndex(d, end.blockId)
|
|
482
|
+
|
|
483
|
+
for (let i = startIdx; i <= endIdx; i++) {
|
|
484
|
+
const block = d.blocks[i]
|
|
485
|
+
const blockStart = i === startIdx ? start.offset : 0
|
|
486
|
+
const blockEnd = i === endIdx ? end.offset : getBlockTextLength(block)
|
|
487
|
+
|
|
488
|
+
block.content = applyMarkToContent(block.content, blockStart, blockEnd, mark)
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
return { doc: d, selection: sel }
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/** Remove a mark type from the selected range. */
|
|
495
|
+
export function removeMark(doc: Document, sel: Selection, markType: MarkType): EditorState {
|
|
496
|
+
if (isCollapsed(sel)) {
|
|
497
|
+
return { doc: cloneDocument(doc), selection: sel }
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
const d = cloneDocument(doc)
|
|
501
|
+
const start = getSelectionStart(d, sel)
|
|
502
|
+
const end = getSelectionEnd(d, sel)
|
|
503
|
+
|
|
504
|
+
const startIdx = findBlockIndex(d, start.blockId)
|
|
505
|
+
const endIdx = findBlockIndex(d, end.blockId)
|
|
506
|
+
|
|
507
|
+
for (let i = startIdx; i <= endIdx; i++) {
|
|
508
|
+
const block = d.blocks[i]
|
|
509
|
+
const blockStart = i === startIdx ? start.offset : 0
|
|
510
|
+
const blockEnd = i === endIdx ? end.offset : getBlockTextLength(block)
|
|
511
|
+
|
|
512
|
+
block.content = removeMarkFromContent(block.content, blockStart, blockEnd, markType)
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
return { doc: d, selection: sel }
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/**
|
|
519
|
+
* Apply a mark to a range within inline content.
|
|
520
|
+
* Splits segments as needed so the mark only applies to the specified range.
|
|
521
|
+
*/
|
|
522
|
+
function applyMarkToContent(
|
|
523
|
+
content: InlineContent[],
|
|
524
|
+
start: number,
|
|
525
|
+
end: number,
|
|
526
|
+
mark: Mark,
|
|
527
|
+
): InlineContent[] {
|
|
528
|
+
const result: InlineContent[] = []
|
|
529
|
+
let pos = 0
|
|
530
|
+
|
|
531
|
+
for (const seg of content) {
|
|
532
|
+
const segLen = seg.type === 'text' ? seg.text.length : seg.type === 'mention' ? seg.label.length : seg.type === 'emoji' ? seg.unicode.length : 0
|
|
533
|
+
const segStart = pos
|
|
534
|
+
const segEnd = pos + segLen
|
|
535
|
+
pos = segEnd
|
|
536
|
+
|
|
537
|
+
if (seg.type !== 'text') {
|
|
538
|
+
result.push(structuredClone(seg))
|
|
539
|
+
continue
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
if (segEnd <= start || segStart >= end) {
|
|
543
|
+
// Outside the range
|
|
544
|
+
result.push(createTextSegment(seg.text, [...seg.marks]))
|
|
545
|
+
} else if (segStart >= start && segEnd <= end) {
|
|
546
|
+
// Fully inside the range
|
|
547
|
+
const newMarks = hasMarkType(seg.marks, mark.type)
|
|
548
|
+
? seg.marks.map((m) => (m.type === mark.type ? { ...mark } : { ...m }))
|
|
549
|
+
: [...seg.marks, { ...mark }]
|
|
550
|
+
result.push(createTextSegment(seg.text, newMarks))
|
|
551
|
+
} else {
|
|
552
|
+
// Partially overlapping — split
|
|
553
|
+
if (segStart < start) {
|
|
554
|
+
result.push(createTextSegment(seg.text.slice(0, start - segStart), [...seg.marks]))
|
|
555
|
+
}
|
|
556
|
+
const overlapStart = Math.max(0, start - segStart)
|
|
557
|
+
const overlapEnd = Math.min(segLen, end - segStart)
|
|
558
|
+
const newMarks = hasMarkType(seg.marks, mark.type)
|
|
559
|
+
? seg.marks.map((m) => (m.type === mark.type ? { ...mark } : { ...m }))
|
|
560
|
+
: [...seg.marks, { ...mark }]
|
|
561
|
+
result.push(createTextSegment(seg.text.slice(overlapStart, overlapEnd), newMarks))
|
|
562
|
+
if (segEnd > end) {
|
|
563
|
+
result.push(createTextSegment(seg.text.slice(end - segStart), [...seg.marks]))
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
return normalizeContent(result)
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
/**
|
|
572
|
+
* Remove a mark type from a range within inline content.
|
|
573
|
+
*/
|
|
574
|
+
function removeMarkFromContent(
|
|
575
|
+
content: InlineContent[],
|
|
576
|
+
start: number,
|
|
577
|
+
end: number,
|
|
578
|
+
markType: MarkType,
|
|
579
|
+
): InlineContent[] {
|
|
580
|
+
const result: InlineContent[] = []
|
|
581
|
+
let pos = 0
|
|
582
|
+
|
|
583
|
+
for (const seg of content) {
|
|
584
|
+
const segLen = seg.type === 'text' ? seg.text.length : seg.type === 'mention' ? seg.label.length : seg.type === 'emoji' ? seg.unicode.length : 0
|
|
585
|
+
const segStart = pos
|
|
586
|
+
const segEnd = pos + segLen
|
|
587
|
+
pos = segEnd
|
|
588
|
+
|
|
589
|
+
if (seg.type !== 'text') {
|
|
590
|
+
result.push(structuredClone(seg))
|
|
591
|
+
continue
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
if (segEnd <= start || segStart >= end) {
|
|
595
|
+
result.push(createTextSegment(seg.text, [...seg.marks]))
|
|
596
|
+
} else if (segStart >= start && segEnd <= end) {
|
|
597
|
+
const newMarks = seg.marks.filter((m) => m.type !== markType)
|
|
598
|
+
result.push(createTextSegment(seg.text, newMarks))
|
|
599
|
+
} else {
|
|
600
|
+
if (segStart < start) {
|
|
601
|
+
result.push(createTextSegment(seg.text.slice(0, start - segStart), [...seg.marks]))
|
|
602
|
+
}
|
|
603
|
+
const overlapStart = Math.max(0, start - segStart)
|
|
604
|
+
const overlapEnd = Math.min(segLen, end - segStart)
|
|
605
|
+
const newMarks = seg.marks.filter((m) => m.type !== markType)
|
|
606
|
+
result.push(createTextSegment(seg.text.slice(overlapStart, overlapEnd), newMarks))
|
|
607
|
+
if (segEnd > end) {
|
|
608
|
+
result.push(createTextSegment(seg.text.slice(end - segStart), [...seg.marks]))
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
return normalizeContent(result)
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
// ---------------------------------------------------------------------------
|
|
617
|
+
// Selection movement operations
|
|
618
|
+
// ---------------------------------------------------------------------------
|
|
619
|
+
|
|
620
|
+
/** Move cursor one position to the left. */
|
|
621
|
+
export function moveLeft(doc: Document, sel: Selection): Selection {
|
|
622
|
+
if (!isCollapsed(sel)) {
|
|
623
|
+
const start = getSelectionStart(doc, sel)
|
|
624
|
+
return createCollapsedSelection(start.blockId, start.offset)
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
const block = findBlockById(doc, sel.anchor.blockId)
|
|
628
|
+
if (!block) return sel
|
|
629
|
+
|
|
630
|
+
if (sel.anchor.offset > 0) {
|
|
631
|
+
return createCollapsedSelection(block.id, sel.anchor.offset - 1)
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
// Move to end of previous block
|
|
635
|
+
const idx = findBlockIndex(doc, block.id)
|
|
636
|
+
if (idx > 0) {
|
|
637
|
+
const prevBlock = doc.blocks[idx - 1]
|
|
638
|
+
return createCollapsedSelection(prevBlock.id, getBlockTextLength(prevBlock))
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
return sel
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
/** Move cursor one position to the right. */
|
|
645
|
+
export function moveRight(doc: Document, sel: Selection): Selection {
|
|
646
|
+
if (!isCollapsed(sel)) {
|
|
647
|
+
const end = getSelectionEnd(doc, sel)
|
|
648
|
+
return createCollapsedSelection(end.blockId, end.offset)
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
const block = findBlockById(doc, sel.anchor.blockId)
|
|
652
|
+
if (!block) return sel
|
|
653
|
+
|
|
654
|
+
const textLen = getBlockTextLength(block)
|
|
655
|
+
if (sel.anchor.offset < textLen) {
|
|
656
|
+
return createCollapsedSelection(block.id, sel.anchor.offset + 1)
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
// Move to start of next block
|
|
660
|
+
const idx = findBlockIndex(doc, block.id)
|
|
661
|
+
if (idx < doc.blocks.length - 1) {
|
|
662
|
+
const nextBlock = doc.blocks[idx + 1]
|
|
663
|
+
return createCollapsedSelection(nextBlock.id, 0)
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
return sel
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
/** Move cursor up one block, trying to preserve offset. */
|
|
670
|
+
export function moveUp(doc: Document, sel: Selection): Selection {
|
|
671
|
+
const pos = isCollapsed(sel) ? sel.anchor : getSelectionStart(doc, sel)
|
|
672
|
+
const idx = findBlockIndex(doc, pos.blockId)
|
|
673
|
+
if (idx <= 0) {
|
|
674
|
+
return createCollapsedSelection(doc.blocks[0].id, 0)
|
|
675
|
+
}
|
|
676
|
+
const prevBlock = doc.blocks[idx - 1]
|
|
677
|
+
const maxOffset = getBlockTextLength(prevBlock)
|
|
678
|
+
return createCollapsedSelection(prevBlock.id, Math.min(pos.offset, maxOffset))
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
/** Move cursor down one block, trying to preserve offset. */
|
|
682
|
+
export function moveDown(doc: Document, sel: Selection): Selection {
|
|
683
|
+
const pos = isCollapsed(sel) ? sel.anchor : getSelectionEnd(doc, sel)
|
|
684
|
+
const idx = findBlockIndex(doc, pos.blockId)
|
|
685
|
+
if (idx >= doc.blocks.length - 1) {
|
|
686
|
+
const lastBlock = doc.blocks[doc.blocks.length - 1]
|
|
687
|
+
return createCollapsedSelection(lastBlock.id, getBlockTextLength(lastBlock))
|
|
688
|
+
}
|
|
689
|
+
const nextBlock = doc.blocks[idx + 1]
|
|
690
|
+
const maxOffset = getBlockTextLength(nextBlock)
|
|
691
|
+
return createCollapsedSelection(nextBlock.id, Math.min(pos.offset, maxOffset))
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
/** Move cursor to the start of the current block. */
|
|
695
|
+
export function moveToStartOfBlock(doc: Document, sel: Selection): Selection {
|
|
696
|
+
return createCollapsedSelection(sel.anchor.blockId, 0)
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
/** Move cursor to the end of the current block. */
|
|
700
|
+
export function moveToEndOfBlock(doc: Document, sel: Selection): Selection {
|
|
701
|
+
const block = findBlockById(doc, sel.anchor.blockId)
|
|
702
|
+
if (!block) return sel
|
|
703
|
+
return createCollapsedSelection(block.id, getBlockTextLength(block))
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
/** Move cursor to the start of the document. */
|
|
707
|
+
export function moveToStartOfDocument(doc: Document): Selection {
|
|
708
|
+
return createCollapsedSelection(doc.blocks[0].id, 0)
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
/** Move cursor to the end of the document. */
|
|
712
|
+
export function moveToEndOfDocument(doc: Document): Selection {
|
|
713
|
+
const last = doc.blocks[doc.blocks.length - 1]
|
|
714
|
+
return createCollapsedSelection(last.id, getBlockTextLength(last))
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
/** Expand selection to the full word surrounding the cursor. */
|
|
718
|
+
export function expandSelectionWord(doc: Document, sel: Selection): Selection {
|
|
719
|
+
const block = findBlockById(doc, sel.anchor.blockId)
|
|
720
|
+
if (!block) return sel
|
|
721
|
+
|
|
722
|
+
const text = getBlockText(block)
|
|
723
|
+
const offset = sel.anchor.offset
|
|
724
|
+
|
|
725
|
+
// Find word boundaries
|
|
726
|
+
let start = offset
|
|
727
|
+
let end = offset
|
|
728
|
+
|
|
729
|
+
const isWordChar = (ch: string) => /\w/.test(ch)
|
|
730
|
+
|
|
731
|
+
while (start > 0 && isWordChar(text[start - 1])) start--
|
|
732
|
+
while (end < text.length && isWordChar(text[end])) end++
|
|
733
|
+
|
|
734
|
+
return createSelection(
|
|
735
|
+
createPosition(block.id, start),
|
|
736
|
+
createPosition(block.id, end),
|
|
737
|
+
)
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
/** Insert an inline content segment (mention, emoji) at the current selection. */
|
|
741
|
+
export function insertInlineContent(
|
|
742
|
+
doc: Document,
|
|
743
|
+
sel: Selection,
|
|
744
|
+
segment: InlineContent,
|
|
745
|
+
): EditorState {
|
|
746
|
+
let d = cloneDocument(doc)
|
|
747
|
+
let s = sel
|
|
748
|
+
|
|
749
|
+
if (!isCollapsed(s)) {
|
|
750
|
+
const result = deleteSelection(d, s)
|
|
751
|
+
d = result.doc
|
|
752
|
+
s = result.selection
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
const block = findBlockById(d, s.anchor.blockId)
|
|
756
|
+
if (!block) return { doc: d, selection: s }
|
|
757
|
+
|
|
758
|
+
block.content = insertSegmentIntoContent(block.content, s.anchor.offset, segment)
|
|
759
|
+
|
|
760
|
+
const segLen = segment.type === 'text'
|
|
761
|
+
? segment.text.length
|
|
762
|
+
: segment.type === 'mention'
|
|
763
|
+
? segment.label.length
|
|
764
|
+
: segment.type === 'emoji'
|
|
765
|
+
? segment.unicode.length
|
|
766
|
+
: 0
|
|
767
|
+
|
|
768
|
+
return {
|
|
769
|
+
doc: d,
|
|
770
|
+
selection: createCollapsedSelection(block.id, s.anchor.offset + segLen),
|
|
771
|
+
}
|
|
772
|
+
}
|