tfrere HF Staff commited on
Commit
561e6f0
·
0 Parent(s):

chore: initial commit

Browse files

Collaborative article editor with TipTap, Yjs, and HF integration.

Features:
- Real-time collaborative editing (Hocuspocus + Yjs)
- Notion-like block handles, slash commands, bubble toolbar
- Academic citations (Citation.js + DOI/BibTeX)
- KaTeX math, code highlighting, tables
- Custom component system (accordion, note, quote, stack, etc.)
- LLM agent sidebar (OpenRouter)
- Image upload with HF dataset storage
- MDX export
- Frontmatter management (title, authors, affiliations)
- Comments system

Made-with: Cursor

This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .gitignore +25 -0
  2. Dockerfile +23 -0
  3. README.md +34 -0
  4. backend/package-lock.json +2326 -0
  5. backend/package.json +38 -0
  6. backend/src/agent/chat.ts +74 -0
  7. backend/src/agent/system-prompt.ts +93 -0
  8. backend/src/agent/tools.ts +118 -0
  9. backend/src/citations.ts +104 -0
  10. backend/src/hf-storage.ts +97 -0
  11. backend/src/server.ts +163 -0
  12. backend/tsconfig.json +14 -0
  13. docs/embed-studio.md +214 -0
  14. frontend/index.html +12 -0
  15. frontend/package-lock.json +0 -0
  16. frontend/package.json +51 -0
  17. frontend/src/App.tsx +321 -0
  18. frontend/src/components/ChatPanel.tsx +319 -0
  19. frontend/src/components/CommentDialog.tsx +80 -0
  20. frontend/src/components/CommentsSidebar.tsx +233 -0
  21. frontend/src/editor.css +1076 -0
  22. frontend/src/editor/BibliographyView.tsx +108 -0
  23. frontend/src/editor/BlockHandle.tsx +294 -0
  24. frontend/src/editor/BubbleToolbar.tsx +151 -0
  25. frontend/src/editor/CitationPanel.tsx +213 -0
  26. frontend/src/editor/CitationView.tsx +158 -0
  27. frontend/src/editor/Editor.tsx +274 -0
  28. frontend/src/editor/FloatingActions.tsx +143 -0
  29. frontend/src/editor/FootnoteView.tsx +107 -0
  30. frontend/src/editor/GlossaryView.tsx +54 -0
  31. frontend/src/editor/ImageUploadView.tsx +153 -0
  32. frontend/src/editor/SlashMenu.tsx +240 -0
  33. frontend/src/editor/StackView.tsx +104 -0
  34. frontend/src/editor/comments.ts +61 -0
  35. frontend/src/editor/components/AtomicView.tsx +144 -0
  36. frontend/src/editor/components/WrapperView.tsx +207 -0
  37. frontend/src/editor/components/factory.ts +134 -0
  38. frontend/src/editor/components/index.ts +5 -0
  39. frontend/src/editor/components/registry.ts +163 -0
  40. frontend/src/editor/components/serializers.ts +41 -0
  41. frontend/src/editor/components/slash-items.ts +27 -0
  42. frontend/src/editor/default-content.ts +233 -0
  43. frontend/src/editor/extensions/bibliography.ts +44 -0
  44. frontend/src/editor/extensions/citation.ts +59 -0
  45. frontend/src/editor/extensions/collaboration-undo.ts +87 -0
  46. frontend/src/editor/extensions/comment.ts +74 -0
  47. frontend/src/editor/extensions/footnote.ts +52 -0
  48. frontend/src/editor/extensions/glossary.ts +53 -0
  49. frontend/src/editor/extensions/image-upload.ts +43 -0
  50. frontend/src/editor/extensions/slash-commands.ts +37 -0
.gitignore ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Dependencies
2
+ node_modules/
3
+
4
+ # Build outputs
5
+ frontend/dist/
6
+ backend/dist/
7
+
8
+ # Local data (Yjs documents, uploads)
9
+ backend/data/
10
+
11
+ # Environment
12
+ .env
13
+ .env.local
14
+
15
+ # OS
16
+ .DS_Store
17
+ Thumbs.db
18
+
19
+ # IDE
20
+ .vscode/
21
+ .idea/
22
+
23
+ # Logs
24
+ *.log
25
+ npm-debug.log*
Dockerfile ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM node:20-slim AS frontend-build
2
+
3
+ WORKDIR /app/frontend
4
+ COPY frontend/package.json frontend/package-lock.json* ./
5
+ RUN npm install
6
+ COPY frontend/ ./
7
+ RUN npm run build
8
+
9
+ FROM node:20-slim
10
+
11
+ WORKDIR /app
12
+ COPY backend/package.json backend/package-lock.json* ./
13
+ RUN npm install --omit=dev
14
+ COPY backend/ ./
15
+ RUN npx tsc
16
+
17
+ COPY --from=frontend-build /app/frontend/dist ./frontend-dist
18
+
19
+ ENV PORT=8080
20
+ ENV NODE_ENV=production
21
+ EXPOSE 8080
22
+
23
+ CMD ["node", "dist/server.js"]
README.md ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Collab Editor
3
+ emoji: ✏️
4
+ colorFrom: purple
5
+ colorTo: blue
6
+ sdk: docker
7
+ app_port: 8080
8
+ pinned: false
9
+ ---
10
+
11
+ # Collab Editor
12
+
13
+ Collaborative real-time article editor with MDX export. Built with TipTap, Yjs, and Hocuspocus.
14
+
15
+ ## Features
16
+
17
+ - Real-time collaborative editing (multiple cursors)
18
+ - Rich text: headings, bold, italic, lists, blockquotes, code blocks
19
+ - Export to MDX format
20
+ - Dark theme
21
+
22
+ ## Development
23
+
24
+ ```bash
25
+ # Backend
26
+ cd backend && npm install && npm run dev
27
+
28
+ # Frontend (separate terminal)
29
+ cd frontend && npm install && npm run dev
30
+ ```
31
+
32
+ Open http://localhost:5173. Open a second tab to see collaboration in action.
33
+
34
+ Use `?doc=my-article` to create/join a specific document.
backend/package-lock.json ADDED
@@ -0,0 +1,2326 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "collab-editor-backend",
3
+ "version": "0.1.0",
4
+ "lockfileVersion": 3,
5
+ "requires": true,
6
+ "packages": {
7
+ "": {
8
+ "name": "collab-editor-backend",
9
+ "version": "0.1.0",
10
+ "dependencies": {
11
+ "@ai-sdk/openai": "^3.0.52",
12
+ "@ai-sdk/openai-compatible": "^2.0.41",
13
+ "@citation-js/core": "^0.7.21",
14
+ "@citation-js/plugin-bibtex": "^0.7.21",
15
+ "@citation-js/plugin-csl": "^0.7.22",
16
+ "@citation-js/plugin-doi": "^0.7.21",
17
+ "@hocuspocus/extension-database": "^3.4.4",
18
+ "@hocuspocus/server": "^3.4.4",
19
+ "@huggingface/hub": "^2.11.0",
20
+ "@openrouter/ai-sdk-provider": "^2.5.1",
21
+ "ai": "^6.0.158",
22
+ "dotenv": "^17.4.1",
23
+ "express": "^4.21.0",
24
+ "express-ws": "^5.0.2",
25
+ "multer": "^2.1.1",
26
+ "yjs": "^13.6.0",
27
+ "zod": "^4.3.6"
28
+ },
29
+ "devDependencies": {
30
+ "@types/express": "^5.0.0",
31
+ "@types/express-ws": "^3.0.5",
32
+ "@types/multer": "^2.1.0",
33
+ "@types/node": "^22.0.0",
34
+ "tsx": "^4.19.0",
35
+ "typescript": "^5.6.0"
36
+ }
37
+ },
38
+ "node_modules/@ai-sdk/gateway": {
39
+ "version": "3.0.95",
40
+ "resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-3.0.95.tgz",
41
+ "integrity": "sha512-ZmUNNbZl3V42xwQzPaNUi+s8eqR2lnrxf0bvB6YbLXpLjHYv0k2Y78t12cNOfY0bxGeuVVTLyk856uLuQIuXEQ==",
42
+ "license": "Apache-2.0",
43
+ "dependencies": {
44
+ "@ai-sdk/provider": "3.0.8",
45
+ "@ai-sdk/provider-utils": "4.0.23",
46
+ "@vercel/oidc": "3.1.0"
47
+ },
48
+ "engines": {
49
+ "node": ">=18"
50
+ },
51
+ "peerDependencies": {
52
+ "zod": "^3.25.76 || ^4.1.8"
53
+ }
54
+ },
55
+ "node_modules/@ai-sdk/openai": {
56
+ "version": "3.0.52",
57
+ "resolved": "https://registry.npmjs.org/@ai-sdk/openai/-/openai-3.0.52.tgz",
58
+ "integrity": "sha512-4Rr8NCGmfWTz6DCUvixn9UmyZcMatiHn0zWoMzI3JCUe9R1P/vsPOpCBALKoSzVYOjyJnhtnVIbfUKujcS39uw==",
59
+ "license": "Apache-2.0",
60
+ "dependencies": {
61
+ "@ai-sdk/provider": "3.0.8",
62
+ "@ai-sdk/provider-utils": "4.0.23"
63
+ },
64
+ "engines": {
65
+ "node": ">=18"
66
+ },
67
+ "peerDependencies": {
68
+ "zod": "^3.25.76 || ^4.1.8"
69
+ }
70
+ },
71
+ "node_modules/@ai-sdk/openai-compatible": {
72
+ "version": "2.0.41",
73
+ "resolved": "https://registry.npmjs.org/@ai-sdk/openai-compatible/-/openai-compatible-2.0.41.tgz",
74
+ "integrity": "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g==",
75
+ "license": "Apache-2.0",
76
+ "dependencies": {
77
+ "@ai-sdk/provider": "3.0.8",
78
+ "@ai-sdk/provider-utils": "4.0.23"
79
+ },
80
+ "engines": {
81
+ "node": ">=18"
82
+ },
83
+ "peerDependencies": {
84
+ "zod": "^3.25.76 || ^4.1.8"
85
+ }
86
+ },
87
+ "node_modules/@ai-sdk/provider": {
88
+ "version": "3.0.8",
89
+ "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-3.0.8.tgz",
90
+ "integrity": "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ==",
91
+ "license": "Apache-2.0",
92
+ "dependencies": {
93
+ "json-schema": "^0.4.0"
94
+ },
95
+ "engines": {
96
+ "node": ">=18"
97
+ }
98
+ },
99
+ "node_modules/@ai-sdk/provider-utils": {
100
+ "version": "4.0.23",
101
+ "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-4.0.23.tgz",
102
+ "integrity": "sha512-z8GlDaCmRSDlqkMF2f4/RFgWxdarvIbyuk+m6WXT1LYgsnGiXRJGTD2Z1+SDl3LqtFuRtGX1aghYvQLoHL/9pg==",
103
+ "license": "Apache-2.0",
104
+ "dependencies": {
105
+ "@ai-sdk/provider": "3.0.8",
106
+ "@standard-schema/spec": "^1.1.0",
107
+ "eventsource-parser": "^3.0.6"
108
+ },
109
+ "engines": {
110
+ "node": ">=18"
111
+ },
112
+ "peerDependencies": {
113
+ "zod": "^3.25.76 || ^4.1.8"
114
+ }
115
+ },
116
+ "node_modules/@citation-js/core": {
117
+ "version": "0.7.21",
118
+ "resolved": "https://registry.npmjs.org/@citation-js/core/-/core-0.7.21.tgz",
119
+ "integrity": "sha512-Vobv2/Yfnn6C6BVO/pvj7madQ7Mfzl83/jAWwixbemGF6ZThhGMz8++FD9hWHyHXDMYuLGa6fK68c2VsolZmTA==",
120
+ "license": "MIT",
121
+ "peer": true,
122
+ "dependencies": {
123
+ "@citation-js/date": "^0.5.0",
124
+ "@citation-js/name": "^0.4.2",
125
+ "fetch-ponyfill": "^7.1.0",
126
+ "sync-fetch": "^0.4.1"
127
+ },
128
+ "engines": {
129
+ "node": ">=16.0.0"
130
+ }
131
+ },
132
+ "node_modules/@citation-js/date": {
133
+ "version": "0.5.1",
134
+ "resolved": "https://registry.npmjs.org/@citation-js/date/-/date-0.5.1.tgz",
135
+ "integrity": "sha512-1iDKAZ4ie48PVhovsOXQ+C6o55dWJloXqtznnnKy6CltJBQLIuLLuUqa8zlIvma0ZigjVjgDUhnVaNU1MErtZw==",
136
+ "license": "MIT",
137
+ "engines": {
138
+ "node": ">=10.0.0"
139
+ }
140
+ },
141
+ "node_modules/@citation-js/name": {
142
+ "version": "0.4.2",
143
+ "resolved": "https://registry.npmjs.org/@citation-js/name/-/name-0.4.2.tgz",
144
+ "integrity": "sha512-brSPsjs2fOVzSnARLKu0qncn6suWjHVQtrqSUrnqyaRH95r/Ad4wPF5EsoWr+Dx8HzkCGb/ogmoAzfCsqlTwTQ==",
145
+ "license": "MIT",
146
+ "engines": {
147
+ "node": ">=6"
148
+ }
149
+ },
150
+ "node_modules/@citation-js/plugin-bibtex": {
151
+ "version": "0.7.21",
152
+ "resolved": "https://registry.npmjs.org/@citation-js/plugin-bibtex/-/plugin-bibtex-0.7.21.tgz",
153
+ "integrity": "sha512-O008pSsJgiYKn4+7gAWrbNpNdUH++aMeYmZaJ2oFQ8X1tcY5jNBxJcr0zZojNtUi5CVOaXXHQ0yIifoUhuF2Vg==",
154
+ "license": "MIT",
155
+ "dependencies": {
156
+ "@citation-js/date": "^0.5.0",
157
+ "@citation-js/name": "^0.4.2",
158
+ "moo": "^0.5.1"
159
+ },
160
+ "engines": {
161
+ "node": ">=16.0.0"
162
+ },
163
+ "peerDependencies": {
164
+ "@citation-js/core": "^0.7.0"
165
+ }
166
+ },
167
+ "node_modules/@citation-js/plugin-csl": {
168
+ "version": "0.7.22",
169
+ "resolved": "https://registry.npmjs.org/@citation-js/plugin-csl/-/plugin-csl-0.7.22.tgz",
170
+ "integrity": "sha512-/rGdtbeP3nS4uZDdEbQUHT8PrUcIs0da2t+sWMKYXoOhXQqfw3oJJ7p4tUD+R8lptyIR5Eq20/DFk/kQDdLpYg==",
171
+ "license": "MIT",
172
+ "dependencies": {
173
+ "@citation-js/date": "^0.5.0",
174
+ "citeproc": "^2.4.6"
175
+ },
176
+ "engines": {
177
+ "node": ">=16.0.0"
178
+ },
179
+ "peerDependencies": {
180
+ "@citation-js/core": "^0.7.0"
181
+ }
182
+ },
183
+ "node_modules/@citation-js/plugin-doi": {
184
+ "version": "0.7.21",
185
+ "resolved": "https://registry.npmjs.org/@citation-js/plugin-doi/-/plugin-doi-0.7.21.tgz",
186
+ "integrity": "sha512-KmViSt6CjfQS5dPHlHI6m6ahfshHUivSd8jQ0z5SvZCj2mKMH+t85tY57/880Xj9hVoXdRLmh53Uf0b+craNHA==",
187
+ "license": "MIT",
188
+ "dependencies": {
189
+ "@citation-js/date": "^0.5.0"
190
+ },
191
+ "engines": {
192
+ "node": ">=16.0.0"
193
+ },
194
+ "peerDependencies": {
195
+ "@citation-js/core": "^0.7.0"
196
+ }
197
+ },
198
+ "node_modules/@esbuild/aix-ppc64": {
199
+ "version": "0.27.7",
200
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz",
201
+ "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==",
202
+ "cpu": [
203
+ "ppc64"
204
+ ],
205
+ "dev": true,
206
+ "license": "MIT",
207
+ "optional": true,
208
+ "os": [
209
+ "aix"
210
+ ],
211
+ "engines": {
212
+ "node": ">=18"
213
+ }
214
+ },
215
+ "node_modules/@esbuild/android-arm": {
216
+ "version": "0.27.7",
217
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz",
218
+ "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==",
219
+ "cpu": [
220
+ "arm"
221
+ ],
222
+ "dev": true,
223
+ "license": "MIT",
224
+ "optional": true,
225
+ "os": [
226
+ "android"
227
+ ],
228
+ "engines": {
229
+ "node": ">=18"
230
+ }
231
+ },
232
+ "node_modules/@esbuild/android-arm64": {
233
+ "version": "0.27.7",
234
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz",
235
+ "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==",
236
+ "cpu": [
237
+ "arm64"
238
+ ],
239
+ "dev": true,
240
+ "license": "MIT",
241
+ "optional": true,
242
+ "os": [
243
+ "android"
244
+ ],
245
+ "engines": {
246
+ "node": ">=18"
247
+ }
248
+ },
249
+ "node_modules/@esbuild/android-x64": {
250
+ "version": "0.27.7",
251
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz",
252
+ "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==",
253
+ "cpu": [
254
+ "x64"
255
+ ],
256
+ "dev": true,
257
+ "license": "MIT",
258
+ "optional": true,
259
+ "os": [
260
+ "android"
261
+ ],
262
+ "engines": {
263
+ "node": ">=18"
264
+ }
265
+ },
266
+ "node_modules/@esbuild/darwin-arm64": {
267
+ "version": "0.27.7",
268
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz",
269
+ "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==",
270
+ "cpu": [
271
+ "arm64"
272
+ ],
273
+ "dev": true,
274
+ "license": "MIT",
275
+ "optional": true,
276
+ "os": [
277
+ "darwin"
278
+ ],
279
+ "engines": {
280
+ "node": ">=18"
281
+ }
282
+ },
283
+ "node_modules/@esbuild/darwin-x64": {
284
+ "version": "0.27.7",
285
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz",
286
+ "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==",
287
+ "cpu": [
288
+ "x64"
289
+ ],
290
+ "dev": true,
291
+ "license": "MIT",
292
+ "optional": true,
293
+ "os": [
294
+ "darwin"
295
+ ],
296
+ "engines": {
297
+ "node": ">=18"
298
+ }
299
+ },
300
+ "node_modules/@esbuild/freebsd-arm64": {
301
+ "version": "0.27.7",
302
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz",
303
+ "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==",
304
+ "cpu": [
305
+ "arm64"
306
+ ],
307
+ "dev": true,
308
+ "license": "MIT",
309
+ "optional": true,
310
+ "os": [
311
+ "freebsd"
312
+ ],
313
+ "engines": {
314
+ "node": ">=18"
315
+ }
316
+ },
317
+ "node_modules/@esbuild/freebsd-x64": {
318
+ "version": "0.27.7",
319
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz",
320
+ "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==",
321
+ "cpu": [
322
+ "x64"
323
+ ],
324
+ "dev": true,
325
+ "license": "MIT",
326
+ "optional": true,
327
+ "os": [
328
+ "freebsd"
329
+ ],
330
+ "engines": {
331
+ "node": ">=18"
332
+ }
333
+ },
334
+ "node_modules/@esbuild/linux-arm": {
335
+ "version": "0.27.7",
336
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz",
337
+ "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==",
338
+ "cpu": [
339
+ "arm"
340
+ ],
341
+ "dev": true,
342
+ "license": "MIT",
343
+ "optional": true,
344
+ "os": [
345
+ "linux"
346
+ ],
347
+ "engines": {
348
+ "node": ">=18"
349
+ }
350
+ },
351
+ "node_modules/@esbuild/linux-arm64": {
352
+ "version": "0.27.7",
353
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz",
354
+ "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==",
355
+ "cpu": [
356
+ "arm64"
357
+ ],
358
+ "dev": true,
359
+ "license": "MIT",
360
+ "optional": true,
361
+ "os": [
362
+ "linux"
363
+ ],
364
+ "engines": {
365
+ "node": ">=18"
366
+ }
367
+ },
368
+ "node_modules/@esbuild/linux-ia32": {
369
+ "version": "0.27.7",
370
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz",
371
+ "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==",
372
+ "cpu": [
373
+ "ia32"
374
+ ],
375
+ "dev": true,
376
+ "license": "MIT",
377
+ "optional": true,
378
+ "os": [
379
+ "linux"
380
+ ],
381
+ "engines": {
382
+ "node": ">=18"
383
+ }
384
+ },
385
+ "node_modules/@esbuild/linux-loong64": {
386
+ "version": "0.27.7",
387
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz",
388
+ "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==",
389
+ "cpu": [
390
+ "loong64"
391
+ ],
392
+ "dev": true,
393
+ "license": "MIT",
394
+ "optional": true,
395
+ "os": [
396
+ "linux"
397
+ ],
398
+ "engines": {
399
+ "node": ">=18"
400
+ }
401
+ },
402
+ "node_modules/@esbuild/linux-mips64el": {
403
+ "version": "0.27.7",
404
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz",
405
+ "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==",
406
+ "cpu": [
407
+ "mips64el"
408
+ ],
409
+ "dev": true,
410
+ "license": "MIT",
411
+ "optional": true,
412
+ "os": [
413
+ "linux"
414
+ ],
415
+ "engines": {
416
+ "node": ">=18"
417
+ }
418
+ },
419
+ "node_modules/@esbuild/linux-ppc64": {
420
+ "version": "0.27.7",
421
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz",
422
+ "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==",
423
+ "cpu": [
424
+ "ppc64"
425
+ ],
426
+ "dev": true,
427
+ "license": "MIT",
428
+ "optional": true,
429
+ "os": [
430
+ "linux"
431
+ ],
432
+ "engines": {
433
+ "node": ">=18"
434
+ }
435
+ },
436
+ "node_modules/@esbuild/linux-riscv64": {
437
+ "version": "0.27.7",
438
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz",
439
+ "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==",
440
+ "cpu": [
441
+ "riscv64"
442
+ ],
443
+ "dev": true,
444
+ "license": "MIT",
445
+ "optional": true,
446
+ "os": [
447
+ "linux"
448
+ ],
449
+ "engines": {
450
+ "node": ">=18"
451
+ }
452
+ },
453
+ "node_modules/@esbuild/linux-s390x": {
454
+ "version": "0.27.7",
455
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz",
456
+ "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==",
457
+ "cpu": [
458
+ "s390x"
459
+ ],
460
+ "dev": true,
461
+ "license": "MIT",
462
+ "optional": true,
463
+ "os": [
464
+ "linux"
465
+ ],
466
+ "engines": {
467
+ "node": ">=18"
468
+ }
469
+ },
470
+ "node_modules/@esbuild/linux-x64": {
471
+ "version": "0.27.7",
472
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz",
473
+ "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==",
474
+ "cpu": [
475
+ "x64"
476
+ ],
477
+ "dev": true,
478
+ "license": "MIT",
479
+ "optional": true,
480
+ "os": [
481
+ "linux"
482
+ ],
483
+ "engines": {
484
+ "node": ">=18"
485
+ }
486
+ },
487
+ "node_modules/@esbuild/netbsd-arm64": {
488
+ "version": "0.27.7",
489
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz",
490
+ "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==",
491
+ "cpu": [
492
+ "arm64"
493
+ ],
494
+ "dev": true,
495
+ "license": "MIT",
496
+ "optional": true,
497
+ "os": [
498
+ "netbsd"
499
+ ],
500
+ "engines": {
501
+ "node": ">=18"
502
+ }
503
+ },
504
+ "node_modules/@esbuild/netbsd-x64": {
505
+ "version": "0.27.7",
506
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz",
507
+ "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==",
508
+ "cpu": [
509
+ "x64"
510
+ ],
511
+ "dev": true,
512
+ "license": "MIT",
513
+ "optional": true,
514
+ "os": [
515
+ "netbsd"
516
+ ],
517
+ "engines": {
518
+ "node": ">=18"
519
+ }
520
+ },
521
+ "node_modules/@esbuild/openbsd-arm64": {
522
+ "version": "0.27.7",
523
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz",
524
+ "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==",
525
+ "cpu": [
526
+ "arm64"
527
+ ],
528
+ "dev": true,
529
+ "license": "MIT",
530
+ "optional": true,
531
+ "os": [
532
+ "openbsd"
533
+ ],
534
+ "engines": {
535
+ "node": ">=18"
536
+ }
537
+ },
538
+ "node_modules/@esbuild/openbsd-x64": {
539
+ "version": "0.27.7",
540
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz",
541
+ "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==",
542
+ "cpu": [
543
+ "x64"
544
+ ],
545
+ "dev": true,
546
+ "license": "MIT",
547
+ "optional": true,
548
+ "os": [
549
+ "openbsd"
550
+ ],
551
+ "engines": {
552
+ "node": ">=18"
553
+ }
554
+ },
555
+ "node_modules/@esbuild/openharmony-arm64": {
556
+ "version": "0.27.7",
557
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz",
558
+ "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==",
559
+ "cpu": [
560
+ "arm64"
561
+ ],
562
+ "dev": true,
563
+ "license": "MIT",
564
+ "optional": true,
565
+ "os": [
566
+ "openharmony"
567
+ ],
568
+ "engines": {
569
+ "node": ">=18"
570
+ }
571
+ },
572
+ "node_modules/@esbuild/sunos-x64": {
573
+ "version": "0.27.7",
574
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz",
575
+ "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==",
576
+ "cpu": [
577
+ "x64"
578
+ ],
579
+ "dev": true,
580
+ "license": "MIT",
581
+ "optional": true,
582
+ "os": [
583
+ "sunos"
584
+ ],
585
+ "engines": {
586
+ "node": ">=18"
587
+ }
588
+ },
589
+ "node_modules/@esbuild/win32-arm64": {
590
+ "version": "0.27.7",
591
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz",
592
+ "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==",
593
+ "cpu": [
594
+ "arm64"
595
+ ],
596
+ "dev": true,
597
+ "license": "MIT",
598
+ "optional": true,
599
+ "os": [
600
+ "win32"
601
+ ],
602
+ "engines": {
603
+ "node": ">=18"
604
+ }
605
+ },
606
+ "node_modules/@esbuild/win32-ia32": {
607
+ "version": "0.27.7",
608
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz",
609
+ "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==",
610
+ "cpu": [
611
+ "ia32"
612
+ ],
613
+ "dev": true,
614
+ "license": "MIT",
615
+ "optional": true,
616
+ "os": [
617
+ "win32"
618
+ ],
619
+ "engines": {
620
+ "node": ">=18"
621
+ }
622
+ },
623
+ "node_modules/@esbuild/win32-x64": {
624
+ "version": "0.27.7",
625
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz",
626
+ "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==",
627
+ "cpu": [
628
+ "x64"
629
+ ],
630
+ "dev": true,
631
+ "license": "MIT",
632
+ "optional": true,
633
+ "os": [
634
+ "win32"
635
+ ],
636
+ "engines": {
637
+ "node": ">=18"
638
+ }
639
+ },
640
+ "node_modules/@hocuspocus/common": {
641
+ "version": "3.4.4",
642
+ "resolved": "https://registry.npmjs.org/@hocuspocus/common/-/common-3.4.4.tgz",
643
+ "integrity": "sha512-RykIJ0tsHHMP4Xk+4UCbc7SO5LgGxGUSTdbh6anJEsaALAyqinf1Nn5HYuMjLPolAmsar1v++m9zufR09NLpXA==",
644
+ "license": "MIT",
645
+ "dependencies": {
646
+ "lib0": "^0.2.87"
647
+ }
648
+ },
649
+ "node_modules/@hocuspocus/extension-database": {
650
+ "version": "3.4.4",
651
+ "resolved": "https://registry.npmjs.org/@hocuspocus/extension-database/-/extension-database-3.4.4.tgz",
652
+ "integrity": "sha512-z7iq2Dw+GOp4aQq7ys3PD0BA++7tQdXBsSHZ+8mkAbxfTDzjzQ576TphxPiXXC1WQ7yjeFXq03xp/KLIhg3Pyg==",
653
+ "license": "MIT",
654
+ "dependencies": {
655
+ "@hocuspocus/server": "^3.4.4"
656
+ },
657
+ "peerDependencies": {
658
+ "yjs": "^13.6.8"
659
+ }
660
+ },
661
+ "node_modules/@hocuspocus/server": {
662
+ "version": "3.4.4",
663
+ "resolved": "https://registry.npmjs.org/@hocuspocus/server/-/server-3.4.4.tgz",
664
+ "integrity": "sha512-UV+oaONAejOzeYgUygNcgsc8RdZvSokVvAxluZJIisLACpRO/VsseQ5lWKDRwLd7Fn6+rHWDH3hGuQ1fdX1Ycg==",
665
+ "license": "MIT",
666
+ "dependencies": {
667
+ "@hocuspocus/common": "^3.4.4",
668
+ "async-lock": "^1.3.1",
669
+ "async-mutex": "^0.5.0",
670
+ "kleur": "^4.1.4",
671
+ "lib0": "^0.2.47",
672
+ "ws": "^8.5.0"
673
+ },
674
+ "peerDependencies": {
675
+ "y-protocols": "^1.0.6",
676
+ "yjs": "^13.6.8"
677
+ }
678
+ },
679
+ "node_modules/@huggingface/hub": {
680
+ "version": "2.11.0",
681
+ "resolved": "https://registry.npmjs.org/@huggingface/hub/-/hub-2.11.0.tgz",
682
+ "integrity": "sha512-WS6QGaXYeBVFlaB4SOn6z4LGUpLB5kRZNL08uUni4izX353KxiwwZMK5+/AWX86MJh8SMZNa/JFcvFCcQsbszQ==",
683
+ "license": "MIT",
684
+ "dependencies": {
685
+ "@huggingface/tasks": "^0.19.90"
686
+ },
687
+ "bin": {
688
+ "hfjs": "dist/cli.js"
689
+ },
690
+ "engines": {
691
+ "node": ">=18"
692
+ },
693
+ "optionalDependencies": {
694
+ "cli-progress": "^3.12.0"
695
+ }
696
+ },
697
+ "node_modules/@huggingface/tasks": {
698
+ "version": "0.19.90",
699
+ "resolved": "https://registry.npmjs.org/@huggingface/tasks/-/tasks-0.19.90.tgz",
700
+ "integrity": "sha512-nfV9luJbvwGQ/5oKXkKhCV9h4X7mwh1YaGG3ORd6UMLDSwr1OFSSatcBX0O9OtBtmNK19aGSjbLFqqgcIR6+IA==",
701
+ "license": "MIT"
702
+ },
703
+ "node_modules/@openrouter/ai-sdk-provider": {
704
+ "version": "2.5.1",
705
+ "resolved": "https://registry.npmjs.org/@openrouter/ai-sdk-provider/-/ai-sdk-provider-2.5.1.tgz",
706
+ "integrity": "sha512-r1fJL1Cb3gQDa2MpWH/sfx1BsEW0uzlRriJM6eihaKqbtKDmZoBisF32VcVaQYassighX7NGCkF68EsrZA43uQ==",
707
+ "license": "Apache-2.0",
708
+ "engines": {
709
+ "node": ">=18"
710
+ },
711
+ "peerDependencies": {
712
+ "ai": "^6.0.0",
713
+ "zod": "^3.25.0 || ^4.0.0"
714
+ }
715
+ },
716
+ "node_modules/@opentelemetry/api": {
717
+ "version": "1.9.0",
718
+ "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
719
+ "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==",
720
+ "license": "Apache-2.0",
721
+ "engines": {
722
+ "node": ">=8.0.0"
723
+ }
724
+ },
725
+ "node_modules/@standard-schema/spec": {
726
+ "version": "1.1.0",
727
+ "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
728
+ "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
729
+ "license": "MIT"
730
+ },
731
+ "node_modules/@types/body-parser": {
732
+ "version": "1.19.6",
733
+ "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
734
+ "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==",
735
+ "dev": true,
736
+ "license": "MIT",
737
+ "dependencies": {
738
+ "@types/connect": "*",
739
+ "@types/node": "*"
740
+ }
741
+ },
742
+ "node_modules/@types/connect": {
743
+ "version": "3.4.38",
744
+ "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
745
+ "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==",
746
+ "dev": true,
747
+ "license": "MIT",
748
+ "dependencies": {
749
+ "@types/node": "*"
750
+ }
751
+ },
752
+ "node_modules/@types/express": {
753
+ "version": "5.0.6",
754
+ "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz",
755
+ "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==",
756
+ "dev": true,
757
+ "license": "MIT",
758
+ "dependencies": {
759
+ "@types/body-parser": "*",
760
+ "@types/express-serve-static-core": "^5.0.0",
761
+ "@types/serve-static": "^2"
762
+ }
763
+ },
764
+ "node_modules/@types/express-serve-static-core": {
765
+ "version": "5.1.1",
766
+ "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.1.tgz",
767
+ "integrity": "sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==",
768
+ "dev": true,
769
+ "license": "MIT",
770
+ "dependencies": {
771
+ "@types/node": "*",
772
+ "@types/qs": "*",
773
+ "@types/range-parser": "*",
774
+ "@types/send": "*"
775
+ }
776
+ },
777
+ "node_modules/@types/express-ws": {
778
+ "version": "3.0.6",
779
+ "resolved": "https://registry.npmjs.org/@types/express-ws/-/express-ws-3.0.6.tgz",
780
+ "integrity": "sha512-6ZDt+tMEQgM4RC1sMX1fIO7kHQkfUDlWfxoPddXUeeDjmc+Yt/fCzqXfp8rFahNr5eIxdomrWphLEWDkB2q3UQ==",
781
+ "dev": true,
782
+ "license": "MIT",
783
+ "dependencies": {
784
+ "@types/express": "*",
785
+ "@types/express-serve-static-core": "*",
786
+ "@types/ws": "*"
787
+ }
788
+ },
789
+ "node_modules/@types/http-errors": {
790
+ "version": "2.0.5",
791
+ "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz",
792
+ "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==",
793
+ "dev": true,
794
+ "license": "MIT"
795
+ },
796
+ "node_modules/@types/multer": {
797
+ "version": "2.1.0",
798
+ "resolved": "https://registry.npmjs.org/@types/multer/-/multer-2.1.0.tgz",
799
+ "integrity": "sha512-zYZb0+nJhOHtPpGDb3vqPjwpdeGlGC157VpkqNQL+UU2qwoacoQ7MpsAmUptI/0Oa127X32JzWDqQVEXp2RcIA==",
800
+ "dev": true,
801
+ "license": "MIT",
802
+ "dependencies": {
803
+ "@types/express": "*"
804
+ }
805
+ },
806
+ "node_modules/@types/node": {
807
+ "version": "22.19.17",
808
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.17.tgz",
809
+ "integrity": "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==",
810
+ "dev": true,
811
+ "license": "MIT",
812
+ "dependencies": {
813
+ "undici-types": "~6.21.0"
814
+ }
815
+ },
816
+ "node_modules/@types/qs": {
817
+ "version": "6.15.0",
818
+ "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.0.tgz",
819
+ "integrity": "sha512-JawvT8iBVWpzTrz3EGw9BTQFg3BQNmwERdKE22vlTxawwtbyUSlMppvZYKLZzB5zgACXdXxbD3m1bXaMqP/9ow==",
820
+ "dev": true,
821
+ "license": "MIT"
822
+ },
823
+ "node_modules/@types/range-parser": {
824
+ "version": "1.2.7",
825
+ "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz",
826
+ "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==",
827
+ "dev": true,
828
+ "license": "MIT"
829
+ },
830
+ "node_modules/@types/send": {
831
+ "version": "1.2.1",
832
+ "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz",
833
+ "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==",
834
+ "dev": true,
835
+ "license": "MIT",
836
+ "dependencies": {
837
+ "@types/node": "*"
838
+ }
839
+ },
840
+ "node_modules/@types/serve-static": {
841
+ "version": "2.2.0",
842
+ "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz",
843
+ "integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==",
844
+ "dev": true,
845
+ "license": "MIT",
846
+ "dependencies": {
847
+ "@types/http-errors": "*",
848
+ "@types/node": "*"
849
+ }
850
+ },
851
+ "node_modules/@types/ws": {
852
+ "version": "8.18.1",
853
+ "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
854
+ "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
855
+ "dev": true,
856
+ "license": "MIT",
857
+ "dependencies": {
858
+ "@types/node": "*"
859
+ }
860
+ },
861
+ "node_modules/@vercel/oidc": {
862
+ "version": "3.1.0",
863
+ "resolved": "https://registry.npmjs.org/@vercel/oidc/-/oidc-3.1.0.tgz",
864
+ "integrity": "sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w==",
865
+ "license": "Apache-2.0",
866
+ "engines": {
867
+ "node": ">= 20"
868
+ }
869
+ },
870
+ "node_modules/accepts": {
871
+ "version": "1.3.8",
872
+ "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
873
+ "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
874
+ "license": "MIT",
875
+ "dependencies": {
876
+ "mime-types": "~2.1.34",
877
+ "negotiator": "0.6.3"
878
+ },
879
+ "engines": {
880
+ "node": ">= 0.6"
881
+ }
882
+ },
883
+ "node_modules/ai": {
884
+ "version": "6.0.158",
885
+ "resolved": "https://registry.npmjs.org/ai/-/ai-6.0.158.tgz",
886
+ "integrity": "sha512-gLTp1UXFtMqKUi3XHs33K7UFglbvojkxF/aq337TxnLGOhHIW9+GyP2jwW4hYX87f1es+wId3VQoPRRu9zEStQ==",
887
+ "license": "Apache-2.0",
888
+ "peer": true,
889
+ "dependencies": {
890
+ "@ai-sdk/gateway": "3.0.95",
891
+ "@ai-sdk/provider": "3.0.8",
892
+ "@ai-sdk/provider-utils": "4.0.23",
893
+ "@opentelemetry/api": "1.9.0"
894
+ },
895
+ "engines": {
896
+ "node": ">=18"
897
+ },
898
+ "peerDependencies": {
899
+ "zod": "^3.25.76 || ^4.1.8"
900
+ }
901
+ },
902
+ "node_modules/ansi-regex": {
903
+ "version": "5.0.1",
904
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
905
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
906
+ "license": "MIT",
907
+ "optional": true,
908
+ "engines": {
909
+ "node": ">=8"
910
+ }
911
+ },
912
+ "node_modules/append-field": {
913
+ "version": "1.0.0",
914
+ "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz",
915
+ "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==",
916
+ "license": "MIT"
917
+ },
918
+ "node_modules/array-flatten": {
919
+ "version": "1.1.1",
920
+ "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
921
+ "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
922
+ "license": "MIT"
923
+ },
924
+ "node_modules/async-lock": {
925
+ "version": "1.4.1",
926
+ "resolved": "https://registry.npmjs.org/async-lock/-/async-lock-1.4.1.tgz",
927
+ "integrity": "sha512-Az2ZTpuytrtqENulXwO3GGv1Bztugx6TT37NIo7imr/Qo0gsYiGtSdBa2B6fsXhTpVZDNfu1Qn3pk531e3q+nQ==",
928
+ "license": "MIT"
929
+ },
930
+ "node_modules/async-mutex": {
931
+ "version": "0.5.0",
932
+ "resolved": "https://registry.npmjs.org/async-mutex/-/async-mutex-0.5.0.tgz",
933
+ "integrity": "sha512-1A94B18jkJ3DYq284ohPxoXbfTA5HsQ7/Mf4DEhcyLx3Bz27Rh59iScbB6EPiP+B+joue6YCxcMXSbFC1tZKwA==",
934
+ "license": "MIT",
935
+ "dependencies": {
936
+ "tslib": "^2.4.0"
937
+ }
938
+ },
939
+ "node_modules/base64-js": {
940
+ "version": "1.5.1",
941
+ "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
942
+ "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
943
+ "funding": [
944
+ {
945
+ "type": "github",
946
+ "url": "https://github.com/sponsors/feross"
947
+ },
948
+ {
949
+ "type": "patreon",
950
+ "url": "https://www.patreon.com/feross"
951
+ },
952
+ {
953
+ "type": "consulting",
954
+ "url": "https://feross.org/support"
955
+ }
956
+ ],
957
+ "license": "MIT"
958
+ },
959
+ "node_modules/body-parser": {
960
+ "version": "1.20.4",
961
+ "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz",
962
+ "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==",
963
+ "license": "MIT",
964
+ "dependencies": {
965
+ "bytes": "~3.1.2",
966
+ "content-type": "~1.0.5",
967
+ "debug": "2.6.9",
968
+ "depd": "2.0.0",
969
+ "destroy": "~1.2.0",
970
+ "http-errors": "~2.0.1",
971
+ "iconv-lite": "~0.4.24",
972
+ "on-finished": "~2.4.1",
973
+ "qs": "~6.14.0",
974
+ "raw-body": "~2.5.3",
975
+ "type-is": "~1.6.18",
976
+ "unpipe": "~1.0.0"
977
+ },
978
+ "engines": {
979
+ "node": ">= 0.8",
980
+ "npm": "1.2.8000 || >= 1.4.16"
981
+ }
982
+ },
983
+ "node_modules/buffer": {
984
+ "version": "5.7.1",
985
+ "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
986
+ "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
987
+ "funding": [
988
+ {
989
+ "type": "github",
990
+ "url": "https://github.com/sponsors/feross"
991
+ },
992
+ {
993
+ "type": "patreon",
994
+ "url": "https://www.patreon.com/feross"
995
+ },
996
+ {
997
+ "type": "consulting",
998
+ "url": "https://feross.org/support"
999
+ }
1000
+ ],
1001
+ "license": "MIT",
1002
+ "dependencies": {
1003
+ "base64-js": "^1.3.1",
1004
+ "ieee754": "^1.1.13"
1005
+ }
1006
+ },
1007
+ "node_modules/buffer-from": {
1008
+ "version": "1.1.2",
1009
+ "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
1010
+ "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
1011
+ "license": "MIT"
1012
+ },
1013
+ "node_modules/busboy": {
1014
+ "version": "1.6.0",
1015
+ "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
1016
+ "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
1017
+ "dependencies": {
1018
+ "streamsearch": "^1.1.0"
1019
+ },
1020
+ "engines": {
1021
+ "node": ">=10.16.0"
1022
+ }
1023
+ },
1024
+ "node_modules/bytes": {
1025
+ "version": "3.1.2",
1026
+ "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
1027
+ "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
1028
+ "license": "MIT",
1029
+ "engines": {
1030
+ "node": ">= 0.8"
1031
+ }
1032
+ },
1033
+ "node_modules/call-bind-apply-helpers": {
1034
+ "version": "1.0.2",
1035
+ "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
1036
+ "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
1037
+ "license": "MIT",
1038
+ "dependencies": {
1039
+ "es-errors": "^1.3.0",
1040
+ "function-bind": "^1.1.2"
1041
+ },
1042
+ "engines": {
1043
+ "node": ">= 0.4"
1044
+ }
1045
+ },
1046
+ "node_modules/call-bound": {
1047
+ "version": "1.0.4",
1048
+ "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
1049
+ "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
1050
+ "license": "MIT",
1051
+ "dependencies": {
1052
+ "call-bind-apply-helpers": "^1.0.2",
1053
+ "get-intrinsic": "^1.3.0"
1054
+ },
1055
+ "engines": {
1056
+ "node": ">= 0.4"
1057
+ },
1058
+ "funding": {
1059
+ "url": "https://github.com/sponsors/ljharb"
1060
+ }
1061
+ },
1062
+ "node_modules/citeproc": {
1063
+ "version": "2.4.63",
1064
+ "resolved": "https://registry.npmjs.org/citeproc/-/citeproc-2.4.63.tgz",
1065
+ "integrity": "sha512-68F95Bp4UbgZU/DBUGQn0qV3HDZLCdI9+Bb2ByrTaNJDL5VEm9LqaiNaxljsvoaExSLEXe1/r6n2Z06SCzW3/Q==",
1066
+ "license": "CPAL-1.0 OR AGPL-1.0"
1067
+ },
1068
+ "node_modules/cli-progress": {
1069
+ "version": "3.12.0",
1070
+ "resolved": "https://registry.npmjs.org/cli-progress/-/cli-progress-3.12.0.tgz",
1071
+ "integrity": "sha512-tRkV3HJ1ASwm19THiiLIXLO7Im7wlTuKnvkYaTkyoAPefqjNg7W7DHKUlGRxy9vxDvbyCYQkQozvptuMkGCg8A==",
1072
+ "license": "MIT",
1073
+ "optional": true,
1074
+ "dependencies": {
1075
+ "string-width": "^4.2.3"
1076
+ },
1077
+ "engines": {
1078
+ "node": ">=4"
1079
+ }
1080
+ },
1081
+ "node_modules/concat-stream": {
1082
+ "version": "2.0.0",
1083
+ "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
1084
+ "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
1085
+ "engines": [
1086
+ "node >= 6.0"
1087
+ ],
1088
+ "license": "MIT",
1089
+ "dependencies": {
1090
+ "buffer-from": "^1.0.0",
1091
+ "inherits": "^2.0.3",
1092
+ "readable-stream": "^3.0.2",
1093
+ "typedarray": "^0.0.6"
1094
+ }
1095
+ },
1096
+ "node_modules/content-disposition": {
1097
+ "version": "0.5.4",
1098
+ "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
1099
+ "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
1100
+ "license": "MIT",
1101
+ "dependencies": {
1102
+ "safe-buffer": "5.2.1"
1103
+ },
1104
+ "engines": {
1105
+ "node": ">= 0.6"
1106
+ }
1107
+ },
1108
+ "node_modules/content-type": {
1109
+ "version": "1.0.5",
1110
+ "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
1111
+ "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
1112
+ "license": "MIT",
1113
+ "engines": {
1114
+ "node": ">= 0.6"
1115
+ }
1116
+ },
1117
+ "node_modules/cookie": {
1118
+ "version": "0.7.2",
1119
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
1120
+ "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
1121
+ "license": "MIT",
1122
+ "engines": {
1123
+ "node": ">= 0.6"
1124
+ }
1125
+ },
1126
+ "node_modules/cookie-signature": {
1127
+ "version": "1.0.7",
1128
+ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
1129
+ "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
1130
+ "license": "MIT"
1131
+ },
1132
+ "node_modules/debug": {
1133
+ "version": "2.6.9",
1134
+ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
1135
+ "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
1136
+ "license": "MIT",
1137
+ "dependencies": {
1138
+ "ms": "2.0.0"
1139
+ }
1140
+ },
1141
+ "node_modules/depd": {
1142
+ "version": "2.0.0",
1143
+ "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
1144
+ "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
1145
+ "license": "MIT",
1146
+ "engines": {
1147
+ "node": ">= 0.8"
1148
+ }
1149
+ },
1150
+ "node_modules/destroy": {
1151
+ "version": "1.2.0",
1152
+ "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
1153
+ "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
1154
+ "license": "MIT",
1155
+ "engines": {
1156
+ "node": ">= 0.8",
1157
+ "npm": "1.2.8000 || >= 1.4.16"
1158
+ }
1159
+ },
1160
+ "node_modules/dotenv": {
1161
+ "version": "17.4.1",
1162
+ "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.1.tgz",
1163
+ "integrity": "sha512-k8DaKGP6r1G30Lx8V4+pCsLzKr8vLmV2paqEj1Y55GdAgJuIqpRp5FfajGF8KtwMxCz9qJc6wUIJnm053d/WCw==",
1164
+ "license": "BSD-2-Clause",
1165
+ "engines": {
1166
+ "node": ">=12"
1167
+ },
1168
+ "funding": {
1169
+ "url": "https://dotenvx.com"
1170
+ }
1171
+ },
1172
+ "node_modules/dunder-proto": {
1173
+ "version": "1.0.1",
1174
+ "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
1175
+ "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
1176
+ "license": "MIT",
1177
+ "dependencies": {
1178
+ "call-bind-apply-helpers": "^1.0.1",
1179
+ "es-errors": "^1.3.0",
1180
+ "gopd": "^1.2.0"
1181
+ },
1182
+ "engines": {
1183
+ "node": ">= 0.4"
1184
+ }
1185
+ },
1186
+ "node_modules/ee-first": {
1187
+ "version": "1.1.1",
1188
+ "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
1189
+ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
1190
+ "license": "MIT"
1191
+ },
1192
+ "node_modules/emoji-regex": {
1193
+ "version": "8.0.0",
1194
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
1195
+ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
1196
+ "license": "MIT",
1197
+ "optional": true
1198
+ },
1199
+ "node_modules/encodeurl": {
1200
+ "version": "2.0.0",
1201
+ "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
1202
+ "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
1203
+ "license": "MIT",
1204
+ "engines": {
1205
+ "node": ">= 0.8"
1206
+ }
1207
+ },
1208
+ "node_modules/es-define-property": {
1209
+ "version": "1.0.1",
1210
+ "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
1211
+ "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
1212
+ "license": "MIT",
1213
+ "engines": {
1214
+ "node": ">= 0.4"
1215
+ }
1216
+ },
1217
+ "node_modules/es-errors": {
1218
+ "version": "1.3.0",
1219
+ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
1220
+ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
1221
+ "license": "MIT",
1222
+ "engines": {
1223
+ "node": ">= 0.4"
1224
+ }
1225
+ },
1226
+ "node_modules/es-object-atoms": {
1227
+ "version": "1.1.1",
1228
+ "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
1229
+ "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
1230
+ "license": "MIT",
1231
+ "dependencies": {
1232
+ "es-errors": "^1.3.0"
1233
+ },
1234
+ "engines": {
1235
+ "node": ">= 0.4"
1236
+ }
1237
+ },
1238
+ "node_modules/esbuild": {
1239
+ "version": "0.27.7",
1240
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz",
1241
+ "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==",
1242
+ "dev": true,
1243
+ "hasInstallScript": true,
1244
+ "license": "MIT",
1245
+ "bin": {
1246
+ "esbuild": "bin/esbuild"
1247
+ },
1248
+ "engines": {
1249
+ "node": ">=18"
1250
+ },
1251
+ "optionalDependencies": {
1252
+ "@esbuild/aix-ppc64": "0.27.7",
1253
+ "@esbuild/android-arm": "0.27.7",
1254
+ "@esbuild/android-arm64": "0.27.7",
1255
+ "@esbuild/android-x64": "0.27.7",
1256
+ "@esbuild/darwin-arm64": "0.27.7",
1257
+ "@esbuild/darwin-x64": "0.27.7",
1258
+ "@esbuild/freebsd-arm64": "0.27.7",
1259
+ "@esbuild/freebsd-x64": "0.27.7",
1260
+ "@esbuild/linux-arm": "0.27.7",
1261
+ "@esbuild/linux-arm64": "0.27.7",
1262
+ "@esbuild/linux-ia32": "0.27.7",
1263
+ "@esbuild/linux-loong64": "0.27.7",
1264
+ "@esbuild/linux-mips64el": "0.27.7",
1265
+ "@esbuild/linux-ppc64": "0.27.7",
1266
+ "@esbuild/linux-riscv64": "0.27.7",
1267
+ "@esbuild/linux-s390x": "0.27.7",
1268
+ "@esbuild/linux-x64": "0.27.7",
1269
+ "@esbuild/netbsd-arm64": "0.27.7",
1270
+ "@esbuild/netbsd-x64": "0.27.7",
1271
+ "@esbuild/openbsd-arm64": "0.27.7",
1272
+ "@esbuild/openbsd-x64": "0.27.7",
1273
+ "@esbuild/openharmony-arm64": "0.27.7",
1274
+ "@esbuild/sunos-x64": "0.27.7",
1275
+ "@esbuild/win32-arm64": "0.27.7",
1276
+ "@esbuild/win32-ia32": "0.27.7",
1277
+ "@esbuild/win32-x64": "0.27.7"
1278
+ }
1279
+ },
1280
+ "node_modules/escape-html": {
1281
+ "version": "1.0.3",
1282
+ "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
1283
+ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
1284
+ "license": "MIT"
1285
+ },
1286
+ "node_modules/etag": {
1287
+ "version": "1.8.1",
1288
+ "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
1289
+ "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
1290
+ "license": "MIT",
1291
+ "engines": {
1292
+ "node": ">= 0.6"
1293
+ }
1294
+ },
1295
+ "node_modules/eventsource-parser": {
1296
+ "version": "3.0.6",
1297
+ "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz",
1298
+ "integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==",
1299
+ "license": "MIT",
1300
+ "engines": {
1301
+ "node": ">=18.0.0"
1302
+ }
1303
+ },
1304
+ "node_modules/express": {
1305
+ "version": "4.22.1",
1306
+ "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz",
1307
+ "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==",
1308
+ "license": "MIT",
1309
+ "peer": true,
1310
+ "dependencies": {
1311
+ "accepts": "~1.3.8",
1312
+ "array-flatten": "1.1.1",
1313
+ "body-parser": "~1.20.3",
1314
+ "content-disposition": "~0.5.4",
1315
+ "content-type": "~1.0.4",
1316
+ "cookie": "~0.7.1",
1317
+ "cookie-signature": "~1.0.6",
1318
+ "debug": "2.6.9",
1319
+ "depd": "2.0.0",
1320
+ "encodeurl": "~2.0.0",
1321
+ "escape-html": "~1.0.3",
1322
+ "etag": "~1.8.1",
1323
+ "finalhandler": "~1.3.1",
1324
+ "fresh": "~0.5.2",
1325
+ "http-errors": "~2.0.0",
1326
+ "merge-descriptors": "1.0.3",
1327
+ "methods": "~1.1.2",
1328
+ "on-finished": "~2.4.1",
1329
+ "parseurl": "~1.3.3",
1330
+ "path-to-regexp": "~0.1.12",
1331
+ "proxy-addr": "~2.0.7",
1332
+ "qs": "~6.14.0",
1333
+ "range-parser": "~1.2.1",
1334
+ "safe-buffer": "5.2.1",
1335
+ "send": "~0.19.0",
1336
+ "serve-static": "~1.16.2",
1337
+ "setprototypeof": "1.2.0",
1338
+ "statuses": "~2.0.1",
1339
+ "type-is": "~1.6.18",
1340
+ "utils-merge": "1.0.1",
1341
+ "vary": "~1.1.2"
1342
+ },
1343
+ "engines": {
1344
+ "node": ">= 0.10.0"
1345
+ },
1346
+ "funding": {
1347
+ "type": "opencollective",
1348
+ "url": "https://opencollective.com/express"
1349
+ }
1350
+ },
1351
+ "node_modules/express-ws": {
1352
+ "version": "5.0.2",
1353
+ "resolved": "https://registry.npmjs.org/express-ws/-/express-ws-5.0.2.tgz",
1354
+ "integrity": "sha512-0uvmuk61O9HXgLhGl3QhNSEtRsQevtmbL94/eILaliEADZBHZOQUAiHFrGPrgsjikohyrmSG5g+sCfASTt0lkQ==",
1355
+ "license": "BSD-2-Clause",
1356
+ "dependencies": {
1357
+ "ws": "^7.4.6"
1358
+ },
1359
+ "engines": {
1360
+ "node": ">=4.5.0"
1361
+ },
1362
+ "peerDependencies": {
1363
+ "express": "^4.0.0 || ^5.0.0-alpha.1"
1364
+ }
1365
+ },
1366
+ "node_modules/express-ws/node_modules/ws": {
1367
+ "version": "7.5.10",
1368
+ "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz",
1369
+ "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==",
1370
+ "license": "MIT",
1371
+ "engines": {
1372
+ "node": ">=8.3.0"
1373
+ },
1374
+ "peerDependencies": {
1375
+ "bufferutil": "^4.0.1",
1376
+ "utf-8-validate": "^5.0.2"
1377
+ },
1378
+ "peerDependenciesMeta": {
1379
+ "bufferutil": {
1380
+ "optional": true
1381
+ },
1382
+ "utf-8-validate": {
1383
+ "optional": true
1384
+ }
1385
+ }
1386
+ },
1387
+ "node_modules/fetch-ponyfill": {
1388
+ "version": "7.1.0",
1389
+ "resolved": "https://registry.npmjs.org/fetch-ponyfill/-/fetch-ponyfill-7.1.0.tgz",
1390
+ "integrity": "sha512-FhbbL55dj/qdVO3YNK7ZEkshvj3eQ7EuIGV2I6ic/2YiocvyWv+7jg2s4AyS0wdRU75s3tA8ZxI/xPigb0v5Aw==",
1391
+ "license": "MIT",
1392
+ "dependencies": {
1393
+ "node-fetch": "~2.6.1"
1394
+ }
1395
+ },
1396
+ "node_modules/finalhandler": {
1397
+ "version": "1.3.2",
1398
+ "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
1399
+ "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==",
1400
+ "license": "MIT",
1401
+ "dependencies": {
1402
+ "debug": "2.6.9",
1403
+ "encodeurl": "~2.0.0",
1404
+ "escape-html": "~1.0.3",
1405
+ "on-finished": "~2.4.1",
1406
+ "parseurl": "~1.3.3",
1407
+ "statuses": "~2.0.2",
1408
+ "unpipe": "~1.0.0"
1409
+ },
1410
+ "engines": {
1411
+ "node": ">= 0.8"
1412
+ }
1413
+ },
1414
+ "node_modules/forwarded": {
1415
+ "version": "0.2.0",
1416
+ "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
1417
+ "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
1418
+ "license": "MIT",
1419
+ "engines": {
1420
+ "node": ">= 0.6"
1421
+ }
1422
+ },
1423
+ "node_modules/fresh": {
1424
+ "version": "0.5.2",
1425
+ "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
1426
+ "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
1427
+ "license": "MIT",
1428
+ "engines": {
1429
+ "node": ">= 0.6"
1430
+ }
1431
+ },
1432
+ "node_modules/fsevents": {
1433
+ "version": "2.3.3",
1434
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
1435
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
1436
+ "dev": true,
1437
+ "hasInstallScript": true,
1438
+ "license": "MIT",
1439
+ "optional": true,
1440
+ "os": [
1441
+ "darwin"
1442
+ ],
1443
+ "engines": {
1444
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
1445
+ }
1446
+ },
1447
+ "node_modules/function-bind": {
1448
+ "version": "1.1.2",
1449
+ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
1450
+ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
1451
+ "license": "MIT",
1452
+ "funding": {
1453
+ "url": "https://github.com/sponsors/ljharb"
1454
+ }
1455
+ },
1456
+ "node_modules/get-intrinsic": {
1457
+ "version": "1.3.0",
1458
+ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
1459
+ "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
1460
+ "license": "MIT",
1461
+ "dependencies": {
1462
+ "call-bind-apply-helpers": "^1.0.2",
1463
+ "es-define-property": "^1.0.1",
1464
+ "es-errors": "^1.3.0",
1465
+ "es-object-atoms": "^1.1.1",
1466
+ "function-bind": "^1.1.2",
1467
+ "get-proto": "^1.0.1",
1468
+ "gopd": "^1.2.0",
1469
+ "has-symbols": "^1.1.0",
1470
+ "hasown": "^2.0.2",
1471
+ "math-intrinsics": "^1.1.0"
1472
+ },
1473
+ "engines": {
1474
+ "node": ">= 0.4"
1475
+ },
1476
+ "funding": {
1477
+ "url": "https://github.com/sponsors/ljharb"
1478
+ }
1479
+ },
1480
+ "node_modules/get-proto": {
1481
+ "version": "1.0.1",
1482
+ "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
1483
+ "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
1484
+ "license": "MIT",
1485
+ "dependencies": {
1486
+ "dunder-proto": "^1.0.1",
1487
+ "es-object-atoms": "^1.0.0"
1488
+ },
1489
+ "engines": {
1490
+ "node": ">= 0.4"
1491
+ }
1492
+ },
1493
+ "node_modules/get-tsconfig": {
1494
+ "version": "4.13.7",
1495
+ "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.7.tgz",
1496
+ "integrity": "sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==",
1497
+ "dev": true,
1498
+ "license": "MIT",
1499
+ "dependencies": {
1500
+ "resolve-pkg-maps": "^1.0.0"
1501
+ },
1502
+ "funding": {
1503
+ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
1504
+ }
1505
+ },
1506
+ "node_modules/gopd": {
1507
+ "version": "1.2.0",
1508
+ "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
1509
+ "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
1510
+ "license": "MIT",
1511
+ "engines": {
1512
+ "node": ">= 0.4"
1513
+ },
1514
+ "funding": {
1515
+ "url": "https://github.com/sponsors/ljharb"
1516
+ }
1517
+ },
1518
+ "node_modules/has-symbols": {
1519
+ "version": "1.1.0",
1520
+ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
1521
+ "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
1522
+ "license": "MIT",
1523
+ "engines": {
1524
+ "node": ">= 0.4"
1525
+ },
1526
+ "funding": {
1527
+ "url": "https://github.com/sponsors/ljharb"
1528
+ }
1529
+ },
1530
+ "node_modules/hasown": {
1531
+ "version": "2.0.2",
1532
+ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
1533
+ "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
1534
+ "license": "MIT",
1535
+ "dependencies": {
1536
+ "function-bind": "^1.1.2"
1537
+ },
1538
+ "engines": {
1539
+ "node": ">= 0.4"
1540
+ }
1541
+ },
1542
+ "node_modules/http-errors": {
1543
+ "version": "2.0.1",
1544
+ "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
1545
+ "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
1546
+ "license": "MIT",
1547
+ "dependencies": {
1548
+ "depd": "~2.0.0",
1549
+ "inherits": "~2.0.4",
1550
+ "setprototypeof": "~1.2.0",
1551
+ "statuses": "~2.0.2",
1552
+ "toidentifier": "~1.0.1"
1553
+ },
1554
+ "engines": {
1555
+ "node": ">= 0.8"
1556
+ },
1557
+ "funding": {
1558
+ "type": "opencollective",
1559
+ "url": "https://opencollective.com/express"
1560
+ }
1561
+ },
1562
+ "node_modules/iconv-lite": {
1563
+ "version": "0.4.24",
1564
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
1565
+ "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
1566
+ "license": "MIT",
1567
+ "dependencies": {
1568
+ "safer-buffer": ">= 2.1.2 < 3"
1569
+ },
1570
+ "engines": {
1571
+ "node": ">=0.10.0"
1572
+ }
1573
+ },
1574
+ "node_modules/ieee754": {
1575
+ "version": "1.2.1",
1576
+ "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
1577
+ "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
1578
+ "funding": [
1579
+ {
1580
+ "type": "github",
1581
+ "url": "https://github.com/sponsors/feross"
1582
+ },
1583
+ {
1584
+ "type": "patreon",
1585
+ "url": "https://www.patreon.com/feross"
1586
+ },
1587
+ {
1588
+ "type": "consulting",
1589
+ "url": "https://feross.org/support"
1590
+ }
1591
+ ],
1592
+ "license": "BSD-3-Clause"
1593
+ },
1594
+ "node_modules/inherits": {
1595
+ "version": "2.0.4",
1596
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
1597
+ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
1598
+ "license": "ISC"
1599
+ },
1600
+ "node_modules/ipaddr.js": {
1601
+ "version": "1.9.1",
1602
+ "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
1603
+ "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
1604
+ "license": "MIT",
1605
+ "engines": {
1606
+ "node": ">= 0.10"
1607
+ }
1608
+ },
1609
+ "node_modules/is-fullwidth-code-point": {
1610
+ "version": "3.0.0",
1611
+ "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
1612
+ "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
1613
+ "license": "MIT",
1614
+ "optional": true,
1615
+ "engines": {
1616
+ "node": ">=8"
1617
+ }
1618
+ },
1619
+ "node_modules/isomorphic.js": {
1620
+ "version": "0.2.5",
1621
+ "resolved": "https://registry.npmjs.org/isomorphic.js/-/isomorphic.js-0.2.5.tgz",
1622
+ "integrity": "sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==",
1623
+ "license": "MIT",
1624
+ "funding": {
1625
+ "type": "GitHub Sponsors ❤",
1626
+ "url": "https://github.com/sponsors/dmonad"
1627
+ }
1628
+ },
1629
+ "node_modules/json-schema": {
1630
+ "version": "0.4.0",
1631
+ "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz",
1632
+ "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==",
1633
+ "license": "(AFL-2.1 OR BSD-3-Clause)"
1634
+ },
1635
+ "node_modules/kleur": {
1636
+ "version": "4.1.5",
1637
+ "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz",
1638
+ "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==",
1639
+ "license": "MIT",
1640
+ "engines": {
1641
+ "node": ">=6"
1642
+ }
1643
+ },
1644
+ "node_modules/lib0": {
1645
+ "version": "0.2.117",
1646
+ "resolved": "https://registry.npmjs.org/lib0/-/lib0-0.2.117.tgz",
1647
+ "integrity": "sha512-DeXj9X5xDCjgKLU/7RR+/HQEVzuuEUiwldwOGsHK/sfAfELGWEyTcf0x+uOvCvK3O2zPmZePXWL85vtia6GyZw==",
1648
+ "license": "MIT",
1649
+ "dependencies": {
1650
+ "isomorphic.js": "^0.2.4"
1651
+ },
1652
+ "bin": {
1653
+ "0ecdsa-generate-keypair": "bin/0ecdsa-generate-keypair.js",
1654
+ "0gentesthtml": "bin/gentesthtml.js",
1655
+ "0serve": "bin/0serve.js"
1656
+ },
1657
+ "engines": {
1658
+ "node": ">=16"
1659
+ },
1660
+ "funding": {
1661
+ "type": "GitHub Sponsors ❤",
1662
+ "url": "https://github.com/sponsors/dmonad"
1663
+ }
1664
+ },
1665
+ "node_modules/math-intrinsics": {
1666
+ "version": "1.1.0",
1667
+ "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
1668
+ "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
1669
+ "license": "MIT",
1670
+ "engines": {
1671
+ "node": ">= 0.4"
1672
+ }
1673
+ },
1674
+ "node_modules/media-typer": {
1675
+ "version": "0.3.0",
1676
+ "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
1677
+ "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
1678
+ "license": "MIT",
1679
+ "engines": {
1680
+ "node": ">= 0.6"
1681
+ }
1682
+ },
1683
+ "node_modules/merge-descriptors": {
1684
+ "version": "1.0.3",
1685
+ "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
1686
+ "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
1687
+ "license": "MIT",
1688
+ "funding": {
1689
+ "url": "https://github.com/sponsors/sindresorhus"
1690
+ }
1691
+ },
1692
+ "node_modules/methods": {
1693
+ "version": "1.1.2",
1694
+ "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
1695
+ "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
1696
+ "license": "MIT",
1697
+ "engines": {
1698
+ "node": ">= 0.6"
1699
+ }
1700
+ },
1701
+ "node_modules/mime": {
1702
+ "version": "1.6.0",
1703
+ "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
1704
+ "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
1705
+ "license": "MIT",
1706
+ "bin": {
1707
+ "mime": "cli.js"
1708
+ },
1709
+ "engines": {
1710
+ "node": ">=4"
1711
+ }
1712
+ },
1713
+ "node_modules/mime-db": {
1714
+ "version": "1.52.0",
1715
+ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
1716
+ "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
1717
+ "license": "MIT",
1718
+ "engines": {
1719
+ "node": ">= 0.6"
1720
+ }
1721
+ },
1722
+ "node_modules/mime-types": {
1723
+ "version": "2.1.35",
1724
+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
1725
+ "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
1726
+ "license": "MIT",
1727
+ "dependencies": {
1728
+ "mime-db": "1.52.0"
1729
+ },
1730
+ "engines": {
1731
+ "node": ">= 0.6"
1732
+ }
1733
+ },
1734
+ "node_modules/moo": {
1735
+ "version": "0.5.3",
1736
+ "resolved": "https://registry.npmjs.org/moo/-/moo-0.5.3.tgz",
1737
+ "integrity": "sha512-m2fmM2dDm7GZQsY7KK2cme8agi+AAljILjQnof7p1ZMDe6dQ4bdnSMx0cPppudoeNv5hEFQirN6u+O4fDE0IWA==",
1738
+ "license": "BSD-3-Clause"
1739
+ },
1740
+ "node_modules/ms": {
1741
+ "version": "2.0.0",
1742
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
1743
+ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
1744
+ "license": "MIT"
1745
+ },
1746
+ "node_modules/multer": {
1747
+ "version": "2.1.1",
1748
+ "resolved": "https://registry.npmjs.org/multer/-/multer-2.1.1.tgz",
1749
+ "integrity": "sha512-mo+QTzKlx8R7E5ylSXxWzGoXoZbOsRMpyitcht8By2KHvMbf3tjwosZ/Mu/XYU6UuJ3VZnODIrak5ZrPiPyB6A==",
1750
+ "license": "MIT",
1751
+ "dependencies": {
1752
+ "append-field": "^1.0.0",
1753
+ "busboy": "^1.6.0",
1754
+ "concat-stream": "^2.0.0",
1755
+ "type-is": "^1.6.18"
1756
+ },
1757
+ "engines": {
1758
+ "node": ">= 10.16.0"
1759
+ },
1760
+ "funding": {
1761
+ "type": "opencollective",
1762
+ "url": "https://opencollective.com/express"
1763
+ }
1764
+ },
1765
+ "node_modules/negotiator": {
1766
+ "version": "0.6.3",
1767
+ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
1768
+ "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
1769
+ "license": "MIT",
1770
+ "engines": {
1771
+ "node": ">= 0.6"
1772
+ }
1773
+ },
1774
+ "node_modules/node-fetch": {
1775
+ "version": "2.6.13",
1776
+ "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.13.tgz",
1777
+ "integrity": "sha512-StxNAxh15zr77QvvkmveSQ8uCQ4+v5FkvNTj0OESmiHu+VRi/gXArXtkWMElOsOUNLtUEvI4yS+rdtOHZTwlQA==",
1778
+ "license": "MIT",
1779
+ "dependencies": {
1780
+ "whatwg-url": "^5.0.0"
1781
+ },
1782
+ "engines": {
1783
+ "node": "4.x || >=6.0.0"
1784
+ },
1785
+ "peerDependencies": {
1786
+ "encoding": "^0.1.0"
1787
+ },
1788
+ "peerDependenciesMeta": {
1789
+ "encoding": {
1790
+ "optional": true
1791
+ }
1792
+ }
1793
+ },
1794
+ "node_modules/object-inspect": {
1795
+ "version": "1.13.4",
1796
+ "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
1797
+ "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
1798
+ "license": "MIT",
1799
+ "engines": {
1800
+ "node": ">= 0.4"
1801
+ },
1802
+ "funding": {
1803
+ "url": "https://github.com/sponsors/ljharb"
1804
+ }
1805
+ },
1806
+ "node_modules/on-finished": {
1807
+ "version": "2.4.1",
1808
+ "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
1809
+ "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
1810
+ "license": "MIT",
1811
+ "dependencies": {
1812
+ "ee-first": "1.1.1"
1813
+ },
1814
+ "engines": {
1815
+ "node": ">= 0.8"
1816
+ }
1817
+ },
1818
+ "node_modules/parseurl": {
1819
+ "version": "1.3.3",
1820
+ "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
1821
+ "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
1822
+ "license": "MIT",
1823
+ "engines": {
1824
+ "node": ">= 0.8"
1825
+ }
1826
+ },
1827
+ "node_modules/path-to-regexp": {
1828
+ "version": "0.1.13",
1829
+ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
1830
+ "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
1831
+ "license": "MIT"
1832
+ },
1833
+ "node_modules/proxy-addr": {
1834
+ "version": "2.0.7",
1835
+ "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
1836
+ "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
1837
+ "license": "MIT",
1838
+ "dependencies": {
1839
+ "forwarded": "0.2.0",
1840
+ "ipaddr.js": "1.9.1"
1841
+ },
1842
+ "engines": {
1843
+ "node": ">= 0.10"
1844
+ }
1845
+ },
1846
+ "node_modules/qs": {
1847
+ "version": "6.14.2",
1848
+ "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz",
1849
+ "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==",
1850
+ "license": "BSD-3-Clause",
1851
+ "dependencies": {
1852
+ "side-channel": "^1.1.0"
1853
+ },
1854
+ "engines": {
1855
+ "node": ">=0.6"
1856
+ },
1857
+ "funding": {
1858
+ "url": "https://github.com/sponsors/ljharb"
1859
+ }
1860
+ },
1861
+ "node_modules/range-parser": {
1862
+ "version": "1.2.1",
1863
+ "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
1864
+ "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
1865
+ "license": "MIT",
1866
+ "engines": {
1867
+ "node": ">= 0.6"
1868
+ }
1869
+ },
1870
+ "node_modules/raw-body": {
1871
+ "version": "2.5.3",
1872
+ "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
1873
+ "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
1874
+ "license": "MIT",
1875
+ "dependencies": {
1876
+ "bytes": "~3.1.2",
1877
+ "http-errors": "~2.0.1",
1878
+ "iconv-lite": "~0.4.24",
1879
+ "unpipe": "~1.0.0"
1880
+ },
1881
+ "engines": {
1882
+ "node": ">= 0.8"
1883
+ }
1884
+ },
1885
+ "node_modules/readable-stream": {
1886
+ "version": "3.6.2",
1887
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
1888
+ "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
1889
+ "license": "MIT",
1890
+ "dependencies": {
1891
+ "inherits": "^2.0.3",
1892
+ "string_decoder": "^1.1.1",
1893
+ "util-deprecate": "^1.0.1"
1894
+ },
1895
+ "engines": {
1896
+ "node": ">= 6"
1897
+ }
1898
+ },
1899
+ "node_modules/resolve-pkg-maps": {
1900
+ "version": "1.0.0",
1901
+ "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
1902
+ "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
1903
+ "dev": true,
1904
+ "license": "MIT",
1905
+ "funding": {
1906
+ "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
1907
+ }
1908
+ },
1909
+ "node_modules/safe-buffer": {
1910
+ "version": "5.2.1",
1911
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
1912
+ "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
1913
+ "funding": [
1914
+ {
1915
+ "type": "github",
1916
+ "url": "https://github.com/sponsors/feross"
1917
+ },
1918
+ {
1919
+ "type": "patreon",
1920
+ "url": "https://www.patreon.com/feross"
1921
+ },
1922
+ {
1923
+ "type": "consulting",
1924
+ "url": "https://feross.org/support"
1925
+ }
1926
+ ],
1927
+ "license": "MIT"
1928
+ },
1929
+ "node_modules/safer-buffer": {
1930
+ "version": "2.1.2",
1931
+ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
1932
+ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
1933
+ "license": "MIT"
1934
+ },
1935
+ "node_modules/send": {
1936
+ "version": "0.19.2",
1937
+ "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
1938
+ "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",
1939
+ "license": "MIT",
1940
+ "dependencies": {
1941
+ "debug": "2.6.9",
1942
+ "depd": "2.0.0",
1943
+ "destroy": "1.2.0",
1944
+ "encodeurl": "~2.0.0",
1945
+ "escape-html": "~1.0.3",
1946
+ "etag": "~1.8.1",
1947
+ "fresh": "~0.5.2",
1948
+ "http-errors": "~2.0.1",
1949
+ "mime": "1.6.0",
1950
+ "ms": "2.1.3",
1951
+ "on-finished": "~2.4.1",
1952
+ "range-parser": "~1.2.1",
1953
+ "statuses": "~2.0.2"
1954
+ },
1955
+ "engines": {
1956
+ "node": ">= 0.8.0"
1957
+ }
1958
+ },
1959
+ "node_modules/send/node_modules/ms": {
1960
+ "version": "2.1.3",
1961
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
1962
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
1963
+ "license": "MIT"
1964
+ },
1965
+ "node_modules/serve-static": {
1966
+ "version": "1.16.3",
1967
+ "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",
1968
+ "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",
1969
+ "license": "MIT",
1970
+ "dependencies": {
1971
+ "encodeurl": "~2.0.0",
1972
+ "escape-html": "~1.0.3",
1973
+ "parseurl": "~1.3.3",
1974
+ "send": "~0.19.1"
1975
+ },
1976
+ "engines": {
1977
+ "node": ">= 0.8.0"
1978
+ }
1979
+ },
1980
+ "node_modules/setprototypeof": {
1981
+ "version": "1.2.0",
1982
+ "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
1983
+ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
1984
+ "license": "ISC"
1985
+ },
1986
+ "node_modules/side-channel": {
1987
+ "version": "1.1.0",
1988
+ "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
1989
+ "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
1990
+ "license": "MIT",
1991
+ "dependencies": {
1992
+ "es-errors": "^1.3.0",
1993
+ "object-inspect": "^1.13.3",
1994
+ "side-channel-list": "^1.0.0",
1995
+ "side-channel-map": "^1.0.1",
1996
+ "side-channel-weakmap": "^1.0.2"
1997
+ },
1998
+ "engines": {
1999
+ "node": ">= 0.4"
2000
+ },
2001
+ "funding": {
2002
+ "url": "https://github.com/sponsors/ljharb"
2003
+ }
2004
+ },
2005
+ "node_modules/side-channel-list": {
2006
+ "version": "1.0.1",
2007
+ "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
2008
+ "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
2009
+ "license": "MIT",
2010
+ "dependencies": {
2011
+ "es-errors": "^1.3.0",
2012
+ "object-inspect": "^1.13.4"
2013
+ },
2014
+ "engines": {
2015
+ "node": ">= 0.4"
2016
+ },
2017
+ "funding": {
2018
+ "url": "https://github.com/sponsors/ljharb"
2019
+ }
2020
+ },
2021
+ "node_modules/side-channel-map": {
2022
+ "version": "1.0.1",
2023
+ "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
2024
+ "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
2025
+ "license": "MIT",
2026
+ "dependencies": {
2027
+ "call-bound": "^1.0.2",
2028
+ "es-errors": "^1.3.0",
2029
+ "get-intrinsic": "^1.2.5",
2030
+ "object-inspect": "^1.13.3"
2031
+ },
2032
+ "engines": {
2033
+ "node": ">= 0.4"
2034
+ },
2035
+ "funding": {
2036
+ "url": "https://github.com/sponsors/ljharb"
2037
+ }
2038
+ },
2039
+ "node_modules/side-channel-weakmap": {
2040
+ "version": "1.0.2",
2041
+ "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
2042
+ "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
2043
+ "license": "MIT",
2044
+ "dependencies": {
2045
+ "call-bound": "^1.0.2",
2046
+ "es-errors": "^1.3.0",
2047
+ "get-intrinsic": "^1.2.5",
2048
+ "object-inspect": "^1.13.3",
2049
+ "side-channel-map": "^1.0.1"
2050
+ },
2051
+ "engines": {
2052
+ "node": ">= 0.4"
2053
+ },
2054
+ "funding": {
2055
+ "url": "https://github.com/sponsors/ljharb"
2056
+ }
2057
+ },
2058
+ "node_modules/statuses": {
2059
+ "version": "2.0.2",
2060
+ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
2061
+ "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
2062
+ "license": "MIT",
2063
+ "engines": {
2064
+ "node": ">= 0.8"
2065
+ }
2066
+ },
2067
+ "node_modules/streamsearch": {
2068
+ "version": "1.1.0",
2069
+ "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
2070
+ "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
2071
+ "engines": {
2072
+ "node": ">=10.0.0"
2073
+ }
2074
+ },
2075
+ "node_modules/string_decoder": {
2076
+ "version": "1.3.0",
2077
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
2078
+ "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
2079
+ "license": "MIT",
2080
+ "dependencies": {
2081
+ "safe-buffer": "~5.2.0"
2082
+ }
2083
+ },
2084
+ "node_modules/string-width": {
2085
+ "version": "4.2.3",
2086
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
2087
+ "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
2088
+ "license": "MIT",
2089
+ "optional": true,
2090
+ "dependencies": {
2091
+ "emoji-regex": "^8.0.0",
2092
+ "is-fullwidth-code-point": "^3.0.0",
2093
+ "strip-ansi": "^6.0.1"
2094
+ },
2095
+ "engines": {
2096
+ "node": ">=8"
2097
+ }
2098
+ },
2099
+ "node_modules/strip-ansi": {
2100
+ "version": "6.0.1",
2101
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
2102
+ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
2103
+ "license": "MIT",
2104
+ "optional": true,
2105
+ "dependencies": {
2106
+ "ansi-regex": "^5.0.1"
2107
+ },
2108
+ "engines": {
2109
+ "node": ">=8"
2110
+ }
2111
+ },
2112
+ "node_modules/sync-fetch": {
2113
+ "version": "0.4.5",
2114
+ "resolved": "https://registry.npmjs.org/sync-fetch/-/sync-fetch-0.4.5.tgz",
2115
+ "integrity": "sha512-esiWJ7ixSKGpd9DJPBTC4ckChqdOjIwJfYhVHkcQ2Gnm41323p1TRmEI+esTQ9ppD+b5opps2OTEGTCGX5kF+g==",
2116
+ "license": "MIT",
2117
+ "dependencies": {
2118
+ "buffer": "^5.7.1",
2119
+ "node-fetch": "^2.6.1"
2120
+ },
2121
+ "engines": {
2122
+ "node": ">=14"
2123
+ }
2124
+ },
2125
+ "node_modules/toidentifier": {
2126
+ "version": "1.0.1",
2127
+ "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
2128
+ "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
2129
+ "license": "MIT",
2130
+ "engines": {
2131
+ "node": ">=0.6"
2132
+ }
2133
+ },
2134
+ "node_modules/tr46": {
2135
+ "version": "0.0.3",
2136
+ "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
2137
+ "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
2138
+ "license": "MIT"
2139
+ },
2140
+ "node_modules/tslib": {
2141
+ "version": "2.8.1",
2142
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
2143
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
2144
+ "license": "0BSD"
2145
+ },
2146
+ "node_modules/tsx": {
2147
+ "version": "4.21.0",
2148
+ "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
2149
+ "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
2150
+ "dev": true,
2151
+ "license": "MIT",
2152
+ "dependencies": {
2153
+ "esbuild": "~0.27.0",
2154
+ "get-tsconfig": "^4.7.5"
2155
+ },
2156
+ "bin": {
2157
+ "tsx": "dist/cli.mjs"
2158
+ },
2159
+ "engines": {
2160
+ "node": ">=18.0.0"
2161
+ },
2162
+ "optionalDependencies": {
2163
+ "fsevents": "~2.3.3"
2164
+ }
2165
+ },
2166
+ "node_modules/type-is": {
2167
+ "version": "1.6.18",
2168
+ "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
2169
+ "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
2170
+ "license": "MIT",
2171
+ "dependencies": {
2172
+ "media-typer": "0.3.0",
2173
+ "mime-types": "~2.1.24"
2174
+ },
2175
+ "engines": {
2176
+ "node": ">= 0.6"
2177
+ }
2178
+ },
2179
+ "node_modules/typedarray": {
2180
+ "version": "0.0.6",
2181
+ "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
2182
+ "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
2183
+ "license": "MIT"
2184
+ },
2185
+ "node_modules/typescript": {
2186
+ "version": "5.9.3",
2187
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
2188
+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
2189
+ "dev": true,
2190
+ "license": "Apache-2.0",
2191
+ "bin": {
2192
+ "tsc": "bin/tsc",
2193
+ "tsserver": "bin/tsserver"
2194
+ },
2195
+ "engines": {
2196
+ "node": ">=14.17"
2197
+ }
2198
+ },
2199
+ "node_modules/undici-types": {
2200
+ "version": "6.21.0",
2201
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
2202
+ "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
2203
+ "dev": true,
2204
+ "license": "MIT"
2205
+ },
2206
+ "node_modules/unpipe": {
2207
+ "version": "1.0.0",
2208
+ "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
2209
+ "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
2210
+ "license": "MIT",
2211
+ "engines": {
2212
+ "node": ">= 0.8"
2213
+ }
2214
+ },
2215
+ "node_modules/util-deprecate": {
2216
+ "version": "1.0.2",
2217
+ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
2218
+ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
2219
+ "license": "MIT"
2220
+ },
2221
+ "node_modules/utils-merge": {
2222
+ "version": "1.0.1",
2223
+ "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
2224
+ "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
2225
+ "license": "MIT",
2226
+ "engines": {
2227
+ "node": ">= 0.4.0"
2228
+ }
2229
+ },
2230
+ "node_modules/vary": {
2231
+ "version": "1.1.2",
2232
+ "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
2233
+ "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
2234
+ "license": "MIT",
2235
+ "engines": {
2236
+ "node": ">= 0.8"
2237
+ }
2238
+ },
2239
+ "node_modules/webidl-conversions": {
2240
+ "version": "3.0.1",
2241
+ "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
2242
+ "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
2243
+ "license": "BSD-2-Clause"
2244
+ },
2245
+ "node_modules/whatwg-url": {
2246
+ "version": "5.0.0",
2247
+ "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
2248
+ "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
2249
+ "license": "MIT",
2250
+ "dependencies": {
2251
+ "tr46": "~0.0.3",
2252
+ "webidl-conversions": "^3.0.0"
2253
+ }
2254
+ },
2255
+ "node_modules/ws": {
2256
+ "version": "8.20.0",
2257
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
2258
+ "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==",
2259
+ "license": "MIT",
2260
+ "engines": {
2261
+ "node": ">=10.0.0"
2262
+ },
2263
+ "peerDependencies": {
2264
+ "bufferutil": "^4.0.1",
2265
+ "utf-8-validate": ">=5.0.2"
2266
+ },
2267
+ "peerDependenciesMeta": {
2268
+ "bufferutil": {
2269
+ "optional": true
2270
+ },
2271
+ "utf-8-validate": {
2272
+ "optional": true
2273
+ }
2274
+ }
2275
+ },
2276
+ "node_modules/y-protocols": {
2277
+ "version": "1.0.7",
2278
+ "resolved": "https://registry.npmjs.org/y-protocols/-/y-protocols-1.0.7.tgz",
2279
+ "integrity": "sha512-YSVsLoXxO67J6eE/nV4AtFtT3QEotZf5sK5BHxFBXso7VDUT3Tx07IfA6hsu5Q5OmBdMkQVmFZ9QOA7fikWvnw==",
2280
+ "license": "MIT",
2281
+ "peer": true,
2282
+ "dependencies": {
2283
+ "lib0": "^0.2.85"
2284
+ },
2285
+ "engines": {
2286
+ "node": ">=16.0.0",
2287
+ "npm": ">=8.0.0"
2288
+ },
2289
+ "funding": {
2290
+ "type": "GitHub Sponsors ❤",
2291
+ "url": "https://github.com/sponsors/dmonad"
2292
+ },
2293
+ "peerDependencies": {
2294
+ "yjs": "^13.0.0"
2295
+ }
2296
+ },
2297
+ "node_modules/yjs": {
2298
+ "version": "13.6.30",
2299
+ "resolved": "https://registry.npmjs.org/yjs/-/yjs-13.6.30.tgz",
2300
+ "integrity": "sha512-vv/9h42eCMC81ZHDFswuu/MKzkl/vyq1BhaNGfHyOonwlG4CJbQF4oiBBJPvfdeCt/PlVDWh7Nov9D34YY09uQ==",
2301
+ "license": "MIT",
2302
+ "peer": true,
2303
+ "dependencies": {
2304
+ "lib0": "^0.2.99"
2305
+ },
2306
+ "engines": {
2307
+ "node": ">=16.0.0",
2308
+ "npm": ">=8.0.0"
2309
+ },
2310
+ "funding": {
2311
+ "type": "GitHub Sponsors ❤",
2312
+ "url": "https://github.com/sponsors/dmonad"
2313
+ }
2314
+ },
2315
+ "node_modules/zod": {
2316
+ "version": "4.3.6",
2317
+ "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
2318
+ "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
2319
+ "license": "MIT",
2320
+ "peer": true,
2321
+ "funding": {
2322
+ "url": "https://github.com/sponsors/colinhacks"
2323
+ }
2324
+ }
2325
+ }
2326
+ }
backend/package.json ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "collab-editor-backend",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "tsx watch src/server.ts",
8
+ "build": "tsc",
9
+ "start": "node dist/server.js"
10
+ },
11
+ "dependencies": {
12
+ "@ai-sdk/openai": "^3.0.52",
13
+ "@ai-sdk/openai-compatible": "^2.0.41",
14
+ "@citation-js/core": "^0.7.21",
15
+ "@citation-js/plugin-bibtex": "^0.7.21",
16
+ "@citation-js/plugin-csl": "^0.7.22",
17
+ "@citation-js/plugin-doi": "^0.7.21",
18
+ "@hocuspocus/extension-database": "^3.4.4",
19
+ "@hocuspocus/server": "^3.4.4",
20
+ "@huggingface/hub": "^2.11.0",
21
+ "@openrouter/ai-sdk-provider": "^2.5.1",
22
+ "ai": "^6.0.158",
23
+ "dotenv": "^17.4.1",
24
+ "express": "^4.21.0",
25
+ "express-ws": "^5.0.2",
26
+ "multer": "^2.1.1",
27
+ "yjs": "^13.6.0",
28
+ "zod": "^4.3.6"
29
+ },
30
+ "devDependencies": {
31
+ "@types/express": "^5.0.0",
32
+ "@types/express-ws": "^3.0.5",
33
+ "@types/multer": "^2.1.0",
34
+ "@types/node": "^22.0.0",
35
+ "tsx": "^4.19.0",
36
+ "typescript": "^5.6.0"
37
+ }
38
+ }
backend/src/agent/chat.ts ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { streamText, convertToModelMessages } from "ai";
2
+ import { createOpenRouter } from "@openrouter/ai-sdk-provider";
3
+ import { editorTools } from "./tools.js";
4
+ import { SYSTEM_PROMPT, buildMessages } from "./system-prompt.js";
5
+ import type { Request, Response } from "express";
6
+
7
+ const DEFAULT_MODEL = "anthropic/claude-sonnet-4";
8
+
9
+ function getProvider() {
10
+ const apiKey = process.env.OPENROUTER_API_KEY;
11
+ if (!apiKey) {
12
+ throw new Error("OPENROUTER_API_KEY environment variable is required");
13
+ }
14
+ return createOpenRouter({ apiKey });
15
+ }
16
+
17
+ export async function handleChat(req: Request, res: Response) {
18
+ try {
19
+ const { messages, context, model } = req.body;
20
+
21
+ if (!messages || !Array.isArray(messages)) {
22
+ res.status(400).json({ error: "messages array is required" });
23
+ return;
24
+ }
25
+
26
+ const provider = getProvider();
27
+ const modelId = model || process.env.OPENROUTER_MODEL || DEFAULT_MODEL;
28
+
29
+ const contextBlock = buildMessages(context?.document, context?.selection, context?.frontmatter);
30
+ const systemMessages = contextBlock
31
+ ? `${SYSTEM_PROMPT}\n\n## Current context\n\n${contextBlock}`
32
+ : SYSTEM_PROMPT;
33
+
34
+ const modelMessages = await convertToModelMessages(messages);
35
+
36
+ const result = streamText({
37
+ model: provider.chat(modelId),
38
+ system: systemMessages,
39
+ messages: modelMessages,
40
+ tools: editorTools,
41
+ });
42
+
43
+ const webResponse = result.toUIMessageStreamResponse({
44
+ onError: (error) => {
45
+ console.error("[chat] stream error:", error);
46
+ return error instanceof Error ? error.message : "Stream error";
47
+ },
48
+ });
49
+
50
+ res.writeHead(
51
+ webResponse.status,
52
+ Object.fromEntries(webResponse.headers.entries()),
53
+ );
54
+ const reader = webResponse.body!.getReader();
55
+ const pump = async (): Promise<void> => {
56
+ const { done, value } = await reader.read();
57
+ if (done) {
58
+ res.end();
59
+ return;
60
+ }
61
+ res.write(value);
62
+ return pump();
63
+ };
64
+ await pump();
65
+ } catch (error: unknown) {
66
+ const message =
67
+ error instanceof Error ? error.message : "Internal server error";
68
+ console.error("[chat] error:", message);
69
+
70
+ if (!res.headersSent) {
71
+ res.status(500).json({ error: message });
72
+ }
73
+ }
74
+ }
backend/src/agent/system-prompt.ts ADDED
@@ -0,0 +1,93 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export const SYSTEM_PROMPT = `You are a writing assistant embedded in a collaborative article editor.
2
+ You help users write, edit, and improve their articles.
3
+
4
+ ## Tools
5
+
6
+ ### Document editing
7
+ - **applyDiff**: Your PRIMARY editing tool. Surgically edit the document with context-aware diffs.
8
+ Provide surrounding context (contextBefore / contextAfter) to locate the exact position, even
9
+ when the same text appears multiple times. Call it multiple times for multiple edits.
10
+ - **replaceSelection**: Replace selected text. Use ONLY when the user has text selected.
11
+ - **insertAtCursor**: Insert new text at cursor. Use for additions only.
12
+
13
+ ### Article metadata (frontmatter)
14
+ - **updateFrontmatter**: Update article metadata fields (title, subtitle, template, DOI, etc.).
15
+ Only include the fields you want to change. Use when the user asks to modify article settings.
16
+ - **addAuthor**: Add an author with name, optional URL, and affiliation indices.
17
+ Can also create a new affiliation in the same call.
18
+ - **removeAuthor**: Remove an author by 0-based index.
19
+
20
+ ## Editing strategy - applyDiff
21
+
22
+ You MUST work with surgical, minimal diffs using applyDiff:
23
+ 1. **contextBefore**: Copy a few words that appear just before the text you want to change.
24
+ This helps locate the right spot when the same phrase exists in multiple places.
25
+ 2. **contentToDelete**: The exact verbatim text to remove. Even a single extra space will
26
+ cause the edit to fail. Keep it as short as possible while being unique.
27
+ 3. **contentToInsert**: The replacement text. Can be empty to simply delete.
28
+ 4. **contextAfter**: Copy a few words that appear just after the text to change.
29
+
30
+ Rules:
31
+ - Make multiple small edits rather than one large replacement.
32
+ - Never rewrite content that does not need changing.
33
+ - Each applyDiff call should target one specific change (a sentence, a paragraph at most).
34
+ - The contentToDelete must be an exact verbatim copy from the document.
35
+
36
+ All your edits within a single response are grouped into one undo step -
37
+ the user can revert everything you did with a single Cmd+Z.
38
+
39
+ ## Frontmatter strategy
40
+
41
+ When the user asks about article metadata:
42
+ - To change title, subtitle, date, DOI, template, etc.: use **updateFrontmatter** with only the fields to change.
43
+ - To add an author: use **addAuthor**. If the affiliation doesn't exist, provide newAffiliationName.
44
+ - To remove an author: use **removeAuthor** with the 0-based index from the current authors list.
45
+ - The current frontmatter is provided in <frontmatter> tags when available.
46
+
47
+ ## Guidelines
48
+
49
+ 1. **Be concise.** When the user asks for an edit, use tools immediately instead of
50
+ explaining what you would change.
51
+ 2. **Preserve the author's voice.** Maintain tone and style unless asked to change them.
52
+ 3. **Use markdown formatting** in inserted/replaced text when appropriate.
53
+ 4. **Ask for clarification** if the request is ambiguous.
54
+ 5. **Reference specific parts** of the document when discussing content.
55
+
56
+ ## Context
57
+
58
+ The user may provide:
59
+ - Their current text selection (between <selection> tags)
60
+ - The full document content (between <document> tags)
61
+ - The article metadata (between <frontmatter> tags)
62
+
63
+ Use this context to make informed edits. Always refer to the actual content, not assumptions.`;
64
+
65
+ export function buildMessages(
66
+ documentContent?: string,
67
+ selectedText?: string,
68
+ frontmatter?: Record<string, unknown>,
69
+ ): string {
70
+ const parts: string[] = [];
71
+
72
+ if (frontmatter && Object.keys(frontmatter).length > 0) {
73
+ const lines: string[] = [];
74
+ for (const [key, value] of Object.entries(frontmatter)) {
75
+ if (value === undefined || value === null || value === "") continue;
76
+ if (Array.isArray(value) && value.length === 0) continue;
77
+ lines.push(`${key}: ${JSON.stringify(value)}`);
78
+ }
79
+ if (lines.length > 0) {
80
+ parts.push(`<frontmatter>\n${lines.join("\n")}\n</frontmatter>`);
81
+ }
82
+ }
83
+
84
+ if (documentContent) {
85
+ parts.push(`<document>\n${documentContent}\n</document>`);
86
+ }
87
+
88
+ if (selectedText) {
89
+ parts.push(`<selection>\n${selectedText}\n</selection>`);
90
+ }
91
+
92
+ return parts.join("\n\n");
93
+ }
backend/src/agent/tools.ts ADDED
@@ -0,0 +1,118 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { tool } from "ai";
2
+ import { z } from "zod";
3
+
4
+ /**
5
+ * Tools exposed to the LLM. These return instructions that the frontend
6
+ * will execute on the TipTap editor instance. The backend never touches
7
+ * the document directly - it only produces "edit commands" that are
8
+ * streamed back and applied client-side.
9
+ *
10
+ * All edits from a single agent turn are grouped into one Yjs undo step,
11
+ * so the user can revert an entire agent action with a single Cmd+Z.
12
+ */
13
+
14
+ export const editorTools = {
15
+ replaceSelection: tool({
16
+ description:
17
+ "Replace the currently selected text in the editor with new content. " +
18
+ "Use ONLY when text is selected and the user asks to rewrite, fix, or rephrase it.",
19
+ inputSchema: z.object({
20
+ newText: z
21
+ .string()
22
+ .describe("The replacement text (plain text or markdown)"),
23
+ }),
24
+ }),
25
+
26
+ insertAtCursor: tool({
27
+ description:
28
+ "Insert text at the current cursor position (does not replace anything). " +
29
+ "Use when the user asks to add or generate new content.",
30
+ inputSchema: z.object({
31
+ text: z.string().describe("The text to insert (plain text or markdown)"),
32
+ }),
33
+ }),
34
+
35
+ applyDiff: tool({
36
+ description:
37
+ "The PRIMARY tool for editing. Locates a piece of text in the document using " +
38
+ "context and replaces it. Provide surrounding context to disambiguate when the " +
39
+ "same text appears multiple times. For multiple edits, call this tool multiple " +
40
+ "times in a single response. Each call should be a minimal, surgical edit.",
41
+ inputSchema: z.object({
42
+ contextBefore: z
43
+ .string()
44
+ .describe(
45
+ "A few words of text that appear immediately BEFORE the content to delete. " +
46
+ "Used to locate the correct position. Can be empty if at the start of the document.",
47
+ ),
48
+ contentToDelete: z
49
+ .string()
50
+ .describe(
51
+ "The exact verbatim text to remove from the document. Must match perfectly.",
52
+ ),
53
+ contentToInsert: z
54
+ .string()
55
+ .describe(
56
+ "The new text to insert in place of the deleted text. " +
57
+ "Can be empty to just delete without replacing.",
58
+ ),
59
+ contextAfter: z
60
+ .string()
61
+ .describe(
62
+ "A few words of text that appear immediately AFTER the content to delete. " +
63
+ "Used to confirm the correct position. Can be empty if at the end.",
64
+ ),
65
+ }),
66
+ }),
67
+
68
+ updateFrontmatter: tool({
69
+ description:
70
+ "Update article metadata (frontmatter). Use when the user asks to change the title, " +
71
+ "subtitle, authors, affiliations, publication date, DOI, template, or any article setting. " +
72
+ "Only include the fields you want to change - omitted fields are left unchanged.",
73
+ inputSchema: z.object({
74
+ title: z.string().optional().describe("Article title"),
75
+ subtitle: z.string().optional().describe("Subtitle"),
76
+ description: z.string().optional().describe("Short SEO description"),
77
+ published: z.string().optional().describe('Publication date, e.g. "Apr. 04, 2026"'),
78
+ template: z.enum(["article", "paper"]).optional().describe("Layout template"),
79
+ doi: z.string().optional().describe("DOI identifier"),
80
+ banner: z.string().optional().describe("Banner embed filename"),
81
+ showPdf: z.boolean().optional().describe("Show PDF download button"),
82
+ tableOfContentsAutoCollapse: z.boolean().optional().describe("Auto-collapse TOC"),
83
+ licence: z.string().optional().describe("Licence text (HTML allowed)"),
84
+ pdfProOnly: z.boolean().optional().describe("Gate PDF behind HF Pro"),
85
+ seoThumbImage: z.string().optional().describe("Custom OG image URL"),
86
+ }),
87
+ }),
88
+
89
+ addAuthor: tool({
90
+ description:
91
+ "Add an author to the article. Provide the author name and optionally a URL " +
92
+ "and affiliation indices (1-based). If the affiliation doesn't exist yet, " +
93
+ "provide newAffiliationName to create it.",
94
+ inputSchema: z.object({
95
+ name: z.string().describe("Author name"),
96
+ url: z.string().optional().describe("Author URL/homepage"),
97
+ affiliations: z
98
+ .array(z.number())
99
+ .optional()
100
+ .describe("1-based affiliation indices"),
101
+ newAffiliationName: z
102
+ .string()
103
+ .optional()
104
+ .describe("Name of a new affiliation to create (if needed)"),
105
+ newAffiliationUrl: z
106
+ .string()
107
+ .optional()
108
+ .describe("URL of the new affiliation"),
109
+ }),
110
+ }),
111
+
112
+ removeAuthor: tool({
113
+ description: "Remove an author by index (0-based).",
114
+ inputSchema: z.object({
115
+ index: z.number().describe("0-based index of the author to remove"),
116
+ }),
117
+ }),
118
+ };
backend/src/citations.ts ADDED
@@ -0,0 +1,104 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Router, type Request, type Response } from "express";
2
+ import { Cite } from "@citation-js/core";
3
+ import "@citation-js/plugin-bibtex";
4
+ import "@citation-js/plugin-doi";
5
+ import "@citation-js/plugin-csl";
6
+
7
+ const router = Router();
8
+
9
+ /**
10
+ * POST /api/citations/resolve
11
+ * Body: { input: string } – DOI, DOI URL, or BibTeX string
12
+ * Returns: { entries: CSL-JSON[] }
13
+ */
14
+ router.post("/resolve", async (req: Request, res: Response) => {
15
+ try {
16
+ const { input } = req.body;
17
+ if (!input || typeof input !== "string") {
18
+ res.status(400).json({ error: "Missing 'input' string" });
19
+ return;
20
+ }
21
+
22
+ const cite = await Cite.async(input.trim());
23
+ const entries = cite.get({ format: "real", type: "json", style: "csl" });
24
+
25
+ if (!entries.length) {
26
+ res.status(404).json({ error: "Could not resolve any reference" });
27
+ return;
28
+ }
29
+
30
+ const enriched = entries.map((entry: any) => ({
31
+ ...entry,
32
+ id: entry.id || generateKey(entry),
33
+ }));
34
+
35
+ res.json({ entries: enriched });
36
+ } catch (err: any) {
37
+ console.error("[citations] resolve error:", err.message);
38
+ res.status(422).json({ error: err.message || "Failed to resolve" });
39
+ }
40
+ });
41
+
42
+ /**
43
+ * POST /api/citations/format
44
+ * Body: { entries: CSL-JSON[], style?: string, locale?: string }
45
+ * Returns: { html: string }
46
+ */
47
+ router.post("/format", async (req: Request, res: Response) => {
48
+ try {
49
+ const { entries, style = "apa", locale = "en-US" } = req.body;
50
+ if (!Array.isArray(entries) || !entries.length) {
51
+ res.status(400).json({ error: "Missing 'entries' array" });
52
+ return;
53
+ }
54
+
55
+ const cite = new Cite(entries);
56
+ const html = cite.format("bibliography", {
57
+ format: "html",
58
+ template: style,
59
+ lang: locale,
60
+ });
61
+
62
+ res.json({ html });
63
+ } catch (err: any) {
64
+ console.error("[citations] format error:", err.message);
65
+ res.status(422).json({ error: err.message || "Failed to format" });
66
+ }
67
+ });
68
+
69
+ /**
70
+ * POST /api/citations/import-bib
71
+ * Body: { bibtex: string }
72
+ * Returns: { entries: CSL-JSON[] }
73
+ */
74
+ router.post("/import-bib", async (req: Request, res: Response) => {
75
+ try {
76
+ const { bibtex } = req.body;
77
+ if (!bibtex || typeof bibtex !== "string") {
78
+ res.status(400).json({ error: "Missing 'bibtex' string" });
79
+ return;
80
+ }
81
+
82
+ const cite = new Cite(bibtex.trim());
83
+ const entries = cite.get({ format: "real", type: "json", style: "csl" });
84
+
85
+ const enriched = entries.map((entry: any) => ({
86
+ ...entry,
87
+ id: entry.id || generateKey(entry),
88
+ }));
89
+
90
+ res.json({ entries: enriched });
91
+ } catch (err: any) {
92
+ console.error("[citations] import-bib error:", err.message);
93
+ res.status(422).json({ error: err.message || "Failed to parse BibTeX" });
94
+ }
95
+ });
96
+
97
+ function generateKey(entry: any): string {
98
+ const firstAuthor = entry.author?.[0]?.family || "unknown";
99
+ const year = entry.issued?.["date-parts"]?.[0]?.[0] || "nd";
100
+ const base = `${firstAuthor.toLowerCase()}${year}`;
101
+ return base.replace(/[^a-z0-9]/gi, "");
102
+ }
103
+
104
+ export { router as citationsRouter };
backend/src/hf-storage.ts ADDED
@@ -0,0 +1,97 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { uploadFile, downloadFile, type RepoDesignation } from "@huggingface/hub";
2
+
3
+ const HF_TOKEN = process.env.HF_TOKEN || "";
4
+ const HF_DATASET_ID = process.env.HF_DATASET_ID || "";
5
+
6
+ const repo: RepoDesignation = { type: "dataset", name: HF_DATASET_ID };
7
+
8
+ export function isHfStorageEnabled(): boolean {
9
+ return Boolean(HF_TOKEN && HF_DATASET_ID);
10
+ }
11
+
12
+ function sanitizeName(name: string): string {
13
+ return name.replace(/[^a-zA-Z0-9_-]/g, "_");
14
+ }
15
+
16
+ // ---------- Images ----------
17
+
18
+ export async function uploadImageToHf(
19
+ buffer: Buffer,
20
+ filename: string,
21
+ ): Promise<string> {
22
+ const path = `images/${filename}`;
23
+
24
+ await uploadFile({
25
+ repo,
26
+ file: { path, content: new Blob([buffer]) },
27
+ accessToken: HF_TOKEN,
28
+ commitTitle: `upload image ${filename}`,
29
+ });
30
+
31
+ return `https://huggingface.co/datasets/${HF_DATASET_ID}/resolve/main/${path}`;
32
+ }
33
+
34
+ // ---------- Documents ----------
35
+
36
+ const dirtyDocs = new Map<string, { state: Buffer; timer: NodeJS.Timeout }>();
37
+ const DEBOUNCE_MS = 10_000;
38
+
39
+ /**
40
+ * Mark a document as dirty. After DEBOUNCE_MS of inactivity,
41
+ * push the latest state to HF.
42
+ */
43
+ export function schedulePush(docName: string, state: Buffer): void {
44
+ const existing = dirtyDocs.get(docName);
45
+ if (existing) clearTimeout(existing.timer);
46
+
47
+ const timer = setTimeout(() => pushDocument(docName), DEBOUNCE_MS);
48
+ dirtyDocs.set(docName, { state, timer });
49
+ }
50
+
51
+ async function pushDocument(docName: string): Promise<void> {
52
+ const entry = dirtyDocs.get(docName);
53
+ if (!entry) return;
54
+ dirtyDocs.delete(docName);
55
+
56
+ const safeName = sanitizeName(docName);
57
+ const path = `articles/${safeName}.yjs`;
58
+
59
+ try {
60
+ await uploadFile({
61
+ repo,
62
+ file: { path, content: new Blob([entry.state]) },
63
+ accessToken: HF_TOKEN,
64
+ commitTitle: `save ${safeName}`,
65
+ });
66
+ console.log(`[hf-storage] pushed ${path}`);
67
+ } catch (err) {
68
+ console.error(`[hf-storage] failed to push ${path}:`, err);
69
+ }
70
+ }
71
+
72
+ export async function pullDocument(docName: string): Promise<Uint8Array | null> {
73
+ const safeName = sanitizeName(docName);
74
+ const path = `articles/${safeName}.yjs`;
75
+
76
+ try {
77
+ const res = await downloadFile({
78
+ repo,
79
+ path,
80
+ accessToken: HF_TOKEN,
81
+ });
82
+ if (!res) return null;
83
+ const arrayBuf = await res.arrayBuffer();
84
+ return new Uint8Array(arrayBuf);
85
+ } catch {
86
+ return null;
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Flush all pending documents to HF immediately.
92
+ * Call this on graceful shutdown.
93
+ */
94
+ export async function flushAll(): Promise<void> {
95
+ const names = [...dirtyDocs.keys()];
96
+ await Promise.allSettled(names.map((n) => pushDocument(n)));
97
+ }
backend/src/server.ts ADDED
@@ -0,0 +1,163 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import "dotenv/config";
2
+ import express from "express";
3
+ import expressWebsockets from "express-ws";
4
+ import { Hocuspocus } from "@hocuspocus/server";
5
+ import { Database } from "@hocuspocus/extension-database";
6
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from "fs";
7
+ import { join, dirname } from "path";
8
+ import { fileURLToPath } from "url";
9
+ import { randomUUID } from "crypto";
10
+ import multer from "multer";
11
+ import { handleChat } from "./agent/chat.js";
12
+ import { citationsRouter } from "./citations.js";
13
+ import {
14
+ isHfStorageEnabled,
15
+ uploadImageToHf,
16
+ pullDocument,
17
+ schedulePush,
18
+ flushAll,
19
+ } from "./hf-storage.js";
20
+
21
+ const __dirname = dirname(fileURLToPath(import.meta.url));
22
+ const DATA_DIR = join(__dirname, "..", "data");
23
+ const PORT = parseInt(process.env.PORT || "8080", 10);
24
+
25
+ mkdirSync(DATA_DIR, { recursive: true });
26
+
27
+ function docPath(name: string) {
28
+ return join(DATA_DIR, `${name.replace(/[^a-zA-Z0-9_-]/g, "_")}.yjs`);
29
+ }
30
+
31
+ const hfEnabled = isHfStorageEnabled();
32
+ if (hfEnabled) {
33
+ console.log("[server] HF Dataset persistence enabled");
34
+ } else {
35
+ console.log("[server] HF Dataset persistence disabled (no HF_TOKEN / HF_DATASET_ID)");
36
+ console.log("[server] falling back to local file persistence");
37
+ }
38
+
39
+ const hocuspocus = new Hocuspocus({
40
+ extensions: [
41
+ new Database({
42
+ fetch: async ({ documentName }: { documentName: string }) => {
43
+ // Try local cache first
44
+ const p = docPath(documentName);
45
+ if (existsSync(p)) return readFileSync(p);
46
+
47
+ // Then try HF dataset
48
+ if (hfEnabled) {
49
+ const data = await pullDocument(documentName);
50
+ if (data) {
51
+ writeFileSync(p, data);
52
+ console.log(`[server] pulled ${documentName} from HF`);
53
+ return Buffer.from(data);
54
+ }
55
+ }
56
+
57
+ return null;
58
+ },
59
+ store: async ({
60
+ documentName,
61
+ state,
62
+ }: {
63
+ documentName: string;
64
+ state: Buffer;
65
+ }) => {
66
+ // Always write locally for fast access
67
+ writeFileSync(docPath(documentName), state);
68
+
69
+ // Schedule debounced push to HF
70
+ if (hfEnabled) {
71
+ schedulePush(documentName, state);
72
+ }
73
+ },
74
+ }),
75
+ ],
76
+ });
77
+
78
+ const { app } = expressWebsockets(express());
79
+
80
+ app.use(express.json({ limit: "1mb" }));
81
+
82
+ // ---------- Collab WebSocket ----------
83
+
84
+ app.ws("/collab/:doc", (ws, req) => {
85
+ hocuspocus.handleConnection(ws, req);
86
+ });
87
+
88
+ // ---------- AI Chat ----------
89
+
90
+ app.post("/api/chat", handleChat);
91
+
92
+ // ---------- Citations ----------
93
+
94
+ app.use("/api/citations", citationsRouter);
95
+
96
+ // ---------- Image Upload ----------
97
+
98
+ const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 10 * 1024 * 1024 } });
99
+
100
+ app.post("/api/upload", upload.single("file"), async (req, res) => {
101
+ try {
102
+ const file = (req as any).file as Express.Multer.File | undefined;
103
+ if (!file) {
104
+ res.status(400).json({ error: "No file provided" });
105
+ return;
106
+ }
107
+
108
+ const ext = file.originalname.split(".").pop() || "png";
109
+ const filename = `${randomUUID()}.${ext}`;
110
+
111
+ if (hfEnabled) {
112
+ const url = await uploadImageToHf(file.buffer, filename);
113
+ res.json({ url });
114
+ return;
115
+ }
116
+
117
+ // Fallback: save locally and serve via /uploads/
118
+ const uploadsDir = join(DATA_DIR, "uploads");
119
+ mkdirSync(uploadsDir, { recursive: true });
120
+ writeFileSync(join(uploadsDir, filename), file.buffer);
121
+ res.json({ url: `/uploads/${filename}` });
122
+ } catch (err: any) {
123
+ console.error("[upload] error:", err);
124
+ res.status(500).json({ error: err.message || "Upload failed" });
125
+ }
126
+ });
127
+
128
+ // Serve locally uploaded images
129
+ app.use("/uploads", express.static(join(DATA_DIR, "uploads")));
130
+
131
+ // ---------- Static frontend ----------
132
+
133
+ const staticDir =
134
+ process.env.NODE_ENV === "production"
135
+ ? join(__dirname, "..", "frontend-dist")
136
+ : join(__dirname, "..", "..", "frontend", "dist");
137
+
138
+ if (existsSync(staticDir)) {
139
+ app.use(express.static(staticDir));
140
+ app.get("*", (_req, res) => {
141
+ res.sendFile(join(staticDir, "index.html"));
142
+ });
143
+ }
144
+
145
+ // ---------- Start ----------
146
+
147
+ const server = app.listen(PORT, () => {
148
+ console.log(`[server] running on http://localhost:${PORT}`);
149
+ console.log(`[server] collab websocket at ws://localhost:${PORT}/collab/:doc`);
150
+ });
151
+
152
+ // Graceful shutdown: flush pending docs to HF
153
+ process.on("SIGTERM", async () => {
154
+ console.log("[server] shutting down, flushing to HF...");
155
+ await flushAll();
156
+ server.close();
157
+ });
158
+
159
+ process.on("SIGINT", async () => {
160
+ console.log("[server] shutting down, flushing to HF...");
161
+ await flushAll();
162
+ server.close();
163
+ });
backend/tsconfig.json ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ES2022",
5
+ "moduleResolution": "bundler",
6
+ "outDir": "dist",
7
+ "rootDir": "src",
8
+ "strict": true,
9
+ "esModuleInterop": true,
10
+ "skipLibCheck": true,
11
+ "declaration": true
12
+ },
13
+ "include": ["src"]
14
+ }
docs/embed-studio.md ADDED
@@ -0,0 +1,214 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Embed Studio - Architecture Document
2
+
3
+ ## Overview
4
+
5
+ The Embed Studio is a dedicated UI mode within the collab-editor for creating, editing, and previewing HTML embed visualizations (D3.js charts). It isolates the dataviz workflow from the article editing flow, providing a focused chat + preview experience similar to the standalone [dataviz-agent-space](https://huggingface.co/spaces/tfrere/dataviz-agent-space).
6
+
7
+ ## Context
8
+
9
+ The research-article-template uses `<HtmlEmbed>` components to embed self-contained D3.js charts into articles. These are `.html` files in `app/src/content/embeds/` with strict conventions (scoped CSS, IIFE scripts, ColorPalettes, responsive, etc.) documented in `.ai/skills/create-html-embed/directives.md`.
10
+
11
+ In the collab-editor, users need to create and iterate on these charts without leaving the editor. The Embed Studio solves this by providing a dedicated panel with an AI assistant specialized in D3 chart generation.
12
+
13
+ ## Storage
14
+
15
+ ### Y.Map("embeds")
16
+
17
+ Embed HTML content is stored in a collaborative Yjs Map, keyed by filename:
18
+
19
+ ```
20
+ Y.Map("embeds") = {
21
+ "d3-scaling-chart.html": "<div class='d3-scaling-chart'>...</div>",
22
+ "d3-performance.html": "<div class='d3-performance'>...</div>"
23
+ }
24
+ ```
25
+
26
+ The ProseMirror node (`htmlEmbed`) only stores the `src` attribute as a reference key. The actual HTML lives in the shared Y.Map, enabling real-time collaboration on embed content.
27
+
28
+ ### Node attributes
29
+
30
+ The `htmlEmbed` TipTap node stores:
31
+
32
+ | Attribute | Type | Description |
33
+ |-----------|------|-------------|
34
+ | `src` | string | Filename key into Y.Map("embeds") |
35
+ | `title` | string | Chart title (displayed above) |
36
+ | `desc` | string | Chart description |
37
+ | `wide` | boolean | Wide layout mode |
38
+ | `downloadable` | boolean | Show download button |
39
+ | `height` | number | Last known content height (pixels) |
40
+
41
+ The `height` attribute eliminates layout jumps: once a chart reports its height, it is persisted and used as the iframe's initial height on subsequent loads.
42
+
43
+ ## UI: Two Modes
44
+
45
+ ### 1. Inline Preview (article view)
46
+
47
+ When the user is editing the article, the `htmlEmbed` NodeView shows a read-only preview:
48
+
49
+ ```
50
+ ┌─────────────────────────────────────────┐
51
+ │ 📊 Chart Title [Edit] [⋮] │
52
+ ├─────────────────────────────────────────┤
53
+ │ │
54
+ │ <iframe preview> │
55
+ │ │
56
+ └─────────────────────────────────────────┘
57
+ ```
58
+
59
+ - The iframe renders the chart using `srcdoc` with the full HTML document (buildDoc wrapper)
60
+ - Height comes from the stored `height` attribute (default: 400px)
61
+ - Clicking "Edit" opens the Embed Studio panel
62
+ - No code editing in this mode
63
+
64
+ ### 2. Embed Studio Panel (creation/editing)
65
+
66
+ A full-width panel (drawer or overlay) opens with a split layout:
67
+
68
+ ```
69
+ ┌────────────────────────────┬─────────────────────────────┐
70
+ │ Chat (D3 context) │ Live Preview │
71
+ │ │ │
72
+ │ System prompt includes: │ ┌─────────────────────┐ │
73
+ │ - D3 embed directives │ │ │ │
74
+ │ - ColorPalettes API │ │ [rendered chart] │ │
75
+ │ - Current chart HTML │ │ │ │
76
+ │ │ └─────────────────────┘ │
77
+ │ User: "make a bar chart │ │
78
+ │ showing model sizes" │ Toggle: [Preview] [Code] │
79
+ │ │ │
80
+ │ AI: Creating chart... │ │
81
+ │ │ [Save & Close] │
82
+ └────────────────────────────┴─────────────────────────────┘
83
+ ```
84
+
85
+ **Key design decisions:**
86
+
87
+ - The chat in this panel has a **separate system prompt** with D3 directives injected. This avoids bloating the main article chat with 500+ lines of D3 conventions.
88
+ - The chat history is **per-embed** (scoped to the `src` key), so each chart has its own conversation thread.
89
+ - The live preview uses **double-buffered iframes** (A/B swap with cross-fade) from the dataviz-agent pattern to avoid flashes on update.
90
+ - An optional "Code" toggle shows the raw HTML for power users (future enhancement).
91
+
92
+ ## AI Tools
93
+
94
+ The Embed Studio provides three tools to the AI (following the dataviz-agent pattern):
95
+
96
+ ### createEmbed(src, html, title, source)
97
+
98
+ Create or fully replace the HTML for an embed. Writes to `Y.Map("embeds")`.
99
+
100
+ ### patchEmbed(src, search, replace)
101
+
102
+ Exact string replacement in the current HTML. More efficient than full rewrite for small changes (color tweaks, label updates, data changes). Reads from and writes to `Y.Map("embeds")`.
103
+
104
+ ### readEmbed(src)
105
+
106
+ Read the current HTML content. The AI should call this before patching to verify exact content.
107
+
108
+ ## Preview Infrastructure
109
+
110
+ ### buildDoc(html, isDark, primaryColor)
111
+
112
+ Wraps a chart HTML fragment into a complete HTML document with:
113
+
114
+ - CSS variables for theming (`--primary-color`, `--text-color`, `--surface-bg`, etc.)
115
+ - `data-theme="dark"` attribute when in dark mode
116
+ - ColorPalettes polyfill (provides `window.ColorPalettes.getColors()`, `.getPrimary()`, etc.)
117
+ - Height reporting script (see below)
118
+ - Base styles (box-sizing, font stack, padding)
119
+
120
+ ### Height reporting
121
+
122
+ A script injected by `buildDoc()` observes the chart container and reports its height to the parent:
123
+
124
+ ```js
125
+ // Injected into every chart iframe
126
+ new ResizeObserver(entries => {
127
+ const height = Math.ceil(entries[0].contentRect.height);
128
+ window.parent.postMessage({ type: 'embedResize', height }, '*');
129
+ }).observe(document.body);
130
+ ```
131
+
132
+ The NodeView listens for this message and updates the node's `height` attribute:
133
+
134
+ ```js
135
+ window.addEventListener('message', (e) => {
136
+ if (e.data?.type === 'embedResize') {
137
+ updateAttributes({ height: e.data.height });
138
+ }
139
+ });
140
+ ```
141
+
142
+ On subsequent renders, the iframe starts at the stored height, eliminating layout jumps.
143
+
144
+ ### Preview strategy: srcdoc first
145
+
146
+ Initial implementation uses `<iframe srcdoc="...">` directly. This avoids backend changes and keeps the architecture simple.
147
+
148
+ If we hit limitations (CSP restrictions, large HTML payloads, script execution issues), we migrate to a server-side preview route (`POST /api/preview` returning an ID, iframe loads `/api/preview/:id`), following the dataviz-agent pattern.
149
+
150
+ ## Export
151
+
152
+ ### Updated export API
153
+
154
+ The `toMdx()` function returns an object instead of a plain string:
155
+
156
+ ```typescript
157
+ interface ExportResult {
158
+ mdx: string; // The MDX content with frontmatter
159
+ embeds: Record<string, string>; // filename -> HTML content
160
+ }
161
+ ```
162
+
163
+ The caller is responsible for writing the embed files to `app/src/content/embeds/`.
164
+
165
+ ### MDX output
166
+
167
+ Each embed in the article exports as:
168
+
169
+ ```mdx
170
+ <HtmlEmbed src="d3-scaling-chart.html" title="Chart Title" desc="Description" />
171
+ ```
172
+
173
+ The HTML files are exported separately from the Y.Map("embeds") contents.
174
+
175
+ ## System Prompt for D3 Generation
176
+
177
+ The Embed Studio injects the D3 directives into the AI's system prompt. The content comes from two sources:
178
+
179
+ 1. **Tool descriptions** (from dataviz-agent): create/patch/read tools with usage guidelines
180
+ 2. **Embed conventions** (from research-article-template): structure, ColorPalettes, CSS variables, mount guard, D3 CDN loading, legends, controls, tooltips, responsiveness, error handling, accessibility, checklist
181
+
182
+ These are only injected when the Embed Studio is open, keeping the main article chat lightweight.
183
+
184
+ ## Implementation Plan
185
+
186
+ ### Phase 1: Core infrastructure
187
+
188
+ 1. Create `Y.Map("embeds")` in `Editor.tsx` and pass to the embed store
189
+ 2. Create `EmbedStore` (similar to FrontmatterStore) with get/set/observe/patch operations
190
+ 3. Replace the current atomic `htmlEmbed` NodeView with an iframe-based preview
191
+ 4. Implement `buildDoc()` with CSS variables and ColorPalettes polyfill
192
+ 5. Implement height reporting via postMessage
193
+
194
+ ### Phase 2: Embed Studio panel
195
+
196
+ 6. Create `EmbedStudio.tsx` - the split-panel UI (chat + preview)
197
+ 7. Create a dedicated chat hook (`useEmbedChat`) with D3 system prompt
198
+ 8. Implement `createEmbed`, `patchEmbed`, `readEmbed` AI tools (backend + frontend)
199
+ 9. Double-buffered iframe preview (A/B swap)
200
+ 10. Wire "Edit" button on inline NodeView to open the studio
201
+
202
+ ### Phase 3: Polish
203
+
204
+ 11. Per-embed chat history persistence
205
+ 12. Code view toggle
206
+ 13. Export API update (`toMdx` returns `{ mdx, embeds }`)
207
+ 14. Data file upload support (CSV/JSON stored in Y.Map)
208
+ 15. Screenshot-based validation (Playwright, optional)
209
+
210
+ ## References
211
+
212
+ - [dataviz-agent-space](../../../dataviz-agent-space/) - Standalone D3 chart generation agent
213
+ - [research-article-template embeds skill](../../../research-article-template/.ai/skills/create-html-embed/) - Embed authoring conventions
214
+ - [ChartFrame.jsx](../../../dataviz-agent-space/frontend/src/components/ChartFrame.jsx) - Double-buffered iframe + buildDoc pattern
frontend/index.html ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>Collab Editor</title>
7
+ </head>
8
+ <body>
9
+ <div id="root"></div>
10
+ <script type="module" src="/src/main.tsx"></script>
11
+ </body>
12
+ </html>
frontend/package-lock.json ADDED
The diff for this file is too large to render. See raw diff
 
frontend/package.json ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "collab-editor-frontend",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "vite",
8
+ "build": "vite build",
9
+ "preview": "vite preview"
10
+ },
11
+ "dependencies": {
12
+ "@ai-sdk/react": "^3.0.160",
13
+ "@emotion/react": "^11.14.0",
14
+ "@emotion/styled": "^11.14.1",
15
+ "@mui/icons-material": "^9.0.0",
16
+ "@mui/material": "^9.0.0",
17
+ "@tiptap/core": "^2.11.0",
18
+ "@tiptap/extension-code-block-lowlight": "^2.11.0",
19
+ "@tiptap/extension-collaboration": "^2.11.0",
20
+ "@tiptap/extension-collaboration-cursor": "^2.11.0",
21
+ "@tiptap/extension-image": "^2.27.2",
22
+ "@tiptap/extension-link": "^3.22.3",
23
+ "@tiptap/extension-mathematics": "^3.22.3",
24
+ "@tiptap/extension-placeholder": "^2.11.0",
25
+ "@tiptap/extension-table": "^2.27.2",
26
+ "@tiptap/extension-table-cell": "^2.27.2",
27
+ "@tiptap/extension-table-header": "^2.27.2",
28
+ "@tiptap/extension-table-row": "^2.27.2",
29
+ "@tiptap/extension-underline": "^3.22.3",
30
+ "@tiptap/pm": "^2.11.0",
31
+ "@tiptap/react": "^2.11.0",
32
+ "@tiptap/starter-kit": "^2.11.0",
33
+ "@tiptap/suggestion": "^2.27.2",
34
+ "@types/katex": "^0.16.8",
35
+ "ai": "^6.0.158",
36
+ "katex": "^0.16.45",
37
+ "lowlight": "^3.2.0",
38
+ "react": "^18.3.0",
39
+ "react-dom": "^18.3.0",
40
+ "y-prosemirror": "^1.2.0",
41
+ "y-websocket": "^2.0.0",
42
+ "yjs": "^13.6.0",
43
+ "zod": "^4.3.6"
44
+ },
45
+ "devDependencies": {
46
+ "@types/react": "^18.3.0",
47
+ "@types/react-dom": "^18.3.0",
48
+ "@vitejs/plugin-react": "^4.3.0",
49
+ "vite": "^6.0.0"
50
+ }
51
+ }
frontend/src/App.tsx ADDED
@@ -0,0 +1,321 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useRef, useState, useCallback, useEffect } from "react";
2
+ import { Editor as TiptapEditor } from "@tiptap/core";
3
+ import { UndoManager } from "yjs";
4
+ import type * as Y from "yjs";
5
+ import {
6
+ Box,
7
+ Chip,
8
+ Collapse,
9
+ Paper,
10
+ IconButton,
11
+ Tooltip,
12
+ } from "@mui/material";
13
+ import UndoIcon from "@mui/icons-material/Undo";
14
+ import RedoIcon from "@mui/icons-material/Redo";
15
+ import FileDownloadOutlinedIcon from "@mui/icons-material/FileDownloadOutlined";
16
+ import SettingsOutlinedIcon from "@mui/icons-material/SettingsOutlined";
17
+ import { Editor } from "./editor/Editor";
18
+ import { CommentsSidebar } from "./components/CommentsSidebar";
19
+ import { CommentDialog } from "./components/CommentDialog";
20
+ import { ChatPanel } from "./components/ChatPanel";
21
+ import { useAgentChat } from "./hooks/useAgentChat";
22
+ import { toMdx } from "./export/to-mdx";
23
+ import type { CommentStore } from "./editor/comments";
24
+ import type { FrontmatterStore } from "./editor/frontmatter/frontmatter-store";
25
+ import { FrontmatterHero } from "./editor/frontmatter/FrontmatterHero";
26
+ import { SettingsDrawer } from "./editor/frontmatter/SettingsDrawer";
27
+
28
+ const COLORS = [
29
+ "#958DF1", "#F98181", "#FBBC88", "#FAF594",
30
+ "#70CFF8", "#94FADB", "#B9F18D", "#C4B5FD",
31
+ ];
32
+
33
+ function randomColor() {
34
+ return COLORS[Math.floor(Math.random() * COLORS.length)];
35
+ }
36
+
37
+ function randomName() {
38
+ const names = ["Alice", "Bob", "Carol", "Dave", "Eve", "Frank", "Grace", "Heidi"];
39
+ return names[Math.floor(Math.random() * names.length)];
40
+ }
41
+
42
+ export default function App() {
43
+ const [user] = useState(() => ({
44
+ name: randomName(),
45
+ color: randomColor(),
46
+ }));
47
+
48
+ const [docName] = useState(() => {
49
+ const params = new URLSearchParams(window.location.search);
50
+ return params.get("doc") || "default";
51
+ });
52
+
53
+ const editorRef = useRef<TiptapEditor | null>(null);
54
+ const editorContainerRef = useRef<HTMLDivElement | null>(null);
55
+ const [editorInstance, setEditorInstance] = useState<TiptapEditor | null>(null);
56
+ const [containerEl, setContainerEl] = useState<HTMLElement | null>(null);
57
+ const [commentStore, setCommentStore] = useState<CommentStore | null>(null);
58
+ const [frontmatterStore, setFrontmatterStore] = useState<FrontmatterStore | null>(null);
59
+ const [settingsMap, setSettingsMap] = useState<Y.Map<any> | null>(null);
60
+ const [mdxOutput, setMdxOutput] = useState("");
61
+ const [showExport, setShowExport] = useState(false);
62
+ const [commentDialogOpen, setCommentDialogOpen] = useState(false);
63
+ const [settingsOpen, setSettingsOpen] = useState(false);
64
+ const selectionRange = useRef<{ from: number; to: number } | null>(null);
65
+ const [hasSelection, setHasSelection] = useState(false);
66
+ const [undoManager, setUndoManager] = useState<UndoManager | null>(null);
67
+
68
+ const agentChat = useAgentChat({ editor: editorInstance, undoManager, frontmatterStore });
69
+
70
+ useEffect(() => {
71
+ if (!editorInstance) return;
72
+ const handler = () => {
73
+ const { from, to } = editorInstance.state.selection;
74
+ setHasSelection(from !== to);
75
+ };
76
+ editorInstance.on("selectionUpdate", handler);
77
+ return () => { editorInstance.off("selectionUpdate", handler); };
78
+ }, [editorInstance]);
79
+
80
+ const editorContainerCallback = useCallback((node: HTMLDivElement | null) => {
81
+ editorContainerRef.current = node;
82
+ setContainerEl(node);
83
+ }, []);
84
+
85
+ const onEditorReady = useCallback((editor: TiptapEditor | null) => {
86
+ editorRef.current = editor;
87
+ setEditorInstance(editor);
88
+ }, []);
89
+
90
+ const handleExport = () => {
91
+ if (!editorRef.current) return;
92
+ const json = editorRef.current.getJSON();
93
+ const fm = frontmatterStore?.getAll();
94
+ setMdxOutput(toMdx(json, fm));
95
+ setShowExport((v) => !v);
96
+ };
97
+
98
+ const onCommentStoreReady = useCallback((store: CommentStore) => {
99
+ setCommentStore(store);
100
+ }, []);
101
+
102
+ const onFrontmatterStoreReady = useCallback((store: FrontmatterStore) => {
103
+ setFrontmatterStore(store);
104
+ }, []);
105
+
106
+ const onSettingsMapReady = useCallback((map: Y.Map<any>) => {
107
+ setSettingsMap(map);
108
+ }, []);
109
+
110
+ const handleAddComment = useCallback(() => {
111
+ const editor = editorRef.current;
112
+ if (!editor) return;
113
+
114
+ const { from, to } = editor.state.selection;
115
+ if (from === to) return;
116
+
117
+ selectionRange.current = { from, to };
118
+ setCommentDialogOpen(true);
119
+ }, []);
120
+
121
+ const handleCommentSubmit = useCallback((text: string) => {
122
+ const editor = editorRef.current;
123
+ if (!editor || !commentStore || !selectionRange.current) return;
124
+
125
+ const { from, to } = selectionRange.current;
126
+ const id = `c_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
127
+
128
+ commentStore.add({
129
+ id,
130
+ author: user.name,
131
+ authorColor: user.color,
132
+ text,
133
+ createdAt: Date.now(),
134
+ resolved: false,
135
+ });
136
+
137
+ editor
138
+ .chain()
139
+ .focus()
140
+ .setTextSelection({ from, to })
141
+ .setComment(id)
142
+ .run();
143
+
144
+ selectionRange.current = null;
145
+ }, [commentStore, user]);
146
+
147
+ return (
148
+ <Box
149
+ sx={{
150
+ height: "100vh",
151
+ display: "flex",
152
+ flexDirection: "column",
153
+ bgcolor: "background.default",
154
+ overflow: "hidden",
155
+ }}
156
+ >
157
+ {/* Top bar - minimal */}
158
+ <Box
159
+ sx={{
160
+ flexShrink: 0,
161
+ display: "flex",
162
+ alignItems: "center",
163
+ justifyContent: "flex-end",
164
+ px: 2,
165
+ py: 0.75,
166
+ gap: 0.5,
167
+ }}
168
+ >
169
+ <Tooltip title="Undo" arrow>
170
+ <IconButton
171
+ size="small"
172
+ onClick={() => editorInstance?.commands.undo()}
173
+ sx={{ color: "text.disabled" }}
174
+ >
175
+ <UndoIcon sx={{ fontSize: 18 }} />
176
+ </IconButton>
177
+ </Tooltip>
178
+ <Tooltip title="Redo" arrow>
179
+ <IconButton
180
+ size="small"
181
+ onClick={() => editorInstance?.commands.redo()}
182
+ sx={{ color: "text.disabled" }}
183
+ >
184
+ <RedoIcon sx={{ fontSize: 18 }} />
185
+ </IconButton>
186
+ </Tooltip>
187
+ <Box sx={{ width: "1px", height: 16, bgcolor: "divider", mx: 0.5 }} />
188
+ <Tooltip title="Article settings" arrow>
189
+ <IconButton size="small" onClick={() => setSettingsOpen(true)} sx={{ color: "text.disabled" }}>
190
+ <SettingsOutlinedIcon sx={{ fontSize: 18 }} />
191
+ </IconButton>
192
+ </Tooltip>
193
+ <Tooltip title="Export MDX" arrow>
194
+ <IconButton size="small" onClick={handleExport} sx={{ color: "text.disabled" }}>
195
+ <FileDownloadOutlinedIcon sx={{ fontSize: 18 }} />
196
+ </IconButton>
197
+ </Tooltip>
198
+ <Chip
199
+ label={user.name}
200
+ size="small"
201
+ sx={{
202
+ bgcolor: user.color,
203
+ color: "#000",
204
+ fontWeight: 600,
205
+ fontSize: "0.65rem",
206
+ height: 22,
207
+ ml: 0.5,
208
+ }}
209
+ />
210
+ </Box>
211
+
212
+ {/* 3-column layout: chat | editor | comments */}
213
+ <Box
214
+ sx={{
215
+ flex: 1,
216
+ display: "grid",
217
+ gridTemplateColumns: "280px 1fr 240px",
218
+ minHeight: 0,
219
+ overflow: "hidden",
220
+ }}
221
+ >
222
+ {/* Chat - fixed, own scroll */}
223
+ <Box
224
+ sx={{
225
+ borderRight: "1px solid",
226
+ borderColor: "divider",
227
+ overflow: "hidden",
228
+ display: "flex",
229
+ }}
230
+ >
231
+ <ChatPanel
232
+ messages={agentChat.messages}
233
+ isLoading={agentChat.isLoading}
234
+ error={agentChat.error}
235
+ input={agentChat.input}
236
+ hasSelection={hasSelection}
237
+ onSend={agentChat.sendMessage}
238
+ onQuickAction={agentChat.sendQuickAction}
239
+ onSetInput={agentChat.setInput}
240
+ onStop={agentChat.stop}
241
+ onNewChat={() => window.location.reload()}
242
+ />
243
+ </Box>
244
+
245
+ {/* Editor - scrolls independently */}
246
+ <Box
247
+ ref={editorContainerCallback}
248
+ sx={{
249
+ overflowY: "auto",
250
+ overflowX: "hidden",
251
+ py: 4,
252
+ }}
253
+ >
254
+ <FrontmatterHero store={frontmatterStore} />
255
+
256
+ <Editor
257
+ docName={docName}
258
+ user={user}
259
+ editorRef={editorRef}
260
+ onCommentStoreReady={onCommentStoreReady}
261
+ onFrontmatterStoreReady={onFrontmatterStoreReady}
262
+ onSettingsMapReady={onSettingsMapReady}
263
+ onEditorReady={onEditorReady}
264
+ onUndoManagerReady={setUndoManager}
265
+ onAddComment={handleAddComment}
266
+ />
267
+
268
+ <Collapse in={showExport}>
269
+ <Paper
270
+ variant="outlined"
271
+ sx={{
272
+ mt: 4,
273
+ mx: "auto",
274
+ maxWidth: 680,
275
+ p: 2.5,
276
+ fontFamily: "monospace",
277
+ fontSize: "0.8rem",
278
+ whiteSpace: "pre-wrap",
279
+ maxHeight: 400,
280
+ overflow: "auto",
281
+ color: "text.secondary",
282
+ }}
283
+ >
284
+ {mdxOutput}
285
+ </Paper>
286
+ </Collapse>
287
+ </Box>
288
+
289
+ {/* Comments - fixed, own scroll */}
290
+ <Box
291
+ sx={{
292
+ overflowY: "auto",
293
+ overflowX: "hidden",
294
+ pl: 2,
295
+ py: 2,
296
+ }}
297
+ >
298
+ <CommentsSidebar
299
+ editor={editorInstance}
300
+ commentStore={commentStore}
301
+ user={user}
302
+ editorContainer={containerEl}
303
+ />
304
+ </Box>
305
+ </Box>
306
+
307
+ <CommentDialog
308
+ open={commentDialogOpen}
309
+ onClose={() => setCommentDialogOpen(false)}
310
+ onSubmit={handleCommentSubmit}
311
+ />
312
+
313
+ <SettingsDrawer
314
+ open={settingsOpen}
315
+ onClose={() => setSettingsOpen(false)}
316
+ store={frontmatterStore}
317
+ settingsMap={settingsMap}
318
+ />
319
+ </Box>
320
+ );
321
+ }
frontend/src/components/ChatPanel.tsx ADDED
@@ -0,0 +1,319 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useRef, useEffect, type KeyboardEvent } from "react";
2
+ import {
3
+ Box,
4
+ Typography,
5
+ IconButton,
6
+ TextField,
7
+ Tooltip,
8
+ Chip,
9
+ CircularProgress,
10
+ } from "@mui/material";
11
+ import SendIcon from "@mui/icons-material/Send";
12
+ import StopIcon from "@mui/icons-material/Stop";
13
+ import AddIcon from "@mui/icons-material/Add";
14
+ import AutoFixHighIcon from "@mui/icons-material/AutoFixHigh";
15
+ import ShortTextIcon from "@mui/icons-material/ShortText";
16
+ import ExpandIcon from "@mui/icons-material/Expand";
17
+ import SpellcheckIcon from "@mui/icons-material/Spellcheck";
18
+ import TranslateIcon from "@mui/icons-material/Translate";
19
+ import CompressIcon from "@mui/icons-material/Compress";
20
+ import type { UIMessage } from "ai";
21
+
22
+ interface ChatPanelProps {
23
+ messages: UIMessage[];
24
+ isLoading: boolean;
25
+ error: Error | undefined;
26
+ input: string;
27
+ hasSelection: boolean;
28
+ onSend: (content: string) => void;
29
+ onQuickAction: (action: string) => void;
30
+ onSetInput: (value: string) => void;
31
+ onStop: () => void;
32
+ onNewChat: () => void;
33
+ }
34
+
35
+ const QUICK_ACTIONS = [
36
+ { id: "rewrite", label: "Rewrite", icon: <AutoFixHighIcon sx={{ fontSize: 14 }} /> },
37
+ { id: "expand", label: "Expand", icon: <ExpandIcon sx={{ fontSize: 14 }} /> },
38
+ { id: "summarize", label: "Summarize", icon: <CompressIcon sx={{ fontSize: 14 }} /> },
39
+ { id: "fix-grammar", label: "Fix grammar", icon: <SpellcheckIcon sx={{ fontSize: 14 }} /> },
40
+ { id: "translate", label: "Translate", icon: <TranslateIcon sx={{ fontSize: 14 }} /> },
41
+ { id: "simplify", label: "Simplify", icon: <ShortTextIcon sx={{ fontSize: 14 }} /> },
42
+ ];
43
+
44
+ export function ChatPanel({
45
+ messages,
46
+ isLoading,
47
+ error,
48
+ input,
49
+ hasSelection,
50
+ onSend,
51
+ onQuickAction,
52
+ onSetInput,
53
+ onStop,
54
+ onNewChat,
55
+ }: ChatPanelProps) {
56
+ const scrollRef = useRef<HTMLDivElement>(null);
57
+
58
+ useEffect(() => {
59
+ if (scrollRef.current) {
60
+ scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
61
+ }
62
+ }, [messages]);
63
+
64
+ const inputValue = input ?? "";
65
+
66
+ const handleSubmit = () => {
67
+ if (!inputValue.trim() || isLoading) return;
68
+ onSend(inputValue.trim());
69
+ onSetInput("");
70
+ };
71
+
72
+ const handleKeyDown = (e: KeyboardEvent) => {
73
+ if (e.key === "Enter" && !e.shiftKey) {
74
+ e.preventDefault();
75
+ handleSubmit();
76
+ }
77
+ };
78
+
79
+ return (
80
+ <Box
81
+ sx={{
82
+ display: "flex",
83
+ flexDirection: "column",
84
+ height: "100%",
85
+ width: "100%",
86
+ }}
87
+ >
88
+ {/* Header */}
89
+ <Box
90
+ sx={{
91
+ display: "flex",
92
+ alignItems: "center",
93
+ justifyContent: "space-between",
94
+ px: 1.5,
95
+ py: 1,
96
+ borderBottom: "1px solid",
97
+ borderColor: "divider",
98
+ flexShrink: 0,
99
+ }}
100
+ >
101
+ <Typography variant="caption" sx={{ fontWeight: 600, color: "text.secondary", letterSpacing: "0.05em", textTransform: "uppercase" }}>
102
+ Assistant
103
+ </Typography>
104
+ <Tooltip title="New conversation" arrow>
105
+ <IconButton size="small" onClick={onNewChat} sx={{ color: "text.disabled" }}>
106
+ <AddIcon sx={{ fontSize: 16 }} />
107
+ </IconButton>
108
+ </Tooltip>
109
+ </Box>
110
+
111
+ {/* Messages */}
112
+ <Box
113
+ ref={scrollRef}
114
+ sx={{
115
+ flex: 1,
116
+ overflow: "auto",
117
+ px: 1.5,
118
+ py: 1,
119
+ display: "flex",
120
+ flexDirection: "column",
121
+ gap: 1.5,
122
+ }}
123
+ >
124
+ {messages.length === 0 && (
125
+ <Box sx={{ display: "flex", alignItems: "center", justifyContent: "center", flex: 1, opacity: 0.4 }}>
126
+ <Typography variant="caption" sx={{ textAlign: "center", maxWidth: 200 }}>
127
+ Ask me to write, edit, expand, or improve your article.
128
+ </Typography>
129
+ </Box>
130
+ )}
131
+
132
+ {messages.map((msg) => (
133
+ <MessageBubble key={msg.id} message={msg} />
134
+ ))}
135
+
136
+ {isLoading && messages.length > 0 && !messages[messages.length - 1]?.content && (
137
+ <Box sx={{ display: "flex", alignItems: "center", gap: 1, px: 1 }}>
138
+ <CircularProgress size={12} sx={{ color: "text.disabled" }} />
139
+ <Typography variant="caption" sx={{ color: "text.disabled" }}>
140
+ Thinking...
141
+ </Typography>
142
+ </Box>
143
+ )}
144
+
145
+ {error && (
146
+ <Box sx={{ px: 1, py: 0.5, bgcolor: "error.dark", borderRadius: 1, opacity: 0.8 }}>
147
+ <Typography variant="caption" sx={{ color: "error.contrastText" }}>
148
+ {error.message}
149
+ </Typography>
150
+ </Box>
151
+ )}
152
+ </Box>
153
+
154
+ {/* Quick actions */}
155
+ {hasSelection && (
156
+ <Box
157
+ sx={{
158
+ display: "flex",
159
+ flexWrap: "wrap",
160
+ gap: 0.5,
161
+ px: 1.5,
162
+ py: 0.75,
163
+ borderTop: "1px solid",
164
+ borderColor: "divider",
165
+ flexShrink: 0,
166
+ }}
167
+ >
168
+ {QUICK_ACTIONS.map((action) => (
169
+ <Chip
170
+ key={action.id}
171
+ label={action.label}
172
+ icon={action.icon}
173
+ size="small"
174
+ variant="outlined"
175
+ onClick={() => onQuickAction(action.id)}
176
+ sx={{
177
+ fontSize: "0.65rem",
178
+ height: 24,
179
+ cursor: "pointer",
180
+ borderColor: "divider",
181
+ "&:hover": { borderColor: "text.secondary", bgcolor: "action.hover" },
182
+ }}
183
+ />
184
+ ))}
185
+ </Box>
186
+ )}
187
+
188
+ {/* Input */}
189
+ <Box
190
+ sx={{
191
+ px: 1.5,
192
+ py: 1,
193
+ borderTop: "1px solid",
194
+ borderColor: "divider",
195
+ flexShrink: 0,
196
+ }}
197
+ >
198
+ <Box sx={{ display: "flex", gap: 0.5, alignItems: "flex-end" }}>
199
+ <TextField
200
+ multiline
201
+ maxRows={4}
202
+ fullWidth
203
+ size="small"
204
+ placeholder="Ask anything..."
205
+ value={inputValue}
206
+ onChange={(e) => onSetInput(e.target.value)}
207
+ onKeyDown={handleKeyDown}
208
+ variant="standard"
209
+ slotProps={{
210
+ input: {
211
+ disableUnderline: true,
212
+ sx: {
213
+ fontSize: "0.85rem",
214
+ lineHeight: 1.5,
215
+ py: 0.5,
216
+ },
217
+ },
218
+ }}
219
+ />
220
+ {isLoading ? (
221
+ <IconButton size="small" onClick={onStop} sx={{ color: "warning.main", flexShrink: 0 }}>
222
+ <StopIcon sx={{ fontSize: 18 }} />
223
+ </IconButton>
224
+ ) : (
225
+ <IconButton
226
+ size="small"
227
+ onClick={handleSubmit}
228
+ disabled={!inputValue.trim()}
229
+ sx={{ color: inputValue.trim() ? "primary.main" : "text.disabled", flexShrink: 0 }}
230
+ >
231
+ <SendIcon sx={{ fontSize: 18 }} />
232
+ </IconButton>
233
+ )}
234
+ </Box>
235
+ </Box>
236
+ </Box>
237
+ );
238
+ }
239
+
240
+ function MessageBubble({ message }: { message: UIMessage }) {
241
+ const isUser = message.role === "user";
242
+
243
+ const parts = message.parts ?? [];
244
+ const textParts = parts.filter((p) => p.type === "text");
245
+ const toolParts = parts.filter((p) => p.type === "tool-invocation");
246
+ const textContent = textParts.length > 0
247
+ ? textParts.map((p) => (p as { type: "text"; text: string }).text).join("")
248
+ : (message as any).content ?? "";
249
+
250
+ if (!textContent && message.role === "assistant" && toolParts.length === 0) return null;
251
+
252
+ return (
253
+ <Box sx={{ display: "flex", flexDirection: "column", gap: 0.5 }}>
254
+ {textContent && (
255
+ <Box
256
+ sx={{
257
+ alignSelf: isUser ? "flex-end" : "flex-start",
258
+ maxWidth: "95%",
259
+ px: 1.5,
260
+ py: 0.75,
261
+ borderRadius: 2,
262
+ bgcolor: isUser ? "rgba(149, 141, 241, 0.15)" : "transparent",
263
+ }}
264
+ >
265
+ <Typography
266
+ variant="body2"
267
+ sx={{
268
+ fontSize: "0.8rem",
269
+ lineHeight: 1.6,
270
+ color: isUser ? "text.primary" : "text.secondary",
271
+ whiteSpace: "pre-wrap",
272
+ wordBreak: "break-word",
273
+ }}
274
+ >
275
+ {textContent}
276
+ </Typography>
277
+ </Box>
278
+ )}
279
+
280
+ {toolParts.map((part, i) => {
281
+ const tool = part as { type: "tool-invocation"; toolInvocation: { toolName: string; state: string; result?: unknown } };
282
+ return (
283
+ <Box
284
+ key={i}
285
+ sx={{
286
+ display: "flex",
287
+ alignItems: "center",
288
+ gap: 0.5,
289
+ px: 1.5,
290
+ opacity: 0.6,
291
+ }}
292
+ >
293
+ <AutoFixHighIcon sx={{ fontSize: 12 }} />
294
+ <Typography variant="caption" sx={{ fontSize: "0.65rem" }}>
295
+ {toolLabel(tool.toolInvocation.toolName)}
296
+ {tool.toolInvocation.state === "result" && tool.toolInvocation.result
297
+ ? ` - ${tool.toolInvocation.result}`
298
+ : tool.toolInvocation.state === "call"
299
+ ? " (executing...)"
300
+ : ""}
301
+ </Typography>
302
+ </Box>
303
+ );
304
+ })}
305
+ </Box>
306
+ );
307
+ }
308
+
309
+ function toolLabel(name: string): string {
310
+ const labels: Record<string, string> = {
311
+ replaceSelection: "Replaced selection",
312
+ insertAtCursor: "Inserted text",
313
+ applyDiff: "Applied edit",
314
+ updateFrontmatter: "Updated metadata",
315
+ addAuthor: "Added author",
316
+ removeAuthor: "Removed author",
317
+ };
318
+ return labels[name] || name;
319
+ }
frontend/src/components/CommentDialog.tsx ADDED
@@ -0,0 +1,80 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useEffect, useRef } from "react";
2
+ import {
3
+ Dialog,
4
+ DialogContent,
5
+ TextField,
6
+ Button,
7
+ Box,
8
+ } from "@mui/material";
9
+
10
+ interface CommentDialogProps {
11
+ open: boolean;
12
+ onClose: () => void;
13
+ onSubmit: (text: string) => void;
14
+ }
15
+
16
+ export function CommentDialog({ open, onClose, onSubmit }: CommentDialogProps) {
17
+ const [text, setText] = useState("");
18
+ const inputRef = useRef<HTMLInputElement>(null);
19
+
20
+ useEffect(() => {
21
+ if (open) {
22
+ setText("");
23
+ setTimeout(() => inputRef.current?.focus(), 100);
24
+ }
25
+ }, [open]);
26
+
27
+ const handleSubmit = () => {
28
+ if (!text.trim()) return;
29
+ onSubmit(text.trim());
30
+ setText("");
31
+ onClose();
32
+ };
33
+
34
+ return (
35
+ <Dialog
36
+ open={open}
37
+ onClose={onClose}
38
+ maxWidth="xs"
39
+ fullWidth
40
+ PaperProps={{
41
+ sx: {
42
+ bgcolor: "background.paper",
43
+ backgroundImage: "none",
44
+ },
45
+ }}
46
+ >
47
+ <DialogContent sx={{ p: 2 }}>
48
+ <TextField
49
+ inputRef={inputRef}
50
+ multiline
51
+ minRows={2}
52
+ maxRows={4}
53
+ fullWidth
54
+ size="small"
55
+ placeholder="Add your comment..."
56
+ value={text}
57
+ onChange={(e) => setText(e.target.value)}
58
+ onKeyDown={(e) => {
59
+ if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) handleSubmit();
60
+ }}
61
+ sx={{ mb: 1.5 }}
62
+ />
63
+ <Box sx={{ display: "flex", gap: 1, justifyContent: "flex-end" }}>
64
+ <Button size="small" onClick={onClose} sx={{ textTransform: "none" }}>
65
+ Cancel
66
+ </Button>
67
+ <Button
68
+ size="small"
69
+ variant="contained"
70
+ onClick={handleSubmit}
71
+ disabled={!text.trim()}
72
+ sx={{ textTransform: "none" }}
73
+ >
74
+ Comment
75
+ </Button>
76
+ </Box>
77
+ </DialogContent>
78
+ </Dialog>
79
+ );
80
+ }
frontend/src/components/CommentsSidebar.tsx ADDED
@@ -0,0 +1,233 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useEffect, useCallback, useRef } from "react";
2
+ import {
3
+ Box,
4
+ Typography,
5
+ IconButton,
6
+ Chip,
7
+ Paper,
8
+ Tooltip,
9
+ Fade,
10
+ } from "@mui/material";
11
+ import CheckCircleOutlinedIcon from "@mui/icons-material/CheckCircleOutlined";
12
+ import DeleteOutlinedIcon from "@mui/icons-material/DeleteOutlined";
13
+ import type { Editor } from "@tiptap/core";
14
+ import type { CommentStore, CommentData } from "../editor/comments";
15
+
16
+ interface CommentsSidebarProps {
17
+ editor: Editor | null;
18
+ commentStore: CommentStore | null;
19
+ user: { name: string; color: string };
20
+ editorContainer: HTMLElement | null;
21
+ }
22
+
23
+ interface PositionedComment extends CommentData {
24
+ top: number;
25
+ }
26
+
27
+ export function CommentsSidebar({ editor, commentStore, user, editorContainer }: CommentsSidebarProps) {
28
+ const [comments, setComments] = useState<CommentData[]>([]);
29
+ const [positioned, setPositioned] = useState<PositionedComment[]>([]);
30
+ const [activeId, setActiveId] = useState<string | null>(null);
31
+ const sidebarRef = useRef<HTMLDivElement>(null);
32
+
33
+ const refresh = useCallback(() => {
34
+ if (!commentStore) return;
35
+ setComments(commentStore.getAll());
36
+ }, [commentStore]);
37
+
38
+ useEffect(() => {
39
+ if (!commentStore) return;
40
+ refresh();
41
+ return commentStore.observe(refresh);
42
+ }, [commentStore, refresh]);
43
+
44
+ // Compute Y positions of comment marks in the editor
45
+ const updatePositions = useCallback(() => {
46
+ if (!editor || !editorContainer) return;
47
+
48
+ const view = editor.view;
49
+ const containerRect = editorContainer.getBoundingClientRect();
50
+ const active = comments.filter((c) => !c.resolved);
51
+ const result: PositionedComment[] = [];
52
+
53
+ for (const comment of active) {
54
+ let markPos = -1;
55
+
56
+ view.state.doc.descendants((node, pos) => {
57
+ if (markPos >= 0) return false;
58
+ for (const mark of node.marks) {
59
+ if (mark.type.name === "comment" && mark.attrs.commentId === comment.id) {
60
+ markPos = pos;
61
+ return false;
62
+ }
63
+ }
64
+ });
65
+
66
+ if (markPos >= 0) {
67
+ try {
68
+ const coords = view.coordsAtPos(markPos);
69
+ const top = coords.top - containerRect.top;
70
+ result.push({ ...comment, top });
71
+ } catch {
72
+ // Position not available yet
73
+ }
74
+ }
75
+ }
76
+
77
+ // Avoid overlaps: push comments down if they'd overlap the previous one
78
+ result.sort((a, b) => a.top - b.top);
79
+ const MIN_GAP = 80;
80
+ for (let i = 1; i < result.length; i++) {
81
+ if (result[i].top < result[i - 1].top + MIN_GAP) {
82
+ result[i].top = result[i - 1].top + MIN_GAP;
83
+ }
84
+ }
85
+
86
+ setPositioned(result);
87
+ }, [editor, editorContainer, comments]);
88
+
89
+ useEffect(() => {
90
+ updatePositions();
91
+
92
+ const onScroll = () => updatePositions();
93
+ const editorEl = editor?.view?.dom;
94
+ const scrollTarget = editorContainer;
95
+
96
+ if (editorEl) {
97
+ if (scrollTarget) {
98
+ scrollTarget.addEventListener("scroll", onScroll, { passive: true });
99
+ }
100
+ const observer = new MutationObserver(updatePositions);
101
+ observer.observe(editorEl, { childList: true, subtree: true, characterData: true });
102
+ return () => {
103
+ scrollTarget?.removeEventListener("scroll", onScroll);
104
+ observer.disconnect();
105
+ };
106
+ }
107
+ }, [editor, editorContainer, updatePositions]);
108
+
109
+ const handleResolve = (id: string) => {
110
+ if (!commentStore) return;
111
+ commentStore.resolve(id, user.name);
112
+ };
113
+
114
+ const handleUnresolve = (id: string) => {
115
+ if (!commentStore) return;
116
+ commentStore.unresolve(id);
117
+ };
118
+
119
+ const handleDelete = (id: string) => {
120
+ if (!commentStore || !editor) return;
121
+ commentStore.remove(id);
122
+
123
+ const { doc, tr } = editor.state;
124
+ const markType = editor.schema.marks.comment;
125
+ doc.descendants((node, pos) => {
126
+ node.marks.forEach((mark) => {
127
+ if (mark.type === markType && mark.attrs.commentId === id) {
128
+ tr.removeMark(pos, pos + node.nodeSize, markType);
129
+ }
130
+ });
131
+ });
132
+ editor.view.dispatch(tr);
133
+ };
134
+
135
+ return (
136
+ <Box ref={sidebarRef} sx={{ position: "relative", width: "100%", minHeight: "100%" }}>
137
+ {/* Positioned comments */}
138
+ {positioned.map((c) => (
139
+ <Fade in key={c.id}>
140
+ <Box
141
+ sx={{
142
+ position: "absolute",
143
+ top: c.top,
144
+ left: 0,
145
+ right: 0,
146
+ transition: "top 0.2s ease",
147
+ }}
148
+ >
149
+ <CommentCard
150
+ comment={c}
151
+ active={activeId === c.id}
152
+ onResolve={() => handleResolve(c.id)}
153
+ onDelete={() => handleDelete(c.id)}
154
+ onClick={() => setActiveId(activeId === c.id ? null : c.id)}
155
+ />
156
+ </Box>
157
+ </Fade>
158
+ ))}
159
+ </Box>
160
+ );
161
+ }
162
+
163
+ function CommentCard({
164
+ comment,
165
+ active,
166
+ onResolve,
167
+ onDelete,
168
+ onClick,
169
+ }: {
170
+ comment: CommentData;
171
+ active?: boolean;
172
+ onResolve: () => void;
173
+ onDelete: () => void;
174
+ onClick: () => void;
175
+ }) {
176
+ const time = new Date(comment.createdAt).toLocaleString(undefined, {
177
+ month: "short",
178
+ day: "numeric",
179
+ hour: "2-digit",
180
+ minute: "2-digit",
181
+ });
182
+
183
+ return (
184
+ <Paper
185
+ variant="outlined"
186
+ onClick={onClick}
187
+ sx={{
188
+ p: 1.25,
189
+ cursor: "pointer",
190
+ borderLeft: 3,
191
+ borderLeftColor: comment.authorColor,
192
+ borderColor: active ? "primary.main" : "divider",
193
+ bgcolor: active ? "rgba(149,141,241,0.05)" : "background.paper",
194
+ transition: "all 0.15s",
195
+ "&:hover": { borderColor: "primary.main" },
196
+ }}
197
+ >
198
+ <Box sx={{ display: "flex", alignItems: "center", gap: 0.5, mb: 0.5 }}>
199
+ <Chip
200
+ label={comment.author}
201
+ size="small"
202
+ sx={{
203
+ bgcolor: comment.authorColor,
204
+ color: "#000",
205
+ fontWeight: 600,
206
+ fontSize: "0.65rem",
207
+ height: 18,
208
+ }}
209
+ />
210
+ <Typography variant="caption" sx={{ color: "text.disabled", ml: "auto", fontSize: "0.65rem" }}>
211
+ {time}
212
+ </Typography>
213
+ </Box>
214
+
215
+ <Typography variant="body2" sx={{ fontSize: "0.8rem", color: "text.primary", mb: 0.75, lineHeight: 1.5 }}>
216
+ {comment.text}
217
+ </Typography>
218
+
219
+ <Box sx={{ display: "flex", gap: 0.25 }}>
220
+ <Tooltip title="Resolve" arrow>
221
+ <IconButton size="small" onClick={(e) => { e.stopPropagation(); onResolve(); }} sx={{ color: "success.main", p: 0.5 }}>
222
+ <CheckCircleOutlinedIcon sx={{ fontSize: 16 }} />
223
+ </IconButton>
224
+ </Tooltip>
225
+ <Tooltip title="Delete" arrow>
226
+ <IconButton size="small" onClick={(e) => { e.stopPropagation(); onDelete(); }} sx={{ color: "error.main", p: 0.5 }}>
227
+ <DeleteOutlinedIcon sx={{ fontSize: 16 }} />
228
+ </IconButton>
229
+ </Tooltip>
230
+ </Box>
231
+ </Paper>
232
+ );
233
+ }
frontend/src/editor.css ADDED
@@ -0,0 +1,1076 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* Notion-like editor typography */
2
+ .tiptap {
3
+ outline: none;
4
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
5
+ font-size: 1rem;
6
+ line-height: 1.7;
7
+ color: rgba(255, 255, 255, 0.85);
8
+ max-width: 780px;
9
+ margin-left: auto;
10
+ margin-right: auto;
11
+ padding: 0 32px 0 64px;
12
+ min-height: 100%;
13
+ overflow-wrap: break-word;
14
+ word-break: break-word;
15
+ }
16
+
17
+ .tiptap > *:first-child {
18
+ margin-top: 0;
19
+ }
20
+
21
+ /* Headings */
22
+ .tiptap h1 {
23
+ font-size: 2rem;
24
+ font-weight: 700;
25
+ color: #fff;
26
+ line-height: 1.2;
27
+ margin: 2em 0 0.4em;
28
+ letter-spacing: -0.02em;
29
+ }
30
+
31
+ .tiptap h1:first-child {
32
+ margin-top: 0;
33
+ }
34
+
35
+ .tiptap h2 {
36
+ font-size: 1.5rem;
37
+ font-weight: 600;
38
+ color: rgba(255, 255, 255, 0.9);
39
+ line-height: 1.3;
40
+ margin: 1.8em 0 0.4em;
41
+ letter-spacing: -0.01em;
42
+ }
43
+
44
+ .tiptap h3 {
45
+ font-size: 1.2rem;
46
+ font-weight: 600;
47
+ color: rgba(255, 255, 255, 0.85);
48
+ line-height: 1.4;
49
+ margin: 1.5em 0 0.3em;
50
+ }
51
+
52
+ /* Paragraphs */
53
+ .tiptap p {
54
+ margin: 0.5em 0;
55
+ }
56
+
57
+ /* Lists */
58
+ .tiptap ul,
59
+ .tiptap ol {
60
+ padding-left: 1.5rem;
61
+ margin: 0.5em 0;
62
+ }
63
+
64
+ .tiptap li {
65
+ margin: 0.25em 0;
66
+ }
67
+
68
+ .tiptap li p {
69
+ margin: 0.15em 0;
70
+ }
71
+
72
+ /* Blockquote */
73
+ .tiptap blockquote {
74
+ border-left: 3px solid rgba(255, 255, 255, 0.2);
75
+ padding-left: 1rem;
76
+ margin: 1em 0;
77
+ color: rgba(255, 255, 255, 0.6);
78
+ }
79
+
80
+ .tiptap blockquote p {
81
+ margin: 0.4em 0;
82
+ }
83
+
84
+ /* Inline code */
85
+ .tiptap code {
86
+ font-family: "SFMono-Regular", "Fira Code", "Fira Mono", Menlo, Consolas, monospace;
87
+ background: rgba(255, 255, 255, 0.06);
88
+ padding: 0.15em 0.4em;
89
+ border-radius: 4px;
90
+ font-size: 0.88em;
91
+ color: #e06c75;
92
+ }
93
+
94
+ /* Code block */
95
+ .tiptap pre {
96
+ background: rgba(255, 255, 255, 0.03);
97
+ border: 1px solid rgba(255, 255, 255, 0.08);
98
+ border-radius: 8px;
99
+ padding: 1rem 1.25rem;
100
+ margin: 1em 0;
101
+ overflow-x: auto;
102
+ }
103
+
104
+ .tiptap pre code {
105
+ background: none;
106
+ padding: 0;
107
+ color: rgba(255, 255, 255, 0.7);
108
+ font-size: 0.875rem;
109
+ line-height: 1.6;
110
+ }
111
+
112
+ /* Syntax highlighting (One Dark inspired) */
113
+ .tiptap pre code .hljs-comment,
114
+ .tiptap pre code .hljs-quote {
115
+ color: #5c6370;
116
+ font-style: italic;
117
+ }
118
+
119
+ .tiptap pre code .hljs-keyword,
120
+ .tiptap pre code .hljs-selector-tag,
121
+ .tiptap pre code .hljs-addition {
122
+ color: #c678dd;
123
+ }
124
+
125
+ .tiptap pre code .hljs-string,
126
+ .tiptap pre code .hljs-meta .hljs-string,
127
+ .tiptap pre code .hljs-regexp,
128
+ .tiptap pre code .hljs-doctag {
129
+ color: #98c379;
130
+ }
131
+
132
+ .tiptap pre code .hljs-number,
133
+ .tiptap pre code .hljs-literal,
134
+ .tiptap pre code .hljs-bullet {
135
+ color: #d19a66;
136
+ }
137
+
138
+ .tiptap pre code .hljs-variable,
139
+ .tiptap pre code .hljs-template-variable,
140
+ .tiptap pre code .hljs-tag .hljs-attr {
141
+ color: #e06c75;
142
+ }
143
+
144
+ .tiptap pre code .hljs-type,
145
+ .tiptap pre code .hljs-built_in,
146
+ .tiptap pre code .hljs-class .hljs-title,
147
+ .tiptap pre code .hljs-title.class_ {
148
+ color: #e5c07b;
149
+ }
150
+
151
+ .tiptap pre code .hljs-function,
152
+ .tiptap pre code .hljs-title,
153
+ .tiptap pre code .hljs-section {
154
+ color: #61afef;
155
+ }
156
+
157
+ .tiptap pre code .hljs-name,
158
+ .tiptap pre code .hljs-tag {
159
+ color: #e06c75;
160
+ }
161
+
162
+ .tiptap pre code .hljs-attr {
163
+ color: #d19a66;
164
+ }
165
+
166
+ .tiptap pre code .hljs-symbol,
167
+ .tiptap pre code .hljs-link {
168
+ color: #56b6c2;
169
+ }
170
+
171
+ .tiptap pre code .hljs-meta,
172
+ .tiptap pre code .hljs-selector-id,
173
+ .tiptap pre code .hljs-selector-class {
174
+ color: #61afef;
175
+ }
176
+
177
+ .tiptap pre code .hljs-deletion {
178
+ color: #e06c75;
179
+ }
180
+
181
+ .tiptap pre code .hljs-emphasis {
182
+ font-style: italic;
183
+ }
184
+
185
+ .tiptap pre code .hljs-strong {
186
+ font-weight: 700;
187
+ }
188
+
189
+ /* Horizontal rule */
190
+ .tiptap hr {
191
+ border: none;
192
+ border-top: 1px solid rgba(255, 255, 255, 0.1);
193
+ margin: 2em 0;
194
+ }
195
+
196
+ /* Tables */
197
+ .tiptap table {
198
+ border-collapse: collapse;
199
+ width: 100%;
200
+ margin: 1em 0;
201
+ overflow: hidden;
202
+ border-radius: 6px;
203
+ border: 1px solid rgba(255, 255, 255, 0.08);
204
+ }
205
+
206
+ .tiptap th,
207
+ .tiptap td {
208
+ padding: 0.5rem 0.75rem;
209
+ border: 1px solid rgba(255, 255, 255, 0.08);
210
+ text-align: left;
211
+ font-size: 0.875rem;
212
+ min-width: 80px;
213
+ }
214
+
215
+ .tiptap th {
216
+ background: rgba(255, 255, 255, 0.04);
217
+ color: rgba(255, 255, 255, 0.7);
218
+ font-weight: 600;
219
+ font-size: 0.8125rem;
220
+ text-transform: uppercase;
221
+ letter-spacing: 0.03em;
222
+ }
223
+
224
+ .tiptap td {
225
+ color: rgba(255, 255, 255, 0.75);
226
+ }
227
+
228
+ .tiptap tr:hover td {
229
+ background: rgba(255, 255, 255, 0.02);
230
+ }
231
+
232
+ .tiptap .selectedCell {
233
+ background: rgba(149, 141, 241, 0.1);
234
+ }
235
+
236
+ /* Links */
237
+ .tiptap a,
238
+ .tiptap .editor-link {
239
+ color: #958DF1;
240
+ text-decoration: underline;
241
+ text-decoration-color: rgba(149, 141, 241, 0.4);
242
+ text-underline-offset: 2px;
243
+ transition: text-decoration-color 0.15s;
244
+ }
245
+
246
+ .tiptap a:hover,
247
+ .tiptap .editor-link:hover {
248
+ text-decoration-color: rgba(149, 141, 241, 0.8);
249
+ }
250
+
251
+ /* Bold */
252
+ .tiptap strong {
253
+ font-weight: 600;
254
+ color: rgba(255, 255, 255, 0.95);
255
+ }
256
+
257
+ /* Comment marks */
258
+ .comment-mark {
259
+ background: rgba(149, 141, 241, 0.15);
260
+ border-bottom: 2px solid rgba(149, 141, 241, 0.4);
261
+ cursor: pointer;
262
+ border-radius: 2px;
263
+ transition: background 0.15s;
264
+ }
265
+
266
+ .comment-mark:hover {
267
+ background: rgba(149, 141, 241, 0.3);
268
+ }
269
+
270
+ .comment-mark.resolved {
271
+ background: transparent;
272
+ border-bottom: 1px dashed rgba(255, 255, 255, 0.1);
273
+ }
274
+
275
+ /* Collaboration cursors */
276
+ .collaboration-cursor__caret {
277
+ position: relative;
278
+ margin-left: -1px;
279
+ margin-right: -1px;
280
+ border-left: 2px solid;
281
+ word-break: normal;
282
+ pointer-events: none;
283
+ }
284
+
285
+ .collaboration-cursor__label {
286
+ position: absolute;
287
+ top: -1.4em;
288
+ left: -1px;
289
+ font-family: -apple-system, BlinkMacSystemFont, sans-serif;
290
+ font-size: 0.65rem;
291
+ font-weight: 600;
292
+ padding: 0.1rem 0.35rem;
293
+ border-radius: 3px 3px 3px 0;
294
+ white-space: nowrap;
295
+ color: #000;
296
+ user-select: none;
297
+ pointer-events: none;
298
+ }
299
+
300
+ /* Placeholder */
301
+ .tiptap p.is-editor-empty:first-child::before {
302
+ content: attr(data-placeholder);
303
+ float: left;
304
+ color: rgba(255, 255, 255, 0.25);
305
+ pointer-events: none;
306
+ height: 0;
307
+ }
308
+
309
+ /* Image upload card */
310
+ .image-upload-card {
311
+ border: 2px dashed rgba(255, 255, 255, 0.1);
312
+ border-radius: 12px;
313
+ padding: 2.5rem 1.5rem;
314
+ margin: 1em 0;
315
+ display: flex;
316
+ flex-direction: column;
317
+ align-items: center;
318
+ gap: 1rem;
319
+ transition: border-color 0.2s, background 0.2s;
320
+ user-select: none;
321
+ }
322
+
323
+ .image-upload-card:hover {
324
+ border-color: rgba(255, 255, 255, 0.18);
325
+ background: rgba(255, 255, 255, 0.015);
326
+ }
327
+
328
+ .image-upload-card.dragging {
329
+ border-color: #958DF1;
330
+ background: rgba(149, 141, 241, 0.06);
331
+ }
332
+
333
+ .image-upload-card-icon {
334
+ color: rgba(255, 255, 255, 0.2);
335
+ }
336
+
337
+ .image-upload-card-actions {
338
+ display: flex;
339
+ gap: 0.5rem;
340
+ }
341
+
342
+ .image-upload-btn {
343
+ border: none;
344
+ border-radius: 6px;
345
+ padding: 0.45rem 1rem;
346
+ font-size: 0.8125rem;
347
+ font-family: inherit;
348
+ font-weight: 500;
349
+ cursor: pointer;
350
+ transition: background 0.15s, color 0.15s;
351
+ }
352
+
353
+ .image-upload-btn.primary {
354
+ background: rgba(149, 141, 241, 0.15);
355
+ color: #b4aef7;
356
+ }
357
+
358
+ .image-upload-btn.primary:hover {
359
+ background: rgba(149, 141, 241, 0.25);
360
+ color: #d0ccfa;
361
+ }
362
+
363
+ .image-upload-btn.secondary {
364
+ background: rgba(255, 255, 255, 0.06);
365
+ color: rgba(255, 255, 255, 0.5);
366
+ }
367
+
368
+ .image-upload-btn.secondary:hover {
369
+ background: rgba(255, 255, 255, 0.1);
370
+ color: rgba(255, 255, 255, 0.7);
371
+ }
372
+
373
+ .image-upload-card-hint {
374
+ font-size: 0.75rem;
375
+ color: rgba(255, 255, 255, 0.25);
376
+ margin: 0;
377
+ }
378
+
379
+ .image-upload-card-url {
380
+ display: flex;
381
+ flex-direction: column;
382
+ align-items: center;
383
+ gap: 0.75rem;
384
+ width: 100%;
385
+ max-width: 400px;
386
+ }
387
+
388
+ .image-upload-url-input {
389
+ width: 100%;
390
+ padding: 0.5rem 0.75rem;
391
+ border: 1px solid rgba(255, 255, 255, 0.12);
392
+ border-radius: 6px;
393
+ background: rgba(255, 255, 255, 0.04);
394
+ color: rgba(255, 255, 255, 0.85);
395
+ font-size: 0.8125rem;
396
+ font-family: inherit;
397
+ outline: none;
398
+ transition: border-color 0.15s;
399
+ }
400
+
401
+ .image-upload-url-input:focus {
402
+ border-color: rgba(149, 141, 241, 0.5);
403
+ }
404
+
405
+ .image-upload-url-input::placeholder {
406
+ color: rgba(255, 255, 255, 0.25);
407
+ }
408
+
409
+ /* Uploaded images */
410
+ .tiptap img {
411
+ max-width: 100%;
412
+ height: auto;
413
+ border-radius: 8px;
414
+ margin: 1em 0;
415
+ }
416
+
417
+ .tiptap .ProseMirror-selectednode img {
418
+ outline: 2px solid #958DF1;
419
+ outline-offset: 2px;
420
+ border-radius: 8px;
421
+ }
422
+
423
+ /* Slash menu */
424
+ .slash-menu {
425
+ background: #1a1a1a;
426
+ border: 1px solid rgba(255, 255, 255, 0.1);
427
+ border-radius: 10px;
428
+ padding: 0.35rem;
429
+ min-width: 220px;
430
+ box-shadow: 0 8px 30px rgba(0, 0, 0, 0.5);
431
+ overflow: hidden;
432
+ }
433
+
434
+ .slash-menu-item {
435
+ display: flex;
436
+ align-items: center;
437
+ gap: 0.75rem;
438
+ padding: 0.5rem 0.65rem;
439
+ border-radius: 6px;
440
+ cursor: pointer;
441
+ transition: background 0.1s;
442
+ border: none;
443
+ background: none;
444
+ width: 100%;
445
+ text-align: left;
446
+ color: rgba(255, 255, 255, 0.8);
447
+ font-size: 0.875rem;
448
+ font-family: inherit;
449
+ }
450
+
451
+ .slash-menu-item:hover,
452
+ .slash-menu-item.is-selected {
453
+ background: rgba(255, 255, 255, 0.08);
454
+ }
455
+
456
+ .slash-menu-item-icon {
457
+ width: 28px;
458
+ height: 28px;
459
+ display: flex;
460
+ align-items: center;
461
+ justify-content: center;
462
+ border-radius: 6px;
463
+ background: rgba(255, 255, 255, 0.05);
464
+ color: rgba(255, 255, 255, 0.5);
465
+ font-size: 0.9rem;
466
+ flex-shrink: 0;
467
+ }
468
+
469
+ .slash-menu-item-content {
470
+ display: flex;
471
+ flex-direction: column;
472
+ }
473
+
474
+ .slash-menu-item-title {
475
+ font-weight: 500;
476
+ color: rgba(255, 255, 255, 0.85);
477
+ font-size: 0.875rem;
478
+ }
479
+
480
+ .slash-menu-item-desc {
481
+ font-size: 0.75rem;
482
+ color: rgba(255, 255, 255, 0.4);
483
+ }
484
+
485
+ /* Math - inline */
486
+ .tiptap [data-type="inline-math"] {
487
+ cursor: pointer;
488
+ }
489
+
490
+ .tiptap [data-type="inline-math"] .tiptap-mathematics-render {
491
+ padding: 0 0.2em;
492
+ border-radius: 3px;
493
+ transition: background 0.15s;
494
+ }
495
+
496
+ .tiptap [data-type="inline-math"] .tiptap-mathematics-render:hover {
497
+ background: rgba(255, 255, 255, 0.06);
498
+ }
499
+
500
+ .tiptap [data-type="inline-math"] .tiptap-mathematics-editor {
501
+ font-family: "SFMono-Regular", "Fira Code", monospace;
502
+ font-size: 0.85em;
503
+ color: #e06c75;
504
+ background: rgba(255, 255, 255, 0.06);
505
+ padding: 0.1em 0.4em;
506
+ border-radius: 3px;
507
+ outline: none;
508
+ }
509
+
510
+ /* Math - block */
511
+ .tiptap [data-type="block-math"] {
512
+ margin: 1em 0;
513
+ cursor: pointer;
514
+ }
515
+
516
+ .tiptap [data-type="block-math"] .tiptap-mathematics-render {
517
+ display: flex;
518
+ justify-content: center;
519
+ padding: 1rem 0;
520
+ border-radius: 8px;
521
+ transition: background 0.15s;
522
+ }
523
+
524
+ .tiptap [data-type="block-math"] .tiptap-mathematics-render:hover {
525
+ background: rgba(255, 255, 255, 0.03);
526
+ }
527
+
528
+ .tiptap [data-type="block-math"] .tiptap-mathematics-editor {
529
+ display: block;
530
+ width: 100%;
531
+ font-family: "SFMono-Regular", "Fira Code", monospace;
532
+ font-size: 0.875rem;
533
+ line-height: 1.6;
534
+ color: #e06c75;
535
+ background: rgba(255, 255, 255, 0.03);
536
+ border: 1px solid rgba(255, 255, 255, 0.08);
537
+ border-radius: 8px;
538
+ padding: 1rem 1.25rem;
539
+ outline: none;
540
+ text-align: center;
541
+ }
542
+
543
+ /* Block handle (Notion-like: + and drag grip) */
544
+ .block-handle {
545
+ display: flex;
546
+ align-items: center;
547
+ gap: 1px;
548
+ height: 28px;
549
+ }
550
+
551
+ .block-handle-btn {
552
+ width: 22px;
553
+ height: 24px;
554
+ display: flex;
555
+ align-items: center;
556
+ justify-content: center;
557
+ border: none;
558
+ background: none;
559
+ color: rgba(255, 255, 255, 0.2);
560
+ border-radius: 4px;
561
+ cursor: pointer;
562
+ transition: color 0.12s, background 0.12s;
563
+ padding: 0;
564
+ line-height: 1;
565
+ }
566
+
567
+ .block-handle-btn:hover {
568
+ color: rgba(255, 255, 255, 0.55);
569
+ background: rgba(255, 255, 255, 0.06);
570
+ }
571
+
572
+ .block-handle-grip {
573
+ cursor: grab;
574
+ }
575
+
576
+ .block-handle-grip:active {
577
+ cursor: grabbing;
578
+ }
579
+
580
+ /* KaTeX color override for dark mode */
581
+ .tiptap .katex {
582
+ color: rgba(255, 255, 255, 0.85);
583
+ }
584
+
585
+ /* Citation inline node */
586
+ .citation-node {
587
+ position: relative;
588
+ color: #958DF1;
589
+ cursor: default;
590
+ font-size: 0.9em;
591
+ white-space: nowrap;
592
+ text-decoration: underline;
593
+ text-decoration-color: rgba(149, 141, 241, 0.3);
594
+ text-underline-offset: 2px;
595
+ transition: text-decoration-color 0.15s;
596
+ }
597
+
598
+ .citation-node:hover {
599
+ text-decoration-color: rgba(149, 141, 241, 0.7);
600
+ }
601
+
602
+ /* Citation hover tooltip */
603
+ .citation-tooltip {
604
+ position: absolute;
605
+ bottom: calc(100% + 8px);
606
+ left: 50%;
607
+ transform: translateX(-50%);
608
+ background: #1e1e1e;
609
+ border: 1px solid rgba(255, 255, 255, 0.12);
610
+ border-radius: 8px;
611
+ padding: 0.75rem;
612
+ min-width: 240px;
613
+ max-width: 320px;
614
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
615
+ z-index: 100;
616
+ white-space: normal;
617
+ pointer-events: auto;
618
+ }
619
+
620
+ .citation-tooltip-title {
621
+ color: rgba(255, 255, 255, 0.9);
622
+ font-size: 0.8125rem;
623
+ font-weight: 500;
624
+ line-height: 1.4;
625
+ margin-bottom: 0.35rem;
626
+ }
627
+
628
+ .citation-tooltip-journal {
629
+ color: rgba(255, 255, 255, 0.5);
630
+ font-size: 0.75rem;
631
+ font-style: italic;
632
+ line-height: 1.3;
633
+ }
634
+
635
+ .citation-tooltip-doi {
636
+ color: rgba(255, 255, 255, 0.35);
637
+ font-size: 0.7rem;
638
+ font-family: "SFMono-Regular", "Fira Code", monospace;
639
+ margin-top: 0.35rem;
640
+ overflow: hidden;
641
+ text-overflow: ellipsis;
642
+ }
643
+
644
+ .citation-tooltip-remove {
645
+ display: block;
646
+ margin-top: 0.5rem;
647
+ margin-left: auto;
648
+ padding: 0.25rem 0.6rem;
649
+ border: none;
650
+ border-radius: 4px;
651
+ background: rgba(224, 108, 117, 0.12);
652
+ color: #e06c75;
653
+ font-size: 0.7rem;
654
+ font-family: inherit;
655
+ cursor: pointer;
656
+ transition: background 0.15s;
657
+ }
658
+
659
+ .citation-tooltip-remove:hover {
660
+ background: rgba(224, 108, 117, 0.25);
661
+ }
662
+
663
+ /* Bibliography block */
664
+ .bibliography-block {
665
+ margin: 2em 0 1em;
666
+ padding-top: 1.5em;
667
+ border-top: 1px solid rgba(255, 255, 255, 0.1);
668
+ }
669
+
670
+ .bibliography-title {
671
+ font-size: 1.3rem;
672
+ font-weight: 600;
673
+ color: rgba(255, 255, 255, 0.9);
674
+ margin: 0 0 1em;
675
+ }
676
+
677
+ .bibliography-empty {
678
+ color: rgba(255, 255, 255, 0.3);
679
+ font-size: 0.875rem;
680
+ font-style: italic;
681
+ }
682
+
683
+ .bibliography-content {
684
+ font-size: 0.875rem;
685
+ line-height: 1.7;
686
+ color: rgba(255, 255, 255, 0.7);
687
+ }
688
+
689
+ .bibliography-content .csl-entry {
690
+ margin-bottom: 0.75em;
691
+ padding-left: 2em;
692
+ text-indent: -2em;
693
+ }
694
+
695
+ .bibliography-content .csl-entry i {
696
+ font-style: italic;
697
+ }
698
+
699
+ /* Citation panel overlay */
700
+ .citation-panel-overlay {
701
+ position: fixed;
702
+ inset: 0;
703
+ background: rgba(0, 0, 0, 0.5);
704
+ display: flex;
705
+ align-items: center;
706
+ justify-content: center;
707
+ z-index: 1000;
708
+ }
709
+
710
+ .citation-panel {
711
+ background: #1a1a1a;
712
+ border: 1px solid rgba(255, 255, 255, 0.1);
713
+ border-radius: 12px;
714
+ width: 480px;
715
+ max-height: 80vh;
716
+ overflow: hidden;
717
+ display: flex;
718
+ flex-direction: column;
719
+ box-shadow: 0 20px 60px rgba(0, 0, 0, 0.6);
720
+ }
721
+
722
+ .citation-panel-header {
723
+ display: flex;
724
+ align-items: center;
725
+ justify-content: space-between;
726
+ padding: 1rem 1.25rem;
727
+ border-bottom: 1px solid rgba(255, 255, 255, 0.06);
728
+ }
729
+
730
+ .citation-panel-header h3 {
731
+ margin: 0;
732
+ font-size: 1rem;
733
+ font-weight: 600;
734
+ color: rgba(255, 255, 255, 0.9);
735
+ }
736
+
737
+ .citation-panel-close {
738
+ background: none;
739
+ border: none;
740
+ color: rgba(255, 255, 255, 0.4);
741
+ font-size: 1.4rem;
742
+ cursor: pointer;
743
+ padding: 0;
744
+ line-height: 1;
745
+ }
746
+
747
+ .citation-panel-close:hover {
748
+ color: rgba(255, 255, 255, 0.8);
749
+ }
750
+
751
+ .citation-panel-body {
752
+ padding: 1.25rem;
753
+ display: flex;
754
+ flex-direction: column;
755
+ gap: 0.75rem;
756
+ overflow-y: auto;
757
+ }
758
+
759
+ .citation-input {
760
+ width: 100%;
761
+ padding: 0.55rem 0.75rem;
762
+ border: 1px solid rgba(255, 255, 255, 0.12);
763
+ border-radius: 6px;
764
+ background: rgba(255, 255, 255, 0.04);
765
+ color: rgba(255, 255, 255, 0.85);
766
+ font-size: 0.8125rem;
767
+ font-family: inherit;
768
+ outline: none;
769
+ transition: border-color 0.15s;
770
+ box-sizing: border-box;
771
+ }
772
+
773
+ .citation-input:focus {
774
+ border-color: rgba(149, 141, 241, 0.5);
775
+ }
776
+
777
+ .citation-input::placeholder {
778
+ color: rgba(255, 255, 255, 0.25);
779
+ }
780
+
781
+ .citation-smart-input {
782
+ resize: vertical;
783
+ line-height: 1.5;
784
+ }
785
+
786
+ .citation-error {
787
+ color: #e06c75;
788
+ font-size: 0.75rem;
789
+ margin: 0;
790
+ }
791
+
792
+ .citation-resolve-btn {
793
+ align-self: flex-end;
794
+ padding: 0.5rem 1.25rem;
795
+ border: none;
796
+ border-radius: 6px;
797
+ background: rgba(149, 141, 241, 0.15);
798
+ color: #b4aef7;
799
+ font-size: 0.8125rem;
800
+ font-family: inherit;
801
+ font-weight: 500;
802
+ cursor: pointer;
803
+ transition: background 0.15s;
804
+ }
805
+
806
+ .citation-resolve-btn:hover:not(:disabled) {
807
+ background: rgba(149, 141, 241, 0.25);
808
+ }
809
+
810
+ .citation-resolve-btn:disabled {
811
+ opacity: 0.4;
812
+ cursor: default;
813
+ }
814
+
815
+ .citation-library-divider {
816
+ display: flex;
817
+ align-items: center;
818
+ gap: 0.75rem;
819
+ color: rgba(255, 255, 255, 0.3);
820
+ font-size: 0.75rem;
821
+ margin-top: 0.25rem;
822
+ }
823
+
824
+ .citation-library-divider::after {
825
+ content: "";
826
+ flex: 1;
827
+ height: 1px;
828
+ background: rgba(255, 255, 255, 0.06);
829
+ }
830
+
831
+ .citation-library-list {
832
+ display: flex;
833
+ flex-direction: column;
834
+ gap: 0.15rem;
835
+ }
836
+
837
+ .citation-library-item {
838
+ display: flex;
839
+ align-items: center;
840
+ gap: 0.5rem;
841
+ padding: 0.5rem 0.65rem;
842
+ border-radius: 6px;
843
+ transition: background 0.1s;
844
+ }
845
+
846
+ .citation-library-item:hover {
847
+ background: rgba(255, 255, 255, 0.04);
848
+ }
849
+
850
+ .citation-library-info {
851
+ flex: 1;
852
+ min-width: 0;
853
+ display: flex;
854
+ flex-direction: column;
855
+ gap: 0.1rem;
856
+ }
857
+
858
+ .citation-library-meta {
859
+ color: rgba(255, 255, 255, 0.5);
860
+ font-size: 0.75rem;
861
+ font-weight: 500;
862
+ }
863
+
864
+ .citation-library-title {
865
+ color: rgba(255, 255, 255, 0.65);
866
+ font-size: 0.75rem;
867
+ white-space: nowrap;
868
+ overflow: hidden;
869
+ text-overflow: ellipsis;
870
+ }
871
+
872
+ .citation-library-insert {
873
+ flex-shrink: 0;
874
+ width: 26px;
875
+ height: 26px;
876
+ display: flex;
877
+ align-items: center;
878
+ justify-content: center;
879
+ border: none;
880
+ border-radius: 6px;
881
+ background: rgba(149, 141, 241, 0.1);
882
+ color: #958DF1;
883
+ font-size: 1rem;
884
+ font-weight: 500;
885
+ cursor: pointer;
886
+ transition: background 0.15s;
887
+ opacity: 0;
888
+ }
889
+
890
+ .citation-library-item:hover .citation-library-insert {
891
+ opacity: 1;
892
+ }
893
+
894
+ .citation-library-insert:hover {
895
+ background: rgba(149, 141, 241, 0.25);
896
+ }
897
+
898
+ /* -----------------------------------------------------------------------
899
+ Custom component blocks (Accordion, Note, Quote, …)
900
+ ----------------------------------------------------------------------- */
901
+
902
+ /* Ensure editable area inside wrapper components has proper spacing */
903
+ .component-content > *:first-child {
904
+ margin-top: 0;
905
+ }
906
+
907
+ .component-content > *:last-child {
908
+ margin-bottom: 0;
909
+ }
910
+
911
+ /* Selection ring when the wrapper node is selected */
912
+ .tiptap [data-component].ProseMirror-selectednode {
913
+ outline: 2px solid rgba(149, 141, 241, 0.5);
914
+ outline-offset: 2px;
915
+ border-radius: 8px;
916
+ }
917
+
918
+ /* -----------------------------------------------------------------------
919
+ Glossary inline node
920
+ ----------------------------------------------------------------------- */
921
+
922
+ .glossary-node {
923
+ position: relative;
924
+ display: inline;
925
+ border-bottom: 1px dashed rgba(149, 141, 241, 0.5);
926
+ color: rgba(149, 141, 241, 0.9);
927
+ cursor: help;
928
+ }
929
+
930
+ .glossary-tooltip {
931
+ position: absolute;
932
+ bottom: calc(100% + 6px);
933
+ left: 50%;
934
+ transform: translateX(-50%);
935
+ background: #1e1e2e;
936
+ border: 1px solid rgba(255, 255, 255, 0.12);
937
+ border-radius: 8px;
938
+ padding: 10px 14px;
939
+ min-width: 200px;
940
+ max-width: 320px;
941
+ z-index: 50;
942
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
943
+ }
944
+
945
+ .glossary-tooltip-term {
946
+ font-weight: 700;
947
+ font-size: 13px;
948
+ color: rgba(255, 255, 255, 0.9);
949
+ margin-bottom: 4px;
950
+ }
951
+
952
+ .glossary-tooltip-def {
953
+ font-size: 12px;
954
+ color: rgba(255, 255, 255, 0.6);
955
+ line-height: 1.5;
956
+ }
957
+
958
+ .glossary-tooltip-remove {
959
+ margin-top: 8px;
960
+ background: none;
961
+ border: none;
962
+ color: rgba(239, 68, 68, 0.8);
963
+ font-size: 11px;
964
+ cursor: pointer;
965
+ padding: 0;
966
+ }
967
+
968
+ .glossary-tooltip-remove:hover {
969
+ color: #ef4444;
970
+ }
971
+
972
+ /* -----------------------------------------------------------------------
973
+ Footnote inline node
974
+ ----------------------------------------------------------------------- */
975
+
976
+ .footnote-node {
977
+ position: relative;
978
+ display: inline;
979
+ }
980
+
981
+ .footnote-marker {
982
+ color: rgba(149, 141, 241, 0.9);
983
+ cursor: pointer;
984
+ font-size: 0.75em;
985
+ font-weight: 700;
986
+ margin: 0 1px;
987
+ }
988
+
989
+ .footnote-tooltip {
990
+ position: absolute;
991
+ bottom: calc(100% + 6px);
992
+ left: 50%;
993
+ transform: translateX(-50%);
994
+ background: #1e1e2e;
995
+ border: 1px solid rgba(255, 255, 255, 0.12);
996
+ border-radius: 8px;
997
+ padding: 10px 14px;
998
+ min-width: 220px;
999
+ max-width: 360px;
1000
+ z-index: 50;
1001
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
1002
+ }
1003
+
1004
+ .footnote-tooltip-content {
1005
+ font-size: 12px;
1006
+ color: rgba(255, 255, 255, 0.7);
1007
+ line-height: 1.5;
1008
+ }
1009
+
1010
+ .footnote-tooltip-input {
1011
+ width: 100%;
1012
+ background: rgba(255, 255, 255, 0.06);
1013
+ border: 1px solid rgba(255, 255, 255, 0.15);
1014
+ border-radius: 4px;
1015
+ color: rgba(255, 255, 255, 0.9);
1016
+ font-size: 12px;
1017
+ padding: 6px 8px;
1018
+ resize: vertical;
1019
+ outline: none;
1020
+ font-family: inherit;
1021
+ line-height: 1.5;
1022
+ }
1023
+
1024
+ .footnote-tooltip-input:focus {
1025
+ border-color: rgba(149, 141, 241, 0.5);
1026
+ }
1027
+
1028
+ .footnote-tooltip-actions {
1029
+ display: flex;
1030
+ gap: 8px;
1031
+ margin-top: 8px;
1032
+ }
1033
+
1034
+ .footnote-tooltip-btn {
1035
+ background: none;
1036
+ border: none;
1037
+ font-size: 11px;
1038
+ cursor: pointer;
1039
+ padding: 0;
1040
+ color: rgba(149, 141, 241, 0.8);
1041
+ }
1042
+
1043
+ .footnote-tooltip-btn:hover {
1044
+ color: #958df1;
1045
+ }
1046
+
1047
+ .footnote-tooltip-remove {
1048
+ color: rgba(239, 68, 68, 0.8) !important;
1049
+ }
1050
+
1051
+ .footnote-tooltip-remove:hover {
1052
+ color: #ef4444 !important;
1053
+ }
1054
+
1055
+ /* -----------------------------------------------------------------------
1056
+ Stack columns
1057
+ ----------------------------------------------------------------------- */
1058
+
1059
+ .stack-columns {
1060
+ display: contents;
1061
+ }
1062
+
1063
+ .stack-columns > div[data-type="stack-column"] {
1064
+ border: 1px dashed rgba(255, 255, 255, 0.08);
1065
+ border-radius: 6px;
1066
+ padding: 8px;
1067
+ min-height: 40px;
1068
+ }
1069
+
1070
+ .stack-columns > div[data-type="stack-column"] > *:first-child {
1071
+ margin-top: 0;
1072
+ }
1073
+
1074
+ .stack-columns > div[data-type="stack-column"] > *:last-child {
1075
+ margin-bottom: 0;
1076
+ }
frontend/src/editor/BibliographyView.tsx ADDED
@@ -0,0 +1,108 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NodeViewWrapper } from "@tiptap/react";
2
+ import { useEffect, useState, useCallback, useRef } from "react";
3
+ import type { NodeViewProps } from "@tiptap/react";
4
+
5
+ export function BibliographyView({ editor }: NodeViewProps) {
6
+ const [html, setHtml] = useState("");
7
+ const [count, setCount] = useState(0);
8
+ const [style, setStyle] = useState("apa");
9
+ const cacheRef = useRef("");
10
+ const timerRef = useRef<ReturnType<typeof setTimeout>>();
11
+
12
+ // Sync style from collaborative Y.Map("settings")
13
+ useEffect(() => {
14
+ const settingsMap = (editor.storage.citation as any)?.settingsMap;
15
+ if (!settingsMap) return;
16
+
17
+ const sync = () => {
18
+ const val = settingsMap.get("citationStyle");
19
+ if (val && val !== style) setStyle(val as string);
20
+ };
21
+ sync();
22
+ settingsMap.observe(sync);
23
+ return () => settingsMap.unobserve(sync);
24
+ }, [editor]);
25
+
26
+ const refresh = useCallback(() => {
27
+ const citationsMap = (editor.storage.citation as any)?.citationsMap;
28
+ if (!citationsMap) return;
29
+
30
+ const keys = new Set<string>();
31
+ editor.state.doc.descendants((node) => {
32
+ if (node.type.name === "citation" && node.attrs.key) {
33
+ keys.add(node.attrs.key as string);
34
+ }
35
+ });
36
+
37
+ const entries: any[] = [];
38
+ for (const key of keys) {
39
+ const entry = citationsMap.get(key);
40
+ if (entry) entries.push({ ...entry, id: key });
41
+ }
42
+
43
+ setCount(entries.length);
44
+
45
+ if (!entries.length) {
46
+ setHtml("");
47
+ cacheRef.current = "";
48
+ return;
49
+ }
50
+
51
+ const cacheKey = entries
52
+ .map((e) => e.id)
53
+ .sort()
54
+ .join(",") + `:${style}`;
55
+ if (cacheKey === cacheRef.current) return;
56
+ cacheRef.current = cacheKey;
57
+
58
+ fetch("/api/citations/format", {
59
+ method: "POST",
60
+ headers: { "Content-Type": "application/json" },
61
+ body: JSON.stringify({ entries, style }),
62
+ })
63
+ .then((r) => r.json())
64
+ .then((data) => {
65
+ if (data.html) setHtml(data.html);
66
+ })
67
+ .catch(console.error);
68
+ }, [editor, style]);
69
+
70
+ useEffect(() => {
71
+ const debouncedRefresh = () => {
72
+ clearTimeout(timerRef.current);
73
+ timerRef.current = setTimeout(refresh, 500);
74
+ };
75
+
76
+ refresh();
77
+
78
+ const citationsMap = (editor.storage.citation as any)?.citationsMap;
79
+ if (citationsMap) citationsMap.observe(debouncedRefresh);
80
+
81
+ const onUpdate = () => debouncedRefresh();
82
+ editor.on("update", onUpdate);
83
+
84
+ return () => {
85
+ clearTimeout(timerRef.current);
86
+ if (citationsMap) citationsMap.unobserve(debouncedRefresh);
87
+ editor.off("update", onUpdate);
88
+ };
89
+ }, [editor, refresh]);
90
+
91
+ return (
92
+ <NodeViewWrapper>
93
+ <div className="bibliography-block" contentEditable={false}>
94
+ <h2 className="bibliography-title">References</h2>
95
+ {count === 0 ? (
96
+ <p className="bibliography-empty">
97
+ No citations yet. Use /Citation to add references.
98
+ </p>
99
+ ) : (
100
+ <div
101
+ className="bibliography-content"
102
+ dangerouslySetInnerHTML={{ __html: html }}
103
+ />
104
+ )}
105
+ </div>
106
+ </NodeViewWrapper>
107
+ );
108
+ }
frontend/src/editor/BlockHandle.tsx ADDED
@@ -0,0 +1,294 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEffect, useRef, useState, useCallback } from "react";
2
+ import type { Editor } from "@tiptap/core";
3
+
4
+ interface BlockHandleProps {
5
+ editor: Editor;
6
+ containerEl: HTMLElement | null;
7
+ }
8
+
9
+ const BLOCK_SELECTOR =
10
+ ".tiptap > p, .tiptap > h1, .tiptap > h2, .tiptap > h3, " +
11
+ ".tiptap > ul, .tiptap > ol, .tiptap > blockquote, " +
12
+ ".tiptap > pre, .tiptap > hr, .tiptap > table, .tiptap > [data-type]";
13
+
14
+ /**
15
+ * Notion-like block handle with two controls:
16
+ * - A "+" button to insert a new block below
17
+ * - A drag grip (⋮⋮) to reorder blocks via drag & drop
18
+ *
19
+ * Hover detection works on the entire container width (including left margin)
20
+ * so the handles are easy to reach.
21
+ */
22
+ export function BlockHandle({ editor, containerEl }: BlockHandleProps) {
23
+ const handleRef = useRef<HTMLDivElement>(null);
24
+ const [visible, setVisible] = useState(false);
25
+ const [top, setTop] = useState(0);
26
+ const [left, setLeft] = useState(0);
27
+ const activeBlockRef = useRef<HTMLElement | null>(null);
28
+ const blockPosRef = useRef<number | null>(null);
29
+ const dragImageRef = useRef<HTMLDivElement | null>(null);
30
+ const hideTimer = useRef<ReturnType<typeof setTimeout>>();
31
+
32
+ const show = useCallback(() => {
33
+ clearTimeout(hideTimer.current);
34
+ setVisible(true);
35
+ }, []);
36
+
37
+ const scheduleHide = useCallback(() => {
38
+ clearTimeout(hideTimer.current);
39
+ hideTimer.current = setTimeout(() => {
40
+ setVisible(false);
41
+ activeBlockRef.current = null;
42
+ }, 200);
43
+ }, []);
44
+
45
+ const positionHandle = useCallback(
46
+ (block: HTMLElement) => {
47
+ if (!handleRef.current || !containerEl) return;
48
+ const containerRect = containerEl.getBoundingClientRect();
49
+ const blockRect = block.getBoundingClientRect();
50
+
51
+ setTop(blockRect.top - containerRect.top + containerEl.scrollTop);
52
+ setLeft(blockRect.left - containerRect.left - 56);
53
+ show();
54
+ activeBlockRef.current = block;
55
+
56
+ const pos = editor.view.posAtDOM(block, 0);
57
+ const resolved = editor.state.doc.resolve(pos);
58
+ blockPosRef.current = resolved.before(1);
59
+ },
60
+ [containerEl, editor, show],
61
+ );
62
+
63
+ // Find the nearest block for a given mouse Y position
64
+ const findBlockAtY = useCallback(
65
+ (clientY: number): HTMLElement | null => {
66
+ if (!containerEl) return null;
67
+ const blocks = containerEl.querySelectorAll<HTMLElement>(BLOCK_SELECTOR);
68
+ let closest: HTMLElement | null = null;
69
+ let closestDist = Infinity;
70
+ for (const block of blocks) {
71
+ const rect = block.getBoundingClientRect();
72
+ if (clientY >= rect.top && clientY <= rect.bottom) return block;
73
+ const dist = Math.min(
74
+ Math.abs(clientY - rect.top),
75
+ Math.abs(clientY - rect.bottom),
76
+ );
77
+ if (dist < closestDist) {
78
+ closestDist = dist;
79
+ closest = block;
80
+ }
81
+ }
82
+ return closestDist < 40 ? closest : null;
83
+ },
84
+ [containerEl],
85
+ );
86
+
87
+ // Listen on the whole container (not just .tiptap) for wider hover zone
88
+ useEffect(() => {
89
+ if (!containerEl) return;
90
+
91
+ const handleMouseMove = (e: MouseEvent) => {
92
+ const target = e.target as HTMLElement;
93
+
94
+ // Direct block hover (inside editor content)
95
+ const directBlock = target.closest<HTMLElement>(BLOCK_SELECTOR);
96
+ if (directBlock) {
97
+ positionHandle(directBlock);
98
+ return;
99
+ }
100
+
101
+ // Hovering in the left margin area - find nearest block by Y
102
+ if (
103
+ handleRef.current?.contains(target) ||
104
+ target === containerEl ||
105
+ target.classList.contains("tiptap")
106
+ ) {
107
+ const block = findBlockAtY(e.clientY);
108
+ if (block) {
109
+ positionHandle(block);
110
+ }
111
+ }
112
+ };
113
+
114
+ const handleMouseLeave = () => {
115
+ scheduleHide();
116
+ };
117
+
118
+ containerEl.addEventListener("mousemove", handleMouseMove);
119
+ containerEl.addEventListener("mouseleave", handleMouseLeave);
120
+
121
+ return () => {
122
+ containerEl.removeEventListener("mousemove", handleMouseMove);
123
+ containerEl.removeEventListener("mouseleave", handleMouseLeave);
124
+ clearTimeout(hideTimer.current);
125
+ };
126
+ }, [containerEl, positionHandle, findBlockAtY, scheduleHide]);
127
+
128
+ const handleAdd = () => {
129
+ if (!activeBlockRef.current || !editor) return;
130
+
131
+ const pos = editor.view.posAtDOM(activeBlockRef.current, 0);
132
+ const resolved = editor.state.doc.resolve(pos);
133
+ const endOfBlock = resolved.end(1);
134
+
135
+ editor
136
+ .chain()
137
+ .focus()
138
+ .insertContentAt(endOfBlock, { type: "paragraph" })
139
+ .setTextSelection(endOfBlock + 1)
140
+ .insertContent("/")
141
+ .run();
142
+ };
143
+
144
+ const handleDragStart = (e: React.DragEvent) => {
145
+ if (blockPosRef.current === null || !activeBlockRef.current) return;
146
+
147
+ const block = activeBlockRef.current;
148
+ const pos = blockPosRef.current;
149
+ const node = editor.state.doc.nodeAt(pos);
150
+ if (!node) return;
151
+
152
+ const clone = block.cloneNode(true) as HTMLDivElement;
153
+ clone.style.cssText =
154
+ "position:fixed;top:-9999px;left:-9999px;width:" +
155
+ block.offsetWidth +
156
+ "px;opacity:0.6;pointer-events:none;background:#1a1a1a;border-radius:6px;padding:4px 8px;";
157
+ document.body.appendChild(clone);
158
+ dragImageRef.current = clone;
159
+ e.dataTransfer.setDragImage(clone, 20, 20);
160
+
161
+ e.dataTransfer.effectAllowed = "move";
162
+ e.dataTransfer.setData(
163
+ "application/block-handle",
164
+ JSON.stringify({ from: pos, to: pos + node.nodeSize }),
165
+ );
166
+
167
+ block.style.opacity = "0.3";
168
+ };
169
+
170
+ const handleDragEnd = () => {
171
+ if (activeBlockRef.current) {
172
+ activeBlockRef.current.style.opacity = "";
173
+ }
174
+ if (dragImageRef.current) {
175
+ dragImageRef.current.remove();
176
+ dragImageRef.current = null;
177
+ }
178
+ setVisible(false);
179
+ };
180
+
181
+ // Drop handler on the editor
182
+ useEffect(() => {
183
+ if (!containerEl) return;
184
+ const editorEl = containerEl.querySelector(".tiptap");
185
+ if (!editorEl) return;
186
+
187
+ const onDragOver = (e: Event) => {
188
+ const de = e as DragEvent;
189
+ if (!de.dataTransfer?.types.includes("application/block-handle")) return;
190
+ de.preventDefault();
191
+ de.dataTransfer!.dropEffect = "move";
192
+ };
193
+
194
+ const onDrop = (e: Event) => {
195
+ const de = e as DragEvent;
196
+ const raw = de.dataTransfer?.getData("application/block-handle");
197
+ if (!raw) return;
198
+ de.preventDefault();
199
+
200
+ const { from, to } = JSON.parse(raw);
201
+ const node = editor.state.doc.nodeAt(from);
202
+ if (!node) return;
203
+
204
+ const coords = { left: de.clientX, top: de.clientY };
205
+ const dropPos = editor.view.posAtCoords(coords);
206
+ if (!dropPos) return;
207
+
208
+ const resolved = editor.state.doc.resolve(dropPos.pos);
209
+ let insertBefore = resolved.before(1);
210
+ const targetNode = editor.state.doc.nodeAt(insertBefore);
211
+ if (targetNode) {
212
+ const domNode = editor.view.nodeDOM(insertBefore) as HTMLElement | null;
213
+ if (domNode) {
214
+ const rect = domNode.getBoundingClientRect();
215
+ if (de.clientY > rect.top + rect.height / 2) {
216
+ insertBefore = insertBefore + targetNode.nodeSize;
217
+ }
218
+ }
219
+ }
220
+
221
+ if (insertBefore >= from && insertBefore <= to) return;
222
+
223
+ const { tr } = editor.state;
224
+ const slice = editor.state.doc.slice(from, to);
225
+
226
+ if (insertBefore > from) {
227
+ tr.delete(from, to);
228
+ const mappedPos = tr.mapping.map(insertBefore);
229
+ tr.insert(mappedPos, slice.content);
230
+ } else {
231
+ tr.insert(insertBefore, slice.content);
232
+ const mappedFrom = tr.mapping.map(from);
233
+ const mappedTo = tr.mapping.map(to);
234
+ tr.delete(mappedFrom, mappedTo);
235
+ }
236
+
237
+ editor.view.dispatch(tr);
238
+ };
239
+
240
+ editorEl.addEventListener("dragover", onDragOver);
241
+ editorEl.addEventListener("drop", onDrop);
242
+
243
+ return () => {
244
+ editorEl.removeEventListener("dragover", onDragOver);
245
+ editorEl.removeEventListener("drop", onDrop);
246
+ };
247
+ }, [containerEl, editor]);
248
+
249
+ if (!containerEl) return null;
250
+
251
+ return (
252
+ <div
253
+ ref={handleRef}
254
+ className="block-handle"
255
+ style={{
256
+ position: "absolute",
257
+ top,
258
+ left,
259
+ opacity: visible ? 1 : 0,
260
+ pointerEvents: visible ? "auto" : "none",
261
+ transition: "opacity 0.12s",
262
+ zIndex: 10,
263
+ }}
264
+ onMouseEnter={show}
265
+ onMouseLeave={scheduleHide}
266
+ >
267
+ <button
268
+ className="block-handle-btn block-handle-add"
269
+ onClick={handleAdd}
270
+ title="Add block below"
271
+ >
272
+ <svg width="14" height="14" viewBox="0 0 14 14" fill="none">
273
+ <path d="M7 1v12M1 7h12" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
274
+ </svg>
275
+ </button>
276
+ <button
277
+ className="block-handle-btn block-handle-grip"
278
+ draggable
279
+ onDragStart={handleDragStart}
280
+ onDragEnd={handleDragEnd}
281
+ title="Drag to reorder"
282
+ >
283
+ <svg width="10" height="14" viewBox="0 0 10 14" fill="none">
284
+ <circle cx="3" cy="2" r="1.2" fill="currentColor" />
285
+ <circle cx="7" cy="2" r="1.2" fill="currentColor" />
286
+ <circle cx="3" cy="7" r="1.2" fill="currentColor" />
287
+ <circle cx="7" cy="7" r="1.2" fill="currentColor" />
288
+ <circle cx="3" cy="12" r="1.2" fill="currentColor" />
289
+ <circle cx="7" cy="12" r="1.2" fill="currentColor" />
290
+ </svg>
291
+ </button>
292
+ </div>
293
+ );
294
+ }
frontend/src/editor/BubbleToolbar.tsx ADDED
@@ -0,0 +1,151 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { BubbleMenu } from "@tiptap/react";
2
+ import type { Editor } from "@tiptap/core";
3
+ import { Box, IconButton, Divider, Tooltip } from "@mui/material";
4
+ import FormatBoldIcon from "@mui/icons-material/FormatBold";
5
+ import FormatItalicIcon from "@mui/icons-material/FormatItalic";
6
+ import StrikethroughSIcon from "@mui/icons-material/StrikethroughS";
7
+ import CodeIcon from "@mui/icons-material/Code";
8
+ import FormatQuoteIcon from "@mui/icons-material/FormatQuote";
9
+ import LinkIcon from "@mui/icons-material/Link";
10
+ import TitleIcon from "@mui/icons-material/Title";
11
+ import ChatBubbleOutlinedIcon from "@mui/icons-material/ChatBubbleOutlined";
12
+ import FunctionsIcon from "@mui/icons-material/Functions";
13
+
14
+ interface BubbleToolbarProps {
15
+ editor: Editor;
16
+ onAddComment?: () => void;
17
+ }
18
+
19
+ function Btn({
20
+ onClick,
21
+ active,
22
+ tooltip,
23
+ children,
24
+ }: {
25
+ onClick: () => void;
26
+ active?: boolean;
27
+ tooltip: string;
28
+ children: React.ReactNode;
29
+ }) {
30
+ return (
31
+ <Tooltip title={tooltip} arrow placement="top">
32
+ <IconButton
33
+ onMouseDown={(e) => {
34
+ e.preventDefault();
35
+ onClick();
36
+ }}
37
+ size="small"
38
+ sx={{
39
+ color: active ? "#fff" : "rgba(255,255,255,0.6)",
40
+ "&:hover": { color: "#fff" },
41
+ borderRadius: 1,
42
+ p: 0.75,
43
+ }}
44
+ >
45
+ {children}
46
+ </IconButton>
47
+ </Tooltip>
48
+ );
49
+ }
50
+
51
+ export function BubbleToolbar({ editor, onAddComment }: BubbleToolbarProps) {
52
+ const setLink = () => {
53
+ const url = window.prompt("URL:");
54
+ if (url) {
55
+ editor.chain().focus().setLink({ href: url }).run();
56
+ }
57
+ };
58
+
59
+ return (
60
+ <BubbleMenu
61
+ editor={editor}
62
+ tippyOptions={{ duration: 150, placement: "top" }}
63
+ >
64
+ <Box
65
+ sx={{
66
+ display: "flex",
67
+ alignItems: "center",
68
+ gap: 0.25,
69
+ bgcolor: "#1a1a1a",
70
+ border: "1px solid #333",
71
+ borderRadius: 2,
72
+ px: 0.5,
73
+ py: 0.25,
74
+ boxShadow: "0 4px 20px rgba(0,0,0,0.4)",
75
+ }}
76
+ >
77
+ <Btn
78
+ onClick={() => editor.chain().focus().toggleBold().run()}
79
+ active={editor.isActive("bold")}
80
+ tooltip="Bold"
81
+ >
82
+ <FormatBoldIcon sx={{ fontSize: 18 }} />
83
+ </Btn>
84
+ <Btn
85
+ onClick={() => editor.chain().focus().toggleItalic().run()}
86
+ active={editor.isActive("italic")}
87
+ tooltip="Italic"
88
+ >
89
+ <FormatItalicIcon sx={{ fontSize: 18 }} />
90
+ </Btn>
91
+ <Btn
92
+ onClick={() => editor.chain().focus().toggleStrike().run()}
93
+ active={editor.isActive("strike")}
94
+ tooltip="Strikethrough"
95
+ >
96
+ <StrikethroughSIcon sx={{ fontSize: 18 }} />
97
+ </Btn>
98
+ <Btn
99
+ onClick={() => editor.chain().focus().toggleCode().run()}
100
+ active={editor.isActive("code")}
101
+ tooltip="Code"
102
+ >
103
+ <CodeIcon sx={{ fontSize: 18 }} />
104
+ </Btn>
105
+ <Btn
106
+ onClick={() => editor.chain().focus().insertInlineMath({ latex: "x^2" }).run()}
107
+ active={editor.isActive("inlineMath")}
108
+ tooltip="Inline math"
109
+ >
110
+ <FunctionsIcon sx={{ fontSize: 18 }} />
111
+ </Btn>
112
+
113
+ <Divider orientation="vertical" flexItem sx={{ mx: 0.25, borderColor: "#444" }} />
114
+
115
+ <Btn
116
+ onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
117
+ active={editor.isActive("heading", { level: 2 })}
118
+ tooltip="Heading"
119
+ >
120
+ <TitleIcon sx={{ fontSize: 18 }} />
121
+ </Btn>
122
+ <Btn
123
+ onClick={() => editor.chain().focus().toggleBlockquote().run()}
124
+ active={editor.isActive("blockquote")}
125
+ tooltip="Quote"
126
+ >
127
+ <FormatQuoteIcon sx={{ fontSize: 18 }} />
128
+ </Btn>
129
+ <Btn
130
+ onClick={setLink}
131
+ active={editor.isActive("link")}
132
+ tooltip="Link"
133
+ >
134
+ <LinkIcon sx={{ fontSize: 18 }} />
135
+ </Btn>
136
+
137
+ {onAddComment && (
138
+ <>
139
+ <Divider orientation="vertical" flexItem sx={{ mx: 0.25, borderColor: "#444" }} />
140
+ <Btn
141
+ onClick={onAddComment}
142
+ tooltip="Comment"
143
+ >
144
+ <ChatBubbleOutlinedIcon sx={{ fontSize: 18 }} />
145
+ </Btn>
146
+ </>
147
+ )}
148
+ </Box>
149
+ </BubbleMenu>
150
+ );
151
+ }
frontend/src/editor/CitationPanel.tsx ADDED
@@ -0,0 +1,213 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useCallback, useEffect } from "react";
2
+ import type { Editor } from "@tiptap/core";
3
+ import type * as Y from "yjs";
4
+
5
+ interface CitationPanelProps {
6
+ editor: Editor;
7
+ citationsMap: Y.Map<any>;
8
+ onClose: () => void;
9
+ }
10
+
11
+ function isBibTeX(input: string): boolean {
12
+ const trimmed = input.trim();
13
+ return trimmed.startsWith("@") && /\{[\s\S]*\}/.test(trimmed);
14
+ }
15
+
16
+ /**
17
+ * Single smart-field citation panel.
18
+ * Auto-detects DOI / URL / BibTeX from the input.
19
+ * Library is always visible below.
20
+ */
21
+ export function CitationPanel({
22
+ editor,
23
+ citationsMap,
24
+ onClose,
25
+ }: CitationPanelProps) {
26
+ const [input, setInput] = useState("");
27
+ const [loading, setLoading] = useState(false);
28
+ const [error, setError] = useState("");
29
+ const [libraryEntries, setLibraryEntries] = useState<
30
+ { key: string; entry: any }[]
31
+ >([]);
32
+
33
+ useEffect(() => {
34
+ const refresh = () => setLibraryEntries(getLibraryEntries(citationsMap));
35
+ refresh();
36
+ citationsMap.observe(refresh);
37
+ return () => citationsMap.unobserve(refresh);
38
+ }, [citationsMap]);
39
+
40
+ const ensureBibliography = useCallback(() => {
41
+ let hasBibliography = false;
42
+ editor.state.doc.descendants((node) => {
43
+ if (node.type.name === "bibliography") hasBibliography = true;
44
+ });
45
+ if (!hasBibliography) {
46
+ const endPos = editor.state.doc.content.size;
47
+ editor
48
+ .chain()
49
+ .insertContentAt(endPos, [
50
+ { type: "paragraph" },
51
+ { type: "bibliography" },
52
+ ])
53
+ .run();
54
+ }
55
+ }, [editor]);
56
+
57
+ const insertCitation = useCallback(
58
+ (entry: any) => {
59
+ const key =
60
+ entry["citation-key"] || entry.id || generateKey(entry);
61
+ citationsMap.set(key, entry);
62
+ editor.chain().focus().insertCitation(key).run();
63
+ ensureBibliography();
64
+ onClose();
65
+ },
66
+ [editor, citationsMap, onClose, ensureBibliography],
67
+ );
68
+
69
+ const insertExisting = useCallback(
70
+ (key: string) => {
71
+ editor.chain().focus().insertCitation(key).run();
72
+ ensureBibliography();
73
+ onClose();
74
+ },
75
+ [editor, onClose, ensureBibliography],
76
+ );
77
+
78
+ const resolveInput = useCallback(async () => {
79
+ if (!input.trim()) return;
80
+ setLoading(true);
81
+ setError("");
82
+ try {
83
+ const bibtex = isBibTeX(input);
84
+ const endpoint = bibtex
85
+ ? "/api/citations/import-bib"
86
+ : "/api/citations/resolve";
87
+ const body = bibtex ? { bibtex: input } : { input };
88
+
89
+ const res = await fetch(endpoint, {
90
+ method: "POST",
91
+ headers: { "Content-Type": "application/json" },
92
+ body: JSON.stringify(body),
93
+ });
94
+ const data = await res.json();
95
+
96
+ if (!res.ok) {
97
+ setError(data.error || "Resolution failed");
98
+ return;
99
+ }
100
+
101
+ if (data.entries?.length >= 1) {
102
+ data.entries.forEach((entry: any) => {
103
+ const key =
104
+ entry["citation-key"] || entry.id || generateKey(entry);
105
+ citationsMap.set(key, entry);
106
+ });
107
+ insertCitation(data.entries[0]);
108
+ }
109
+ } catch (err: any) {
110
+ setError(err.message || "Network error");
111
+ } finally {
112
+ setLoading(false);
113
+ }
114
+ }, [input, insertCitation, citationsMap]);
115
+
116
+ return (
117
+ <div className="citation-panel-overlay" onClick={onClose}>
118
+ <div className="citation-panel" onClick={(e) => e.stopPropagation()}>
119
+ <div className="citation-panel-header">
120
+ <h3>Add reference</h3>
121
+ <button className="citation-panel-close" onClick={onClose}>
122
+ &times;
123
+ </button>
124
+ </div>
125
+
126
+ <div className="citation-panel-body">
127
+ <textarea
128
+ className="citation-input citation-smart-input"
129
+ placeholder={"Paste a DOI, URL, or BibTeX entry...\ne.g. 10.1038/s41586-020-2649-2"}
130
+ value={input}
131
+ onChange={(e) => setInput(e.target.value)}
132
+ onKeyDown={(e) => {
133
+ if (e.key === "Enter" && !e.shiftKey && !isBibTeX(input)) {
134
+ e.preventDefault();
135
+ resolveInput();
136
+ }
137
+ }}
138
+ rows={3}
139
+ autoFocus
140
+ />
141
+
142
+ {error && <p className="citation-error">{error}</p>}
143
+
144
+ <button
145
+ className="citation-resolve-btn"
146
+ onClick={resolveInput}
147
+ disabled={loading || !input.trim()}
148
+ >
149
+ {loading ? "Resolving..." : "Resolve & Add"}
150
+ </button>
151
+
152
+ {libraryEntries.length > 0 && (
153
+ <>
154
+ <div className="citation-library-divider">
155
+ <span>Your references ({libraryEntries.length})</span>
156
+ </div>
157
+ <div className="citation-library-list">
158
+ {libraryEntries.map(({ key, entry }) => (
159
+ <div key={key} className="citation-library-item">
160
+ <div className="citation-library-info">
161
+ <span className="citation-library-meta">
162
+ {formatAuthorsShort(entry)}{" "}
163
+ {entry.issued?.["date-parts"]?.[0]?.[0] || ""}
164
+ </span>
165
+ <span className="citation-library-title">
166
+ {entry.title || key}
167
+ </span>
168
+ </div>
169
+ <button
170
+ className="citation-library-insert"
171
+ onClick={() => insertExisting(key)}
172
+ title="Insert citation"
173
+ >
174
+ +
175
+ </button>
176
+ </div>
177
+ ))}
178
+ </div>
179
+ </>
180
+ )}
181
+ </div>
182
+ </div>
183
+ </div>
184
+ );
185
+ }
186
+
187
+ function generateKey(entry: any): string {
188
+ const firstAuthor = entry.author?.[0]?.family || "unknown";
189
+ const year = entry.issued?.["date-parts"]?.[0]?.[0] || "nd";
190
+ return `${firstAuthor.toLowerCase()}${year}`.replace(/[^a-z0-9]/gi, "");
191
+ }
192
+
193
+ function formatAuthorsShort(entry: any): string {
194
+ const authors = entry.author;
195
+ if (!authors?.length) return "";
196
+ const first = authors[0].family || "";
197
+ if (authors.length === 1) return first;
198
+ if (authors.length === 2) return `${first} & ${authors[1].family || ""}`;
199
+ return `${first} et al.`;
200
+ }
201
+
202
+ function getLibraryEntries(map: Y.Map<any>): { key: string; entry: any }[] {
203
+ const result: { key: string; entry: any }[] = [];
204
+ map.forEach((entry, key) => {
205
+ result.push({ key, entry });
206
+ });
207
+ result.sort((a, b) => {
208
+ const aName = a.entry.author?.[0]?.family || a.key;
209
+ const bName = b.entry.author?.[0]?.family || b.key;
210
+ return aName.localeCompare(bName);
211
+ });
212
+ return result;
213
+ }
frontend/src/editor/CitationView.tsx ADDED
@@ -0,0 +1,158 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NodeViewWrapper } from "@tiptap/react";
2
+ import { useEffect, useState, useRef, useCallback } from "react";
3
+ import type { NodeViewProps } from "@tiptap/react";
4
+
5
+ type CitationStyle = "apa" | "ieee" | "vancouver" | "chicago-author-date" | "harvard1";
6
+
7
+ const NUMERIC_STYLES = new Set<CitationStyle>(["ieee", "vancouver"]);
8
+
9
+ export function CitationView({ node, editor, getPos }: NodeViewProps) {
10
+ const key = node.attrs.key as string;
11
+ const [label, setLabel] = useState(`[${key}]`);
12
+ const [style, setStyle] = useState<CitationStyle>("apa");
13
+ const [showTooltip, setShowTooltip] = useState(false);
14
+ const [entry, setEntry] = useState<any>(null);
15
+ const tooltipTimer = useRef<ReturnType<typeof setTimeout>>();
16
+ const wrapperRef = useRef<HTMLSpanElement>(null);
17
+
18
+ // Observe citation data + settings
19
+ useEffect(() => {
20
+ const citationsMap = (editor.storage.citation as any)?.citationsMap;
21
+ const settingsMap = (editor.storage.citation as any)?.settingsMap;
22
+
23
+ const updateEntry = () => {
24
+ if (!citationsMap) return;
25
+ setEntry(citationsMap.get(key) || null);
26
+ };
27
+
28
+ const updateStyle = () => {
29
+ if (!settingsMap) return;
30
+ const val = settingsMap.get("citationStyle");
31
+ if (val) setStyle(val as CitationStyle);
32
+ };
33
+
34
+ updateEntry();
35
+ updateStyle();
36
+
37
+ if (citationsMap) citationsMap.observe(updateEntry);
38
+ if (settingsMap) settingsMap.observe(updateStyle);
39
+
40
+ return () => {
41
+ if (citationsMap) citationsMap.unobserve(updateEntry);
42
+ if (settingsMap) settingsMap.unobserve(updateStyle);
43
+ };
44
+ }, [key, editor]);
45
+
46
+ // Recompute label when entry or style changes
47
+ useEffect(() => {
48
+ if (!entry) {
49
+ setLabel(`[${key}]`);
50
+ return;
51
+ }
52
+
53
+ if (NUMERIC_STYLES.has(style)) {
54
+ const order = getDocumentCitationOrder(editor);
55
+ const idx = order.indexOf(key);
56
+ const num = idx >= 0 ? idx + 1 : "?";
57
+ setLabel(style === "ieee" ? `[${num}]` : `(${num})`);
58
+ } else {
59
+ setLabel(formatAuthorDate(entry));
60
+ }
61
+ }, [entry, style, key, editor]);
62
+
63
+ // Re-format when doc changes (for numeric order updates)
64
+ useEffect(() => {
65
+ if (!NUMERIC_STYLES.has(style)) return;
66
+
67
+ const onUpdate = () => {
68
+ if (!entry) return;
69
+ const order = getDocumentCitationOrder(editor);
70
+ const idx = order.indexOf(key);
71
+ const num = idx >= 0 ? idx + 1 : "?";
72
+ setLabel(style === "ieee" ? `[${num}]` : `(${num})`);
73
+ };
74
+
75
+ editor.on("update", onUpdate);
76
+ return () => { editor.off("update", onUpdate); };
77
+ }, [editor, style, key, entry]);
78
+
79
+ const handleMouseEnter = useCallback(() => {
80
+ tooltipTimer.current = setTimeout(() => setShowTooltip(true), 300);
81
+ }, []);
82
+
83
+ const handleMouseLeave = useCallback(() => {
84
+ clearTimeout(tooltipTimer.current);
85
+ setShowTooltip(false);
86
+ }, []);
87
+
88
+ const removeCitation = useCallback(() => {
89
+ const pos = getPos();
90
+ if (typeof pos === "number") {
91
+ editor.chain().focus().deleteRange({ from: pos, to: pos + 1 }).run();
92
+ }
93
+ setShowTooltip(false);
94
+ }, [editor, getPos]);
95
+
96
+ return (
97
+ <NodeViewWrapper
98
+ as="span"
99
+ className="citation-node"
100
+ ref={wrapperRef}
101
+ onMouseEnter={handleMouseEnter}
102
+ onMouseLeave={handleMouseLeave}
103
+ >
104
+ {label}
105
+ {showTooltip && entry && (
106
+ <div className="citation-tooltip">
107
+ <div className="citation-tooltip-title">{entry.title || key}</div>
108
+ {entry["container-title"] && (
109
+ <div className="citation-tooltip-journal">
110
+ {entry["container-title"]}
111
+ {entry.volume ? `, ${entry.volume}` : ""}
112
+ {entry.page ? `, ${entry.page}` : ""}
113
+ </div>
114
+ )}
115
+ {entry.DOI && (
116
+ <div className="citation-tooltip-doi">doi: {entry.DOI}</div>
117
+ )}
118
+ <button
119
+ className="citation-tooltip-remove"
120
+ onMouseDown={(e) => {
121
+ e.preventDefault();
122
+ e.stopPropagation();
123
+ removeCitation();
124
+ }}
125
+ >
126
+ Remove
127
+ </button>
128
+ </div>
129
+ )}
130
+ </NodeViewWrapper>
131
+ );
132
+ }
133
+
134
+ function getDocumentCitationOrder(editor: any): string[] {
135
+ const seen: string[] = [];
136
+ editor.state.doc.descendants((node: any) => {
137
+ if (node.type.name === "citation" && node.attrs.key) {
138
+ const k = node.attrs.key as string;
139
+ if (!seen.includes(k)) seen.push(k);
140
+ }
141
+ });
142
+ return seen;
143
+ }
144
+
145
+ function formatAuthorDate(entry: any): string {
146
+ const authors = entry.author;
147
+ if (!authors?.length) return `(${entry.id || "?"})`;
148
+
149
+ const year = entry.issued?.["date-parts"]?.[0]?.[0] || "n.d.";
150
+ const first = authors[0].family || authors[0].literal || "?";
151
+
152
+ if (authors.length === 1) return `(${first}, ${year})`;
153
+ if (authors.length === 2) {
154
+ const second = authors[1].family || authors[1].literal || "?";
155
+ return `(${first} & ${second}, ${year})`;
156
+ }
157
+ return `(${first} et al., ${year})`;
158
+ }
frontend/src/editor/Editor.tsx ADDED
@@ -0,0 +1,274 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEditor, EditorContent } from "@tiptap/react";
2
+ import { Editor as TiptapEditor } from "@tiptap/core";
3
+ import StarterKit from "@tiptap/starter-kit";
4
+ import Collaboration from "@tiptap/extension-collaboration";
5
+ import CollaborationCursor from "@tiptap/extension-collaboration-cursor";
6
+ import Placeholder from "@tiptap/extension-placeholder";
7
+ import CodeBlockLowlight from "@tiptap/extension-code-block-lowlight";
8
+ import Link from "@tiptap/extension-link";
9
+ import Mathematics from "@tiptap/extension-mathematics";
10
+ import "katex/dist/katex.min.css";
11
+ import { all, createLowlight } from "lowlight";
12
+ import * as Y from "yjs";
13
+ import { UndoManager } from "yjs";
14
+ import { WebsocketProvider } from "y-websocket";
15
+ import { useEffect, useMemo, useRef, useState, MutableRefObject } from "react";
16
+ import { BubbleToolbar } from "./BubbleToolbar";
17
+ import { BlockHandle } from "./BlockHandle";
18
+ import Image from "@tiptap/extension-image";
19
+ import Table from "@tiptap/extension-table";
20
+ import TableRow from "@tiptap/extension-table-row";
21
+ import TableCell from "@tiptap/extension-table-cell";
22
+ import TableHeader from "@tiptap/extension-table-header";
23
+ import { Comment } from "./extensions/comment";
24
+ import { CollaborationUndo } from "./extensions/collaboration-undo";
25
+ import { SlashCommands } from "./extensions/slash-commands";
26
+ import { ImageUpload } from "./extensions/image-upload";
27
+ import { Citation } from "./extensions/citation";
28
+ import { Bibliography } from "./extensions/bibliography";
29
+ import { Glossary } from "./extensions/glossary";
30
+ import { Footnote } from "./extensions/footnote";
31
+ import { Stack, StackColumn } from "./extensions/stack";
32
+ import { CitationPanel } from "./CitationPanel";
33
+ import { createCommentStore, CommentStore } from "./comments";
34
+ import { createFrontmatterStore, FrontmatterStore } from "./frontmatter/frontmatter-store";
35
+ import { seedFrontmatter } from "./frontmatter/seed-frontmatter";
36
+ import { COMPONENTS, createWrapperExtension, createAtomicExtension, registerComponentSerializers } from "./components";
37
+ import { uploadImage } from "./upload";
38
+ import { DEFAULT_CONTENT, SEED_CITATIONS } from "./default-content";
39
+
40
+ // Auto-register MDX serializers for all custom components (once)
41
+ registerComponentSerializers();
42
+
43
+ const lowlight = createLowlight(all);
44
+
45
+ interface EditorProps {
46
+ docName: string;
47
+ user: { name: string; color: string };
48
+ editorRef: MutableRefObject<TiptapEditor | null>;
49
+ onCommentStoreReady: (store: CommentStore) => void;
50
+ onFrontmatterStoreReady: (store: FrontmatterStore) => void;
51
+ onSettingsMapReady?: (map: Y.Map<any>) => void;
52
+ onEditorReady: (editor: TiptapEditor | null) => void;
53
+ onUndoManagerReady?: (manager: UndoManager) => void;
54
+ onAddComment?: () => void;
55
+ }
56
+
57
+ export function Editor({
58
+ docName,
59
+ user,
60
+ editorRef,
61
+ onCommentStoreReady,
62
+ onFrontmatterStoreReady,
63
+ onSettingsMapReady,
64
+ onEditorReady,
65
+ onUndoManagerReady,
66
+ onAddComment,
67
+ }: EditorProps) {
68
+ const ydoc = useMemo(() => new Y.Doc(), []);
69
+ const fragment = useMemo(() => ydoc.getXmlFragment("default"), [ydoc]);
70
+ const undoManagerCallbackRef = useRef(onUndoManagerReady);
71
+ undoManagerCallbackRef.current = onUndoManagerReady;
72
+
73
+ const provider = useMemo(() => {
74
+ const wsUrl =
75
+ window.location.protocol === "https:"
76
+ ? `wss://${window.location.host}/collab`
77
+ : `ws://${window.location.host}/collab`;
78
+
79
+ return new WebsocketProvider(wsUrl, docName, ydoc);
80
+ }, [docName, ydoc]);
81
+
82
+ const citationsMap = useMemo(() => ydoc.getMap("citations"), [ydoc]);
83
+ const settingsMap = useMemo(() => ydoc.getMap("settings"), [ydoc]);
84
+ const commentStore = useMemo(() => createCommentStore(ydoc), [ydoc]);
85
+ const frontmatterStore = useMemo(() => createFrontmatterStore(ydoc), [ydoc]);
86
+ const [showCitationPanel, setShowCitationPanel] = useState(false);
87
+
88
+ useEffect(() => {
89
+ onCommentStoreReady(commentStore);
90
+ }, [commentStore, onCommentStoreReady]);
91
+
92
+ useEffect(() => {
93
+ onFrontmatterStoreReady(frontmatterStore);
94
+ }, [frontmatterStore, onFrontmatterStoreReady]);
95
+
96
+ useEffect(() => {
97
+ onSettingsMapReady?.(settingsMap);
98
+ }, [settingsMap, onSettingsMapReady]);
99
+
100
+ useEffect(() => {
101
+ return () => {
102
+ provider.destroy();
103
+ ydoc.destroy();
104
+ };
105
+ }, [provider, ydoc]);
106
+
107
+ const editor = useEditor(
108
+ {
109
+ editorProps: {
110
+ handleDrop: (view, event, _slice, moved) => {
111
+ if (moved || !event.dataTransfer?.files?.length) return false;
112
+ const images = Array.from(event.dataTransfer.files).filter((f) =>
113
+ f.type.startsWith("image/"),
114
+ );
115
+ if (!images.length) return false;
116
+ event.preventDefault();
117
+ const insertPos = view.posAtCoords({
118
+ left: event.clientX,
119
+ top: event.clientY,
120
+ });
121
+ images.forEach((file) => {
122
+ uploadImage(file).then((url) => {
123
+ const node = view.state.schema.nodes.image.create({ src: url });
124
+ const tr = view.state.tr.insert(
125
+ insertPos?.pos ?? view.state.doc.content.size,
126
+ node,
127
+ );
128
+ view.dispatch(tr);
129
+ });
130
+ });
131
+ return true;
132
+ },
133
+ handlePaste: (view, event) => {
134
+ const items = Array.from(event.clipboardData?.items ?? []);
135
+ const imageItems = items.filter((i) => i.type.startsWith("image/"));
136
+ if (!imageItems.length) return false;
137
+ event.preventDefault();
138
+ imageItems.forEach((item) => {
139
+ const file = item.getAsFile();
140
+ if (!file) return;
141
+ uploadImage(file).then((url) => {
142
+ const node = view.state.schema.nodes.image.create({ src: url });
143
+ const tr = view.state.tr.replaceSelectionWith(node);
144
+ view.dispatch(tr);
145
+ });
146
+ });
147
+ return true;
148
+ },
149
+ },
150
+ extensions: [
151
+ StarterKit.configure({
152
+ history: false,
153
+ codeBlock: false,
154
+ }),
155
+ CodeBlockLowlight.configure({ lowlight }),
156
+ Placeholder.configure({
157
+ placeholder: 'Type "/" for commands...',
158
+ }),
159
+ Link.configure({
160
+ openOnClick: false,
161
+ HTMLAttributes: { class: "editor-link" },
162
+ }),
163
+ Collaboration.configure({
164
+ document: ydoc,
165
+ }),
166
+ CollaborationCursor.configure({
167
+ provider,
168
+ user,
169
+ }),
170
+ Mathematics.configure({
171
+ katexOptions: { throwOnError: false },
172
+ }),
173
+ Image.configure({
174
+ allowBase64: true,
175
+ HTMLAttributes: { class: "editor-image" },
176
+ }),
177
+ Table.configure({ resizable: false }),
178
+ TableRow,
179
+ TableCell,
180
+ TableHeader,
181
+ SlashCommands,
182
+ ImageUpload,
183
+ Citation,
184
+ Bibliography,
185
+ Glossary,
186
+ Footnote,
187
+ Stack,
188
+ StackColumn,
189
+ ...COMPONENTS.filter((d) => d.kind === "wrapper").map(createWrapperExtension),
190
+ ...COMPONENTS.filter((d) => d.kind === "atomic").map(createAtomicExtension),
191
+ CollaborationUndo.configure({
192
+ fragment,
193
+ onUndoManagerReady: (um) => undoManagerCallbackRef.current?.(um),
194
+ }),
195
+ Comment,
196
+ ],
197
+ },
198
+ [ydoc, provider, fragment],
199
+ );
200
+
201
+ const seededRef = useRef(false);
202
+
203
+ useEffect(() => {
204
+ if (!editor || seededRef.current) return;
205
+
206
+ const seed = () => {
207
+ if (seededRef.current) return;
208
+ if (editor.state.doc.textContent.trim().length === 0) {
209
+ seededRef.current = true;
210
+ editor.commands.setContent(DEFAULT_CONTENT);
211
+ seedFrontmatter(frontmatterStore);
212
+ for (const [key, entry] of Object.entries(SEED_CITATIONS)) {
213
+ if (!citationsMap.has(key)) citationsMap.set(key, entry);
214
+ }
215
+ if (!settingsMap.has("citationStyle")) {
216
+ settingsMap.set("citationStyle", "apa");
217
+ }
218
+ }
219
+ };
220
+
221
+ const timer = setTimeout(seed, 800);
222
+
223
+ const onSync = () => setTimeout(seed, 100);
224
+ provider.on("sync", onSync);
225
+ provider.on("synced", onSync);
226
+
227
+ return () => {
228
+ provider.off("sync", onSync);
229
+ provider.off("synced", onSync);
230
+ clearTimeout(timer);
231
+ };
232
+ }, [editor, provider, frontmatterStore]);
233
+
234
+ useEffect(() => {
235
+ if (!editor) return;
236
+ if (!editor.storage.citation) {
237
+ editor.storage.citation = {} as any;
238
+ }
239
+ editor.storage.citation.citationsMap = citationsMap;
240
+ editor.storage.citation.settingsMap = settingsMap;
241
+ }, [editor, citationsMap, settingsMap]);
242
+
243
+ useEffect(() => {
244
+ const handler = () => setShowCitationPanel(true);
245
+ window.addEventListener("open-citation-panel", handler);
246
+ return () => window.removeEventListener("open-citation-panel", handler);
247
+ }, []);
248
+
249
+ useEffect(() => {
250
+ editorRef.current = editor;
251
+ onEditorReady(editor);
252
+ }, [editor, editorRef, onEditorReady]);
253
+
254
+ const [containerEl, setContainerEl] = useState<HTMLElement | null>(null);
255
+
256
+ if (!editor) return null;
257
+
258
+ return (
259
+ <>
260
+ <BubbleToolbar editor={editor} onAddComment={onAddComment} />
261
+ <div ref={setContainerEl} style={{ position: "relative" }}>
262
+ <BlockHandle editor={editor} containerEl={containerEl} />
263
+ <EditorContent editor={editor} />
264
+ </div>
265
+ {showCitationPanel && (
266
+ <CitationPanel
267
+ editor={editor}
268
+ citationsMap={citationsMap}
269
+ onClose={() => setShowCitationPanel(false)}
270
+ />
271
+ )}
272
+ </>
273
+ );
274
+ }
frontend/src/editor/FloatingActions.tsx ADDED
@@ -0,0 +1,143 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { FloatingMenu } from "@tiptap/react";
2
+ import type { Editor } from "@tiptap/core";
3
+ import { useState } from "react";
4
+ import { Box, IconButton, Tooltip, Fade, Paper } from "@mui/material";
5
+ import AddIcon from "@mui/icons-material/Add";
6
+ import TitleIcon from "@mui/icons-material/Title";
7
+ import FormatQuoteIcon from "@mui/icons-material/FormatQuote";
8
+ import FormatListBulletedIcon from "@mui/icons-material/FormatListBulleted";
9
+ import FormatListNumberedIcon from "@mui/icons-material/FormatListNumbered";
10
+ import DataObjectIcon from "@mui/icons-material/DataObject";
11
+ import HorizontalRuleIcon from "@mui/icons-material/HorizontalRule";
12
+ import FunctionsIcon from "@mui/icons-material/Functions";
13
+
14
+ interface FloatingActionsProps {
15
+ editor: Editor;
16
+ }
17
+
18
+ export function FloatingActions({ editor }: FloatingActionsProps) {
19
+ const [open, setOpen] = useState(false);
20
+
21
+ const insert = (fn: () => void) => {
22
+ fn();
23
+ setOpen(false);
24
+ };
25
+
26
+ return (
27
+ <FloatingMenu
28
+ editor={editor}
29
+ tippyOptions={{
30
+ duration: 150,
31
+ placement: "left-start",
32
+ offset: [-4, 0],
33
+ }}
34
+ >
35
+ <Box sx={{ display: "flex", alignItems: "flex-start", gap: 0.5 }}>
36
+ <Tooltip title="Insert block" arrow placement="left">
37
+ <IconButton
38
+ size="small"
39
+ onClick={() => setOpen(!open)}
40
+ sx={{
41
+ color: open ? "text.primary" : "text.disabled",
42
+ border: "1px solid",
43
+ borderColor: open ? "divider" : "transparent",
44
+ transition: "all 0.15s",
45
+ "&:hover": { color: "text.primary", borderColor: "divider" },
46
+ transform: open ? "rotate(45deg)" : "none",
47
+ p: 0.5,
48
+ }}
49
+ >
50
+ <AddIcon sx={{ fontSize: 20 }} />
51
+ </IconButton>
52
+ </Tooltip>
53
+
54
+ <Fade in={open} timeout={150}>
55
+ <Paper
56
+ variant="outlined"
57
+ sx={{
58
+ display: open ? "flex" : "none",
59
+ gap: 0.25,
60
+ px: 0.5,
61
+ py: 0.25,
62
+ bgcolor: "background.paper",
63
+ boxShadow: "0 4px 20px rgba(0,0,0,0.3)",
64
+ }}
65
+ >
66
+ <Tooltip title="Heading 2" arrow>
67
+ <IconButton
68
+ size="small"
69
+ onClick={() => insert(() => editor.chain().focus().toggleHeading({ level: 2 }).run())}
70
+ sx={{ color: "text.secondary", p: 0.75 }}
71
+ >
72
+ <TitleIcon sx={{ fontSize: 18 }} />
73
+ </IconButton>
74
+ </Tooltip>
75
+ <Tooltip title="Heading 3" arrow>
76
+ <IconButton
77
+ size="small"
78
+ onClick={() => insert(() => editor.chain().focus().toggleHeading({ level: 3 }).run())}
79
+ sx={{ color: "text.secondary", fontSize: "0.75rem", fontWeight: 700, p: 0.75 }}
80
+ >
81
+ H3
82
+ </IconButton>
83
+ </Tooltip>
84
+ <Tooltip title="Quote" arrow>
85
+ <IconButton
86
+ size="small"
87
+ onClick={() => insert(() => editor.chain().focus().toggleBlockquote().run())}
88
+ sx={{ color: "text.secondary", p: 0.75 }}
89
+ >
90
+ <FormatQuoteIcon sx={{ fontSize: 18 }} />
91
+ </IconButton>
92
+ </Tooltip>
93
+ <Tooltip title="Bullet list" arrow>
94
+ <IconButton
95
+ size="small"
96
+ onClick={() => insert(() => editor.chain().focus().toggleBulletList().run())}
97
+ sx={{ color: "text.secondary", p: 0.75 }}
98
+ >
99
+ <FormatListBulletedIcon sx={{ fontSize: 18 }} />
100
+ </IconButton>
101
+ </Tooltip>
102
+ <Tooltip title="Numbered list" arrow>
103
+ <IconButton
104
+ size="small"
105
+ onClick={() => insert(() => editor.chain().focus().toggleOrderedList().run())}
106
+ sx={{ color: "text.secondary", p: 0.75 }}
107
+ >
108
+ <FormatListNumberedIcon sx={{ fontSize: 18 }} />
109
+ </IconButton>
110
+ </Tooltip>
111
+ <Tooltip title="Code block" arrow>
112
+ <IconButton
113
+ size="small"
114
+ onClick={() => insert(() => editor.chain().focus().toggleCodeBlock().run())}
115
+ sx={{ color: "text.secondary", p: 0.75 }}
116
+ >
117
+ <DataObjectIcon sx={{ fontSize: 18 }} />
118
+ </IconButton>
119
+ </Tooltip>
120
+ <Tooltip title="Equation" arrow>
121
+ <IconButton
122
+ size="small"
123
+ onClick={() => insert(() => editor.chain().focus().insertBlockMath({ latex: "E = mc^2" }).run())}
124
+ sx={{ color: "text.secondary", p: 0.75 }}
125
+ >
126
+ <FunctionsIcon sx={{ fontSize: 18 }} />
127
+ </IconButton>
128
+ </Tooltip>
129
+ <Tooltip title="Divider" arrow>
130
+ <IconButton
131
+ size="small"
132
+ onClick={() => insert(() => editor.chain().focus().setHorizontalRule().run())}
133
+ sx={{ color: "text.secondary", p: 0.75 }}
134
+ >
135
+ <HorizontalRuleIcon sx={{ fontSize: 18 }} />
136
+ </IconButton>
137
+ </Tooltip>
138
+ </Paper>
139
+ </Fade>
140
+ </Box>
141
+ </FloatingMenu>
142
+ );
143
+ }
frontend/src/editor/FootnoteView.tsx ADDED
@@ -0,0 +1,107 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NodeViewWrapper } from "@tiptap/react";
2
+ import { useState, useEffect, useRef, useCallback } from "react";
3
+ import type { NodeViewProps } from "@tiptap/react";
4
+
5
+ export function FootnoteView({ node, editor, getPos, updateAttributes }: NodeViewProps) {
6
+ const content = node.attrs.content as string;
7
+ const [index, setIndex] = useState(0);
8
+ const [showTooltip, setShowTooltip] = useState(false);
9
+ const [editing, setEditing] = useState(false);
10
+ const [draft, setDraft] = useState(content);
11
+ const tooltipTimer = useRef<ReturnType<typeof setTimeout>>();
12
+ const inputRef = useRef<HTMLTextAreaElement>(null);
13
+
14
+ // Auto-number: count all footnotes in document order
15
+ useEffect(() => {
16
+ const computeIndex = () => {
17
+ let i = 0;
18
+ const pos = getPos();
19
+ editor.state.doc.descendants((n, p) => {
20
+ if (n.type.name === "footnote") {
21
+ i++;
22
+ if (p === pos) setIndex(i);
23
+ }
24
+ });
25
+ };
26
+
27
+ computeIndex();
28
+ editor.on("update", computeIndex);
29
+ return () => { editor.off("update", computeIndex); };
30
+ }, [editor, getPos]);
31
+
32
+ useEffect(() => {
33
+ if (editing && inputRef.current) {
34
+ inputRef.current.focus();
35
+ inputRef.current.select();
36
+ }
37
+ }, [editing]);
38
+
39
+ const handleMouseEnter = useCallback(() => {
40
+ if (!editing) tooltipTimer.current = setTimeout(() => setShowTooltip(true), 300);
41
+ }, [editing]);
42
+
43
+ const handleMouseLeave = useCallback(() => {
44
+ clearTimeout(tooltipTimer.current);
45
+ if (!editing) setShowTooltip(false);
46
+ }, [editing]);
47
+
48
+ const startEdit = useCallback(() => {
49
+ setDraft(content);
50
+ setEditing(true);
51
+ setShowTooltip(true);
52
+ }, [content]);
53
+
54
+ const commitEdit = useCallback(() => {
55
+ updateAttributes({ content: draft });
56
+ setEditing(false);
57
+ setShowTooltip(false);
58
+ }, [draft, updateAttributes]);
59
+
60
+ const remove = useCallback(() => {
61
+ const pos = getPos();
62
+ if (typeof pos === "number") {
63
+ editor.chain().focus().deleteRange({ from: pos, to: pos + 1 }).run();
64
+ }
65
+ }, [editor, getPos]);
66
+
67
+ return (
68
+ <NodeViewWrapper
69
+ as="span"
70
+ className="footnote-node"
71
+ onMouseEnter={handleMouseEnter}
72
+ onMouseLeave={handleMouseLeave}
73
+ onDoubleClick={startEdit}
74
+ >
75
+ <sup className="footnote-marker">{index}</sup>
76
+ {showTooltip && (
77
+ <div className="footnote-tooltip">
78
+ {editing ? (
79
+ <textarea
80
+ ref={inputRef}
81
+ className="footnote-tooltip-input"
82
+ value={draft}
83
+ onChange={(e) => setDraft(e.target.value)}
84
+ onKeyDown={(e) => {
85
+ if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); commitEdit(); }
86
+ if (e.key === "Escape") { setEditing(false); setShowTooltip(false); }
87
+ }}
88
+ rows={3}
89
+ />
90
+ ) : (
91
+ <div className="footnote-tooltip-content">{content || "Empty footnote. Double-click to edit."}</div>
92
+ )}
93
+ <div className="footnote-tooltip-actions">
94
+ {editing ? (
95
+ <button className="footnote-tooltip-btn" onMouseDown={(e) => { e.preventDefault(); commitEdit(); }}>Save</button>
96
+ ) : (
97
+ <>
98
+ <button className="footnote-tooltip-btn" onMouseDown={(e) => { e.preventDefault(); startEdit(); }}>Edit</button>
99
+ <button className="footnote-tooltip-btn footnote-tooltip-remove" onMouseDown={(e) => { e.preventDefault(); e.stopPropagation(); remove(); }}>Remove</button>
100
+ </>
101
+ )}
102
+ </div>
103
+ </div>
104
+ )}
105
+ </NodeViewWrapper>
106
+ );
107
+ }
frontend/src/editor/GlossaryView.tsx ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NodeViewWrapper } from "@tiptap/react";
2
+ import { useState, useRef, useCallback } from "react";
3
+ import type { NodeViewProps } from "@tiptap/react";
4
+
5
+ export function GlossaryView({ node, editor, getPos }: NodeViewProps) {
6
+ const term = node.attrs.term as string;
7
+ const definition = node.attrs.definition as string;
8
+ const [showTooltip, setShowTooltip] = useState(false);
9
+ const tooltipTimer = useRef<ReturnType<typeof setTimeout>>();
10
+
11
+ const handleMouseEnter = useCallback(() => {
12
+ tooltipTimer.current = setTimeout(() => setShowTooltip(true), 300);
13
+ }, []);
14
+
15
+ const handleMouseLeave = useCallback(() => {
16
+ clearTimeout(tooltipTimer.current);
17
+ setShowTooltip(false);
18
+ }, []);
19
+
20
+ const remove = useCallback(() => {
21
+ const pos = getPos();
22
+ if (typeof pos === "number") {
23
+ editor.chain().focus().deleteRange({ from: pos, to: pos + 1 }).run();
24
+ }
25
+ setShowTooltip(false);
26
+ }, [editor, getPos]);
27
+
28
+ return (
29
+ <NodeViewWrapper
30
+ as="span"
31
+ className="glossary-node"
32
+ onMouseEnter={handleMouseEnter}
33
+ onMouseLeave={handleMouseLeave}
34
+ >
35
+ {term || "term"}
36
+ {showTooltip && (
37
+ <div className="glossary-tooltip">
38
+ <div className="glossary-tooltip-term">{term}</div>
39
+ <div className="glossary-tooltip-def">{definition || "No definition yet."}</div>
40
+ <button
41
+ className="glossary-tooltip-remove"
42
+ onMouseDown={(e) => {
43
+ e.preventDefault();
44
+ e.stopPropagation();
45
+ remove();
46
+ }}
47
+ >
48
+ Remove
49
+ </button>
50
+ </div>
51
+ )}
52
+ </NodeViewWrapper>
53
+ );
54
+ }
frontend/src/editor/ImageUploadView.tsx ADDED
@@ -0,0 +1,153 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NodeViewWrapper } from "@tiptap/react";
2
+ import { useCallback, useRef, useState } from "react";
3
+ import type { NodeViewProps } from "@tiptap/react";
4
+ import { uploadImage } from "./upload";
5
+
6
+ export function ImageUploadView({ editor, getPos }: NodeViewProps) {
7
+ const [dragging, setDragging] = useState(false);
8
+ const [uploading, setUploading] = useState(false);
9
+ const [urlMode, setUrlMode] = useState(false);
10
+ const [urlValue, setUrlValue] = useState("");
11
+ const fileRef = useRef<HTMLInputElement>(null);
12
+
13
+ const replaceWithImage = useCallback(
14
+ (src: string) => {
15
+ const pos = getPos();
16
+ if (pos === undefined) return;
17
+ editor
18
+ .chain()
19
+ .focus()
20
+ .deleteRange({ from: pos, to: pos + 1 })
21
+ .insertContentAt(pos, {
22
+ type: "image",
23
+ attrs: { src },
24
+ })
25
+ .run();
26
+ },
27
+ [editor, getPos],
28
+ );
29
+
30
+ const handleFiles = useCallback(
31
+ (files: FileList | File[]) => {
32
+ const file = Array.from(files).find((f) => f.type.startsWith("image/"));
33
+ if (!file) return;
34
+ setUploading(true);
35
+ uploadImage(file)
36
+ .then((url) => replaceWithImage(url))
37
+ .catch((err) => {
38
+ console.error("[image-upload]", err);
39
+ setUploading(false);
40
+ });
41
+ },
42
+ [replaceWithImage],
43
+ );
44
+
45
+ const onDrop = useCallback(
46
+ (e: React.DragEvent) => {
47
+ e.preventDefault();
48
+ setDragging(false);
49
+ if (e.dataTransfer.files.length) handleFiles(e.dataTransfer.files);
50
+ },
51
+ [handleFiles],
52
+ );
53
+
54
+ const onDragOver = useCallback((e: React.DragEvent) => {
55
+ e.preventDefault();
56
+ setDragging(true);
57
+ }, []);
58
+
59
+ const onDragLeave = useCallback(() => setDragging(false), []);
60
+
61
+ const submitUrl = useCallback(() => {
62
+ const src = urlValue.trim();
63
+ if (src) replaceWithImage(src);
64
+ }, [urlValue, replaceWithImage]);
65
+
66
+ return (
67
+ <NodeViewWrapper>
68
+ <div
69
+ className={`image-upload-card${dragging ? " dragging" : ""}`}
70
+ onDrop={onDrop}
71
+ onDragOver={onDragOver}
72
+ onDragLeave={onDragLeave}
73
+ contentEditable={false}
74
+ >
75
+ <input
76
+ ref={fileRef}
77
+ type="file"
78
+ accept="image/*"
79
+ style={{ display: "none" }}
80
+ onChange={(e) => {
81
+ if (e.target.files?.length) handleFiles(e.target.files);
82
+ }}
83
+ />
84
+
85
+ {uploading ? (
86
+ <>
87
+ <div className="image-upload-card-icon" style={{ opacity: 0.5 }}>
88
+ <svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
89
+ <rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
90
+ <circle cx="8.5" cy="8.5" r="1.5" />
91
+ <polyline points="21 15 16 10 5 21" />
92
+ </svg>
93
+ </div>
94
+ <p className="image-upload-card-hint">Uploading...</p>
95
+ </>
96
+ ) : !urlMode ? (
97
+ <>
98
+ <div className="image-upload-card-icon">
99
+ <svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
100
+ <rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
101
+ <circle cx="8.5" cy="8.5" r="1.5" />
102
+ <polyline points="21 15 16 10 5 21" />
103
+ </svg>
104
+ </div>
105
+ <div className="image-upload-card-actions">
106
+ <button
107
+ className="image-upload-btn primary"
108
+ onClick={() => fileRef.current?.click()}
109
+ >
110
+ Upload image
111
+ </button>
112
+ <button
113
+ className="image-upload-btn secondary"
114
+ onClick={() => setUrlMode(true)}
115
+ >
116
+ Embed link
117
+ </button>
118
+ </div>
119
+ <p className="image-upload-card-hint">
120
+ Drag & drop an image, or click to upload
121
+ </p>
122
+ </>
123
+ ) : (
124
+ <div className="image-upload-card-url">
125
+ <input
126
+ type="text"
127
+ className="image-upload-url-input"
128
+ placeholder="Paste image URL..."
129
+ value={urlValue}
130
+ onChange={(e) => setUrlValue(e.target.value)}
131
+ onKeyDown={(e) => {
132
+ if (e.key === "Enter") submitUrl();
133
+ if (e.key === "Escape") setUrlMode(false);
134
+ }}
135
+ autoFocus
136
+ />
137
+ <div className="image-upload-card-actions">
138
+ <button className="image-upload-btn primary" onClick={submitUrl}>
139
+ Embed
140
+ </button>
141
+ <button
142
+ className="image-upload-btn secondary"
143
+ onClick={() => setUrlMode(false)}
144
+ >
145
+ Cancel
146
+ </button>
147
+ </div>
148
+ </div>
149
+ )}
150
+ </div>
151
+ </NodeViewWrapper>
152
+ );
153
+ }
frontend/src/editor/SlashMenu.tsx ADDED
@@ -0,0 +1,240 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { ReactRenderer } from "@tiptap/react";
2
+ import tippy, { type Instance as TippyInstance } from "tippy.js";
3
+ import { type SuggestionProps, type SuggestionKeyDownProps } from "@tiptap/suggestion";
4
+ import {
5
+ forwardRef,
6
+ useEffect,
7
+ useImperativeHandle,
8
+ useState,
9
+ useCallback,
10
+ } from "react";
11
+ import type { Editor } from "@tiptap/core";
12
+ import { getComponentSlashItems } from "./components";
13
+
14
+ interface SlashItem {
15
+ title: string;
16
+ description: string;
17
+ icon: string;
18
+ command: (editor: Editor) => void;
19
+ }
20
+
21
+ const BUILT_IN_ITEMS: SlashItem[] = [
22
+ {
23
+ title: "Heading 1",
24
+ description: "Large section heading",
25
+ icon: "H1",
26
+ command: (editor) =>
27
+ editor.chain().focus().toggleHeading({ level: 1 }).run(),
28
+ },
29
+ {
30
+ title: "Heading 2",
31
+ description: "Medium section heading",
32
+ icon: "H2",
33
+ command: (editor) =>
34
+ editor.chain().focus().toggleHeading({ level: 2 }).run(),
35
+ },
36
+ {
37
+ title: "Heading 3",
38
+ description: "Small section heading",
39
+ icon: "H3",
40
+ command: (editor) =>
41
+ editor.chain().focus().toggleHeading({ level: 3 }).run(),
42
+ },
43
+ {
44
+ title: "Bullet list",
45
+ description: "Unordered list",
46
+ icon: "•",
47
+ command: (editor) => editor.chain().focus().toggleBulletList().run(),
48
+ },
49
+ {
50
+ title: "Numbered list",
51
+ description: "Ordered list",
52
+ icon: "1.",
53
+ command: (editor) => editor.chain().focus().toggleOrderedList().run(),
54
+ },
55
+ {
56
+ title: "Quote",
57
+ description: "Blockquote",
58
+ icon: "❝",
59
+ command: (editor) => editor.chain().focus().toggleBlockquote().run(),
60
+ },
61
+ {
62
+ title: "Code block",
63
+ description: "Code with syntax highlighting",
64
+ icon: "<>",
65
+ command: (editor) => editor.chain().focus().toggleCodeBlock().run(),
66
+ },
67
+ {
68
+ title: "Divider",
69
+ description: "Horizontal rule",
70
+ icon: "—",
71
+ command: (editor) => editor.chain().focus().setHorizontalRule().run(),
72
+ },
73
+ {
74
+ title: "Image",
75
+ description: "Upload or embed an image",
76
+ icon: "🖼",
77
+ command: (editor) => {
78
+ editor.chain().focus().insertImageUpload().run();
79
+ },
80
+ },
81
+ {
82
+ title: "Citation",
83
+ description: "Cite a paper (DOI or BibTeX)",
84
+ icon: "📎",
85
+ command: () => {
86
+ window.dispatchEvent(new CustomEvent("open-citation-panel"));
87
+ },
88
+ },
89
+ {
90
+ title: "Glossary",
91
+ description: "Highlighted term with tooltip definition",
92
+ icon: "📖",
93
+ command: (editor) => {
94
+ editor.chain().focus().insertGlossary("term", "Definition here…").run();
95
+ },
96
+ },
97
+ {
98
+ title: "Footnote",
99
+ description: "Numbered footnote reference",
100
+ icon: "¹",
101
+ command: (editor) => {
102
+ editor.chain().focus().insertFootnote("Footnote content…").run();
103
+ },
104
+ },
105
+ {
106
+ title: "Stack",
107
+ description: "Multi-column layout (2-4 columns)",
108
+ icon: "▥",
109
+ command: (editor) => {
110
+ editor.chain().focus().insertStack(2).run();
111
+ },
112
+ },
113
+ ];
114
+
115
+ const ITEMS: SlashItem[] = [...BUILT_IN_ITEMS, ...getComponentSlashItems()];
116
+
117
+ interface SlashMenuListProps {
118
+ items: SlashItem[];
119
+ command: (item: SlashItem) => void;
120
+ }
121
+
122
+ interface SlashMenuListRef {
123
+ onKeyDown: (props: SuggestionKeyDownProps) => boolean;
124
+ }
125
+
126
+ const SlashMenuList = forwardRef<SlashMenuListRef, SlashMenuListProps>(
127
+ ({ items, command }, ref) => {
128
+ const [selectedIndex, setSelectedIndex] = useState(0);
129
+
130
+ useEffect(() => {
131
+ setSelectedIndex(0);
132
+ }, [items]);
133
+
134
+ const selectItem = useCallback(
135
+ (index: number) => {
136
+ const item = items[index];
137
+ if (item) command(item);
138
+ },
139
+ [items, command],
140
+ );
141
+
142
+ useImperativeHandle(ref, () => ({
143
+ onKeyDown: ({ event }: SuggestionKeyDownProps) => {
144
+ if (event.key === "ArrowUp") {
145
+ setSelectedIndex((i) => (i + items.length - 1) % items.length);
146
+ return true;
147
+ }
148
+ if (event.key === "ArrowDown") {
149
+ setSelectedIndex((i) => (i + 1) % items.length);
150
+ return true;
151
+ }
152
+ if (event.key === "Enter") {
153
+ selectItem(selectedIndex);
154
+ return true;
155
+ }
156
+ return false;
157
+ },
158
+ }));
159
+
160
+ if (items.length === 0) return null;
161
+
162
+ return (
163
+ <div className="slash-menu">
164
+ {items.map((item, index) => (
165
+ <button
166
+ key={item.title}
167
+ className={`slash-menu-item ${index === selectedIndex ? "is-selected" : ""}`}
168
+ onClick={() => selectItem(index)}
169
+ >
170
+ <span className="slash-menu-item-icon">{item.icon}</span>
171
+ <span className="slash-menu-item-content">
172
+ <span className="slash-menu-item-title">{item.title}</span>
173
+ <span className="slash-menu-item-desc">{item.description}</span>
174
+ </span>
175
+ </button>
176
+ ))}
177
+ </div>
178
+ );
179
+ },
180
+ );
181
+
182
+ SlashMenuList.displayName = "SlashMenuList";
183
+
184
+ export function slashMenuSuggestion() {
185
+ return {
186
+ items: ({ query }: { query: string }) => {
187
+ return ITEMS.filter((item) =>
188
+ item.title.toLowerCase().includes(query.toLowerCase()),
189
+ );
190
+ },
191
+
192
+ render: () => {
193
+ let component: ReactRenderer<SlashMenuListRef> | null = null;
194
+ let popup: TippyInstance[] | null = null;
195
+
196
+ return {
197
+ onStart: (props: SuggestionProps<SlashItem>) => {
198
+ component = new ReactRenderer(SlashMenuList, {
199
+ props,
200
+ editor: props.editor,
201
+ });
202
+
203
+ if (!props.clientRect) return;
204
+
205
+ popup = tippy("body", {
206
+ getReferenceClientRect: props.clientRect as () => DOMRect,
207
+ appendTo: () => document.body,
208
+ content: component.element,
209
+ showOnCreate: true,
210
+ interactive: true,
211
+ trigger: "manual",
212
+ placement: "bottom-start",
213
+ });
214
+ },
215
+
216
+ onUpdate(props: SuggestionProps<SlashItem>) {
217
+ component?.updateProps(props);
218
+
219
+ if (!props.clientRect || !popup?.[0]) return;
220
+ popup[0].setProps({
221
+ getReferenceClientRect: props.clientRect as () => DOMRect,
222
+ });
223
+ },
224
+
225
+ onKeyDown(props: SuggestionKeyDownProps) {
226
+ if (props.event.key === "Escape") {
227
+ popup?.[0]?.hide();
228
+ return true;
229
+ }
230
+ return component?.ref?.onKeyDown(props) ?? false;
231
+ },
232
+
233
+ onExit() {
234
+ popup?.[0]?.destroy();
235
+ component?.destroy();
236
+ },
237
+ };
238
+ },
239
+ };
240
+ }
frontend/src/editor/StackView.tsx ADDED
@@ -0,0 +1,104 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NodeViewWrapper, NodeViewContent } from "@tiptap/react";
2
+ import { useCallback } from "react";
3
+ import type { NodeViewProps } from "@tiptap/react";
4
+
5
+ const GAP_MAP: Record<string, string> = {
6
+ small: "8px",
7
+ medium: "16px",
8
+ large: "24px",
9
+ };
10
+
11
+ const LAYOUT_OPTIONS = ["2-column", "3-column", "4-column"];
12
+ const GAP_OPTIONS = ["small", "medium", "large"];
13
+
14
+ export function StackView({ node, updateAttributes }: NodeViewProps) {
15
+ const layout = (node.attrs.layout as string) || "2-column";
16
+ const gap = (node.attrs.gap as string) || "medium";
17
+ const colCount = parseInt(layout) || 2;
18
+
19
+ const handleLayoutChange = useCallback(
20
+ (e: React.ChangeEvent<HTMLSelectElement>) => updateAttributes({ layout: e.target.value }),
21
+ [updateAttributes],
22
+ );
23
+
24
+ const handleGapChange = useCallback(
25
+ (e: React.ChangeEvent<HTMLSelectElement>) => updateAttributes({ gap: e.target.value }),
26
+ [updateAttributes],
27
+ );
28
+
29
+ return (
30
+ <NodeViewWrapper data-component="stack">
31
+ <div
32
+ style={{
33
+ border: "1px solid rgba(255,255,255,0.1)",
34
+ borderRadius: 8,
35
+ background: "rgba(255,255,255,0.02)",
36
+ margin: "0.75em 0",
37
+ overflow: "hidden",
38
+ }}
39
+ >
40
+ {/* Header */}
41
+ <div
42
+ contentEditable={false}
43
+ style={{
44
+ display: "flex",
45
+ alignItems: "center",
46
+ gap: 8,
47
+ padding: "6px 12px",
48
+ borderBottom: "1px solid rgba(255,255,255,0.1)",
49
+ userSelect: "none",
50
+ }}
51
+ >
52
+ <span style={{ fontSize: 14, lineHeight: 1 }}>▥</span>
53
+ <span style={{ fontSize: 13, fontWeight: 600, color: "rgba(255,255,255,0.6)" }}>Stack</span>
54
+ <select
55
+ value={layout}
56
+ onChange={handleLayoutChange}
57
+ style={{
58
+ background: "rgba(255,255,255,0.06)",
59
+ border: "1px solid rgba(255,255,255,0.1)",
60
+ borderRadius: 4,
61
+ color: "rgba(255,255,255,0.8)",
62
+ fontSize: 12,
63
+ padding: "2px 6px",
64
+ outline: "none",
65
+ }}
66
+ >
67
+ {LAYOUT_OPTIONS.map((opt) => (
68
+ <option key={opt} value={opt}>{opt}</option>
69
+ ))}
70
+ </select>
71
+ <select
72
+ value={gap}
73
+ onChange={handleGapChange}
74
+ style={{
75
+ background: "rgba(255,255,255,0.06)",
76
+ border: "1px solid rgba(255,255,255,0.1)",
77
+ borderRadius: 4,
78
+ color: "rgba(255,255,255,0.8)",
79
+ fontSize: 12,
80
+ padding: "2px 6px",
81
+ outline: "none",
82
+ }}
83
+ >
84
+ {GAP_OPTIONS.map((opt) => (
85
+ <option key={opt} value={opt}>{opt}</option>
86
+ ))}
87
+ </select>
88
+ </div>
89
+
90
+ {/* Columns rendered by ProseMirror via NodeViewContent */}
91
+ <div
92
+ style={{
93
+ display: "grid",
94
+ gridTemplateColumns: `repeat(${colCount}, 1fr)`,
95
+ gap: GAP_MAP[gap] || "16px",
96
+ padding: "8px 12px",
97
+ }}
98
+ >
99
+ <NodeViewContent className="stack-columns" />
100
+ </div>
101
+ </div>
102
+ </NodeViewWrapper>
103
+ );
104
+ }
frontend/src/editor/comments.ts ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as Y from "yjs";
2
+
3
+ export interface CommentData {
4
+ id: string;
5
+ author: string;
6
+ authorColor: string;
7
+ text: string;
8
+ createdAt: number;
9
+ resolved: boolean;
10
+ resolvedBy?: string;
11
+ resolvedAt?: number;
12
+ }
13
+
14
+ export function createCommentStore(ydoc: Y.Doc) {
15
+ const ymap = ydoc.getMap<CommentData>("comments");
16
+
17
+ return {
18
+ add(comment: CommentData) {
19
+ ymap.set(comment.id, comment);
20
+ },
21
+
22
+ resolve(id: string, resolvedBy: string) {
23
+ const comment = ymap.get(id);
24
+ if (!comment) return;
25
+ ymap.set(id, {
26
+ ...comment,
27
+ resolved: true,
28
+ resolvedBy,
29
+ resolvedAt: Date.now(),
30
+ });
31
+ },
32
+
33
+ unresolve(id: string) {
34
+ const comment = ymap.get(id);
35
+ if (!comment) return;
36
+ ymap.set(id, {
37
+ ...comment,
38
+ resolved: false,
39
+ resolvedBy: undefined,
40
+ resolvedAt: undefined,
41
+ });
42
+ },
43
+
44
+ remove(id: string) {
45
+ ymap.delete(id);
46
+ },
47
+
48
+ getAll(): CommentData[] {
49
+ const comments: CommentData[] = [];
50
+ ymap.forEach((value) => comments.push(value));
51
+ return comments.sort((a, b) => a.createdAt - b.createdAt);
52
+ },
53
+
54
+ observe(callback: () => void) {
55
+ ymap.observe(callback);
56
+ return () => ymap.unobserve(callback);
57
+ },
58
+ };
59
+ }
60
+
61
+ export type CommentStore = ReturnType<typeof createCommentStore>;
frontend/src/editor/components/AtomicView.tsx ADDED
@@ -0,0 +1,144 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // ---------------------------------------------------------------------------
2
+ // Generic atomic NodeView
3
+ //
4
+ // Renders any "atomic" component (no editable children) as a placeholder
5
+ // card showing the component type, icon, and its field values.
6
+ // ---------------------------------------------------------------------------
7
+
8
+ import React, { useCallback } from "react";
9
+ import { NodeViewWrapper } from "@tiptap/react";
10
+ import type { NodeViewProps } from "@tiptap/react";
11
+ import type { ComponentDef, ComponentField } from "./registry";
12
+
13
+ function AtomicFieldRow({
14
+ field,
15
+ value,
16
+ onChange,
17
+ }: {
18
+ field: ComponentField;
19
+ value: unknown;
20
+ onChange: (val: unknown) => void;
21
+ }) {
22
+ if (field.type === "boolean") {
23
+ return (
24
+ <label style={{ display: "flex", alignItems: "center", gap: 6, fontSize: 12, color: "rgba(255,255,255,0.5)", cursor: "pointer" }}>
25
+ <input
26
+ type="checkbox"
27
+ checked={!!value}
28
+ onChange={(e) => onChange(e.target.checked)}
29
+ style={{ accentColor: "#7c3aed" }}
30
+ />
31
+ {field.label}
32
+ </label>
33
+ );
34
+ }
35
+
36
+ if (field.type === "select" && field.options) {
37
+ return (
38
+ <div style={{ display: "flex", alignItems: "center", gap: 8 }}>
39
+ <span style={{ fontSize: 12, color: "rgba(255,255,255,0.4)", minWidth: 60 }}>{field.label}</span>
40
+ <select
41
+ value={String(value ?? field.default ?? "")}
42
+ onChange={(e) => onChange(e.target.value)}
43
+ style={{
44
+ background: "rgba(255,255,255,0.06)",
45
+ border: "1px solid rgba(255,255,255,0.1)",
46
+ borderRadius: 4,
47
+ color: "rgba(255,255,255,0.8)",
48
+ fontSize: 12,
49
+ padding: "2px 6px",
50
+ outline: "none",
51
+ flex: 1,
52
+ }}
53
+ >
54
+ {field.options.map((opt) => (
55
+ <option key={opt} value={opt}>{opt}</option>
56
+ ))}
57
+ </select>
58
+ </div>
59
+ );
60
+ }
61
+
62
+ return (
63
+ <div style={{ display: "flex", alignItems: "center", gap: 8 }}>
64
+ <span style={{ fontSize: 12, color: "rgba(255,255,255,0.4)", minWidth: 60 }}>{field.label}</span>
65
+ <input
66
+ type="text"
67
+ value={String(value ?? "")}
68
+ placeholder={field.placeholder || field.label}
69
+ onChange={(e) => onChange(e.target.value)}
70
+ style={{
71
+ background: "rgba(255,255,255,0.04)",
72
+ border: "1px solid rgba(255,255,255,0.08)",
73
+ borderRadius: 4,
74
+ color: "rgba(255,255,255,0.85)",
75
+ fontSize: 13,
76
+ padding: "4px 8px",
77
+ outline: "none",
78
+ flex: 1,
79
+ minWidth: 0,
80
+ }}
81
+ />
82
+ </div>
83
+ );
84
+ }
85
+
86
+ export function makeAtomicView(def: ComponentDef) {
87
+ function AtomicNodeView({ node, updateAttributes }: NodeViewProps) {
88
+ const handleFieldChange = useCallback(
89
+ (fieldName: string, value: unknown) => {
90
+ updateAttributes({ [fieldName]: value });
91
+ },
92
+ [updateAttributes],
93
+ );
94
+
95
+ const primaryField = def.fields.find((f) => f.name === "src" || f.name === "title") || def.fields[0];
96
+ const primaryValue = primaryField ? String(node.attrs[primaryField.name] || "") : "";
97
+
98
+ return (
99
+ <NodeViewWrapper data-component={def.name}>
100
+ <div
101
+ contentEditable={false}
102
+ style={{
103
+ border: "1px dashed rgba(255,255,255,0.15)",
104
+ borderRadius: 8,
105
+ background: "rgba(255,255,255,0.02)",
106
+ margin: "0.75em 0",
107
+ padding: "12px 16px",
108
+ userSelect: "none",
109
+ }}
110
+ >
111
+ {/* Header */}
112
+ <div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: def.fields.length > 0 ? 10 : 0 }}>
113
+ <span style={{ fontSize: 16, lineHeight: 1 }}>{def.icon}</span>
114
+ <span style={{ fontSize: 13, fontWeight: 600, color: "rgba(255,255,255,0.6)" }}>
115
+ {def.label}
116
+ </span>
117
+ {primaryValue && (
118
+ <span style={{ fontSize: 12, color: "rgba(255,255,255,0.35)", fontFamily: "monospace" }}>
119
+ {primaryValue}
120
+ </span>
121
+ )}
122
+ </div>
123
+
124
+ {/* Fields */}
125
+ {def.fields.length > 0 && (
126
+ <div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
127
+ {def.fields.map((f) => (
128
+ <AtomicFieldRow
129
+ key={f.name}
130
+ field={f}
131
+ value={node.attrs[f.name]}
132
+ onChange={(v) => handleFieldChange(f.name, v)}
133
+ />
134
+ ))}
135
+ </div>
136
+ )}
137
+ </div>
138
+ </NodeViewWrapper>
139
+ );
140
+ }
141
+
142
+ AtomicNodeView.displayName = `${def.tag}View`;
143
+ return AtomicNodeView;
144
+ }
frontend/src/editor/components/WrapperView.tsx ADDED
@@ -0,0 +1,207 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // ---------------------------------------------------------------------------
2
+ // Generic wrapper NodeView
3
+ //
4
+ // Renders any "wrapper" component from the registry as:
5
+ // ┌──────────────────────────────────────┐
6
+ // │ [icon] Title / fields [chevron]│ <- chrome (contentEditable=false)
7
+ // ├──────────────────────────────────────┤
8
+ // │ NodeViewContent (editable blocks) │ <- ProseMirror content hole
9
+ // └──────────────────────────────────────┘
10
+ // ---------------------------------------------------------------------------
11
+
12
+ import React, { useState, useCallback } from "react";
13
+ import { NodeViewWrapper, NodeViewContent } from "@tiptap/react";
14
+ import type { NodeViewProps } from "@tiptap/react";
15
+ import type { ComponentDef, ComponentField } from "./registry";
16
+
17
+ // Variant color mapping for the Note component
18
+ const VARIANT_COLORS: Record<string, { border: string; bg: string; icon: string }> = {
19
+ neutral: { border: "rgba(255,255,255,0.12)", bg: "rgba(255,255,255,0.03)", icon: "📝" },
20
+ info: { border: "rgba(59,130,246,0.35)", bg: "rgba(59,130,246,0.06)", icon: "ℹ️" },
21
+ success: { border: "rgba(34,197,94,0.35)", bg: "rgba(34,197,94,0.06)", icon: "✅" },
22
+ danger: { border: "rgba(239,68,68,0.35)", bg: "rgba(239,68,68,0.06)", icon: "⚠️" },
23
+ };
24
+
25
+ function FieldInput({
26
+ field,
27
+ value,
28
+ onChange,
29
+ }: {
30
+ field: ComponentField;
31
+ value: unknown;
32
+ onChange: (val: unknown) => void;
33
+ }) {
34
+ if (field.type === "boolean") {
35
+ return (
36
+ <label style={{ display: "flex", alignItems: "center", gap: 4, fontSize: 12, color: "rgba(255,255,255,0.5)", cursor: "pointer" }}>
37
+ <input
38
+ type="checkbox"
39
+ checked={!!value}
40
+ onChange={(e) => onChange(e.target.checked)}
41
+ style={{ accentColor: "#7c3aed" }}
42
+ />
43
+ {field.label}
44
+ </label>
45
+ );
46
+ }
47
+
48
+ if (field.type === "select" && field.options) {
49
+ return (
50
+ <select
51
+ value={String(value ?? field.default ?? "")}
52
+ onChange={(e) => onChange(e.target.value)}
53
+ style={{
54
+ background: "rgba(255,255,255,0.06)",
55
+ border: "1px solid rgba(255,255,255,0.1)",
56
+ borderRadius: 4,
57
+ color: "rgba(255,255,255,0.8)",
58
+ fontSize: 12,
59
+ padding: "2px 6px",
60
+ outline: "none",
61
+ }}
62
+ >
63
+ {field.options.map((opt) => (
64
+ <option key={opt} value={opt}>{opt}</option>
65
+ ))}
66
+ </select>
67
+ );
68
+ }
69
+
70
+ // string field
71
+ return (
72
+ <input
73
+ type="text"
74
+ value={String(value ?? "")}
75
+ placeholder={field.placeholder || field.label}
76
+ onChange={(e) => onChange(e.target.value)}
77
+ style={{
78
+ background: "transparent",
79
+ border: "none",
80
+ borderBottom: "1px solid rgba(255,255,255,0.1)",
81
+ color: "rgba(255,255,255,0.9)",
82
+ fontSize: 14,
83
+ fontWeight: 600,
84
+ padding: "2px 0",
85
+ outline: "none",
86
+ flex: 1,
87
+ minWidth: 0,
88
+ }}
89
+ />
90
+ );
91
+ }
92
+
93
+ /**
94
+ * Factory: returns a React component bound to a specific ComponentDef.
95
+ */
96
+ export function makeWrapperView(def: ComponentDef) {
97
+ function WrapperNodeView({ node, updateAttributes }: NodeViewProps) {
98
+ const [collapsed, setCollapsed] = useState(
99
+ def.name === "accordion" ? !node.attrs.open : false,
100
+ );
101
+
102
+ const handleFieldChange = useCallback(
103
+ (fieldName: string, value: unknown) => {
104
+ updateAttributes({ [fieldName]: value });
105
+ },
106
+ [updateAttributes],
107
+ );
108
+
109
+ const titleField = def.fields.find((f) => f.name === "title");
110
+ const otherFields = def.fields.filter((f) => f.name !== "title");
111
+
112
+ // Variant-aware styling for Note
113
+ const variant = node.attrs.variant as string | undefined;
114
+ const colors = def.name === "note" && variant ? VARIANT_COLORS[variant] || VARIANT_COLORS.neutral : null;
115
+
116
+ const emoji = (node.attrs.emoji as string) || colors?.icon || def.icon;
117
+
118
+ const borderColor = colors?.border || "rgba(255,255,255,0.1)";
119
+ const bgColor = colors?.bg || "rgba(255,255,255,0.02)";
120
+
121
+ const isAccordion = def.name === "accordion";
122
+
123
+ return (
124
+ <NodeViewWrapper
125
+ data-component={def.name}
126
+ style={{
127
+ border: `1px solid ${borderColor}`,
128
+ borderRadius: 8,
129
+ background: bgColor,
130
+ margin: "0.75em 0",
131
+ overflow: "hidden",
132
+ }}
133
+ >
134
+ {/* Header chrome - not editable by ProseMirror */}
135
+ <div
136
+ contentEditable={false}
137
+ style={{
138
+ display: "flex",
139
+ alignItems: "center",
140
+ gap: 8,
141
+ padding: "8px 12px",
142
+ borderBottom: collapsed ? "none" : `1px solid ${borderColor}`,
143
+ userSelect: "none",
144
+ cursor: isAccordion ? "pointer" : "default",
145
+ }}
146
+ onClick={isAccordion ? () => setCollapsed((c) => !c) : undefined}
147
+ >
148
+ <span style={{ fontSize: 14, lineHeight: 1, flexShrink: 0 }}>{emoji}</span>
149
+
150
+ {titleField ? (
151
+ <div
152
+ style={{ flex: 1, minWidth: 0 }}
153
+ onClick={(e) => e.stopPropagation()}
154
+ >
155
+ <FieldInput
156
+ field={titleField}
157
+ value={node.attrs[titleField.name]}
158
+ onChange={(v) => handleFieldChange(titleField.name, v)}
159
+ />
160
+ </div>
161
+ ) : (
162
+ <span style={{ flex: 1, fontSize: 13, fontWeight: 600, color: "rgba(255,255,255,0.6)" }}>
163
+ {def.label}
164
+ </span>
165
+ )}
166
+
167
+ {otherFields.map((f) => (
168
+ <div key={f.name} onClick={(e) => e.stopPropagation()}>
169
+ <FieldInput
170
+ field={f}
171
+ value={node.attrs[f.name]}
172
+ onChange={(v) => handleFieldChange(f.name, v)}
173
+ />
174
+ </div>
175
+ ))}
176
+
177
+ {isAccordion && (
178
+ <span
179
+ style={{
180
+ fontSize: 14,
181
+ transition: "transform 200ms ease",
182
+ transform: collapsed ? "rotate(-90deg)" : "rotate(0deg)",
183
+ color: "rgba(255,255,255,0.4)",
184
+ flexShrink: 0,
185
+ }}
186
+ >
187
+
188
+ </span>
189
+ )}
190
+ </div>
191
+
192
+ {/* Editable content area */}
193
+ <div
194
+ style={{
195
+ display: collapsed ? "none" : "block",
196
+ padding: "8px 12px",
197
+ }}
198
+ >
199
+ <NodeViewContent className="component-content" />
200
+ </div>
201
+ </NodeViewWrapper>
202
+ );
203
+ }
204
+
205
+ WrapperNodeView.displayName = `${def.tag}View`;
206
+ return WrapperNodeView;
207
+ }
frontend/src/editor/components/factory.ts ADDED
@@ -0,0 +1,134 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // ---------------------------------------------------------------------------
2
+ // Extension factories
3
+ //
4
+ // Generate TipTap Node extensions from ComponentDef declarations.
5
+ // ---------------------------------------------------------------------------
6
+
7
+ import { Node, mergeAttributes } from "@tiptap/core";
8
+ import { ReactNodeViewRenderer } from "@tiptap/react";
9
+ import type { ComponentDef } from "./registry";
10
+ import { makeWrapperView } from "./WrapperView";
11
+ import { makeAtomicView } from "./AtomicView";
12
+
13
+ /**
14
+ * Build a TipTap Node for a "wrapper" component (has editable children).
15
+ *
16
+ * The resulting node:
17
+ * - belongs to the "block" group
18
+ * - accepts block+ content (paragraphs, lists, code, …)
19
+ * - stores each field as a node attribute
20
+ * - exposes an `insert<Tag>` command
21
+ * - renders via a generic React NodeView parameterised by the def
22
+ */
23
+ export function createWrapperExtension(def: ComponentDef) {
24
+ const commandName = `insert${def.tag}`;
25
+
26
+ return Node.create({
27
+ name: def.name,
28
+ group: "block",
29
+ content: def.content || "block+",
30
+ defining: true,
31
+ isolating: true,
32
+
33
+ addAttributes() {
34
+ const attrs: Record<string, { default: unknown }> = {};
35
+ for (const f of def.fields) {
36
+ attrs[f.name] = { default: f.default ?? null };
37
+ }
38
+ return attrs;
39
+ },
40
+
41
+ parseHTML() {
42
+ return [{ tag: `div[data-component="${def.name}"]` }];
43
+ },
44
+
45
+ renderHTML({ HTMLAttributes }) {
46
+ return [
47
+ "div",
48
+ mergeAttributes(HTMLAttributes, { "data-component": def.name }),
49
+ 0, // ProseMirror content hole
50
+ ];
51
+ },
52
+
53
+ addCommands() {
54
+ return {
55
+ [commandName]:
56
+ () =>
57
+ ({ commands }: { commands: any }) => {
58
+ const attrs: Record<string, unknown> = {};
59
+ for (const f of def.fields) {
60
+ attrs[f.name] = f.default ?? null;
61
+ }
62
+ return commands.insertContent({
63
+ type: def.name,
64
+ attrs,
65
+ content: [{ type: "paragraph" }],
66
+ });
67
+ },
68
+ } as any;
69
+ },
70
+
71
+ addNodeView() {
72
+ return ReactNodeViewRenderer(makeWrapperView(def));
73
+ },
74
+ });
75
+ }
76
+
77
+ /**
78
+ * Build a TipTap Node for an "atomic" component (no editable children).
79
+ *
80
+ * The resulting node:
81
+ * - belongs to the "block" group
82
+ * - is an atom (no ProseMirror content inside)
83
+ * - stores each field as a node attribute
84
+ * - exposes an `insert<Tag>` command
85
+ * - renders via a generic placeholder NodeView
86
+ */
87
+ export function createAtomicExtension(def: ComponentDef) {
88
+ const commandName = `insert${def.tag}`;
89
+
90
+ return Node.create({
91
+ name: def.name,
92
+ group: "block",
93
+ atom: true,
94
+ draggable: true,
95
+ selectable: true,
96
+
97
+ addAttributes() {
98
+ const attrs: Record<string, { default: unknown }> = {};
99
+ for (const f of def.fields) {
100
+ attrs[f.name] = { default: f.default ?? null };
101
+ }
102
+ return attrs;
103
+ },
104
+
105
+ parseHTML() {
106
+ return [{ tag: `div[data-component="${def.name}"]` }];
107
+ },
108
+
109
+ renderHTML({ HTMLAttributes }) {
110
+ return [
111
+ "div",
112
+ mergeAttributes(HTMLAttributes, { "data-component": def.name }),
113
+ ];
114
+ },
115
+
116
+ addCommands() {
117
+ return {
118
+ [commandName]:
119
+ () =>
120
+ ({ commands }: { commands: any }) => {
121
+ const attrs: Record<string, unknown> = {};
122
+ for (const f of def.fields) {
123
+ attrs[f.name] = f.default ?? null;
124
+ }
125
+ return commands.insertContent({ type: def.name, attrs });
126
+ },
127
+ } as any;
128
+ },
129
+
130
+ addNodeView() {
131
+ return ReactNodeViewRenderer(makeAtomicView(def));
132
+ },
133
+ });
134
+ }
frontend/src/editor/components/index.ts ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ export { COMPONENTS } from "./registry";
2
+ export type { ComponentDef, ComponentField } from "./registry";
3
+ export { createWrapperExtension, createAtomicExtension } from "./factory";
4
+ export { registerComponentSerializers } from "./serializers";
5
+ export { getComponentSlashItems } from "./slash-items";
frontend/src/editor/components/registry.ts ADDED
@@ -0,0 +1,163 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // ---------------------------------------------------------------------------
2
+ // Component Registry
3
+ //
4
+ // Each entry describes a custom MDX component that can be used inside the
5
+ // editor. Factories (factory.ts) consume these definitions to auto-generate
6
+ // TipTap extensions, NodeViews, slash-menu items and MDX serializers.
7
+ // ---------------------------------------------------------------------------
8
+
9
+ export interface ComponentField {
10
+ name: string;
11
+ type: "string" | "boolean" | "select";
12
+ default?: unknown;
13
+ options?: string[];
14
+ label: string;
15
+ placeholder?: string;
16
+ }
17
+
18
+ export interface ComponentDef {
19
+ /** Internal TipTap node name, e.g. "accordion" */
20
+ name: string;
21
+ /** MDX / Astro tag name, e.g. "Accordion" */
22
+ tag: string;
23
+ /** Single-char or emoji icon for the slash menu */
24
+ icon: string;
25
+ /** Human-readable label shown in the slash menu */
26
+ label: string;
27
+ /** Short description shown below the label */
28
+ description: string;
29
+ /** "wrapper" = has editable children, "atomic" = self-closing */
30
+ kind: "wrapper" | "atomic";
31
+ /** Editable attributes exposed in the toolbar */
32
+ fields: ComponentField[];
33
+ /** ProseMirror content expression (wrapper only, defaults to "block+") */
34
+ content?: string;
35
+ }
36
+
37
+ // ---------------------------------------------------------------------------
38
+ // Registry — add new components here
39
+ // ---------------------------------------------------------------------------
40
+
41
+ export const COMPONENTS: ComponentDef[] = [
42
+ {
43
+ name: "accordion",
44
+ tag: "Accordion",
45
+ icon: "▸",
46
+ label: "Accordion",
47
+ description: "Collapsible content section",
48
+ kind: "wrapper",
49
+ fields: [
50
+ { name: "title", type: "string", label: "Title", default: "Details", placeholder: "Section title…" },
51
+ { name: "open", type: "boolean", label: "Open by default", default: false },
52
+ ],
53
+ },
54
+ {
55
+ name: "note",
56
+ tag: "Note",
57
+ icon: "ℹ",
58
+ label: "Note / Callout",
59
+ description: "Highlighted callout box",
60
+ kind: "wrapper",
61
+ fields: [
62
+ { name: "title", type: "string", label: "Title", default: "", placeholder: "Optional title…" },
63
+ { name: "emoji", type: "string", label: "Emoji", default: "", placeholder: "e.g. 💡" },
64
+ {
65
+ name: "variant",
66
+ type: "select",
67
+ label: "Variant",
68
+ default: "neutral",
69
+ options: ["neutral", "info", "success", "danger"],
70
+ },
71
+ ],
72
+ },
73
+ {
74
+ name: "quoteBlock",
75
+ tag: "Quote",
76
+ icon: "❝",
77
+ label: "Quote block",
78
+ description: "Quote with author attribution",
79
+ kind: "wrapper",
80
+ fields: [
81
+ { name: "author", type: "string", label: "Author", default: "", placeholder: "Author name…" },
82
+ { name: "source", type: "string", label: "Source", default: "", placeholder: "Book, URL…" },
83
+ ],
84
+ },
85
+ {
86
+ name: "wide",
87
+ tag: "Wide",
88
+ icon: "↔",
89
+ label: "Wide",
90
+ description: "Wider-than-column container",
91
+ kind: "wrapper",
92
+ fields: [],
93
+ },
94
+ {
95
+ name: "fullWidth",
96
+ tag: "FullWidth",
97
+ icon: "⟷",
98
+ label: "Full width",
99
+ description: "Full-width container",
100
+ kind: "wrapper",
101
+ fields: [],
102
+ },
103
+ {
104
+ name: "sidenote",
105
+ tag: "Sidenote",
106
+ icon: "¶",
107
+ label: "Sidenote",
108
+ description: "Margin note alongside content",
109
+ kind: "wrapper",
110
+ fields: [],
111
+ },
112
+ {
113
+ name: "reference",
114
+ tag: "Reference",
115
+ icon: "🏷",
116
+ label: "Reference / Figure",
117
+ description: "Captioned figure with anchor ID",
118
+ kind: "wrapper",
119
+ fields: [
120
+ { name: "id", type: "string", label: "ID", default: "", placeholder: "fig-1" },
121
+ { name: "caption", type: "string", label: "Caption", default: "", placeholder: "Figure caption…" },
122
+ ],
123
+ },
124
+ {
125
+ name: "htmlEmbed",
126
+ tag: "HtmlEmbed",
127
+ icon: "📊",
128
+ label: "HTML Embed",
129
+ description: "Embed an external HTML visualization",
130
+ kind: "atomic",
131
+ fields: [
132
+ { name: "src", type: "string", label: "Source file", default: "", placeholder: "d3-chart.html" },
133
+ { name: "title", type: "string", label: "Title", default: "", placeholder: "Chart title…" },
134
+ { name: "desc", type: "string", label: "Description", default: "", placeholder: "Chart description…" },
135
+ { name: "wide", type: "boolean", label: "Wide", default: false },
136
+ { name: "downloadable", type: "boolean", label: "Downloadable", default: false },
137
+ ],
138
+ },
139
+ {
140
+ name: "hfUser",
141
+ tag: "HfUser",
142
+ icon: "👤",
143
+ label: "HF User card",
144
+ description: "Hugging Face user profile card",
145
+ kind: "atomic",
146
+ fields: [
147
+ { name: "username", type: "string", label: "Username", default: "", placeholder: "username" },
148
+ { name: "name", type: "string", label: "Display name", default: "", placeholder: "Full Name" },
149
+ { name: "url", type: "string", label: "URL", default: "", placeholder: "https://huggingface.co/username" },
150
+ ],
151
+ },
152
+ {
153
+ name: "rawHtml",
154
+ tag: "RawHtml",
155
+ icon: "</>",
156
+ label: "Raw HTML",
157
+ description: "Inject raw HTML content",
158
+ kind: "atomic",
159
+ fields: [
160
+ { name: "html", type: "string", label: "HTML", default: "", placeholder: "<div>…</div>" },
161
+ ],
162
+ },
163
+ ];
frontend/src/editor/components/serializers.ts ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // ---------------------------------------------------------------------------
2
+ // Auto-register MDX serializers for every component in the registry.
3
+ //
4
+ // Call `registerComponentSerializers()` once at startup (side-effect import).
5
+ // ---------------------------------------------------------------------------
6
+
7
+ import { registerSerializer, serializeNode } from "../../export/serializers/index";
8
+ import { COMPONENTS } from "./registry";
9
+ import type { ComponentDef } from "./registry";
10
+
11
+ function serializeAttrs(def: ComponentDef, attrs: Record<string, unknown>): string {
12
+ const parts: string[] = [];
13
+
14
+ for (const f of def.fields) {
15
+ const val = attrs[f.name];
16
+ if (val === undefined || val === null || val === f.default) continue;
17
+
18
+ if (f.type === "boolean") {
19
+ if (val) parts.push(f.name);
20
+ } else {
21
+ parts.push(`${f.name}="${String(val)}"`);
22
+ }
23
+ }
24
+
25
+ return parts.length ? ` ${parts.join(" ")}` : "";
26
+ }
27
+
28
+ export function registerComponentSerializers() {
29
+ for (const def of COMPONENTS) {
30
+ registerSerializer(def.name, (node) => {
31
+ const attrStr = serializeAttrs(def, node.attrs ?? {});
32
+
33
+ if (def.kind === "atomic") {
34
+ return `<${def.tag}${attrStr} />`;
35
+ }
36
+
37
+ const inner = node.content?.map(serializeNode).join("\n\n") ?? "";
38
+ return `<${def.tag}${attrStr}>\n${inner}\n</${def.tag}>`;
39
+ });
40
+ }
41
+ }
frontend/src/editor/components/slash-items.ts ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // ---------------------------------------------------------------------------
2
+ // Auto-generate slash-menu items from the component registry.
3
+ // ---------------------------------------------------------------------------
4
+
5
+ import { COMPONENTS } from "./registry";
6
+ import type { Editor } from "@tiptap/core";
7
+
8
+ export interface SlashItem {
9
+ title: string;
10
+ description: string;
11
+ icon: string;
12
+ command: (editor: Editor) => void;
13
+ }
14
+
15
+ export function getComponentSlashItems(): SlashItem[] {
16
+ return COMPONENTS.map((def) => {
17
+ const commandName = `insert${def.tag}`;
18
+ return {
19
+ title: def.label,
20
+ description: def.description,
21
+ icon: def.icon,
22
+ command: (editor: Editor) => {
23
+ (editor.chain().focus() as any)[commandName]().run();
24
+ },
25
+ };
26
+ });
27
+ }
frontend/src/editor/default-content.ts ADDED
@@ -0,0 +1,233 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Default demo content loaded when a document is empty (first sync).
3
+ * Covers every block/mark type supported by the editor.
4
+ */
5
+ export const DEFAULT_CONTENT = `
6
+ <h1>Welcome to the Collaborative Editor</h1>
7
+
8
+ <p>This demo article showcases <strong>every content type</strong> available in the editor. Feel free to edit, delete, or rewrite anything — the AI assistant in the left panel can help you.</p>
9
+
10
+ <h2>Text formatting</h2>
11
+
12
+ <p>You can make text <strong>bold</strong>, <em>italic</em>, or <s>strikethrough</s>. Combine them for <strong><em>bold italic</em></strong>. Use <code>inline code</code> for technical terms, and add <a href="https://huggingface.co">links</a> to external resources.</p>
13
+
14
+ <h2>Lists</h2>
15
+
16
+ <h3>Unordered list</h3>
17
+
18
+ <ul>
19
+ <li>First item with some context</li>
20
+ <li>Second item — supports <strong>rich formatting</strong> inside</li>
21
+ <li>Third item with <code>inline code</code></li>
22
+ </ul>
23
+
24
+ <h3>Ordered list</h3>
25
+
26
+ <ol>
27
+ <li>Prepare the dataset</li>
28
+ <li>Fine-tune the model</li>
29
+ <li>Evaluate on the test split</li>
30
+ <li>Deploy to production</li>
31
+ </ol>
32
+
33
+ <h2>Blockquote</h2>
34
+
35
+ <blockquote>
36
+ <p>"The best way to predict the future is to invent it." — Alan Kay</p>
37
+ </blockquote>
38
+
39
+ <h2>Code block</h2>
40
+
41
+ <pre><code class="language-python">import torch
42
+ from transformers import AutoModelForCausalLM, AutoTokenizer
43
+
44
+ model_id = "meta-llama/Llama-3-8B"
45
+ tokenizer = AutoTokenizer.from_pretrained(model_id)
46
+ model = AutoModelForCausalLM.from_pretrained(
47
+ model_id,
48
+ torch_dtype=torch.bfloat16,
49
+ device_map="auto",
50
+ )
51
+
52
+ prompt = "The future of AI is"
53
+ inputs = tokenizer(prompt, return_tensors="pt").to(model.device)
54
+ outputs = model.generate(**inputs, max_new_tokens=100)
55
+ print(tokenizer.decode(outputs[0], skip_special_tokens=True))</code></pre>
56
+
57
+ <h2>Table</h2>
58
+
59
+ <table>
60
+ <tr>
61
+ <th>Model</th>
62
+ <th>Parameters</th>
63
+ <th>MMLU</th>
64
+ <th>HellaSwag</th>
65
+ </tr>
66
+ <tr>
67
+ <td>LLaMA 3 8B</td>
68
+ <td>8B</td>
69
+ <td>66.6</td>
70
+ <td>82.0</td>
71
+ </tr>
72
+ <tr>
73
+ <td>LLaMA 3 70B</td>
74
+ <td>70B</td>
75
+ <td>79.5</td>
76
+ <td>87.3</td>
77
+ </tr>
78
+ <tr>
79
+ <td>GPT-4</td>
80
+ <td>~1.8T</td>
81
+ <td>86.4</td>
82
+ <td>95.3</td>
83
+ </tr>
84
+ <tr>
85
+ <td>Mixtral 8x7B</td>
86
+ <td>46.7B</td>
87
+ <td>70.6</td>
88
+ <td>84.4</td>
89
+ </tr>
90
+ </table>
91
+
92
+ <h2>Mathematics</h2>
93
+
94
+ <p>Inline math works with double dollars: the quadratic formula is <span data-type="inline-math" data-latex="x = \\frac{-b \\pm \\sqrt{b^2 - 4ac}}{2a}"></span> and Euler's identity is <span data-type="inline-math" data-latex="e^{i\\pi} + 1 = 0"></span>.</p>
95
+
96
+ <p>Block equations use triple dollars. Here is a standard autoregressive loss:</p>
97
+
98
+ <div data-type="block-math" data-latex="\\mathcal{L}(\\theta) = -\\sum_{t=1}^{T} \\log P(x_t \\mid x_{<t}; \\theta)"></div>
99
+
100
+ <p>And the full variational trajectory decomposition:</p>
101
+
102
+ <div data-type="block-math" data-latex="\\begin{aligned} \\log p_\\theta(\\mathcal{D}) &amp;= \\log \\sum_{i=0}^N p_\\theta((o,a)_i) \\\\ &amp;= \\log \\sum_{i=0}^N \\int_{\\text{supp}(Z)} p_\\theta((o,a)_i \\mid z)\\, p(z) \\\\ &amp;= \\log \\sum_{i=0}^N \\int_{\\text{supp}(Z)} \\frac{q_\\theta(z \\mid (o,a)_i)}{q_\\theta(z \\mid (o,a)_i)} \\cdot p_\\theta((o,a)_i \\mid z)\\, p(z) \\\\ &amp;= \\log \\sum_{i=0}^N \\mathbb{E}_{z \\sim p_\\theta(\\bullet \\mid (o,a)_i)} \\left[ \\frac{p(z)}{q_\\theta(z \\mid (o,a)_i)} \\cdot p_\\theta((o,a)_i \\mid z) \\right] \\end{aligned}"></div>
103
+
104
+ <h2>Scientific references</h2>
105
+
106
+ <p>The editor supports academic citations. The Transformer architecture <span data-type="citation" key="vaswani2017"></span> revolutionized natural language processing by introducing the self-attention mechanism. Large-scale pretraining <span data-type="citation" key="devlin2019"></span> demonstrated that unsupervised objectives on massive corpora produce powerful general-purpose representations.</p>
107
+
108
+ <p>More recently, scaling laws <span data-type="citation" key="kaplan2020"></span> have shown predictable relationships between model size, dataset size, and performance.</p>
109
+
110
+ <hr />
111
+
112
+ <h2>Horizontal rule</h2>
113
+
114
+ <p>Use a horizontal rule to visually separate sections:</p>
115
+
116
+ <hr />
117
+
118
+ <h2>Nested content</h2>
119
+
120
+ <p>Lists can contain multiple levels of formatting. Blockquotes can hold structured content:</p>
121
+
122
+ <blockquote>
123
+ <p><strong>Note:</strong> This editor supports real-time collaboration. Open this page in another tab to see cursors sync live.</p>
124
+ </blockquote>
125
+
126
+ <h3>A deeper heading level</h3>
127
+
128
+ <p>Heading levels go from H1 down to H3, giving you a clear document hierarchy. The floating menu on the left lets you insert any block type, and the bubble toolbar appears when you select text.</p>
129
+
130
+ <h2>Custom components</h2>
131
+
132
+ <p>The editor supports rich custom components from the research article template. Use the <code>/</code> slash menu to insert them.</p>
133
+
134
+ <div data-component="accordion" title="Implementation details" open="false"><p>The model was fine-tuned using LoRA adapters with rank 16 on 4× A100 GPUs. Training took approximately 12 hours on the full dataset. We used a cosine learning rate schedule with warm-up over the first 10% of steps.</p></div>
135
+
136
+ <div data-component="note" title="Important" emoji="💡" variant="info"><p>All experiments were conducted with mixed-precision (bf16) training. Results may vary slightly with different random seeds.</p></div>
137
+
138
+ <div data-component="quoteBlock" author="Ilya Sutskever" source="NeurIPS 2024 keynote"><p>If you have a very large neural network and you train it on a very large dataset, you get very good results. It really is that simple.</p></div>
139
+
140
+ <div data-component="note" title="Warning" emoji="⚠️" variant="danger"><p>Do not use these scaling estimates for production capacity planning without accounting for inference overhead and memory constraints.</p></div>
141
+
142
+ <div data-component="wide"><p>This content stretches beyond the normal column width, useful for wide tables, figures or visualizations that need extra horizontal space.</p></div>
143
+
144
+ <div data-component="fullWidth"><p>This content spans the entire page width. It is ideal for large figures, panoramic images, or full-bleed data visualizations.</p></div>
145
+
146
+ <div data-component="sidenote"><p>Scaling laws were first systematically studied in the context of neural machine translation before being generalized to language models.</p></div>
147
+
148
+ <div data-component="reference" id="fig-scaling" caption="Log-linear relationship between compute budget and model performance across three orders of magnitude."><p>[ Figure placeholder — insert an image or chart here ]</p></div>
149
+
150
+ <div data-component="htmlEmbed" src="d3-scaling-chart.html" title="Interactive scaling law visualization" desc="Explore how model size, data, and compute interact." wide="false" downloadable="true"></div>
151
+
152
+ <p>The concept of <span data-type="glossary" term="Scaling law" definition="A power-law relationship between model size, dataset size, compute budget, and performance."></span> has become central to modern AI research. Early work<span data-type="footnote" content="Hestness et al. (2017) observed similar scaling behavior in vision and translation tasks before the scaling laws paper formalized the framework."></span> suggested these relationships hold across modalities.</p>
153
+
154
+ <div data-component="stack" layout="2-column" gap="medium"><div data-type="stack-column"><p><strong>Column A</strong></p><p>Multi-column layouts let you place content side by side. Each column is fully editable.</p></div><div data-type="stack-column"><p><strong>Column B</strong></p><p>Use the layout selector in the header to switch between 2, 3, or 4 columns.</p></div></div>
155
+
156
+ <div data-component="hfUser" username="tfrere" name="Thibaud Frere" url="https://huggingface.co/tfrere"></div>
157
+
158
+ <h2>What's next?</h2>
159
+
160
+ <p>Try asking the AI assistant to:</p>
161
+
162
+ <ul>
163
+ <li>Rewrite a paragraph in a different tone</li>
164
+ <li>Expand a section with more detail</li>
165
+ <li>Fix grammar or spelling errors</li>
166
+ <li>Translate content to another language</li>
167
+ <li>Add a new section on a specific topic</li>
168
+ </ul>
169
+
170
+ <p>Select some text and use the quick actions, or type a message in the chat panel. All AI edits can be undone with <code>Cmd+Z</code>.</p>
171
+
172
+ <div data-type="bibliography"></div>
173
+ `;
174
+
175
+ /**
176
+ * Demo citation entries (CSL-JSON) seeded into Y.Map("citations").
177
+ */
178
+ export const SEED_CITATIONS: Record<string, any> = {
179
+ vaswani2017: {
180
+ id: "vaswani2017",
181
+ type: "paper-conference",
182
+ title: "Attention is all you need",
183
+ author: [
184
+ { family: "Vaswani", given: "Ashish" },
185
+ { family: "Shazeer", given: "Noam" },
186
+ { family: "Parmar", given: "Niki" },
187
+ { family: "Uszkoreit", given: "Jakob" },
188
+ { family: "Jones", given: "Llion" },
189
+ { family: "Gomez", given: "Aidan N." },
190
+ { family: "Kaiser", given: "Lukasz" },
191
+ { family: "Polosukhin", given: "Illia" },
192
+ ],
193
+ issued: { "date-parts": [[2017]] },
194
+ "container-title": "Advances in Neural Information Processing Systems",
195
+ volume: "30",
196
+ DOI: "10.48550/arXiv.1706.03762",
197
+ },
198
+ devlin2019: {
199
+ id: "devlin2019",
200
+ type: "paper-conference",
201
+ title: "BERT: Pre-training of deep bidirectional transformers for language understanding",
202
+ author: [
203
+ { family: "Devlin", given: "Jacob" },
204
+ { family: "Chang", given: "Ming-Wei" },
205
+ { family: "Lee", given: "Kenton" },
206
+ { family: "Toutanova", given: "Kristina" },
207
+ ],
208
+ issued: { "date-parts": [[2019]] },
209
+ "container-title": "Proceedings of NAACL-HLT",
210
+ page: "4171-4186",
211
+ DOI: "10.18653/v1/N19-1423",
212
+ },
213
+ kaplan2020: {
214
+ id: "kaplan2020",
215
+ type: "article-journal",
216
+ title: "Scaling laws for neural language models",
217
+ author: [
218
+ { family: "Kaplan", given: "Jared" },
219
+ { family: "McCandlish", given: "Sam" },
220
+ { family: "Henighan", given: "Tom" },
221
+ { family: "Brown", given: "Tom B." },
222
+ { family: "Chess", given: "Benjamin" },
223
+ { family: "Child", given: "Rewon" },
224
+ { family: "Gray", given: "Scott" },
225
+ { family: "Radford", given: "Alec" },
226
+ { family: "Wu", given: "Jeffrey" },
227
+ { family: "Amodei", given: "Dario" },
228
+ ],
229
+ issued: { "date-parts": [[2020]] },
230
+ "container-title": "arXiv preprint arXiv:2001.08361",
231
+ DOI: "10.48550/arXiv.2001.08361",
232
+ },
233
+ };
frontend/src/editor/extensions/bibliography.ts ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Node, mergeAttributes } from "@tiptap/core";
2
+ import { ReactNodeViewRenderer } from "@tiptap/react";
3
+ import { BibliographyView } from "../BibliographyView";
4
+
5
+ declare module "@tiptap/core" {
6
+ interface Commands<ReturnType> {
7
+ bibliography: {
8
+ insertBibliography: () => ReturnType;
9
+ };
10
+ }
11
+ }
12
+
13
+ export const Bibliography = Node.create({
14
+ name: "bibliography",
15
+ group: "block",
16
+ atom: true,
17
+ draggable: false,
18
+ selectable: true,
19
+
20
+ parseHTML() {
21
+ return [{ tag: 'div[data-type="bibliography"]' }];
22
+ },
23
+
24
+ renderHTML({ HTMLAttributes }) {
25
+ return [
26
+ "div",
27
+ mergeAttributes(HTMLAttributes, { "data-type": "bibliography" }),
28
+ ];
29
+ },
30
+
31
+ addCommands() {
32
+ return {
33
+ insertBibliography:
34
+ () =>
35
+ ({ commands }) => {
36
+ return commands.insertContent({ type: this.name });
37
+ },
38
+ };
39
+ },
40
+
41
+ addNodeView() {
42
+ return ReactNodeViewRenderer(BibliographyView);
43
+ },
44
+ });
frontend/src/editor/extensions/citation.ts ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Node, mergeAttributes } from "@tiptap/core";
2
+ import { ReactNodeViewRenderer } from "@tiptap/react";
3
+ import { CitationView } from "../CitationView";
4
+
5
+ declare module "@tiptap/core" {
6
+ interface Commands<ReturnType> {
7
+ citation: {
8
+ insertCitation: (key: string) => ReturnType;
9
+ };
10
+ }
11
+ }
12
+
13
+ export const Citation = Node.create({
14
+ name: "citation",
15
+ group: "inline",
16
+ inline: true,
17
+ atom: true,
18
+
19
+ addStorage() {
20
+ return {
21
+ citationsMap: null as any,
22
+ settingsMap: null as any,
23
+ };
24
+ },
25
+
26
+ addAttributes() {
27
+ return {
28
+ key: { default: "" },
29
+ };
30
+ },
31
+
32
+ parseHTML() {
33
+ return [{ tag: 'span[data-type="citation"]' }];
34
+ },
35
+
36
+ renderHTML({ HTMLAttributes }) {
37
+ return [
38
+ "span",
39
+ mergeAttributes(HTMLAttributes, { "data-type": "citation" }),
40
+ ];
41
+ },
42
+
43
+ addCommands() {
44
+ return {
45
+ insertCitation:
46
+ (key: string) =>
47
+ ({ commands }) => {
48
+ return commands.insertContent({
49
+ type: this.name,
50
+ attrs: { key },
51
+ });
52
+ },
53
+ };
54
+ },
55
+
56
+ addNodeView() {
57
+ return ReactNodeViewRenderer(CitationView);
58
+ },
59
+ });
frontend/src/editor/extensions/collaboration-undo.ts ADDED
@@ -0,0 +1,87 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Extension } from "@tiptap/core";
2
+ import { UndoManager } from "yjs";
3
+ import type { XmlFragment } from "yjs";
4
+
5
+ declare module "@tiptap/core" {
6
+ interface Commands<ReturnType> {
7
+ collaborationUndo: {
8
+ undo: () => ReturnType;
9
+ redo: () => ReturnType;
10
+ };
11
+ }
12
+ }
13
+
14
+ export interface CollaborationUndoOptions {
15
+ fragment: XmlFragment;
16
+ onUndoManagerReady?: (manager: UndoManager) => void;
17
+ }
18
+
19
+ /**
20
+ * Wraps Y.UndoManager as a TipTap extension, replacing the disabled
21
+ * ProseMirror history with collaborative undo/redo.
22
+ *
23
+ * Agent edits can be grouped by calling undoManager.stopCapturing()
24
+ * before and after a batch of changes - everything between two
25
+ * stopCapturing() calls is merged into a single undo step.
26
+ */
27
+ export const CollaborationUndo = Extension.create<CollaborationUndoOptions>({
28
+ name: "collaborationUndo",
29
+
30
+ addOptions() {
31
+ return {
32
+ fragment: null as unknown as XmlFragment,
33
+ onUndoManagerReady: undefined,
34
+ };
35
+ },
36
+
37
+ addStorage() {
38
+ return {
39
+ undoManager: null as UndoManager | null,
40
+ };
41
+ },
42
+
43
+ onCreate() {
44
+ const um = new UndoManager(this.options.fragment, {
45
+ captureTimeout: 500,
46
+ });
47
+ this.storage.undoManager = um;
48
+ this.options.onUndoManagerReady?.(um);
49
+ },
50
+
51
+ onDestroy() {
52
+ this.storage.undoManager?.destroy();
53
+ },
54
+
55
+ addCommands() {
56
+ return {
57
+ undo:
58
+ () =>
59
+ () => {
60
+ const um = this.storage.undoManager;
61
+ if (um && um.undoStack.length > 0) {
62
+ um.undo();
63
+ return true;
64
+ }
65
+ return false;
66
+ },
67
+ redo:
68
+ () =>
69
+ () => {
70
+ const um = this.storage.undoManager;
71
+ if (um && um.redoStack.length > 0) {
72
+ um.redo();
73
+ return true;
74
+ }
75
+ return false;
76
+ },
77
+ };
78
+ },
79
+
80
+ addKeyboardShortcuts() {
81
+ return {
82
+ "Mod-z": () => this.editor.commands.undo(),
83
+ "Mod-Shift-z": () => this.editor.commands.redo(),
84
+ "Mod-y": () => this.editor.commands.redo(),
85
+ };
86
+ },
87
+ });
frontend/src/editor/extensions/comment.ts ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Mark, mergeAttributes } from "@tiptap/core";
2
+
3
+ export interface CommentOptions {
4
+ HTMLAttributes: Record<string, string>;
5
+ }
6
+
7
+ declare module "@tiptap/core" {
8
+ interface Commands<ReturnType> {
9
+ comment: {
10
+ setComment: (commentId: string) => ReturnType;
11
+ unsetComment: () => ReturnType;
12
+ };
13
+ }
14
+ }
15
+
16
+ export const Comment = Mark.create<CommentOptions>({
17
+ name: "comment",
18
+
19
+ addOptions() {
20
+ return {
21
+ HTMLAttributes: {},
22
+ };
23
+ },
24
+
25
+ addAttributes() {
26
+ return {
27
+ commentId: {
28
+ default: null,
29
+ parseHTML: (el) => el.getAttribute("data-comment-id"),
30
+ renderHTML: (attrs) => {
31
+ if (!attrs.commentId) return {};
32
+ return { "data-comment-id": attrs.commentId };
33
+ },
34
+ },
35
+ resolved: {
36
+ default: false,
37
+ parseHTML: (el) => el.getAttribute("data-resolved") === "true",
38
+ renderHTML: (attrs) => {
39
+ if (!attrs.resolved) return {};
40
+ return { "data-resolved": "true" };
41
+ },
42
+ },
43
+ };
44
+ },
45
+
46
+ parseHTML() {
47
+ return [{ tag: "span[data-comment-id]" }];
48
+ },
49
+
50
+ renderHTML({ HTMLAttributes }) {
51
+ return [
52
+ "span",
53
+ mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, {
54
+ class: `comment-mark ${HTMLAttributes["data-resolved"] === "true" ? "resolved" : ""}`,
55
+ }),
56
+ 0,
57
+ ];
58
+ },
59
+
60
+ addCommands() {
61
+ return {
62
+ setComment:
63
+ (commentId: string) =>
64
+ ({ commands }) => {
65
+ return commands.setMark(this.name, { commentId });
66
+ },
67
+ unsetComment:
68
+ () =>
69
+ ({ commands }) => {
70
+ return commands.unsetMark(this.name);
71
+ },
72
+ };
73
+ },
74
+ });
frontend/src/editor/extensions/footnote.ts ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Node, mergeAttributes } from "@tiptap/core";
2
+ import { ReactNodeViewRenderer } from "@tiptap/react";
3
+ import { FootnoteView } from "../FootnoteView";
4
+
5
+ declare module "@tiptap/core" {
6
+ interface Commands<ReturnType> {
7
+ footnote: {
8
+ insertFootnote: (content: string) => ReturnType;
9
+ };
10
+ }
11
+ }
12
+
13
+ export const Footnote = Node.create({
14
+ name: "footnote",
15
+ group: "inline",
16
+ inline: true,
17
+ atom: true,
18
+
19
+ addAttributes() {
20
+ return {
21
+ content: { default: "" },
22
+ };
23
+ },
24
+
25
+ parseHTML() {
26
+ return [{ tag: 'span[data-type="footnote"]' }];
27
+ },
28
+
29
+ renderHTML({ HTMLAttributes }) {
30
+ return [
31
+ "span",
32
+ mergeAttributes(HTMLAttributes, { "data-type": "footnote" }),
33
+ ];
34
+ },
35
+
36
+ addCommands() {
37
+ return {
38
+ insertFootnote:
39
+ (content: string) =>
40
+ ({ commands }) => {
41
+ return commands.insertContent({
42
+ type: this.name,
43
+ attrs: { content },
44
+ });
45
+ },
46
+ };
47
+ },
48
+
49
+ addNodeView() {
50
+ return ReactNodeViewRenderer(FootnoteView);
51
+ },
52
+ });
frontend/src/editor/extensions/glossary.ts ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Node, mergeAttributes } from "@tiptap/core";
2
+ import { ReactNodeViewRenderer } from "@tiptap/react";
3
+ import { GlossaryView } from "../GlossaryView";
4
+
5
+ declare module "@tiptap/core" {
6
+ interface Commands<ReturnType> {
7
+ glossary: {
8
+ insertGlossary: (term: string, definition: string) => ReturnType;
9
+ };
10
+ }
11
+ }
12
+
13
+ export const Glossary = Node.create({
14
+ name: "glossary",
15
+ group: "inline",
16
+ inline: true,
17
+ atom: true,
18
+
19
+ addAttributes() {
20
+ return {
21
+ term: { default: "" },
22
+ definition: { default: "" },
23
+ };
24
+ },
25
+
26
+ parseHTML() {
27
+ return [{ tag: 'span[data-type="glossary"]' }];
28
+ },
29
+
30
+ renderHTML({ HTMLAttributes }) {
31
+ return [
32
+ "span",
33
+ mergeAttributes(HTMLAttributes, { "data-type": "glossary" }),
34
+ ];
35
+ },
36
+
37
+ addCommands() {
38
+ return {
39
+ insertGlossary:
40
+ (term: string, definition: string) =>
41
+ ({ commands }) => {
42
+ return commands.insertContent({
43
+ type: this.name,
44
+ attrs: { term, definition },
45
+ });
46
+ },
47
+ };
48
+ },
49
+
50
+ addNodeView() {
51
+ return ReactNodeViewRenderer(GlossaryView);
52
+ },
53
+ });
frontend/src/editor/extensions/image-upload.ts ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Node, mergeAttributes } from "@tiptap/core";
2
+ import { ReactNodeViewRenderer } from "@tiptap/react";
3
+ import { ImageUploadView } from "../ImageUploadView";
4
+
5
+ declare module "@tiptap/core" {
6
+ interface Commands<ReturnType> {
7
+ imageUpload: {
8
+ insertImageUpload: () => ReturnType;
9
+ };
10
+ }
11
+ }
12
+
13
+ export const ImageUpload = Node.create({
14
+ name: "imageUpload",
15
+ group: "block",
16
+ atom: true,
17
+ draggable: true,
18
+
19
+ parseHTML() {
20
+ return [{ tag: 'div[data-type="image-upload"]' }];
21
+ },
22
+
23
+ renderHTML({ HTMLAttributes }) {
24
+ return [
25
+ "div",
26
+ mergeAttributes(HTMLAttributes, { "data-type": "image-upload" }),
27
+ ];
28
+ },
29
+
30
+ addCommands() {
31
+ return {
32
+ insertImageUpload:
33
+ () =>
34
+ ({ commands }) => {
35
+ return commands.insertContent({ type: this.name });
36
+ },
37
+ };
38
+ },
39
+
40
+ addNodeView() {
41
+ return ReactNodeViewRenderer(ImageUploadView);
42
+ },
43
+ });
frontend/src/editor/extensions/slash-commands.ts ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Extension } from "@tiptap/core";
2
+ import Suggestion from "@tiptap/suggestion";
3
+ import { slashMenuSuggestion } from "../SlashMenu";
4
+
5
+ export const SlashCommands = Extension.create({
6
+ name: "slashCommands",
7
+
8
+ addOptions() {
9
+ return {
10
+ suggestion: {
11
+ char: "/",
12
+ command: ({
13
+ editor,
14
+ range,
15
+ props,
16
+ }: {
17
+ editor: any;
18
+ range: any;
19
+ props: any;
20
+ }) => {
21
+ editor.chain().focus().deleteRange(range).run();
22
+ props.command(editor);
23
+ },
24
+ ...slashMenuSuggestion(),
25
+ },
26
+ };
27
+ },
28
+
29
+ addProseMirrorPlugins() {
30
+ return [
31
+ Suggestion({
32
+ editor: this.editor,
33
+ ...this.options.suggestion,
34
+ }),
35
+ ];
36
+ },
37
+ });