Replace notes textarea with collaborative CodeMirror editor
- CodeMirror 6 + y-codemirror.next for proper character-level Yjs sync (replaces delete-all/insert-all textarea binding) - Remote cursor awareness: colored carets with participant names - Line wrapping, placeholder text, history (undo/redo) - Awareness user fields include colorLight for cursor selection tint Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
360bd87de2
commit
5f21564a19
3 changed files with 243 additions and 46 deletions
|
|
@ -1,66 +1,106 @@
|
|||
import { useEffect, useRef, useCallback } from "react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { EditorView, keymap, placeholder } from "@codemirror/view";
|
||||
import { EditorState } from "@codemirror/state";
|
||||
import { defaultKeymap, history, historyKeymap } from "@codemirror/commands";
|
||||
import { syntaxHighlighting, defaultHighlightStyle } from "@codemirror/language";
|
||||
import { yCollab } from "y-codemirror.next";
|
||||
import type { YjsState } from "~/lib/use-yjs";
|
||||
|
||||
interface NotesPanelProps {
|
||||
yjs: YjsState;
|
||||
}
|
||||
|
||||
const theme = EditorView.theme({
|
||||
"&": {
|
||||
height: "100%",
|
||||
fontSize: "13px",
|
||||
},
|
||||
".cm-editor": {
|
||||
height: "100%",
|
||||
},
|
||||
".cm-scroller": {
|
||||
overflow: "auto",
|
||||
fontFamily: "inherit",
|
||||
},
|
||||
".cm-content": {
|
||||
padding: "12px",
|
||||
caretColor: "#1f2937",
|
||||
},
|
||||
".cm-line": {
|
||||
lineHeight: "1.5",
|
||||
},
|
||||
"&.cm-focused": {
|
||||
outline: "none",
|
||||
},
|
||||
// Remote cursor styling
|
||||
".cm-ySelectionInfo": {
|
||||
fontSize: "10px",
|
||||
fontFamily: "system-ui, sans-serif",
|
||||
padding: "1px 4px",
|
||||
borderRadius: "3px",
|
||||
opacity: "0.9",
|
||||
fontWeight: "500",
|
||||
position: "absolute",
|
||||
top: "-1.2em",
|
||||
left: "-1px",
|
||||
whiteSpace: "nowrap",
|
||||
},
|
||||
});
|
||||
|
||||
export function NotesPanel({ yjs }: NotesPanelProps) {
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const isLocalChange = useRef(false);
|
||||
const { t } = useTranslation("planner");
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const viewRef = useRef<EditorView | null>(null);
|
||||
|
||||
// Sync Y.Text → textarea
|
||||
useEffect(() => {
|
||||
const textarea = textareaRef.current;
|
||||
if (!textarea) return;
|
||||
if (!containerRef.current) return;
|
||||
|
||||
// Set initial value
|
||||
textarea.value = yjs.notes.toString();
|
||||
// Set awareness user fields for cursor display
|
||||
const localState = yjs.awareness.getLocalState() as Record<string, unknown> | null;
|
||||
const user = localState?.user as { name: string; color: string } | undefined;
|
||||
if (user) {
|
||||
yjs.awareness.setLocalStateField("user", {
|
||||
...user,
|
||||
colorLight: user.color + "30",
|
||||
});
|
||||
}
|
||||
|
||||
const observer = () => {
|
||||
if (isLocalChange.current) return;
|
||||
const pos = textarea.selectionStart;
|
||||
textarea.value = yjs.notes.toString();
|
||||
textarea.selectionStart = pos;
|
||||
textarea.selectionEnd = pos;
|
||||
const state = EditorState.create({
|
||||
extensions: [
|
||||
keymap.of([...defaultKeymap, ...historyKeymap]),
|
||||
history(),
|
||||
syntaxHighlighting(defaultHighlightStyle),
|
||||
EditorView.lineWrapping,
|
||||
placeholder(t("notes.placeholder")),
|
||||
theme,
|
||||
yCollab(yjs.notes, yjs.awareness, {
|
||||
undoManager: yjs.undoManager,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const view = new EditorView({
|
||||
state,
|
||||
parent: containerRef.current,
|
||||
});
|
||||
|
||||
viewRef.current = view;
|
||||
|
||||
return () => {
|
||||
view.destroy();
|
||||
viewRef.current = null;
|
||||
};
|
||||
|
||||
yjs.notes.observe(observer);
|
||||
return () => yjs.notes.unobserve(observer);
|
||||
}, [yjs.notes]);
|
||||
|
||||
// textarea → Y.Text
|
||||
const handleInput = useCallback(
|
||||
(e: React.FormEvent<HTMLTextAreaElement>) => {
|
||||
const textarea = e.currentTarget;
|
||||
const newValue = textarea.value;
|
||||
const currentValue = yjs.notes.toString();
|
||||
|
||||
if (newValue === currentValue) return;
|
||||
|
||||
isLocalChange.current = true;
|
||||
yjs.doc.transact(() => {
|
||||
yjs.notes.delete(0, yjs.notes.length);
|
||||
yjs.notes.insert(0, newValue);
|
||||
}, "local");
|
||||
isLocalChange.current = false;
|
||||
},
|
||||
[yjs.notes, yjs.doc],
|
||||
);
|
||||
}, [yjs]);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="border-b border-gray-200 px-4 py-3">
|
||||
<h2 className="text-sm font-medium text-gray-900">Notes</h2>
|
||||
</div>
|
||||
<div className="flex-1 p-2">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
onInput={handleInput}
|
||||
className="h-full w-full resize-none rounded border border-gray-200 p-3 text-sm text-gray-700 placeholder-gray-400 focus:border-blue-300 focus:outline-none"
|
||||
placeholder="Add notes for this session..."
|
||||
/>
|
||||
<h2 className="text-sm font-medium text-gray-900">
|
||||
{t("sidebar.notes")}
|
||||
</h2>
|
||||
</div>
|
||||
<div ref={containerRef} className="flex-1 overflow-hidden" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,10 @@
|
|||
"lint": "eslint ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/commands": "^6.10.3",
|
||||
"@codemirror/language": "^6.12.3",
|
||||
"@codemirror/state": "^6.6.0",
|
||||
"@codemirror/view": "^6.41.0",
|
||||
"@geoman-io/leaflet-geoman-free": "^2.19.3",
|
||||
"@react-router/node": "catalog:",
|
||||
"@react-router/serve": "catalog:",
|
||||
|
|
@ -22,6 +26,7 @@
|
|||
"@trails-cool/map": "workspace:*",
|
||||
"@trails-cool/types": "workspace:*",
|
||||
"@trails-cool/ui": "workspace:*",
|
||||
"codemirror": "^6.0.2",
|
||||
"drizzle-orm": "catalog:",
|
||||
"isbot": "^5.1.37",
|
||||
"lib0": "^0.2.117",
|
||||
|
|
@ -31,6 +36,7 @@
|
|||
"react-dom": "catalog:",
|
||||
"react-router": "catalog:",
|
||||
"ws": "^8.20.0",
|
||||
"y-codemirror.next": "^0.3.5",
|
||||
"y-protocols": "^1.0.7",
|
||||
"y-websocket": "^3.0.0",
|
||||
"yjs": "^13.6.30"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue