From 5f21564a194b1410e5786e862972972e01d501a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Sat, 11 Apr 2026 02:55:06 +0200 Subject: [PATCH] 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) --- apps/planner/app/components/NotesPanel.tsx | 132 +++++++++++------- apps/planner/package.json | 6 + pnpm-lock.yaml | 151 +++++++++++++++++++++ 3 files changed, 243 insertions(+), 46 deletions(-) diff --git a/apps/planner/app/components/NotesPanel.tsx b/apps/planner/app/components/NotesPanel.tsx index caaf9bb..4b91620 100644 --- a/apps/planner/app/components/NotesPanel.tsx +++ b/apps/planner/app/components/NotesPanel.tsx @@ -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(null); - const isLocalChange = useRef(false); + const { t } = useTranslation("planner"); + const containerRef = useRef(null); + const viewRef = useRef(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 | 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) => { - 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 (
-

Notes

-
-
-