@tangle-network/ui 1.0.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.
Files changed (220) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/LICENSE +21 -0
  3. package/README.md +33 -0
  4. package/dist/active-sessions-store-CeOmXgv5.d.ts +85 -0
  5. package/dist/artifact-pane-DvJyPWV4.d.ts +24 -0
  6. package/dist/auth.d.ts +74 -0
  7. package/dist/auth.js +15 -0
  8. package/dist/button-CMQuQEW_.d.ts +17 -0
  9. package/dist/chat.d.ts +232 -0
  10. package/dist/chat.js +30 -0
  11. package/dist/chunk-2NFQRQOD.js +1009 -0
  12. package/dist/chunk-2VH6PUXD.js +186 -0
  13. package/dist/chunk-34A66VBG.js +214 -0
  14. package/dist/chunk-3OI2QKFD.js +0 -0
  15. package/dist/chunk-4CLN43XT.js +45 -0
  16. package/dist/chunk-54SQQMMM.js +156 -0
  17. package/dist/chunk-5Z5ZYMOJ.js +0 -0
  18. package/dist/chunk-66BNMOVT.js +167 -0
  19. package/dist/chunk-6BGQA4BQ.js +0 -0
  20. package/dist/chunk-7UO2ZMRQ.js +133 -0
  21. package/dist/chunk-BX6AQMUS.js +183 -0
  22. package/dist/chunk-CD53GZOM.js +59 -0
  23. package/dist/chunk-CSAIKY36.js +54 -0
  24. package/dist/chunk-EEE55AVS.js +1201 -0
  25. package/dist/chunk-GYPQXTJU.js +230 -0
  26. package/dist/chunk-HFL6R6IF.js +37 -0
  27. package/dist/chunk-HJKCSXCH.js +737 -0
  28. package/dist/chunk-LISXUB4D.js +1222 -0
  29. package/dist/chunk-LQS34IGP.js +0 -0
  30. package/dist/chunk-MKTSMWVD.js +109 -0
  31. package/dist/chunk-NKDZ7GZE.js +192 -0
  32. package/dist/chunk-OEX7NZE3.js +321 -0
  33. package/dist/chunk-Q56BYXQF.js +61 -0
  34. package/dist/chunk-Q7EIIWTC.js +0 -0
  35. package/dist/chunk-REJESC5U.js +117 -0
  36. package/dist/chunk-RQGKSCEZ.js +0 -0
  37. package/dist/chunk-RQHJBTEU.js +10 -0
  38. package/dist/chunk-TMFOPHHN.js +299 -0
  39. package/dist/chunk-XGKULLYE.js +40 -0
  40. package/dist/chunk-XIHMJ7ZQ.js +614 -0
  41. package/dist/chunk-YJ2G3XO5.js +1048 -0
  42. package/dist/chunk-YNN4O57I.js +754 -0
  43. package/dist/code-block-DjXf8eOG.d.ts +19 -0
  44. package/dist/document-editor-pane-A5LT5H4N.js +12 -0
  45. package/dist/document-editor-pane-DyDEX_Zm.d.ts +124 -0
  46. package/dist/editor.d.ts +120 -0
  47. package/dist/editor.js +34 -0
  48. package/dist/files.d.ts +175 -0
  49. package/dist/files.js +20 -0
  50. package/dist/hooks.d.ts +56 -0
  51. package/dist/hooks.js +41 -0
  52. package/dist/index.d.ts +43 -0
  53. package/dist/index.js +446 -0
  54. package/dist/markdown.d.ts +15 -0
  55. package/dist/markdown.js +14 -0
  56. package/dist/message-BHWbxBtT.d.ts +15 -0
  57. package/dist/openui.d.ts +115 -0
  58. package/dist/openui.js +12 -0
  59. package/dist/parts-dj7AcUg0.d.ts +36 -0
  60. package/dist/primitives.d.ts +332 -0
  61. package/dist/primitives.js +191 -0
  62. package/dist/run-PfLmDAox.d.ts +41 -0
  63. package/dist/run.d.ts +69 -0
  64. package/dist/run.js +36 -0
  65. package/dist/sdk-hooks.d.ts +285 -0
  66. package/dist/sdk-hooks.js +31 -0
  67. package/dist/stores.d.ts +17 -0
  68. package/dist/stores.js +76 -0
  69. package/dist/tool-call-feed-Bs3MyQMT.d.ts +68 -0
  70. package/dist/tool-display-z4JcDmMQ.d.ts +32 -0
  71. package/dist/tool-previews.d.ts +48 -0
  72. package/dist/tool-previews.js +21 -0
  73. package/dist/types.d.ts +19 -0
  74. package/dist/types.js +1 -0
  75. package/dist/utils.d.ts +45 -0
  76. package/dist/utils.js +32 -0
  77. package/package.json +193 -0
  78. package/src/auth/auth.tsx +228 -0
  79. package/src/auth/index.ts +13 -0
  80. package/src/auth/login-layout.tsx +46 -0
  81. package/src/chat/agent-timeline.stories.tsx +429 -0
  82. package/src/chat/agent-timeline.tsx +360 -0
  83. package/src/chat/chat-container.tsx +486 -0
  84. package/src/chat/chat-input.stories.tsx +142 -0
  85. package/src/chat/chat-input.tsx +389 -0
  86. package/src/chat/chat-message.stories.tsx +237 -0
  87. package/src/chat/chat-message.tsx +129 -0
  88. package/src/chat/index.ts +18 -0
  89. package/src/chat/message-list.stories.tsx +336 -0
  90. package/src/chat/message-list.tsx +79 -0
  91. package/src/chat/thinking-indicator.stories.tsx +56 -0
  92. package/src/chat/thinking-indicator.tsx +30 -0
  93. package/src/chat/user-message.stories.tsx +92 -0
  94. package/src/chat/user-message.tsx +43 -0
  95. package/src/editor/document-editor-pane.tsx +351 -0
  96. package/src/editor/editor-provider.tsx +428 -0
  97. package/src/editor/editor-toolbar.tsx +130 -0
  98. package/src/editor/index.ts +31 -0
  99. package/src/editor/markdown-conversion.ts +21 -0
  100. package/src/editor/markdown-document-editor.tsx +137 -0
  101. package/src/editor/tiptap-editor.tsx +331 -0
  102. package/src/editor/use-editor.ts +221 -0
  103. package/src/files/file-artifact-pane.tsx +183 -0
  104. package/src/files/file-preview.tsx +342 -0
  105. package/src/files/file-tabs.tsx +71 -0
  106. package/src/files/file-tree.tsx +258 -0
  107. package/src/files/index.ts +17 -0
  108. package/src/files/rich-file-tree.stories.tsx +104 -0
  109. package/src/files/rich-file-tree.test.tsx +42 -0
  110. package/src/files/rich-file-tree.tsx +232 -0
  111. package/src/hooks/index.ts +10 -0
  112. package/src/hooks/use-auth.ts +153 -0
  113. package/src/hooks/use-auto-scroll.ts +59 -0
  114. package/src/hooks/use-dropdown-menu.ts +40 -0
  115. package/src/hooks/use-live-time.test.tsx +40 -0
  116. package/src/hooks/use-live-time.ts +27 -0
  117. package/src/hooks/use-realtime-session.ts +319 -0
  118. package/src/hooks/use-run-collapse-state.ts +25 -0
  119. package/src/hooks/use-run-groups.ts +111 -0
  120. package/src/hooks/use-sdk-session.ts +575 -0
  121. package/src/hooks/use-sse-stream.ts +475 -0
  122. package/src/hooks/use-tool-call-stream.ts +96 -0
  123. package/src/index.ts +14 -0
  124. package/src/lib/utils.ts +6 -0
  125. package/src/markdown/code-block.tsx +198 -0
  126. package/src/markdown/index.ts +2 -0
  127. package/src/markdown/markdown.stories.tsx +190 -0
  128. package/src/markdown/markdown.tsx +62 -0
  129. package/src/openui/index.ts +20 -0
  130. package/src/openui/openui-artifact-renderer.tsx +542 -0
  131. package/src/primitives/artifact-pane.tsx +91 -0
  132. package/src/primitives/avatar.stories.tsx +95 -0
  133. package/src/primitives/avatar.tsx +47 -0
  134. package/src/primitives/badge.stories.tsx +57 -0
  135. package/src/primitives/badge.tsx +97 -0
  136. package/src/primitives/button.stories.tsx +48 -0
  137. package/src/primitives/button.tsx +115 -0
  138. package/src/primitives/card.stories.tsx +53 -0
  139. package/src/primitives/card.tsx +98 -0
  140. package/src/primitives/code-block.stories.tsx +115 -0
  141. package/src/primitives/code-block.tsx +22 -0
  142. package/src/primitives/design-tokens.stories.tsx +162 -0
  143. package/src/primitives/dialog.stories.tsx +176 -0
  144. package/src/primitives/dialog.tsx +137 -0
  145. package/src/primitives/drop-zone.stories.tsx +123 -0
  146. package/src/primitives/drop-zone.tsx +131 -0
  147. package/src/primitives/dropdown-menu.stories.tsx +122 -0
  148. package/src/primitives/dropdown-menu.tsx +214 -0
  149. package/src/primitives/empty-state.stories.tsx +81 -0
  150. package/src/primitives/empty-state.tsx +40 -0
  151. package/src/primitives/index.ts +118 -0
  152. package/src/primitives/input.stories.tsx +113 -0
  153. package/src/primitives/input.tsx +136 -0
  154. package/src/primitives/label.stories.tsx +84 -0
  155. package/src/primitives/label.tsx +24 -0
  156. package/src/primitives/progress.stories.tsx +93 -0
  157. package/src/primitives/progress.tsx +50 -0
  158. package/src/primitives/segmented-control.test.tsx +328 -0
  159. package/src/primitives/segmented-control.tsx +154 -0
  160. package/src/primitives/select.stories.tsx +164 -0
  161. package/src/primitives/select.tsx +158 -0
  162. package/src/primitives/sidebar-drop-zone.stories.tsx +100 -0
  163. package/src/primitives/sidebar-drop-zone.tsx +149 -0
  164. package/src/primitives/skeleton.stories.tsx +79 -0
  165. package/src/primitives/skeleton.tsx +55 -0
  166. package/src/primitives/stat-card.stories.tsx +137 -0
  167. package/src/primitives/stat-card.tsx +97 -0
  168. package/src/primitives/switch.stories.tsx +85 -0
  169. package/src/primitives/switch.tsx +28 -0
  170. package/src/primitives/table.stories.tsx +170 -0
  171. package/src/primitives/table.tsx +116 -0
  172. package/src/primitives/tabs.stories.tsx +180 -0
  173. package/src/primitives/tabs.tsx +71 -0
  174. package/src/primitives/terminal-display.stories.tsx +191 -0
  175. package/src/primitives/terminal-display.tsx +189 -0
  176. package/src/primitives/theme-toggle.stories.tsx +32 -0
  177. package/src/primitives/theme-toggle.tsx +96 -0
  178. package/src/primitives/toast.stories.tsx +155 -0
  179. package/src/primitives/toast.tsx +190 -0
  180. package/src/primitives/upload-progress.stories.tsx +120 -0
  181. package/src/primitives/upload-progress.tsx +110 -0
  182. package/src/run/expanded-tool-detail.stories.tsx +182 -0
  183. package/src/run/expanded-tool-detail.tsx +186 -0
  184. package/src/run/index.ts +13 -0
  185. package/src/run/inline-thinking-item.stories.tsx +136 -0
  186. package/src/run/inline-thinking-item.tsx +120 -0
  187. package/src/run/inline-tool-item.stories.tsx +222 -0
  188. package/src/run/inline-tool-item.tsx +190 -0
  189. package/src/run/run-group.stories.tsx +322 -0
  190. package/src/run/run-group.tsx +569 -0
  191. package/src/run/run-item-primitives.tsx +17 -0
  192. package/src/run/tool-call-feed.stories.tsx +294 -0
  193. package/src/run/tool-call-feed.tsx +192 -0
  194. package/src/run/tool-call-step.stories.tsx +198 -0
  195. package/src/run/tool-call-step.tsx +240 -0
  196. package/src/sdk-hooks.ts +38 -0
  197. package/src/stores/active-sessions-store.ts +455 -0
  198. package/src/stores/chat-store.ts +43 -0
  199. package/src/stores/index.ts +2 -0
  200. package/src/tool-previews/command-preview.tsx +116 -0
  201. package/src/tool-previews/diff-preview.tsx +85 -0
  202. package/src/tool-previews/glob-results-preview.tsx +98 -0
  203. package/src/tool-previews/grep-results-preview.tsx +157 -0
  204. package/src/tool-previews/index.ts +22 -0
  205. package/src/tool-previews/preview-primitives.tsx +84 -0
  206. package/src/tool-previews/question-preview.tsx +101 -0
  207. package/src/tool-previews/web-search-preview.tsx +117 -0
  208. package/src/tool-previews/write-file-preview.tsx +80 -0
  209. package/src/types/branding.ts +11 -0
  210. package/src/types/index.ts +5 -0
  211. package/src/types/message.ts +13 -0
  212. package/src/types/parts.ts +51 -0
  213. package/src/types/run.ts +56 -0
  214. package/src/types/tool-display.ts +41 -0
  215. package/src/utils/copy-text.ts +30 -0
  216. package/src/utils/format.test.ts +43 -0
  217. package/src/utils/format.ts +56 -0
  218. package/src/utils/index.ts +10 -0
  219. package/src/utils/time-ago.ts +9 -0
  220. package/src/utils/tool-display.ts +238 -0
@@ -0,0 +1,137 @@
1
+ "use client";
2
+
3
+ import { type Editor, EditorContent, useEditor } from "@tiptap/react";
4
+ import StarterKit from "@tiptap/starter-kit";
5
+ import { useEffect, useMemo, useRef } from "react";
6
+ import { cn } from "../lib/utils";
7
+ import { EditorToolbar } from "./editor-toolbar";
8
+ import {
9
+ htmlToMarkdown,
10
+ markdownToHtml,
11
+ normalizeMarkdown,
12
+ } from "./markdown-conversion";
13
+
14
+ export interface MarkdownDocumentEditorProps {
15
+ value?: string;
16
+ placeholder?: string;
17
+ readOnly?: boolean;
18
+ autoFocus?: boolean;
19
+ className?: string;
20
+ contentClassName?: string;
21
+ onChange?: (markdown: string, editor: Editor) => void;
22
+ onReady?: (editor: Editor) => void;
23
+ }
24
+
25
+ export function MarkdownDocumentEditor({
26
+ value = "",
27
+ placeholder = "Start writing...",
28
+ readOnly = false,
29
+ autoFocus = false,
30
+ className,
31
+ contentClassName,
32
+ onChange,
33
+ onReady,
34
+ }: MarkdownDocumentEditorProps) {
35
+ const initialHtml = useMemo(() => markdownToHtml(value), []);
36
+ const lastAppliedMarkdownRef = useRef(normalizeMarkdown(value));
37
+
38
+ const editor = useEditor({
39
+ extensions: [
40
+ StarterKit.configure({
41
+ codeBlock: {
42
+ HTMLAttributes: {
43
+ class: "hljs",
44
+ },
45
+ },
46
+ }),
47
+ ],
48
+ content: initialHtml,
49
+ editable: !readOnly,
50
+ autofocus: autoFocus,
51
+ editorProps: {
52
+ attributes: {
53
+ class: cn(
54
+ "prose prose-sm sm:prose-base max-w-none focus:outline-none",
55
+ "prose-headings:text-foreground prose-p:text-foreground prose-li:text-foreground",
56
+ "prose-strong:text-foreground prose-code:text-foreground prose-pre:text-foreground",
57
+ contentClassName,
58
+ ),
59
+ "data-placeholder": placeholder,
60
+ },
61
+ },
62
+ onUpdate: ({ editor: currentEditor }) => {
63
+ const nextMarkdown = normalizeMarkdown(htmlToMarkdown(currentEditor.getHTML()));
64
+ lastAppliedMarkdownRef.current = nextMarkdown;
65
+ onChange?.(nextMarkdown, currentEditor);
66
+ },
67
+ onCreate: ({ editor: currentEditor }) => {
68
+ onReady?.(currentEditor);
69
+ },
70
+ });
71
+
72
+ useEffect(() => {
73
+ if (editor) {
74
+ editor.setEditable(!readOnly);
75
+ }
76
+ }, [editor, readOnly]);
77
+
78
+ useEffect(() => {
79
+ if (!editor) {
80
+ return;
81
+ }
82
+
83
+ const normalizedValue = normalizeMarkdown(value);
84
+ if (normalizedValue === lastAppliedMarkdownRef.current) {
85
+ return;
86
+ }
87
+
88
+ editor.commands.setContent(markdownToHtml(value), { emitUpdate: false });
89
+ lastAppliedMarkdownRef.current = normalizedValue;
90
+ }, [editor, value]);
91
+
92
+ return (
93
+ <div
94
+ className={cn(
95
+ "flex min-h-[14rem] w-full flex-col overflow-hidden rounded-lg border border-border bg-background",
96
+ className,
97
+ )}
98
+ >
99
+ <EditorToolbar
100
+ editor={editor}
101
+ className="border-border bg-card px-2 py-2"
102
+ />
103
+ <div className="min-h-0 flex-1 overflow-auto px-5 py-4">
104
+ <EditorContent editor={editor} />
105
+ </div>
106
+
107
+ <style>{`
108
+ .ProseMirror p.is-editor-empty:first-child::before {
109
+ content: attr(data-placeholder);
110
+ float: left;
111
+ color: hsl(var(--muted-foreground));
112
+ pointer-events: none;
113
+ height: 0;
114
+ }
115
+
116
+ .ProseMirror pre {
117
+ background: hsl(var(--muted));
118
+ border: 1px solid hsl(var(--border));
119
+ border-radius: 0.75rem;
120
+ padding: 0.875rem 1rem;
121
+ overflow-x: auto;
122
+ }
123
+
124
+ .ProseMirror code {
125
+ background: hsl(var(--muted));
126
+ border-radius: 0.35rem;
127
+ padding: 0.12rem 0.3rem;
128
+ }
129
+
130
+ .ProseMirror pre code {
131
+ background: transparent;
132
+ padding: 0;
133
+ }
134
+ `}</style>
135
+ </div>
136
+ );
137
+ }
@@ -0,0 +1,331 @@
1
+ "use client";
2
+
3
+ import type { AnyExtension } from "@tiptap/core";
4
+ import Collaboration from "@tiptap/extension-collaboration";
5
+ import CollaborationCaret from "@tiptap/extension-collaboration-caret";
6
+ import { type Editor, EditorContent, useEditor } from "@tiptap/react";
7
+ import StarterKit from "@tiptap/starter-kit";
8
+ import { useEffect, useMemo } from "react";
9
+ import { cn } from "../lib/utils";
10
+ import { type Collaborator, useEditorContext } from "./editor-provider";
11
+
12
+ /**
13
+ * Props for TiptapEditor component.
14
+ */
15
+ export interface TiptapEditorProps {
16
+ /** Initial content for new documents (markdown string) */
17
+ initialContent?: string;
18
+ /** Placeholder text when editor is empty */
19
+ placeholder?: string;
20
+ /** Whether the editor is read-only */
21
+ readOnly?: boolean;
22
+ /** Whether to auto-focus on mount */
23
+ autoFocus?: boolean;
24
+ /** Custom className for the editor wrapper */
25
+ className?: string;
26
+ /** Custom className for the editor content area */
27
+ contentClassName?: string;
28
+ /** Callback when content changes */
29
+ onUpdate?: (editor: Editor) => void;
30
+ /** Callback when selection changes */
31
+ onSelectionUpdate?: (editor: Editor) => void;
32
+ /** Callback when editor is ready */
33
+ onReady?: (editor: Editor) => void;
34
+ }
35
+
36
+ /**
37
+ * Cursor colors with contrasting text.
38
+ */
39
+ const cursorColors: Record<string, { background: string; text: string }> = {
40
+ "#FF6B6B": { background: "#FF6B6B", text: "#FFFFFF" },
41
+ "#4ECDC4": { background: "#4ECDC4", text: "#000000" },
42
+ "#45B7D1": { background: "#45B7D1", text: "#000000" },
43
+ "#96CEB4": { background: "#96CEB4", text: "#000000" },
44
+ "#FFEAA7": { background: "#FFEAA7", text: "#000000" },
45
+ "#DDA0DD": { background: "#DDA0DD", text: "#000000" },
46
+ "#98D8C8": { background: "#98D8C8", text: "#000000" },
47
+ "#F7DC6F": { background: "#F7DC6F", text: "#000000" },
48
+ "#BB8FCE": { background: "#BB8FCE", text: "#000000" },
49
+ "#85C1E9": { background: "#85C1E9", text: "#000000" },
50
+ };
51
+
52
+ /**
53
+ * Get cursor label colors based on user color.
54
+ */
55
+ function getCursorColors(color: string) {
56
+ return cursorColors[color] ?? { background: color, text: "#FFFFFF" };
57
+ }
58
+
59
+ /**
60
+ * TiptapEditor - Collaborative markdown editor with Y.js sync.
61
+ * Must be used within an EditorProvider.
62
+ */
63
+ export function TiptapEditor({
64
+ initialContent,
65
+ placeholder = "Start writing...",
66
+ readOnly = false,
67
+ autoFocus = false,
68
+ className,
69
+ contentClassName,
70
+ onUpdate,
71
+ onSelectionUpdate,
72
+ onReady,
73
+ }: TiptapEditorProps) {
74
+ const { doc, provider, connectionState } = useEditorContext();
75
+
76
+ // Y.js fragment for the editor content
77
+ const fragment = useMemo(() => doc.getXmlFragment("prosemirror"), [doc]);
78
+
79
+ // Configure Tiptap extensions
80
+ const extensions = useMemo(() => {
81
+ const baseExtensions: AnyExtension[] = [
82
+ StarterKit.configure({
83
+ // Disable history - Y.js handles undo/redo
84
+ ...({ history: false } as any),
85
+ // Configure code block for syntax highlighting placeholder
86
+ codeBlock: {
87
+ HTMLAttributes: {
88
+ class: "hljs",
89
+ },
90
+ },
91
+ }),
92
+ Collaboration.configure({
93
+ fragment,
94
+ }),
95
+ ];
96
+
97
+ // Add collaboration cursor if provider is available
98
+ if (provider?.awareness) {
99
+ baseExtensions.push(
100
+ CollaborationCaret.configure({
101
+ provider,
102
+ user: provider.awareness.getLocalState()?.user ?? {
103
+ name: "Anonymous",
104
+ color: "#808080",
105
+ },
106
+ render: (user: { name: string; color: string }) => {
107
+ const { background, text } = getCursorColors(user.color);
108
+
109
+ const cursor = document.createElement("span");
110
+ cursor.className = "collaboration-cursor";
111
+ cursor.style.borderColor = background;
112
+
113
+ const label = document.createElement("span");
114
+ label.className = "collaboration-cursor-label";
115
+ label.style.backgroundColor = background;
116
+ label.style.color = text;
117
+ label.textContent = user.name;
118
+
119
+ cursor.appendChild(label);
120
+ return cursor;
121
+ },
122
+ }),
123
+ );
124
+ }
125
+
126
+ return baseExtensions;
127
+ }, [fragment, provider]);
128
+
129
+ // Initialize Tiptap editor
130
+ const editor = useEditor({
131
+ extensions,
132
+ editable: !readOnly,
133
+ autofocus: autoFocus,
134
+ editorProps: {
135
+ attributes: {
136
+ class: cn(
137
+ "prose prose-sm sm:prose-base dark:prose-invert max-w-none",
138
+ "focus:outline-none",
139
+ contentClassName,
140
+ ),
141
+ "data-placeholder": placeholder,
142
+ },
143
+ },
144
+ onUpdate: ({ editor: ed }) => {
145
+ onUpdate?.(ed);
146
+ },
147
+ onSelectionUpdate: ({ editor: ed }) => {
148
+ onSelectionUpdate?.(ed);
149
+ },
150
+ onCreate: ({ editor: ed }) => {
151
+ onReady?.(ed);
152
+ },
153
+ });
154
+
155
+ // Update editable state when readOnly changes
156
+ useEffect(() => {
157
+ if (editor) {
158
+ editor.setEditable(!readOnly);
159
+ }
160
+ }, [editor, readOnly]);
161
+
162
+ // Handle initial content (only for new documents)
163
+ useEffect(() => {
164
+ if (
165
+ editor &&
166
+ initialContent &&
167
+ connectionState === "synced" &&
168
+ editor.isEmpty
169
+ ) {
170
+ editor.commands.setContent(initialContent);
171
+ }
172
+ }, [editor, initialContent, connectionState]);
173
+
174
+ return (
175
+ <div
176
+ className={cn(
177
+ "relative min-h-[200px] w-full rounded-lg border border-border",
178
+ "bg-background",
179
+ className,
180
+ )}
181
+ >
182
+ {/* Connection status indicator */}
183
+ <div className="absolute top-2 right-2 z-10">
184
+ <ConnectionIndicator state={connectionState} />
185
+ </div>
186
+
187
+ {/* Editor content */}
188
+ <div className="p-4 pt-10">
189
+ <EditorContent editor={editor} />
190
+ </div>
191
+
192
+ {/* Placeholder when empty */}
193
+ <style>{`
194
+ .ProseMirror p.is-editor-empty:first-child::before {
195
+ content: attr(data-placeholder);
196
+ float: left;
197
+ color: var(--muted-foreground, #999);
198
+ pointer-events: none;
199
+ height: 0;
200
+ }
201
+
202
+ .collaboration-cursor {
203
+ position: relative;
204
+ border-left: 2px solid;
205
+ margin-left: -1px;
206
+ margin-right: -1px;
207
+ pointer-events: none;
208
+ word-break: normal;
209
+ }
210
+
211
+ .collaboration-cursor-label {
212
+ position: absolute;
213
+ top: -1.4em;
214
+ left: -1px;
215
+ font-size: 12px;
216
+ font-style: normal;
217
+ font-weight: 600;
218
+ line-height: normal;
219
+ padding: 2px 6px;
220
+ border-radius: 4px 4px 4px 0;
221
+ white-space: nowrap;
222
+ user-select: none;
223
+ }
224
+
225
+ .ProseMirror pre {
226
+ background: var(--muted, #f4f4f5);
227
+ border-radius: 0.375rem;
228
+ padding: 0.75rem 1rem;
229
+ font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
230
+ font-size: 0.875rem;
231
+ overflow-x: auto;
232
+ }
233
+
234
+ .dark .ProseMirror pre {
235
+ background: var(--muted, #27272a);
236
+ }
237
+
238
+ .ProseMirror code {
239
+ background: var(--muted, #f4f4f5);
240
+ border-radius: 0.25rem;
241
+ padding: 0.125rem 0.25rem;
242
+ font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
243
+ font-size: 0.875em;
244
+ }
245
+
246
+ .dark .ProseMirror code {
247
+ background: var(--muted, #27272a);
248
+ }
249
+
250
+ .ProseMirror pre code {
251
+ background: transparent;
252
+ padding: 0;
253
+ font-size: inherit;
254
+ }
255
+ `}</style>
256
+ </div>
257
+ );
258
+ }
259
+
260
+ /**
261
+ * Connection status indicator component.
262
+ */
263
+ function ConnectionIndicator({
264
+ state,
265
+ }: {
266
+ state: "disconnected" | "connecting" | "connected" | "synced";
267
+ }) {
268
+ const config = {
269
+ disconnected: {
270
+ color: "bg-red-500",
271
+ label: "Disconnected",
272
+ },
273
+ connecting: {
274
+ color: "bg-yellow-500 animate-pulse",
275
+ label: "Connecting...",
276
+ },
277
+ connected: {
278
+ color: "bg-blue-500",
279
+ label: "Connected",
280
+ },
281
+ synced: {
282
+ color: "bg-green-500",
283
+ label: "Synced",
284
+ },
285
+ }[state];
286
+
287
+ return (
288
+ <div className="flex items-center gap-1.5 text-muted-foreground text-xs">
289
+ <span className={cn("h-2 w-2 rounded-full", config.color)} />
290
+ <span>{config.label}</span>
291
+ </div>
292
+ );
293
+ }
294
+
295
+ /**
296
+ * Collaborators list component.
297
+ * Shows active users in the document.
298
+ */
299
+ export function CollaboratorsList({
300
+ collaborators,
301
+ className,
302
+ }: {
303
+ collaborators: Collaborator[];
304
+ className?: string;
305
+ }) {
306
+ if (collaborators.length === 0) {
307
+ return null;
308
+ }
309
+
310
+ return (
311
+ <div className={cn("flex items-center gap-1", className)}>
312
+ {collaborators.slice(0, 5).map((collab) => (
313
+ <div
314
+ key={collab.clientId}
315
+ className="flex h-6 w-6 items-center justify-center rounded-full font-medium text-xs"
316
+ style={{ backgroundColor: collab.user.color }}
317
+ title={collab.user.name}
318
+ >
319
+ {collab.user.name.charAt(0).toUpperCase()}
320
+ </div>
321
+ ))}
322
+ {collaborators.length > 5 && (
323
+ <div className="flex h-6 w-6 items-center justify-center rounded-full bg-muted font-medium text-xs">
324
+ +{collaborators.length - 5}
325
+ </div>
326
+ )}
327
+ </div>
328
+ );
329
+ }
330
+
331
+ export { EditorToolbar } from "./editor-toolbar";
@@ -0,0 +1,221 @@
1
+ "use client";
2
+
3
+ import type { HocuspocusProvider } from "@hocuspocus/provider";
4
+ import { useCallback, useEffect, useState } from "react";
5
+ import { type Collaborator, useEditorContext } from "./editor-provider";
6
+
7
+ /**
8
+ * Hook to get the current connection state and helpers.
9
+ */
10
+ export function useEditorConnection() {
11
+ const { connectionState, isSynced, connect, disconnect } = useEditorContext();
12
+
13
+ const isConnected =
14
+ connectionState === "connected" || connectionState === "synced";
15
+ const isConnecting = connectionState === "connecting";
16
+ const isDisconnected = connectionState === "disconnected";
17
+
18
+ return {
19
+ /** Current connection state string */
20
+ state: connectionState,
21
+ /** Whether connected to the server (connected or synced) */
22
+ isConnected,
23
+ /** Whether currently attempting to connect */
24
+ isConnecting,
25
+ /** Whether disconnected from the server */
26
+ isDisconnected,
27
+ /** Whether the document is synced with the server */
28
+ isSynced,
29
+ /** Connect to the collaboration server */
30
+ connect,
31
+ /** Disconnect from the collaboration server */
32
+ disconnect,
33
+ };
34
+ }
35
+
36
+ /**
37
+ * Hook to get the list of active collaborators.
38
+ */
39
+ export function useCollaborators() {
40
+ const { collaborators } = useEditorContext();
41
+
42
+ const count = collaborators.length;
43
+ const hasOthers = count > 0;
44
+
45
+ return {
46
+ /** List of active collaborators (excluding self) */
47
+ collaborators,
48
+ /** Number of other collaborators */
49
+ count,
50
+ /** Whether there are other collaborators present */
51
+ hasOthers,
52
+ };
53
+ }
54
+
55
+ /**
56
+ * Hook to track a specific collaborator's presence.
57
+ */
58
+ export function useCollaboratorPresence(userId: string): Collaborator | null {
59
+ const { collaborators } = useEditorContext();
60
+
61
+ return collaborators.find((c) => c.user.userId === userId) ?? null;
62
+ }
63
+
64
+ /**
65
+ * Hook to sync local state with the Y.Doc.
66
+ * Useful for persisting editor metadata.
67
+ */
68
+ export function useYjsState<T>(
69
+ key: string,
70
+ initialValue: T,
71
+ ): [T, (value: T) => void] {
72
+ const { doc } = useEditorContext();
73
+ const [value, setLocalValue] = useState<T>(initialValue);
74
+
75
+ // Get or create the metadata map
76
+ const metaMap = doc.getMap<T>("metadata");
77
+
78
+ // Sync from Y.Map on mount and changes
79
+ useEffect(() => {
80
+ const updateValue = () => {
81
+ const stored = metaMap.get(key);
82
+ if (stored !== undefined) {
83
+ setLocalValue(stored);
84
+ }
85
+ };
86
+
87
+ updateValue();
88
+ metaMap.observe(updateValue);
89
+
90
+ return () => {
91
+ metaMap.unobserve(updateValue);
92
+ };
93
+ }, [metaMap, key]);
94
+
95
+ // Set value in Y.Map
96
+ const setValue = useCallback(
97
+ (newValue: T) => {
98
+ doc.transact(() => {
99
+ metaMap.set(key, newValue);
100
+ });
101
+ },
102
+ [doc, metaMap, key],
103
+ );
104
+
105
+ return [value, setValue];
106
+ }
107
+
108
+ /**
109
+ * Hook to track document changes.
110
+ * Returns true if there are unsaved changes since the last save.
111
+ */
112
+ export function useDocumentChanges(onSave?: () => Promise<void>) {
113
+ const { doc } = useEditorContext();
114
+ const [isDirty, setIsDirty] = useState(false);
115
+ const [isSaving, setIsSaving] = useState(false);
116
+ const [lastSaved, setLastSaved] = useState<Date | null>(null);
117
+
118
+ // Track changes
119
+ useEffect(() => {
120
+ const handleUpdate = () => {
121
+ setIsDirty(true);
122
+ };
123
+
124
+ doc.on("update", handleUpdate);
125
+
126
+ return () => {
127
+ doc.off("update", handleUpdate);
128
+ };
129
+ }, [doc]);
130
+
131
+ // Save function
132
+ const save = useCallback(async () => {
133
+ if (!onSave || isSaving) return;
134
+
135
+ setIsSaving(true);
136
+ try {
137
+ await onSave();
138
+ setIsDirty(false);
139
+ setLastSaved(new Date());
140
+ } finally {
141
+ setIsSaving(false);
142
+ }
143
+ }, [onSave, isSaving]);
144
+
145
+ return {
146
+ /** Whether there are unsaved changes */
147
+ isDirty,
148
+ /** Whether save is in progress */
149
+ isSaving,
150
+ /** When the document was last saved */
151
+ lastSaved,
152
+ /** Save the document */
153
+ save,
154
+ };
155
+ }
156
+
157
+ /**
158
+ * Hook to get awareness state for presence features.
159
+ * Provides lower-level access to the Hocuspocus awareness.
160
+ */
161
+ export function useAwareness(): {
162
+ localState: Record<string, unknown>;
163
+ setLocalState: (state: Record<string, unknown>) => void;
164
+ setLocalStateField: (field: string, value: unknown) => void;
165
+ awareness: HocuspocusProvider["awareness"] | undefined;
166
+ } {
167
+ const { provider } = useEditorContext();
168
+ const [localState, setLocalStateValue] = useState<Record<string, unknown>>(
169
+ {},
170
+ );
171
+
172
+ const awareness = provider?.awareness;
173
+
174
+ // Sync local state
175
+ useEffect(() => {
176
+ if (!awareness) return;
177
+
178
+ const updateState = () => {
179
+ setLocalStateValue(awareness.getLocalState() ?? {});
180
+ };
181
+
182
+ updateState();
183
+ awareness.on("change", updateState);
184
+
185
+ return () => {
186
+ awareness.off("change", updateState);
187
+ };
188
+ }, [awareness]);
189
+
190
+ // Set local state
191
+ const setLocalState = useCallback(
192
+ (state: Record<string, unknown>) => {
193
+ if (!awareness) return;
194
+
195
+ // Merge with existing state
196
+ const current = awareness.getLocalState() ?? {};
197
+ awareness.setLocalState({ ...current, ...state });
198
+ },
199
+ [awareness],
200
+ );
201
+
202
+ // Set a single field
203
+ const setLocalStateField = useCallback(
204
+ (field: string, value: unknown) => {
205
+ if (!awareness) return;
206
+ awareness.setLocalStateField(field, value);
207
+ },
208
+ [awareness],
209
+ );
210
+
211
+ return {
212
+ /** Current local awareness state */
213
+ localState,
214
+ /** Set the entire local state (merges with existing) */
215
+ setLocalState,
216
+ /** Set a single field in the local state */
217
+ setLocalStateField,
218
+ /** The raw awareness instance */
219
+ awareness,
220
+ };
221
+ }