@refraction-ui/react 0.19.1 → 0.20.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/dist/chunk-533MCMSU.js +2355 -0
- package/dist/chunk-533MCMSU.js.map +7 -0
- package/dist/chunk-AMK4VCBG.js +250 -0
- package/dist/chunk-AMK4VCBG.js.map +7 -0
- package/dist/chunk-BOLFYVI6.js +90 -0
- package/dist/chunk-BOLFYVI6.js.map +7 -0
- package/dist/chunk-F7B64KT3.js +187 -0
- package/dist/chunk-F7B64KT3.js.map +7 -0
- package/dist/chunk-FD37DFCC.js +77 -0
- package/dist/chunk-FD37DFCC.js.map +7 -0
- package/dist/chunk-H54FLNAF.js +132 -0
- package/dist/chunk-H54FLNAF.js.map +7 -0
- package/dist/chunk-INP46354.js +664 -0
- package/dist/chunk-INP46354.js.map +7 -0
- package/dist/chunk-ONACRMG4.js +116 -0
- package/dist/chunk-ONACRMG4.js.map +7 -0
- package/dist/chunk-SDZ3LROZ.js +7 -0
- package/dist/chunk-SDZ3LROZ.js.map +7 -0
- package/dist/chunk-TOZBR4GL.js +154 -0
- package/dist/chunk-TOZBR4GL.js.map +7 -0
- package/dist/chunk-UNEM2TRI.js +225 -0
- package/dist/chunk-UNEM2TRI.js.map +7 -0
- package/dist/chunk-VK3EQ34F.js +92 -0
- package/dist/chunk-VK3EQ34F.js.map +7 -0
- package/dist/chunk-WK2IIUFO.js +316 -0
- package/dist/chunk-WK2IIUFO.js.map +7 -0
- package/dist/chunk-WSPJTQAV.js +59 -0
- package/dist/chunk-WSPJTQAV.js.map +7 -0
- package/dist/chunk-XWP763SH.js +3 -2
- package/dist/chunk-XWP763SH.js.map +7 -1
- package/dist/chunk-Y2FNNAIL.js +80 -0
- package/dist/chunk-Y2FNNAIL.js.map +1 -0
- package/dist/{chunk-4A4BW3GR.js → chunk-ZF3OHCGV.js} +28 -5
- package/dist/chunk-ZF3OHCGV.js.map +7 -0
- package/dist/form.cjs +5 -11
- package/dist/form.cjs.map +1 -1
- package/dist/form.js +6 -12
- package/dist/form.js.map +1 -1
- package/dist/index.cjs +2879 -33936
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +126 -28780
- package/dist/internal/react-app-shell/index.d.cts +2 -22
- package/dist/internal/react-app-shell/index.d.ts +2 -22
- package/dist/internal/react-badge/index.d.cts +5 -0
- package/dist/internal/react-badge/index.d.ts +5 -0
- package/dist/internal/react-callout/index.d.cts +4 -0
- package/dist/internal/react-callout/index.d.ts +4 -0
- package/dist/internal/react-card/index.d.cts +8 -1
- package/dist/internal/react-card/index.d.ts +8 -1
- package/dist/internal/react-collapsible/index.d.cts +0 -3
- package/dist/internal/react-collapsible/index.d.ts +0 -3
- package/dist/internal/react-combobox/index.d.cts +0 -3
- package/dist/internal/react-combobox/index.d.ts +0 -3
- package/dist/internal/react-command/index.d.cts +0 -3
- package/dist/internal/react-command/index.d.ts +0 -3
- package/dist/internal/react-data-table/index.d.cts +0 -3
- package/dist/internal/react-data-table/index.d.ts +0 -3
- package/dist/internal/react-dialog/index.d.cts +0 -3
- package/dist/internal/react-dialog/index.d.ts +0 -3
- package/dist/internal/react-dropdown-menu/index.d.cts +0 -3
- package/dist/internal/react-dropdown-menu/index.d.ts +0 -3
- package/dist/internal/react-feedback-dialog/index.d.cts +0 -3
- package/dist/internal/react-feedback-dialog/index.d.ts +0 -3
- package/dist/internal/react-form/index.d.cts +2 -21
- package/dist/internal/react-form/index.d.ts +2 -21
- package/dist/internal/react-keyboard-shortcut/index.d.cts +0 -6
- package/dist/internal/react-keyboard-shortcut/index.d.ts +0 -6
- package/dist/internal/react-link-card/index.d.cts +8 -2
- package/dist/internal/react-link-card/index.d.ts +8 -2
- package/dist/internal/react-popover/index.d.cts +0 -3
- package/dist/internal/react-popover/index.d.ts +0 -3
- package/dist/internal/react-sheet/index.d.cts +0 -3
- package/dist/internal/react-sheet/index.d.ts +0 -3
- package/dist/internal/react-slot/index.d.cts +23 -0
- package/dist/internal/react-slot/index.d.ts +23 -0
- package/dist/internal/react-toast/index.d.cts +0 -3
- package/dist/internal/react-toast/index.d.ts +0 -3
- package/dist/internal/react-tooltip/index.d.cts +0 -3
- package/dist/internal/react-tooltip/index.d.ts +0 -3
- package/dist/react-accordion.js +125 -0
- package/dist/react-accordion.js.map +7 -0
- package/dist/react-ai.js +257 -0
- package/dist/react-ai.js.map +7 -0
- package/dist/react-analytics.js +1698 -0
- package/dist/react-analytics.js.map +7 -0
- package/dist/react-animated-text.js +164 -0
- package/dist/react-animated-text.js.map +7 -0
- package/dist/react-app-shell.js +631 -0
- package/dist/react-app-shell.js.map +7 -0
- package/dist/react-audience-feature-card.js +53 -0
- package/dist/react-audience-feature-card.js.map +7 -0
- package/dist/react-audio-room.js +166 -0
- package/dist/react-audio-room.js.map +7 -0
- package/dist/react-auth.js +211 -0
- package/dist/react-auth.js.map +7 -0
- package/dist/react-avatar-group.js +154 -0
- package/dist/react-avatar-group.js.map +7 -0
- package/dist/react-avatar.js +168 -0
- package/dist/react-avatar.js.map +7 -0
- package/dist/react-badge.js +86 -0
- package/dist/react-badge.js.map +7 -0
- package/dist/react-bottom-nav.js +67 -0
- package/dist/react-bottom-nav.js.map +7 -0
- package/dist/react-brand-network-cell.js +92 -0
- package/dist/react-brand-network-cell.js.map +7 -0
- package/dist/react-breadcrumbs.js +104 -0
- package/dist/react-breadcrumbs.js.map +7 -0
- package/dist/react-browser-chrome-mock.js +89 -0
- package/dist/react-browser-chrome-mock.js.map +7 -0
- package/dist/react-button.js +13 -0
- package/dist/react-button.js.map +7 -0
- package/dist/react-calendar.js +301 -0
- package/dist/react-calendar.js.map +7 -0
- package/dist/react-call-controls.js +113 -0
- package/dist/react-call-controls.js.map +7 -0
- package/dist/react-callout.js +152 -0
- package/dist/react-callout.js.map +7 -0
- package/dist/react-card-grid.js +34 -0
- package/dist/react-card-grid.js.map +7 -0
- package/dist/react-card.js +200 -0
- package/dist/react-card.js.map +7 -0
- package/dist/react-carousel.js +132 -0
- package/dist/react-carousel.js.map +7 -0
- package/dist/react-charts.js +509 -0
- package/dist/react-charts.js.map +7 -0
- package/dist/react-checkbox.js +156 -0
- package/dist/react-checkbox.js.map +7 -0
- package/dist/react-checklist.js +168 -0
- package/dist/react-checklist.js.map +7 -0
- package/dist/react-code-block.js +78 -0
- package/dist/react-code-block.js.map +7 -0
- package/dist/react-code-editor.js +184 -0
- package/dist/react-code-editor.js.map +7 -0
- package/dist/react-collapsible.js +192 -0
- package/dist/react-collapsible.js.map +7 -0
- package/dist/react-combobox.js +609 -0
- package/dist/react-combobox.js.map +7 -0
- package/dist/react-command-input.js +202 -0
- package/dist/react-command-input.js.map +7 -0
- package/dist/react-command.js +364 -0
- package/dist/react-command.js.map +7 -0
- package/dist/react-composer.js +2036 -0
- package/dist/react-composer.js.map +7 -0
- package/dist/react-content-protection.js +133 -0
- package/dist/react-content-protection.js.map +7 -0
- package/dist/react-conversation.js +1226 -0
- package/dist/react-conversation.js.map +7 -0
- package/dist/react-cookie-consent.js +291 -0
- package/dist/react-cookie-consent.js.map +7 -0
- package/dist/react-data-table.js +310 -0
- package/dist/react-data-table.js.map +7 -0
- package/dist/react-date-picker.js +95 -0
- package/dist/react-date-picker.js.map +7 -0
- package/dist/react-device-frame.js +137 -0
- package/dist/react-device-frame.js.map +7 -0
- package/dist/react-dialog.js +218 -0
- package/dist/react-dialog.js.map +7 -0
- package/dist/react-diff-viewer.js +460 -0
- package/dist/react-diff-viewer.js.map +7 -0
- package/dist/react-dropdown-menu.js +292 -0
- package/dist/react-dropdown-menu.js.map +7 -0
- package/dist/react-editor-status-bar.js +148 -0
- package/dist/react-editor-status-bar.js.map +7 -0
- package/dist/react-editor-tabs.js +159 -0
- package/dist/react-editor-tabs.js.map +7 -0
- package/dist/react-emoji-picker.js +238 -0
- package/dist/react-emoji-picker.js.map +7 -0
- package/dist/react-empty-state.js +90 -0
- package/dist/react-empty-state.js.map +7 -0
- package/dist/react-feedback-dialog.js +275 -0
- package/dist/react-feedback-dialog.js.map +7 -0
- package/dist/react-file-tree.js +376 -0
- package/dist/react-file-tree.js.map +7 -0
- package/dist/react-file-upload.js +360 -0
- package/dist/react-file-upload.js.map +7 -0
- package/dist/react-floating-reactions.js +124 -0
- package/dist/react-floating-reactions.js.map +7 -0
- package/dist/react-flow-editor.js +198 -0
- package/dist/react-flow-editor.js.map +7 -0
- package/dist/react-footer.js +67 -0
- package/dist/react-footer.js.map +7 -0
- package/dist/react-graph-view.js +197 -0
- package/dist/react-graph-view.js.map +7 -0
- package/dist/react-icon-system.js +9 -0
- package/dist/react-icon-system.js.map +7 -0
- package/dist/react-infinite-canvas.js +8 -0
- package/dist/react-infinite-canvas.js.map +7 -0
- package/dist/react-inline-editor.js +216 -0
- package/dist/react-inline-editor.js.map +7 -0
- package/dist/react-input-group.js +150 -0
- package/dist/react-input-group.js.map +7 -0
- package/dist/react-input.js +10 -0
- package/dist/react-input.js.map +7 -0
- package/dist/react-install-prompt.js +132 -0
- package/dist/react-install-prompt.js.map +7 -0
- package/dist/react-kanban-board.js +198 -0
- package/dist/react-kanban-board.js.map +7 -0
- package/dist/react-keyboard-shortcut.js +36 -0
- package/dist/react-keyboard-shortcut.js.map +7 -0
- package/dist/react-language-selector.js +311 -0
- package/dist/react-language-selector.js.map +7 -0
- package/dist/react-link-card.js +48 -0
- package/dist/react-link-card.js.map +7 -0
- package/dist/react-live-captions.js +92 -0
- package/dist/react-live-captions.js.map +7 -0
- package/dist/react-live-cursors.js +145 -0
- package/dist/react-live-cursors.js.map +7 -0
- package/dist/react-live-transcript.js +117 -0
- package/dist/react-live-transcript.js.map +7 -0
- package/dist/react-location-selector.js +252 -0
- package/dist/react-location-selector.js.map +7 -0
- package/dist/react-logger.js +415 -0
- package/dist/react-logger.js.map +7 -0
- package/dist/react-markdown-renderer.js +12 -0
- package/dist/react-markdown-renderer.js.map +7 -0
- package/dist/react-marquee-strip.js +62 -0
- package/dist/react-marquee-strip.js.map +7 -0
- package/dist/react-mascot.js +367 -0
- package/dist/react-mascot.js.map +7 -0
- package/dist/react-mastery-bar.js +97 -0
- package/dist/react-mastery-bar.js.map +7 -0
- package/dist/react-mini-map.js +207 -0
- package/dist/react-mini-map.js.map +7 -0
- package/dist/react-mobile-nav.js +216 -0
- package/dist/react-mobile-nav.js.map +7 -0
- package/dist/react-navbar.js +96 -0
- package/dist/react-navbar.js.map +7 -0
- package/dist/react-numbered-steps.js +67 -0
- package/dist/react-numbered-steps.js.map +7 -0
- package/dist/react-otp-input.js +318 -0
- package/dist/react-otp-input.js.map +7 -0
- package/dist/react-pagination.js +199 -0
- package/dist/react-pagination.js.map +7 -0
- package/dist/react-password-input.js +104 -0
- package/dist/react-password-input.js.map +7 -0
- package/dist/react-payment.js +81 -0
- package/dist/react-payment.js.map +7 -0
- package/dist/react-popover.js +176 -0
- package/dist/react-popover.js.map +7 -0
- package/dist/react-pre-call-lobby.js +309 -0
- package/dist/react-pre-call-lobby.js.map +7 -0
- package/dist/react-presence-indicator.js +86 -0
- package/dist/react-presence-indicator.js.map +7 -0
- package/dist/react-pricing-card.js +142 -0
- package/dist/react-pricing-card.js.map +7 -0
- package/dist/react-progress-display.js +188 -0
- package/dist/react-progress-display.js.map +7 -0
- package/dist/react-radial-gauge.js +193 -0
- package/dist/react-radial-gauge.js.map +7 -0
- package/dist/react-radio.js +169 -0
- package/dist/react-radio.js.map +7 -0
- package/dist/react-rating-scale.js +171 -0
- package/dist/react-rating-scale.js.map +7 -0
- package/dist/react-reaction-bar.js +111 -0
- package/dist/react-reaction-bar.js.map +7 -0
- package/dist/react-resizable-layout.js +298 -0
- package/dist/react-resizable-layout.js.map +7 -0
- package/dist/react-rich-editor.js +1 -0
- package/dist/react-rich-editor.js.map +7 -0
- package/dist/react-search-bar.js +285 -0
- package/dist/react-search-bar.js.map +7 -0
- package/dist/react-section-head.js +65 -0
- package/dist/react-section-head.js.map +7 -0
- package/dist/react-segmented-control.js +215 -0
- package/dist/react-segmented-control.js.map +7 -0
- package/dist/react-select.js +389 -0
- package/dist/react-select.js.map +7 -0
- package/dist/react-separator.js +86 -0
- package/dist/react-separator.js.map +7 -0
- package/dist/react-sheet.js +310 -0
- package/dist/react-sheet.js.map +7 -0
- package/dist/react-sidebar.js +100 -0
- package/dist/react-sidebar.js.map +7 -0
- package/dist/react-skeleton.js +91 -0
- package/dist/react-skeleton.js.map +7 -0
- package/dist/react-skip-to-content.js +38 -0
- package/dist/react-skip-to-content.js.map +7 -0
- package/dist/react-slide-viewer.js +264 -0
- package/dist/react-slide-viewer.js.map +7 -0
- package/dist/react-slider.js +105 -0
- package/dist/react-slider.js.map +7 -0
- package/dist/react-slot-picker.js +184 -0
- package/dist/react-slot-picker.js.map +7 -0
- package/dist/react-slot.js +7 -0
- package/dist/react-slot.js.map +7 -0
- package/dist/react-social-auth-button.js +169 -0
- package/dist/react-social-auth-button.js.map +7 -0
- package/dist/react-sortable-list.js +204 -0
- package/dist/react-sortable-list.js.map +7 -0
- package/dist/react-stat-grid.js +69 -0
- package/dist/react-stat-grid.js.map +7 -0
- package/dist/react-status-indicator.js +117 -0
- package/dist/react-status-indicator.js.map +7 -0
- package/dist/react-steps.js +167 -0
- package/dist/react-steps.js.map +7 -0
- package/dist/react-sticky-note.js +131 -0
- package/dist/react-sticky-note.js.map +7 -0
- package/dist/react-switch.js +139 -0
- package/dist/react-switch.js.map +7 -0
- package/dist/react-table-of-contents.js +80 -0
- package/dist/react-table-of-contents.js.map +7 -0
- package/dist/react-tabs.js +230 -0
- package/dist/react-tabs.js.map +7 -0
- package/dist/react-terminal.js +101 -0
- package/dist/react-terminal.js.map +7 -0
- package/dist/react-test-results.js +158 -0
- package/dist/react-test-results.js.map +7 -0
- package/dist/react-textarea.js +89 -0
- package/dist/react-textarea.js.map +7 -0
- package/dist/react-thread-view.js +139 -0
- package/dist/react-thread-view.js.map +7 -0
- package/dist/react-timeline.js +153 -0
- package/dist/react-timeline.js.map +7 -0
- package/dist/react-toast.js +253 -0
- package/dist/react-toast.js.map +7 -0
- package/dist/react-tooltip.js +221 -0
- package/dist/react-tooltip.js.map +7 -0
- package/dist/react-version-selector.js +270 -0
- package/dist/react-version-selector.js.map +7 -0
- package/dist/react-video-grid.js +129 -0
- package/dist/react-video-grid.js.map +7 -0
- package/dist/react-video-player.js +206 -0
- package/dist/react-video-player.js.map +7 -0
- package/dist/react-video-tile.js +8 -0
- package/dist/react-video-tile.js.map +7 -0
- package/dist/react-voice-pill.js +314 -0
- package/dist/react-voice-pill.js.map +7 -0
- package/dist/react-waveform.js +558 -0
- package/dist/react-waveform.js.map +7 -0
- package/dist/react-wizard.js +337 -0
- package/dist/react-wizard.js.map +7 -0
- package/dist/styles.css +7 -0
- package/dist/theme.js +1 -1
- package/package.json +3 -2
- package/dist/applicationinsights-web-H7OBLIVJ.js +0 -23393
- package/dist/applicationinsights-web-H7OBLIVJ.js.map +0 -1
- package/dist/chunk-4A4BW3GR.js.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/module-X3HTTRQK.js +0 -6438
- package/dist/module-X3HTTRQK.js.map +0 -1
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import {
|
|
2
|
+
cn,
|
|
3
|
+
cva
|
|
4
|
+
} from "./chunk-ZF3OHCGV.js";
|
|
5
|
+
|
|
6
|
+
// ../react-infinite-canvas/dist/index.js
|
|
7
|
+
import * as React from "react";
|
|
8
|
+
|
|
9
|
+
// ../infinite-canvas/dist/index.js
|
|
10
|
+
function clampZoom(zoom, min = 0.25, max = 4) {
|
|
11
|
+
return Math.min(Math.max(zoom, min), max);
|
|
12
|
+
}
|
|
13
|
+
function fitTransform(bounds, viewportW, viewportH, padding = 32) {
|
|
14
|
+
const contentW = bounds.maxX - bounds.minX;
|
|
15
|
+
const contentH = bounds.maxY - bounds.minY;
|
|
16
|
+
if (contentW <= 0 || contentH <= 0 || viewportW <= 0 || viewportH <= 0) {
|
|
17
|
+
return { zoom: 1, x: 0, y: 0 };
|
|
18
|
+
}
|
|
19
|
+
const availW = viewportW - padding * 2;
|
|
20
|
+
const availH = viewportH - padding * 2;
|
|
21
|
+
const rawZoom = Math.min(availW / contentW, availH / contentH);
|
|
22
|
+
const zoom = clampZoom(rawZoom);
|
|
23
|
+
const scaledW = contentW * zoom;
|
|
24
|
+
const scaledH = contentH * zoom;
|
|
25
|
+
const x = (viewportW - scaledW) / 2 - bounds.minX * zoom;
|
|
26
|
+
const y = (viewportH - scaledH) / 2 - bounds.minY * zoom;
|
|
27
|
+
return { zoom, x, y };
|
|
28
|
+
}
|
|
29
|
+
function transformToCss(t) {
|
|
30
|
+
return `translate(${t.x}px, ${t.y}px) scale(${t.zoom})`;
|
|
31
|
+
}
|
|
32
|
+
function createInfiniteCanvas(options = {}) {
|
|
33
|
+
const { zoom = 1 } = options;
|
|
34
|
+
return {
|
|
35
|
+
ariaProps: {
|
|
36
|
+
role: "group",
|
|
37
|
+
"aria-label": "Canvas"
|
|
38
|
+
},
|
|
39
|
+
dataAttributes: {
|
|
40
|
+
"data-zoom": zoom
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
var infiniteCanvasVariants = cva({
|
|
45
|
+
base: "relative overflow-hidden bg-background",
|
|
46
|
+
variants: {
|
|
47
|
+
grid: {
|
|
48
|
+
"true": [
|
|
49
|
+
"bg-[radial-gradient(circle,hsl(var(--border))_1px,transparent_1px)]",
|
|
50
|
+
"[background-size:24px_24px]"
|
|
51
|
+
].join(" "),
|
|
52
|
+
"false": ""
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
defaultVariants: {
|
|
56
|
+
grid: "false"
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
var canvasContentClass = "absolute inset-0 origin-top-left will-change-transform";
|
|
60
|
+
var canvasControlsClass = "absolute bottom-4 right-4 z-10 flex items-center gap-1";
|
|
61
|
+
var canvasZoomButtonVariants = cva({
|
|
62
|
+
base: [
|
|
63
|
+
"inline-flex h-8 w-8 items-center justify-center rounded-md border border-border",
|
|
64
|
+
"bg-card text-foreground text-sm font-medium",
|
|
65
|
+
"hover:bg-muted transition-colors",
|
|
66
|
+
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
|
|
67
|
+
"disabled:pointer-events-none disabled:opacity-50"
|
|
68
|
+
].join(" "),
|
|
69
|
+
variants: {},
|
|
70
|
+
defaultVariants: {}
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
// ../react-infinite-canvas/dist/index.js
|
|
74
|
+
import { jsxs, jsx } from "react/jsx-runtime";
|
|
75
|
+
var InfiniteCanvas = React.forwardRef(
|
|
76
|
+
function InfiniteCanvas2({
|
|
77
|
+
zoom: zoomProp,
|
|
78
|
+
x: xProp,
|
|
79
|
+
y: yProp,
|
|
80
|
+
pan = true,
|
|
81
|
+
minZoom = 0.25,
|
|
82
|
+
maxZoom = 4,
|
|
83
|
+
showGrid = false,
|
|
84
|
+
showControls = false,
|
|
85
|
+
onTransformChange,
|
|
86
|
+
contentBounds,
|
|
87
|
+
className,
|
|
88
|
+
children,
|
|
89
|
+
...props
|
|
90
|
+
}, ref) {
|
|
91
|
+
const isControlled = zoomProp !== void 0 || xProp !== void 0 || yProp !== void 0;
|
|
92
|
+
const [internal, setInternal] = React.useState({
|
|
93
|
+
zoom: 1,
|
|
94
|
+
x: 0,
|
|
95
|
+
y: 0
|
|
96
|
+
});
|
|
97
|
+
const transform = isControlled ? { zoom: zoomProp ?? 1, x: xProp ?? 0, y: yProp ?? 0 } : internal;
|
|
98
|
+
const containerRef = React.useRef(null);
|
|
99
|
+
const dragging = React.useRef(false);
|
|
100
|
+
const dragStart = React.useRef({ px: 0, py: 0, tx: 0, ty: 0 });
|
|
101
|
+
const applyTransform = React.useCallback(
|
|
102
|
+
(next) => {
|
|
103
|
+
if (!isControlled) setInternal(next);
|
|
104
|
+
onTransformChange?.(next);
|
|
105
|
+
},
|
|
106
|
+
[isControlled, onTransformChange]
|
|
107
|
+
);
|
|
108
|
+
React.useEffect(() => {
|
|
109
|
+
const el = containerRef.current;
|
|
110
|
+
if (!el) return;
|
|
111
|
+
const onWheel = (e) => {
|
|
112
|
+
e.preventDefault();
|
|
113
|
+
setInternal((prev) => {
|
|
114
|
+
const delta = -e.deltaY * 1e-3;
|
|
115
|
+
const next = {
|
|
116
|
+
...prev,
|
|
117
|
+
zoom: clampZoom(prev.zoom + prev.zoom * delta, minZoom, maxZoom)
|
|
118
|
+
};
|
|
119
|
+
onTransformChange?.(next);
|
|
120
|
+
return next;
|
|
121
|
+
});
|
|
122
|
+
};
|
|
123
|
+
el.addEventListener("wheel", onWheel, { passive: false });
|
|
124
|
+
return () => el.removeEventListener("wheel", onWheel);
|
|
125
|
+
}, [minZoom, maxZoom, onTransformChange]);
|
|
126
|
+
React.useEffect(() => {
|
|
127
|
+
if (!pan || isControlled) return;
|
|
128
|
+
const el = containerRef.current;
|
|
129
|
+
if (!el) return;
|
|
130
|
+
const onPointerDown = (e) => {
|
|
131
|
+
dragging.current = true;
|
|
132
|
+
dragStart.current = {
|
|
133
|
+
px: e.clientX,
|
|
134
|
+
py: e.clientY,
|
|
135
|
+
tx: internal.x,
|
|
136
|
+
ty: internal.y
|
|
137
|
+
};
|
|
138
|
+
el.setPointerCapture(e.pointerId);
|
|
139
|
+
};
|
|
140
|
+
const onPointerMove = (e) => {
|
|
141
|
+
if (!dragging.current) return;
|
|
142
|
+
const dx = e.clientX - dragStart.current.px;
|
|
143
|
+
const dy = e.clientY - dragStart.current.py;
|
|
144
|
+
setInternal((prev) => {
|
|
145
|
+
const next = {
|
|
146
|
+
...prev,
|
|
147
|
+
x: dragStart.current.tx + dx,
|
|
148
|
+
y: dragStart.current.ty + dy
|
|
149
|
+
};
|
|
150
|
+
onTransformChange?.(next);
|
|
151
|
+
return next;
|
|
152
|
+
});
|
|
153
|
+
};
|
|
154
|
+
const onPointerUp = () => {
|
|
155
|
+
dragging.current = false;
|
|
156
|
+
};
|
|
157
|
+
el.addEventListener("pointerdown", onPointerDown);
|
|
158
|
+
el.addEventListener("pointermove", onPointerMove);
|
|
159
|
+
el.addEventListener("pointerup", onPointerUp);
|
|
160
|
+
el.addEventListener("pointercancel", onPointerUp);
|
|
161
|
+
return () => {
|
|
162
|
+
el.removeEventListener("pointerdown", onPointerDown);
|
|
163
|
+
el.removeEventListener("pointermove", onPointerMove);
|
|
164
|
+
el.removeEventListener("pointerup", onPointerUp);
|
|
165
|
+
el.removeEventListener("pointercancel", onPointerUp);
|
|
166
|
+
};
|
|
167
|
+
}, [pan, isControlled, onTransformChange, internal.x, internal.y]);
|
|
168
|
+
const handleZoomIn = () => {
|
|
169
|
+
const next = { ...transform, zoom: clampZoom(transform.zoom * 1.25, minZoom, maxZoom) };
|
|
170
|
+
applyTransform(next);
|
|
171
|
+
};
|
|
172
|
+
const handleZoomOut = () => {
|
|
173
|
+
const next = { ...transform, zoom: clampZoom(transform.zoom / 1.25, minZoom, maxZoom) };
|
|
174
|
+
applyTransform(next);
|
|
175
|
+
};
|
|
176
|
+
const handleFit = () => {
|
|
177
|
+
const el = containerRef.current;
|
|
178
|
+
if (!el) return;
|
|
179
|
+
const { width, height } = el.getBoundingClientRect();
|
|
180
|
+
const bounds = contentBounds ?? { minX: 0, minY: 0, maxX: width, maxY: height };
|
|
181
|
+
applyTransform(fitTransform(bounds, width, height));
|
|
182
|
+
};
|
|
183
|
+
const api = createInfiniteCanvas({ zoom: transform.zoom });
|
|
184
|
+
const setRefs = (node) => {
|
|
185
|
+
containerRef.current = node;
|
|
186
|
+
if (typeof ref === "function") ref(node);
|
|
187
|
+
else if (ref) ref.current = node;
|
|
188
|
+
};
|
|
189
|
+
return /* @__PURE__ */ jsxs(
|
|
190
|
+
"div",
|
|
191
|
+
{
|
|
192
|
+
ref: setRefs,
|
|
193
|
+
className: cn(
|
|
194
|
+
infiniteCanvasVariants({ grid: showGrid ? "true" : "false" }),
|
|
195
|
+
className
|
|
196
|
+
),
|
|
197
|
+
...api.ariaProps,
|
|
198
|
+
...api.dataAttributes,
|
|
199
|
+
...props,
|
|
200
|
+
children: [
|
|
201
|
+
/* @__PURE__ */ jsx(
|
|
202
|
+
"div",
|
|
203
|
+
{
|
|
204
|
+
className: canvasContentClass,
|
|
205
|
+
style: { transform: transformToCss(transform), transformOrigin: "0 0" },
|
|
206
|
+
children
|
|
207
|
+
}
|
|
208
|
+
),
|
|
209
|
+
showControls && /* @__PURE__ */ jsxs("div", { className: canvasControlsClass, "aria-label": "Zoom controls", children: [
|
|
210
|
+
/* @__PURE__ */ jsx(
|
|
211
|
+
"button",
|
|
212
|
+
{
|
|
213
|
+
type: "button",
|
|
214
|
+
className: canvasZoomButtonVariants(),
|
|
215
|
+
"aria-label": "Zoom in",
|
|
216
|
+
onClick: handleZoomIn,
|
|
217
|
+
children: "+"
|
|
218
|
+
}
|
|
219
|
+
),
|
|
220
|
+
/* @__PURE__ */ jsx(
|
|
221
|
+
"button",
|
|
222
|
+
{
|
|
223
|
+
type: "button",
|
|
224
|
+
className: canvasZoomButtonVariants(),
|
|
225
|
+
"aria-label": "Zoom out",
|
|
226
|
+
onClick: handleZoomOut,
|
|
227
|
+
children: "\u2212"
|
|
228
|
+
}
|
|
229
|
+
),
|
|
230
|
+
/* @__PURE__ */ jsx(
|
|
231
|
+
"button",
|
|
232
|
+
{
|
|
233
|
+
type: "button",
|
|
234
|
+
className: canvasZoomButtonVariants(),
|
|
235
|
+
"aria-label": "Fit to content",
|
|
236
|
+
onClick: handleFit,
|
|
237
|
+
children: "\u229E"
|
|
238
|
+
}
|
|
239
|
+
)
|
|
240
|
+
] })
|
|
241
|
+
]
|
|
242
|
+
}
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
);
|
|
246
|
+
|
|
247
|
+
export {
|
|
248
|
+
InfiniteCanvas
|
|
249
|
+
};
|
|
250
|
+
//# sourceMappingURL=chunk-AMK4VCBG.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../infinite-canvas/src/infinite-canvas.ts", "../../infinite-canvas/src/infinite-canvas.styles.ts", "../../react-infinite-canvas/src/infinite-canvas.tsx"],
|
|
4
|
+
"sourcesContent": ["/** Transform state for an infinite canvas viewport. */\nexport type CanvasTransform = {\n zoom: number\n x: number\n y: number\n}\n\n/** Axis-aligned bounding box of canvas content. */\nexport interface Bounds {\n minX: number\n minY: number\n maxX: number\n maxY: number\n}\n\n/**\n * Clamp a zoom level to [min, max].\n *\n * Defaults match common design-tool conventions: 25 % (0.25) to 400 % (4).\n */\nexport function clampZoom(zoom: number, min = 0.25, max = 4): number {\n return Math.min(Math.max(zoom, min), max)\n}\n\n/**\n * Compute the transform that centers `bounds` inside a viewport of size\n * `viewportW × viewportH` with optional uniform `padding` (default 32 px).\n *\n * Returns a `CanvasTransform` clamped to [0.25, 4].\n */\nexport function fitTransform(\n bounds: Bounds,\n viewportW: number,\n viewportH: number,\n padding = 32,\n): CanvasTransform {\n const contentW = bounds.maxX - bounds.minX\n const contentH = bounds.maxY - bounds.minY\n\n if (contentW <= 0 || contentH <= 0 || viewportW <= 0 || viewportH <= 0) {\n return { zoom: 1, x: 0, y: 0 }\n }\n\n const availW = viewportW - padding * 2\n const availH = viewportH - padding * 2\n const rawZoom = Math.min(availW / contentW, availH / contentH)\n const zoom = clampZoom(rawZoom)\n\n // Center the scaled content within the viewport.\n const scaledW = contentW * zoom\n const scaledH = contentH * zoom\n const x = (viewportW - scaledW) / 2 - bounds.minX * zoom\n const y = (viewportH - scaledH) / 2 - bounds.minY * zoom\n\n return { zoom, x, y }\n}\n\n/**\n * Convert a `CanvasTransform` to a CSS `transform` string suitable for the\n * content layer (`transformOrigin: '0 0'`).\n */\nexport function transformToCss(t: CanvasTransform): string {\n return `translate(${t.x}px, ${t.y}px) scale(${t.zoom})`\n}\n\nexport interface CreateInfiniteCanvasOptions {\n zoom?: number\n}\n\nexport interface InfiniteCanvasAPI {\n /** ARIA attributes to spread on the viewport element. */\n ariaProps: Record<string, string | number | boolean>\n /** Data attributes for styling hooks. */\n dataAttributes: Record<string, string | number | boolean>\n}\n\n/**\n * Build the framework-agnostic props for an infinite canvas viewport.\n *\n * Returns `role=\"group\"` plus data attributes; adapters spread these onto\n * their container element.\n */\nexport function createInfiniteCanvas(\n options: CreateInfiniteCanvasOptions = {},\n): InfiniteCanvasAPI {\n const { zoom = 1 } = options\n\n return {\n ariaProps: {\n role: 'group',\n 'aria-label': 'Canvas',\n },\n dataAttributes: {\n 'data-zoom': zoom,\n },\n }\n}\n", "import { cva } from '@refraction-ui/shared'\n\n/**\n * The outer viewport container. `overflow-hidden` clips panned content;\n * `relative` anchors the absolute controls overlay.\n *\n * The `grid` variant adds a subtle dot-grid background rendered with a radial\n * gradient using the `--border` semantic token so it respects the active theme.\n */\nexport const infiniteCanvasVariants = cva({\n base: 'relative overflow-hidden bg-background',\n variants: {\n grid: {\n 'true': [\n 'bg-[radial-gradient(circle,hsl(var(--border))_1px,transparent_1px)]',\n '[background-size:24px_24px]',\n ].join(' '),\n 'false': '',\n },\n },\n defaultVariants: {\n grid: 'false',\n },\n})\n\n/**\n * The transformed content layer that wraps children.\n * `transform-gpu` promotes the layer for smooth panning/zooming.\n * `origin-top-left` matches `transformOrigin: '0 0'` in the React adapter.\n */\nexport const canvasContentClass =\n 'absolute inset-0 origin-top-left will-change-transform'\n\n/** Zoom-controls cluster — pinned to the bottom-right of the viewport. */\nexport const canvasControlsClass =\n 'absolute bottom-4 right-4 z-10 flex items-center gap-1'\n\n/** Individual zoom button (+ / − / fit). */\nexport const canvasZoomButtonVariants = cva({\n base: [\n 'inline-flex h-8 w-8 items-center justify-center rounded-md border border-border',\n 'bg-card text-foreground text-sm font-medium',\n 'hover:bg-muted transition-colors',\n 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background',\n 'disabled:pointer-events-none disabled:opacity-50',\n ].join(' '),\n variants: {},\n defaultVariants: {},\n})\n", "import * as React from 'react'\nimport {\n clampZoom,\n fitTransform,\n transformToCss,\n createInfiniteCanvas,\n infiniteCanvasVariants,\n canvasContentClass,\n canvasControlsClass,\n canvasZoomButtonVariants,\n type CanvasTransform,\n type Bounds,\n} from '@refraction-ui/infinite-canvas'\nimport { cn } from '@refraction-ui/shared'\n\nexport type { CanvasTransform, Bounds }\n\nexport interface InfiniteCanvasProps\n extends Omit<React.HTMLAttributes<HTMLDivElement>, 'color' | 'content'> {\n /** Controlled zoom level. */\n zoom?: number\n /** Controlled pan offset (x). */\n x?: number\n /** Controlled pan offset (y). */\n y?: number\n /** Whether pointer-drag-to-pan is enabled (uncontrolled mode). */\n pan?: boolean\n /** Minimum zoom level (default 0.25). */\n minZoom?: number\n /** Maximum zoom level (default 4). */\n maxZoom?: number\n /** Show a dot-grid background. */\n showGrid?: boolean\n /** Show the zoom-controls overlay (+, −, fit). */\n showControls?: boolean\n /** Called when the transform changes (wheel or drag in uncontrolled mode). */\n onTransformChange?: (transform: CanvasTransform) => void\n /** Content bounds used by the fit/reset control. */\n contentBounds?: Bounds\n}\n\n/**\n * InfiniteCanvas — a pan/zoom viewport foundation.\n *\n * Renders a `role=\"group\"` container that clips children and applies a CSS\n * transform to a content layer, enabling infinite-canvas interactions\n * (whiteboard, system-design diagram, goal-graph).\n *\n * Supports controlled (`zoom`/`x`/`y`) and uncontrolled usage. Wheel-to-zoom\n * and pointer-drag-to-pan are enabled by default in uncontrolled mode. All\n * pointer handlers are registered after mount so they are SSR-safe.\n */\nexport const InfiniteCanvas = React.forwardRef<HTMLDivElement, InfiniteCanvasProps>(\n function InfiniteCanvas(\n {\n zoom: zoomProp,\n x: xProp,\n y: yProp,\n pan = true,\n minZoom = 0.25,\n maxZoom = 4,\n showGrid = false,\n showControls = false,\n onTransformChange,\n contentBounds,\n className,\n children,\n ...props\n },\n ref,\n ) {\n const isControlled =\n zoomProp !== undefined || xProp !== undefined || yProp !== undefined\n\n const [internal, setInternal] = React.useState<CanvasTransform>({\n zoom: 1,\n x: 0,\n y: 0,\n })\n\n const transform: CanvasTransform = isControlled\n ? { zoom: zoomProp ?? 1, x: xProp ?? 0, y: yProp ?? 0 }\n : internal\n\n const containerRef = React.useRef<HTMLDivElement | null>(null)\n // Dragging state kept in a ref so event handlers don't capture stale values.\n const dragging = React.useRef(false)\n const dragStart = React.useRef({ px: 0, py: 0, tx: 0, ty: 0 })\n\n const applyTransform = React.useCallback(\n (next: CanvasTransform) => {\n if (!isControlled) setInternal(next)\n onTransformChange?.(next)\n },\n [isControlled, onTransformChange],\n )\n\n // Register wheel and pointer handlers after mount (SSR-safe).\n React.useEffect(() => {\n const el = containerRef.current\n if (!el) return\n\n const onWheel = (e: WheelEvent) => {\n e.preventDefault()\n setInternal((prev) => {\n const delta = -e.deltaY * 0.001\n const next: CanvasTransform = {\n ...prev,\n zoom: clampZoom(prev.zoom + prev.zoom * delta, minZoom, maxZoom),\n }\n onTransformChange?.(next)\n return next\n })\n }\n\n el.addEventListener('wheel', onWheel, { passive: false })\n return () => el.removeEventListener('wheel', onWheel)\n }, [minZoom, maxZoom, onTransformChange])\n\n React.useEffect(() => {\n if (!pan || isControlled) return\n const el = containerRef.current\n if (!el) return\n\n const onPointerDown = (e: PointerEvent) => {\n dragging.current = true\n dragStart.current = {\n px: e.clientX,\n py: e.clientY,\n tx: internal.x,\n ty: internal.y,\n }\n el.setPointerCapture(e.pointerId)\n }\n\n const onPointerMove = (e: PointerEvent) => {\n if (!dragging.current) return\n const dx = e.clientX - dragStart.current.px\n const dy = e.clientY - dragStart.current.py\n setInternal((prev) => {\n const next: CanvasTransform = {\n ...prev,\n x: dragStart.current.tx + dx,\n y: dragStart.current.ty + dy,\n }\n onTransformChange?.(next)\n return next\n })\n }\n\n const onPointerUp = () => {\n dragging.current = false\n }\n\n el.addEventListener('pointerdown', onPointerDown)\n el.addEventListener('pointermove', onPointerMove)\n el.addEventListener('pointerup', onPointerUp)\n el.addEventListener('pointercancel', onPointerUp)\n\n return () => {\n el.removeEventListener('pointerdown', onPointerDown)\n el.removeEventListener('pointermove', onPointerMove)\n el.removeEventListener('pointerup', onPointerUp)\n el.removeEventListener('pointercancel', onPointerUp)\n }\n }, [pan, isControlled, onTransformChange, internal.x, internal.y])\n\n const handleZoomIn = () => {\n const next = { ...transform, zoom: clampZoom(transform.zoom * 1.25, minZoom, maxZoom) }\n applyTransform(next)\n }\n\n const handleZoomOut = () => {\n const next = { ...transform, zoom: clampZoom(transform.zoom / 1.25, minZoom, maxZoom) }\n applyTransform(next)\n }\n\n const handleFit = () => {\n const el = containerRef.current\n if (!el) return\n const { width, height } = el.getBoundingClientRect()\n const bounds = contentBounds ?? { minX: 0, minY: 0, maxX: width, maxY: height }\n applyTransform(fitTransform(bounds, width, height))\n }\n\n const api = createInfiniteCanvas({ zoom: transform.zoom })\n\n const setRefs = (node: HTMLDivElement | null) => {\n containerRef.current = node\n if (typeof ref === 'function') ref(node)\n else if (ref) ref.current = node\n }\n\n return (\n <div\n ref={setRefs}\n className={cn(\n infiniteCanvasVariants({ grid: showGrid ? 'true' : 'false' }),\n className,\n )}\n {...api.ariaProps}\n {...api.dataAttributes}\n {...props}\n >\n <div\n className={canvasContentClass}\n style={{ transform: transformToCss(transform), transformOrigin: '0 0' }}\n >\n {children}\n </div>\n\n {showControls && (\n <div className={canvasControlsClass} aria-label=\"Zoom controls\">\n <button\n type=\"button\"\n className={canvasZoomButtonVariants()}\n aria-label=\"Zoom in\"\n onClick={handleZoomIn}\n >\n +\n </button>\n <button\n type=\"button\"\n className={canvasZoomButtonVariants()}\n aria-label=\"Zoom out\"\n onClick={handleZoomOut}\n >\n −\n </button>\n <button\n type=\"button\"\n className={canvasZoomButtonVariants()}\n aria-label=\"Fit to content\"\n onClick={handleFit}\n >\n ⊞\n </button>\n </div>\n )}\n </div>\n )\n },\n)\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
|
+
"names": ["InfiniteCanvas"]
|
|
7
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import {
|
|
2
|
+
buildShortcodeMap
|
|
3
|
+
} from "./chunk-533MCMSU.js";
|
|
4
|
+
|
|
5
|
+
// ../rich-editor/dist/index.js
|
|
6
|
+
function createTextSegment(text, marks = []) {
|
|
7
|
+
return { type: "text", text, marks };
|
|
8
|
+
}
|
|
9
|
+
function findBlockById(doc, blockId) {
|
|
10
|
+
for (const block of doc.blocks) {
|
|
11
|
+
if (block.id === blockId) return block;
|
|
12
|
+
if (block.children) {
|
|
13
|
+
for (const child of block.children) {
|
|
14
|
+
if (child.id === blockId) return child;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
return void 0;
|
|
19
|
+
}
|
|
20
|
+
function cloneDocument(doc) {
|
|
21
|
+
return JSON.parse(JSON.stringify(doc));
|
|
22
|
+
}
|
|
23
|
+
function changeBlockType(doc, sel, type, meta) {
|
|
24
|
+
const d = cloneDocument(doc);
|
|
25
|
+
const block = findBlockById(d, sel.anchor.blockId);
|
|
26
|
+
if (!block) return { doc: d, selection: sel };
|
|
27
|
+
block.type = type;
|
|
28
|
+
if (meta) {
|
|
29
|
+
block.meta = { ...block.meta, ...meta };
|
|
30
|
+
} else if (type !== block.type) ;
|
|
31
|
+
if (type === "divider") {
|
|
32
|
+
block.content = [createTextSegment("")];
|
|
33
|
+
}
|
|
34
|
+
return { doc: d, selection: sel };
|
|
35
|
+
}
|
|
36
|
+
function detectTriggerInText(text, caret, triggerChar) {
|
|
37
|
+
if (triggerChar.length === 0) return null;
|
|
38
|
+
const before = text.slice(0, caret);
|
|
39
|
+
const idx = before.lastIndexOf(triggerChar);
|
|
40
|
+
if (idx === -1) return null;
|
|
41
|
+
if (idx > 0 && !/\s/.test(before[idx - 1])) return null;
|
|
42
|
+
const query = before.slice(idx + triggerChar.length);
|
|
43
|
+
if (/\s/.test(query)) return null;
|
|
44
|
+
return { triggered: true, query, start: idx };
|
|
45
|
+
}
|
|
46
|
+
function makeBlockCommand(name, label, description, keywords, blockType, meta) {
|
|
47
|
+
return {
|
|
48
|
+
name,
|
|
49
|
+
label,
|
|
50
|
+
description,
|
|
51
|
+
keywords,
|
|
52
|
+
action: (doc, sel) => {
|
|
53
|
+
return changeBlockType(doc, sel, blockType, meta);
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
var BUILT_IN_COMMANDS = [
|
|
58
|
+
makeBlockCommand("heading1", "Heading 1", "Large section heading", ["h1", "title", "heading"], "heading", { level: 1 }),
|
|
59
|
+
makeBlockCommand("heading2", "Heading 2", "Medium section heading", ["h2", "subtitle", "heading"], "heading", { level: 2 }),
|
|
60
|
+
makeBlockCommand("heading3", "Heading 3", "Small section heading", ["h3", "heading"], "heading", { level: 3 }),
|
|
61
|
+
makeBlockCommand("bullet-list", "Bullet List", "Create a bulleted list", ["ul", "unordered", "bullet", "list"], "list-item", { listType: "bullet" }),
|
|
62
|
+
makeBlockCommand("numbered-list", "Numbered List", "Create a numbered list", ["ol", "ordered", "number", "list"], "list-item", { listType: "ordered" }),
|
|
63
|
+
makeBlockCommand("quote", "Quote", "Create a blockquote", ["blockquote", "citation", "quote"], "blockquote"),
|
|
64
|
+
makeBlockCommand("code", "Code Block", "Create a code block", ["code", "pre", "snippet", "programming"], "code-block"),
|
|
65
|
+
makeBlockCommand("divider", "Divider", "Insert a horizontal divider", ["hr", "horizontal", "rule", "separator", "line"], "divider"),
|
|
66
|
+
{
|
|
67
|
+
name: "image",
|
|
68
|
+
label: "Image",
|
|
69
|
+
description: "Insert an image",
|
|
70
|
+
keywords: ["img", "picture", "photo"],
|
|
71
|
+
action: (doc, sel) => {
|
|
72
|
+
return changeBlockType(doc, sel, "image");
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
];
|
|
76
|
+
var EMOJI_MAP = buildShortcodeMap();
|
|
77
|
+
function detectEmojiShortcode(text) {
|
|
78
|
+
const match = text.match(/:([a-z0-9_]+):$/);
|
|
79
|
+
if (!match) return null;
|
|
80
|
+
const shortcode = `:${match[1]}:`;
|
|
81
|
+
const unicode = EMOJI_MAP[shortcode];
|
|
82
|
+
if (!unicode) return null;
|
|
83
|
+
return { shortcode, unicode };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export {
|
|
87
|
+
detectTriggerInText,
|
|
88
|
+
detectEmojiShortcode
|
|
89
|
+
};
|
|
90
|
+
//# sourceMappingURL=chunk-BOLFYVI6.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../rich-editor/src/model.ts", "../../rich-editor/src/selection.ts", "../../rich-editor/src/operations.ts", "../../rich-editor/src/markdown-shortcuts.ts", "../../rich-editor/src/trigger.ts", "../../rich-editor/src/slash-commands.ts", "../../rich-editor/src/mentions.ts", "../../rich-editor/src/emoji.ts", "../../rich-editor/src/history.ts", "../../rich-editor/src/serialization.ts", "../../rich-editor/src/editor.ts"],
|
|
4
|
+
"sourcesContent": ["/**\n * Core data model for the rich text editor.\n * Document is a tree of blocks, each containing inline content segments.\n */\n\n// ---------------------------------------------------------------------------\n// Block types\n// ---------------------------------------------------------------------------\nexport type BlockType =\n | 'paragraph'\n | 'heading'\n | 'blockquote'\n | 'code-block'\n | 'list-item'\n | 'divider'\n | 'image'\n\nexport type HeadingLevel = 1 | 2 | 3\n\n// ---------------------------------------------------------------------------\n// Inline content\n// ---------------------------------------------------------------------------\nexport type MarkType = 'bold' | 'italic' | 'strikethrough' | 'code' | 'underline' | 'link'\n\nexport interface Mark {\n type: MarkType\n attrs?: Record<string, string>\n}\n\nexport interface TextSegment {\n type: 'text'\n text: string\n marks: Mark[]\n}\n\nexport interface MentionSegment {\n type: 'mention'\n id: string\n label: string\n}\n\nexport interface EmojiSegment {\n type: 'emoji'\n shortcode: string\n unicode: string\n}\n\nexport type InlineContent = TextSegment | MentionSegment | EmojiSegment\n\n// ---------------------------------------------------------------------------\n// Block\n// ---------------------------------------------------------------------------\nexport interface Block {\n id: string\n type: BlockType\n content: InlineContent[]\n meta?: Record<string, unknown>\n indent?: number\n children?: Block[]\n}\n\n// ---------------------------------------------------------------------------\n// Document\n// ---------------------------------------------------------------------------\nexport interface Document {\n blocks: Block[]\n version: number\n}\n\n// ---------------------------------------------------------------------------\n// Helpers\n// ---------------------------------------------------------------------------\nlet _idCounter = 0\n\nexport function generateBlockId(): string {\n _idCounter++\n return `block-${_idCounter}`\n}\n\nexport function resetBlockIdCounter(): void {\n _idCounter = 0\n}\n\nexport function createTextSegment(text: string, marks: Mark[] = []): TextSegment {\n return { type: 'text', text, marks }\n}\n\nexport function createMentionSegment(id: string, label: string): MentionSegment {\n return { type: 'mention', id, label }\n}\n\nexport function createEmojiSegment(shortcode: string, unicode: string): EmojiSegment {\n return { type: 'emoji', shortcode, unicode }\n}\n\nexport function createBlock(\n type: BlockType = 'paragraph',\n content: InlineContent[] = [],\n meta?: Record<string, unknown>,\n): Block {\n return {\n id: generateBlockId(),\n type,\n content: content.length === 0 ? [createTextSegment('')] : content,\n ...(meta ? { meta } : {}),\n }\n}\n\nexport function createDocument(blocks?: Block[]): Document {\n return {\n blocks: blocks && blocks.length > 0 ? blocks : [createBlock('paragraph')],\n version: 1,\n }\n}\n\n/** Get the full plain text length of a block's inline content. */\nexport function getBlockTextLength(block: Block): number {\n let len = 0\n for (const seg of block.content) {\n if (seg.type === 'text') {\n len += seg.text.length\n } else if (seg.type === 'mention') {\n len += seg.label.length\n } else if (seg.type === 'emoji') {\n len += seg.unicode.length\n }\n }\n return len\n}\n\n/** Get the full plain text of a block's inline content. */\nexport function getBlockText(block: Block): string {\n let text = ''\n for (const seg of block.content) {\n if (seg.type === 'text') {\n text += seg.text\n } else if (seg.type === 'mention') {\n text += seg.label\n } else if (seg.type === 'emoji') {\n text += seg.unicode\n }\n }\n return text\n}\n\n/** Find a block by its id. */\nexport function findBlockById(doc: Document, blockId: string): Block | undefined {\n for (const block of doc.blocks) {\n if (block.id === blockId) return block\n if (block.children) {\n for (const child of block.children) {\n if (child.id === blockId) return child\n }\n }\n }\n return undefined\n}\n\n/** Find the index of a block by its id. Returns -1 if not found at top level. */\nexport function findBlockIndex(doc: Document, blockId: string): number {\n return doc.blocks.findIndex((b) => b.id === blockId)\n}\n\n/** Deep clone a document. */\nexport function cloneDocument(doc: Document): Document {\n return JSON.parse(JSON.stringify(doc)) as Document\n}\n\n/** Deep clone a block. */\nexport function cloneBlock(block: Block): Block {\n return JSON.parse(JSON.stringify(block)) as Block\n}\n\n/** Check that marks are equal (order-insensitive). */\nexport function marksEqual(a: Mark[], b: Mark[]): boolean {\n if (a.length !== b.length) return false\n for (const markA of a) {\n const found = b.find(\n (markB) =>\n markB.type === markA.type &&\n JSON.stringify(markB.attrs ?? {}) === JSON.stringify(markA.attrs ?? {}),\n )\n if (!found) return false\n }\n return true\n}\n\n/** Normalize a block's inline content — merge adjacent text segments with identical marks. */\nexport function normalizeContent(content: InlineContent[]): InlineContent[] {\n const result: InlineContent[] = []\n for (const seg of content) {\n if (seg.type === 'text' && seg.text === '' && content.length > 1) {\n continue // skip empty text segments unless it's the only one\n }\n const last = result[result.length - 1]\n if (seg.type === 'text' && last && last.type === 'text' && marksEqual(last.marks, seg.marks)) {\n last.text += seg.text\n } else {\n result.push({ ...seg, ...(seg.type === 'text' ? { marks: [...seg.marks] } : {}) })\n }\n }\n if (result.length === 0) {\n return [createTextSegment('')]\n }\n return result\n}\n", "/**\n * Selection model and utilities.\n * A selection has an anchor (start) and focus (end), each referencing\n * a block id and character offset within the block's text content.\n */\n\nimport type { Document, Block } from './model.js'\nimport { findBlockById, findBlockIndex, getBlockText, getBlockTextLength } from './model.js'\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\nexport interface Position {\n blockId: string\n offset: number\n}\n\nexport interface Selection {\n anchor: Position\n focus: Position\n}\n\n// ---------------------------------------------------------------------------\n// Creation helpers\n// ---------------------------------------------------------------------------\nexport function createPosition(blockId: string, offset: number): Position {\n return { blockId, offset }\n}\n\nexport function createSelection(anchor: Position, focus: Position): Selection {\n return { anchor, focus }\n}\n\nexport function createCollapsedSelection(blockId: string, offset: number): Selection {\n const pos = createPosition(blockId, offset)\n return { anchor: pos, focus: { ...pos } }\n}\n\n// ---------------------------------------------------------------------------\n// Predicates\n// ---------------------------------------------------------------------------\nexport function isCollapsed(sel: Selection): boolean {\n return sel.anchor.blockId === sel.focus.blockId && sel.anchor.offset === sel.focus.offset\n}\n\nexport function isForward(doc: Document, sel: Selection): boolean {\n if (sel.anchor.blockId === sel.focus.blockId) {\n return sel.anchor.offset <= sel.focus.offset\n }\n const anchorIdx = findBlockIndex(doc, sel.anchor.blockId)\n const focusIdx = findBlockIndex(doc, sel.focus.blockId)\n return anchorIdx <= focusIdx\n}\n\n// ---------------------------------------------------------------------------\n// Selection queries\n// ---------------------------------------------------------------------------\n\n/** Get the start (earlier) position of a selection. */\nexport function getSelectionStart(doc: Document, sel: Selection): Position {\n return isForward(doc, sel) ? sel.anchor : sel.focus\n}\n\n/** Get the end (later) position of a selection. */\nexport function getSelectionEnd(doc: Document, sel: Selection): Position {\n return isForward(doc, sel) ? sel.focus : sel.anchor\n}\n\n/** Get the text content that is selected. */\nexport function getSelectedText(doc: Document, sel: Selection): string {\n if (isCollapsed(sel)) return ''\n\n const start = getSelectionStart(doc, sel)\n const end = getSelectionEnd(doc, sel)\n\n if (start.blockId === end.blockId) {\n const block = findBlockById(doc, start.blockId)\n if (!block) return ''\n const text = getBlockText(block)\n return text.slice(start.offset, end.offset)\n }\n\n // Multi-block selection\n const startIdx = findBlockIndex(doc, start.blockId)\n const endIdx = findBlockIndex(doc, end.blockId)\n const parts: string[] = []\n\n for (let i = startIdx; i <= endIdx; i++) {\n const block = doc.blocks[i]\n const text = getBlockText(block)\n if (i === startIdx) {\n parts.push(text.slice(start.offset))\n } else if (i === endIdx) {\n parts.push(text.slice(0, end.offset))\n } else {\n parts.push(text)\n }\n }\n\n return parts.join('\\n')\n}\n\n/** Get all blocks that are (partially or fully) within the selection. */\nexport function getSelectedBlocks(doc: Document, sel: Selection): Block[] {\n if (isCollapsed(sel)) {\n const block = findBlockById(doc, sel.anchor.blockId)\n return block ? [block] : []\n }\n\n const start = getSelectionStart(doc, sel)\n const end = getSelectionEnd(doc, sel)\n const startIdx = findBlockIndex(doc, start.blockId)\n const endIdx = findBlockIndex(doc, end.blockId)\n\n if (startIdx === -1 || endIdx === -1) return []\n return doc.blocks.slice(startIdx, endIdx + 1)\n}\n\n/** Clamp a selection so its positions are valid within the document. */\nexport function clampSelection(doc: Document, sel: Selection): Selection {\n function clampPosition(pos: Position): Position {\n const block = findBlockById(doc, pos.blockId)\n if (!block) {\n // Fall back to first block\n const first = doc.blocks[0]\n return { blockId: first.id, offset: 0 }\n }\n const maxOffset = getBlockTextLength(block)\n return { blockId: pos.blockId, offset: Math.max(0, Math.min(pos.offset, maxOffset)) }\n }\n return {\n anchor: clampPosition(sel.anchor),\n focus: clampPosition(sel.focus),\n }\n}\n\n/** Create a selection at the very start of the document. */\nexport function selectionAtDocStart(doc: Document): Selection {\n const block = doc.blocks[0]\n return createCollapsedSelection(block.id, 0)\n}\n\n/** Create a selection at the very end of the document. */\nexport function selectionAtDocEnd(doc: Document): Selection {\n const block = doc.blocks[doc.blocks.length - 1]\n return createCollapsedSelection(block.id, getBlockTextLength(block))\n}\n\n/** Create a selection that spans the entire document. */\nexport function selectAll(doc: Document): Selection {\n const first = doc.blocks[0]\n const last = doc.blocks[doc.blocks.length - 1]\n return createSelection(\n createPosition(first.id, 0),\n createPosition(last.id, getBlockTextLength(last)),\n )\n}\n", "/**\n * Editor operations — pure functions that take (doc, selection) and return new state.\n * Every operation is immutable: documents and selections are never mutated in-place.\n */\n\nimport type {\n Document,\n Block,\n InlineContent,\n TextSegment,\n Mark,\n MarkType,\n BlockType,\n} from './model.js'\nimport {\n cloneDocument,\n createBlock,\n createTextSegment,\n findBlockById,\n findBlockIndex,\n getBlockText,\n getBlockTextLength,\n normalizeContent,\n} from './model.js'\nimport type { Selection, Position } from './selection.js'\nimport {\n createCollapsedSelection,\n createPosition,\n createSelection,\n getSelectionEnd,\n getSelectionStart,\n isCollapsed,\n isForward,\n} from './selection.js'\n\n// ---------------------------------------------------------------------------\n// Editor state\n// ---------------------------------------------------------------------------\nexport interface EditorState {\n doc: Document\n selection: Selection\n}\n\n// ---------------------------------------------------------------------------\n// Internal helpers\n// ---------------------------------------------------------------------------\n\n/**\n * Split a block's inline content at a character offset, returning [before, after].\n */\nfunction splitContentAt(\n content: InlineContent[],\n offset: number,\n): [InlineContent[], InlineContent[]] {\n const before: InlineContent[] = []\n const after: InlineContent[] = []\n let pos = 0\n\n for (const seg of content) {\n const segLen = seg.type === 'text' ? seg.text.length : seg.type === 'mention' ? seg.label.length : seg.type === 'emoji' ? seg.unicode.length : 0\n\n if (pos + segLen <= offset) {\n before.push(structuredClone(seg))\n pos += segLen\n } else if (pos >= offset) {\n after.push(structuredClone(seg))\n pos += segLen\n } else {\n // Split within this segment\n const splitAt = offset - pos\n if (seg.type === 'text') {\n before.push(createTextSegment(seg.text.slice(0, splitAt), [...seg.marks]))\n after.push(createTextSegment(seg.text.slice(splitAt), [...seg.marks]))\n } else {\n // Mention/emoji can't really be split; put whole segment in before\n before.push(structuredClone(seg))\n }\n pos += segLen\n }\n }\n\n return [\n before.length > 0 ? normalizeContent(before) : [createTextSegment('')],\n after.length > 0 ? normalizeContent(after) : [createTextSegment('')],\n ]\n}\n\n/**\n * Delete a range of characters from content, returning new content.\n */\nfunction deleteContentRange(\n content: InlineContent[],\n start: number,\n end: number,\n): InlineContent[] {\n const [beforeAll] = splitContentAt(content, start)\n const [, afterAll] = splitContentAt(content, end)\n return normalizeContent([...beforeAll, ...afterAll])\n}\n\n/**\n * Insert text into content at a given offset, with given marks.\n */\nfunction insertTextIntoContent(\n content: InlineContent[],\n offset: number,\n text: string,\n marks: Mark[] = [],\n): InlineContent[] {\n const [before, after] = splitContentAt(content, offset)\n const newSeg = createTextSegment(text, marks)\n return normalizeContent([...before, newSeg, ...after])\n}\n\n/**\n * Insert an inline content segment at a given offset.\n */\nfunction insertSegmentIntoContent(\n content: InlineContent[],\n offset: number,\n segment: InlineContent,\n): InlineContent[] {\n const [before, after] = splitContentAt(content, offset)\n return normalizeContent([...before, segment, ...after])\n}\n\n/**\n * Get the marks at a specific offset within content.\n */\nfunction getMarksAtOffset(content: InlineContent[], offset: number): Mark[] {\n let pos = 0\n for (const seg of content) {\n const segLen = seg.type === 'text' ? seg.text.length : seg.type === 'mention' ? seg.label.length : seg.type === 'emoji' ? seg.unicode.length : 0\n if (seg.type === 'text' && pos <= offset && offset <= pos + segLen) {\n return [...seg.marks]\n }\n pos += segLen\n }\n return []\n}\n\n// ---------------------------------------------------------------------------\n// Text operations\n// ---------------------------------------------------------------------------\n\n/** Insert text at the current selection. If selection is expanded, delete first. */\nexport function insertText(doc: Document, sel: Selection, text: string): EditorState {\n let d = cloneDocument(doc)\n let s = sel\n\n // If there's a selection range, delete it first\n if (!isCollapsed(s)) {\n const result = deleteSelection(d, s)\n d = result.doc\n s = result.selection\n }\n\n const block = findBlockById(d, s.anchor.blockId)\n if (!block) return { doc: d, selection: s }\n\n const marks = getMarksAtOffset(block.content, s.anchor.offset)\n block.content = insertTextIntoContent(block.content, s.anchor.offset, text, marks)\n const newOffset = s.anchor.offset + text.length\n const newSel = createCollapsedSelection(block.id, newOffset)\n\n return { doc: d, selection: newSel }\n}\n\n/** Delete one character backward from the cursor (Backspace). */\nexport function deleteBackward(doc: Document, sel: Selection): EditorState {\n if (!isCollapsed(sel)) {\n return deleteSelection(doc, sel)\n }\n\n const d = cloneDocument(doc)\n const block = findBlockById(d, sel.anchor.blockId)\n if (!block) return { doc: d, selection: sel }\n\n if (sel.anchor.offset > 0) {\n // Delete one character within the block\n block.content = deleteContentRange(block.content, sel.anchor.offset - 1, sel.anchor.offset)\n return {\n doc: d,\n selection: createCollapsedSelection(block.id, sel.anchor.offset - 1),\n }\n }\n\n // At start of block — merge with previous block\n return mergeBlockBackward(d, sel)\n}\n\n/** Delete one character forward from the cursor (Delete key). */\nexport function deleteForward(doc: Document, sel: Selection): EditorState {\n if (!isCollapsed(sel)) {\n return deleteSelection(doc, sel)\n }\n\n const d = cloneDocument(doc)\n const block = findBlockById(d, sel.anchor.blockId)\n if (!block) return { doc: d, selection: sel }\n\n const textLen = getBlockTextLength(block)\n if (sel.anchor.offset < textLen) {\n // Delete one character forward within the block\n block.content = deleteContentRange(block.content, sel.anchor.offset, sel.anchor.offset + 1)\n return { doc: d, selection: createCollapsedSelection(block.id, sel.anchor.offset) }\n }\n\n // At end of block — merge next block into this one\n return mergeBlockForward(d, sel)\n}\n\n/** Delete the currently selected content. */\nexport function deleteSelection(doc: Document, sel: Selection): EditorState {\n if (isCollapsed(sel)) return { doc: cloneDocument(doc), selection: sel }\n\n const d = cloneDocument(doc)\n const start = getSelectionStart(d, sel)\n const end = getSelectionEnd(d, sel)\n\n if (start.blockId === end.blockId) {\n // Single block deletion\n const block = findBlockById(d, start.blockId)!\n block.content = deleteContentRange(block.content, start.offset, end.offset)\n return { doc: d, selection: createCollapsedSelection(block.id, start.offset) }\n }\n\n // Multi-block deletion\n const startIdx = findBlockIndex(d, start.blockId)\n const endIdx = findBlockIndex(d, end.blockId)\n\n const startBlock = d.blocks[startIdx]\n const endBlock = d.blocks[endIdx]\n\n // Keep content before start and after end, merge into start block\n const [beforeContent] = splitContentAt(startBlock.content, start.offset)\n const [, afterContent] = splitContentAt(endBlock.content, end.offset)\n\n startBlock.content = normalizeContent([...beforeContent, ...afterContent])\n\n // Remove all blocks between start and end (inclusive of end)\n d.blocks.splice(startIdx + 1, endIdx - startIdx)\n\n return { doc: d, selection: createCollapsedSelection(startBlock.id, start.offset) }\n}\n\n// ---------------------------------------------------------------------------\n// Block operations\n// ---------------------------------------------------------------------------\n\n/** Insert a new block of the given type after the current block. */\nexport function insertBlock(doc: Document, sel: Selection, type: BlockType, meta?: Record<string, unknown>): EditorState {\n const d = cloneDocument(doc)\n const idx = findBlockIndex(d, sel.anchor.blockId)\n if (idx === -1) return { doc: d, selection: sel }\n\n const newBlock = createBlock(type, [], meta)\n d.blocks.splice(idx + 1, 0, newBlock)\n\n return { doc: d, selection: createCollapsedSelection(newBlock.id, 0) }\n}\n\n/** Split the current block at the cursor position (Enter key). */\nexport function splitBlock(doc: Document, sel: Selection): EditorState {\n let d = cloneDocument(doc)\n let s = sel\n\n if (!isCollapsed(s)) {\n const result = deleteSelection(d, s)\n d = result.doc\n s = result.selection\n }\n\n const idx = findBlockIndex(d, s.anchor.blockId)\n if (idx === -1) return { doc: d, selection: s }\n\n const block = d.blocks[idx]\n\n // For dividers, just insert a new paragraph after\n if (block.type === 'divider') {\n const newBlock = createBlock('paragraph')\n d.blocks.splice(idx + 1, 0, newBlock)\n return { doc: d, selection: createCollapsedSelection(newBlock.id, 0) }\n }\n\n const [beforeContent, afterContent] = splitContentAt(block.content, s.anchor.offset)\n\n block.content = beforeContent\n\n // New block inherits type for paragraphs and list items, otherwise becomes paragraph\n const newType: BlockType = block.type === 'list-item' ? 'list-item' : 'paragraph'\n const newBlock = createBlock(newType, afterContent)\n\n d.blocks.splice(idx + 1, 0, newBlock)\n\n return { doc: d, selection: createCollapsedSelection(newBlock.id, 0) }\n}\n\n/** Merge the current block backward into the previous block (Backspace at start). */\nexport function mergeBlockBackward(doc: Document, sel: Selection): EditorState {\n const d = cloneDocument(doc)\n const idx = findBlockIndex(d, sel.anchor.blockId)\n\n // If it's a non-paragraph block at start, convert to paragraph first\n if (idx <= 0) {\n const block = d.blocks[idx]\n if (block && block.type !== 'paragraph') {\n block.type = 'paragraph'\n delete block.meta\n return { doc: d, selection: createCollapsedSelection(block.id, 0) }\n }\n return { doc: d, selection: sel }\n }\n\n const prevBlock = d.blocks[idx - 1]\n const currBlock = d.blocks[idx]\n\n // If previous block is a divider, just remove it\n if (prevBlock.type === 'divider') {\n d.blocks.splice(idx - 1, 1)\n return { doc: d, selection: createCollapsedSelection(currBlock.id, 0) }\n }\n\n const prevLen = getBlockTextLength(prevBlock)\n prevBlock.content = normalizeContent([...prevBlock.content, ...currBlock.content])\n d.blocks.splice(idx, 1)\n\n return { doc: d, selection: createCollapsedSelection(prevBlock.id, prevLen) }\n}\n\n/** Merge the next block forward into the current block (Delete at end). */\nexport function mergeBlockForward(doc: Document, sel: Selection): EditorState {\n const d = cloneDocument(doc)\n const idx = findBlockIndex(d, sel.anchor.blockId)\n if (idx === -1 || idx >= d.blocks.length - 1) return { doc: d, selection: sel }\n\n const currBlock = d.blocks[idx]\n const nextBlock = d.blocks[idx + 1]\n\n // If next block is a divider, just remove it\n if (nextBlock.type === 'divider') {\n d.blocks.splice(idx + 1, 1)\n return { doc: d, selection: sel }\n }\n\n const currLen = getBlockTextLength(currBlock)\n currBlock.content = normalizeContent([...currBlock.content, ...nextBlock.content])\n d.blocks.splice(idx + 1, 1)\n\n return { doc: d, selection: createCollapsedSelection(currBlock.id, currLen) }\n}\n\n/** Change the type of the block at the cursor. */\nexport function changeBlockType(\n doc: Document,\n sel: Selection,\n type: BlockType,\n meta?: Record<string, unknown>,\n): EditorState {\n const d = cloneDocument(doc)\n const block = findBlockById(d, sel.anchor.blockId)\n if (!block) return { doc: d, selection: sel }\n\n block.type = type\n if (meta) {\n block.meta = { ...block.meta, ...meta }\n } else if (type !== block.type) {\n // Clear meta when changing type without new meta, unless it's same type\n }\n\n // Dividers have no content\n if (type === 'divider') {\n block.content = [createTextSegment('')]\n }\n\n return { doc: d, selection: sel }\n}\n\n/** Indent a block (increase indent level). */\nexport function indentBlock(doc: Document, sel: Selection): EditorState {\n const d = cloneDocument(doc)\n const block = findBlockById(d, sel.anchor.blockId)\n if (!block) return { doc: d, selection: sel }\n\n const currentIndent = block.indent ?? 0\n if (currentIndent < 5) {\n block.indent = currentIndent + 1\n }\n\n return { doc: d, selection: sel }\n}\n\n/** Outdent a block (decrease indent level). */\nexport function outdentBlock(doc: Document, sel: Selection): EditorState {\n const d = cloneDocument(doc)\n const block = findBlockById(d, sel.anchor.blockId)\n if (!block) return { doc: d, selection: sel }\n\n const currentIndent = block.indent ?? 0\n if (currentIndent > 0) {\n block.indent = currentIndent - 1\n }\n\n return { doc: d, selection: sel }\n}\n\n// ---------------------------------------------------------------------------\n// Mark operations\n// ---------------------------------------------------------------------------\n\n/** Check if a mark of a given type is in the marks array. */\nfunction hasMarkType(marks: Mark[], type: MarkType): boolean {\n return marks.some((m) => m.type === type)\n}\n\n/** Get the active marks at the current selection. */\nexport function getActiveMarks(doc: Document, sel: Selection): Mark[] {\n const block = findBlockById(doc, sel.anchor.blockId)\n if (!block) return []\n\n if (isCollapsed(sel)) {\n // Return marks at the cursor position\n return getMarksAtOffset(block.content, sel.anchor.offset > 0 ? sel.anchor.offset - 1 : 0)\n }\n\n // For a range selection, return marks common to all characters in the range\n const start = getSelectionStart(doc, sel)\n const end = getSelectionEnd(doc, sel)\n\n if (start.blockId !== end.blockId) {\n // For simplicity across blocks, get marks from the start position\n return getMarksAtOffset(block.content, start.offset)\n }\n\n // Get all marks that apply to every character in the range\n const allMarks: Mark[][] = []\n let pos = 0\n for (const seg of block.content) {\n if (seg.type !== 'text') {\n pos += seg.type === 'mention' ? seg.label.length : seg.type === 'emoji' ? seg.unicode.length : 0\n continue\n }\n const segStart = pos\n const segEnd = pos + seg.text.length\n pos = segEnd\n\n // Check if this segment overlaps with the selection\n if (segEnd <= start.offset || segStart >= end.offset) continue\n allMarks.push([...seg.marks])\n }\n\n if (allMarks.length === 0) return []\n\n // Intersection of all mark arrays\n return allMarks[0].filter((mark) => allMarks.every((marks) => hasMarkType(marks, mark.type)))\n}\n\n/** Toggle a mark type on the selection. If fully applied, remove; otherwise add. */\nexport function toggleMark(doc: Document, sel: Selection, markType: MarkType, attrs?: Record<string, string>): EditorState {\n const active = getActiveMarks(doc, sel)\n const mark: Mark = { type: markType, ...(attrs ? { attrs } : {}) }\n if (hasMarkType(active, markType)) {\n return removeMark(doc, sel, markType)\n }\n return addMark(doc, sel, mark)\n}\n\n/** Add a mark to the selected range. */\nexport function addMark(doc: Document, sel: Selection, mark: Mark): EditorState {\n if (isCollapsed(sel)) {\n // For collapsed selections, we can't apply marks to nothing\n return { doc: cloneDocument(doc), selection: sel }\n }\n\n const d = cloneDocument(doc)\n const start = getSelectionStart(d, sel)\n const end = getSelectionEnd(d, sel)\n\n // Apply to each block in the selection\n const startIdx = findBlockIndex(d, start.blockId)\n const endIdx = findBlockIndex(d, end.blockId)\n\n for (let i = startIdx; i <= endIdx; i++) {\n const block = d.blocks[i]\n const blockStart = i === startIdx ? start.offset : 0\n const blockEnd = i === endIdx ? end.offset : getBlockTextLength(block)\n\n block.content = applyMarkToContent(block.content, blockStart, blockEnd, mark)\n }\n\n return { doc: d, selection: sel }\n}\n\n/** Remove a mark type from the selected range. */\nexport function removeMark(doc: Document, sel: Selection, markType: MarkType): EditorState {\n if (isCollapsed(sel)) {\n return { doc: cloneDocument(doc), selection: sel }\n }\n\n const d = cloneDocument(doc)\n const start = getSelectionStart(d, sel)\n const end = getSelectionEnd(d, sel)\n\n const startIdx = findBlockIndex(d, start.blockId)\n const endIdx = findBlockIndex(d, end.blockId)\n\n for (let i = startIdx; i <= endIdx; i++) {\n const block = d.blocks[i]\n const blockStart = i === startIdx ? start.offset : 0\n const blockEnd = i === endIdx ? end.offset : getBlockTextLength(block)\n\n block.content = removeMarkFromContent(block.content, blockStart, blockEnd, markType)\n }\n\n return { doc: d, selection: sel }\n}\n\n/**\n * Apply a mark to a range within inline content.\n * Splits segments as needed so the mark only applies to the specified range.\n */\nfunction applyMarkToContent(\n content: InlineContent[],\n start: number,\n end: number,\n mark: Mark,\n): InlineContent[] {\n const result: InlineContent[] = []\n let pos = 0\n\n for (const seg of content) {\n const segLen = seg.type === 'text' ? seg.text.length : seg.type === 'mention' ? seg.label.length : seg.type === 'emoji' ? seg.unicode.length : 0\n const segStart = pos\n const segEnd = pos + segLen\n pos = segEnd\n\n if (seg.type !== 'text') {\n result.push(structuredClone(seg))\n continue\n }\n\n if (segEnd <= start || segStart >= end) {\n // Outside the range\n result.push(createTextSegment(seg.text, [...seg.marks]))\n } else if (segStart >= start && segEnd <= end) {\n // Fully inside the range\n const newMarks = hasMarkType(seg.marks, mark.type)\n ? seg.marks.map((m) => (m.type === mark.type ? { ...mark } : { ...m }))\n : [...seg.marks, { ...mark }]\n result.push(createTextSegment(seg.text, newMarks))\n } else {\n // Partially overlapping — split\n if (segStart < start) {\n result.push(createTextSegment(seg.text.slice(0, start - segStart), [...seg.marks]))\n }\n const overlapStart = Math.max(0, start - segStart)\n const overlapEnd = Math.min(segLen, end - segStart)\n const newMarks = hasMarkType(seg.marks, mark.type)\n ? seg.marks.map((m) => (m.type === mark.type ? { ...mark } : { ...m }))\n : [...seg.marks, { ...mark }]\n result.push(createTextSegment(seg.text.slice(overlapStart, overlapEnd), newMarks))\n if (segEnd > end) {\n result.push(createTextSegment(seg.text.slice(end - segStart), [...seg.marks]))\n }\n }\n }\n\n return normalizeContent(result)\n}\n\n/**\n * Remove a mark type from a range within inline content.\n */\nfunction removeMarkFromContent(\n content: InlineContent[],\n start: number,\n end: number,\n markType: MarkType,\n): InlineContent[] {\n const result: InlineContent[] = []\n let pos = 0\n\n for (const seg of content) {\n const segLen = seg.type === 'text' ? seg.text.length : seg.type === 'mention' ? seg.label.length : seg.type === 'emoji' ? seg.unicode.length : 0\n const segStart = pos\n const segEnd = pos + segLen\n pos = segEnd\n\n if (seg.type !== 'text') {\n result.push(structuredClone(seg))\n continue\n }\n\n if (segEnd <= start || segStart >= end) {\n result.push(createTextSegment(seg.text, [...seg.marks]))\n } else if (segStart >= start && segEnd <= end) {\n const newMarks = seg.marks.filter((m) => m.type !== markType)\n result.push(createTextSegment(seg.text, newMarks))\n } else {\n if (segStart < start) {\n result.push(createTextSegment(seg.text.slice(0, start - segStart), [...seg.marks]))\n }\n const overlapStart = Math.max(0, start - segStart)\n const overlapEnd = Math.min(segLen, end - segStart)\n const newMarks = seg.marks.filter((m) => m.type !== markType)\n result.push(createTextSegment(seg.text.slice(overlapStart, overlapEnd), newMarks))\n if (segEnd > end) {\n result.push(createTextSegment(seg.text.slice(end - segStart), [...seg.marks]))\n }\n }\n }\n\n return normalizeContent(result)\n}\n\n// ---------------------------------------------------------------------------\n// Selection movement operations\n// ---------------------------------------------------------------------------\n\n/** Move cursor one position to the left. */\nexport function moveLeft(doc: Document, sel: Selection): Selection {\n if (!isCollapsed(sel)) {\n const start = getSelectionStart(doc, sel)\n return createCollapsedSelection(start.blockId, start.offset)\n }\n\n const block = findBlockById(doc, sel.anchor.blockId)\n if (!block) return sel\n\n if (sel.anchor.offset > 0) {\n return createCollapsedSelection(block.id, sel.anchor.offset - 1)\n }\n\n // Move to end of previous block\n const idx = findBlockIndex(doc, block.id)\n if (idx > 0) {\n const prevBlock = doc.blocks[idx - 1]\n return createCollapsedSelection(prevBlock.id, getBlockTextLength(prevBlock))\n }\n\n return sel\n}\n\n/** Move cursor one position to the right. */\nexport function moveRight(doc: Document, sel: Selection): Selection {\n if (!isCollapsed(sel)) {\n const end = getSelectionEnd(doc, sel)\n return createCollapsedSelection(end.blockId, end.offset)\n }\n\n const block = findBlockById(doc, sel.anchor.blockId)\n if (!block) return sel\n\n const textLen = getBlockTextLength(block)\n if (sel.anchor.offset < textLen) {\n return createCollapsedSelection(block.id, sel.anchor.offset + 1)\n }\n\n // Move to start of next block\n const idx = findBlockIndex(doc, block.id)\n if (idx < doc.blocks.length - 1) {\n const nextBlock = doc.blocks[idx + 1]\n return createCollapsedSelection(nextBlock.id, 0)\n }\n\n return sel\n}\n\n/** Move cursor up one block, trying to preserve offset. */\nexport function moveUp(doc: Document, sel: Selection): Selection {\n const pos = isCollapsed(sel) ? sel.anchor : getSelectionStart(doc, sel)\n const idx = findBlockIndex(doc, pos.blockId)\n if (idx <= 0) {\n return createCollapsedSelection(doc.blocks[0].id, 0)\n }\n const prevBlock = doc.blocks[idx - 1]\n const maxOffset = getBlockTextLength(prevBlock)\n return createCollapsedSelection(prevBlock.id, Math.min(pos.offset, maxOffset))\n}\n\n/** Move cursor down one block, trying to preserve offset. */\nexport function moveDown(doc: Document, sel: Selection): Selection {\n const pos = isCollapsed(sel) ? sel.anchor : getSelectionEnd(doc, sel)\n const idx = findBlockIndex(doc, pos.blockId)\n if (idx >= doc.blocks.length - 1) {\n const lastBlock = doc.blocks[doc.blocks.length - 1]\n return createCollapsedSelection(lastBlock.id, getBlockTextLength(lastBlock))\n }\n const nextBlock = doc.blocks[idx + 1]\n const maxOffset = getBlockTextLength(nextBlock)\n return createCollapsedSelection(nextBlock.id, Math.min(pos.offset, maxOffset))\n}\n\n/** Move cursor to the start of the current block. */\nexport function moveToStartOfBlock(doc: Document, sel: Selection): Selection {\n return createCollapsedSelection(sel.anchor.blockId, 0)\n}\n\n/** Move cursor to the end of the current block. */\nexport function moveToEndOfBlock(doc: Document, sel: Selection): Selection {\n const block = findBlockById(doc, sel.anchor.blockId)\n if (!block) return sel\n return createCollapsedSelection(block.id, getBlockTextLength(block))\n}\n\n/** Move cursor to the start of the document. */\nexport function moveToStartOfDocument(doc: Document): Selection {\n return createCollapsedSelection(doc.blocks[0].id, 0)\n}\n\n/** Move cursor to the end of the document. */\nexport function moveToEndOfDocument(doc: Document): Selection {\n const last = doc.blocks[doc.blocks.length - 1]\n return createCollapsedSelection(last.id, getBlockTextLength(last))\n}\n\n/** Expand selection to the full word surrounding the cursor. */\nexport function expandSelectionWord(doc: Document, sel: Selection): Selection {\n const block = findBlockById(doc, sel.anchor.blockId)\n if (!block) return sel\n\n const text = getBlockText(block)\n const offset = sel.anchor.offset\n\n // Find word boundaries\n let start = offset\n let end = offset\n\n const isWordChar = (ch: string) => /\\w/.test(ch)\n\n while (start > 0 && isWordChar(text[start - 1])) start--\n while (end < text.length && isWordChar(text[end])) end++\n\n return createSelection(\n createPosition(block.id, start),\n createPosition(block.id, end),\n )\n}\n\n/** Insert an inline content segment (mention, emoji) at the current selection. */\nexport function insertInlineContent(\n doc: Document,\n sel: Selection,\n segment: InlineContent,\n): EditorState {\n let d = cloneDocument(doc)\n let s = sel\n\n if (!isCollapsed(s)) {\n const result = deleteSelection(d, s)\n d = result.doc\n s = result.selection\n }\n\n const block = findBlockById(d, s.anchor.blockId)\n if (!block) return { doc: d, selection: s }\n\n block.content = insertSegmentIntoContent(block.content, s.anchor.offset, segment)\n\n const segLen = segment.type === 'text'\n ? segment.text.length\n : segment.type === 'mention'\n ? segment.label.length\n : segment.type === 'emoji'\n ? segment.unicode.length\n : 0\n\n return {\n doc: d,\n selection: createCollapsedSelection(block.id, s.anchor.offset + segLen),\n }\n}\n", "/**\n * Markdown shortcuts — auto-convert markdown syntax as the user types.\n *\n * Block shortcuts trigger at the start of a block (e.g. \"# \" -> heading).\n * Inline shortcuts trigger when a closing marker completes a pair (e.g. \"**text**\" -> bold).\n */\n\nimport type { Document, Block, Mark, MarkType, BlockType } from './model.js'\nimport type { Selection } from './selection.js'\nimport type { EditorState } from './operations.js'\nimport {\n findBlockById,\n getBlockText,\n createTextSegment,\n normalizeContent,\n cloneDocument,\n} from './model.js'\nimport { createCollapsedSelection, isCollapsed } from './selection.js'\nimport { changeBlockType } from './operations.js'\n\n// ---------------------------------------------------------------------------\n// Block shortcuts\n// ---------------------------------------------------------------------------\n\ninterface BlockShortcut {\n pattern: RegExp\n type: BlockType\n meta?: Record<string, unknown>\n}\n\nconst BLOCK_SHORTCUTS: BlockShortcut[] = [\n { pattern: /^### $/, type: 'heading', meta: { level: 3 } },\n { pattern: /^## $/, type: 'heading', meta: { level: 2 } },\n { pattern: /^# $/, type: 'heading', meta: { level: 1 } },\n { pattern: /^- $/, type: 'list-item', meta: { listType: 'bullet' } },\n { pattern: /^\\* $/, type: 'list-item', meta: { listType: 'bullet' } },\n { pattern: /^1\\. $/, type: 'list-item', meta: { listType: 'ordered' } },\n { pattern: /^> $/, type: 'blockquote' },\n { pattern: /^```$/, type: 'code-block' },\n { pattern: /^---$/, type: 'divider' },\n]\n\nfunction tryBlockShortcut(\n doc: Document,\n sel: Selection,\n char: string,\n): { doc: Document; sel: Selection; consumed: boolean } | null {\n if (!isCollapsed(sel)) return null\n\n const block = findBlockById(doc, sel.anchor.blockId)\n if (!block || block.type !== 'paragraph') return null\n\n const text = getBlockText(block)\n const textBeforeCursor = text.slice(0, sel.anchor.offset) + char\n\n for (const shortcut of BLOCK_SHORTCUTS) {\n if (shortcut.pattern.test(textBeforeCursor)) {\n const d = cloneDocument(doc)\n const b = d.blocks.find((bl) => bl.id === block.id)!\n\n // Clear the shortcut text\n b.content = [createTextSegment('')]\n\n // Special case for code-block — the ``` doesn't need the trailing space\n // and divider --- is also self-contained\n b.type = shortcut.type\n if (shortcut.meta) {\n b.meta = { ...shortcut.meta }\n }\n\n if (shortcut.type === 'divider') {\n b.content = [createTextSegment('')]\n }\n\n return {\n doc: d,\n sel: createCollapsedSelection(b.id, 0),\n consumed: true,\n }\n }\n }\n\n return null\n}\n\n// ---------------------------------------------------------------------------\n// Inline shortcuts\n// ---------------------------------------------------------------------------\n\ninterface InlineShortcut {\n openMarker: string\n closeMarker: string\n markType: MarkType\n}\n\nconst INLINE_SHORTCUTS: InlineShortcut[] = [\n { openMarker: '**', closeMarker: '**', markType: 'bold' },\n { openMarker: '__', closeMarker: '__', markType: 'bold' },\n { openMarker: '*', closeMarker: '*', markType: 'italic' },\n { openMarker: '_', closeMarker: '_', markType: 'italic' },\n { openMarker: '~~', closeMarker: '~~', markType: 'strikethrough' },\n { openMarker: '`', closeMarker: '`', markType: 'code' },\n]\n\n/**\n * Try to match an inline shortcut like **bold** or *italic*.\n * We look at the text before the cursor + the new char, and see if it\n * completes a matched pair of markers with content between them.\n */\nfunction tryInlineShortcut(\n doc: Document,\n sel: Selection,\n char: string,\n): { doc: Document; sel: Selection; consumed: boolean } | null {\n if (!isCollapsed(sel)) return null\n\n const block = findBlockById(doc, sel.anchor.blockId)\n if (!block) return null\n\n const text = getBlockText(block)\n const textWithChar = text.slice(0, sel.anchor.offset) + char\n\n // Check each inline shortcut — longer markers first (already sorted)\n for (const shortcut of INLINE_SHORTCUTS) {\n const { openMarker, closeMarker, markType } = shortcut\n\n // Check if textWithChar ends with the close marker\n if (!textWithChar.endsWith(closeMarker)) continue\n\n // Find the matching open marker before the close marker\n const beforeClose = textWithChar.slice(0, textWithChar.length - closeMarker.length)\n\n // For single-char markers like * and _, we need to be careful not to\n // confuse with the double-char ** and __ markers.\n // The open marker should not be preceded by the same character (to avoid ** matching as two *).\n const openIdx = beforeClose.lastIndexOf(openMarker)\n if (openIdx === -1) continue\n\n // Make sure there's content between the markers\n const content = beforeClose.slice(openIdx + openMarker.length)\n if (content.length === 0) continue\n\n // For single-char markers, check that the char before the open marker isn't the same\n // (to avoid **text** being matched by the * shortcut on the inner *)\n if (openMarker.length === 1 && openIdx > 0 && beforeClose[openIdx - 1] === openMarker) continue\n\n // We have a valid match! Replace the markers with marked content.\n const d = cloneDocument(doc)\n const b = d.blocks.find((bl) => bl.id === block.id)!\n\n const beforeMarkerText = text.slice(0, openIdx)\n const afterCursorText = text.slice(sel.anchor.offset)\n\n const mark: Mark = { type: markType }\n const segments = [\n ...(beforeMarkerText ? [createTextSegment(beforeMarkerText)] : []),\n createTextSegment(content, [mark]),\n ...(afterCursorText ? [createTextSegment(afterCursorText)] : []),\n ]\n\n b.content = normalizeContent(segments)\n\n const newOffset = openIdx + content.length\n return {\n doc: d,\n sel: createCollapsedSelection(b.id, newOffset),\n consumed: true,\n }\n }\n\n return null\n}\n\n// ---------------------------------------------------------------------------\n// Link shortcut: [text](url)\n// ---------------------------------------------------------------------------\nfunction tryLinkShortcut(\n doc: Document,\n sel: Selection,\n char: string,\n): { doc: Document; sel: Selection; consumed: boolean } | null {\n if (!isCollapsed(sel) || char !== ')') return null\n\n const block = findBlockById(doc, sel.anchor.blockId)\n if (!block) return null\n\n const text = getBlockText(block)\n const textWithChar = text.slice(0, sel.anchor.offset) + char\n\n // Look for [text](url) pattern ending at cursor\n const match = textWithChar.match(/\\[([^\\]]+)\\]\\(([^)]+)\\)$/)\n if (!match) return null\n\n const fullMatch = match[0]\n const linkText = match[1]\n const linkUrl = match[2]\n\n const d = cloneDocument(doc)\n const b = d.blocks.find((bl) => bl.id === block.id)!\n\n const matchStart = textWithChar.length - fullMatch.length\n const beforeText = text.slice(0, matchStart)\n const afterText = text.slice(sel.anchor.offset)\n\n const mark: Mark = { type: 'link', attrs: { href: linkUrl } }\n const segments = [\n ...(beforeText ? [createTextSegment(beforeText)] : []),\n createTextSegment(linkText, [mark]),\n ...(afterText ? [createTextSegment(afterText)] : []),\n ]\n\n b.content = normalizeContent(segments)\n\n const newOffset = matchStart + linkText.length\n return {\n doc: d,\n sel: createCollapsedSelection(b.id, newOffset),\n consumed: true,\n }\n}\n\n// ---------------------------------------------------------------------------\n// Main entry point\n// ---------------------------------------------------------------------------\n\n/**\n * Process a character input and check if it triggers a markdown shortcut.\n * Returns { consumed: true } if a shortcut was applied, { consumed: false } otherwise.\n */\nexport function processMarkdownShortcut(\n doc: Document,\n sel: Selection,\n char: string,\n): { doc: Document; sel: Selection; consumed: boolean } {\n // Try block shortcuts first (they require a space or specific ending)\n const blockResult = tryBlockShortcut(doc, sel, char)\n if (blockResult) return blockResult\n\n // Try link shortcut\n const linkResult = tryLinkShortcut(doc, sel, char)\n if (linkResult) return linkResult\n\n // Try inline shortcuts\n const inlineResult = tryInlineShortcut(doc, sel, char)\n if (inlineResult) return inlineResult\n\n return { doc, sel, consumed: false }\n}\n", "/**\n * Generalized trigger detection shared by mentions, slash commands, and any\n * symbol-prefixed autocomplete (e.g. the composer's trigger engine).\n *\n * One implementation exists so the boundary rules (\"alice@example.com\" never\n * triggers, a query never spans whitespace) cannot drift between features.\n */\n\nexport interface TriggerHit {\n triggered: boolean\n query: string\n /** Index of the trigger symbol's first character in `text`. */\n start: number\n}\n\n/**\n * Scan backward from `caret` for the nearest occurrence of `triggerChar` that\n * is preceded by start-of-text or whitespace and followed by a whitespace-free\n * query running up to the caret. Returns `null` when no live trigger exists.\n *\n * `triggerChar` may be longer than one character (e.g. `!!`); the boundary is\n * checked against the character before its first character.\n */\nexport function detectTriggerInText(\n text: string,\n caret: number,\n triggerChar: string,\n): TriggerHit | null {\n if (triggerChar.length === 0) return null\n const before = text.slice(0, caret)\n const idx = before.lastIndexOf(triggerChar)\n if (idx === -1) return null\n // Boundary rule: only start-of-text or whitespace may precede the symbol,\n // so mid-word occurrences (emails, \"and/or\") never arm.\n if (idx > 0 && !/\\s/.test(before[idx - 1])) return null\n const query = before.slice(idx + triggerChar.length)\n // A query never spans whitespace — a space closes the trigger.\n if (/\\s/.test(query)) return null\n return { triggered: true, query, start: idx }\n}\n", "/**\n * Slash command system.\n * When user types \"/\" at the start of a block or after a space, a menu appears\n * with available commands that can be filtered by typing.\n */\n\nimport type { Document, BlockType } from './model.js'\nimport type { Selection } from './selection.js'\nimport type { EditorState } from './operations.js'\nimport { changeBlockType, insertBlock, deleteSelection } from './operations.js'\nimport { createCollapsedSelection, createSelection, createPosition } from './selection.js'\nimport { findBlockById, getBlockText, cloneDocument, createTextSegment } from './model.js'\nimport { detectTriggerInText } from './trigger.js'\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\nexport interface SlashCommand {\n name: string\n label: string\n description: string\n icon?: string\n keywords: string[]\n action: (doc: Document, sel: Selection) => EditorState\n}\n\nexport interface SlashCommandMenu {\n isOpen: boolean\n query: string\n filteredCommands: SlashCommand[]\n selectedIndex: number\n open(query?: string): void\n close(): void\n search(query: string): void\n selectNext(): void\n selectPrevious(): void\n execute(doc: Document, sel: Selection): EditorState | null\n}\n\n// ---------------------------------------------------------------------------\n// Built-in commands\n// ---------------------------------------------------------------------------\n\nfunction makeBlockCommand(\n name: string,\n label: string,\n description: string,\n keywords: string[],\n blockType: BlockType,\n meta?: Record<string, unknown>,\n): SlashCommand {\n return {\n name,\n label,\n description,\n keywords,\n action: (doc: Document, sel: Selection): EditorState => {\n return changeBlockType(doc, sel, blockType, meta)\n },\n }\n}\n\nexport const BUILT_IN_COMMANDS: SlashCommand[] = [\n makeBlockCommand('heading1', 'Heading 1', 'Large section heading', ['h1', 'title', 'heading'], 'heading', { level: 1 }),\n makeBlockCommand('heading2', 'Heading 2', 'Medium section heading', ['h2', 'subtitle', 'heading'], 'heading', { level: 2 }),\n makeBlockCommand('heading3', 'Heading 3', 'Small section heading', ['h3', 'heading'], 'heading', { level: 3 }),\n makeBlockCommand('bullet-list', 'Bullet List', 'Create a bulleted list', ['ul', 'unordered', 'bullet', 'list'], 'list-item', { listType: 'bullet' }),\n makeBlockCommand('numbered-list', 'Numbered List', 'Create a numbered list', ['ol', 'ordered', 'number', 'list'], 'list-item', { listType: 'ordered' }),\n makeBlockCommand('quote', 'Quote', 'Create a blockquote', ['blockquote', 'citation', 'quote'], 'blockquote'),\n makeBlockCommand('code', 'Code Block', 'Create a code block', ['code', 'pre', 'snippet', 'programming'], 'code-block'),\n makeBlockCommand('divider', 'Divider', 'Insert a horizontal divider', ['hr', 'horizontal', 'rule', 'separator', 'line'], 'divider'),\n {\n name: 'image',\n label: 'Image',\n description: 'Insert an image',\n keywords: ['img', 'picture', 'photo'],\n action: (doc: Document, sel: Selection): EditorState => {\n return changeBlockType(doc, sel, 'image')\n },\n },\n]\n\n// ---------------------------------------------------------------------------\n// Fuzzy filter\n// ---------------------------------------------------------------------------\nfunction matchesQuery(command: SlashCommand, query: string): boolean {\n const q = query.toLowerCase()\n if (q === '') return true\n if (command.name.toLowerCase().includes(q)) return true\n if (command.label.toLowerCase().includes(q)) return true\n if (command.description.toLowerCase().includes(q)) return true\n return command.keywords.some((kw) => kw.toLowerCase().includes(q))\n}\n\n// ---------------------------------------------------------------------------\n// Menu factory\n// ---------------------------------------------------------------------------\n\nexport function createSlashCommandMenu(commands?: SlashCommand[]): SlashCommandMenu {\n const allCommands = commands ?? BUILT_IN_COMMANDS\n\n const state = {\n isOpen: false,\n query: '',\n filteredCommands: [] as SlashCommand[],\n selectedIndex: 0,\n }\n\n function updateFiltered() {\n state.filteredCommands = allCommands.filter((cmd) => matchesQuery(cmd, state.query))\n if (state.selectedIndex >= state.filteredCommands.length) {\n state.selectedIndex = Math.max(0, state.filteredCommands.length - 1)\n }\n }\n\n const menu: SlashCommandMenu = {\n get isOpen() { return state.isOpen },\n get query() { return state.query },\n get filteredCommands() { return state.filteredCommands },\n get selectedIndex() { return state.selectedIndex },\n\n open(query?: string) {\n state.isOpen = true\n state.query = query ?? ''\n state.selectedIndex = 0\n updateFiltered()\n },\n\n close() {\n state.isOpen = false\n state.query = ''\n state.filteredCommands = []\n state.selectedIndex = 0\n },\n\n search(query: string) {\n state.query = query\n updateFiltered()\n },\n\n selectNext() {\n if (state.filteredCommands.length === 0) return\n state.selectedIndex = (state.selectedIndex + 1) % state.filteredCommands.length\n },\n\n selectPrevious() {\n if (state.filteredCommands.length === 0) return\n state.selectedIndex =\n (state.selectedIndex - 1 + state.filteredCommands.length) % state.filteredCommands.length\n },\n\n execute(doc: Document, sel: Selection): EditorState | null {\n if (!state.isOpen || state.filteredCommands.length === 0) return null\n const command = state.filteredCommands[state.selectedIndex]\n if (!command) return null\n\n // Remove the slash and query text from the block before executing\n const block = findBlockById(doc, sel.anchor.blockId)\n if (block) {\n const text = getBlockText(block)\n // Find the \"/\" that started this command\n const slashIdx = text.lastIndexOf('/', sel.anchor.offset)\n if (slashIdx !== -1) {\n const d = cloneDocument(doc)\n const b = d.blocks.find((bl) => bl.id === block.id)!\n const beforeSlash = text.slice(0, slashIdx)\n const afterCursor = text.slice(sel.anchor.offset)\n b.content = [createTextSegment(beforeSlash + afterCursor)]\n const newSel = createCollapsedSelection(b.id, slashIdx)\n const result = command.action(d, newSel)\n menu.close()\n return result\n }\n }\n\n const result = command.action(doc, sel)\n menu.close()\n return result\n },\n }\n\n return menu\n}\n\n/**\n * Detect whether the user has typed a \"/\" that should trigger the slash command menu.\n */\nexport function detectSlashTrigger(\n doc: Document,\n sel: Selection,\n): { triggered: boolean; query: string } {\n const block = findBlockById(doc, sel.anchor.blockId)\n if (!block) return { triggered: false, query: '' }\n\n const hit = detectTriggerInText(getBlockText(block), sel.anchor.offset, '/')\n return hit ? { triggered: true, query: hit.query } : { triggered: false, query: '' }\n}\n", "/**\n * Mention system — trigger on \"@\" to show a searchable menu of users/channels/teams.\n */\n\nimport type { Document } from './model.js'\nimport type { Selection } from './selection.js'\nimport type { MentionSegment } from './model.js'\nimport { findBlockById, getBlockText, createMentionSegment } from './model.js'\nimport { detectTriggerInText } from './trigger.js'\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\nexport interface MentionOption {\n id: string\n label: string\n avatar?: string\n type: 'user' | 'channel' | 'team'\n}\n\nexport interface MentionMenu {\n isOpen: boolean\n query: string\n filteredOptions: MentionOption[]\n selectedIndex: number\n open(): void\n close(): void\n search(query: string): void\n selectNext(): void\n selectPrevious(): void\n select(option: MentionOption): { mention: MentionSegment }\n}\n\n// ---------------------------------------------------------------------------\n// Fuzzy matching\n// ---------------------------------------------------------------------------\n\nfunction fuzzyMatch(text: string, query: string): boolean {\n const t = text.toLowerCase()\n const q = query.toLowerCase()\n if (q === '') return true\n if (t.includes(q)) return true\n\n // Simple fuzzy: all characters of query appear in order in text\n let qi = 0\n for (let ti = 0; ti < t.length && qi < q.length; ti++) {\n if (t[ti] === q[qi]) qi++\n }\n return qi === q.length\n}\n\nfunction matchScore(option: MentionOption, query: string): number {\n const q = query.toLowerCase()\n const label = option.label.toLowerCase()\n\n if (label === q) return 100\n if (label.startsWith(q)) return 90\n if (label.includes(q)) return 80\n // Fuzzy match gets lower score\n return 50\n}\n\n// ---------------------------------------------------------------------------\n// Menu factory\n// ---------------------------------------------------------------------------\n\nexport function createMentionMenu(options: MentionOption[]): MentionMenu {\n const state = {\n isOpen: false,\n query: '',\n filteredOptions: [] as MentionOption[],\n selectedIndex: 0,\n }\n\n function updateFiltered() {\n if (state.query === '') {\n state.filteredOptions = [...options]\n } else {\n state.filteredOptions = options\n .filter((opt) => fuzzyMatch(opt.label, state.query) || fuzzyMatch(opt.id, state.query))\n .sort((a, b) => matchScore(b, state.query) - matchScore(a, state.query))\n }\n if (state.selectedIndex >= state.filteredOptions.length) {\n state.selectedIndex = Math.max(0, state.filteredOptions.length - 1)\n }\n }\n\n const menu: MentionMenu = {\n get isOpen() { return state.isOpen },\n get query() { return state.query },\n get filteredOptions() { return state.filteredOptions },\n get selectedIndex() { return state.selectedIndex },\n\n open() {\n state.isOpen = true\n state.query = ''\n state.selectedIndex = 0\n updateFiltered()\n },\n\n close() {\n state.isOpen = false\n state.query = ''\n state.filteredOptions = []\n state.selectedIndex = 0\n },\n\n search(query: string) {\n state.query = query\n updateFiltered()\n },\n\n selectNext() {\n if (state.filteredOptions.length === 0) return\n state.selectedIndex = (state.selectedIndex + 1) % state.filteredOptions.length\n },\n\n selectPrevious() {\n if (state.filteredOptions.length === 0) return\n state.selectedIndex =\n (state.selectedIndex - 1 + state.filteredOptions.length) % state.filteredOptions.length\n },\n\n select(option: MentionOption): { mention: MentionSegment } {\n menu.close()\n return {\n mention: createMentionSegment(option.id, `@${option.label}`),\n }\n },\n }\n\n return menu\n}\n\n/**\n * Detect whether the user has typed \"@\" that should trigger the mention menu.\n */\nexport function detectMentionTrigger(\n doc: Document,\n sel: Selection,\n): { triggered: boolean; query: string } {\n const block = findBlockById(doc, sel.anchor.blockId)\n if (!block) return { triggered: false, query: '' }\n\n const hit = detectTriggerInText(getBlockText(block), sel.anchor.offset, '@')\n return hit ? { triggered: true, query: hit.query } : { triggered: false, query: '' }\n}\n", "/**\n * Emoji system — shortcode to unicode mapping and search.\n *\n * The map is derived from the single shared emoji dataset in\n * `@refraction-ui/emoji-picker` (`buildShortcodeMap`), so rich-editor, the\n * emoji picker, and the composer's `:` trigger all resolve `:shortcode:` the\n * same way — the ~120-entry hand-list that used to live here (and diverge from\n * the picker) is gone. Popular aliases (`:smile:`, `:joy:`, `:100:`) live in\n * the shared dataset's `SHORTCODE_ALIASES`.\n */\nimport { buildShortcodeMap } from '@refraction-ui/emoji-picker'\n\n// ---------------------------------------------------------------------------\n// Emoji map\n// ---------------------------------------------------------------------------\n\n/** `:shortcode:` → glyph, built from the shared dataset (full set + aliases). */\nexport const EMOJI_MAP: Record<string, string> = buildShortcodeMap()\n\n// ---------------------------------------------------------------------------\n// Detection and search\n// ---------------------------------------------------------------------------\n\n/**\n * Detect if a text ends with a completed emoji shortcode like `:smile:`.\n * Returns the shortcode and its unicode value if found, null otherwise.\n */\nexport function detectEmojiShortcode(\n text: string,\n): { shortcode: string; unicode: string } | null {\n // Look for a pattern like :word: at the end of text\n const match = text.match(/:([a-z0-9_]+):$/)\n if (!match) return null\n\n const shortcode = `:${match[1]}:`\n const unicode = EMOJI_MAP[shortcode]\n if (!unicode) return null\n\n return { shortcode, unicode }\n}\n\n/**\n * Search emoji by partial query (matches against shortcode names).\n */\nexport function searchEmoji(\n query: string,\n): { shortcode: string; unicode: string }[] {\n const q = query.toLowerCase().replace(/^:/, '').replace(/:$/, '')\n if (q === '') {\n return Object.entries(EMOJI_MAP).map(([shortcode, unicode]) => ({ shortcode, unicode }))\n }\n\n return Object.entries(EMOJI_MAP)\n .filter(([shortcode]) => {\n const name = shortcode.slice(1, -1) // remove colons\n return name.includes(q)\n })\n .map(([shortcode, unicode]) => ({ shortcode, unicode }))\n .sort((a, b) => {\n const aName = a.shortcode.slice(1, -1)\n const bName = b.shortcode.slice(1, -1)\n // Exact start match first\n const aStarts = aName.startsWith(q)\n const bStarts = bName.startsWith(q)\n if (aStarts && !bStarts) return -1\n if (!aStarts && bStarts) return 1\n return aName.localeCompare(bName)\n })\n}\n", "/**\n * Undo/Redo history manager.\n * Stores snapshots of editor state for undo/redo operations.\n */\n\nimport type { EditorState } from './operations.js'\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\nexport interface HistoryManager {\n push(state: EditorState): void\n undo(): EditorState | null\n redo(): EditorState | null\n canUndo(): boolean\n canRedo(): boolean\n clear(): void\n size(): number\n}\n\n// ---------------------------------------------------------------------------\n// Factory\n// ---------------------------------------------------------------------------\n\nconst DEFAULT_MAX_SIZE = 100\n\nexport function createHistory(maxSize: number = DEFAULT_MAX_SIZE): HistoryManager {\n let undoStack: EditorState[] = []\n let redoStack: EditorState[] = []\n\n return {\n push(state: EditorState): void {\n // Deep clone to prevent mutation\n const snapshot = JSON.parse(JSON.stringify(state)) as EditorState\n undoStack.push(snapshot)\n\n // Enforce max size\n if (undoStack.length > maxSize) {\n undoStack = undoStack.slice(undoStack.length - maxSize)\n }\n\n // New edit clears the redo stack\n redoStack = []\n },\n\n undo(): EditorState | null {\n if (undoStack.length === 0) return null\n const state = undoStack.pop()!\n redoStack.push(state)\n if (undoStack.length === 0) return state\n return JSON.parse(JSON.stringify(undoStack[undoStack.length - 1])) as EditorState\n },\n\n redo(): EditorState | null {\n if (redoStack.length === 0) return null\n const state = redoStack.pop()!\n undoStack.push(state)\n return JSON.parse(JSON.stringify(state)) as EditorState\n },\n\n canUndo(): boolean {\n return undoStack.length > 0\n },\n\n canRedo(): boolean {\n return redoStack.length > 0\n },\n\n clear(): void {\n undoStack = []\n redoStack = []\n },\n\n size(): number {\n return undoStack.length\n },\n }\n}\n", "/**\n * Serialization — convert documents to/from Markdown, HTML, and plain text.\n */\n\nimport type {\n Document,\n Block,\n InlineContent,\n TextSegment,\n Mark,\n MarkType,\n BlockType,\n} from './model.js'\nimport {\n createDocument,\n createBlock,\n createTextSegment,\n createMentionSegment,\n createEmojiSegment,\n normalizeContent,\n} from './model.js'\nimport { EMOJI_MAP } from './emoji.js'\n\n// ---------------------------------------------------------------------------\n// To Markdown\n// ---------------------------------------------------------------------------\n\nfunction marksToMarkdown(text: string, marks: Mark[]): string {\n let result = text\n\n // Sort marks so that wrapping is consistent: link outermost, then bold, italic, etc.\n const sorted = [...marks].sort((a, b) => {\n const order: MarkType[] = ['link', 'bold', 'italic', 'strikethrough', 'code', 'underline']\n return order.indexOf(a.type) - order.indexOf(b.type)\n })\n\n for (const mark of sorted) {\n switch (mark.type) {\n case 'bold':\n result = `**${result}**`\n break\n case 'italic':\n result = `*${result}*`\n break\n case 'strikethrough':\n result = `~~${result}~~`\n break\n case 'code':\n result = `\\`${result}\\``\n break\n case 'underline':\n // No standard markdown for underline; use HTML\n result = `<u>${result}</u>`\n break\n case 'link':\n result = `[${result}](${mark.attrs?.href ?? ''})`\n break\n }\n }\n\n return result\n}\n\nfunction inlineContentToMarkdown(content: InlineContent[]): string {\n return content\n .map((seg) => {\n if (seg.type === 'text') {\n if (seg.text === '' && seg.marks.length === 0) return ''\n return marksToMarkdown(seg.text, seg.marks)\n }\n if (seg.type === 'mention') {\n return seg.label\n }\n if (seg.type === 'emoji') {\n return seg.shortcode\n }\n return ''\n })\n .join('')\n}\n\nfunction blockToMarkdown(block: Block): string {\n const content = inlineContentToMarkdown(block.content)\n\n switch (block.type) {\n case 'paragraph':\n return content\n case 'heading': {\n const level = (block.meta?.level as number) ?? 1\n const prefix = '#'.repeat(level)\n return `${prefix} ${content}`\n }\n case 'blockquote':\n return `> ${content}`\n case 'code-block': {\n const lang = (block.meta?.language as string) ?? ''\n return `\\`\\`\\`${lang}\\n${content}\\n\\`\\`\\``\n }\n case 'list-item': {\n const listType = block.meta?.listType as string\n const indent = ' '.repeat(block.indent ?? 0)\n if (listType === 'ordered') {\n return `${indent}1. ${content}`\n }\n return `${indent}- ${content}`\n }\n case 'divider':\n return '---'\n case 'image': {\n const src = (block.meta?.src as string) ?? ''\n const alt = (block.meta?.alt as string) ?? ''\n return ``\n }\n default:\n return content\n }\n}\n\nexport function toMarkdown(doc: Document): string {\n return doc.blocks.map(blockToMarkdown).join('\\n\\n')\n}\n\n// ---------------------------------------------------------------------------\n// From Markdown\n// ---------------------------------------------------------------------------\n\nfunction parseInlineMarkdown(text: string): InlineContent[] {\n const segments: InlineContent[] = []\n let remaining = text\n\n // Process inline patterns\n // We use a priority-based approach: longer/more complex patterns first\n while (remaining.length > 0) {\n let matched = false\n\n // Bold: **text** or __text__\n const boldMatch = remaining.match(/^\\*\\*(.+?)\\*\\*/) || remaining.match(/^__(.+?)__/)\n if (boldMatch) {\n segments.push(createTextSegment(boldMatch[1], [{ type: 'bold' }]))\n remaining = remaining.slice(boldMatch[0].length)\n matched = true\n continue\n }\n\n // Strikethrough: ~~text~~\n const strikeMatch = remaining.match(/^~~(.+?)~~/)\n if (strikeMatch) {\n segments.push(createTextSegment(strikeMatch[1], [{ type: 'strikethrough' }]))\n remaining = remaining.slice(strikeMatch[0].length)\n matched = true\n continue\n }\n\n // Italic: *text* or _text_\n const italicMatch = remaining.match(/^\\*(.+?)\\*/) || remaining.match(/^_(.+?)_/)\n if (italicMatch) {\n segments.push(createTextSegment(italicMatch[1], [{ type: 'italic' }]))\n remaining = remaining.slice(italicMatch[0].length)\n matched = true\n continue\n }\n\n // Inline code: `text`\n const codeMatch = remaining.match(/^`(.+?)`/)\n if (codeMatch) {\n segments.push(createTextSegment(codeMatch[1], [{ type: 'code' }]))\n remaining = remaining.slice(codeMatch[0].length)\n matched = true\n continue\n }\n\n // Link: [text](url)\n const linkMatch = remaining.match(/^\\[([^\\]]+)\\]\\(([^)]+)\\)/)\n if (linkMatch) {\n segments.push(\n createTextSegment(linkMatch[1], [{ type: 'link', attrs: { href: linkMatch[2] } }]),\n )\n remaining = remaining.slice(linkMatch[0].length)\n matched = true\n continue\n }\n\n // Mention: @username (word characters after @)\n const mentionMatch = remaining.match(/^@(\\w+)/)\n if (mentionMatch) {\n segments.push(createMentionSegment(mentionMatch[1], `@${mentionMatch[1]}`))\n remaining = remaining.slice(mentionMatch[0].length)\n matched = true\n continue\n }\n\n // Emoji shortcode: :name:\n const emojiMatch = remaining.match(/^:([a-z0-9_]+):/)\n if (emojiMatch) {\n const shortcode = `:${emojiMatch[1]}:`\n const unicode = EMOJI_MAP[shortcode]\n if (unicode) {\n segments.push(createEmojiSegment(shortcode, unicode))\n remaining = remaining.slice(emojiMatch[0].length)\n matched = true\n continue\n }\n }\n\n // Underline (HTML): <u>text</u>\n const underlineMatch = remaining.match(/^<u>(.+?)<\\/u>/)\n if (underlineMatch) {\n segments.push(createTextSegment(underlineMatch[1], [{ type: 'underline' }]))\n remaining = remaining.slice(underlineMatch[0].length)\n matched = true\n continue\n }\n\n if (!matched) {\n // Consume one character as plain text\n // Aggregate consecutive plain characters\n let endIdx = 1\n while (endIdx < remaining.length) {\n const ch = remaining[endIdx]\n if (ch === '*' || ch === '~' || ch === '`' || ch === '[' || ch === '@' || ch === ':' || ch === '_' || ch === '<') break\n endIdx++\n }\n segments.push(createTextSegment(remaining.slice(0, endIdx)))\n remaining = remaining.slice(endIdx)\n }\n }\n\n return segments.length > 0 ? normalizeContent(segments) : [createTextSegment('')]\n}\n\nfunction parseMarkdownLine(line: string, isInCodeBlock: boolean): Block | null {\n if (isInCodeBlock) return null // handled by caller\n\n // Heading\n const headingMatch = line.match(/^(#{1,3}) (.*)/)\n if (headingMatch) {\n const level = headingMatch[1].length as 1 | 2 | 3\n return createBlock('heading', parseInlineMarkdown(headingMatch[2]), { level })\n }\n\n // Blockquote\n const quoteMatch = line.match(/^> (.*)/)\n if (quoteMatch) {\n return createBlock('blockquote', parseInlineMarkdown(quoteMatch[1]))\n }\n\n // Divider\n if (/^---+$/.test(line.trim())) {\n return createBlock('divider')\n }\n\n // Ordered list\n const orderedMatch = line.match(/^(\\s*)(\\d+)\\. (.*)/)\n if (orderedMatch) {\n const indent = Math.floor(orderedMatch[1].length / 2)\n const block = createBlock('list-item', parseInlineMarkdown(orderedMatch[3]), { listType: 'ordered' })\n if (indent > 0) block.indent = indent\n return block\n }\n\n // Unordered list\n const unorderedMatch = line.match(/^(\\s*)[-*] (.*)/)\n if (unorderedMatch) {\n const indent = Math.floor(unorderedMatch[1].length / 2)\n const block = createBlock('list-item', parseInlineMarkdown(unorderedMatch[2]), { listType: 'bullet' })\n if (indent > 0) block.indent = indent\n return block\n }\n\n // Image\n const imageMatch = line.match(/^!\\[([^\\]]*)\\]\\(([^)]+)\\)/)\n if (imageMatch) {\n return createBlock('image', [createTextSegment('')], { alt: imageMatch[1], src: imageMatch[2] })\n }\n\n // Empty line\n if (line.trim() === '') {\n return null\n }\n\n // Paragraph (default)\n return createBlock('paragraph', parseInlineMarkdown(line))\n}\n\nexport function fromMarkdown(markdown: string): Document {\n const lines = markdown.split('\\n')\n const blocks: Block[] = []\n let i = 0\n\n while (i < lines.length) {\n const line = lines[i]\n\n // Code block\n if (line.startsWith('```')) {\n const lang = line.slice(3).trim()\n const codeLines: string[] = []\n i++\n while (i < lines.length && !lines[i].startsWith('```')) {\n codeLines.push(lines[i])\n i++\n }\n i++ // skip closing ```\n blocks.push(\n createBlock('code-block', [createTextSegment(codeLines.join('\\n'))], lang ? { language: lang } : undefined),\n )\n continue\n }\n\n const block = parseMarkdownLine(line, false)\n if (block) {\n blocks.push(block)\n }\n i++\n }\n\n return createDocument(blocks.length > 0 ? blocks : undefined)\n}\n\n// ---------------------------------------------------------------------------\n// To HTML\n// ---------------------------------------------------------------------------\n\nfunction escapeHtml(text: string): string {\n return text\n .replace(/&/g, '&')\n .replace(/</g, '<')\n .replace(/>/g, '>')\n .replace(/\"/g, '"')\n}\n\nfunction marksToHtml(text: string, marks: Mark[]): string {\n let result = escapeHtml(text)\n\n for (const mark of marks) {\n switch (mark.type) {\n case 'bold':\n result = `<strong>${result}</strong>`\n break\n case 'italic':\n result = `<em>${result}</em>`\n break\n case 'strikethrough':\n result = `<del>${result}</del>`\n break\n case 'code':\n result = `<code>${result}</code>`\n break\n case 'underline':\n result = `<u>${result}</u>`\n break\n case 'link':\n result = `<a href=\"${escapeHtml(mark.attrs?.href ?? '')}\">${result}</a>`\n break\n }\n }\n\n return result\n}\n\nfunction inlineContentToHtml(content: InlineContent[]): string {\n return content\n .map((seg) => {\n if (seg.type === 'text') {\n return marksToHtml(seg.text, seg.marks)\n }\n if (seg.type === 'mention') {\n return `<span class=\"mention\" data-mention-id=\"${escapeHtml(seg.id)}\">${escapeHtml(seg.label)}</span>`\n }\n if (seg.type === 'emoji') {\n return `<span class=\"emoji\" data-shortcode=\"${escapeHtml(seg.shortcode)}\">${seg.unicode}</span>`\n }\n return ''\n })\n .join('')\n}\n\nfunction blockToHtml(block: Block): string {\n const content = inlineContentToHtml(block.content)\n\n switch (block.type) {\n case 'paragraph':\n return `<p>${content}</p>`\n case 'heading': {\n const level = (block.meta?.level as number) ?? 1\n return `<h${level}>${content}</h${level}>`\n }\n case 'blockquote':\n return `<blockquote>${content}</blockquote>`\n case 'code-block': {\n const lang = block.meta?.language as string\n const langAttr = lang ? ` class=\"language-${escapeHtml(lang)}\"` : ''\n return `<pre><code${langAttr}>${content}</code></pre>`\n }\n case 'list-item':\n return `<li>${content}</li>`\n case 'divider':\n return '<hr>'\n case 'image': {\n const src = (block.meta?.src as string) ?? ''\n const alt = (block.meta?.alt as string) ?? ''\n return `<img src=\"${escapeHtml(src)}\" alt=\"${escapeHtml(alt)}\">`\n }\n default:\n return `<p>${content}</p>`\n }\n}\n\nexport function toHTML(doc: Document): string {\n // Group list items into <ul> or <ol>\n const parts: string[] = []\n let i = 0\n\n while (i < doc.blocks.length) {\n const block = doc.blocks[i]\n\n if (block.type === 'list-item') {\n const listType = block.meta?.listType === 'ordered' ? 'ol' : 'ul'\n const items: string[] = []\n while (i < doc.blocks.length && doc.blocks[i].type === 'list-item') {\n items.push(blockToHtml(doc.blocks[i]))\n i++\n }\n parts.push(`<${listType}>${items.join('')}</${listType}>`)\n } else {\n parts.push(blockToHtml(block))\n i++\n }\n }\n\n return parts.join('')\n}\n\n// ---------------------------------------------------------------------------\n// From HTML (basic)\n// ---------------------------------------------------------------------------\n\nfunction parseHtmlInlineContent(html: string): InlineContent[] {\n const segments: InlineContent[] = []\n let remaining = html\n\n while (remaining.length > 0) {\n let matched = false\n\n // <strong>text</strong>\n const boldMatch = remaining.match(/^<strong>(.*?)<\\/strong>/)\n if (boldMatch) {\n segments.push(createTextSegment(unescapeHtml(boldMatch[1]), [{ type: 'bold' }]))\n remaining = remaining.slice(boldMatch[0].length)\n matched = true\n continue\n }\n\n // <em>text</em>\n const emMatch = remaining.match(/^<em>(.*?)<\\/em>/)\n if (emMatch) {\n segments.push(createTextSegment(unescapeHtml(emMatch[1]), [{ type: 'italic' }]))\n remaining = remaining.slice(emMatch[0].length)\n matched = true\n continue\n }\n\n // <del>text</del>\n const delMatch = remaining.match(/^<del>(.*?)<\\/del>/)\n if (delMatch) {\n segments.push(createTextSegment(unescapeHtml(delMatch[1]), [{ type: 'strikethrough' }]))\n remaining = remaining.slice(delMatch[0].length)\n matched = true\n continue\n }\n\n // <code>text</code>\n const codeMatch = remaining.match(/^<code>(.*?)<\\/code>/)\n if (codeMatch) {\n segments.push(createTextSegment(unescapeHtml(codeMatch[1]), [{ type: 'code' }]))\n remaining = remaining.slice(codeMatch[0].length)\n matched = true\n continue\n }\n\n // <u>text</u>\n const uMatch = remaining.match(/^<u>(.*?)<\\/u>/)\n if (uMatch) {\n segments.push(createTextSegment(unescapeHtml(uMatch[1]), [{ type: 'underline' }]))\n remaining = remaining.slice(uMatch[0].length)\n matched = true\n continue\n }\n\n // <a href=\"url\">text</a>\n const linkMatch = remaining.match(/^<a href=\"([^\"]*)\">(.*?)<\\/a>/)\n if (linkMatch) {\n segments.push(\n createTextSegment(unescapeHtml(linkMatch[2]), [{ type: 'link', attrs: { href: unescapeHtml(linkMatch[1]) } }]),\n )\n remaining = remaining.slice(linkMatch[0].length)\n matched = true\n continue\n }\n\n // <span class=\"mention\" data-mention-id=\"id\">label</span>\n const mentionMatch = remaining.match(\n /^<span class=\"mention\" data-mention-id=\"([^\"]*)\">(.*?)<\\/span>/,\n )\n if (mentionMatch) {\n segments.push(createMentionSegment(unescapeHtml(mentionMatch[1]), unescapeHtml(mentionMatch[2])))\n remaining = remaining.slice(mentionMatch[0].length)\n matched = true\n continue\n }\n\n // <span class=\"emoji\" data-shortcode=\":name:\">unicode</span>\n const emojiMatch = remaining.match(\n /^<span class=\"emoji\" data-shortcode=\"([^\"]*)\">(.*?)<\\/span>/,\n )\n if (emojiMatch) {\n segments.push(createEmojiSegment(unescapeHtml(emojiMatch[1]), emojiMatch[2]))\n remaining = remaining.slice(emojiMatch[0].length)\n matched = true\n continue\n }\n\n if (!matched) {\n // Plain text until next tag\n const nextTagIdx = remaining.indexOf('<', 1)\n if (nextTagIdx === -1) {\n segments.push(createTextSegment(unescapeHtml(remaining)))\n break\n }\n // Check if the first char is '<' (unknown tag)\n if (remaining[0] === '<') {\n // Skip unknown tag\n const closeIdx = remaining.indexOf('>')\n if (closeIdx !== -1) {\n remaining = remaining.slice(closeIdx + 1)\n } else {\n remaining = remaining.slice(1)\n }\n } else {\n segments.push(createTextSegment(unescapeHtml(remaining.slice(0, nextTagIdx))))\n remaining = remaining.slice(nextTagIdx)\n }\n }\n }\n\n return segments.length > 0 ? normalizeContent(segments) : [createTextSegment('')]\n}\n\nfunction unescapeHtml(text: string): string {\n return text\n .replace(/&/g, '&')\n .replace(/</g, '<')\n .replace(/>/g, '>')\n .replace(/"/g, '\"')\n}\n\nexport function fromHTML(html: string): Document {\n const blocks: Block[] = []\n let remaining = html\n\n while (remaining.length > 0) {\n let matched = false\n\n // <h1>...</h1>, <h2>...</h2>, <h3>...</h3>\n const headingMatch = remaining.match(/^<h([123])>(.*?)<\\/h\\1>/)\n if (headingMatch) {\n const level = parseInt(headingMatch[1]) as 1 | 2 | 3\n blocks.push(createBlock('heading', parseHtmlInlineContent(headingMatch[2]), { level }))\n remaining = remaining.slice(headingMatch[0].length)\n matched = true\n continue\n }\n\n // <p>...</p>\n const pMatch = remaining.match(/^<p>(.*?)<\\/p>/)\n if (pMatch) {\n blocks.push(createBlock('paragraph', parseHtmlInlineContent(pMatch[1])))\n remaining = remaining.slice(pMatch[0].length)\n matched = true\n continue\n }\n\n // <blockquote>...</blockquote>\n const bqMatch = remaining.match(/^<blockquote>(.*?)<\\/blockquote>/)\n if (bqMatch) {\n blocks.push(createBlock('blockquote', parseHtmlInlineContent(bqMatch[1])))\n remaining = remaining.slice(bqMatch[0].length)\n matched = true\n continue\n }\n\n // <pre><code...>...</code></pre>\n const preMatch = remaining.match(/^<pre><code(?: class=\"language-([^\"]*)\")?>(.*?)<\\/code><\\/pre>/)\n if (preMatch) {\n const lang = preMatch[1]\n blocks.push(\n createBlock('code-block', [createTextSegment(unescapeHtml(preMatch[2]))], lang ? { language: lang } : undefined),\n )\n remaining = remaining.slice(preMatch[0].length)\n matched = true\n continue\n }\n\n // <ul>...</ul>\n const ulMatch = remaining.match(/^<ul>(.*?)<\\/ul>/)\n if (ulMatch) {\n const liMatches = ulMatch[1].matchAll(/<li>(.*?)<\\/li>/g)\n for (const li of liMatches) {\n blocks.push(createBlock('list-item', parseHtmlInlineContent(li[1]), { listType: 'bullet' }))\n }\n remaining = remaining.slice(ulMatch[0].length)\n matched = true\n continue\n }\n\n // <ol>...</ol>\n const olMatch = remaining.match(/^<ol>(.*?)<\\/ol>/)\n if (olMatch) {\n const liMatches = olMatch[1].matchAll(/<li>(.*?)<\\/li>/g)\n for (const li of liMatches) {\n blocks.push(createBlock('list-item', parseHtmlInlineContent(li[1]), { listType: 'ordered' }))\n }\n remaining = remaining.slice(olMatch[0].length)\n matched = true\n continue\n }\n\n // <hr> or <hr/>\n const hrMatch = remaining.match(/^<hr\\s*\\/?>/)\n if (hrMatch) {\n blocks.push(createBlock('divider'))\n remaining = remaining.slice(hrMatch[0].length)\n matched = true\n continue\n }\n\n // <img src=\"...\" alt=\"...\">\n const imgMatch = remaining.match(/^<img src=\"([^\"]*)\" alt=\"([^\"]*)\"\\s*\\/?>/)\n if (imgMatch) {\n blocks.push(createBlock('image', [createTextSegment('')], { src: unescapeHtml(imgMatch[1]), alt: unescapeHtml(imgMatch[2]) }))\n remaining = remaining.slice(imgMatch[0].length)\n matched = true\n continue\n }\n\n if (!matched) {\n // Skip whitespace or unknown content\n const nextTagIdx = remaining.indexOf('<', 1)\n if (nextTagIdx === -1) {\n // Remaining text as paragraph\n const trimmed = remaining.trim()\n if (trimmed) {\n blocks.push(createBlock('paragraph', [createTextSegment(trimmed)]))\n }\n break\n }\n if (remaining[0] !== '<') {\n const text = remaining.slice(0, nextTagIdx).trim()\n if (text) {\n blocks.push(createBlock('paragraph', [createTextSegment(text)]))\n }\n remaining = remaining.slice(nextTagIdx)\n } else {\n // Unknown tag, skip\n const closeIdx = remaining.indexOf('>')\n if (closeIdx !== -1) {\n remaining = remaining.slice(closeIdx + 1)\n } else {\n break\n }\n }\n }\n }\n\n return createDocument(blocks.length > 0 ? blocks : undefined)\n}\n\n// ---------------------------------------------------------------------------\n// To Plain Text\n// ---------------------------------------------------------------------------\n\nfunction blockToPlainText(block: Block): string {\n const content = block.content\n .map((seg) => {\n if (seg.type === 'text') return seg.text\n if (seg.type === 'mention') return seg.label\n if (seg.type === 'emoji') return seg.unicode\n return ''\n })\n .join('')\n\n switch (block.type) {\n case 'heading':\n return content\n case 'blockquote':\n return content\n case 'list-item': {\n const indent = ' '.repeat(block.indent ?? 0)\n const listType = block.meta?.listType\n const prefix = listType === 'ordered' ? '1. ' : '- '\n return `${indent}${prefix}${content}`\n }\n case 'divider':\n return '---'\n default:\n return content\n }\n}\n\nexport function toPlainText(doc: Document): string {\n return doc.blocks.map(blockToPlainText).join('\\n')\n}\n", "/**\n * Main editor factory — ties together all subsystems into a unified API.\n */\n\nimport type {\n Document,\n Mark,\n MarkType,\n BlockType,\n} from './model.js'\nimport {\n createDocument,\n getBlockTextLength,\n} from './model.js'\nimport type { Selection } from './selection.js'\nimport {\n selectionAtDocStart,\n} from './selection.js'\nimport type { EditorState } from './operations.js'\nimport {\n insertText as opInsertText,\n deleteBackward as opDeleteBackward,\n deleteForward as opDeleteForward,\n splitBlock as opSplitBlock,\n changeBlockType as opChangeBlockType,\n indentBlock as opIndentBlock,\n outdentBlock as opOutdentBlock,\n toggleMark as opToggleMark,\n getActiveMarks as opGetActiveMarks,\n} from './operations.js'\nimport { processMarkdownShortcut } from './markdown-shortcuts.js'\nimport type { SlashCommand, SlashCommandMenu } from './slash-commands.js'\nimport { createSlashCommandMenu } from './slash-commands.js'\nimport type { MentionOption, MentionMenu } from './mentions.js'\nimport { createMentionMenu } from './mentions.js'\nimport { toMarkdown, fromMarkdown, toHTML, toPlainText } from './serialization.js'\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\nexport interface EditorConfig {\n initialContent?: Document | string\n placeholder?: string\n readOnly?: boolean\n maxLength?: number\n slashCommands?: SlashCommand[]\n mentionOptions?: MentionOption[]\n onUpdate?: (state: EditorState) => void\n}\n\nexport interface EditorAPI {\n // State\n getState(): EditorState\n getDoc(): Document\n getSelection(): Selection\n\n // Content\n insertText(text: string): void\n deleteBackward(): void\n deleteForward(): void\n toggleMark(mark: MarkType): void\n changeBlockType(type: BlockType, meta?: Record<string, unknown>): void\n splitBlock(): void\n indentBlock(): void\n outdentBlock(): void\n\n // Markdown\n getMarkdown(): string\n getHTML(): string\n getPlainText(): string\n setContent(markdown: string): void\n\n // Menus\n slashMenu: SlashCommandMenu\n mentionMenu: MentionMenu\n\n // History\n undo(): void\n redo(): void\n canUndo(): boolean\n canRedo(): boolean\n\n // Marks\n getActiveMarks(): Mark[]\n isMarkActive(type: MarkType): boolean\n\n // State queries\n isEmpty(): boolean\n getCharCount(): number\n getWordCount(): number\n\n // Events\n subscribe(fn: (state: EditorState) => void): () => void\n destroy(): void\n}\n\n// ---------------------------------------------------------------------------\n// Factory\n// ---------------------------------------------------------------------------\n\nexport function createEditor(config?: EditorConfig): EditorAPI {\n const {\n initialContent,\n placeholder,\n readOnly = false,\n maxLength,\n slashCommands,\n mentionOptions = [],\n onUpdate,\n } = config ?? {}\n\n // Initialize document\n let doc: Document = (() => {\n if (!initialContent) return createDocument()\n if (typeof initialContent === 'string') return fromMarkdown(initialContent)\n return JSON.parse(JSON.stringify(initialContent)) as Document\n })()\n\n let selection: Selection = selectionAtDocStart(doc)\n const slashMenu = createSlashCommandMenu(slashCommands)\n const mentionMenuInstance = createMentionMenu(mentionOptions)\n const subscribers: Set<(state: EditorState) => void> = new Set()\n\n // Internal undo/redo stacks (stores pre-edit snapshots)\n let undoStack: EditorState[] = []\n let redoStack: EditorState[] = []\n const MAX_HISTORY = 100\n\n function cloneState(): EditorState {\n return JSON.parse(JSON.stringify({ doc, selection })) as EditorState\n }\n\n function getState(): EditorState {\n return { doc, selection }\n }\n\n function notify(): void {\n const state = getState()\n for (const fn of subscribers) {\n fn(state)\n }\n onUpdate?.(state)\n }\n\n function pushHistory(): void {\n undoStack.push(cloneState())\n if (undoStack.length > MAX_HISTORY) {\n undoStack = undoStack.slice(undoStack.length - MAX_HISTORY)\n }\n // New edit clears redo\n redoStack = []\n }\n\n function getPlainTextContent(): string {\n return toPlainText(doc)\n }\n\n function checkMaxLength(text: string): boolean {\n if (maxLength === undefined) return true\n const currentLen = getPlainTextContent().length\n return currentLen + text.length <= maxLength\n }\n\n // ---------------------------------------------------------------------------\n // API\n // ---------------------------------------------------------------------------\n\n const api: EditorAPI = {\n getState,\n getDoc: () => doc,\n getSelection: () => selection,\n\n insertText(text: string): void {\n if (readOnly) return\n if (!checkMaxLength(text)) return\n\n pushHistory()\n\n // Check markdown shortcuts for single character input\n if (text.length === 1) {\n const shortcutResult = processMarkdownShortcut(doc, selection, text)\n if (shortcutResult.consumed) {\n doc = shortcutResult.doc\n selection = shortcutResult.sel\n notify()\n return\n }\n }\n\n const result = opInsertText(doc, selection, text)\n doc = result.doc\n selection = result.selection\n notify()\n },\n\n deleteBackward(): void {\n if (readOnly) return\n pushHistory()\n const result = opDeleteBackward(doc, selection)\n doc = result.doc\n selection = result.selection\n notify()\n },\n\n deleteForward(): void {\n if (readOnly) return\n pushHistory()\n const result = opDeleteForward(doc, selection)\n doc = result.doc\n selection = result.selection\n notify()\n },\n\n toggleMark(mark: MarkType): void {\n if (readOnly) return\n pushHistory()\n const result = opToggleMark(doc, selection, mark)\n doc = result.doc\n selection = result.selection\n notify()\n },\n\n changeBlockType(type: BlockType, meta?: Record<string, unknown>): void {\n if (readOnly) return\n pushHistory()\n const result = opChangeBlockType(doc, selection, type, meta)\n doc = result.doc\n selection = result.selection\n notify()\n },\n\n splitBlock(): void {\n if (readOnly) return\n pushHistory()\n const result = opSplitBlock(doc, selection)\n doc = result.doc\n selection = result.selection\n notify()\n },\n\n indentBlock(): void {\n if (readOnly) return\n pushHistory()\n const result = opIndentBlock(doc, selection)\n doc = result.doc\n selection = result.selection\n notify()\n },\n\n outdentBlock(): void {\n if (readOnly) return\n pushHistory()\n const result = opOutdentBlock(doc, selection)\n doc = result.doc\n selection = result.selection\n notify()\n },\n\n getMarkdown: () => toMarkdown(doc),\n getHTML: () => toHTML(doc),\n getPlainText: () => toPlainText(doc),\n\n setContent(markdown: string): void {\n pushHistory()\n doc = fromMarkdown(markdown)\n selection = selectionAtDocStart(doc)\n notify()\n },\n\n slashMenu,\n mentionMenu: mentionMenuInstance,\n\n undo(): void {\n if (undoStack.length === 0) return\n // Save current state to redo stack\n redoStack.push(cloneState())\n // Restore from undo stack\n const state = undoStack.pop()!\n doc = state.doc\n selection = state.selection\n notify()\n },\n\n redo(): void {\n if (redoStack.length === 0) return\n // Save current state to undo stack\n undoStack.push(cloneState())\n // Restore from redo stack\n const state = redoStack.pop()!\n doc = state.doc\n selection = state.selection\n notify()\n },\n\n canUndo: () => undoStack.length > 0,\n canRedo: () => redoStack.length > 0,\n\n getActiveMarks(): Mark[] {\n return opGetActiveMarks(doc, selection)\n },\n\n isMarkActive(type: MarkType): boolean {\n return opGetActiveMarks(doc, selection).some((m) => m.type === type)\n },\n\n isEmpty(): boolean {\n if (doc.blocks.length === 0) return true\n if (doc.blocks.length > 1) return false\n const block = doc.blocks[0]\n return getBlockTextLength(block) === 0\n },\n\n getCharCount(): number {\n let count = 0\n for (const block of doc.blocks) {\n count += getBlockTextLength(block)\n }\n return count\n },\n\n getWordCount(): number {\n const text = toPlainText(doc)\n const trimmed = text.trim()\n if (trimmed === '') return 0\n return trimmed.split(/\\s+/).length\n },\n\n subscribe(fn: (state: EditorState) => void): () => void {\n subscribers.add(fn)\n return () => { subscribers.delete(fn) }\n },\n\n destroy(): void {\n subscribers.clear()\n undoStack = []\n redoStack = []\n },\n }\n\n return api\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|