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"

151
pnpm-lock.yaml generated
View file

@ -285,6 +285,18 @@ importers:
apps/planner:
dependencies:
'@codemirror/commands':
specifier: ^6.10.3
version: 6.10.3
'@codemirror/language':
specifier: ^6.12.3
version: 6.12.3
'@codemirror/state':
specifier: ^6.6.0
version: 6.6.0
'@codemirror/view':
specifier: ^6.41.0
version: 6.41.0
'@geoman-io/leaflet-geoman-free':
specifier: ^2.19.3
version: 2.19.3(leaflet@1.9.4)
@ -318,6 +330,9 @@ importers:
'@trails-cool/ui':
specifier: workspace:*
version: link:../../packages/ui
codemirror:
specifier: ^6.0.2
version: 6.0.2
drizzle-orm:
specifier: 'catalog:'
version: 0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.15.6)(postgres@3.4.9)
@ -345,6 +360,9 @@ importers:
ws:
specifier: ^8.20.0
version: 8.20.0
y-codemirror.next:
specifier: ^0.3.5
version: 0.3.5(@codemirror/state@6.6.0)(@codemirror/view@6.41.0)(yjs@13.6.30)
y-protocols:
specifier: ^1.0.7
version: 1.0.7(yjs@13.6.30)
@ -588,6 +606,27 @@ packages:
resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==}
hasBin: true
'@codemirror/autocomplete@6.20.1':
resolution: {integrity: sha512-1cvg3Vz1dSSToCNlJfRA2WSI4ht3K+WplO0UMOgmUYPivCyy2oueZY6Lx7M9wThm7SDUBViRmuT+OG/i8+ON9A==}
'@codemirror/commands@6.10.3':
resolution: {integrity: sha512-JFRiqhKu+bvSkDLI+rUhJwSxQxYb759W5GBezE8Uc8mHLqC9aV/9aTC7yJSqCtB3F00pylrLCwnyS91Ap5ej4Q==}
'@codemirror/language@6.12.3':
resolution: {integrity: sha512-QwCZW6Tt1siP37Jet9Tb02Zs81TQt6qQrZR2H+eGMcFsL1zMrk2/b9CLC7/9ieP1fjIUMgviLWMmgiHoJrj+ZA==}
'@codemirror/lint@6.9.5':
resolution: {integrity: sha512-GElsbU9G7QT9xXhpUg1zWGmftA/7jamh+7+ydKRuT0ORpWS3wOSP0yT1FOlIZa7mIJjpVPipErsyvVqB9cfTFA==}
'@codemirror/search@6.6.0':
resolution: {integrity: sha512-koFuNXcDvyyotWcgOnZGmY7LZqEOXZaaxD/j6n18TCLx2/9HieZJ5H6hs1g8FiRxBD0DNfs0nXn17g872RmYdw==}
'@codemirror/state@6.6.0':
resolution: {integrity: sha512-4nbvra5R5EtiCzr9BTHiTLc+MLXK2QGiAVYMyi8PkQd3SR+6ixar/Q/01Fa21TBIDOZXgeWV4WppsQolSreAPQ==}
'@codemirror/view@6.41.0':
resolution: {integrity: sha512-6H/qadXsVuDY219Yljhohglve8xf4B8xJkVOEWfA5uiYKiTFppjqsvsfR5iPA0RbvRBoOyTZpbLIxe9+0UR8xA==}
'@csstools/color-helpers@6.0.2':
resolution: {integrity: sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==}
engines: {node: '>=20.19.0'}
@ -1185,6 +1224,18 @@ packages:
'@levischuck/tiny-cbor@0.2.11':
resolution: {integrity: sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow==}
'@lezer/common@1.5.2':
resolution: {integrity: sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==}
'@lezer/highlight@1.2.3':
resolution: {integrity: sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==}
'@lezer/lr@1.4.8':
resolution: {integrity: sha512-bPWa0Pgx69ylNlMlPvBPryqeLYQjyJjqPx+Aupm5zydLIF3NE+6MMLT8Yi23Bd9cif9VS00aUebn+6fDIGBcDA==}
'@marijn/find-cluster-break@1.0.2':
resolution: {integrity: sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==}
'@mjackson/node-fetch-server@0.2.0':
resolution: {integrity: sha512-EMlH1e30yzmTpGLQjlFmaDAjyOeZhng1/XCd7DExR8PNAnG/G1tyruZxEoUe11ClnwGhGrtsdnyyUx1frSzjng==}
@ -2368,6 +2419,9 @@ packages:
cjs-module-lexer@2.2.0:
resolution: {integrity: sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==}
codemirror@6.0.2:
resolution: {integrity: sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==}
colorette@2.0.20:
resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==}
@ -2404,6 +2458,9 @@ packages:
resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==}
engines: {node: '>=18'}
crelt@1.0.6:
resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==}
cross-spawn@7.0.6:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'}
@ -3627,6 +3684,9 @@ packages:
resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==}
engines: {node: '>=14.16'}
style-mod@4.1.3:
resolution: {integrity: sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==}
sweepline-intersections@1.5.0:
resolution: {integrity: sha512-AoVmx72QHpKtItPu72TzFL+kcYjd67BPLDoR0LarIk+xyaRg+pDTMFXndIEvZf9xEKnJv6JdhgRMnocoG0D3AQ==}
@ -3909,6 +3969,9 @@ packages:
resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==}
engines: {node: '>=0.10.0'}
w3c-keyname@2.2.8:
resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==}
w3c-xmlserializer@5.0.0:
resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==}
engines: {node: '>=18'}
@ -3974,6 +4037,13 @@ packages:
resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==}
engines: {node: '>=0.4'}
y-codemirror.next@0.3.5:
resolution: {integrity: sha512-VluNu3e5HfEXybnypnsGwKAj+fKLd4iAnR7JuX1Sfyydmn1jCBS5wwEL/uS04Ch2ib0DnMAOF6ZRR/8kK3wyGw==}
peerDependencies:
'@codemirror/state': ^6.0.0
'@codemirror/view': ^6.0.0
yjs: ^13.5.6
y-protocols@1.0.7:
resolution: {integrity: sha512-YSVsLoXxO67J6eE/nV4AtFtT3QEotZf5sK5BHxFBXso7VDUT3Tx07IfA6hsu5Q5OmBdMkQVmFZ9QOA7fikWvnw==}
engines: {node: '>=16.0.0', npm: '>=8.0.0'}
@ -4209,6 +4279,52 @@ snapshots:
dependencies:
css-tree: 3.2.1
'@codemirror/autocomplete@6.20.1':
dependencies:
'@codemirror/language': 6.12.3
'@codemirror/state': 6.6.0
'@codemirror/view': 6.41.0
'@lezer/common': 1.5.2
'@codemirror/commands@6.10.3':
dependencies:
'@codemirror/language': 6.12.3
'@codemirror/state': 6.6.0
'@codemirror/view': 6.41.0
'@lezer/common': 1.5.2
'@codemirror/language@6.12.3':
dependencies:
'@codemirror/state': 6.6.0
'@codemirror/view': 6.41.0
'@lezer/common': 1.5.2
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.8
style-mod: 4.1.3
'@codemirror/lint@6.9.5':
dependencies:
'@codemirror/state': 6.6.0
'@codemirror/view': 6.41.0
crelt: 1.0.6
'@codemirror/search@6.6.0':
dependencies:
'@codemirror/state': 6.6.0
'@codemirror/view': 6.41.0
crelt: 1.0.6
'@codemirror/state@6.6.0':
dependencies:
'@marijn/find-cluster-break': 1.0.2
'@codemirror/view@6.41.0':
dependencies:
'@codemirror/state': 6.6.0
crelt: 1.0.6
style-mod: 4.1.3
w3c-keyname: 2.2.8
'@csstools/color-helpers@6.0.2': {}
'@csstools/css-calc@3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)':
@ -4573,6 +4689,18 @@ snapshots:
'@levischuck/tiny-cbor@0.2.11': {}
'@lezer/common@1.5.2': {}
'@lezer/highlight@1.2.3':
dependencies:
'@lezer/common': 1.5.2
'@lezer/lr@1.4.8':
dependencies:
'@lezer/common': 1.5.2
'@marijn/find-cluster-break@1.0.2': {}
'@mjackson/node-fetch-server@0.2.0': {}
'@napi-rs/wasm-runtime@1.1.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)':
@ -5935,6 +6063,16 @@ snapshots:
cjs-module-lexer@2.2.0: {}
codemirror@6.0.2:
dependencies:
'@codemirror/autocomplete': 6.20.1
'@codemirror/commands': 6.10.3
'@codemirror/language': 6.12.3
'@codemirror/lint': 6.9.5
'@codemirror/search': 6.6.0
'@codemirror/state': 6.6.0
'@codemirror/view': 6.41.0
colorette@2.0.20: {}
compressible@2.0.18:
@ -5969,6 +6107,8 @@ snapshots:
cookie@1.1.1: {}
crelt@1.0.6: {}
cross-spawn@7.0.6:
dependencies:
path-key: 3.1.1
@ -7158,6 +7298,8 @@ snapshots:
strip-json-comments@5.0.3: {}
style-mod@4.1.3: {}
sweepline-intersections@1.5.0:
dependencies:
tinyqueue: 2.0.3
@ -7368,6 +7510,8 @@ snapshots:
void-elements@3.1.0: {}
w3c-keyname@2.2.8: {}
w3c-xmlserializer@5.0.0:
dependencies:
xml-name-validator: 5.0.0
@ -7416,6 +7560,13 @@ snapshots:
xtend@4.0.2: {}
y-codemirror.next@0.3.5(@codemirror/state@6.6.0)(@codemirror/view@6.41.0)(yjs@13.6.30):
dependencies:
'@codemirror/state': 6.6.0
'@codemirror/view': 6.41.0
lib0: 0.2.117
yjs: 13.6.30
y-protocols@1.0.7(yjs@13.6.30):
dependencies:
lib0: 0.2.117