@collaflow/app 0.1.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 (102) hide show
  1. package/.next/BUILD_ID +1 -0
  2. package/.next/app-build-manifest.json +29 -0
  3. package/.next/app-path-routes-manifest.json +4 -0
  4. package/.next/build-manifest.json +33 -0
  5. package/.next/cache/.previewinfo +1 -0
  6. package/.next/cache/.rscinfo +1 -0
  7. package/.next/cache/.tsbuildinfo +1 -0
  8. package/.next/cache/config.json +7 -0
  9. package/.next/cache/webpack/client-production/0.pack +0 -0
  10. package/.next/cache/webpack/client-production/1.pack +0 -0
  11. package/.next/cache/webpack/client-production/2.pack +0 -0
  12. package/.next/cache/webpack/client-production/index.pack +0 -0
  13. package/.next/cache/webpack/client-production/index.pack.old +0 -0
  14. package/.next/cache/webpack/edge-server-production/0.pack +0 -0
  15. package/.next/cache/webpack/edge-server-production/index.pack +0 -0
  16. package/.next/cache/webpack/server-production/0.pack +0 -0
  17. package/.next/cache/webpack/server-production/index.pack +0 -0
  18. package/.next/diagnostics/build-diagnostics.json +6 -0
  19. package/.next/diagnostics/framework.json +1 -0
  20. package/.next/export-marker.json +6 -0
  21. package/.next/images-manifest.json +58 -0
  22. package/.next/next-minimal-server.js.nft.json +1 -0
  23. package/.next/next-server.js.nft.json +1 -0
  24. package/.next/package.json +1 -0
  25. package/.next/prerender-manifest.json +61 -0
  26. package/.next/react-loadable-manifest.json +6 -0
  27. package/.next/required-server-files.json +321 -0
  28. package/.next/routes-manifest.json +53 -0
  29. package/.next/server/app/_not-found/page.js +2 -0
  30. package/.next/server/app/_not-found/page.js.nft.json +1 -0
  31. package/.next/server/app/_not-found/page_client-reference-manifest.js +1 -0
  32. package/.next/server/app/_not-found.html +1 -0
  33. package/.next/server/app/_not-found.meta +8 -0
  34. package/.next/server/app/_not-found.rsc +15 -0
  35. package/.next/server/app/index.html +1 -0
  36. package/.next/server/app/index.meta +7 -0
  37. package/.next/server/app/index.rsc +19 -0
  38. package/.next/server/app/page.js +86 -0
  39. package/.next/server/app/page.js.nft.json +1 -0
  40. package/.next/server/app/page_client-reference-manifest.js +1 -0
  41. package/.next/server/app-paths-manifest.json +4 -0
  42. package/.next/server/chunks/15.js +6 -0
  43. package/.next/server/chunks/575.js +32 -0
  44. package/.next/server/functions-config-manifest.json +4 -0
  45. package/.next/server/interception-route-rewrite-manifest.js +1 -0
  46. package/.next/server/middleware-build-manifest.js +1 -0
  47. package/.next/server/middleware-manifest.json +6 -0
  48. package/.next/server/middleware-react-loadable-manifest.js +1 -0
  49. package/.next/server/next-font-manifest.js +1 -0
  50. package/.next/server/next-font-manifest.json +1 -0
  51. package/.next/server/pages/404.html +1 -0
  52. package/.next/server/pages/500.html +1 -0
  53. package/.next/server/pages/_app.js +1 -0
  54. package/.next/server/pages/_app.js.nft.json +1 -0
  55. package/.next/server/pages/_document.js +1 -0
  56. package/.next/server/pages/_document.js.nft.json +1 -0
  57. package/.next/server/pages/_error.js +19 -0
  58. package/.next/server/pages/_error.js.nft.json +1 -0
  59. package/.next/server/pages-manifest.json +6 -0
  60. package/.next/server/server-reference-manifest.js +1 -0
  61. package/.next/server/server-reference-manifest.json +1 -0
  62. package/.next/server/webpack-runtime.js +1 -0
  63. package/.next/static/cawI4PxFre1uXaV3w_OOh/_buildManifest.js +1 -0
  64. package/.next/static/cawI4PxFre1uXaV3w_OOh/_ssgManifest.js +1 -0
  65. package/.next/static/chunks/5f994265-851b4fe5bb9f6bdf.js +1 -0
  66. package/.next/static/chunks/730-47357526b52cb7a3.js +1 -0
  67. package/.next/static/chunks/761-d78a04fe4a7f0c61.js +2 -0
  68. package/.next/static/chunks/7fa71398-d732f2f130f8dbf8.js +1 -0
  69. package/.next/static/chunks/9f40746c-e311a6f65dce894f.js +1 -0
  70. package/.next/static/chunks/app/_not-found/page-d0d3baa38855a614.js +1 -0
  71. package/.next/static/chunks/app/layout-6114418103b4dbcc.js +1 -0
  72. package/.next/static/chunks/app/page-a5e0c15f5813dcaf.js +1 -0
  73. package/.next/static/chunks/framework-b71824acec8cd700.js +1 -0
  74. package/.next/static/chunks/main-5d112cac3f90f4f6.js +1 -0
  75. package/.next/static/chunks/main-app-3133c331999859a2.js +1 -0
  76. package/.next/static/chunks/pages/_app-c068c4a8491e6db8.js +1 -0
  77. package/.next/static/chunks/pages/_error-464ab45f79bb2315.js +1 -0
  78. package/.next/static/chunks/polyfills-42372ed130431b0a.js +1 -0
  79. package/.next/static/chunks/webpack-e72e354969197063.js +1 -0
  80. package/.next/static/css/043b8a737f2ce68d.css +5 -0
  81. package/.next/trace +2 -0
  82. package/.next/types/app/layout.ts +84 -0
  83. package/.next/types/app/page.ts +84 -0
  84. package/.next/types/cache-life.d.ts +141 -0
  85. package/.next/types/package.json +1 -0
  86. package/.next/types/routes.d.ts +57 -0
  87. package/.next/types/validator.ts +61 -0
  88. package/LICENSE +201 -0
  89. package/components.json +20 -0
  90. package/next-env.d.ts +6 -0
  91. package/next.config.mjs +19 -0
  92. package/package.json +46 -0
  93. package/postcss.config.mjs +9 -0
  94. package/src/app/globals.css +16 -0
  95. package/src/app/layout.tsx +19 -0
  96. package/src/app/page.test.tsx +11 -0
  97. package/src/app/page.tsx +86 -0
  98. package/src/components/MarkdownEditor.tsx +432 -0
  99. package/src/lib/utils.ts +6 -0
  100. package/tailwind.config.ts +59 -0
  101. package/tsconfig.json +27 -0
  102. package/vitest.config.ts +8 -0
@@ -0,0 +1,19 @@
1
+ import type { Metadata } from 'next';
2
+ import './globals.css';
3
+
4
+ export const metadata: Metadata = {
5
+ title: 'CollaFlow',
6
+ description: 'High-performance collaborative mind map & document workspace',
7
+ };
8
+
9
+ export default function RootLayout({
10
+ children,
11
+ }: Readonly<{
12
+ children: React.ReactNode;
13
+ }>) {
14
+ return (
15
+ <html lang="en">
16
+ <body className="antialiased">{children}</body>
17
+ </html>
18
+ );
19
+ }
@@ -0,0 +1,11 @@
1
+ import { describe, it, expect } from 'vitest';
2
+
3
+ import Home from './page';
4
+
5
+ describe('app page', () => {
6
+ it('renders without crashing', () => {
7
+ // Simple smoke test that the default export is a valid component.
8
+ expect(Home).toBeDefined();
9
+ expect(typeof Home).toBe('function');
10
+ });
11
+ });
@@ -0,0 +1,86 @@
1
+ 'use client';
2
+
3
+ import { useState } from 'react';
4
+ import { MarkdownEditor } from '@/components/MarkdownEditor';
5
+
6
+ const DEFAULT_SERVER = 'ws://localhost:1234';
7
+
8
+ export default function Home() {
9
+ const [serverUrl, setServerUrl] = useState(DEFAULT_SERVER);
10
+ const [roomInput, setRoomInput] = useState('');
11
+ const [joined, setJoined] = useState(false);
12
+ const [activeRoom, setActiveRoom] = useState('');
13
+
14
+ const handleEnter = () => {
15
+ setActiveRoom(roomInput.trim());
16
+ setJoined(true);
17
+ };
18
+
19
+ const handleLeave = () => {
20
+ setJoined(false);
21
+ };
22
+
23
+ return (
24
+ <main className="mx-auto flex min-h-screen max-w-5xl flex-col gap-6 p-8">
25
+ <header>
26
+ <h1 className="text-3xl font-bold tracking-tight">CollaFlow</h1>
27
+ <p className="mt-2 text-muted-foreground">
28
+ 高性能协同脑图 / 文档工作台 · 基于 CollaMarkdown 编辑器
29
+ </p>
30
+ </header>
31
+
32
+ <section className="flex flex-wrap items-end gap-4 rounded-lg border border-border bg-card p-4">
33
+ <label className="flex flex-col gap-1 text-sm">
34
+ <span className="text-muted-foreground">协同服务地址</span>
35
+ <input
36
+ className="rounded-md border border-border bg-background px-3 py-1.5 text-sm"
37
+ value={serverUrl}
38
+ onChange={(e) => setServerUrl(e.target.value)}
39
+ placeholder="ws://localhost:1234"
40
+ />
41
+ </label>
42
+ <label className="flex flex-col gap-1 text-sm">
43
+ <span className="text-muted-foreground">房间 / 文档 ID</span>
44
+ <input
45
+ className="rounded-md border border-border bg-background px-3 py-1.5 text-sm"
46
+ value={roomInput}
47
+ onChange={(e) => setRoomInput(e.target.value)}
48
+ onKeyDown={(e) => {
49
+ if (e.key === 'Enter') handleEnter();
50
+ }}
51
+ placeholder="留空则单机编辑"
52
+ />
53
+ </label>
54
+ <button
55
+ type="button"
56
+ onClick={handleEnter}
57
+ className="rounded-md bg-foreground px-4 py-1.5 text-sm font-medium text-background transition-opacity hover:opacity-90"
58
+ >
59
+ {joined ? '重新进入' : '进入房间'}
60
+ </button>
61
+ {joined && (
62
+ <button
63
+ type="button"
64
+ onClick={handleLeave}
65
+ className="rounded-md border border-border px-4 py-1.5 text-sm text-muted-foreground transition-colors hover:bg-muted"
66
+ >
67
+ 退出
68
+ </button>
69
+ )}
70
+ {joined && (
71
+ <p className="text-xs text-muted-foreground">
72
+ {activeRoom ? `已接入协同房间「${activeRoom}」` : '本地编辑模式(未启用协同)'}
73
+ </p>
74
+ )}
75
+ </section>
76
+
77
+ {joined ? (
78
+ <MarkdownEditor roomId={activeRoom || undefined} serverUrl={serverUrl} />
79
+ ) : (
80
+ <div className="rounded-lg border border-dashed border-border p-10 text-center text-muted-foreground">
81
+ 输入房间 ID(可选)后点击「进入房间」开始编辑
82
+ </div>
83
+ )}
84
+ </main>
85
+ );
86
+ }
@@ -0,0 +1,432 @@
1
+ 'use client';
2
+
3
+ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
4
+ import type { InputEvent, MouseEvent } from 'react';
5
+ import type { CollaMarkdownEditor, RemoteCursor } from '@collaflow/markdown';
6
+ import {
7
+ buildDomTextCounts,
8
+ diffApply,
9
+ markdownOffsetToDomTextOffset,
10
+ YJS_ORIGIN_TEXTAREA,
11
+ } from '@collaflow/markdown';
12
+
13
+ interface MarkdownEditorProps {
14
+ /** 协同房间 ID;为空时不启用协同(纯本地编辑) */
15
+ roomId?: string;
16
+ /** CollaFlow Core(Hocuspocus)WebSocket 地址 */
17
+ serverUrl?: string;
18
+ /** 初始 Markdown 内容 */
19
+ defaultValue?: string;
20
+ }
21
+
22
+ const DEFAULT_CONTENT = '# Hello CollaFlow\n\n开始编辑,支持 **Markdown** 与协同光标。';
23
+
24
+ interface RenderedCursor {
25
+ id: number;
26
+ top: number;
27
+ left: number;
28
+ height: number;
29
+ color: string;
30
+ name: string;
31
+ }
32
+
33
+ /* ----------------------------- 坐标辅助函数 ----------------------------- */
34
+
35
+ /** 计算 textarea 中指定字符位置的屏幕坐标(mirror-div 技术)。 */
36
+ function getCaretCoordinates(
37
+ el: HTMLTextAreaElement,
38
+ position: number
39
+ ): { top: number; left: number; height: number } {
40
+ const div = document.createElement('div');
41
+ const style = window.getComputedStyle(el);
42
+ const props = [
43
+ 'boxSizing', 'width', 'height', 'overflowX', 'overflowY',
44
+ 'borderTopWidth', 'borderRightWidth', 'borderBottomWidth', 'borderLeftWidth',
45
+ 'paddingTop', 'paddingRight', 'paddingBottom', 'paddingLeft',
46
+ 'fontStyle', 'fontVariant', 'fontWeight', 'fontStretch', 'fontSize',
47
+ 'lineHeight', 'fontFamily', 'textAlign', 'textTransform', 'textIndent',
48
+ 'letterSpacing', 'wordSpacing', 'tabSize', 'whiteSpace', 'wordWrap',
49
+ ] as const;
50
+ props.forEach((p) => {
51
+ const value = (style as unknown as Record<string, string>)[p] ?? '';
52
+ (div.style as unknown as Record<string, string>)[p] = value;
53
+ });
54
+ div.style.position = 'absolute';
55
+ div.style.visibility = 'hidden';
56
+ div.style.whiteSpace = 'pre-wrap';
57
+ div.style.wordWrap = 'break-word';
58
+ div.textContent = el.value.substring(0, position);
59
+ const span = document.createElement('span');
60
+ span.textContent = el.value.substring(position) || '.';
61
+ div.appendChild(span);
62
+ document.body.appendChild(div);
63
+ const coords = {
64
+ top: span.offsetTop + parseInt(style.borderTopWidth || '0', 10),
65
+ left: span.offsetLeft + parseInt(style.borderLeftWidth || '0', 10),
66
+ height: parseInt(style.lineHeight || '0', 10) || span.offsetHeight,
67
+ };
68
+ document.body.removeChild(div);
69
+ return coords;
70
+ }
71
+
72
+ /** 统计 container 内、指定 DOM 位置之前的「真实文本字符数」。 */
73
+ function getDomTextOffset(container: HTMLElement, node: Node, offset: number): number {
74
+ // 空行 / <br> 等场景下 domAtPos 返回的是「元素节点」而非文本节点,
75
+ // 仅遍历文本节点会匹配不到、把偏移错算到文末。这里改用 Range 量化从容器起点到目标位置,
76
+ // 元素节点也能正确得到其之前的真实文本长度。
77
+ const range = document.createRange();
78
+ range.setStart(container, 0);
79
+ try {
80
+ range.setEnd(node, offset);
81
+ } catch {
82
+ return 0;
83
+ }
84
+ const frag = range.cloneContents();
85
+ let count = 0;
86
+ const walker = document.createTreeWalker(frag, NodeFilter.SHOW_TEXT);
87
+ let n = walker.nextNode();
88
+ while (n) {
89
+ count += (n.textContent ?? '').length;
90
+ n = walker.nextNode();
91
+ }
92
+ return count;
93
+ }
94
+
95
+ /** 在 container 中找到第 target 个「真实文本字符」处的屏幕坐标。 */
96
+ function domPosAtTextOffset(
97
+ container: HTMLElement,
98
+ target: number
99
+ ): { top: number; left: number; height: number } | null {
100
+ let count = 0;
101
+ const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT);
102
+ let last: Text | null = null;
103
+ let lastLen = 0;
104
+ let n = walker.nextNode() as Text | null;
105
+ while (n) {
106
+ const len = (n.textContent ?? '').length;
107
+ if (count + len >= target) {
108
+ const pos = Math.max(0, Math.min(target - count, len));
109
+ const range = document.createRange();
110
+ range.setStart(n, pos);
111
+ range.collapse(true);
112
+ const rect = range.getBoundingClientRect();
113
+ return { top: rect.top, left: rect.left, height: rect.height };
114
+ }
115
+ count += len;
116
+ last = n;
117
+ lastLen = len;
118
+ n = walker.nextNode() as Text | null;
119
+ }
120
+ if (last) {
121
+ const range = document.createRange();
122
+ range.setStart(last, lastLen);
123
+ range.collapse(true);
124
+ const rect = range.getBoundingClientRect();
125
+ return { top: rect.top, left: rect.left, height: rect.height };
126
+ }
127
+ return null;
128
+ }
129
+
130
+ /** 由「真实文本字符偏移」反查 Markdown 字符偏移(closest match)。 */
131
+ function domTextOffsetToMdOffset(counts: number[], domOffset: number): number {
132
+ let best = 0;
133
+ let bestDiff = Number.POSITIVE_INFINITY;
134
+ for (let i = 0; i < counts.length; i++) {
135
+ const diff = Math.abs((counts[i] ?? 0) - domOffset);
136
+ if (diff < bestDiff) {
137
+ bestDiff = diff;
138
+ best = i;
139
+ }
140
+ }
141
+ return best;
142
+ }
143
+
144
+ /* ------------------------------- 组件 ------------------------------- */
145
+
146
+ function RemoteCaret({ top, left, height, color, name }: RenderedCursor) {
147
+ return (
148
+ <>
149
+ <div className="colla-rc-cursor" style={{ top, left, height, background: color }} />
150
+ <div className="colla-rc-label" style={{ top, left, background: color }}>
151
+ {name}
152
+ </div>
153
+ </>
154
+ );
155
+ }
156
+
157
+ export function MarkdownEditor({
158
+ roomId,
159
+ serverUrl,
160
+ defaultValue = DEFAULT_CONTENT,
161
+ }: MarkdownEditorProps) {
162
+ const containerRef = useRef<HTMLDivElement>(null);
163
+ const previewWrapRef = useRef<HTMLDivElement>(null);
164
+ const previewRef = useRef<HTMLDivElement>(null);
165
+ const sourceWrapRef = useRef<HTMLDivElement>(null);
166
+ const textareaRef = useRef<HTMLTextAreaElement>(null);
167
+ const contentRef = useRef<CollaMarkdownEditor['content'] | null>(null);
168
+ const editorRef = useRef<CollaMarkdownEditor | null>(null);
169
+ const countsRef = useRef<number[]>([]);
170
+
171
+ const [leftWidth, setLeftWidth] = useState(50);
172
+ const [isResizing, setIsResizing] = useState(false);
173
+ const [remoteCursors, setRemoteCursors] = useState<RemoteCursor[]>([]);
174
+ const [version, setVersion] = useState(0);
175
+ const [textCursors, setTextCursors] = useState<RenderedCursor[]>([]);
176
+ const [previewCursors, setPreviewCursors] = useState<RenderedCursor[]>([]);
177
+
178
+ useEffect(() => {
179
+ let cancelled = false;
180
+ let editor: CollaMarkdownEditor | null = null;
181
+ let cleanupPreview: (() => void) | null = null;
182
+ let editorCleanup: (() => void) | null = null;
183
+
184
+ void (async () => {
185
+ const { createEditor } = await import('@collaflow/markdown');
186
+ if (cancelled || !previewRef.current) return;
187
+
188
+ editor = await createEditor({
189
+ root: previewRef.current,
190
+ defaultValue,
191
+ highlight: true,
192
+ theme: 'light',
193
+ collab:
194
+ roomId && serverUrl
195
+ ? { roomId, serverUrl, user: { name: 'You', color: '#3b82f6' } }
196
+ : undefined,
197
+ onAwarenessChange: () => {
198
+ setRemoteCursors(editorRef.current?.getRemoteCursors() ?? []);
199
+ },
200
+ });
201
+ editorRef.current = editor;
202
+
203
+ const content = editor.content;
204
+ contentRef.current = content;
205
+
206
+ if (textareaRef.current) {
207
+ textareaRef.current.value = content.toString();
208
+ }
209
+
210
+ // content 变化(远端协同 / 预览区编辑)→ 同步回 textarea + 触发光标重算
211
+ const observer = (_: unknown, transaction: { origin: unknown }) => {
212
+ if (transaction.origin === YJS_ORIGIN_TEXTAREA) {
213
+ setVersion((v) => v + 1);
214
+ return;
215
+ }
216
+ syncTextareaFromContent();
217
+ recomputePreviewCounts();
218
+ setVersion((v) => v + 1);
219
+ };
220
+ content.observe(observer);
221
+
222
+ // 预览区本地选区 → 广播
223
+ const view = editor.getView();
224
+ const onPreviewSelection = () => {
225
+ const { from, to } = view.state.selection;
226
+ const a = getDomTextOffset(view.dom, view.domAtPos(from).node, view.domAtPos(from).offset);
227
+ const b = getDomTextOffset(view.dom, view.domAtPos(to).node, view.domAtPos(to).offset);
228
+ editor?.setLocalSelection(
229
+ domTextOffsetToMdOffset(countsRef.current, a),
230
+ domTextOffsetToMdOffset(countsRef.current, b)
231
+ );
232
+ };
233
+ view.dom.addEventListener('keyup', onPreviewSelection);
234
+ view.dom.addEventListener('mouseup', onPreviewSelection);
235
+ view.dom.addEventListener('focus', onPreviewSelection);
236
+ cleanupPreview = () => {
237
+ view.dom.removeEventListener('keyup', onPreviewSelection);
238
+ view.dom.removeEventListener('mouseup', onPreviewSelection);
239
+ view.dom.removeEventListener('focus', onPreviewSelection);
240
+ };
241
+
242
+ recomputePreviewCounts();
243
+ setRemoteCursors(editor.getRemoteCursors());
244
+
245
+ editorCleanup = () => content.unobserve(observer);
246
+ })();
247
+
248
+ return () => {
249
+ cancelled = true;
250
+ cleanupPreview?.();
251
+ editorCleanup?.();
252
+ contentRef.current = null;
253
+ void editorRef.current?.destroy();
254
+ editorRef.current = null;
255
+ };
256
+ // eslint-disable-next-line react-hooks/exhaustive-deps
257
+ }, [roomId, serverUrl, defaultValue]);
258
+
259
+ /** 命令式把 content 同步到 textarea(保留光标)。 */
260
+ const syncTextareaFromContent = useCallback(() => {
261
+ const ta = textareaRef.current;
262
+ const content = contentRef.current;
263
+ if (!ta || !content) return;
264
+
265
+ const next = content.toString();
266
+ if (ta.value === next) return;
267
+
268
+ const prev = ta.value;
269
+ const selStart = ta.selectionStart;
270
+ let i = 0;
271
+ const min = Math.min(prev.length, next.length);
272
+ while (i < min && prev[i] === next[i]) i++;
273
+ const delta = next.length - prev.length;
274
+
275
+ ta.value = next;
276
+ if (document.activeElement === ta) {
277
+ const newPos = Math.max(i, Math.min(next.length, selStart + (selStart <= i ? 0 : delta)));
278
+ ta.setSelectionRange(newPos, newPos);
279
+ }
280
+ }, []);
281
+
282
+ /** 重新计算预览区「Markdown 偏移 → 真实文本偏移」映射。 */
283
+ const recomputePreviewCounts = useCallback(() => {
284
+ const editor = editorRef.current;
285
+ if (!editor) return;
286
+ const view = editor.getView();
287
+ countsRef.current = buildDomTextCounts(view.state.doc, editor.getMarkdown());
288
+ }, []);
289
+
290
+ // 编辑源码 → 最小字符差异写入 content(本地输入,光标稳定)
291
+ const handleSourceInput = useCallback((e: InputEvent<HTMLTextAreaElement>) => {
292
+ const content = contentRef.current;
293
+ if (!content) return;
294
+ diffApply(content, e.currentTarget.value, YJS_ORIGIN_TEXTAREA);
295
+ }, []);
296
+
297
+ // 源码区本地选区 → 广播
298
+ const handleSourceSelection = useCallback(() => {
299
+ const ta = textareaRef.current;
300
+ const editor = editorRef.current;
301
+ if (!ta || !editor) return;
302
+ editor.setLocalSelection(ta.selectionStart, ta.selectionEnd);
303
+ }, []);
304
+
305
+ // 拖拽分隔条调整左右宽度
306
+ const startResize = useCallback((e: MouseEvent<HTMLDivElement>) => {
307
+ e.preventDefault();
308
+ const container = containerRef.current;
309
+ if (!container) return;
310
+ setIsResizing(true);
311
+
312
+ const onMove = (ev: globalThis.MouseEvent) => {
313
+ const rect = container.getBoundingClientRect();
314
+ if (rect.width === 0) return;
315
+ const pct = ((ev.clientX - rect.left) / rect.width) * 100;
316
+ setLeftWidth(Math.min(80, Math.max(20, pct)));
317
+ };
318
+ const onUp = () => {
319
+ setIsResizing(false);
320
+ window.removeEventListener('mousemove', onMove);
321
+ window.removeEventListener('mouseup', onUp);
322
+ };
323
+ window.addEventListener('mousemove', onMove);
324
+ window.addEventListener('mouseup', onUp);
325
+ }, []);
326
+
327
+ // 远端光标坐标:随 content / remoteCursors 变化重算
328
+ useLayoutEffect(() => {
329
+ const ta = textareaRef.current;
330
+ if (ta) {
331
+ setTextCursors(
332
+ remoteCursors.map((c) => {
333
+ const coords = getCaretCoordinates(ta, c.anchor);
334
+ return {
335
+ id: c.clientId,
336
+ top: coords.top - ta.scrollTop,
337
+ left: coords.left - ta.scrollLeft,
338
+ height: coords.height,
339
+ color: c.user.color,
340
+ name: c.user.name,
341
+ };
342
+ })
343
+ );
344
+ }
345
+
346
+ const pWrap = previewWrapRef.current;
347
+ const editor = editorRef.current;
348
+ if (pWrap && editor) {
349
+ const view = editor.getView();
350
+ const wrapRect = pWrap.getBoundingClientRect();
351
+ setPreviewCursors(
352
+ remoteCursors.map((c) => {
353
+ const domOffset = markdownOffsetToDomTextOffset(countsRef.current, c.anchor);
354
+ const rect = domPosAtTextOffset(view.dom, domOffset);
355
+ if (!rect) return null;
356
+ return {
357
+ id: c.clientId,
358
+ top: rect.top - wrapRect.top,
359
+ left: rect.left - wrapRect.left,
360
+ height: rect.height,
361
+ color: c.user.color,
362
+ name: c.user.name,
363
+ };
364
+ }).filter((c): c is RenderedCursor => c !== null)
365
+ );
366
+ }
367
+ }, [remoteCursors, version]);
368
+
369
+ return (
370
+ <div
371
+ ref={containerRef}
372
+ className={`flex w-full overflow-hidden rounded-lg border border-border bg-card ${
373
+ isResizing ? 'select-none' : ''
374
+ }`}
375
+ style={{ height: '70vh', minHeight: '420px' }}
376
+ >
377
+ {/* 预览(WYSIWYG) */}
378
+ <section
379
+ className="flex h-full flex-col overflow-hidden"
380
+ style={{ width: `${leftWidth}%` }}
381
+ >
382
+ <div className="border-b border-border px-3 py-1.5 text-xs text-muted-foreground">
383
+ 预览
384
+ </div>
385
+ <div ref={previewWrapRef} className="relative h-full flex-1 overflow-hidden">
386
+ <div ref={previewRef} className="colla-editor h-full w-full overflow-auto p-4" />
387
+ <div className="pointer-events-none absolute inset-0 overflow-hidden">
388
+ {previewCursors.map((c) => (
389
+ <RemoteCaret key={c.id} {...c} />
390
+ ))}
391
+ </div>
392
+ </div>
393
+ </section>
394
+
395
+ {/* 拖拽分隔条 */}
396
+ <div
397
+ onMouseDown={startResize}
398
+ role="separator"
399
+ aria-orientation="vertical"
400
+ className="w-1.5 shrink-0 cursor-col-resize bg-border transition-colors hover:bg-accent"
401
+ />
402
+
403
+ {/* 源码(可编辑 Markdown,非受控) */}
404
+ <section
405
+ className="flex h-full flex-col overflow-hidden"
406
+ style={{ width: `${100 - leftWidth}%` }}
407
+ >
408
+ <div className="border-b border-border px-3 py-1.5 text-xs text-muted-foreground">
409
+ 源码(Markdown)
410
+ </div>
411
+ <div ref={sourceWrapRef} className="relative h-full flex-1 overflow-hidden">
412
+ <textarea
413
+ ref={textareaRef}
414
+ defaultValue={defaultValue}
415
+ onInput={handleSourceInput}
416
+ onKeyUp={handleSourceSelection}
417
+ onMouseUp={handleSourceSelection}
418
+ onSelect={handleSourceSelection}
419
+ spellCheck={false}
420
+ placeholder="在此编辑 Markdown 源码…"
421
+ className="h-full w-full flex-1 resize-none bg-transparent p-4 font-mono text-sm leading-relaxed text-foreground outline-none"
422
+ />
423
+ <div className="pointer-events-none absolute inset-0 overflow-hidden">
424
+ {textCursors.map((c) => (
425
+ <RemoteCaret key={c.id} {...c} />
426
+ ))}
427
+ </div>
428
+ </div>
429
+ </section>
430
+ </div>
431
+ );
432
+ }
@@ -0,0 +1,6 @@
1
+ import { type ClassValue, clsx } from 'clsx';
2
+ import { twMerge } from 'tailwind-merge';
3
+
4
+ export function cn(...inputs: ClassValue[]) {
5
+ return twMerge(clsx(inputs));
6
+ }
@@ -0,0 +1,59 @@
1
+ import type { Config } from 'tailwindcss';
2
+ import { collaflowPreset, radius } from '@collaflow/design';
3
+
4
+ const config: Config = {
5
+ presets: [collaflowPreset],
6
+ darkMode: ['class'],
7
+ content: [
8
+ './src/pages/**/*.{js,ts,jsx,tsx,mdx}',
9
+ './src/components/**/*.{js,ts,jsx,tsx,mdx}',
10
+ './src/app/**/*.{js,ts,jsx,tsx,mdx}',
11
+ ],
12
+ theme: {
13
+ extend: {
14
+ colors: {
15
+ border: 'var(--cf-border-default)',
16
+ input: 'var(--cf-border-default)',
17
+ ring: 'var(--cf-accent-blue)',
18
+ background: 'var(--cf-bg-primary)',
19
+ foreground: 'var(--cf-text-primary)',
20
+ primary: {
21
+ DEFAULT: 'var(--cf-text-primary)',
22
+ foreground: 'var(--cf-bg-secondary)',
23
+ },
24
+ secondary: {
25
+ DEFAULT: 'var(--cf-bg-tertiary)',
26
+ foreground: 'var(--cf-text-primary)',
27
+ },
28
+ destructive: {
29
+ DEFAULT: 'var(--cf-accent-red)',
30
+ foreground: 'var(--cf-bg-secondary)',
31
+ },
32
+ muted: {
33
+ DEFAULT: 'var(--cf-bg-tertiary)',
34
+ foreground: 'var(--cf-text-secondary)',
35
+ },
36
+ accent: {
37
+ DEFAULT: 'var(--cf-bg-tertiary)',
38
+ foreground: 'var(--cf-text-primary)',
39
+ },
40
+ popover: {
41
+ DEFAULT: 'var(--cf-bg-secondary)',
42
+ foreground: 'var(--cf-text-primary)',
43
+ },
44
+ card: {
45
+ DEFAULT: 'var(--cf-bg-secondary)',
46
+ foreground: 'var(--cf-text-primary)',
47
+ },
48
+ },
49
+ borderRadius: {
50
+ lg: radius.lg,
51
+ md: radius.md,
52
+ sm: radius.sm,
53
+ },
54
+ },
55
+ },
56
+ plugins: [],
57
+ };
58
+
59
+ export default config;
package/tsconfig.json ADDED
@@ -0,0 +1,27 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "lib": ["DOM", "DOM.Iterable", "ES2022"],
5
+ "allowJs": true,
6
+ "skipLibCheck": true,
7
+ "strict": true,
8
+ "noEmit": true,
9
+ "esModuleInterop": true,
10
+ "module": "ESNext",
11
+ "moduleResolution": "Bundler",
12
+ "resolveJsonModule": true,
13
+ "isolatedModules": true,
14
+ "jsx": "preserve",
15
+ "incremental": true,
16
+ "plugins": [
17
+ {
18
+ "name": "next"
19
+ }
20
+ ],
21
+ "paths": {
22
+ "@/*": ["./src/*"]
23
+ }
24
+ },
25
+ "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
26
+ "exclude": ["node_modules", "dist", ".next"]
27
+ }
@@ -0,0 +1,8 @@
1
+ import { defineConfig } from 'vitest/config';
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ environment: 'jsdom',
6
+ globals: true,
7
+ },
8
+ });