@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.
- package/CHANGELOG.md +12 -0
- package/LICENSE +21 -0
- package/README.md +33 -0
- package/dist/active-sessions-store-CeOmXgv5.d.ts +85 -0
- package/dist/artifact-pane-DvJyPWV4.d.ts +24 -0
- package/dist/auth.d.ts +74 -0
- package/dist/auth.js +15 -0
- package/dist/button-CMQuQEW_.d.ts +17 -0
- package/dist/chat.d.ts +232 -0
- package/dist/chat.js +30 -0
- package/dist/chunk-2NFQRQOD.js +1009 -0
- package/dist/chunk-2VH6PUXD.js +186 -0
- package/dist/chunk-34A66VBG.js +214 -0
- package/dist/chunk-3OI2QKFD.js +0 -0
- package/dist/chunk-4CLN43XT.js +45 -0
- package/dist/chunk-54SQQMMM.js +156 -0
- package/dist/chunk-5Z5ZYMOJ.js +0 -0
- package/dist/chunk-66BNMOVT.js +167 -0
- package/dist/chunk-6BGQA4BQ.js +0 -0
- package/dist/chunk-7UO2ZMRQ.js +133 -0
- package/dist/chunk-BX6AQMUS.js +183 -0
- package/dist/chunk-CD53GZOM.js +59 -0
- package/dist/chunk-CSAIKY36.js +54 -0
- package/dist/chunk-EEE55AVS.js +1201 -0
- package/dist/chunk-GYPQXTJU.js +230 -0
- package/dist/chunk-HFL6R6IF.js +37 -0
- package/dist/chunk-HJKCSXCH.js +737 -0
- package/dist/chunk-LISXUB4D.js +1222 -0
- package/dist/chunk-LQS34IGP.js +0 -0
- package/dist/chunk-MKTSMWVD.js +109 -0
- package/dist/chunk-NKDZ7GZE.js +192 -0
- package/dist/chunk-OEX7NZE3.js +321 -0
- package/dist/chunk-Q56BYXQF.js +61 -0
- package/dist/chunk-Q7EIIWTC.js +0 -0
- package/dist/chunk-REJESC5U.js +117 -0
- package/dist/chunk-RQGKSCEZ.js +0 -0
- package/dist/chunk-RQHJBTEU.js +10 -0
- package/dist/chunk-TMFOPHHN.js +299 -0
- package/dist/chunk-XGKULLYE.js +40 -0
- package/dist/chunk-XIHMJ7ZQ.js +614 -0
- package/dist/chunk-YJ2G3XO5.js +1048 -0
- package/dist/chunk-YNN4O57I.js +754 -0
- package/dist/code-block-DjXf8eOG.d.ts +19 -0
- package/dist/document-editor-pane-A5LT5H4N.js +12 -0
- package/dist/document-editor-pane-DyDEX_Zm.d.ts +124 -0
- package/dist/editor.d.ts +120 -0
- package/dist/editor.js +34 -0
- package/dist/files.d.ts +175 -0
- package/dist/files.js +20 -0
- package/dist/hooks.d.ts +56 -0
- package/dist/hooks.js +41 -0
- package/dist/index.d.ts +43 -0
- package/dist/index.js +446 -0
- package/dist/markdown.d.ts +15 -0
- package/dist/markdown.js +14 -0
- package/dist/message-BHWbxBtT.d.ts +15 -0
- package/dist/openui.d.ts +115 -0
- package/dist/openui.js +12 -0
- package/dist/parts-dj7AcUg0.d.ts +36 -0
- package/dist/primitives.d.ts +332 -0
- package/dist/primitives.js +191 -0
- package/dist/run-PfLmDAox.d.ts +41 -0
- package/dist/run.d.ts +69 -0
- package/dist/run.js +36 -0
- package/dist/sdk-hooks.d.ts +285 -0
- package/dist/sdk-hooks.js +31 -0
- package/dist/stores.d.ts +17 -0
- package/dist/stores.js +76 -0
- package/dist/tool-call-feed-Bs3MyQMT.d.ts +68 -0
- package/dist/tool-display-z4JcDmMQ.d.ts +32 -0
- package/dist/tool-previews.d.ts +48 -0
- package/dist/tool-previews.js +21 -0
- package/dist/types.d.ts +19 -0
- package/dist/types.js +1 -0
- package/dist/utils.d.ts +45 -0
- package/dist/utils.js +32 -0
- package/package.json +193 -0
- package/src/auth/auth.tsx +228 -0
- package/src/auth/index.ts +13 -0
- package/src/auth/login-layout.tsx +46 -0
- package/src/chat/agent-timeline.stories.tsx +429 -0
- package/src/chat/agent-timeline.tsx +360 -0
- package/src/chat/chat-container.tsx +486 -0
- package/src/chat/chat-input.stories.tsx +142 -0
- package/src/chat/chat-input.tsx +389 -0
- package/src/chat/chat-message.stories.tsx +237 -0
- package/src/chat/chat-message.tsx +129 -0
- package/src/chat/index.ts +18 -0
- package/src/chat/message-list.stories.tsx +336 -0
- package/src/chat/message-list.tsx +79 -0
- package/src/chat/thinking-indicator.stories.tsx +56 -0
- package/src/chat/thinking-indicator.tsx +30 -0
- package/src/chat/user-message.stories.tsx +92 -0
- package/src/chat/user-message.tsx +43 -0
- package/src/editor/document-editor-pane.tsx +351 -0
- package/src/editor/editor-provider.tsx +428 -0
- package/src/editor/editor-toolbar.tsx +130 -0
- package/src/editor/index.ts +31 -0
- package/src/editor/markdown-conversion.ts +21 -0
- package/src/editor/markdown-document-editor.tsx +137 -0
- package/src/editor/tiptap-editor.tsx +331 -0
- package/src/editor/use-editor.ts +221 -0
- package/src/files/file-artifact-pane.tsx +183 -0
- package/src/files/file-preview.tsx +342 -0
- package/src/files/file-tabs.tsx +71 -0
- package/src/files/file-tree.tsx +258 -0
- package/src/files/index.ts +17 -0
- package/src/files/rich-file-tree.stories.tsx +104 -0
- package/src/files/rich-file-tree.test.tsx +42 -0
- package/src/files/rich-file-tree.tsx +232 -0
- package/src/hooks/index.ts +10 -0
- package/src/hooks/use-auth.ts +153 -0
- package/src/hooks/use-auto-scroll.ts +59 -0
- package/src/hooks/use-dropdown-menu.ts +40 -0
- package/src/hooks/use-live-time.test.tsx +40 -0
- package/src/hooks/use-live-time.ts +27 -0
- package/src/hooks/use-realtime-session.ts +319 -0
- package/src/hooks/use-run-collapse-state.ts +25 -0
- package/src/hooks/use-run-groups.ts +111 -0
- package/src/hooks/use-sdk-session.ts +575 -0
- package/src/hooks/use-sse-stream.ts +475 -0
- package/src/hooks/use-tool-call-stream.ts +96 -0
- package/src/index.ts +14 -0
- package/src/lib/utils.ts +6 -0
- package/src/markdown/code-block.tsx +198 -0
- package/src/markdown/index.ts +2 -0
- package/src/markdown/markdown.stories.tsx +190 -0
- package/src/markdown/markdown.tsx +62 -0
- package/src/openui/index.ts +20 -0
- package/src/openui/openui-artifact-renderer.tsx +542 -0
- package/src/primitives/artifact-pane.tsx +91 -0
- package/src/primitives/avatar.stories.tsx +95 -0
- package/src/primitives/avatar.tsx +47 -0
- package/src/primitives/badge.stories.tsx +57 -0
- package/src/primitives/badge.tsx +97 -0
- package/src/primitives/button.stories.tsx +48 -0
- package/src/primitives/button.tsx +115 -0
- package/src/primitives/card.stories.tsx +53 -0
- package/src/primitives/card.tsx +98 -0
- package/src/primitives/code-block.stories.tsx +115 -0
- package/src/primitives/code-block.tsx +22 -0
- package/src/primitives/design-tokens.stories.tsx +162 -0
- package/src/primitives/dialog.stories.tsx +176 -0
- package/src/primitives/dialog.tsx +137 -0
- package/src/primitives/drop-zone.stories.tsx +123 -0
- package/src/primitives/drop-zone.tsx +131 -0
- package/src/primitives/dropdown-menu.stories.tsx +122 -0
- package/src/primitives/dropdown-menu.tsx +214 -0
- package/src/primitives/empty-state.stories.tsx +81 -0
- package/src/primitives/empty-state.tsx +40 -0
- package/src/primitives/index.ts +118 -0
- package/src/primitives/input.stories.tsx +113 -0
- package/src/primitives/input.tsx +136 -0
- package/src/primitives/label.stories.tsx +84 -0
- package/src/primitives/label.tsx +24 -0
- package/src/primitives/progress.stories.tsx +93 -0
- package/src/primitives/progress.tsx +50 -0
- package/src/primitives/segmented-control.test.tsx +328 -0
- package/src/primitives/segmented-control.tsx +154 -0
- package/src/primitives/select.stories.tsx +164 -0
- package/src/primitives/select.tsx +158 -0
- package/src/primitives/sidebar-drop-zone.stories.tsx +100 -0
- package/src/primitives/sidebar-drop-zone.tsx +149 -0
- package/src/primitives/skeleton.stories.tsx +79 -0
- package/src/primitives/skeleton.tsx +55 -0
- package/src/primitives/stat-card.stories.tsx +137 -0
- package/src/primitives/stat-card.tsx +97 -0
- package/src/primitives/switch.stories.tsx +85 -0
- package/src/primitives/switch.tsx +28 -0
- package/src/primitives/table.stories.tsx +170 -0
- package/src/primitives/table.tsx +116 -0
- package/src/primitives/tabs.stories.tsx +180 -0
- package/src/primitives/tabs.tsx +71 -0
- package/src/primitives/terminal-display.stories.tsx +191 -0
- package/src/primitives/terminal-display.tsx +189 -0
- package/src/primitives/theme-toggle.stories.tsx +32 -0
- package/src/primitives/theme-toggle.tsx +96 -0
- package/src/primitives/toast.stories.tsx +155 -0
- package/src/primitives/toast.tsx +190 -0
- package/src/primitives/upload-progress.stories.tsx +120 -0
- package/src/primitives/upload-progress.tsx +110 -0
- package/src/run/expanded-tool-detail.stories.tsx +182 -0
- package/src/run/expanded-tool-detail.tsx +186 -0
- package/src/run/index.ts +13 -0
- package/src/run/inline-thinking-item.stories.tsx +136 -0
- package/src/run/inline-thinking-item.tsx +120 -0
- package/src/run/inline-tool-item.stories.tsx +222 -0
- package/src/run/inline-tool-item.tsx +190 -0
- package/src/run/run-group.stories.tsx +322 -0
- package/src/run/run-group.tsx +569 -0
- package/src/run/run-item-primitives.tsx +17 -0
- package/src/run/tool-call-feed.stories.tsx +294 -0
- package/src/run/tool-call-feed.tsx +192 -0
- package/src/run/tool-call-step.stories.tsx +198 -0
- package/src/run/tool-call-step.tsx +240 -0
- package/src/sdk-hooks.ts +38 -0
- package/src/stores/active-sessions-store.ts +455 -0
- package/src/stores/chat-store.ts +43 -0
- package/src/stores/index.ts +2 -0
- package/src/tool-previews/command-preview.tsx +116 -0
- package/src/tool-previews/diff-preview.tsx +85 -0
- package/src/tool-previews/glob-results-preview.tsx +98 -0
- package/src/tool-previews/grep-results-preview.tsx +157 -0
- package/src/tool-previews/index.ts +22 -0
- package/src/tool-previews/preview-primitives.tsx +84 -0
- package/src/tool-previews/question-preview.tsx +101 -0
- package/src/tool-previews/web-search-preview.tsx +117 -0
- package/src/tool-previews/write-file-preview.tsx +80 -0
- package/src/types/branding.ts +11 -0
- package/src/types/index.ts +5 -0
- package/src/types/message.ts +13 -0
- package/src/types/parts.ts +51 -0
- package/src/types/run.ts +56 -0
- package/src/types/tool-display.ts +41 -0
- package/src/utils/copy-text.ts +30 -0
- package/src/utils/format.test.ts +43 -0
- package/src/utils/format.ts +56 -0
- package/src/utils/index.ts +10 -0
- package/src/utils/time-ago.ts +9 -0
- 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
|
+
}
|