@agent-native/toolkit 0.5.1 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +17 -1
- package/dist/chat-history/ChatHistoryList.d.ts +103 -0
- package/dist/chat-history/ChatHistoryList.d.ts.map +1 -0
- package/dist/chat-history/ChatHistoryList.js +109 -0
- package/dist/chat-history/ChatHistoryList.js.map +1 -0
- package/dist/chat-history/ChatHistoryList.spec.d.ts +2 -0
- package/dist/chat-history/ChatHistoryList.spec.d.ts.map +1 -0
- package/dist/chat-history/ChatHistoryList.spec.js +283 -0
- package/dist/chat-history/ChatHistoryList.spec.js.map +1 -0
- package/dist/chat-history/index.d.ts +2 -0
- package/dist/chat-history/index.d.ts.map +1 -0
- package/dist/chat-history/index.js +2 -0
- package/dist/chat-history/index.js.map +1 -0
- package/dist/chat-history.css +314 -0
- package/dist/composer/AgentComposerFrame.d.ts +19 -0
- package/dist/composer/AgentComposerFrame.d.ts.map +1 -0
- package/dist/composer/AgentComposerFrame.js +14 -0
- package/dist/composer/AgentComposerFrame.js.map +1 -0
- package/dist/composer/ComposerPlusMenu.d.ts +17 -0
- package/dist/composer/ComposerPlusMenu.d.ts.map +1 -0
- package/dist/composer/ComposerPlusMenu.js +501 -0
- package/dist/composer/ComposerPlusMenu.js.map +1 -0
- package/dist/composer/MentionPopover.d.ts +29 -0
- package/dist/composer/MentionPopover.d.ts.map +1 -0
- package/dist/composer/MentionPopover.js +180 -0
- package/dist/composer/MentionPopover.js.map +1 -0
- package/dist/composer/PastedTextChip.d.ts +9 -0
- package/dist/composer/PastedTextChip.d.ts.map +1 -0
- package/dist/composer/PastedTextChip.js +47 -0
- package/dist/composer/PastedTextChip.js.map +1 -0
- package/dist/composer/PromptComposer.d.ts +114 -0
- package/dist/composer/PromptComposer.d.ts.map +1 -0
- package/dist/composer/PromptComposer.js +302 -0
- package/dist/composer/PromptComposer.js.map +1 -0
- package/dist/composer/PromptComposer.spec.d.ts +2 -0
- package/dist/composer/PromptComposer.spec.d.ts.map +1 -0
- package/dist/composer/PromptComposer.spec.js +64 -0
- package/dist/composer/PromptComposer.spec.js.map +1 -0
- package/dist/composer/RealtimeVoiceMode.d.ts +104 -0
- package/dist/composer/RealtimeVoiceMode.d.ts.map +1 -0
- package/dist/composer/RealtimeVoiceMode.js +245 -0
- package/dist/composer/RealtimeVoiceMode.js.map +1 -0
- package/dist/composer/RealtimeVoiceMode.spec.d.ts +2 -0
- package/dist/composer/RealtimeVoiceMode.spec.d.ts.map +1 -0
- package/dist/composer/RealtimeVoiceMode.spec.js +439 -0
- package/dist/composer/RealtimeVoiceMode.spec.js.map +1 -0
- package/dist/composer/TiptapComposer.d.ts +221 -0
- package/dist/composer/TiptapComposer.d.ts.map +1 -0
- package/dist/composer/TiptapComposer.js +1838 -0
- package/dist/composer/TiptapComposer.js.map +1 -0
- package/dist/composer/TiptapComposer.spec.d.ts +2 -0
- package/dist/composer/TiptapComposer.spec.d.ts.map +1 -0
- package/dist/composer/TiptapComposer.spec.js +277 -0
- package/dist/composer/TiptapComposer.spec.js.map +1 -0
- package/dist/composer/VoiceButton.d.ts +25 -0
- package/dist/composer/VoiceButton.d.ts.map +1 -0
- package/dist/composer/VoiceButton.js +100 -0
- package/dist/composer/VoiceButton.js.map +1 -0
- package/dist/composer/VoiceButton.spec.d.ts +2 -0
- package/dist/composer/VoiceButton.spec.d.ts.map +1 -0
- package/dist/composer/VoiceButton.spec.js +19 -0
- package/dist/composer/VoiceButton.spec.js.map +1 -0
- package/dist/composer/asset-picker-url.d.ts +16 -0
- package/dist/composer/asset-picker-url.d.ts.map +1 -0
- package/dist/composer/asset-picker-url.js +64 -0
- package/dist/composer/asset-picker-url.js.map +1 -0
- package/dist/composer/asset-picker-url.spec.d.ts +2 -0
- package/dist/composer/asset-picker-url.spec.d.ts.map +1 -0
- package/dist/composer/asset-picker-url.spec.js +23 -0
- package/dist/composer/asset-picker-url.spec.js.map +1 -0
- package/dist/composer/attachment-accept.d.ts +9 -0
- package/dist/composer/attachment-accept.d.ts.map +1 -0
- package/dist/composer/attachment-accept.js +65 -0
- package/dist/composer/attachment-accept.js.map +1 -0
- package/dist/composer/attachment-accept.spec.d.ts +2 -0
- package/dist/composer/attachment-accept.spec.d.ts.map +1 -0
- package/dist/composer/attachment-accept.spec.js +15 -0
- package/dist/composer/attachment-accept.spec.js.map +1 -0
- package/dist/composer/draft-key.d.ts +2 -0
- package/dist/composer/draft-key.d.ts.map +1 -0
- package/dist/composer/draft-key.js +8 -0
- package/dist/composer/draft-key.js.map +1 -0
- package/dist/composer/draft-key.spec.d.ts +2 -0
- package/dist/composer/draft-key.spec.d.ts.map +1 -0
- package/dist/composer/draft-key.spec.js +13 -0
- package/dist/composer/draft-key.spec.js.map +1 -0
- package/dist/composer/extensions/FileReference.d.ts +3 -0
- package/dist/composer/extensions/FileReference.d.ts.map +1 -0
- package/dist/composer/extensions/FileReference.js +36 -0
- package/dist/composer/extensions/FileReference.js.map +1 -0
- package/dist/composer/extensions/MentionReference.d.ts +3 -0
- package/dist/composer/extensions/MentionReference.d.ts.map +1 -0
- package/dist/composer/extensions/MentionReference.js +66 -0
- package/dist/composer/extensions/MentionReference.js.map +1 -0
- package/dist/composer/extensions/SkillReference.d.ts +3 -0
- package/dist/composer/extensions/SkillReference.d.ts.map +1 -0
- package/dist/composer/extensions/SkillReference.js +40 -0
- package/dist/composer/extensions/SkillReference.js.map +1 -0
- package/dist/composer/index.d.ts +14 -0
- package/dist/composer/index.d.ts.map +1 -0
- package/dist/composer/index.js +13 -0
- package/dist/composer/index.js.map +1 -0
- package/dist/composer/pasted-text.d.ts +31 -0
- package/dist/composer/pasted-text.d.ts.map +1 -0
- package/dist/composer/pasted-text.js +130 -0
- package/dist/composer/pasted-text.js.map +1 -0
- package/dist/composer/pasted-text.spec.d.ts +2 -0
- package/dist/composer/pasted-text.spec.d.ts.map +1 -0
- package/dist/composer/pasted-text.spec.js +107 -0
- package/dist/composer/pasted-text.spec.js.map +1 -0
- package/dist/composer/prompt-attachments.d.ts +18 -0
- package/dist/composer/prompt-attachments.d.ts.map +1 -0
- package/dist/composer/prompt-attachments.js +65 -0
- package/dist/composer/prompt-attachments.js.map +1 -0
- package/dist/composer/prompt-attachments.spec.d.ts +2 -0
- package/dist/composer/prompt-attachments.spec.d.ts.map +1 -0
- package/dist/composer/prompt-attachments.spec.js +63 -0
- package/dist/composer/prompt-attachments.spec.js.map +1 -0
- package/dist/composer/realtime-voice-audio-level.d.ts +14 -0
- package/dist/composer/realtime-voice-audio-level.d.ts.map +1 -0
- package/dist/composer/realtime-voice-audio-level.js +47 -0
- package/dist/composer/realtime-voice-audio-level.js.map +1 -0
- package/dist/composer/realtime-voice-audio-level.spec.d.ts +2 -0
- package/dist/composer/realtime-voice-audio-level.spec.d.ts.map +1 -0
- package/dist/composer/realtime-voice-audio-level.spec.js +25 -0
- package/dist/composer/realtime-voice-audio-level.spec.js.map +1 -0
- package/dist/composer/realtime-voice-transcript.d.ts +26 -0
- package/dist/composer/realtime-voice-transcript.d.ts.map +1 -0
- package/dist/composer/realtime-voice-transcript.js +103 -0
- package/dist/composer/realtime-voice-transcript.js.map +1 -0
- package/dist/composer/realtime-voice-transcript.spec.d.ts +2 -0
- package/dist/composer/realtime-voice-transcript.spec.d.ts.map +1 -0
- package/dist/composer/realtime-voice-transcript.spec.js +76 -0
- package/dist/composer/realtime-voice-transcript.spec.js.map +1 -0
- package/dist/composer/runtime-adapters.d.ts +163 -0
- package/dist/composer/runtime-adapters.d.ts.map +1 -0
- package/dist/composer/runtime-adapters.js +130 -0
- package/dist/composer/runtime-adapters.js.map +1 -0
- package/dist/composer/types.d.ts +60 -0
- package/dist/composer/types.d.ts.map +1 -0
- package/dist/composer/types.js +2 -0
- package/dist/composer/types.js.map +1 -0
- package/dist/composer/use-file-search.d.ts +6 -0
- package/dist/composer/use-file-search.d.ts.map +1 -0
- package/dist/composer/use-file-search.js +53 -0
- package/dist/composer/use-file-search.js.map +1 -0
- package/dist/composer/use-mention-search.d.ts +6 -0
- package/dist/composer/use-mention-search.d.ts.map +1 -0
- package/dist/composer/use-mention-search.js +75 -0
- package/dist/composer/use-mention-search.js.map +1 -0
- package/dist/composer/use-skills.d.ts +7 -0
- package/dist/composer/use-skills.d.ts.map +1 -0
- package/dist/composer/use-skills.js +40 -0
- package/dist/composer/use-skills.js.map +1 -0
- package/dist/composer/useRealtimeVoiceMode.d.ts +179 -0
- package/dist/composer/useRealtimeVoiceMode.d.ts.map +1 -0
- package/dist/composer/useRealtimeVoiceMode.js +1657 -0
- package/dist/composer/useRealtimeVoiceMode.js.map +1 -0
- package/dist/composer/useRealtimeVoiceMode.spec.d.ts +2 -0
- package/dist/composer/useRealtimeVoiceMode.spec.d.ts.map +1 -0
- package/dist/composer/useRealtimeVoiceMode.spec.js +618 -0
- package/dist/composer/useRealtimeVoiceMode.spec.js.map +1 -0
- package/dist/composer/useVoiceDictation.d.ts +57 -0
- package/dist/composer/useVoiceDictation.d.ts.map +1 -0
- package/dist/composer/useVoiceDictation.js +1004 -0
- package/dist/composer/useVoiceDictation.js.map +1 -0
- package/dist/composer/useVoiceDictation.spec.d.ts +2 -0
- package/dist/composer/useVoiceDictation.spec.d.ts.map +1 -0
- package/dist/composer/useVoiceDictation.spec.js +32 -0
- package/dist/composer/useVoiceDictation.spec.js.map +1 -0
- package/dist/editor/RegistryBlockContext.d.ts +68 -0
- package/dist/editor/RegistryBlockContext.d.ts.map +1 -0
- package/dist/editor/RegistryBlockContext.js +10 -0
- package/dist/editor/RegistryBlockContext.js.map +1 -0
- package/dist/editor/RegistryBlockNode.d.ts +66 -0
- package/dist/editor/RegistryBlockNode.d.ts.map +1 -0
- package/dist/editor/RegistryBlockNode.js +406 -0
- package/dist/editor/RegistryBlockNode.js.map +1 -0
- package/dist/editor/RegistryBlockNode.spec.d.ts +2 -0
- package/dist/editor/RegistryBlockNode.spec.d.ts.map +1 -0
- package/dist/editor/RegistryBlockNode.spec.js +215 -0
- package/dist/editor/RegistryBlockNode.spec.js.map +1 -0
- package/dist/editor/index.d.ts +2 -0
- package/dist/editor/index.d.ts.map +1 -1
- package/dist/editor/index.js +2 -0
- package/dist/editor/index.js.map +1 -1
- package/dist/editor/registrySlashCommands.d.ts +68 -0
- package/dist/editor/registrySlashCommands.d.ts.map +1 -0
- package/dist/editor/registrySlashCommands.js +51 -0
- package/dist/editor/registrySlashCommands.js.map +1 -0
- package/package.json +32 -1
- package/src/app-shell/header-actions.tsx +84 -0
- package/src/app-shell/index.ts +1 -0
- package/src/chat-history/ChatHistoryList.spec.tsx +453 -0
- package/src/chat-history/ChatHistoryList.tsx +469 -0
- package/src/chat-history/index.ts +7 -0
- package/src/chat-history.css +314 -0
- package/src/collab-ui/AgentPresenceChip.tsx +69 -0
- package/src/collab-ui/LiveCursorOverlay.tsx +241 -0
- package/src/collab-ui/PresenceBar.tsx +346 -0
- package/src/collab-ui/RecentEditHighlights.tsx +225 -0
- package/src/collab-ui/RemoteSelectionRings.tsx +227 -0
- package/src/collab-ui/agent-identity.ts +16 -0
- package/src/collab-ui/index.ts +39 -0
- package/src/collab-ui/lead-client.ts +32 -0
- package/src/collab-ui/types.spec.ts +18 -0
- package/src/collab-ui/types.ts +96 -0
- package/src/composer/AgentComposerFrame.tsx +59 -0
- package/src/composer/ComposerPlusMenu.tsx +983 -0
- package/src/composer/MentionPopover.tsx +412 -0
- package/src/composer/PastedTextChip.tsx +150 -0
- package/src/composer/PromptComposer.spec.ts +73 -0
- package/src/composer/PromptComposer.tsx +718 -0
- package/src/composer/RealtimeVoiceMode.spec.tsx +771 -0
- package/src/composer/RealtimeVoiceMode.tsx +856 -0
- package/src/composer/TiptapComposer.spec.ts +390 -0
- package/src/composer/TiptapComposer.tsx +2936 -0
- package/src/composer/VoiceButton.spec.ts +33 -0
- package/src/composer/VoiceButton.tsx +255 -0
- package/src/composer/asset-picker-url.spec.ts +56 -0
- package/src/composer/asset-picker-url.ts +80 -0
- package/src/composer/attachment-accept.spec.ts +22 -0
- package/src/composer/attachment-accept.ts +71 -0
- package/src/composer/draft-key.spec.ts +19 -0
- package/src/composer/draft-key.ts +7 -0
- package/src/composer/extensions/FileReference.tsx +55 -0
- package/src/composer/extensions/MentionReference.tsx +93 -0
- package/src/composer/extensions/SkillReference.tsx +56 -0
- package/src/composer/index.ts +79 -0
- package/src/composer/pasted-text.spec.ts +162 -0
- package/src/composer/pasted-text.ts +160 -0
- package/src/composer/prompt-attachments.spec.ts +80 -0
- package/src/composer/prompt-attachments.ts +93 -0
- package/src/composer/realtime-voice-audio-level.spec.ts +32 -0
- package/src/composer/realtime-voice-audio-level.ts +64 -0
- package/src/composer/realtime-voice-transcript.spec.ts +95 -0
- package/src/composer/realtime-voice-transcript.ts +134 -0
- package/src/composer/runtime-adapters.tsx +343 -0
- package/src/composer/types.ts +66 -0
- package/src/composer/use-file-search.ts +64 -0
- package/src/composer/use-mention-search.ts +90 -0
- package/src/composer/use-skills.ts +45 -0
- package/src/composer/useRealtimeVoiceMode.spec.ts +818 -0
- package/src/composer/useRealtimeVoiceMode.tsx +2117 -0
- package/src/composer/useVoiceDictation.spec.ts +45 -0
- package/src/composer/useVoiceDictation.ts +1191 -0
- package/src/context-ui/ContextMeter.tsx +101 -0
- package/src/context-ui/ContextSegmentRow.tsx +184 -0
- package/src/context-ui/ContextTreemap.tsx +125 -0
- package/src/context-ui/ContextXRayPanel.tsx +377 -0
- package/src/context-ui/format.ts +42 -0
- package/src/context-ui/index.ts +15 -0
- package/src/context-ui/types.ts +43 -0
- package/src/design-tweaks/index.ts +1 -0
- package/src/design-tweaks/visual-style-controls.tsx +1084 -0
- package/src/editor/BubbleToolbar.tsx +253 -0
- package/src/editor/CodeBlockNode.tsx +254 -0
- package/src/editor/DragHandle.spec.ts +588 -0
- package/src/editor/DragHandle.ts +1437 -0
- package/src/editor/ImageExtension.roundtrip.spec.ts +117 -0
- package/src/editor/ImageExtension.ts +306 -0
- package/src/editor/RegistryBlockContext.tsx +107 -0
- package/src/editor/RegistryBlockNode.spec.ts +279 -0
- package/src/editor/RegistryBlockNode.tsx +652 -0
- package/src/editor/RichMarkdownEditor.collab.spec.ts +79 -0
- package/src/editor/RichMarkdownEditor.roundtrip.spec.ts +226 -0
- package/src/editor/RichMarkdownEditor.tsx +81 -0
- package/src/editor/RunId.ts +61 -0
- package/src/editor/SharedRichEditor.tsx +321 -0
- package/src/editor/SlashCommandMenu.tsx +310 -0
- package/src/editor/disableHistory.spec.ts +85 -0
- package/src/editor/extensions.ts +402 -0
- package/src/editor/gfmDoc.spec.ts +230 -0
- package/src/editor/gfmDoc.ts +92 -0
- package/src/editor/index.ts +93 -0
- package/src/editor/registrySlashCommands.ts +131 -0
- package/src/editor/structuralReorderUndo.spec.ts +144 -0
- package/src/editor/surgical-apply.spec.ts +279 -0
- package/src/editor/surgical-apply.ts +192 -0
- package/src/editor/useCollabReconcile.concurrent.spec.ts +422 -0
- package/src/editor/useCollabReconcile.idempotent.spec.ts +324 -0
- package/src/editor/useCollabReconcile.ts +589 -0
- package/src/editor.css +510 -0
- package/src/hooks/index.ts +2 -0
- package/src/hooks/use-mobile.tsx +21 -0
- package/src/hooks/use-toast.ts +188 -0
- package/src/index.ts +8 -0
- package/src/onboarding/index.ts +1 -0
- package/src/provider.tsx +47 -0
- package/src/sharing/VisibilityBadge.spec.ts +96 -0
- package/src/sharing/VisibilityBadge.tsx +37 -0
- package/src/sharing/index.ts +4 -0
- package/src/styles.css +21 -0
- package/src/ui/accordion.tsx +56 -0
- package/src/ui/alert-dialog.tsx +141 -0
- package/src/ui/alert.tsx +59 -0
- package/src/ui/aspect-ratio.tsx +5 -0
- package/src/ui/avatar.tsx +48 -0
- package/src/ui/badge.tsx +37 -0
- package/src/ui/breadcrumb.tsx +115 -0
- package/src/ui/button.tsx +78 -0
- package/src/ui/calendar.tsx +214 -0
- package/src/ui/card.tsx +86 -0
- package/src/ui/carousel.tsx +260 -0
- package/src/ui/chart.tsx +375 -0
- package/src/ui/checkbox.tsx +28 -0
- package/src/ui/collapsible.tsx +9 -0
- package/src/ui/command.spec.tsx +51 -0
- package/src/ui/command.tsx +174 -0
- package/src/ui/context-menu.tsx +202 -0
- package/src/ui/date-picker.tsx +64 -0
- package/src/ui/dialog.spec.tsx +43 -0
- package/src/ui/dialog.tsx +146 -0
- package/src/ui/drawer.tsx +126 -0
- package/src/ui/dropdown-menu.tsx +218 -0
- package/src/ui/form.tsx +177 -0
- package/src/ui/hover-card.tsx +27 -0
- package/src/ui/index.ts +52 -0
- package/src/ui/input-otp.tsx +69 -0
- package/src/ui/input.tsx +22 -0
- package/src/ui/label.tsx +24 -0
- package/src/ui/logo.tsx +36 -0
- package/src/ui/menubar.tsx +235 -0
- package/src/ui/navigation-menu.tsx +128 -0
- package/src/ui/pagination.tsx +121 -0
- package/src/ui/popover.tsx +44 -0
- package/src/ui/progress.tsx +26 -0
- package/src/ui/radio-group.tsx +42 -0
- package/src/ui/resizable.tsx +43 -0
- package/src/ui/scroll-area.tsx +46 -0
- package/src/ui/select.tsx +158 -0
- package/src/ui/separator.tsx +29 -0
- package/src/ui/sheet.tsx +160 -0
- package/src/ui/sidebar.tsx +777 -0
- package/src/ui/skeleton.tsx +18 -0
- package/src/ui/slider.tsx +26 -0
- package/src/ui/sonner.tsx +58 -0
- package/src/ui/spinner.tsx +17 -0
- package/src/ui/switch.tsx +27 -0
- package/src/ui/table.tsx +117 -0
- package/src/ui/tabs.tsx +53 -0
- package/src/ui/textarea.tsx +23 -0
- package/src/ui/toast.tsx +127 -0
- package/src/ui/toaster.tsx +33 -0
- package/src/ui/toggle-group.tsx +59 -0
- package/src/ui/toggle.tsx +43 -0
- package/src/ui/tooltip.tsx +53 -0
- package/src/ui/use-toast.ts +3 -0
- package/src/utils.ts +8 -0
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import type { JSONContent } from "@tiptap/core";
|
|
4
|
+
import { describe, expect, it } from "vitest";
|
|
5
|
+
|
|
6
|
+
import { gfmToProseJSON, proseJSONToGfm } from "./gfmDoc.js";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* GFM ↔ ProseMirror primitive for the plan single-doc editor.
|
|
10
|
+
*
|
|
11
|
+
* `gfmToProseJSON` / `proseJSONToGfm` go through ONE memoized headless Tiptap
|
|
12
|
+
* editor built from the SAME `createSharedEditorExtensions({ dialect: "gfm",
|
|
13
|
+
* features: { image: true } })` config the live plan editor uses, plus the
|
|
14
|
+
* `RunId` extension. These tests assert two things:
|
|
15
|
+
*
|
|
16
|
+
* 1. `proseJSONToGfm(gfmToProseJSON(md))` normalizes STABLY — once a corpus
|
|
17
|
+
* entry has been through the converter, running it through again is a
|
|
18
|
+
* fixed point (the live editor re-serializes the whole doc on every edit,
|
|
19
|
+
* so a non-stable round-trip would churn untouched prose on first edit).
|
|
20
|
+
* 2. `gfmToProseJSON` produces the EXPECTED top-level node types for a known
|
|
21
|
+
* corpus, so the bridge can rely on the shapes it walks.
|
|
22
|
+
*
|
|
23
|
+
* The headless editor needs a DOM (ProseMirror's EditorView); this file runs
|
|
24
|
+
* under `happy-dom` (the same env the other editor specs use), proving the
|
|
25
|
+
* primitive works in jsdom-style test runners as well as the browser.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/** First pass: md → prose → md. */
|
|
29
|
+
function once(markdown: string): string {
|
|
30
|
+
return proseJSONToGfm(gfmToProseJSON(markdown));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Asserts the converter reaches a fixed point (re-running is a no-op). */
|
|
34
|
+
function expectStableRoundTrip(markdown: string): void {
|
|
35
|
+
const first = once(markdown);
|
|
36
|
+
const second = once(first);
|
|
37
|
+
expect(second).toBe(first);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Top-level node types produced for a markdown string. */
|
|
41
|
+
function topLevelTypes(markdown: string): string[] {
|
|
42
|
+
return gfmToProseJSON(markdown).map((node) => node.type ?? "");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Top-level node types with any trailing empty paragraph(s) dropped.
|
|
47
|
+
*
|
|
48
|
+
* The markdown parser appends a trailing empty `paragraph` whenever the doc
|
|
49
|
+
* would otherwise end in a non-paragraph block (heading / list / code / table /
|
|
50
|
+
* blockquote). This is a genuine ProseMirror parse artifact — the GFM
|
|
51
|
+
* serializer drops it again, so the round-trip stays byte-stable — and the
|
|
52
|
+
* plan's doc→blocks bridge skips whitespace-only runs, so it is not meaningful
|
|
53
|
+
* content. These assertions focus on the meaningful leading block types.
|
|
54
|
+
*/
|
|
55
|
+
function meaningfulTypes(markdown: string): string[] {
|
|
56
|
+
const types = topLevelTypes(markdown);
|
|
57
|
+
while (
|
|
58
|
+
types.length > 1 &&
|
|
59
|
+
types[types.length - 1] === "paragraph" &&
|
|
60
|
+
isEmptyParagraph(gfmToProseJSON(markdown)[types.length - 1])
|
|
61
|
+
) {
|
|
62
|
+
types.pop();
|
|
63
|
+
}
|
|
64
|
+
return types;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** True when a paragraph node has no inline content (the trailing filler). */
|
|
68
|
+
function isEmptyParagraph(node: JSONContent | undefined): boolean {
|
|
69
|
+
return node?.type === "paragraph" && (node.content ?? []).length === 0;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const PROSE_CORPUS: Record<string, string> = {
|
|
73
|
+
"heading 1": "# Heading 1",
|
|
74
|
+
"heading 2": "## Heading 2",
|
|
75
|
+
"heading 3": "### Heading 3",
|
|
76
|
+
paragraph: "A plain paragraph of prose.",
|
|
77
|
+
"bulleted list": "- One\n- Two\n- Three",
|
|
78
|
+
"numbered list": "1. One\n2. Two\n3. Three",
|
|
79
|
+
"task list": "- [ ] Todo item\n- [x] Done item",
|
|
80
|
+
blockquote: "> A quoted line.",
|
|
81
|
+
"fenced code block": "```ts\nconst x: number = 1;\n```",
|
|
82
|
+
"inline marks": "Text with **bold**, *italic*, `code`, and a word.",
|
|
83
|
+
link: "See [the docs](https://example.com/docs) for details.",
|
|
84
|
+
table: [
|
|
85
|
+
"| Name | Status |",
|
|
86
|
+
"| --- | --- |",
|
|
87
|
+
"| Alpha | Done |",
|
|
88
|
+
"| Beta | Open |",
|
|
89
|
+
"",
|
|
90
|
+
"Trailing paragraph so the table is not the last block.",
|
|
91
|
+
].join("\n"),
|
|
92
|
+
"mixed body": [
|
|
93
|
+
"# Project Plan",
|
|
94
|
+
"",
|
|
95
|
+
"## Overview",
|
|
96
|
+
"",
|
|
97
|
+
"This plan covers the **rollout** of the new flow.",
|
|
98
|
+
"",
|
|
99
|
+
"- Step one",
|
|
100
|
+
"- Step two",
|
|
101
|
+
"",
|
|
102
|
+
"> A note to remember.",
|
|
103
|
+
"",
|
|
104
|
+
"```ts",
|
|
105
|
+
"const ready = true;",
|
|
106
|
+
"```",
|
|
107
|
+
].join("\n"),
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
describe("gfmDoc GFM ↔ ProseMirror primitive", () => {
|
|
111
|
+
describe("round-trip is stable (fixed point)", () => {
|
|
112
|
+
for (const [name, markdown] of Object.entries(PROSE_CORPUS)) {
|
|
113
|
+
it(`stabilizes: ${name}`, () => {
|
|
114
|
+
expectStableRoundTrip(markdown);
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
describe("gfmToProseJSON produces expected node types", () => {
|
|
120
|
+
it("headings carry their level", () => {
|
|
121
|
+
expect(meaningfulTypes("# A\n\n## B\n\n### C")).toEqual([
|
|
122
|
+
"heading",
|
|
123
|
+
"heading",
|
|
124
|
+
"heading",
|
|
125
|
+
]);
|
|
126
|
+
const levels = gfmToProseJSON("# A\n\n## B\n\n### C")
|
|
127
|
+
.filter((n) => n.type === "heading")
|
|
128
|
+
.map((n) => n.attrs?.level);
|
|
129
|
+
expect(levels).toEqual([1, 2, 3]);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("paragraph", () => {
|
|
133
|
+
expect(meaningfulTypes("Just text.")).toEqual(["paragraph"]);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("bulleted list", () => {
|
|
137
|
+
expect(meaningfulTypes("- a\n- b")).toEqual(["bulletList"]);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("numbered list", () => {
|
|
141
|
+
expect(meaningfulTypes("1. a\n2. b")).toEqual(["orderedList"]);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("task list", () => {
|
|
145
|
+
expect(meaningfulTypes("- [ ] a\n- [x] b")).toEqual(["taskList"]);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("blockquote", () => {
|
|
149
|
+
expect(meaningfulTypes("> quoted")).toEqual(["blockquote"]);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("fenced code block keeps language", () => {
|
|
153
|
+
const nodes = gfmToProseJSON("```ts\nconst x = 1;\n```");
|
|
154
|
+
const code = nodes.find((n) => n.type === "codeBlock");
|
|
155
|
+
expect(meaningfulTypes("```ts\nconst x = 1;\n```")).toEqual([
|
|
156
|
+
"codeBlock",
|
|
157
|
+
]);
|
|
158
|
+
expect(code?.attrs?.language).toBe("ts");
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("GFM pipe table", () => {
|
|
162
|
+
expect(meaningfulTypes(["| H |", "| --- |", "| v |"].join("\n"))).toEqual(
|
|
163
|
+
["table"],
|
|
164
|
+
);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("inline marks land on the paragraph's text", () => {
|
|
168
|
+
const nodes = gfmToProseJSON("Has **bold**, *italic*, `code`.");
|
|
169
|
+
expect(nodes).toHaveLength(1);
|
|
170
|
+
const para = nodes[0] as JSONContent;
|
|
171
|
+
expect(para.type).toBe("paragraph");
|
|
172
|
+
const markTypes = new Set(
|
|
173
|
+
(para.content ?? []).flatMap((child) =>
|
|
174
|
+
(child.marks ?? []).map((m) => m.type),
|
|
175
|
+
),
|
|
176
|
+
);
|
|
177
|
+
expect(markTypes.has("bold")).toBe(true);
|
|
178
|
+
expect(markTypes.has("italic")).toBe(true);
|
|
179
|
+
expect(markTypes.has("code")).toBe(true);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("link mark carries the href", () => {
|
|
183
|
+
const nodes = gfmToProseJSON("See [docs](https://example.com).");
|
|
184
|
+
const para = nodes[0] as JSONContent;
|
|
185
|
+
const linkMark = (para.content ?? [])
|
|
186
|
+
.flatMap((child) => child.marks ?? [])
|
|
187
|
+
.find((m) => m.type === "link");
|
|
188
|
+
expect(linkMark?.attrs?.href).toBe("https://example.com");
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
describe("runId attribute", () => {
|
|
193
|
+
it("is omitted from serialized GFM markdown", () => {
|
|
194
|
+
const nodes = gfmToProseJSON("A paragraph.");
|
|
195
|
+
const stamped = nodes.map((n, i) =>
|
|
196
|
+
i === 0
|
|
197
|
+
? { ...n, attrs: { ...(n.attrs ?? {}), runId: "block-123" } }
|
|
198
|
+
: n,
|
|
199
|
+
);
|
|
200
|
+
const markdown = proseJSONToGfm(stamped);
|
|
201
|
+
expect(markdown).toBe("A paragraph.");
|
|
202
|
+
expect(markdown).not.toContain("block-123");
|
|
203
|
+
expect(markdown).not.toContain("run-id");
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("survives a prose → JSON → prose round-trip when set", () => {
|
|
207
|
+
const nodes = gfmToProseJSON("A paragraph.");
|
|
208
|
+
const stamped = nodes.map((n, i) =>
|
|
209
|
+
i === 0 ? { ...n, attrs: { ...(n.attrs ?? {}), runId: "block-9" } } : n,
|
|
210
|
+
);
|
|
211
|
+
// Serializing drops runId (GFM), but the attribute is a valid schema
|
|
212
|
+
// attribute on the node JSON — re-parsing markdown yields runId: null.
|
|
213
|
+
const reparsed = gfmToProseJSON(proseJSONToGfm(stamped));
|
|
214
|
+
expect(reparsed[0]?.type).toBe("paragraph");
|
|
215
|
+
expect(reparsed[0]?.attrs?.runId ?? null).toBeNull();
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
describe("edge cases", () => {
|
|
220
|
+
it("empty string yields a single empty paragraph", () => {
|
|
221
|
+
const nodes = gfmToProseJSON("");
|
|
222
|
+
expect(nodes).toHaveLength(1);
|
|
223
|
+
expect(nodes[0]?.type).toBe("paragraph");
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it("empty node array serializes to empty markdown", () => {
|
|
227
|
+
expect(proseJSONToGfm([])).toBe("");
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
});
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { Editor, type JSONContent } from "@tiptap/core";
|
|
2
|
+
|
|
3
|
+
import { createSharedEditorExtensions } from "./extensions.js";
|
|
4
|
+
import { RunId } from "./RunId.js";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The GFM ↔ ProseMirror primitive for the plan single-doc editor.
|
|
8
|
+
*
|
|
9
|
+
* Plans keep `PlanContent.blocks[]` as the source of truth, but the editor is
|
|
10
|
+
* ONE ProseMirror/Tiptap document. The `doc ↔ blocks[]` serializer
|
|
11
|
+
* (`templates/plan/shared/plan-doc.ts`) needs to turn a `rich-text` block's GFM
|
|
12
|
+
* markdown into prose nodes and back. This module is that primitive.
|
|
13
|
+
*
|
|
14
|
+
* Both directions go through a SINGLE headless Tiptap {@link Editor} built from
|
|
15
|
+
* the exact same `createSharedEditorExtensions` config the live plan editor
|
|
16
|
+
* uses (`dialect: "gfm"`, `features.image: true`) plus the {@link RunId}
|
|
17
|
+
* extension, so the schema and the GFM serializer can never drift from the live
|
|
18
|
+
* editor. The instance is created lazily on first use and reused across calls.
|
|
19
|
+
*
|
|
20
|
+
* The headless editor needs a DOM (ProseMirror's `EditorView`). It works under
|
|
21
|
+
* `happy-dom` in vitest (see `gfmDoc.spec.ts`) and under the real browser DOM
|
|
22
|
+
* in production. `createElement` is used rather than mounting into the page so
|
|
23
|
+
* nothing is ever attached to the document.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
let sharedEditor: Editor | null = null;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Lazily build (and memoize) the single headless editor. Throws if no DOM is
|
|
30
|
+
* available — this primitive is for the client / jsdom-style test envs only.
|
|
31
|
+
*/
|
|
32
|
+
function getSharedEditor(): Editor {
|
|
33
|
+
if (sharedEditor) return sharedEditor;
|
|
34
|
+
if (typeof document === "undefined") {
|
|
35
|
+
throw new Error(
|
|
36
|
+
"gfmDoc requires a DOM (document). It runs in the browser and in jsdom/happy-dom tests, not in a bare Node server context.",
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
sharedEditor = new Editor({
|
|
40
|
+
element: document.createElement("div"),
|
|
41
|
+
extensions: createSharedEditorExtensions({
|
|
42
|
+
dialect: "gfm",
|
|
43
|
+
features: { image: true },
|
|
44
|
+
extraExtensions: [RunId],
|
|
45
|
+
}),
|
|
46
|
+
content: "",
|
|
47
|
+
});
|
|
48
|
+
return sharedEditor;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Reads the GFM markdown out of the tiptap-markdown storage. */
|
|
52
|
+
function getMarkdown(editor: Editor): string {
|
|
53
|
+
const storage = editor.storage as unknown as {
|
|
54
|
+
markdown?: { getMarkdown?: () => string };
|
|
55
|
+
};
|
|
56
|
+
return storage.markdown?.getMarkdown?.() ?? "";
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Parse a GFM markdown string into an array of top-level ProseMirror node JSON
|
|
61
|
+
* (paragraph / heading / list / table / code block / etc.), matching the live
|
|
62
|
+
* plan editor schema (`createSharedEditorExtensions({ dialect: "gfm",
|
|
63
|
+
* features: { image: true } })`) plus the {@link RunId} attribute.
|
|
64
|
+
*
|
|
65
|
+
* `tiptap-markdown` registers the markdown parser, so handing the raw markdown
|
|
66
|
+
* string to `setContent` deserializes it (the same path the live editor uses
|
|
67
|
+
* when it seeds `content: markdown`). Returns the doc's child nodes; an empty
|
|
68
|
+
* string yields a single empty paragraph.
|
|
69
|
+
*/
|
|
70
|
+
export function gfmToProseJSON(markdown: string): JSONContent[] {
|
|
71
|
+
const editor = getSharedEditor();
|
|
72
|
+
// `emitUpdate: false` keeps this a pure transform with no side effects on any
|
|
73
|
+
// (non-existent) consumers of the headless editor's update stream.
|
|
74
|
+
editor.commands.setContent(markdown, { emitUpdate: false });
|
|
75
|
+
return editor.getJSON().content ?? [];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Serialize an array of top-level ProseMirror node JSON into GFM markdown. The
|
|
80
|
+
* `runId` attribute is omitted by GFM (the {@link RunId} extension registers no
|
|
81
|
+
* markdown serializer), so it never leaks into the saved markdown.
|
|
82
|
+
*
|
|
83
|
+
* The nodes are wrapped in a `doc` and set on the shared editor, then the GFM
|
|
84
|
+
* markdown is read from the tiptap-markdown storage — the exact serializer the
|
|
85
|
+
* live editor persists with, so output is byte-stable with the live save path.
|
|
86
|
+
*/
|
|
87
|
+
export function proseJSONToGfm(nodes: JSONContent[]): string {
|
|
88
|
+
const editor = getSharedEditor();
|
|
89
|
+
const content = nodes.length > 0 ? nodes : [{ type: "paragraph" }];
|
|
90
|
+
editor.commands.setContent({ type: "doc", content }, { emitUpdate: false });
|
|
91
|
+
return getMarkdown(editor);
|
|
92
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
export {
|
|
2
|
+
createSharedEditorExtensions,
|
|
3
|
+
MARKDOWN_DIALECT_CONFIG,
|
|
4
|
+
type RichMarkdownDialect,
|
|
5
|
+
type RichMarkdownEditorPreset,
|
|
6
|
+
type RichMarkdownCollabUser,
|
|
7
|
+
type SharedEditorCollab,
|
|
8
|
+
type SharedEditorFeatures,
|
|
9
|
+
type CreateSharedEditorExtensionsOptions,
|
|
10
|
+
} from "./extensions.js";
|
|
11
|
+
export {
|
|
12
|
+
useCollabReconcile,
|
|
13
|
+
getEditorMarkdown,
|
|
14
|
+
RICH_MARKDOWN_PROGRAMMATIC_TRANSACTION,
|
|
15
|
+
type UseCollabReconcileOptions,
|
|
16
|
+
type UseCollabReconcileResult,
|
|
17
|
+
} from "./useCollabReconcile.js";
|
|
18
|
+
export {
|
|
19
|
+
applyDocSurgically,
|
|
20
|
+
defaultParseValue,
|
|
21
|
+
diffTopLevel,
|
|
22
|
+
type TopLevelDiff,
|
|
23
|
+
} from "./surgical-apply.js";
|
|
24
|
+
export {
|
|
25
|
+
SlashCommandMenu,
|
|
26
|
+
DEFAULT_SLASH_COMMANDS,
|
|
27
|
+
createImageSlashCommand,
|
|
28
|
+
type SlashCommandItem,
|
|
29
|
+
type SlashCommandMenuProps,
|
|
30
|
+
} from "./SlashCommandMenu.js";
|
|
31
|
+
export {
|
|
32
|
+
SharedImage,
|
|
33
|
+
createImageExtension,
|
|
34
|
+
pickAndInsertImage,
|
|
35
|
+
type ImageUploadFn,
|
|
36
|
+
type SharedImageOptions,
|
|
37
|
+
} from "./ImageExtension.js";
|
|
38
|
+
export {
|
|
39
|
+
BubbleToolbar,
|
|
40
|
+
buildDefaultBubbleItems,
|
|
41
|
+
type BubbleToolbarItem,
|
|
42
|
+
type BubbleToolbarProps,
|
|
43
|
+
} from "./BubbleToolbar.js";
|
|
44
|
+
export {
|
|
45
|
+
SharedRichEditor,
|
|
46
|
+
type SharedRichEditorProps,
|
|
47
|
+
} from "./SharedRichEditor.js";
|
|
48
|
+
export {
|
|
49
|
+
createCodeBlockNode,
|
|
50
|
+
DEFAULT_CODE_LANGUAGES,
|
|
51
|
+
type CodeLanguageOption,
|
|
52
|
+
type CodeBlockClassNames,
|
|
53
|
+
type CreateCodeBlockNodeOptions,
|
|
54
|
+
} from "./CodeBlockNode.js";
|
|
55
|
+
export {
|
|
56
|
+
RichMarkdownEditor,
|
|
57
|
+
createRichMarkdownExtensions,
|
|
58
|
+
type RichMarkdownEditorProps,
|
|
59
|
+
type CreateRichMarkdownExtensionsOptions,
|
|
60
|
+
} from "./RichMarkdownEditor.js";
|
|
61
|
+
export { RunId, RUN_ID_NODE_TYPES } from "./RunId.js";
|
|
62
|
+
export { gfmToProseJSON, proseJSONToGfm } from "./gfmDoc.js";
|
|
63
|
+
export {
|
|
64
|
+
DragHandle,
|
|
65
|
+
DEFAULT_DRAG_HANDLE_WRAPPER_SELECTOR,
|
|
66
|
+
type DragHandleDropContext,
|
|
67
|
+
type DragHandleDropPlacement,
|
|
68
|
+
type DragHandleOptions,
|
|
69
|
+
} from "./DragHandle.js";
|
|
70
|
+
export {
|
|
71
|
+
createRegistryBlockNode,
|
|
72
|
+
LegacyJsonEditSurface,
|
|
73
|
+
RegistryBlockDataProvider,
|
|
74
|
+
RegistryBlockNodeView,
|
|
75
|
+
useRegistryBlockData,
|
|
76
|
+
type CreateRegistryBlockNodeOptions,
|
|
77
|
+
type RegistryBlockDataChangeMeta,
|
|
78
|
+
type RegistryBlockDataValue,
|
|
79
|
+
type RegistryBlockEditSurfaceOptions,
|
|
80
|
+
type RegistryBlockNestedBlock,
|
|
81
|
+
type RegistryBlockRenderOptions,
|
|
82
|
+
type RegistryBlockRenderResult,
|
|
83
|
+
type RegistryBlockSideMapBlock,
|
|
84
|
+
} from "./RegistryBlockNode.js";
|
|
85
|
+
export {
|
|
86
|
+
buildRegistryBlockSlashItems,
|
|
87
|
+
getRegistryBlockSlashDescription,
|
|
88
|
+
getRegistryBlockSlashSearchText,
|
|
89
|
+
type BuildRegistryBlockSlashItemsOptions,
|
|
90
|
+
type RegistrySlashBlockRegistry,
|
|
91
|
+
type RegistrySlashBlockSpec,
|
|
92
|
+
} from "./registrySlashCommands.js";
|
|
93
|
+
export type { JSONContent } from "@tiptap/core";
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
export interface RegistrySlashBlockSpec {
|
|
2
|
+
type: string;
|
|
3
|
+
label: string;
|
|
4
|
+
description: string;
|
|
5
|
+
notionCompatible?: boolean;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface RegistrySlashBlockRegistry<
|
|
9
|
+
TSpec extends RegistrySlashBlockSpec = RegistrySlashBlockSpec,
|
|
10
|
+
> {
|
|
11
|
+
list: (placement: "block") => TSpec[];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const COMPACT_REGISTRY_BLOCK_DESCRIPTIONS: Record<string, string> = {
|
|
15
|
+
callout: "Emphasized note",
|
|
16
|
+
diagram: "Inline diagram",
|
|
17
|
+
wireframe: "Screen mockup",
|
|
18
|
+
"question-form": "Interactive questions",
|
|
19
|
+
checklist: "Checklist items",
|
|
20
|
+
table: "Editable grid",
|
|
21
|
+
"table-block": "Editable grid",
|
|
22
|
+
"code-tabs": "Tabbed code snippets",
|
|
23
|
+
"custom-html": "Sandboxed HTML",
|
|
24
|
+
tabs: "Tabbed block group",
|
|
25
|
+
columns: "Side-by-side columns",
|
|
26
|
+
mermaid: "Mermaid diagram",
|
|
27
|
+
"api-endpoint": "API reference",
|
|
28
|
+
"openapi-spec": "OpenAPI document",
|
|
29
|
+
"data-model": "ERD schema",
|
|
30
|
+
diff: "Code diff",
|
|
31
|
+
"file-tree": "File/change tree",
|
|
32
|
+
"json-explorer": "JSON tree",
|
|
33
|
+
"annotated-code": "Code walkthrough",
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Compact, user-facing slash-menu copy for structured registry blocks. The full
|
|
38
|
+
* registry description remains available through search text, but the visible
|
|
39
|
+
* row should scan like a command palette, not a block reference page.
|
|
40
|
+
*/
|
|
41
|
+
export function getRegistryBlockSlashDescription(
|
|
42
|
+
spec: Pick<RegistrySlashBlockSpec, "type" | "description">,
|
|
43
|
+
): string {
|
|
44
|
+
return (
|
|
45
|
+
COMPACT_REGISTRY_BLOCK_DESCRIPTIONS[spec.type] ??
|
|
46
|
+
spec.description.trim().replace(/\s+/g, " ")
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Searchable text for registry block slash items, including raw type keywords. */
|
|
51
|
+
export function getRegistryBlockSlashSearchText(
|
|
52
|
+
spec: Pick<RegistrySlashBlockSpec, "type" | "label" | "description">,
|
|
53
|
+
): string {
|
|
54
|
+
return [spec.label, spec.description, spec.type]
|
|
55
|
+
.filter(Boolean)
|
|
56
|
+
.join(" ")
|
|
57
|
+
.toLowerCase();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Shared builder for the registry-derived block slash commands both the plan and
|
|
62
|
+
* content editors offer. Both apps take every `BlockSpec` whose `placement`
|
|
63
|
+
* includes `"block"`, gate it by Notion-compatibility when the open document is
|
|
64
|
+
* linked to a Notion page, and emit one slash item per surviving spec that
|
|
65
|
+
* inserts that block's atom node. The only legitimate per-app differences are:
|
|
66
|
+
*
|
|
67
|
+
* - the ITEM SHAPE (plan uses a text-glyph `icon`, content a React component),
|
|
68
|
+
* - the Notion-compat PREDICATE (plan unions in prose-only NFM analogs, content
|
|
69
|
+
* reads the registry `notionCompatible` flag directly), and
|
|
70
|
+
* - the INSERT behavior (plan inserts a `planBlock` node, content a
|
|
71
|
+
* `registryBlock` node seeded with inline `__raw`).
|
|
72
|
+
*
|
|
73
|
+
* Those three are injected; everything else (the `list("block")` source, the
|
|
74
|
+
* Notion filter wiring, the one-item-per-spec mapping) lives here so adding a
|
|
75
|
+
* new library block only touches the registry, never the slash builders.
|
|
76
|
+
*/
|
|
77
|
+
export interface BuildRegistryBlockSlashItemsOptions<
|
|
78
|
+
TItem,
|
|
79
|
+
TEditor,
|
|
80
|
+
TSpec extends RegistrySlashBlockSpec = RegistrySlashBlockSpec,
|
|
81
|
+
> {
|
|
82
|
+
/**
|
|
83
|
+
* When `true`, only specs the predicate accepts are offered (the open document
|
|
84
|
+
* is linked to a Notion page, so blocks that can't round-trip to NFM are
|
|
85
|
+
* hidden). When unset/false, every block-placed spec is offered.
|
|
86
|
+
*/
|
|
87
|
+
notionCompatibleOnly?: boolean;
|
|
88
|
+
/**
|
|
89
|
+
* Decide whether a spec round-trips to Notion. Defaults to the spec's own
|
|
90
|
+
* `notionCompatible` flag (content's rule). Plan passes a predicate that unions
|
|
91
|
+
* in prose-only NFM analogs not carried as registry flags.
|
|
92
|
+
*/
|
|
93
|
+
isNotionCompatible?: (spec: TSpec) => boolean;
|
|
94
|
+
/** Build one app-shaped slash item from a surviving block spec. */
|
|
95
|
+
toItem: (spec: TSpec, insert: (editor: TEditor) => void) => TItem;
|
|
96
|
+
/**
|
|
97
|
+
* Optional app-level capability gate. Use this for blocks whose schema is
|
|
98
|
+
* registered for parse/render compatibility but whose authoring experience is
|
|
99
|
+
* not available in this editor yet.
|
|
100
|
+
*/
|
|
101
|
+
includeSpec?: (spec: TSpec) => boolean;
|
|
102
|
+
/**
|
|
103
|
+
* Insert this spec's block atom into the editor. Plan inserts a `planBlock`
|
|
104
|
+
* node; content inserts a `registryBlock` node seeded with inline `__raw`.
|
|
105
|
+
*/
|
|
106
|
+
insertBlock: (editor: TEditor, spec: TSpec) => void;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Build the registry-derived block slash items, shared by plan and content. Each
|
|
111
|
+
* app prepends its own prose/base commands and wraps the result in its own item
|
|
112
|
+
* type via {@link BuildRegistryBlockSlashItemsOptions.toItem}.
|
|
113
|
+
*/
|
|
114
|
+
export function buildRegistryBlockSlashItems<
|
|
115
|
+
TItem,
|
|
116
|
+
TEditor,
|
|
117
|
+
TSpec extends RegistrySlashBlockSpec = RegistrySlashBlockSpec,
|
|
118
|
+
>(
|
|
119
|
+
registry: RegistrySlashBlockRegistry<TSpec>,
|
|
120
|
+
options: BuildRegistryBlockSlashItemsOptions<TItem, TEditor, TSpec>,
|
|
121
|
+
): TItem[] {
|
|
122
|
+
const isCompatible =
|
|
123
|
+
options.isNotionCompatible ?? ((spec) => Boolean(spec.notionCompatible));
|
|
124
|
+
return registry
|
|
125
|
+
.list("block")
|
|
126
|
+
.filter((spec) => options.includeSpec?.(spec) ?? true)
|
|
127
|
+
.filter((spec) => !options.notionCompatibleOnly || isCompatible(spec))
|
|
128
|
+
.map((spec) =>
|
|
129
|
+
options.toItem(spec, (editor) => options.insertBlock(editor, spec)),
|
|
130
|
+
);
|
|
131
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import { Editor, Node } from "@tiptap/core";
|
|
4
|
+
import { describe, expect, it } from "vitest";
|
|
5
|
+
|
|
6
|
+
import { createRichMarkdownExtensions } from "./RichMarkdownEditor.js";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Structural block reorder must be undoable with cmd+z (Notion parity).
|
|
10
|
+
*
|
|
11
|
+
* The plan editor renders the whole document as ONE ProseMirror doc; structured
|
|
12
|
+
* blocks are atomic `planBlock` nodes. Dragging a block to reorder it repaints
|
|
13
|
+
* the doc via a whole-document `replaceWith` (the plan editor's
|
|
14
|
+
* `replaceEditorViewBlocks`). That repaint used to be dispatched with
|
|
15
|
+
* `addToHistory: false`, so the editor's (already working) undo keymap had
|
|
16
|
+
* nothing to revert — cmd+z after a drag did nothing. The fix makes a
|
|
17
|
+
* single-editor drag repaint a HISTORICAL transaction, so the existing undo
|
|
18
|
+
* stack captures the reorder and interleaves it with text edits, exactly like
|
|
19
|
+
* Notion.
|
|
20
|
+
*
|
|
21
|
+
* This reproduces that exact mechanism headlessly: a minimal block-level ATOM
|
|
22
|
+
* (the analog of `planBlock`, but with a plain-DOM render so no React NodeView
|
|
23
|
+
* is needed) reordered by a whole-document `replaceWith`, dispatched the two
|
|
24
|
+
* ways — and asserts cmd+z reverts ONLY the historical one.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
// Minimal stand-in for the plan's atomic `planBlock` node.
|
|
28
|
+
const BlockAtom = Node.create({
|
|
29
|
+
name: "blockAtom",
|
|
30
|
+
group: "block",
|
|
31
|
+
atom: true,
|
|
32
|
+
selectable: true,
|
|
33
|
+
draggable: true,
|
|
34
|
+
addAttributes() {
|
|
35
|
+
return { blockId: { default: null } };
|
|
36
|
+
},
|
|
37
|
+
parseHTML() {
|
|
38
|
+
return [{ tag: "div[data-block-atom]" }];
|
|
39
|
+
},
|
|
40
|
+
renderHTML({ HTMLAttributes }) {
|
|
41
|
+
return ["div", { ...HTMLAttributes, "data-block-atom": "" }];
|
|
42
|
+
},
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
function docOf(ids: string[]) {
|
|
46
|
+
return {
|
|
47
|
+
type: "doc",
|
|
48
|
+
content: ids.map((id) => ({ type: "blockAtom", attrs: { blockId: id } })),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Initial content goes through the constructor, NOT setContent, so the seed is
|
|
53
|
+
// the baseline doc and is NOT itself an undoable history entry — the first
|
|
54
|
+
// undoable step is whatever the test dispatches next.
|
|
55
|
+
function makeEditor(ids: string[]) {
|
|
56
|
+
const element = document.createElement("div");
|
|
57
|
+
document.body.appendChild(element);
|
|
58
|
+
return new Editor({
|
|
59
|
+
element,
|
|
60
|
+
extensions: [...createRichMarkdownExtensions(), BlockAtom],
|
|
61
|
+
content: docOf(ids),
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function atomOrder(editor: Editor): string[] {
|
|
66
|
+
const ids: string[] = [];
|
|
67
|
+
editor.state.doc.forEach((node) => {
|
|
68
|
+
if (node.type.name === "blockAtom") ids.push(node.attrs.blockId as string);
|
|
69
|
+
});
|
|
70
|
+
return ids;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Reorder by replacing the whole doc — the shape of `replaceEditorViewBlocks`. */
|
|
74
|
+
function reorderViaReplace(
|
|
75
|
+
editor: Editor,
|
|
76
|
+
ids: string[],
|
|
77
|
+
addToHistory: boolean,
|
|
78
|
+
) {
|
|
79
|
+
const view = editor.view;
|
|
80
|
+
const next = view.state.schema.nodeFromJSON(docOf(ids));
|
|
81
|
+
const tr = view.state.tr.replaceWith(
|
|
82
|
+
0,
|
|
83
|
+
view.state.doc.content.size,
|
|
84
|
+
next.content,
|
|
85
|
+
);
|
|
86
|
+
if (!addToHistory) tr.setMeta("addToHistory", false);
|
|
87
|
+
view.dispatch(tr);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Fire a real Mod-z (or Shift-Mod-z) through the ProseMirror undo keymap. */
|
|
91
|
+
function pressModZ(editor: Editor, opts: { shift?: boolean } = {}): boolean {
|
|
92
|
+
const isMac =
|
|
93
|
+
typeof navigator !== "undefined" &&
|
|
94
|
+
/Mac|iP(hone|ad|od)/.test(navigator.platform || "");
|
|
95
|
+
const event = new KeyboardEvent("keydown", {
|
|
96
|
+
key: opts.shift ? "Z" : "z",
|
|
97
|
+
code: "KeyZ",
|
|
98
|
+
// prosemirror-keymap resolves a shifted letter (Shift-Mod-Z → redo) through
|
|
99
|
+
// the physical key via `event.keyCode`; a real browser sends it, so the
|
|
100
|
+
// synthetic event must too or the redo binding never matches.
|
|
101
|
+
keyCode: 90,
|
|
102
|
+
which: 90,
|
|
103
|
+
metaKey: isMac,
|
|
104
|
+
ctrlKey: !isMac,
|
|
105
|
+
shiftKey: !!opts.shift,
|
|
106
|
+
bubbles: true,
|
|
107
|
+
cancelable: true,
|
|
108
|
+
} as KeyboardEventInit);
|
|
109
|
+
return (
|
|
110
|
+
editor.view.someProp("handleKeyDown", (f) => f(editor.view, event)) ?? false
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
describe("structural block reorder undo", () => {
|
|
115
|
+
it("OLD behavior: a non-historical reorder repaint is NOT undoable", () => {
|
|
116
|
+
const editor = makeEditor(["a", "b", "c"]);
|
|
117
|
+
expect(atomOrder(editor)).toEqual(["a", "b", "c"]);
|
|
118
|
+
|
|
119
|
+
reorderViaReplace(editor, ["c", "a", "b"], /* addToHistory */ false);
|
|
120
|
+
expect(atomOrder(editor)).toEqual(["c", "a", "b"]);
|
|
121
|
+
|
|
122
|
+
pressModZ(editor);
|
|
123
|
+
// Nothing to undo — the reorder was deliberately excluded from history.
|
|
124
|
+
expect(atomOrder(editor)).toEqual(["c", "a", "b"]);
|
|
125
|
+
editor.destroy();
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("FIX: a historical reorder repaint is reverted by cmd+z and redone by cmd+shift+z", () => {
|
|
129
|
+
const editor = makeEditor(["a", "b", "c"]);
|
|
130
|
+
expect(atomOrder(editor)).toEqual(["a", "b", "c"]);
|
|
131
|
+
|
|
132
|
+
reorderViaReplace(editor, ["c", "a", "b"], /* addToHistory */ true);
|
|
133
|
+
expect(atomOrder(editor)).toEqual(["c", "a", "b"]);
|
|
134
|
+
|
|
135
|
+
const undid = pressModZ(editor);
|
|
136
|
+
expect(undid).toBe(true);
|
|
137
|
+
expect(atomOrder(editor)).toEqual(["a", "b", "c"]);
|
|
138
|
+
|
|
139
|
+
const redid = pressModZ(editor, { shift: true });
|
|
140
|
+
expect(redid).toBe(true);
|
|
141
|
+
expect(atomOrder(editor)).toEqual(["c", "a", "b"]);
|
|
142
|
+
editor.destroy();
|
|
143
|
+
});
|
|
144
|
+
});
|