diff --git a/package.json b/package.json index adb1eb8..56d005d 100644 --- a/package.json +++ b/package.json @@ -15,9 +15,19 @@ }, "dependencies": { "@base-ui/react": "^1.6.0", + "@codemirror/autocomplete": "^6.20.4", + "@codemirror/commands": "^6.11.1", + "@codemirror/language": "^6.13.1", + "@codemirror/legacy-modes": "^6.5.5", + "@codemirror/lint": "^6.9.7", + "@codemirror/search": "^6.7.2", + "@codemirror/state": "^6.7.6", + "@codemirror/view": "^6.43.15", "@dnd-kit/react": "^0.4.0", "@fontsource-variable/dm-sans": "^5.3.0", "@fontsource/dm-mono": "^5.3.0", + "@hocuspocus/provider": "^4.8.0", + "@lezer/highlight": "^1.2.5", "@polinetwork/backend": "^4.0.0", "@t3-oss/env-core": "^0.13.10", "@tanstack/charts": "1.1.0", @@ -45,6 +55,8 @@ "superjson": "^2.2.1", "tailwind-merge": "^3.6.0", "tw-animate-css": "^1.4.0", + "y-codemirror.next": "^0.3.6", + "y-protocols": "^1.0.7", "yjs": "^13.6.33", "zod": "^4.3.5" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ab2feda..46095fe 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -22,6 +22,30 @@ importers: '@base-ui/react': specifier: ^1.6.0 version: 1.6.0(@date-fns/tz@1.5.0)(@types/react@19.2.17)(date-fns@4.4.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@codemirror/autocomplete': + specifier: ^6.20.4 + version: 6.20.4 + '@codemirror/commands': + specifier: ^6.11.1 + version: 6.11.1 + '@codemirror/language': + specifier: ^6.13.1 + version: 6.13.1 + '@codemirror/legacy-modes': + specifier: ^6.5.5 + version: 6.5.5 + '@codemirror/lint': + specifier: ^6.9.7 + version: 6.9.7 + '@codemirror/search': + specifier: ^6.7.2 + version: 6.7.2 + '@codemirror/state': + specifier: ^6.7.6 + version: 6.7.6 + '@codemirror/view': + specifier: ^6.43.15 + version: 6.43.15 '@dnd-kit/react': specifier: ^0.4.0 version: 0.4.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7) @@ -31,6 +55,12 @@ importers: '@fontsource/dm-mono': specifier: ^5.3.0 version: 5.3.0 + '@hocuspocus/provider': + specifier: ^4.8.0 + version: 4.8.0(y-protocols@1.0.7(yjs@13.6.33))(yjs@13.6.33) + '@lezer/highlight': + specifier: ^1.2.5 + version: 1.2.5 '@polinetwork/backend': specifier: ^4.0.0 version: 4.0.0 @@ -112,6 +142,12 @@ importers: tw-animate-css: specifier: ^1.4.0 version: 1.4.0 + y-codemirror.next: + specifier: ^0.3.6 + version: 0.3.6(@codemirror/state@6.7.6)(@codemirror/view@6.43.15)(yjs@13.6.33) + y-protocols: + specifier: ^1.0.7 + version: 1.0.7(yjs@13.6.33) yjs: specifier: ^13.6.33 version: 13.6.33 @@ -380,6 +416,33 @@ packages: '@blazediff/core@1.9.1': resolution: {integrity: sha512-ehg3jIkYKulZh+8om/O25vkvSsXXwC+skXmyA87FFx6A/45eqOkZsBltMw/TVteb0mloiGT8oGRTcjRAz66zaA==} + '@codemirror/autocomplete@6.20.4': + resolution: {integrity: sha512-yF8RhOf2SXlg00+qtupzLhrglcas61boqaONiNcbPIebHCpJocpqTBA6dyrtUrQO47hqyTdeob/3XnYilT7TaQ==} + + '@codemirror/commands@6.11.1': + resolution: {integrity: sha512-O/4hG3SC1YwcmQ0d2UVNDs+AsaNWd1iHVxbTeEBuqH+6bExAiPK3iS/BvpY6rZGURALv4ZD3sIgcCmRvw3ehBg==} + + '@codemirror/language@6.13.1': + resolution: {integrity: sha512-zfvU2r3TyylQLMj/te+5CnQ0rSqZi8B1oTCBbQkagcLW4Xc6imab6qN+pEJhsuNOLaJqiRyBXp9UhWA1DhVvDw==} + + '@codemirror/legacy-modes@6.5.5': + resolution: {integrity: sha512-9ltT214oMb3Y1w5eAZMSigzrjzZnMI2Wa3Feq73rU/i4C56MkwrpfswfQYOr28C5pXr6dWwspNTMyeHXlzWMxg==} + + '@codemirror/lint@6.9.7': + resolution: {integrity: sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg==} + + '@codemirror/search@6.7.2': + resolution: {integrity: sha512-gUYkYhT2+n/+VGZ+8EzE5WFkYZUZYm1VOKDudIsNqh42uRVQJ0a6Yss9sdKT3MeOYfuL1N6AZA57oza0Oyr0LA==} + + '@codemirror/state@6.7.6': + resolution: {integrity: sha512-kAz+AncRtKuIknedxT1bq4XwXv4UowhbkHU1myPrtVb/jZtImWuV5BXzv5vK6i3kYACsdiZiQKFQQ5Mq7elW8w==} + + '@codemirror/streamparser@6.0.0': + resolution: {integrity: sha512-JTFPF6xrfjt0mnfGPgzcLiMwYmvIyNaoizp8BYzv8FE8KrT6GBGDZ190rtt+O7Wke3gG/MFHyiY9YGNeimof4w==} + + '@codemirror/view@6.43.15': + resolution: {integrity: sha512-HHsfpqFeHyXLs983pSRe38Xajs0vFWZQ2BuX0m7Ki8w3jZ5iD8cuR3lBz2/5/X53Dhf32SBHKh6rtSTB6tKeGw==} + '@csstools/css-tokenizer@3.0.4': resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==} engines: {node: '>=18'} @@ -509,6 +572,12 @@ packages: '@hocuspocus/common@4.8.0': resolution: {integrity: sha512-UI8Vye3mds2aMUHaWfwkDq0+257JUpvKa0nyAOjXWLfyhYHsAq8eku+ynZc/PaShyOci/VD54cW/rLQJGHHPKg==} + '@hocuspocus/provider@4.8.0': + resolution: {integrity: sha512-WUwwu3W55n0fkMF1uYhx4WXlRjJ/t7OjsxiuzTMMQGDvvZrZA37CAysdqc7JXLVmN6A5IIWadE921pxN9/JotA==} + peerDependencies: + y-protocols: ^1.0.6 + yjs: ^13.6.8 + '@hocuspocus/server@4.8.0': resolution: {integrity: sha512-P8U4I4R4Ft+B37d4CFs4tG8oPJ3rnJJiFx3CXBJpKSIrXTz5YDldRmv7eGqmUq4QRSoWR6FjN4DUIX1QO0PccA==} engines: {node: '>=22'} @@ -558,6 +627,21 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@lezer/common@1.5.5': + resolution: {integrity: sha512-Pw7PVCndruF1pb3ebCxpSVpkMHSDjwRCLcTRw+RizUKco5QbWylwq2g4lnG/RYN7yTTQDXE9F77MwtkAyphrjg==} + + '@lezer/highlight@1.2.5': + resolution: {integrity: sha512-O1GMVKgtf5YspFaRzpmqkVgMtIk2HG9uMAbH6wdAIdNh3TVzCsch/+cRi+6l2//UGDKe6NB9BW3cvuR5ezMQ0w==} + + '@lezer/lr@1.4.12': + resolution: {integrity: sha512-On9HU4oMMiHPMkQDgoTnGahjgG5n/MkN38CgG77sMraKLL+3JBbeQNi1FcCXV9QRqwF9/BUXP3caHyJC1fBOTQ==} + + '@lifeomic/attempt@3.1.0': + resolution: {integrity: sha512-QZqem4QuAnAyzfz+Gj5/+SLxqwCAw2qmt7732ZXodr6VDWGeYLG6w1i/vYLa55JQM9wRuBKLmXmiZ2P0LtE5rw==} + + '@marijn/find-cluster-break@1.0.4': + resolution: {integrity: sha512-Wy0V7+SGUjnF9/TkiM1hKVDPj7jKXduPNboMVtHTA8dySMURWqfg/JZ9E2Sq8JgSJmkl7k7Qe9FLeMSrSraWmQ==} + '@modelcontextprotocol/sdk@1.29.0': resolution: {integrity: sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==} engines: {node: '>=18'} @@ -2463,6 +2547,9 @@ packages: typescript: optional: true + crelt@1.0.7: + resolution: {integrity: sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==} + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -4716,6 +4803,9 @@ packages: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} + style-mod@4.1.4: + resolution: {integrity: sha512-XXWIQt633/EpAFx8aZDOTjBzrCaGmhvEQlQo6MVPfa2OzO2cWo+4hV9h+6UkHYlXGfy+ODXKUdP7Pthmcu5ATw==} + style-to-object@1.0.14: resolution: {integrity: sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==} @@ -5094,6 +5184,9 @@ packages: jsdom: optional: true + w3c-keyname@2.2.8: + resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==} + webidl-conversions@7.0.0: resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} engines: {node: '>=12'} @@ -5163,6 +5256,13 @@ packages: resolution: {integrity: sha512-bx8Q1STctnNaaDymWnkfQLKofs0mGNN7rLLapJlGuV3VlvegD7Ls4ggMjE3aUSWItCCzU0PEv45lI87iSigiCA==} engines: {node: '>=20.0'} + y-codemirror.next@0.3.6: + resolution: {integrity: sha512-GnmVXhTe+UtoFbbaSdwhq6gdAQZdIY9az0Xj6HR2J4PO6Yw4w3xaaSssU+6T72WhXAI0wf9FbuY2s0Ms+WrexA==} + 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'} @@ -5507,6 +5607,59 @@ snapshots: '@blazediff/core@1.9.1': {} + '@codemirror/autocomplete@6.20.4': + dependencies: + '@codemirror/language': 6.13.1 + '@codemirror/state': 6.7.6 + '@codemirror/view': 6.43.15 + '@lezer/common': 1.5.5 + + '@codemirror/commands@6.11.1': + dependencies: + '@codemirror/language': 6.13.1 + '@codemirror/state': 6.7.6 + '@codemirror/view': 6.43.15 + '@lezer/common': 1.5.5 + + '@codemirror/language@6.13.1': + dependencies: + '@codemirror/state': 6.7.6 + '@codemirror/streamparser': 6.0.0 + '@codemirror/view': 6.43.15 + '@lezer/common': 1.5.5 + '@lezer/highlight': 1.2.5 + '@lezer/lr': 1.4.12 + style-mod: 4.1.4 + + '@codemirror/legacy-modes@6.5.5': + dependencies: + '@codemirror/streamparser': 6.0.0 + + '@codemirror/lint@6.9.7': + dependencies: + '@codemirror/state': 6.7.6 + '@codemirror/view': 6.43.15 + crelt: 1.0.7 + + '@codemirror/search@6.7.2': + dependencies: + '@codemirror/state': 6.7.6 + '@codemirror/view': 6.43.15 + crelt: 1.0.7 + + '@codemirror/state@6.7.6': + dependencies: + '@marijn/find-cluster-break': 1.0.4 + + '@codemirror/streamparser@6.0.0': {} + + '@codemirror/view@6.43.15': + dependencies: + '@codemirror/state': 6.7.6 + crelt: 1.0.7 + style-mod: 4.1.4 + w3c-keyname: 2.2.8 + '@csstools/css-tokenizer@3.0.4': {} '@date-fns/tz@1.5.0': {} @@ -5675,6 +5828,14 @@ snapshots: dependencies: lib0: 0.2.119 + '@hocuspocus/provider@4.8.0(y-protocols@1.0.7(yjs@13.6.33))(yjs@13.6.33)': + dependencies: + '@hocuspocus/common': 4.8.0 + '@lifeomic/attempt': 3.1.0 + lib0: 0.2.119 + y-protocols: 1.0.7(yjs@13.6.33) + yjs: 13.6.33 + '@hocuspocus/server@4.8.0(srvx@0.11.21)(y-protocols@1.0.7(yjs@13.6.33))(yjs@13.6.33)': dependencies: '@hocuspocus/common': 4.8.0 @@ -5726,6 +5887,20 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@lezer/common@1.5.5': {} + + '@lezer/highlight@1.2.5': + dependencies: + '@lezer/common': 1.5.5 + + '@lezer/lr@1.4.12': + dependencies: + '@lezer/common': 1.5.5 + + '@lifeomic/attempt@3.1.0': {} + + '@marijn/find-cluster-break@1.0.4': {} + '@modelcontextprotocol/sdk@1.29.0(zod@3.25.76)': dependencies: '@hono/node-server': 1.19.14(hono@4.12.29) @@ -7348,6 +7523,8 @@ snapshots: optionalDependencies: typescript: 6.0.3 + crelt@1.0.7: {} + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -9704,6 +9881,8 @@ snapshots: strip-json-comments@3.1.1: {} + style-mod@4.1.4: {} + style-to-object@1.0.14: dependencies: inline-style-parser: 0.2.7 @@ -10009,6 +10188,8 @@ snapshots: transitivePeerDependencies: - msw + w3c-keyname@2.2.8: {} + webidl-conversions@7.0.0: optional: true @@ -10092,6 +10273,13 @@ snapshots: '@oozcitak/util': 10.0.0 js-yaml: 4.1.1 + y-codemirror.next@0.3.6(@codemirror/state@6.7.6)(@codemirror/view@6.43.15)(yjs@13.6.33): + dependencies: + '@codemirror/state': 6.7.6 + '@codemirror/view': 6.43.15 + lib0: 0.2.119 + yjs: 13.6.33 + y-protocols@1.0.7(yjs@13.6.33): dependencies: lib0: 0.2.119 diff --git a/src/features/regulations/editor/code-editor.tsx b/src/features/regulations/editor/code-editor.tsx new file mode 100644 index 0000000..90fe3fe --- /dev/null +++ b/src/features/regulations/editor/code-editor.tsx @@ -0,0 +1,204 @@ +import { closeBrackets, completionKeymap } from "@codemirror/autocomplete" +import { defaultKeymap, indentWithTab } from "@codemirror/commands" +import { bracketMatching, indentOnInput } from "@codemirror/language" +import { type Diagnostic, lintGutter, setDiagnostics } from "@codemirror/lint" +import { highlightSelectionMatches, searchKeymap } from "@codemirror/search" +import { Compartment, EditorState } from "@codemirror/state" +import { + drawSelection, + EditorView, + highlightActiveLine, + highlightActiveLineGutter, + keymap, + lineNumbers, +} from "@codemirror/view" +import { type Ref, useEffect, useImperativeHandle, useRef } from "react" +import { yCollab, yUndoManagerKeymap } from "y-codemirror.next" +import type { Awareness } from "y-protocols/awareness" +import * as Y from "yjs" + +import { cn } from "@/lib/utils" + +import { type CommentRange, commentMarks, setCommentRanges } from "./comment-marks" +import { editorTheme } from "./editor-theme" +import { type DraftContext, latexLanguage, sourceModeMarks } from "./latex-language" +import { visualMode } from "./visual-mode" + +export type EditorMode = "source" | "visual" + +export type CodeEditorHandle = { + /** Selects a range and brings it into view. */ + reveal: (from: number, to: number) => void + focus: () => void +} + +/** + * One CodeMirror view bound to one file's `Y.Text` (y-codemirror.next): edits, remote cursors and undo go through + * Yjs. Switching file mounts a new view (the parent keys it by path); mode, read-only state, diagnostics and comment + * highlights reconfigure the live one. + */ +export function CodeEditor({ + text, + awareness, + readOnly, + mode, + context, + path, + diagnostics, + comments, + onSelection, + onCommentClick, + handle, + className, +}: { + text: Y.Text + awareness: Awareness | null + readOnly: boolean + mode: EditorMode + context: DraftContext + /** The file this view edits, to number its nodes within the whole document. */ + path: string + diagnostics: Diagnostic[] + comments: CommentRange[] + onSelection: (selection: { from: number; to: number }) => void + onCommentClick: (id: string) => void + handle?: Ref + className?: string +}) { + const host = useRef(null) + const view = useRef(null) + const compartments = useRef({ mode: new Compartment(), readOnly: new Compartment() }) + const latest = useRef({ onSelection, onCommentClick, context }) + useEffect(() => { + latest.current = { onSelection, onCommentClick, context } + }) + + // The view lives as long as its Y.Text; the rest reconfigures it below. + useEffect(() => { + if (!host.current) return + const undoManager = new Y.UndoManager(text) + const stableContext: DraftContext = { + allText: () => latest.current.context.allText(), + nodes: () => latest.current.context.nodes(), + nodesOf: (file) => latest.current.context.nodesOf(file), + } + const created = new EditorView({ + parent: host.current, + state: EditorState.create({ + doc: text.toJSON(), + extensions: [ + highlightActiveLine(), + drawSelection(), + indentOnInput(), + bracketMatching(), + closeBrackets(), + highlightSelectionMatches(), + EditorView.lineWrapping, + keymap.of([...yUndoManagerKeymap, ...completionKeymap, ...defaultKeymap, ...searchKeymap, indentWithTab]), + latexLanguage(stableContext), + editorTheme, + compartments.current.mode.of(mode === "visual" ? visualMode(stableContext, path) : SOURCE_MODE), + compartments.current.readOnly.of([EditorState.readOnly.of(readOnly), EditorView.editable.of(!readOnly)]), + yCollab(text, awareness, { undoManager }), + commentMarks((id) => latest.current.onCommentClick(id)), + EditorView.updateListener.of((update) => { + if (update.selectionSet || update.docChanged) { + const { from, to } = update.state.selection.main + latest.current.onSelection({ from, to }) + } + }), + EditorState.phrases.of(ITALIAN_PHRASES), + ], + }), + }) + view.current = created + return () => { + created.destroy() + undoManager.destroy() + view.current = null + } + // Mode and read-only are applied by the effects below; a new view only for a new text or awareness. Undo is + // Yjs's (yCollab's keymap), so it only undoes one's own edits. + }, [text, awareness]) + + useEffect(() => { + view.current?.dispatch({ + effects: compartments.current.mode.reconfigure( + mode === "visual" + ? visualMode( + { + allText: () => latest.current.context.allText(), + nodes: () => latest.current.context.nodes(), + nodesOf: (file) => latest.current.context.nodesOf(file), + }, + path + ) + : SOURCE_MODE + ), + }) + }, [mode, text]) + + useEffect(() => { + view.current?.dispatch({ + effects: compartments.current.readOnly.reconfigure([ + EditorState.readOnly.of(readOnly), + EditorView.editable.of(!readOnly), + ]), + }) + }, [readOnly, text]) + + useEffect(() => { + const current = view.current + if (!current) return + const length = current.state.doc.length + const valid = diagnostics.filter((diagnostic) => diagnostic.from <= length && diagnostic.to <= length) + current.dispatch(setDiagnostics(current.state, valid)) + }, [diagnostics, text]) + + useEffect(() => { + view.current?.dispatch({ effects: setCommentRanges.of(comments) }) + }, [comments, text]) + + useImperativeHandle(handle, () => ({ + reveal(from, to) { + const current = view.current + if (!current) return + const length = current.state.doc.length + current.dispatch({ + selection: { anchor: Math.min(from, length), head: Math.min(to, length) }, + effects: EditorView.scrollIntoView(Math.min(from, length), { y: "center" }), + }) + current.focus() + }, + focus() { + view.current?.focus() + }, + })) + + return
+} + +/** + * Source mode adds line numbers and the problems gutter; the visual mode reads like the document, where hidden + * command lines would leave the numbers out of step. + */ +const SOURCE_MODE = [lineNumbers(), highlightActiveLineGutter(), lintGutter(), sourceModeMarks] + +/** CodeMirror's own words (search panel, completions, lint), in Italian. */ +const ITALIAN_PHRASES = { + Find: "Trova", + Replace: "Sostituisci", + next: "successivo", + previous: "precedente", + all: "tutti", + "match case": "maiuscole/minuscole", + "by word": "parola intera", + regexp: "espressione regolare", + replace: "sostituisci", + "replace all": "sostituisci tutti", + close: "chiudi", + "current match": "corrispondenza attuale", + "on line": "alla riga", + Diagnostics: "Problemi", + "No diagnostics": "Nessun problema", +} diff --git a/src/features/regulations/editor/comment-marks.ts b/src/features/regulations/editor/comment-marks.ts new file mode 100644 index 0000000..18ec766 --- /dev/null +++ b/src/features/regulations/editor/comment-marks.ts @@ -0,0 +1,77 @@ +import { type Extension, StateEffect, StateField } from "@codemirror/state" +import { Decoration, type DecorationSet, EditorView } from "@codemirror/view" +import * as Y from "yjs" + +import type { CommentThread } from "../regulations.contract" + +/** + * Comment threads anchored to ranges of a file's `Y.Text` (Yjs relative positions, as the backend stores them), drawn + * as highlights in the editor. A click on one selects its thread in the panel. + */ + +export type CommentRange = { id: string; from: number; to: number; active: boolean; resolved: boolean } + +export const setCommentRanges = StateEffect.define() + +function decorations(ranges: CommentRange[], length: number) { + return Decoration.set( + ranges + .filter((range) => !range.resolved && range.from < range.to && range.to <= length) + .map((range) => + Decoration.mark({ + class: range.active ? "cm-pn-comment cm-pn-comment-active" : "cm-pn-comment", + attributes: { "data-comment": range.id }, + }).range(range.from, range.to) + ), + true + ) +} + +const commentField = StateField.define({ + create: () => Decoration.none, + update(value, transaction) { + let next = value.map(transaction.changes) + for (const effect of transaction.effects) + if (effect.is(setCommentRanges)) next = decorations(effect.value, transaction.state.doc.length) + return next + }, + provide: (field) => EditorView.decorations.from(field), +}) + +export function commentMarks(onSelect: (id: string) => void): Extension { + return [ + commentField, + EditorView.domEventHandlers({ + click(event) { + const target = event.target instanceof Element ? event.target.closest("[data-comment]") : null + if (target?.dataset.comment) onSelect(target.dataset.comment) + return false + }, + }), + ] +} + +export function encodePosition(text: Y.Text, index: number, assoc: number) { + const bytes = Y.encodeRelativePosition(Y.createRelativePositionFromTypeIndex(text, index, assoc)) + let binary = "" + for (const byte of bytes) binary += String.fromCharCode(byte) + return btoa(binary) +} + +function decodePosition(document: Y.Doc, encoded: string) { + try { + const bytes = Uint8Array.from(atob(encoded), (char) => char.charCodeAt(0)) + return Y.createAbsolutePositionFromRelativePosition(Y.decodeRelativePosition(bytes), document) + } catch (error) { + console.error(error) + return null + } +} + +/** Where a thread's anchor sits now in `file`'s text, or null when the text it marked is gone. */ +export function resolveAnchor(document: Y.Doc, text: Y.Text, thread: Pick) { + const start = decodePosition(document, thread.anchor.start) + const end = decodePosition(document, thread.anchor.end) + if (!start || !end || start.type !== text || end.type !== text || end.index <= start.index) return null + return { from: start.index, to: end.index } +} diff --git a/src/features/regulations/editor/comments-panel.tsx b/src/features/regulations/editor/comments-panel.tsx new file mode 100644 index 0000000..1c8cc0f --- /dev/null +++ b/src/features/regulations/editor/comments-panel.tsx @@ -0,0 +1,429 @@ +import { useServerFn } from "@tanstack/react-start" +import { CheckCheck, MessageSquare, RotateCcw, Trash2, X } from "lucide-react" +import { useCallback, useEffect, useState } from "react" + +import { + ConfirmDialog, + fieldControl, + IconButton, + InlineAlert, + LoadingButton, + SegmentedControl, +} from "@/components/primitives" +import { appToast } from "@/components/shell" +import { Button } from "@/components/ui/button" +import { Textarea } from "@/components/ui/textarea" +import { cn } from "@/lib/utils" + +import { + createComment, + deleteComment, + reopenComment, + replyComment, + resolveComment, +} from "../regulations-editor.functions" +import { formatDayTime } from "../regulations-format" +import { failureMessage } from "../regulations-hooks" +import type { CommentThread } from "../regulations.contract" +import { getComments } from "../regulations.functions" + +/** The text selected for a new comment, with its anchor in the file's `Y.Text`. */ +export type PendingAnchor = { file: string; start: string; end: string; quote: string } + +const REFRESH_MS = 15_000 + +/** The draft's comment threads, reloaded every few seconds and after every change. */ +export function useCommentThreads(draftId: string) { + const load = useServerFn(getComments) + const [threads, setThreads] = useState(null) + const [failed, setFailed] = useState(false) + const refresh = useCallback(async () => { + try { + setThreads(await load({ data: { draftId } })) + setFailed(false) + } catch (error) { + console.error(error) + setFailed(true) + } + }, [draftId, load]) + useEffect(() => { + void refresh() + const timer = setInterval(() => void refresh(), REFRESH_MS) + return () => clearInterval(timer) + }, [refresh]) + return { threads, failed, refresh } +} + +/** + * Comments on ranges of the text: threads with replies, resolved or open. No mentions. Commenters and editors write; + * anyone deletes their own comments, the Direttivo any. + */ +export function CommentsPanel({ + draftId, + threads, + failed, + refresh, + canComment, + canModerate, + viewerId, + pending, + onCancelPending, + activeId, + onSelect, + orphaned, + onClose, +}: { + draftId: string + threads: CommentThread[] | null + failed: boolean + refresh: () => Promise + canComment: boolean + canModerate: boolean + viewerId: string + pending: PendingAnchor | null + onCancelPending: () => void + activeId: string | null + onSelect: (thread: CommentThread) => void + /** Threads whose text was deleted: shown with their quote only. */ + orphaned: Set + onClose: () => void +}) { + const create = useServerFn(createComment) + const reply = useServerFn(replyComment) + const resolve = useServerFn(resolveComment) + const reopen = useServerFn(reopenComment) + const remove = useServerFn(deleteComment) + const [filter, setFilter] = useState<"open" | "resolved">("open") + const [body, setBody] = useState("") + const [saving, setSaving] = useState(false) + const [error, setError] = useState(null) + const [deleting, setDeleting] = useState<{ id: string; reply: boolean } | null>(null) + + const open = threads?.filter((thread) => thread.resolvedAt === null) ?? [] + const resolved = threads?.filter((thread) => thread.resolvedAt !== null) ?? [] + const shown = filter === "open" ? open : resolved + + async function submitNew() { + if (!pending || !body.trim()) return + setSaving(true) + setError(null) + try { + await create({ + data: { + draftId, + anchor: { file: pending.file, start: pending.start, end: pending.end }, + quote: pending.quote.slice(0, 2000), + body: body.trim(), + }, + }) + setBody("") + onCancelPending() + setFilter("open") + await refresh() + } catch (caught) { + console.error(caught) + setError(failureMessage(caught, "Non è stato possibile aggiungere il commento.")) + } finally { + setSaving(false) + } + } + + async function act(run: () => Promise, failure: string) { + try { + await run() + await refresh() + } catch (caught) { + console.error(caught) + appToast.error(failureMessage(caught, failure)) + } + } + + return ( +
+
+

Commenti

+ +
+
+ + {pending ? ( +
{ + event.preventDefault() + void submitNew() + }} + > +
+ {pending.quote} +
+ +