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:
Ullrich Schäfer 2026-04-11 02:55:06 +02:00
parent 360bd87de2
commit 5f21564a19
No known key found for this signature in database
GPG key ID: A32FF691A0F752D9
3 changed files with 243 additions and 46 deletions

View file

@ -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>
);
}

View file

@ -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"