├── .eslintrc.cjs ├── .github ├── dependabot.yml └── workflows │ └── build.yml ├── .gitignore ├── .prettierignore ├── .vscode ├── extensions.json └── settings.template.json ├── LICENSE ├── README.md ├── components.json ├── index.html ├── package.json ├── pnpm-lock.yaml ├── postcss.config.js ├── prettier.config.js ├── public └── yjs.png ├── src ├── app.tsx ├── components │ ├── add-data-dialog.tsx │ ├── config-panel.tsx │ ├── connect-button.tsx │ ├── connect-dialog.tsx │ ├── delete-dialog.tsx │ ├── export-button.tsx │ ├── filter-button.tsx │ ├── filter-sphere.tsx │ ├── full-screen-drop-zone.tsx │ ├── load-button.tsx │ ├── mode-toggle.tsx │ ├── preview-panel.tsx │ ├── site-header.tsx │ ├── status-indicator.tsx │ ├── theme-provider.tsx │ └── ui │ │ ├── alert.tsx │ │ ├── badge.tsx │ │ ├── button.tsx │ │ ├── command.tsx │ │ ├── dialog.tsx │ │ ├── dropdown-menu.tsx │ │ ├── input.tsx │ │ ├── label.tsx │ │ ├── multi-select.tsx │ │ ├── popover.tsx │ │ ├── select.tsx │ │ ├── switch.tsx │ │ ├── tabs.tsx │ │ ├── toast.tsx │ │ ├── toaster.tsx │ │ └── use-toast.ts ├── data-types.tsx ├── filter-map.tsx ├── globals.css ├── lib │ └── utils.ts ├── main.tsx ├── print-build-info.ts ├── providers │ ├── blocksuite │ │ ├── provider.ts │ │ ├── types.ts │ │ ├── utils.ts │ │ └── web-socket-doc-source.ts │ ├── types.ts │ └── websocket.tsx ├── state │ ├── atom-with-listeners.ts │ ├── config.ts │ ├── filter.ts │ ├── index.ts │ ├── undo.ts │ └── ydoc.ts ├── utils.ts ├── vite-env.d.ts └── y-shape.ts ├── tsconfig.json ├── tsconfig.node.json └── vite.config.ts /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { browser: true, es2020: true }, 4 | extends: [ 5 | "eslint:recommended", 6 | "plugin:@typescript-eslint/recommended", 7 | "plugin:react-hooks/recommended", 8 | ], 9 | ignorePatterns: ["dist", ".eslintrc.cjs"], 10 | parser: "@typescript-eslint/parser", 11 | plugins: ["react-refresh"], 12 | rules: { 13 | "@typescript-eslint/no-explicit-any": "off", 14 | // "react-refresh/only-export-components": [ 15 | // "warn", 16 | // { allowConstantExport: true }, 17 | // ], 18 | }, 19 | }; 20 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file 2 | version: 2 3 | updates: 4 | - package-ecosystem: npm 5 | directory: "/" 6 | schedule: 7 | interval: weekly 8 | open-pull-requests-limit: 10 9 | 10 | groups: 11 | minor-and-patch: 12 | applies-to: version-updates 13 | update-types: 14 | - "patch" 15 | - "minor" 16 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | 9 | jobs: 10 | build: 11 | runs-on: "ubuntu-latest" 12 | permissions: 13 | pages: write # to deploy to Pages 14 | id-token: write # to verify the deployment originates from an appropriate source 15 | 16 | steps: 17 | - name: Checkout Repo 18 | uses: actions/checkout@v4 19 | 20 | - name: Setup pnpm 21 | uses: pnpm/action-setup@v3 22 | 23 | - name: Use Node.js 24 | uses: actions/setup-node@v4 25 | with: 26 | node-version: 20 27 | 28 | - name: Install node modules 29 | run: pnpm install 30 | 31 | - name: Type check 32 | run: pnpm run typeCheck 33 | 34 | - name: Lint 35 | run: pnpm run lint 36 | 37 | - name: Format 38 | run: pnpm run format 39 | 40 | - name: Build 41 | run: pnpm run build 42 | 43 | # - name: Test 44 | # run: pnpm run test --coverage 45 | 46 | # - name: Upload test coverage 47 | # uses: actions/upload-artifact@v4 48 | # with: 49 | # name: coverage 50 | # path: ./coverage 51 | # if-no-files-found: ignore 52 | 53 | - name: Upload pages artifacts 54 | # https://github.com/actions/upload-pages-artifact 55 | uses: actions/upload-pages-artifact@v3 56 | with: 57 | path: "dist/" 58 | 59 | - name: Deploy to GitHub Pages 60 | if: github.ref == 'refs/heads/main' 61 | # https://github.com/actions/deploy-pages 62 | uses: actions/deploy-pages@v4 63 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | !.vscode/*.template.json 19 | .idea 20 | .DS_Store 21 | *.suo 22 | *.ntvs* 23 | *.njsproj 24 | *.sln 25 | *.sw? 26 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | dist 2 | .next 3 | package.json 4 | pnpm-lock.yaml 5 | tests/snapshots/ 6 | __snapshots__/ 7 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["dbaeumer.vscode-eslint", "esbenp.prettier-vscode"] 3 | } 4 | -------------------------------------------------------------------------------- /.vscode/settings.template.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true, 3 | "editor.formatOnSaveMode": "file", 4 | "editor.defaultFormatter": "esbenp.prettier-vscode", 5 | "editor.codeActionsOnSave": { 6 | "source.organizeImports": "explicit", 7 | "source.fixAll": "explicit" 8 | }, 9 | "testing.automaticallyOpenPeekView": "never" 10 | } 11 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Yjs 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 🛝 Yjs Inspector 2 | 3 | [![Build](https://github.com/yjs/yjs-inspector/actions/workflows/build.yml/badge.svg)](https://github.com/yjs/yjs-inspector/actions/workflows/build.yml) 4 | 5 | The playground of [Yjs](https://docs.yjs.dev/). 6 | 7 | ## ✨ Features 8 | 9 | - Connect to a Yjs demo.
10 | ![image](https://github.com/yjs/yjs-inspector/assets/18554747/144810a2-4da1-4fd3-822d-1f4a015af29f) 11 | - Inspect the Yjs document model
12 | ![image](https://github.com/yjs/yjs-inspector/assets/18554747/edb040f2-6bdd-4c2a-b9cf-43f7eaef08d2) 13 | - Advanced Filters
14 | ![image](https://github.com/user-attachments/assets/ecadd716-0163-462e-8762-daf08d964370) 15 | - Edit the Yjs document model.
16 | ![image](https://github.com/yjs/yjs-inspector/assets/18554747/46a061e9-3466-46bd-91cc-80e80476de37) 17 | - Export the YDoc snapshot 18 | - Dark mode 19 | -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "default", 4 | "rsc": false, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.js", 8 | "css": "src/globals.css", 9 | "baseColor": "slate", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Yjs Inspector 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "yjs-inspector", 3 | "version": "0.1.0", 4 | "license": "MIT", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "tsc && vite build", 9 | "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", 10 | "preview": "vite preview", 11 | "format": "prettier --check .", 12 | "format:fix": "prettier --write .", 13 | "typeCheck": "tsc --noEmit" 14 | }, 15 | "dependencies": { 16 | "@blocksuite/sync": "^0.21.0", 17 | "@emotion/react": "^11.14.0", 18 | "@emotion/styled": "^11.14.0", 19 | "@fn-sphere/filter": "^0.7.2", 20 | "@mui/material": "^7.1.0", 21 | "@radix-ui/react-dialog": "^1.1.14", 22 | "@radix-ui/react-dropdown-menu": "^2.1.15", 23 | "@radix-ui/react-label": "^2.1.7", 24 | "@radix-ui/react-popover": "^1.1.14", 25 | "@radix-ui/react-select": "^2.2.5", 26 | "@radix-ui/react-slot": "^1.2.3", 27 | "@radix-ui/react-switch": "^1.2.5", 28 | "@radix-ui/react-tabs": "^1.1.12", 29 | "@radix-ui/react-toast": "^1.2.14", 30 | "@textea/json-viewer": "^4.0.1", 31 | "class-variance-authority": "^0.7.1", 32 | "clsx": "^2.1.1", 33 | "cmdk": "1.1.1", 34 | "jotai": "^2.12.5", 35 | "lucide-react": "^0.511.0", 36 | "react": "^19.1.0", 37 | "react-dom": "^19.1.0", 38 | "tailwind-merge": "^3.3.0", 39 | "tailwindcss-animate": "^1.0.7", 40 | "y-websocket": "^3.0.0", 41 | "yjs": "13.6.27", 42 | "zod": "^3.25.46" 43 | }, 44 | "devDependencies": { 45 | "@tailwindcss/postcss": "^4.1.8", 46 | "@types/node": "^22.15.29", 47 | "@types/react": "^19.1.6", 48 | "@types/react-dom": "^19.1.5", 49 | "@typescript-eslint/eslint-plugin": "^7.18.0", 50 | "@typescript-eslint/parser": "^7.18.0", 51 | "@vitejs/plugin-react": "^4.5.0", 52 | "eslint": "^8.57.1", 53 | "eslint-plugin-react-hooks": "^5.2.0", 54 | "eslint-plugin-react-refresh": "^0.4.20", 55 | "postcss": "^8.5.4", 56 | "prettier": "^3.5.3", 57 | "prettier-plugin-tailwindcss": "^0.6.12", 58 | "tailwindcss": "^4.1.8", 59 | "typescript": "^5.8.3", 60 | "unplugin-info": "^1.2.2", 61 | "vite": "^6.3.5" 62 | }, 63 | "engines": { 64 | "node": ">=20.0.0" 65 | }, 66 | "packageManager": "pnpm@9.6.0" 67 | } -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | "@tailwindcss/postcss": {}, 4 | }, 5 | }; 6 | -------------------------------------------------------------------------------- /prettier.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('prettier').Config & import('prettier-plugin-tailwindcss').PluginOptions} */ 2 | const config = { 3 | plugins: ["prettier-plugin-tailwindcss"], 4 | }; 5 | 6 | export default config; 7 | -------------------------------------------------------------------------------- /public/yjs.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yjs/yjs-inspector/a998dd2c1d3671880f1ea6c80706ae2b9a19c667/public/yjs.png -------------------------------------------------------------------------------- /src/app.tsx: -------------------------------------------------------------------------------- 1 | import { Toaster } from "./components/ui/toaster"; 2 | import { ThemeProvider } from "./components/theme-provider"; 3 | import { Header } from "./components/site-header"; 4 | import { ConfigPanel } from "./components/config-panel"; 5 | import { PreviewPanel } from "./components/preview-panel"; 6 | import * as Y from "yjs"; 7 | 8 | export function App() { 9 | return ( 10 | 11 |
12 |
13 |
14 | 15 | 16 |
17 |
18 | 19 |
20 | ); 21 | } 22 | 23 | // For debugging 24 | (globalThis as any).Y = Y; 25 | -------------------------------------------------------------------------------- /src/components/add-data-dialog.tsx: -------------------------------------------------------------------------------- 1 | import { Path } from "@textea/json-viewer"; 2 | import { Braces, Brackets, Type } from "lucide-react"; 3 | import { ComponentProps, useState } from "react"; 4 | import * as Y from "yjs"; 5 | import { getHumanReadablePath } from "../utils"; 6 | import { getYTypeName, isYDoc, isYMap, isYShape } from "../y-shape"; 7 | import { Button } from "./ui/button"; 8 | import { 9 | Dialog, 10 | DialogContent, 11 | DialogDescription, 12 | DialogFooter, 13 | DialogHeader, 14 | DialogTitle, 15 | } from "./ui/dialog"; 16 | import { Input } from "./ui/input"; 17 | import { Label } from "./ui/label"; 18 | import { Tabs, TabsContent, TabsList, TabsTrigger } from "./ui/tabs"; 19 | import { toast } from "./ui/use-toast"; 20 | 21 | export function AddDataDialog({ 22 | target, 23 | path, 24 | ...props 25 | }: { target: unknown; path: Path } & ComponentProps) { 26 | const humanReadablePath = getHumanReadablePath(path); 27 | const [tab, setTab] = useState<"yMap" | "yArray" | "yText">("yMap"); 28 | const [key, setKey] = useState(""); 29 | 30 | const handleAdd = () => { 31 | if (!key) { 32 | toast({ 33 | variant: "destructive", 34 | description: "Key is required", 35 | duration: 2000, 36 | }); 37 | return; 38 | } 39 | if (!isYShape(target)) { 40 | toast({ 41 | variant: "destructive", 42 | description: "Invalid target", 43 | duration: 2000, 44 | }); 45 | console.error("Invalid target", target); 46 | return; 47 | } 48 | if (isYDoc(target)) { 49 | if (tab === "yMap") { 50 | target.getMap(key); 51 | props.onOpenChange?.(false); 52 | setKey(""); 53 | return; 54 | } 55 | if (tab === "yArray") { 56 | target.getArray(key); 57 | props.onOpenChange?.(false); 58 | setKey(""); 59 | return; 60 | } 61 | if (tab === "yText") { 62 | target.getText(key); 63 | props.onOpenChange?.(false); 64 | setKey(""); 65 | return; 66 | } 67 | throw new Error("Invalid tab"); 68 | } 69 | if (isYMap(target)) { 70 | const tabMap = { 71 | yMap: Y.Map, 72 | yArray: Y.Array, 73 | yText: Y.Text, 74 | } as const; 75 | target.set(key, new tabMap[tab]()); 76 | 77 | props.onOpenChange?.(false); 78 | setKey(""); 79 | return; 80 | } 81 | console.error("Invalid add target", path, target); 82 | throw new Error("Invalid add target"); 83 | }; 84 | 85 | const KeyField = ( 86 |
87 | 88 | { 94 | if (e.key === "Enter") { 95 | e.preventDefault(); 96 | handleAdd(); 97 | } 98 | }} 99 | onChange={(e) => { 100 | setKey(e.target.value); 101 | }} 102 | /> 103 |
104 | ); 105 | 106 | return ( 107 | 108 | 109 | 110 | Add YType 111 | 112 | Add a new YType to  113 | 114 | {isYShape(target) ? getYTypeName(target) : "object"} 115 | 116 |  at  117 | 118 | {humanReadablePath} 119 | 120 | 121 | 122 | 123 | 128 | setTab(value as "yMap" | "yArray" | "yText") 129 | } 130 | > 131 | 132 | 133 | 134 | YMap 135 | 136 | 137 | 138 | YArray 139 | 140 | 141 | 142 | YText 143 | 144 | 145 | 146 | 147 | {KeyField} 148 | 149 |
150 | 151 | 152 |
153 |
154 | 155 | 156 | {KeyField} 157 | 158 |
159 | 160 | 161 |
162 |
163 | 164 | {KeyField} 165 | 166 |
167 | 168 | 169 |
170 |
171 |
172 | 173 | 174 | 175 | 176 |
177 |
178 | ); 179 | } 180 | -------------------------------------------------------------------------------- /src/components/config-panel.tsx: -------------------------------------------------------------------------------- 1 | import { Redo, Undo } from "lucide-react"; 2 | import { useConfig, useUndoManager, useYDoc } from "../state/index"; 3 | import { fileToYDoc } from "../utils"; 4 | import { ConnectButton } from "./connect-button"; 5 | import { ExportButton } from "./export-button"; 6 | import { FilterButton } from "./filter-button"; 7 | import { FullScreenDropZone } from "./full-screen-drop-zone"; 8 | import { LoadButton } from "./load-button"; 9 | import { Button } from "./ui/button"; 10 | import { Label } from "./ui/label"; 11 | import { Switch } from "./ui/switch"; 12 | import { useToast } from "./ui/use-toast"; 13 | 14 | export function ConfigPanel() { 15 | const [yDoc, setYDoc] = useYDoc(); 16 | const { toast } = useToast(); 17 | const [config, setConfig] = useConfig(); 18 | const { undoManager, canRedo, canUndo, undoStackSize, redoStackSize } = 19 | useUndoManager(); 20 | 21 | return ( 22 |
23 |

Configure

24 | 25 | 26 | 27 |
28 | 32 | setConfig({ 33 | ...config, 34 | parseYDoc: checked, 35 | }) 36 | } 37 | /> 38 | 39 |
40 | 41 |
42 | 47 | setConfig({ 48 | ...config, 49 | showDelta: checked, 50 | }) 51 | } 52 | /> 53 | 54 |
55 | 56 |
57 | 61 | setConfig({ 62 | ...config, 63 | showSize: checked, 64 | }) 65 | } 66 | /> 67 | 68 |
69 | 70 |
71 | 76 | setConfig({ 77 | ...config, 78 | editable: checked, 79 | }) 80 | } 81 | /> 82 | 83 |
84 | 85 | {config.editable && ( 86 |
87 | 102 | 117 |
118 | )} 119 | 120 | 121 | 122 | 123 | { 126 | if (!fileList.length) { 127 | console.error("No files dropped"); 128 | return; 129 | } 130 | if (fileList.length > 1) { 131 | console.warn( 132 | "Multiple files dropped, only the first file will be loaded", 133 | ); 134 | } 135 | const file = fileList[0]; 136 | try { 137 | const newYDoc = await fileToYDoc(file); 138 | yDoc.destroy(); 139 | setYDoc(newYDoc); 140 | } catch (error) { 141 | console.error(error); 142 | toast({ 143 | variant: "destructive", 144 | title: "Error", 145 | description: "Failed to load YDoc", 146 | }); 147 | } 148 | }} 149 | /> 150 |
151 | ); 152 | } 153 | -------------------------------------------------------------------------------- /src/components/connect-button.tsx: -------------------------------------------------------------------------------- 1 | import { Cable, RotateCw } from "lucide-react"; 2 | import { useCallback, useEffect, useState } from "react"; 3 | import { ConnectProvider } from "../providers/types"; 4 | import { useYDoc } from "../state/index"; 5 | import { ConnectDialog } from "./connect-dialog"; 6 | import { StatusIndicator } from "./status-indicator"; 7 | import { Button } from "./ui/button"; 8 | import { Dialog } from "./ui/dialog"; 9 | 10 | export function ConnectButton() { 11 | const [yDoc] = useYDoc(); 12 | const [open, setOpen] = useState(false); 13 | const [provider, setProvider] = useState(); 14 | const [connectState, setConnectState] = useState< 15 | "connecting" | "connected" | "disconnected" 16 | >("disconnected"); 17 | 18 | const disconnect = useCallback(() => { 19 | if (connectState === "disconnected") return; 20 | provider?.disconnect(); 21 | setProvider(undefined); 22 | setConnectState("disconnected"); 23 | }, [connectState, provider]); 24 | 25 | // This effect is for convenience, it is evil. We should add the connect logic to global state and handle it there. 26 | useEffect(() => { 27 | // Disconnect when the yDoc changes 28 | if (connectState === "disconnected") return; 29 | if (!provider) { 30 | console.error( 31 | "Provider should be defined when connectState is not disconnected", 32 | provider, 33 | connectState, 34 | ); 35 | return; 36 | } 37 | if (yDoc !== provider.doc) { 38 | disconnect(); 39 | } 40 | }, [yDoc, disconnect, provider, connectState]); 41 | 42 | const onConnect = useCallback( 43 | (provider: ConnectProvider) => { 44 | if (connectState !== "disconnected") { 45 | throw new Error("Should not be able to connect when already connected"); 46 | } 47 | provider.connect(); 48 | setConnectState("connecting"); 49 | provider.waitForSynced().then(() => { 50 | setConnectState("connected"); 51 | }); 52 | setProvider(provider); 53 | setOpen(false); 54 | }, 55 | [connectState], 56 | ); 57 | 58 | const handleClick = () => { 59 | if (connectState === "disconnected") { 60 | setOpen(true); 61 | return; 62 | } 63 | disconnect(); 64 | return; 65 | }; 66 | 67 | if (connectState === "connecting") { 68 | return ( 69 | setOpen(open)}> 70 | 74 | 75 | 76 | ); 77 | } 78 | 79 | if (connectState === "connected") { 80 | return ( 81 | setOpen(open)}> 82 | 86 | 87 | 88 | ); 89 | } 90 | 91 | return ( 92 | setOpen(open)}> 93 | 97 | 98 | 99 | ); 100 | } 101 | -------------------------------------------------------------------------------- /src/components/connect-dialog.tsx: -------------------------------------------------------------------------------- 1 | import { BlocksuiteWebsocketProvider } from "@/providers/blocksuite/provider"; 2 | import { WebSocketConnectProvider } from "@/providers/websocket"; 3 | import { RocketIcon, TriangleAlert } from "lucide-react"; 4 | import { useState } from "react"; 5 | import * as Y from "yjs"; 6 | import { ConnectProvider } from "../providers/types"; 7 | import { useYDoc } from "../state/index"; 8 | import { Alert, AlertDescription, AlertTitle } from "./ui/alert"; 9 | import { Button } from "./ui/button"; 10 | import { 11 | DialogContent, 12 | DialogDescription, 13 | DialogFooter, 14 | DialogHeader, 15 | DialogTitle, 16 | } from "./ui/dialog"; 17 | import { Input } from "./ui/input"; 18 | import { Label } from "./ui/label"; 19 | import { 20 | Select, 21 | SelectContent, 22 | SelectGroup, 23 | SelectItem, 24 | SelectLabel, 25 | SelectTrigger, 26 | SelectValue, 27 | } from "./ui/select"; 28 | import { Switch } from "./ui/switch"; 29 | 30 | // Hardcoded in the playground of blocksuite 31 | // See https://github.com/toeverything/blocksuite/blob/db6e9d278e4d821e1d5aea912681e8fd1692b39e/packages/playground/apps/default/utils/collection.ts#L66 32 | const BLOCKSUITE_PLAYGROUND_DOC_GUID = "collabPlayground"; 33 | const BLOCKSUITE_NAME = "Blocksuite Playground"; 34 | 35 | const officialDemos = [ 36 | { 37 | name: "ProseMirror", 38 | room: "prosemirror-demo-2024/06", 39 | url: "wss://demos.yjs.dev/ws", 40 | demoUrl: "https://demos.yjs.dev/prosemirror/prosemirror.html", 41 | }, 42 | { 43 | name: "ProseMirror with Version History", 44 | room: "prosemirror-versions-demo-2024/06", 45 | url: "wss://demos.yjs.dev/ws", 46 | demoUrl: 47 | "https://demos.yjs.dev/prosemirror-versions/prosemirror-versions.html", 48 | }, 49 | { 50 | name: "Quill", 51 | room: "quill-demo-2024/06", 52 | url: "wss://demos.yjs.dev/ws", 53 | demoUrl: "https://demos.yjs.dev/quill/quill.html", 54 | }, 55 | { 56 | name: "Monaco", 57 | room: "monaco-demo-2024/06", 58 | url: "wss://demos.yjs.dev/ws", 59 | demoUrl: "https://demos.yjs.dev/monaco/monaco.html", 60 | }, 61 | { 62 | name: "CodeMirror", 63 | room: "codemirror-demo-2024/06", 64 | url: "wss://demos.yjs.dev/ws", 65 | demoUrl: "https://demos.yjs.dev/codemirror/codemirror.html", 66 | }, 67 | { 68 | name: "CodeMirror 6", 69 | room: "codemirror.next-demo-2024/06", 70 | url: "wss://demos.yjs.dev/ws", 71 | demoUrl: "https://demos.yjs.dev/codemirror.next/codemirror.next.html", 72 | }, 73 | { 74 | name: BLOCKSUITE_NAME, 75 | room: "", 76 | url: "wss://blocksuite-playground.toeverything.workers.dev", 77 | demoUrl: "https://try-blocksuite.vercel.app", 78 | custom: true, 79 | }, 80 | ]; 81 | 82 | export function ConnectDialog({ 83 | onConnect, 84 | }: { 85 | onConnect: (provider: ConnectProvider) => void; 86 | }) { 87 | const [yDoc, setYDoc] = useYDoc(); 88 | const [url, setUrl] = useState("wss://demos.yjs.dev/ws"); 89 | const [room, setRoom] = useState("quill-demo-2024/06"); 90 | const [provider, setProvider] = useState("Quill"); 91 | const [needCreateNewDoc, setNeedCreateNewDoc] = useState(true); 92 | const officialDemo = officialDemos.find((demo) => demo.name === provider); 93 | 94 | return ( 95 | 96 | 97 | Connect 98 | 99 | Collaborate with others by connecting to a shared YDoc 100 | 101 | 102 |
103 |
104 | 107 | 108 | 156 |
157 | 158 |
159 | 162 | setUrl(e.currentTarget.value)} 167 | placeholder="wss://demos.yjs.dev/ws" 168 | className="col-span-3" 169 | /> 170 |
171 | 172 |
173 | 176 | setRoom(e.currentTarget.value)} 182 | placeholder="Please enter a room name" 183 | /> 184 |
185 | 186 |
187 | setNeedCreateNewDoc(value)} 192 | /> 193 | 196 |
197 | 198 | {!needCreateNewDoc && ( 199 | 200 | 201 | Caution! 202 | 203 | This may contaminate the remote YDoc. Make sure you know what you 204 | are doing. 205 | 206 | 207 | )} 208 | 209 | {officialDemo && ( 210 | 211 | 212 | Heads up! 213 | 214 | Click here to access the  215 | 221 | {officialDemo.name} 222 | 223 |  demo. 224 | 225 | 226 | )} 227 |
228 | 229 | 263 | 264 |
265 | ); 266 | } 267 | -------------------------------------------------------------------------------- /src/components/delete-dialog.tsx: -------------------------------------------------------------------------------- 1 | import { Path } from "@textea/json-viewer"; 2 | import { useYDoc } from "../state/index"; 3 | import { getHumanReadablePath } from "../utils"; 4 | import { 5 | getYTypeFromPath, 6 | getYTypeName, 7 | isYArray, 8 | isYMap, 9 | isYShape, 10 | } from "../y-shape"; 11 | import { Button } from "./ui/button"; 12 | import { 13 | Dialog, 14 | DialogContent, 15 | DialogDescription, 16 | DialogFooter, 17 | DialogHeader, 18 | DialogTitle, 19 | } from "./ui/dialog"; 20 | 21 | export function DeleteDialog({ 22 | value, 23 | path, 24 | open, 25 | onOpenChange, 26 | }: { 27 | value: unknown; 28 | path: Path; 29 | open: boolean; 30 | onOpenChange: (open: boolean) => void; 31 | }) { 32 | const [yDoc] = useYDoc(); 33 | const onConfirm = () => { 34 | const parent = getYTypeFromPath(yDoc, path.slice(0, -1)); 35 | const key = path[path.length - 1]; 36 | if (isYMap(parent)) { 37 | if (typeof key !== "string") { 38 | throw new Error( 39 | "Key must be a string, but got " + key + " of type " + typeof key, 40 | ); 41 | } 42 | parent.delete(key); 43 | } else if (isYArray(parent)) { 44 | if (typeof key !== "number") { 45 | throw new Error( 46 | "Key must be a number, but got " + key + " of type " + typeof key, 47 | ); 48 | } 49 | parent.delete(key); 50 | } else { 51 | console.error("Invalid parent type", parent); 52 | throw new Error("Invalid parent type"); 53 | } 54 | onOpenChange(false); 55 | }; 56 | const targetName = isYShape(value) ? getYTypeName(value) : "object"; 57 | const humanReadablePath = getHumanReadablePath(path); 58 | return ( 59 | 60 | 61 | 62 | Delete 63 | 64 | Are you sure you want to delete  65 | 66 | {targetName} 67 | 68 |  from  69 | 70 | {humanReadablePath} 71 | 72 | ? 73 | 74 | 75 | 76 | 77 | 80 | 83 | 84 | 85 | 86 | ); 87 | } 88 | -------------------------------------------------------------------------------- /src/components/export-button.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@/components/ui/button"; 2 | import { 3 | DropdownMenu, 4 | DropdownMenuContent, 5 | DropdownMenuItem, 6 | DropdownMenuTrigger, 7 | } from "@/components/ui/dropdown-menu"; 8 | import { Download } from "lucide-react"; 9 | import * as Y from "yjs"; 10 | import { useYDoc } from "../state/index"; 11 | import { yShapeToJSON } from "../y-shape"; 12 | 13 | function downloadFile(blob: Blob, filename: string) { 14 | const url = URL.createObjectURL(blob); 15 | const a = document.createElement("a"); 16 | a.href = url; 17 | a.download = filename; 18 | a.click(); 19 | URL.revokeObjectURL(url); 20 | } 21 | 22 | export function ExportButton() { 23 | const [yDoc] = useYDoc(); 24 | 25 | return ( 26 | 27 | 28 | 32 | 33 | 34 | { 36 | const encodeUpdate = Y.encodeStateAsUpdate(yDoc); 37 | const blob = new Blob([encodeUpdate], { 38 | type: "application/octet-stream", 39 | }); 40 | downloadFile(blob, "ydoc-update"); 41 | }} 42 | > 43 | encodeStateAsUpdate 44 | 45 | { 47 | const encodedStateVector = Y.encodeStateVector(yDoc); 48 | const blob = new Blob([encodedStateVector], { 49 | type: "application/octet-stream", 50 | }); 51 | downloadFile(blob, "ydoc-state-vector"); 52 | }} 53 | > 54 | encodeStateVector 55 | 56 | { 58 | const snapshot = Y.snapshot(yDoc); 59 | const encodedSnapshot = Y.encodeSnapshot(snapshot); 60 | const blob = new Blob([encodedSnapshot], { 61 | type: "application/octet-stream", 62 | }); 63 | downloadFile(blob, "ydoc-snapshot"); 64 | }} 65 | > 66 | Snapshot 67 | 68 | { 70 | const json = yShapeToJSON(yDoc); 71 | const jsonStr = JSON.stringify(json, null, 2); 72 | const blob = new Blob([jsonStr], { 73 | type: "application/json", 74 | }); 75 | downloadFile(blob, "ydoc-json"); 76 | }} 77 | > 78 | JSON(unofficial) 79 | 80 | 81 | 82 | ); 83 | } 84 | -------------------------------------------------------------------------------- /src/components/filter-button.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | FilterBuilder, 3 | FilterSphereProvider, 4 | useFilterSphere, 5 | } from "@fn-sphere/filter"; 6 | import { Filter } from "lucide-react"; 7 | import { useState } from "react"; 8 | import { 9 | useConfig, 10 | useFilterDataCount, 11 | useIsFilterEnabled, 12 | useSetHasValidFilterRule, 13 | useUpdateFilterPredicate, 14 | } from "../state/index"; 15 | import { 16 | createFlattenFilterGroup, 17 | filterFnList, 18 | filterTheme, 19 | schema, 20 | } from "./filter-sphere"; 21 | import { Button } from "./ui/button"; 22 | import { 23 | Dialog, 24 | DialogContent, 25 | DialogDescription, 26 | DialogFooter, 27 | DialogHeader, 28 | DialogTitle, 29 | } from "./ui/dialog"; 30 | 31 | export function FilterButton() { 32 | const [config] = useConfig(); 33 | const [open, setOpen] = useState(false); 34 | const updateFilterPredicate = useUpdateFilterPredicate(); 35 | const { predicate, validRuleCount, reset, context } = useFilterSphere({ 36 | schema, 37 | filterFnList, 38 | defaultRule: createFlattenFilterGroup(), 39 | }); 40 | const isFilterEnabled = useIsFilterEnabled(); 41 | const setHasValidFilterRule = useSetHasValidFilterRule(); 42 | const countOfFilterData = useFilterDataCount(); 43 | 44 | const handleClick = () => { 45 | setOpen(true); 46 | return; 47 | }; 48 | 49 | const updateFilter = () => { 50 | updateFilterPredicate({ fn: predicate }); 51 | setHasValidFilterRule(validRuleCount > 0); 52 | }; 53 | 54 | return ( 55 | { 60 | setOpen(open); 61 | if (open) { 62 | return; 63 | } 64 | updateFilter(); 65 | }} 66 | > 67 | 75 | 76 | { 78 | setOpen(false); 79 | updateFilter(); 80 | }} 81 | onReset={() => { 82 | reset(); 83 | }} 84 | /> 85 | 86 | 87 | ); 88 | } 89 | 90 | function FilterDialog({ 91 | onConfirm, 92 | onReset, 93 | }: { 94 | onConfirm: () => void; 95 | onReset: () => void; 96 | }) { 97 | return ( 98 | // See https://github.com/shadcn-ui/ui/issues/16 99 | 100 | 101 | Filter 102 | 103 | 104 |
105 | 106 |
107 | 108 | 116 | 123 | 127 | Powered by  128 | 134 | Filter Sphere 135 | 136 | 137 | 138 |
139 | ); 140 | } 141 | -------------------------------------------------------------------------------- /src/components/filter-sphere.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | createFilterGroup, 3 | createFilterTheme, 4 | createSingleFilter, 5 | defineTypedFn, 6 | FilterTheme, 7 | presetFilter, 8 | SingleFilter, 9 | useFilterRule, 10 | useRootRule, 11 | useView, 12 | } from "@fn-sphere/filter"; 13 | import { CircleAlert, X } from "lucide-react"; 14 | import { ChangeEvent, useCallback } from "react"; 15 | import { z } from "zod"; 16 | import { isYText, isYXmlText } from "../y-shape"; 17 | import { Button } from "./ui/button"; 18 | import { Input } from "./ui/input"; 19 | import { MultiSelect } from "./ui/multi-select"; 20 | import { 21 | Select, 22 | SelectContent, 23 | SelectItem, 24 | SelectTrigger, 25 | SelectValue, 26 | } from "./ui/select"; 27 | 28 | export const schema = z.object({ 29 | type: z 30 | .union([ 31 | z.literal("YText"), 32 | z.literal("YMap"), 33 | z.literal("YArray"), 34 | z.literal("YXmlElement"), 35 | z.literal("YXmlFragment"), 36 | z.literal("YAbstractType"), 37 | z.literal("YDoc"), 38 | z.literal("Object"), 39 | z.literal("Boolean"), 40 | z.literal("String"), 41 | z.literal("Number"), 42 | z.literal("Uint8Array"), 43 | ]) 44 | .describe("Type"), 45 | key: z.string().describe("Key"), 46 | path: z.string().describe("Path"), 47 | value: z.unknown().describe("Value"), 48 | }); 49 | 50 | export type YShapeItem = z.infer; 51 | 52 | const likeFn = defineTypedFn({ 53 | name: "Likes", 54 | define: z.function().args(z.unknown(), z.string()).returns(z.boolean()), 55 | implement: (value, string) => { 56 | if (typeof value === "string") { 57 | return value.includes(string); 58 | } 59 | if (typeof value === "number") { 60 | return value.toString().includes(string); 61 | } 62 | if (isYText(value)) { 63 | return value.toString().includes(string); 64 | } 65 | if (isYXmlText(value)) { 66 | return value.toString().includes(string); 67 | } 68 | return false; 69 | }, 70 | }); 71 | 72 | export const filterFnList = [likeFn, ...presetFilter]; 73 | 74 | const componentsSpec = { 75 | Button: (props) => { 76 | return 220 | )} 221 | 222 | ); 223 | }; 224 | 225 | const templatesSpec = { 226 | SingleFilter: SingleFilterView, 227 | FilterGroupContainer: ({ children }) => ( 228 |
{children}
229 | ), 230 | RuleJoiner: ({ joinBetween: [before, after], parent }) => { 231 | const op = parent.op === "and" ? "And" : "Or"; 232 | if (before.type === "Filter" && after.type === "Filter") { 233 | return ( 234 |
235 |
236 | 239 |
240 |
241 | ); 242 | } 243 | return ( 244 | 247 | ); 248 | }, 249 | } satisfies Partial; 250 | 251 | export const filterTheme = createFilterTheme({ 252 | components: componentsSpec, 253 | templates: templatesSpec, 254 | }); 255 | -------------------------------------------------------------------------------- /src/components/full-screen-drop-zone.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useRef, useState } from "react"; 2 | 3 | // Ported from https://github.com/react-dropzone/react-dropzone/issues/753#issuecomment-774782919 4 | function useDropZone(callback: (files: FileList) => void | Promise) { 5 | const [isDragging, setIsDragging] = useState(false); 6 | const dragCounter = useRef(0); 7 | 8 | const onDragEnter = useCallback((event: DragEvent) => { 9 | event.preventDefault(); 10 | dragCounter.current++; 11 | setIsDragging(true); 12 | }, []); 13 | 14 | const onDragLeave = useCallback((event: DragEvent) => { 15 | event.preventDefault(); 16 | dragCounter.current--; 17 | if (dragCounter.current > 0) return; 18 | setIsDragging(false); 19 | }, []); 20 | 21 | const onDragOver = useCallback((event: DragEvent) => { 22 | event.preventDefault(); 23 | }, []); 24 | 25 | const onDrop = useCallback( 26 | async (event: DragEvent) => { 27 | event.preventDefault(); 28 | setIsDragging(false); 29 | if ( 30 | event.dataTransfer && 31 | event.dataTransfer.files && 32 | event.dataTransfer.files.length > 0 33 | ) { 34 | dragCounter.current = 0; 35 | await callback(event.dataTransfer.files); 36 | event.dataTransfer.clearData(); 37 | } 38 | }, 39 | [callback], 40 | ); 41 | 42 | useEffect(() => { 43 | window.addEventListener("dragenter", onDragEnter); 44 | window.addEventListener("dragleave", onDragLeave); 45 | window.addEventListener("dragover", onDragOver); 46 | window.addEventListener("drop", onDrop); 47 | 48 | return () => { 49 | window.removeEventListener("dragenter", onDragEnter); 50 | window.removeEventListener("dragleave", onDragLeave); 51 | window.removeEventListener("dragover", onDragOver); 52 | window.removeEventListener("drop", onDrop); 53 | }; 54 | }, [onDragEnter, onDragLeave, onDragOver, onDrop]); 55 | 56 | return isDragging; 57 | } 58 | 59 | export function FullScreenDropZone({ 60 | text, 61 | onDrop, 62 | }: { 63 | text: string; 64 | onDrop: (files: FileList) => void | Promise; 65 | }) { 66 | const isDragging = useDropZone(onDrop); 67 | return ( 68 |
71 |
72 | {text} 73 |
74 |
75 | ); 76 | } 77 | -------------------------------------------------------------------------------- /src/components/load-button.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@/components/ui/button"; 2 | import { 3 | Dialog, 4 | DialogContent, 5 | DialogDescription, 6 | DialogFooter, 7 | DialogHeader, 8 | DialogTitle, 9 | DialogTrigger, 10 | } from "@/components/ui/dialog"; 11 | import { 12 | DropdownMenu, 13 | DropdownMenuContent, 14 | DropdownMenuItem, 15 | DropdownMenuTrigger, 16 | } from "@/components/ui/dropdown-menu"; 17 | import { Input } from "@/components/ui/input"; 18 | import { Label } from "@/components/ui/label"; 19 | import { 20 | File as FileIcon, 21 | FilePlus, 22 | Link, 23 | RotateCw, 24 | Upload, 25 | } from "lucide-react"; 26 | import { useState } from "react"; 27 | import * as Y from "yjs"; 28 | import { useYDoc } from "../state/index"; 29 | import { fileToYDoc } from "../utils"; 30 | import { toast } from "./ui/use-toast"; 31 | 32 | const ExampleYDocUrl = 33 | "https://insider.affine.pro/api/workspaces/af3478a2-9c9c-4d16-864d-bffa1eb10eb6/docs/-3bEQPBoOEkNH13ULW9Ed"; 34 | 35 | function LoadFromUrlDialog({ children }: { children: React.ReactNode }) { 36 | const [open, setOpen] = useState(false); 37 | const [loading, setLoading] = useState(false); 38 | const [url, setUrl] = useState(ExampleYDocUrl); 39 | const [, setYDoc] = useYDoc(); 40 | 41 | const handleLoadYDoc = async () => { 42 | setLoading(true); 43 | try { 44 | const resp = await fetch(url); 45 | if (!resp.ok) { 46 | throw new Error("Failed to fetch YDoc"); 47 | } 48 | const newYDoc = await fileToYDoc(new File([await resp.blob()], "ydoc")); 49 | setYDoc(newYDoc); 50 | setOpen(false); 51 | } catch (error) { 52 | console.error(error); 53 | toast({ 54 | variant: "destructive", 55 | title: "Error", 56 | description: "Failed to load YDoc", 57 | }); 58 | } finally { 59 | setLoading(false); 60 | } 61 | }; 62 | 63 | return ( 64 | 65 | {children} 66 | 67 | 68 | Load from URL 69 | 70 | Paste the URL of the YDoc you want to load 71 | 72 | 73 |
74 |
75 | 78 | { 84 | if (e.key === "Enter") { 85 | e.preventDefault(); 86 | handleLoadYDoc(); 87 | } 88 | }} 89 | onChange={(e) => { 90 | setUrl(e.target.value); 91 | }} 92 | /> 93 |
94 |
95 | 96 | 100 | 101 |
102 |
103 | ); 104 | } 105 | LoadFromUrlDialog.Trigger = DialogTrigger; 106 | 107 | export function LoadButton() { 108 | const [, setYDoc] = useYDoc(); 109 | 110 | return ( 111 | 112 | 113 | 114 | 118 | 119 | 120 | { 122 | const handles = await window.showOpenFilePicker({ 123 | startIn: "downloads", 124 | }); 125 | const file = await handles[0].getFile(); 126 | try { 127 | const newYDoc = await fileToYDoc(file); 128 | setYDoc(newYDoc); 129 | } catch (error) { 130 | console.error(error); 131 | toast({ 132 | variant: "destructive", 133 | title: "Error", 134 | description: "Failed to load YDoc", 135 | }); 136 | } 137 | }} 138 | > 139 | 140 | Load from file 141 | 142 | 143 | 144 | 145 | 146 | Load from URL 147 | 148 | 149 | 150 | { 152 | setYDoc(new Y.Doc()); 153 | }} 154 | > 155 | 156 | Create new YDoc 157 | 158 | 159 | 160 | 161 | ); 162 | } 163 | -------------------------------------------------------------------------------- /src/components/mode-toggle.tsx: -------------------------------------------------------------------------------- 1 | import { Moon, Sun, SunMoon } from "lucide-react"; 2 | 3 | import { Button } from "@/components/ui/button"; 4 | import { 5 | DropdownMenu, 6 | DropdownMenuContent, 7 | DropdownMenuItem, 8 | DropdownMenuTrigger, 9 | } from "@/components/ui/dropdown-menu"; 10 | import { useTheme } from "@/components/theme-provider"; 11 | 12 | export function ModeToggle() { 13 | const { setTheme } = useTheme(); 14 | 15 | return ( 16 | 17 | 18 | 23 | 24 | 25 | setTheme("light")}> 26 | 27 | Light 28 | 29 | setTheme("dark")}> 30 | 31 | Dark 32 | 33 | setTheme("system")}> 34 | 35 | System 36 | 37 | 38 | 39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /src/components/preview-panel.tsx: -------------------------------------------------------------------------------- 1 | import { JsonViewer, Path } from "@textea/json-viewer"; 2 | import { Bug } from "lucide-react"; 3 | import { useEffect, useState } from "react"; 4 | import * as Y from "yjs"; 5 | import { yDataType } from "../data-types"; 6 | import { 7 | useConfig, 8 | useFilterMap, 9 | useIsFilterEnabled, 10 | useYDoc, 11 | } from "../state/index"; 12 | import { getYTypeFromPath, isYArray, isYDoc, isYMap } from "../y-shape"; 13 | import { AddDataDialog } from "./add-data-dialog"; 14 | import { DeleteDialog } from "./delete-dialog"; 15 | import { useTheme } from "./theme-provider"; 16 | import { Button } from "./ui/button"; 17 | 18 | function useYDocUpdates(yDoc: Y.Doc) { 19 | const [, setCount] = useState(0); 20 | 21 | useEffect(() => { 22 | const callback = () => { 23 | // Force re-render 24 | setCount((count) => count + 1); 25 | }; 26 | yDoc.on("update", callback); 27 | yDoc.on("subdocs", ({ added }) => { 28 | for (const subDoc of added) { 29 | subDoc.on("update", callback); 30 | } 31 | }); 32 | return () => { 33 | yDoc.off("update", callback); 34 | yDoc.off("subdocs", callback); 35 | yDoc.subdocs.forEach((subDoc) => { 36 | subDoc.off("update", callback); 37 | }); 38 | }; 39 | }, [yDoc]); 40 | } 41 | 42 | export function PreviewPanel() { 43 | const { resolvedTheme } = useTheme(); 44 | const [yDoc] = useYDoc(); 45 | const [config] = useConfig(); 46 | const [addDialogOpen, setAddDialogOpen] = useState(false); 47 | const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); 48 | const [path, setPath] = useState([]); 49 | const [target, setTarget] = useState(null); 50 | 51 | const filterMap = useFilterMap(); 52 | const filterEnable = useIsFilterEnabled(); 53 | const inspectDepth = filterEnable ? 1 : 3; 54 | const jsonViewerValue = filterEnable ? filterMap : yDoc; 55 | 56 | useYDocUpdates(yDoc); 57 | 58 | return ( 59 |
60 |
61 |

Inspect

62 | 63 | 73 |
74 | 75 |
76 | {/* See https://viewer.textea.io/apis */} 77 | { 82 | return ( 83 | config.editable && 84 | config.parseYDoc && 85 | // TODO support YArray/YText 86 | (isYDoc(value) || isYMap(value)) 87 | ); 88 | }} 89 | onAdd={(path) => { 90 | const target = getYTypeFromPath(yDoc, path); 91 | if (!target) { 92 | console.error("Invalid target", path, target); 93 | return; 94 | } 95 | setTarget(target); 96 | setPath(path); 97 | setAddDialogOpen(true); 98 | }} 99 | enableDelete={(path) => { 100 | if (!config.editable || !config.parseYDoc) { 101 | return false; 102 | } 103 | const parent = getYTypeFromPath(yDoc, path.slice(0, -1)); 104 | return isYMap(parent) || isYArray(parent); 105 | }} 106 | onDelete={(path, value) => { 107 | setTarget(value); 108 | setPath(path); 109 | setDeleteDialogOpen(true); 110 | }} 111 | displaySize={config.showSize} 112 | theme={resolvedTheme} 113 | defaultInspectDepth={inspectDepth} 114 | valueTypes={[yDataType]} 115 | /> 116 |
117 | 123 | 129 |
130 | ); 131 | } 132 | -------------------------------------------------------------------------------- /src/components/site-header.tsx: -------------------------------------------------------------------------------- 1 | import { Github } from "lucide-react"; 2 | import packageJSON from "../../package.json"; 3 | import { ModeToggle } from "./mode-toggle"; 4 | import { badgeVariants } from "./ui/badge"; 5 | import { Button } from "./ui/button"; 6 | import yjsLogo from "/yjs.png"; 7 | 8 | export function Header() { 9 | return ( 10 |
11 |
12 |
13 | 14 | Yjs logo 15 | 16 | 17 | 18 | Yjs Inspector 19 | 20 |
21 | {/* Placeholder for right side of header */} 22 |
23 | 29 | Yjs Version: {packageJSON.dependencies.yjs} 30 | 31 | 32 | 42 |
43 |
44 | ); 45 | } 46 | -------------------------------------------------------------------------------- /src/components/status-indicator.tsx: -------------------------------------------------------------------------------- 1 | import { CloudDownload, CloudUpload, Unplug } from "lucide-react"; 2 | import { useCallback, useEffect, useRef, useState } from "react"; 3 | import { useDownloadListener, useUploadListener } from "../state/index"; 4 | 5 | export function StatusIndicator({ className }: { className?: string }) { 6 | const [status, setStatus] = useState<"download" | "upload" | "none">("none"); 7 | const timeoutRef = useRef(null); 8 | 9 | const resetStatus = useCallback(() => { 10 | if (timeoutRef.current) { 11 | clearTimeout(timeoutRef.current); 12 | } 13 | timeoutRef.current = window.setTimeout(() => { 14 | setStatus("none"); 15 | }, 1000); 16 | }, []); 17 | 18 | useDownloadListener( 19 | useCallback(() => { 20 | setStatus("download"); 21 | resetStatus(); 22 | }, [resetStatus]), 23 | ); 24 | 25 | useUploadListener( 26 | useCallback(() => { 27 | setStatus("upload"); 28 | resetStatus(); 29 | }, [resetStatus]), 30 | ); 31 | 32 | useEffect(() => { 33 | return () => { 34 | if (timeoutRef.current) { 35 | clearTimeout(timeoutRef.current); 36 | } 37 | }; 38 | }, []); 39 | 40 | return ( 41 | <> 42 | {status === "none" && } 43 | {status === "download" && } 44 | {status === "upload" && } 45 | 46 | ); 47 | } 48 | -------------------------------------------------------------------------------- /src/components/theme-provider.tsx: -------------------------------------------------------------------------------- 1 | import { createContext, useContext, useEffect, useState } from "react"; 2 | 3 | type ResolvedTheme = "dark" | "light"; 4 | type Theme = ResolvedTheme | "system"; 5 | 6 | type ThemeProviderProps = { 7 | children: React.ReactNode; 8 | defaultTheme?: Theme; 9 | storageKey?: string; 10 | }; 11 | 12 | type ThemeProviderState = { 13 | theme: Theme; 14 | resolvedTheme: ResolvedTheme; 15 | setTheme: (theme: Theme) => void; 16 | }; 17 | 18 | const initialState: ThemeProviderState = { 19 | theme: "system", 20 | resolvedTheme: "light", 21 | setTheme: () => null, 22 | }; 23 | 24 | export const ThemeProviderContext = 25 | createContext(initialState); 26 | 27 | const query = "(prefers-color-scheme: dark)"; 28 | 29 | export function useSystemPreferenceDark() { 30 | const [isDark, setIsDark] = useState(false); 31 | useEffect(() => { 32 | const listener = (e: MediaQueryListEvent) => setIsDark(e.matches); 33 | setIsDark(window.matchMedia(query).matches); 34 | const queryMedia = window.matchMedia(query); 35 | queryMedia.addEventListener("change", listener); 36 | return () => queryMedia.removeEventListener("change", listener); 37 | }, []); 38 | return isDark; 39 | } 40 | 41 | export function ThemeProvider({ 42 | children, 43 | defaultTheme = "system", 44 | storageKey = "ui-theme", 45 | ...props 46 | }: ThemeProviderProps) { 47 | const [theme, setTheme] = useState( 48 | () => (localStorage.getItem(storageKey) as Theme) || defaultTheme, 49 | ); 50 | const systemPreferenceTheme = useSystemPreferenceDark() ? "dark" : "light"; 51 | const resolvedTheme: ResolvedTheme = 52 | theme === "system" ? systemPreferenceTheme : theme; 53 | useEffect(() => { 54 | const root = window.document.documentElement; 55 | root.classList.remove("light", "dark"); 56 | root.classList.add(resolvedTheme); 57 | }, [resolvedTheme, theme]); 58 | 59 | const value = { 60 | theme, 61 | resolvedTheme: resolvedTheme, 62 | setTheme: (theme: Theme) => { 63 | localStorage.setItem(storageKey, theme); 64 | setTheme(theme); 65 | }, 66 | }; 67 | 68 | return ( 69 | 70 | {children} 71 | 72 | ); 73 | } 74 | 75 | export const useTheme = () => { 76 | const context = useContext(ThemeProviderContext); 77 | 78 | if (context === undefined) 79 | throw new Error("useTheme must be used within a ThemeProvider"); 80 | 81 | return context; 82 | }; 83 | -------------------------------------------------------------------------------- /src/components/ui/alert.tsx: -------------------------------------------------------------------------------- 1 | import { cva, type VariantProps } from "class-variance-authority"; 2 | import * as React from "react"; 3 | 4 | import { cn } from "@/lib/utils"; 5 | 6 | const alertVariants = cva( 7 | "relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground", 8 | { 9 | variants: { 10 | variant: { 11 | default: "bg-background text-foreground", 12 | destructive: 13 | "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive", 14 | }, 15 | }, 16 | defaultVariants: { 17 | variant: "default", 18 | }, 19 | }, 20 | ); 21 | 22 | const Alert = React.forwardRef< 23 | HTMLDivElement, 24 | React.HTMLAttributes & VariantProps 25 | >(({ className, variant, ...props }, ref) => ( 26 |
32 | )); 33 | Alert.displayName = "Alert"; 34 | 35 | const AlertTitle = React.forwardRef< 36 | HTMLParagraphElement, 37 | React.HTMLAttributes 38 | >(({ className, ...props }, ref) => ( 39 |
44 | )); 45 | AlertTitle.displayName = "AlertTitle"; 46 | 47 | const AlertDescription = React.forwardRef< 48 | HTMLParagraphElement, 49 | React.HTMLAttributes 50 | >(({ className, ...props }, ref) => ( 51 |
56 | )); 57 | AlertDescription.displayName = "AlertDescription"; 58 | 59 | export { Alert, AlertDescription, AlertTitle }; 60 | -------------------------------------------------------------------------------- /src/components/ui/badge.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { cva, type VariantProps } from "class-variance-authority"; 3 | 4 | import { cn } from "@/lib/utils"; 5 | 6 | const badgeVariants = cva( 7 | "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2", 8 | { 9 | variants: { 10 | variant: { 11 | default: 12 | "border-transparent bg-primary text-primary-foreground hover:bg-primary/80", 13 | secondary: 14 | "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", 15 | destructive: 16 | "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80", 17 | outline: "text-foreground", 18 | }, 19 | }, 20 | defaultVariants: { 21 | variant: "default", 22 | }, 23 | }, 24 | ); 25 | 26 | export interface BadgeProps 27 | extends React.HTMLAttributes, 28 | VariantProps {} 29 | 30 | function Badge({ className, variant, ...props }: BadgeProps) { 31 | return ( 32 |
33 | ); 34 | } 35 | 36 | export { Badge, badgeVariants }; 37 | -------------------------------------------------------------------------------- /src/components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { Slot } from "@radix-ui/react-slot"; 3 | import { cva, type VariantProps } from "class-variance-authority"; 4 | 5 | import { cn } from "@/lib/utils"; 6 | 7 | const buttonVariants = cva( 8 | "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50", 9 | { 10 | variants: { 11 | variant: { 12 | default: "bg-primary text-primary-foreground hover:bg-primary/90", 13 | destructive: 14 | "bg-destructive text-destructive-foreground hover:bg-destructive/90", 15 | outline: 16 | "border border-input bg-background hover:bg-accent hover:text-accent-foreground", 17 | secondary: 18 | "bg-secondary text-secondary-foreground hover:bg-secondary/80", 19 | ghost: "hover:bg-accent hover:text-accent-foreground", 20 | link: "text-primary underline-offset-4 hover:underline", 21 | }, 22 | size: { 23 | default: "h-10 px-4 py-2", 24 | sm: "h-9 rounded-md px-3", 25 | lg: "h-11 rounded-md px-8", 26 | icon: "h-10 w-10", 27 | }, 28 | }, 29 | defaultVariants: { 30 | variant: "default", 31 | size: "default", 32 | }, 33 | }, 34 | ); 35 | 36 | export interface ButtonProps 37 | extends React.ButtonHTMLAttributes, 38 | VariantProps { 39 | asChild?: boolean; 40 | } 41 | 42 | const Button = React.forwardRef( 43 | ({ className, variant, size, asChild = false, ...props }, ref) => { 44 | const Comp = asChild ? Slot : "button"; 45 | return ( 46 | 51 | ); 52 | }, 53 | ); 54 | Button.displayName = "Button"; 55 | 56 | export { Button, buttonVariants }; 57 | -------------------------------------------------------------------------------- /src/components/ui/command.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { type DialogProps } from "@radix-ui/react-dialog"; 3 | import { Command as CommandPrimitive } from "cmdk"; 4 | import { Search } from "lucide-react"; 5 | 6 | import { cn } from "@/lib/utils"; 7 | import { Dialog, DialogContent } from "@/components/ui/dialog"; 8 | 9 | const Command = React.forwardRef< 10 | React.ElementRef, 11 | React.ComponentPropsWithoutRef 12 | >(({ className, ...props }, ref) => ( 13 | 21 | )); 22 | Command.displayName = CommandPrimitive.displayName; 23 | 24 | interface CommandDialogProps extends DialogProps {} 25 | 26 | const CommandDialog = ({ children, ...props }: CommandDialogProps) => { 27 | return ( 28 | 29 | 30 | 31 | {children} 32 | 33 | 34 | 35 | ); 36 | }; 37 | 38 | const CommandInput = React.forwardRef< 39 | React.ElementRef, 40 | React.ComponentPropsWithoutRef 41 | >(({ className, ...props }, ref) => ( 42 |
43 | 44 | 52 |
53 | )); 54 | 55 | CommandInput.displayName = CommandPrimitive.Input.displayName; 56 | 57 | const CommandList = React.forwardRef< 58 | React.ElementRef, 59 | React.ComponentPropsWithoutRef 60 | >(({ className, ...props }, ref) => ( 61 | 66 | )); 67 | 68 | CommandList.displayName = CommandPrimitive.List.displayName; 69 | 70 | const CommandEmpty = React.forwardRef< 71 | React.ElementRef, 72 | React.ComponentPropsWithoutRef 73 | >((props, ref) => ( 74 | 79 | )); 80 | 81 | CommandEmpty.displayName = CommandPrimitive.Empty.displayName; 82 | 83 | const CommandGroup = React.forwardRef< 84 | React.ElementRef, 85 | React.ComponentPropsWithoutRef 86 | >(({ className, ...props }, ref) => ( 87 | 95 | )); 96 | 97 | CommandGroup.displayName = CommandPrimitive.Group.displayName; 98 | 99 | const CommandSeparator = React.forwardRef< 100 | React.ElementRef, 101 | React.ComponentPropsWithoutRef 102 | >(({ className, ...props }, ref) => ( 103 | 108 | )); 109 | CommandSeparator.displayName = CommandPrimitive.Separator.displayName; 110 | 111 | const CommandItem = React.forwardRef< 112 | React.ElementRef, 113 | React.ComponentPropsWithoutRef 114 | >(({ className, ...props }, ref) => ( 115 | 123 | )); 124 | 125 | CommandItem.displayName = CommandPrimitive.Item.displayName; 126 | 127 | const CommandShortcut = ({ 128 | className, 129 | ...props 130 | }: React.HTMLAttributes) => { 131 | return ( 132 | 139 | ); 140 | }; 141 | CommandShortcut.displayName = "CommandShortcut"; 142 | 143 | export { 144 | Command, 145 | CommandDialog, 146 | CommandInput, 147 | CommandList, 148 | CommandEmpty, 149 | CommandGroup, 150 | CommandItem, 151 | CommandShortcut, 152 | CommandSeparator, 153 | }; 154 | -------------------------------------------------------------------------------- /src/components/ui/dialog.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import * as DialogPrimitive from "@radix-ui/react-dialog"; 3 | import { X } from "lucide-react"; 4 | 5 | import { cn } from "@/lib/utils"; 6 | 7 | const Dialog = DialogPrimitive.Root; 8 | 9 | const DialogTrigger = DialogPrimitive.Trigger; 10 | 11 | const DialogPortal = DialogPrimitive.Portal; 12 | 13 | const DialogClose = DialogPrimitive.Close; 14 | 15 | const DialogOverlay = React.forwardRef< 16 | React.ElementRef, 17 | React.ComponentPropsWithoutRef 18 | >(({ className, ...props }, ref) => ( 19 | 27 | )); 28 | DialogOverlay.displayName = DialogPrimitive.Overlay.displayName; 29 | 30 | const DialogContent = React.forwardRef< 31 | React.ElementRef, 32 | React.ComponentPropsWithoutRef 33 | >(({ className, children, ...props }, ref) => ( 34 | 35 | 36 | 44 | {children} 45 | 46 | 47 | Close 48 | 49 | 50 | 51 | )); 52 | DialogContent.displayName = DialogPrimitive.Content.displayName; 53 | 54 | const DialogHeader = ({ 55 | className, 56 | ...props 57 | }: React.HTMLAttributes) => ( 58 |
65 | ); 66 | DialogHeader.displayName = "DialogHeader"; 67 | 68 | const DialogFooter = ({ 69 | className, 70 | ...props 71 | }: React.HTMLAttributes) => ( 72 |
79 | ); 80 | DialogFooter.displayName = "DialogFooter"; 81 | 82 | const DialogTitle = React.forwardRef< 83 | React.ElementRef, 84 | React.ComponentPropsWithoutRef 85 | >(({ className, ...props }, ref) => ( 86 | 94 | )); 95 | DialogTitle.displayName = DialogPrimitive.Title.displayName; 96 | 97 | const DialogDescription = React.forwardRef< 98 | React.ElementRef, 99 | React.ComponentPropsWithoutRef 100 | >(({ className, ...props }, ref) => ( 101 | 106 | )); 107 | DialogDescription.displayName = DialogPrimitive.Description.displayName; 108 | 109 | export { 110 | Dialog, 111 | DialogPortal, 112 | DialogOverlay, 113 | DialogClose, 114 | DialogTrigger, 115 | DialogContent, 116 | DialogHeader, 117 | DialogFooter, 118 | DialogTitle, 119 | DialogDescription, 120 | }; 121 | -------------------------------------------------------------------------------- /src/components/ui/dropdown-menu.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"; 3 | import { Check, ChevronRight, Circle } from "lucide-react"; 4 | 5 | import { cn } from "@/lib/utils"; 6 | 7 | const DropdownMenu = DropdownMenuPrimitive.Root; 8 | 9 | const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger; 10 | 11 | const DropdownMenuGroup = DropdownMenuPrimitive.Group; 12 | 13 | const DropdownMenuPortal = DropdownMenuPrimitive.Portal; 14 | 15 | const DropdownMenuSub = DropdownMenuPrimitive.Sub; 16 | 17 | const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup; 18 | 19 | const DropdownMenuSubTrigger = React.forwardRef< 20 | React.ElementRef, 21 | React.ComponentPropsWithoutRef & { 22 | inset?: boolean; 23 | } 24 | >(({ className, inset, children, ...props }, ref) => ( 25 | 34 | {children} 35 | 36 | 37 | )); 38 | DropdownMenuSubTrigger.displayName = 39 | DropdownMenuPrimitive.SubTrigger.displayName; 40 | 41 | const DropdownMenuSubContent = React.forwardRef< 42 | React.ElementRef, 43 | React.ComponentPropsWithoutRef 44 | >(({ className, ...props }, ref) => ( 45 | 53 | )); 54 | DropdownMenuSubContent.displayName = 55 | DropdownMenuPrimitive.SubContent.displayName; 56 | 57 | const DropdownMenuContent = React.forwardRef< 58 | React.ElementRef, 59 | React.ComponentPropsWithoutRef 60 | >(({ className, sideOffset = 4, ...props }, ref) => ( 61 | 62 | 71 | 72 | )); 73 | DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName; 74 | 75 | const DropdownMenuItem = React.forwardRef< 76 | React.ElementRef, 77 | React.ComponentPropsWithoutRef & { 78 | inset?: boolean; 79 | } 80 | >(({ className, inset, ...props }, ref) => ( 81 | 90 | )); 91 | DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName; 92 | 93 | const DropdownMenuCheckboxItem = React.forwardRef< 94 | React.ElementRef, 95 | React.ComponentPropsWithoutRef 96 | >(({ className, children, checked, ...props }, ref) => ( 97 | 106 | 107 | 108 | 109 | 110 | 111 | {children} 112 | 113 | )); 114 | DropdownMenuCheckboxItem.displayName = 115 | DropdownMenuPrimitive.CheckboxItem.displayName; 116 | 117 | const DropdownMenuRadioItem = React.forwardRef< 118 | React.ElementRef, 119 | React.ComponentPropsWithoutRef 120 | >(({ className, children, ...props }, ref) => ( 121 | 129 | 130 | 131 | 132 | 133 | 134 | {children} 135 | 136 | )); 137 | DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName; 138 | 139 | const DropdownMenuLabel = React.forwardRef< 140 | React.ElementRef, 141 | React.ComponentPropsWithoutRef & { 142 | inset?: boolean; 143 | } 144 | >(({ className, inset, ...props }, ref) => ( 145 | 154 | )); 155 | DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName; 156 | 157 | const DropdownMenuSeparator = React.forwardRef< 158 | React.ElementRef, 159 | React.ComponentPropsWithoutRef 160 | >(({ className, ...props }, ref) => ( 161 | 166 | )); 167 | DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName; 168 | 169 | const DropdownMenuShortcut = ({ 170 | className, 171 | ...props 172 | }: React.HTMLAttributes) => { 173 | return ( 174 | 178 | ); 179 | }; 180 | DropdownMenuShortcut.displayName = "DropdownMenuShortcut"; 181 | 182 | export { 183 | DropdownMenu, 184 | DropdownMenuTrigger, 185 | DropdownMenuContent, 186 | DropdownMenuItem, 187 | DropdownMenuCheckboxItem, 188 | DropdownMenuRadioItem, 189 | DropdownMenuLabel, 190 | DropdownMenuSeparator, 191 | DropdownMenuShortcut, 192 | DropdownMenuGroup, 193 | DropdownMenuPortal, 194 | DropdownMenuSub, 195 | DropdownMenuSubContent, 196 | DropdownMenuSubTrigger, 197 | DropdownMenuRadioGroup, 198 | }; 199 | -------------------------------------------------------------------------------- /src/components/ui/input.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | import { cn } from "@/lib/utils"; 4 | 5 | export interface InputProps 6 | extends React.InputHTMLAttributes {} 7 | 8 | const Input = React.forwardRef( 9 | ({ className, type, ...props }, ref) => { 10 | return ( 11 | 20 | ); 21 | }, 22 | ); 23 | Input.displayName = "Input"; 24 | 25 | export { Input }; 26 | -------------------------------------------------------------------------------- /src/components/ui/label.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import * as LabelPrimitive from "@radix-ui/react-label"; 3 | import { cva, type VariantProps } from "class-variance-authority"; 4 | 5 | import { cn } from "@/lib/utils"; 6 | 7 | const labelVariants = cva( 8 | "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70", 9 | ); 10 | 11 | const Label = React.forwardRef< 12 | React.ElementRef, 13 | React.ComponentPropsWithoutRef & 14 | VariantProps 15 | >(({ className, ...props }, ref) => ( 16 | 21 | )); 22 | Label.displayName = LabelPrimitive.Root.displayName; 23 | 24 | export { Label }; 25 | -------------------------------------------------------------------------------- /src/components/ui/multi-select.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/lib/utils"; 2 | import * as React from "react"; 3 | 4 | import { Button } from "@/components/ui/button"; 5 | import { 6 | Command, 7 | CommandEmpty, 8 | CommandGroup, 9 | CommandInput, 10 | CommandItem, 11 | CommandList, 12 | } from "@/components/ui/command"; 13 | import { 14 | Popover, 15 | PopoverContent, 16 | PopoverTrigger, 17 | } from "@/components/ui/popover"; 18 | import { Check, ChevronsUpDown } from "lucide-react"; 19 | 20 | // Ported from https://github.com/shadcn-ui/ui/issues/66#issuecomment-1718329393 21 | 22 | export type OptionType = { 23 | label: string; 24 | value: string; 25 | }; 26 | 27 | interface MultiSelectProps { 28 | options: OptionType[]; 29 | selected: string[]; 30 | onChange: (val: string[]) => void; 31 | className?: string; 32 | } 33 | 34 | function MultiSelect({ 35 | options, 36 | selected, 37 | onChange, 38 | className, 39 | ...props 40 | }: MultiSelectProps) { 41 | const [open, setOpen] = React.useState(false); 42 | 43 | // const handleUnselect = (item: string) => { 44 | // onChange(selected.filter((i) => i !== item)); 45 | // }; 46 | 47 | return ( 48 | 49 | 50 | 65 | 66 | 67 | 68 | 69 | No item found. 70 | 71 | 72 | {options.map((option) => ( 73 | { 76 | onChange( 77 | selected.includes(option.value) 78 | ? selected.filter((item) => item !== option.value) 79 | : [...selected, option.value], 80 | ); 81 | setOpen(true); 82 | }} 83 | > 84 | 92 | {option.label} 93 | 94 | ))} 95 | 96 | 97 | 98 | 99 | 100 | ); 101 | } 102 | 103 | export { MultiSelect }; 104 | -------------------------------------------------------------------------------- /src/components/ui/popover.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import * as PopoverPrimitive from "@radix-ui/react-popover"; 3 | 4 | import { cn } from "@/lib/utils"; 5 | 6 | const Popover = PopoverPrimitive.Root; 7 | 8 | const PopoverTrigger = PopoverPrimitive.Trigger; 9 | 10 | const PopoverContent = React.forwardRef< 11 | React.ElementRef, 12 | React.ComponentPropsWithoutRef 13 | >(({ className, align = "center", sideOffset = 4, ...props }, ref) => ( 14 | 15 | 25 | 26 | )); 27 | PopoverContent.displayName = PopoverPrimitive.Content.displayName; 28 | 29 | export { Popover, PopoverTrigger, PopoverContent }; 30 | -------------------------------------------------------------------------------- /src/components/ui/select.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import * as SelectPrimitive from "@radix-ui/react-select"; 3 | import { Check, ChevronDown, ChevronUp } from "lucide-react"; 4 | 5 | import { cn } from "@/lib/utils"; 6 | 7 | const Select = SelectPrimitive.Root; 8 | 9 | const SelectGroup = SelectPrimitive.Group; 10 | 11 | const SelectValue = SelectPrimitive.Value; 12 | 13 | const SelectTrigger = React.forwardRef< 14 | React.ElementRef, 15 | React.ComponentPropsWithoutRef 16 | >(({ className, children, ...props }, ref) => ( 17 | span]:line-clamp-1", 21 | className, 22 | )} 23 | {...props} 24 | > 25 | {children} 26 | 27 | 28 | 29 | 30 | )); 31 | SelectTrigger.displayName = SelectPrimitive.Trigger.displayName; 32 | 33 | const SelectScrollUpButton = React.forwardRef< 34 | React.ElementRef, 35 | React.ComponentPropsWithoutRef 36 | >(({ className, ...props }, ref) => ( 37 | 45 | 46 | 47 | )); 48 | SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName; 49 | 50 | const SelectScrollDownButton = React.forwardRef< 51 | React.ElementRef, 52 | React.ComponentPropsWithoutRef 53 | >(({ className, ...props }, ref) => ( 54 | 62 | 63 | 64 | )); 65 | SelectScrollDownButton.displayName = 66 | SelectPrimitive.ScrollDownButton.displayName; 67 | 68 | const SelectContent = React.forwardRef< 69 | React.ElementRef, 70 | React.ComponentPropsWithoutRef 71 | >(({ className, children, position = "popper", ...props }, ref) => ( 72 | 73 | 84 | 85 | 92 | {children} 93 | 94 | 95 | 96 | 97 | )); 98 | SelectContent.displayName = SelectPrimitive.Content.displayName; 99 | 100 | const SelectLabel = React.forwardRef< 101 | React.ElementRef, 102 | React.ComponentPropsWithoutRef 103 | >(({ className, ...props }, ref) => ( 104 | 109 | )); 110 | SelectLabel.displayName = SelectPrimitive.Label.displayName; 111 | 112 | const SelectItem = React.forwardRef< 113 | React.ElementRef, 114 | React.ComponentPropsWithoutRef 115 | >(({ className, children, ...props }, ref) => ( 116 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | {children} 131 | 132 | )); 133 | SelectItem.displayName = SelectPrimitive.Item.displayName; 134 | 135 | const SelectSeparator = React.forwardRef< 136 | React.ElementRef, 137 | React.ComponentPropsWithoutRef 138 | >(({ className, ...props }, ref) => ( 139 | 144 | )); 145 | SelectSeparator.displayName = SelectPrimitive.Separator.displayName; 146 | 147 | export { 148 | Select, 149 | SelectGroup, 150 | SelectValue, 151 | SelectTrigger, 152 | SelectContent, 153 | SelectLabel, 154 | SelectItem, 155 | SelectSeparator, 156 | SelectScrollUpButton, 157 | SelectScrollDownButton, 158 | }; 159 | -------------------------------------------------------------------------------- /src/components/ui/switch.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import * as SwitchPrimitives from "@radix-ui/react-switch"; 3 | 4 | import { cn } from "@/lib/utils"; 5 | 6 | const Switch = React.forwardRef< 7 | React.ElementRef, 8 | React.ComponentPropsWithoutRef 9 | >(({ className, ...props }, ref) => ( 10 | 18 | 23 | 24 | )); 25 | Switch.displayName = SwitchPrimitives.Root.displayName; 26 | 27 | export { Switch }; 28 | -------------------------------------------------------------------------------- /src/components/ui/tabs.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import * as TabsPrimitive from "@radix-ui/react-tabs"; 3 | 4 | import { cn } from "@/lib/utils"; 5 | 6 | const Tabs = TabsPrimitive.Root; 7 | 8 | const TabsList = React.forwardRef< 9 | React.ElementRef, 10 | React.ComponentPropsWithoutRef 11 | >(({ className, ...props }, ref) => ( 12 | 20 | )); 21 | TabsList.displayName = TabsPrimitive.List.displayName; 22 | 23 | const TabsTrigger = React.forwardRef< 24 | React.ElementRef, 25 | React.ComponentPropsWithoutRef 26 | >(({ className, ...props }, ref) => ( 27 | 35 | )); 36 | TabsTrigger.displayName = TabsPrimitive.Trigger.displayName; 37 | 38 | const TabsContent = React.forwardRef< 39 | React.ElementRef, 40 | React.ComponentPropsWithoutRef 41 | >(({ className, ...props }, ref) => ( 42 | 50 | )); 51 | TabsContent.displayName = TabsPrimitive.Content.displayName; 52 | 53 | export { Tabs, TabsList, TabsTrigger, TabsContent }; 54 | -------------------------------------------------------------------------------- /src/components/ui/toast.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import * as ToastPrimitives from "@radix-ui/react-toast"; 3 | import { cva, type VariantProps } from "class-variance-authority"; 4 | import { X } from "lucide-react"; 5 | 6 | import { cn } from "@/lib/utils"; 7 | 8 | const ToastProvider = ToastPrimitives.Provider; 9 | 10 | const ToastViewport = React.forwardRef< 11 | React.ElementRef, 12 | React.ComponentPropsWithoutRef 13 | >(({ className, ...props }, ref) => ( 14 | 22 | )); 23 | ToastViewport.displayName = ToastPrimitives.Viewport.displayName; 24 | 25 | const toastVariants = cva( 26 | "group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full sm:data-[state=open]:slide-in-from-bottom-full", 27 | { 28 | variants: { 29 | variant: { 30 | default: "border bg-background text-foreground", 31 | destructive: 32 | "destructive group border-destructive bg-destructive text-destructive-foreground", 33 | }, 34 | }, 35 | defaultVariants: { 36 | variant: "default", 37 | }, 38 | }, 39 | ); 40 | 41 | const Toast = React.forwardRef< 42 | React.ElementRef, 43 | React.ComponentPropsWithoutRef & 44 | VariantProps 45 | >(({ className, variant, ...props }, ref) => { 46 | return ( 47 | 52 | ); 53 | }); 54 | Toast.displayName = ToastPrimitives.Root.displayName; 55 | 56 | const ToastAction = React.forwardRef< 57 | React.ElementRef, 58 | React.ComponentPropsWithoutRef 59 | >(({ className, ...props }, ref) => ( 60 | 68 | )); 69 | ToastAction.displayName = ToastPrimitives.Action.displayName; 70 | 71 | const ToastClose = React.forwardRef< 72 | React.ElementRef, 73 | React.ComponentPropsWithoutRef 74 | >(({ className, ...props }, ref) => ( 75 | 84 | 85 | 86 | )); 87 | ToastClose.displayName = ToastPrimitives.Close.displayName; 88 | 89 | const ToastTitle = React.forwardRef< 90 | React.ElementRef, 91 | React.ComponentPropsWithoutRef 92 | >(({ className, ...props }, ref) => ( 93 | 98 | )); 99 | ToastTitle.displayName = ToastPrimitives.Title.displayName; 100 | 101 | const ToastDescription = React.forwardRef< 102 | React.ElementRef, 103 | React.ComponentPropsWithoutRef 104 | >(({ className, ...props }, ref) => ( 105 | 110 | )); 111 | ToastDescription.displayName = ToastPrimitives.Description.displayName; 112 | 113 | type ToastProps = React.ComponentPropsWithoutRef; 114 | 115 | type ToastActionElement = React.ReactElement; 116 | 117 | export { 118 | type ToastProps, 119 | type ToastActionElement, 120 | ToastProvider, 121 | ToastViewport, 122 | Toast, 123 | ToastTitle, 124 | ToastDescription, 125 | ToastClose, 126 | ToastAction, 127 | }; 128 | -------------------------------------------------------------------------------- /src/components/ui/toaster.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Toast, 3 | ToastClose, 4 | ToastDescription, 5 | ToastProvider, 6 | ToastTitle, 7 | ToastViewport, 8 | } from "@/components/ui/toast"; 9 | import { useToast } from "@/components/ui/use-toast"; 10 | 11 | export function Toaster() { 12 | const { toasts } = useToast(); 13 | 14 | return ( 15 | 16 | {toasts.map(function ({ id, title, description, action, ...props }) { 17 | return ( 18 | 19 |
20 | {title && {title}} 21 | {description && ( 22 | {description} 23 | )} 24 |
25 | {action} 26 | 27 |
28 | ); 29 | })} 30 | 31 |
32 | ); 33 | } 34 | -------------------------------------------------------------------------------- /src/components/ui/use-toast.ts: -------------------------------------------------------------------------------- 1 | // Inspired by react-hot-toast library 2 | import * as React from "react"; 3 | 4 | import type { ToastActionElement, ToastProps } from "@/components/ui/toast"; 5 | 6 | const TOAST_LIMIT = 1; 7 | const TOAST_REMOVE_DELAY = 1000000; 8 | 9 | type ToasterToast = ToastProps & { 10 | id: string; 11 | title?: React.ReactNode; 12 | description?: React.ReactNode; 13 | action?: ToastActionElement; 14 | }; 15 | 16 | const actionTypes = { 17 | ADD_TOAST: "ADD_TOAST", 18 | UPDATE_TOAST: "UPDATE_TOAST", 19 | DISMISS_TOAST: "DISMISS_TOAST", 20 | REMOVE_TOAST: "REMOVE_TOAST", 21 | } as const; 22 | 23 | let count = 0; 24 | 25 | function genId() { 26 | count = (count + 1) % Number.MAX_SAFE_INTEGER; 27 | return count.toString(); 28 | } 29 | 30 | type ActionType = typeof actionTypes; 31 | 32 | type Action = 33 | | { 34 | type: ActionType["ADD_TOAST"]; 35 | toast: ToasterToast; 36 | } 37 | | { 38 | type: ActionType["UPDATE_TOAST"]; 39 | toast: Partial; 40 | } 41 | | { 42 | type: ActionType["DISMISS_TOAST"]; 43 | toastId?: ToasterToast["id"]; 44 | } 45 | | { 46 | type: ActionType["REMOVE_TOAST"]; 47 | toastId?: ToasterToast["id"]; 48 | }; 49 | 50 | interface State { 51 | toasts: ToasterToast[]; 52 | } 53 | 54 | const toastTimeouts = new Map>(); 55 | 56 | const addToRemoveQueue = (toastId: string) => { 57 | if (toastTimeouts.has(toastId)) { 58 | return; 59 | } 60 | 61 | const timeout = setTimeout(() => { 62 | toastTimeouts.delete(toastId); 63 | dispatch({ 64 | type: "REMOVE_TOAST", 65 | toastId: toastId, 66 | }); 67 | }, TOAST_REMOVE_DELAY); 68 | 69 | toastTimeouts.set(toastId, timeout); 70 | }; 71 | 72 | export const reducer = (state: State, action: Action): State => { 73 | switch (action.type) { 74 | case "ADD_TOAST": 75 | return { 76 | ...state, 77 | toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT), 78 | }; 79 | 80 | case "UPDATE_TOAST": 81 | return { 82 | ...state, 83 | toasts: state.toasts.map((t) => 84 | t.id === action.toast.id ? { ...t, ...action.toast } : t, 85 | ), 86 | }; 87 | 88 | case "DISMISS_TOAST": { 89 | const { toastId } = action; 90 | 91 | // ! Side effects ! - This could be extracted into a dismissToast() action, 92 | // but I'll keep it here for simplicity 93 | if (toastId) { 94 | addToRemoveQueue(toastId); 95 | } else { 96 | state.toasts.forEach((toast) => { 97 | addToRemoveQueue(toast.id); 98 | }); 99 | } 100 | 101 | return { 102 | ...state, 103 | toasts: state.toasts.map((t) => 104 | t.id === toastId || toastId === undefined 105 | ? { 106 | ...t, 107 | open: false, 108 | } 109 | : t, 110 | ), 111 | }; 112 | } 113 | case "REMOVE_TOAST": 114 | if (action.toastId === undefined) { 115 | return { 116 | ...state, 117 | toasts: [], 118 | }; 119 | } 120 | return { 121 | ...state, 122 | toasts: state.toasts.filter((t) => t.id !== action.toastId), 123 | }; 124 | } 125 | }; 126 | 127 | const listeners: Array<(state: State) => void> = []; 128 | 129 | let memoryState: State = { toasts: [] }; 130 | 131 | function dispatch(action: Action) { 132 | memoryState = reducer(memoryState, action); 133 | listeners.forEach((listener) => { 134 | listener(memoryState); 135 | }); 136 | } 137 | 138 | type Toast = Omit; 139 | 140 | function toast({ ...props }: Toast) { 141 | const id = genId(); 142 | 143 | const update = (props: ToasterToast) => 144 | dispatch({ 145 | type: "UPDATE_TOAST", 146 | toast: { ...props, id }, 147 | }); 148 | const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id }); 149 | 150 | dispatch({ 151 | type: "ADD_TOAST", 152 | toast: { 153 | ...props, 154 | id, 155 | open: true, 156 | onOpenChange: (open) => { 157 | if (!open) dismiss(); 158 | }, 159 | }, 160 | }); 161 | 162 | return { 163 | id: id, 164 | dismiss, 165 | update, 166 | }; 167 | } 168 | 169 | function useToast() { 170 | const [state, setState] = React.useState(memoryState); 171 | 172 | React.useEffect(() => { 173 | listeners.push(setState); 174 | return () => { 175 | const index = listeners.indexOf(setState); 176 | if (index > -1) { 177 | listeners.splice(index, 1); 178 | } 179 | }; 180 | }, [state]); 181 | 182 | return { 183 | ...state, 184 | toast, 185 | dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }), 186 | }; 187 | } 188 | 189 | export { useToast, toast }; 190 | -------------------------------------------------------------------------------- /src/data-types.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | DataItemProps, 3 | defineDataType, 4 | objectType, 5 | stringType, 6 | } from "@textea/json-viewer"; 7 | import { ComponentType } from "react"; 8 | import * as Y from "yjs"; 9 | import { Badge } from "./components/ui/badge"; 10 | import { toast } from "./components/ui/use-toast"; 11 | import { useConfig } from "./state/index"; 12 | import { getYTypeName, isYShape, parseYShape } from "./y-shape"; 13 | 14 | const TypeLabel = ({ value }: { value: unknown }) => { 15 | const typeName = getYTypeName(value as Y.AbstractType); 16 | return ( 17 | { 21 | e.stopPropagation(); 22 | // This logs is expected to be used for user debugging 23 | // Do not remove this log! 24 | console.log(value); 25 | toast({ 26 | duration: 2000, 27 | description: "Check the console for the value", 28 | }); 29 | }} 30 | > 31 | {typeName} 32 | 33 | ); 34 | }; 35 | 36 | const YTypePreComponent = ({ 37 | value, 38 | prevValue, 39 | ...props 40 | }: DataItemProps) => { 41 | const ObjPreComponent = objectType.PreComponent!; 42 | const [config] = useConfig(); 43 | if (!config.parseYDoc) { 44 | if (typeof value === "string") { 45 | throw new Error("YDoc should not be a string"); 46 | } 47 | return ( 48 | 49 | 50 | 55 | 56 | ); 57 | } 58 | const parsedValue = parseYShape(value as Y.AbstractType, { 59 | showDelta: config.showDelta, 60 | }); 61 | const parsedPrevValue = parseYShape(prevValue as Y.AbstractType, { 62 | showDelta: config.showDelta, 63 | }); 64 | if (typeof parsedValue === "string") { 65 | return ; 66 | } 67 | return ( 68 | 69 | 70 | 75 | 76 | ); 77 | }; 78 | 79 | const YTypeComponent: ComponentType> = ({ 80 | value, 81 | prevValue, 82 | ...props 83 | }: DataItemProps) => { 84 | const StrComponent = stringType.Component!; 85 | const ObjComponent = objectType.Component!; 86 | const [config] = useConfig(); 87 | 88 | if (!config.parseYDoc) { 89 | if (typeof value === "string") { 90 | throw new Error("YDoc should not be a string"); 91 | } 92 | return ( 93 | 98 | ); 99 | } 100 | 101 | const parsedValue = parseYShape(value as Y.AbstractType, { 102 | showDelta: config.showDelta, 103 | }); 104 | const parsedPrevValue = parseYShape(prevValue as Y.AbstractType, { 105 | showDelta: config.showDelta, 106 | }); 107 | if (typeof parsedValue === "string") { 108 | return ( 109 | 114 | ); 115 | } 116 | 117 | return ( 118 | 123 | ); 124 | }; 125 | 126 | const YTypePostComponent: ComponentType> = ({ 127 | value, 128 | prevValue, 129 | ...props 130 | }: DataItemProps) => { 131 | const ObjPostComponent = objectType.PostComponent!; 132 | const [config] = useConfig(); 133 | 134 | if (!config.parseYDoc) { 135 | if (typeof value === "string") { 136 | throw new Error("YDoc should not be a string"); 137 | } 138 | return ( 139 | 144 | ); 145 | } 146 | 147 | const parsedValue = parseYShape(value as Y.AbstractType, { 148 | showDelta: config.showDelta, 149 | }); 150 | const parsedPrevValue = parseYShape(prevValue as Y.AbstractType, { 151 | showDelta: config.showDelta, 152 | }); 153 | if (typeof parsedValue === "string") { 154 | return null; 155 | } 156 | 157 | return ( 158 | 163 | ); 164 | }; 165 | 166 | export const yDataType = defineDataType({ 167 | is: isYShape, 168 | PreComponent: YTypePreComponent, 169 | PostComponent: YTypePostComponent, 170 | Component: YTypeComponent, 171 | }); 172 | -------------------------------------------------------------------------------- /src/filter-map.tsx: -------------------------------------------------------------------------------- 1 | import * as Y from "yjs"; 2 | import type { YShapeItem } from "./components/filter-sphere"; 3 | import { getYTypeName, isYDoc, isYMap, isYShape, parseYShape } from "./y-shape"; 4 | 5 | type FilterType = 6 | | Y.Doc 7 | | Y.AbstractType 8 | | Record 9 | | boolean 10 | | string 11 | | number 12 | | Uint8Array; 13 | 14 | function isPureObject(input: unknown): input is Record { 15 | return ( 16 | null !== input && typeof input === "object" && input.constructor === Object 17 | ); 18 | } 19 | 20 | function isFilterableType(input: unknown): input is FilterType { 21 | const isShape = isYShape(input); 22 | return ( 23 | isShape || 24 | isPureObject(input) || 25 | typeof input === "boolean" || 26 | typeof input === "string" || 27 | typeof input === "number" || 28 | input instanceof Uint8Array 29 | ); 30 | } 31 | 32 | function getFilterType(input: FilterType): YShapeItem["type"] { 33 | if (isYShape(input)) { 34 | return getYTypeName(input); 35 | } 36 | if (typeof input === "boolean") { 37 | return "Boolean"; 38 | } 39 | if (typeof input === "string") { 40 | return "String"; 41 | } 42 | if (typeof input === "number") { 43 | return "Number"; 44 | } 45 | if (input instanceof Uint8Array) { 46 | return "Uint8Array"; 47 | } 48 | return "Object"; 49 | } 50 | 51 | /** 52 | * Recursively filter YDoc based on the predicate. 53 | */ 54 | export const filterYDoc = ( 55 | yDoc: Y.Doc, 56 | predicate: (data: YShapeItem) => boolean, 57 | ) => { 58 | const selectedMap: Record = {}; 59 | const accessed = new Set(); 60 | 61 | function traverseYDoc( 62 | data: FilterType, 63 | context: { path: (string | number)[] }, 64 | ) { 65 | if (accessed.has(data)) return false; 66 | accessed.add(data); 67 | 68 | const item: YShapeItem = { 69 | path: context.path.join("."), 70 | type: getFilterType(data), 71 | key: (context.path.at(-1) ?? "").toString(), 72 | value: data, 73 | }; 74 | 75 | const result = predicate(item); 76 | if (result) { 77 | const path = context.path.join("."); 78 | if (path.length) { 79 | // Skip the root node 80 | selectedMap[path] = data; 81 | } 82 | } 83 | 84 | if (!isYShape(data)) { 85 | return; 86 | } 87 | const parsedShape = parseYShape(data, { showDelta: false }); 88 | if (Array.isArray(parsedShape)) { 89 | const arr = parsedShape as unknown[]; 90 | for (let index = 0; index < arr.length; index++) { 91 | const element = arr[index]; 92 | if (isFilterableType(element)) { 93 | traverseYDoc(element, { 94 | path: [...context.path, index], 95 | }); 96 | } 97 | } 98 | } else if ((isYDoc(data) || isYMap(data)) && isPureObject(parsedShape)) { 99 | for (const key in parsedShape) { 100 | const value = parsedShape[key]; 101 | if (isFilterableType(value)) { 102 | traverseYDoc(value, { 103 | path: [...context.path, key], 104 | }); 105 | } 106 | } 107 | } 108 | } 109 | 110 | traverseYDoc(yDoc, { 111 | path: [], 112 | }); 113 | 114 | return selectedMap; 115 | }; 116 | -------------------------------------------------------------------------------- /src/globals.css: -------------------------------------------------------------------------------- 1 | @import "tailwindcss"; 2 | 3 | @custom-variant dark (&:is(.dark *)); 4 | 5 | @theme { 6 | --color-border: hsl(var(--border)); 7 | --color-input: hsl(var(--input)); 8 | --color-ring: hsl(var(--ring)); 9 | --color-background: hsl(var(--background)); 10 | --color-foreground: hsl(var(--foreground)); 11 | 12 | --color-primary: hsl(var(--primary)); 13 | --color-primary-foreground: hsl(var(--primary-foreground)); 14 | 15 | --color-secondary: hsl(var(--secondary)); 16 | --color-secondary-foreground: hsl(var(--secondary-foreground)); 17 | 18 | --color-destructive: hsl(var(--destructive)); 19 | --color-destructive-foreground: hsl(var(--destructive-foreground)); 20 | 21 | --color-muted: hsl(var(--muted)); 22 | --color-muted-foreground: hsl(var(--muted-foreground)); 23 | 24 | --color-accent: hsl(var(--accent)); 25 | --color-accent-foreground: hsl(var(--accent-foreground)); 26 | 27 | --color-popover: hsl(var(--popover)); 28 | --color-popover-foreground: hsl(var(--popover-foreground)); 29 | 30 | --color-card: hsl(var(--card)); 31 | --color-card-foreground: hsl(var(--card-foreground)); 32 | 33 | --radius-lg: var(--radius); 34 | --radius-md: calc(var(--radius) - 2px); 35 | --radius-sm: calc(var(--radius) - 4px); 36 | 37 | --animate-accordion-down: accordion-down 0.2s ease-out; 38 | --animate-accordion-up: accordion-up 0.2s ease-out; 39 | 40 | @keyframes accordion-down { 41 | from { 42 | height: 0; 43 | } 44 | to { 45 | height: var(--radix-accordion-content-height); 46 | } 47 | } 48 | @keyframes accordion-up { 49 | from { 50 | height: var(--radix-accordion-content-height); 51 | } 52 | to { 53 | height: 0; 54 | } 55 | } 56 | } 57 | 58 | @utility container { 59 | margin-inline: auto; 60 | padding-inline: 2rem; 61 | @media (width >= --theme(--breakpoint-sm)) { 62 | max-width: none; 63 | } 64 | @media (width >= 1400px) { 65 | max-width: 1400px; 66 | } 67 | } 68 | 69 | /* 70 | The default border color has changed to `currentColor` in Tailwind CSS v4, 71 | so we've added these compatibility styles to make sure everything still 72 | looks the same as it did with Tailwind CSS v3. 73 | 74 | If we ever want to remove these styles, we need to add an explicit border 75 | color utility to any element that depends on these defaults. 76 | */ 77 | @layer base { 78 | *, 79 | ::after, 80 | ::before, 81 | ::backdrop, 82 | ::file-selector-button { 83 | border-color: var(--color-gray-200, currentColor); 84 | } 85 | } 86 | 87 | @layer base { 88 | :root { 89 | --background: 0 0% 100%; 90 | --foreground: 20 14.3% 4.1%; 91 | --card: 0 0% 100%; 92 | --card-foreground: 20 14.3% 4.1%; 93 | --popover: 0 0% 100%; 94 | --popover-foreground: 20 14.3% 4.1%; 95 | --primary: 24.6 95% 53.1%; 96 | --primary-foreground: 60 9.1% 97.8%; 97 | --secondary: 60 4.8% 95.9%; 98 | --secondary-foreground: 24 9.8% 10%; 99 | --muted: 60 4.8% 95.9%; 100 | --muted-foreground: 25 5.3% 44.7%; 101 | --accent: 60 4.8% 95.9%; 102 | --accent-foreground: 24 9.8% 10%; 103 | --destructive: 0 84.2% 60.2%; 104 | --destructive-foreground: 60 9.1% 97.8%; 105 | --border: 20 5.9% 90%; 106 | --input: 20 5.9% 90%; 107 | --ring: 24.6 95% 53.1%; 108 | --radius: 0.5rem; 109 | } 110 | 111 | .dark { 112 | --background: 20 14.3% 4.1%; 113 | --foreground: 60 9.1% 97.8%; 114 | --card: 20 14.3% 4.1%; 115 | --card-foreground: 60 9.1% 97.8%; 116 | --popover: 20 14.3% 4.1%; 117 | --popover-foreground: 60 9.1% 97.8%; 118 | --primary: 20.5 90.2% 48.2%; 119 | --primary-foreground: 60 9.1% 97.8%; 120 | --secondary: 12 6.5% 15.1%; 121 | --secondary-foreground: 60 9.1% 97.8%; 122 | --muted: 12 6.5% 15.1%; 123 | --muted-foreground: 24 5.4% 63.9%; 124 | --accent: 12 6.5% 15.1%; 125 | --accent-foreground: 60 9.1% 97.8%; 126 | --destructive: 0 72.2% 50.6%; 127 | --destructive-foreground: 60 9.1% 97.8%; 128 | --border: 12 6.5% 15.1%; 129 | --input: 12 6.5% 15.1%; 130 | --ring: 20.5 90.2% 48.2%; 131 | } 132 | } 133 | 134 | @layer base { 135 | * { 136 | @apply border-border; 137 | } 138 | body { 139 | @apply bg-background text-foreground; 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { type ClassValue, clsx } from "clsx"; 2 | import { twMerge } from "tailwind-merge"; 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)); 6 | } 7 | -------------------------------------------------------------------------------- /src/main.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom/client"; 3 | import { App } from "./app"; 4 | import "./print-build-info"; 5 | 6 | import "./globals.css"; 7 | 8 | ReactDOM.createRoot(document.getElementById("root")!).render( 9 | 10 | 11 | , 12 | ); 13 | -------------------------------------------------------------------------------- /src/print-build-info.ts: -------------------------------------------------------------------------------- 1 | import { isCI } from "~build/ci"; 2 | import { abbreviatedSha, github } from "~build/git"; 3 | import { name, version } from "~build/package"; 4 | import time from "~build/time"; 5 | 6 | const printBuildInfo = () => { 7 | if (process.env.NODE_ENV === "development") { 8 | return; 9 | } 10 | console.group("Build info"); 11 | console.log("Project:", name); 12 | console.log("Build date:", time ? time.toLocaleString() : "Unknown"); 13 | console.log("Environment:", `${process.env.NODE_ENV}${isCI ? "(ci)" : ""}`); 14 | console.log("Version:", `${version}-${abbreviatedSha}`); 15 | console.log( 16 | `This is an open source project, you can view its source code on Github!`, 17 | ); 18 | console.log(`${github}/tree/${abbreviatedSha}`); 19 | console.groupEnd(); 20 | }; 21 | 22 | printBuildInfo(); 23 | -------------------------------------------------------------------------------- /src/providers/blocksuite/provider.ts: -------------------------------------------------------------------------------- 1 | import { DocEngine } from "@blocksuite/sync"; 2 | import * as Y from "yjs"; 3 | import { ConnectProvider } from "../types"; 4 | import { NoopLogger } from "./utils"; 5 | import { WebSocketDocSource } from "./web-socket-doc-source"; 6 | 7 | export class BlocksuiteWebsocketProvider implements ConnectProvider { 8 | doc: Y.Doc; 9 | private docEngine: DocEngine; 10 | 11 | constructor(ws: WebSocket, doc: Y.Doc) { 12 | this.doc = doc; 13 | const docSource = new WebSocketDocSource(ws); 14 | this.docEngine = new DocEngine(doc, docSource, [], new NoopLogger()); 15 | } 16 | 17 | connect() { 18 | this.docEngine.start(); 19 | this.docEngine; 20 | } 21 | 22 | disconnect() { 23 | this.docEngine.forceStop(); 24 | } 25 | 26 | destroy() { 27 | this.disconnect(); 28 | } 29 | 30 | async waitForSynced() { 31 | await this.docEngine.waitForLoadedRootDoc(); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/providers/blocksuite/types.ts: -------------------------------------------------------------------------------- 1 | export type AwarenessMessage = { 2 | channel: "awareness"; 3 | payload: { type: "connect" } | { type: "update"; update: number[] }; 4 | }; 5 | 6 | export type DocMessage = { 7 | channel: "doc"; 8 | payload: 9 | | { 10 | type: "init"; 11 | } 12 | | { 13 | type: "update"; 14 | docId: string; 15 | updates: number[]; 16 | }; 17 | }; 18 | 19 | export type WebSocketMessage = AwarenessMessage | DocMessage; 20 | -------------------------------------------------------------------------------- /src/providers/blocksuite/utils.ts: -------------------------------------------------------------------------------- 1 | // Ported from blocksuite 2 | // 3 | // https://github.com/toeverything/blocksuite/blob/2d5a5f4b2b397af0a8cd96451fd3054d86da66a0/packages/framework/global/src/utils/assert.ts#L17 4 | // https://github.com/toeverything/blocksuite/blob/226e9f4efab9f5243d4dbab9b7d6fa896bd39f1e/packages/framework/global/src/utils/logger.ts#L8 5 | // 6 | // Licensed under the MPL 2.0 7 | 8 | export function assertExists( 9 | val: T | null | undefined, 10 | message: string | Error = "val does not exist", 11 | ): asserts val is T { 12 | if (val === null || val === undefined) { 13 | if (message instanceof Error) { 14 | throw message; 15 | } 16 | throw new Error(message); 17 | } 18 | } 19 | 20 | export class NoopLogger { 21 | debug() {} 22 | 23 | error() {} 24 | 25 | info() {} 26 | 27 | warn() {} 28 | } 29 | 30 | export class ConsoleLogger { 31 | debug(message: string, ...args: unknown[]) { 32 | console.debug(message, ...args); 33 | } 34 | 35 | error(message: string, ...args: unknown[]) { 36 | console.error(message, ...args); 37 | } 38 | 39 | info(message: string, ...args: unknown[]) { 40 | console.info(message, ...args); 41 | } 42 | 43 | warn(message: string, ...args: unknown[]) { 44 | console.warn(message, ...args); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/providers/blocksuite/web-socket-doc-source.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Ported from https://github.com/toeverything/blocksuite/blob/226e9f4efab9f5243d4dbab9b7d6fa896bd39f1e/packages/playground/apps/_common/sync/websocket/doc.ts 3 | * 4 | * License: MPL-2.0 5 | */ 6 | import type { DocSource } from "@blocksuite/sync"; 7 | 8 | import { diffUpdate, encodeStateVectorFromUpdate, mergeUpdates } from "yjs"; 9 | 10 | import type { WebSocketMessage } from "./types"; 11 | import { assertExists } from "./utils"; 12 | 13 | export class WebSocketDocSource implements DocSource { 14 | private _onMessage = (event: MessageEvent) => { 15 | const data = JSON.parse(event.data) as WebSocketMessage; 16 | 17 | if (data.channel !== "doc") return; 18 | 19 | if (data.payload.type === "init") { 20 | for (const [docId, data] of this.docMap) { 21 | this.ws.send( 22 | JSON.stringify({ 23 | channel: "doc", 24 | payload: { 25 | type: "update", 26 | docId, 27 | updates: Array.from(data), 28 | }, 29 | } satisfies WebSocketMessage), 30 | ); 31 | } 32 | return; 33 | } 34 | 35 | const { docId, updates } = data.payload; 36 | const update = this.docMap.get(docId); 37 | if (update) { 38 | this.docMap.set(docId, mergeUpdates([update, new Uint8Array(updates)])); 39 | } else { 40 | this.docMap.set(docId, new Uint8Array(updates)); 41 | } 42 | }; 43 | 44 | docMap = new Map(); 45 | 46 | name = "websocket"; 47 | 48 | constructor(readonly ws: WebSocket) { 49 | this.ws.addEventListener("message", this._onMessage); 50 | 51 | this.ws.send( 52 | JSON.stringify({ 53 | channel: "doc", 54 | payload: { 55 | type: "init", 56 | }, 57 | } satisfies WebSocketMessage), 58 | ); 59 | } 60 | 61 | pull(docId: string, state: Uint8Array) { 62 | const update = this.docMap.get(docId); 63 | if (!update) return null; 64 | 65 | const diff = state.length ? diffUpdate(update, state) : update; 66 | return { data: diff, state: encodeStateVectorFromUpdate(update) }; 67 | } 68 | 69 | push(docId: string, data: Uint8Array) { 70 | const update = this.docMap.get(docId); 71 | if (update) { 72 | this.docMap.set(docId, mergeUpdates([update, data])); 73 | } else { 74 | this.docMap.set(docId, data); 75 | } 76 | 77 | const latest = this.docMap.get(docId); 78 | assertExists(latest); 79 | this.ws.send( 80 | JSON.stringify({ 81 | channel: "doc", 82 | payload: { 83 | type: "update", 84 | docId, 85 | updates: Array.from(latest), 86 | }, 87 | } satisfies WebSocketMessage), 88 | ); 89 | } 90 | 91 | subscribe(cb: (docId: string, data: Uint8Array) => void) { 92 | const abortController = new AbortController(); 93 | this.ws.addEventListener( 94 | "message", 95 | (event: MessageEvent) => { 96 | const data = JSON.parse(event.data) as WebSocketMessage; 97 | 98 | if (data.channel !== "doc" || data.payload.type !== "update") return; 99 | 100 | const { docId, updates } = data.payload; 101 | cb(docId, new Uint8Array(updates)); 102 | }, 103 | { signal: abortController.signal }, 104 | ); 105 | return () => { 106 | abortController.abort(); 107 | }; 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /src/providers/types.ts: -------------------------------------------------------------------------------- 1 | import * as Y from "yjs"; 2 | 3 | export interface ConnectProvider { 4 | doc: Y.Doc; 5 | connect(): void; 6 | disconnect(): void; 7 | waitForSynced(): Promise; 8 | } 9 | -------------------------------------------------------------------------------- /src/providers/websocket.tsx: -------------------------------------------------------------------------------- 1 | import { WebsocketProvider } from "y-websocket"; 2 | import * as Y from "yjs"; 3 | import { ConnectProvider } from "./types"; 4 | 5 | export class WebSocketConnectProvider implements ConnectProvider { 6 | doc: Y.Doc; 7 | private provider: WebsocketProvider; 8 | 9 | constructor(url: string, room: string, doc: Y.Doc) { 10 | this.doc = doc; 11 | this.provider = new WebsocketProvider(url, room, doc); 12 | } 13 | 14 | connect() { 15 | this.provider.connect(); 16 | } 17 | 18 | disconnect() { 19 | this.provider.disconnect(); 20 | this.provider.destroy(); 21 | } 22 | 23 | async waitForSynced() { 24 | return new Promise((resolve) => { 25 | this.provider.once("sync", () => resolve()); 26 | }); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/state/atom-with-listeners.ts: -------------------------------------------------------------------------------- 1 | import { atom, Getter, SetStateAction, Setter, useSetAtom } from "jotai"; 2 | import { useEffect } from "react"; 3 | 4 | // See https://jotai.org/docs/recipes/atom-with-listeners 5 | 6 | type Callback = ( 7 | get: Getter, 8 | set: Setter, 9 | newVal: Value, 10 | prevVal: Value, 11 | ) => void; 12 | 13 | export function atomWithListeners(initialValue: Value) { 14 | const baseAtom = atom(initialValue); 15 | const listenersAtom = atom[]>([]); 16 | const anAtom = atom( 17 | (get) => get(baseAtom), 18 | (get, set, arg: SetStateAction) => { 19 | const prevVal = get(baseAtom); 20 | set(baseAtom, arg); 21 | const newVal = get(baseAtom); 22 | get(listenersAtom).forEach((callback) => { 23 | callback(get, set, newVal, prevVal); 24 | }); 25 | }, 26 | ); 27 | const useListener = (callback: Callback) => { 28 | const setListeners = useSetAtom(listenersAtom); 29 | useEffect(() => { 30 | setListeners((prev) => [...prev, callback]); 31 | return () => 32 | setListeners((prev) => { 33 | const index = prev.indexOf(callback); 34 | return [...prev.slice(0, index), ...prev.slice(index + 1)]; 35 | }); 36 | }, [setListeners, callback]); 37 | }; 38 | return [anAtom, useListener] as const; 39 | } 40 | -------------------------------------------------------------------------------- /src/state/config.ts: -------------------------------------------------------------------------------- 1 | import { useAtom } from "jotai"; 2 | import { atomWithStorage } from "jotai/utils"; 3 | 4 | export type Config = { 5 | parseYDoc: boolean; 6 | showDelta: boolean; 7 | showSize: boolean; 8 | editable: boolean; 9 | }; 10 | const defaultConfig = { 11 | parseYDoc: true, 12 | showDelta: true, 13 | showSize: true, 14 | editable: false, 15 | } satisfies Config; 16 | 17 | export const configAtom = atomWithStorage( 18 | "yjs-playground-config", 19 | defaultConfig, 20 | ); 21 | 22 | export const useConfig = () => { 23 | return useAtom(configAtom); 24 | }; 25 | -------------------------------------------------------------------------------- /src/state/filter.ts: -------------------------------------------------------------------------------- 1 | import { atom, useAtomValue, useSetAtom } from "jotai"; 2 | import { YShapeItem } from "../components/filter-sphere"; 3 | import { filterYDoc } from "../filter-map"; 4 | import { configAtom } from "./config"; 5 | import { yDocAtom } from "./ydoc"; 6 | 7 | const falseFn = () => false; 8 | 9 | const filterPredicateAtom = atom<{ fn: (data: YShapeItem) => boolean }>({ 10 | fn: falseFn, 11 | }); 12 | 13 | export const useUpdateFilterPredicate = () => { 14 | const set = useSetAtom(filterPredicateAtom); 15 | return set; 16 | }; 17 | const hasValidFilterRuleAtom = atom(false); 18 | const filteredYDocAtom = atom((get) => { 19 | const hasValidFilterRule = get(hasValidFilterRuleAtom); 20 | if (!hasValidFilterRule) { 21 | return {}; 22 | } 23 | const yDoc = get(yDocAtom); 24 | const predicate = get(filterPredicateAtom).fn; 25 | const filterMap = filterYDoc(yDoc, predicate); 26 | return filterMap; 27 | }); 28 | const filterCountAtom = atom((get) => { 29 | const data = get(filteredYDocAtom); 30 | return Object.keys(data).length; 31 | }); 32 | 33 | export const useSetHasValidFilterRule = () => { 34 | return useSetAtom(hasValidFilterRuleAtom); 35 | }; 36 | 37 | export const useFilterMap = () => { 38 | return useAtomValue(filteredYDocAtom); 39 | }; 40 | 41 | export const useFilterDataCount = () => { 42 | return useAtomValue(filterCountAtom); 43 | }; 44 | 45 | export const useIsFilterEnabled = () => { 46 | const hasValidFilterRule = useAtomValue(hasValidFilterRuleAtom); 47 | const config = useAtomValue(configAtom); 48 | return config.parseYDoc && hasValidFilterRule; 49 | }; 50 | -------------------------------------------------------------------------------- /src/state/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./config"; 2 | export * from "./filter"; 3 | export * from "./undo"; 4 | export * from "./ydoc"; 5 | -------------------------------------------------------------------------------- /src/state/undo.ts: -------------------------------------------------------------------------------- 1 | import { atom, useAtomValue } from "jotai"; 2 | import { useEffect, useState } from "react"; 3 | import * as Y from "yjs"; 4 | 5 | const TRACK_ALL_ORIGINS = Symbol(); 6 | 7 | export function createUndoManager(doc: Y.Doc) { 8 | const undoManager = new Y.UndoManager([], { 9 | doc, 10 | trackedOrigins: new Set([TRACK_ALL_ORIGINS]), 11 | }); 12 | const updateScope = (d: Y.Doc) => { 13 | // The UndoManager can only track shared types that are created 14 | // See https://discuss.yjs.dev/t/global-document-undo-manager/2555 15 | const keys = Array.from(d.share.keys()); 16 | if (!keys.length) return; 17 | const scope = keys.map((key) => d.get(key)); 18 | undoManager.addToScope(scope); 19 | // undoManager.addTrackedOrigin(origin); 20 | }; 21 | const beforeTransactionCallback = (transaction: Y.Transaction) => { 22 | // Try to track all origins 23 | // Workaround for https://github.com/yjs/yjs/issues/624 24 | // @ts-expect-error backup origin 25 | transaction.__origin = transaction.origin; 26 | transaction.origin = TRACK_ALL_ORIGINS; 27 | // Track all shared types before running UndoManager.afterTransactionHandler 28 | updateScope(transaction.doc); 29 | }; 30 | 31 | // see https://github.com/yjs/yjs/blob/7422b18e87cb41ac675c17ea09dfa832253b6cd2/src/utils/UndoManager.js#L268 32 | doc.on("beforeTransaction", beforeTransactionCallback); 33 | 34 | // Fix undo manager not tracking subdocs 35 | // doc.on("subdocs", ({ added }) => { 36 | // for (const subDoc of added) { 37 | // subDoc.on("beforeTransaction", beforeTransactionCallback); 38 | // } 39 | // }); 40 | return undoManager; 41 | } 42 | 43 | const defaultUndoManager = createUndoManager(new Y.Doc()); 44 | export const undoManagerAtom = atom(defaultUndoManager); 45 | 46 | export const useUndoManager = () => { 47 | const undoManager = useAtomValue(undoManagerAtom); 48 | const [state, setState] = useState({ 49 | canUndo: undoManager.canUndo(), 50 | canRedo: undoManager.canRedo(), 51 | undoStackSize: undoManager.undoStack.length, 52 | redoStackSize: undoManager.redoStack.length, 53 | }); 54 | 55 | // TODO use useSyncExternalStore 56 | useEffect(() => { 57 | const callback = () => { 58 | setState({ 59 | canUndo: undoManager.canUndo(), 60 | canRedo: undoManager.canRedo(), 61 | undoStackSize: undoManager.undoStack.length, 62 | redoStackSize: undoManager.redoStack.length, 63 | }); 64 | }; 65 | callback(); 66 | 67 | undoManager.on("stack-item-added", callback); 68 | undoManager.on("stack-item-popped", callback); 69 | return () => { 70 | undoManager.off("stack-item-added", callback); 71 | undoManager.off("stack-item-popped", callback); 72 | }; 73 | }, [undoManager]); 74 | 75 | return { 76 | undoManager, 77 | ...state, 78 | }; 79 | }; 80 | -------------------------------------------------------------------------------- /src/state/ydoc.ts: -------------------------------------------------------------------------------- 1 | import { atom, Setter, useAtom } from "jotai"; 2 | import * as Y from "yjs"; 3 | import { atomWithListeners } from "./atom-with-listeners"; 4 | import { createUndoManager, undoManagerAtom } from "./undo"; 5 | 6 | const [uploadAtom, useUploadListener] = atomWithListeners(0); 7 | const [downloadAtom, useDownloadListener] = atomWithListeners(0); 8 | export { useDownloadListener, useUploadListener }; 9 | 10 | function connectStatusIndicator(yDoc: Y.Doc, set: Setter) { 11 | yDoc.on("beforeTransaction", (tr) => { 12 | // Cation: The origin will be overwritten by the UndoManager to `TRACK_ALL_ORIGINS` 13 | const origin = tr.origin; 14 | if (origin === null || origin instanceof Y.UndoManager) { 15 | set(uploadAtom, (prev) => prev + 1); 16 | } else { 17 | set(downloadAtom, (prev) => prev + 1); 18 | } 19 | }); 20 | 21 | yDoc.on("subdocs", ({ added }) => { 22 | for (const subDoc of added) { 23 | subDoc.on("beforeTransaction", (tr) => { 24 | const origin = tr.origin; 25 | if (origin === null || origin instanceof Y.UndoManager) { 26 | set(uploadAtom, (prev) => prev + 1); 27 | } else { 28 | set(downloadAtom, (prev) => prev + 1); 29 | } 30 | }); 31 | } 32 | }); 33 | } 34 | 35 | const defaultYDoc = new Y.Doc(); 36 | 37 | export const yDocAtom = atom(defaultYDoc, (get, set, newDoc: Y.Doc) => { 38 | if (newDoc === get(yDocAtom)) return; 39 | get(undoManagerAtom).destroy(); 40 | connectStatusIndicator(newDoc, set); 41 | const undoManager = createUndoManager(newDoc); 42 | set(undoManagerAtom, undoManager); 43 | get(yDocAtom).destroy(); 44 | set(yDocAtom, newDoc); 45 | }); 46 | 47 | export const useYDoc = () => { 48 | return useAtom(yDocAtom); 49 | }; 50 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import type { Path } from "@textea/json-viewer"; 2 | import * as Y from "yjs"; 3 | 4 | const decoders = [ 5 | { 6 | name: "binary update", 7 | decode: async (file: File) => { 8 | return new Uint8Array(await file.arrayBuffer()); 9 | }, 10 | }, 11 | { 12 | name: "binary string", 13 | decode: async (file: File) => { 14 | const text = await file.text(); 15 | // Parse binary string 16 | // `Buffer.from(encodeUpdate).toString("binary")` 17 | return Uint8Array.from(text, (c) => c.charCodeAt(0)); 18 | }, 19 | }, 20 | // TODO handle base64 encoding 21 | // https://docs.yjs.dev/api/document-updates#example-base64-encoding 22 | ]; 23 | 24 | export async function fileToYDoc(file: File) { 25 | for (const decoder of decoders) { 26 | try { 27 | const yDocUpdate = await decoder.decode(file); 28 | const newYDoc = new Y.Doc(); 29 | Y.applyUpdate(newYDoc, yDocUpdate); 30 | Y.logUpdate(yDocUpdate); 31 | return newYDoc; 32 | } catch (error) { 33 | console.warn(`Failed to decode ${decoder.name}`, error); 34 | } 35 | } 36 | throw new Error("Failed to decode file"); 37 | } 38 | 39 | export function getPathValue( 40 | obj: T, 41 | path: Path, 42 | getter: (obj: T, key: string | number) => unknown = (obj, key) => 43 | (obj as any)[key], 44 | ) { 45 | return path.reduce( 46 | (acc, key) => getter(acc, key) as any, 47 | obj, 48 | ) as unknown as R; 49 | } 50 | 51 | export const and = 52 | boolean>(...fnArray: NoInfer[]) => 53 | (...args: Parameters) => 54 | fnArray.every((fn) => fn(...args)); 55 | 56 | export const or = 57 | boolean>(...fnArray: NoInfer[]) => 58 | (...args: Parameters) => 59 | fnArray.some((fn) => fn(...args)); 60 | 61 | export function getHumanReadablePath(path: Path) { 62 | return ["root", ...path].join("."); 63 | } 64 | 65 | /** 66 | * This function should never be called. If it is called, it means that the 67 | * code has reached a point that should be unreachable. 68 | * 69 | * @example 70 | * ```ts 71 | * function f(val: 'a' | 'b') { 72 | * if (val === 'a') { 73 | * return 1; 74 | * } else if (val === 'b') { 75 | * return 2; 76 | * } 77 | * unreachable(val); 78 | * ``` 79 | */ 80 | export function unreachable( 81 | _val: never, 82 | message = "Unreachable code reached", 83 | ): never { 84 | throw new Error(message); 85 | } 86 | -------------------------------------------------------------------------------- /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // Polyfill for `showOpenFilePicker` API 5 | // See https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/wicg-file-system-access/index.d.ts 6 | // See also https://caniuse.com/?search=showOpenFilePicker 7 | interface OpenFilePickerOptions { 8 | /** 9 | * A boolean value that defaults to `false`. 10 | * By default the picker should include an option to not apply any file type filters (instigated with the type option below). Setting this option to true means that option is not available. 11 | */ 12 | excludeAcceptAllOption?: boolean | undefined; 13 | /** 14 | * By specifying an ID, the browser can remember different directories for different IDs. If the same ID is used for another picker, the picker opens in the same directory. 15 | */ 16 | id?: string | undefined; 17 | /** 18 | * A boolean value that defaults to `false`. 19 | * When set to true multiple files may be selected. 20 | */ 21 | multiple?: boolean | undefined; 22 | /** 23 | * A FileSystemHandle or a well known directory ("desktop", "documents", "downloads", "music", "pictures", or "videos") to open the dialog in. 24 | */ 25 | startIn?: 26 | | "documents" 27 | | "desktop" 28 | | "downloads" 29 | | "home" 30 | | "pictures" 31 | | "videos" 32 | | "music" 33 | | "custom"; 34 | /** 35 | * An Array of allowed file types to pick. Each item is an object with the following options. 36 | */ 37 | types?: 38 | | { 39 | /** 40 | * An optional description of the category of files types allowed. Defaults to an empty string. 41 | */ 42 | description?: string | undefined; 43 | /** 44 | * An Object with the keys set to the MIME type and the values an Array of file extensions (see below for an example). 45 | */ 46 | accept: Record; 47 | }[] 48 | | undefined; 49 | } 50 | 51 | export declare global { 52 | interface Window { 53 | /** 54 | * Window API: showOpenFilePicker 55 | * 56 | * [MDN](https://developer.mozilla.org/en-US/docs/Web/API/Window/showOpenFilePicker) 57 | */ 58 | showOpenFilePicker: ( 59 | options?: OpenFilePickerOptions, 60 | ) => Promise; 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/y-shape.ts: -------------------------------------------------------------------------------- 1 | import type { Path } from "@textea/json-viewer"; 2 | import * as Y from "yjs"; 3 | import { getPathValue, or, unreachable } from "./utils"; 4 | 5 | /** 6 | * Guess AbstractType 7 | * 8 | * Don't use it in production! 9 | * See https://github.com/yjs/yjs/issues/563 10 | */ 11 | export function guessType(abstractType: Y.AbstractType) { 12 | if (abstractType.constructor === Y.Array) { 13 | return Y.Array; 14 | } 15 | if (abstractType.constructor === Y.Map) { 16 | return Y.Map; 17 | } 18 | if (abstractType._map.size) { 19 | return Y.Map; 20 | } 21 | if (abstractType._length > 0) { 22 | const firstItem = abstractType._first; 23 | if (!firstItem) { 24 | console.error( 25 | "The length is greater than 0 but _first is not set", 26 | abstractType, 27 | ); 28 | return Y.AbstractType; 29 | } 30 | 31 | // Try distinguish between Y.Text and Y.Array 32 | // Only check the first element, it's unreliable! 33 | if ( 34 | firstItem.content instanceof Y.ContentString || 35 | firstItem.content instanceof Y.ContentFormat 36 | ) { 37 | return Y.Text; 38 | } 39 | return Y.Array; 40 | } 41 | return Y.AbstractType; 42 | } 43 | 44 | export function getYTypeName(value: Y.Doc | Y.AbstractType) { 45 | if (value instanceof Y.Doc) { 46 | return "YDoc"; 47 | } 48 | if (value instanceof Y.Map) { 49 | return "YMap"; 50 | } 51 | if (value instanceof Y.Array) { 52 | return "YArray"; 53 | } 54 | if (value instanceof Y.Text) { 55 | return "YText"; 56 | } 57 | if (value instanceof Y.XmlElement) { 58 | return "YXmlElement"; 59 | } 60 | if (value instanceof Y.XmlFragment) { 61 | return "YXmlFragment"; 62 | } 63 | if (value instanceof Y.AbstractType) { 64 | return "YAbstractType"; 65 | } 66 | // return "Y." + value.constructor.name; 67 | console.error("Unknown Yjs type", value); 68 | throw new Error("Unknown Yjs type"); 69 | } 70 | 71 | export function isYDoc(value: unknown): value is Y.Doc { 72 | return value instanceof Y.Doc; 73 | } 74 | 75 | export function isYMap(value: unknown): value is Y.Map { 76 | return value instanceof Y.Map; 77 | } 78 | 79 | export function isYArray(value: unknown): value is Y.Array { 80 | return value instanceof Y.Array; 81 | } 82 | 83 | export function isYText(value: unknown): value is Y.Text { 84 | return value instanceof Y.Text; 85 | } 86 | 87 | export function isYXmlElement(value: unknown): value is Y.XmlElement { 88 | return value instanceof Y.XmlElement; 89 | } 90 | 91 | export function isYXmlFragment(value: unknown): value is Y.XmlFragment { 92 | return value instanceof Y.XmlFragment; 93 | } 94 | 95 | export function isYXmlText(value: unknown): value is Y.XmlText { 96 | return value instanceof Y.XmlText; 97 | } 98 | 99 | /** 100 | * Check if the value is a Y.AbstractType. 101 | * 102 | * **Note: Y.Doc is not a Y.AbstractType.** 103 | * 104 | * See also {@link isYShape} 105 | */ 106 | export function isYAbstractType( 107 | value: unknown, 108 | ): value is Y.AbstractType { 109 | return value instanceof Y.AbstractType; 110 | } 111 | 112 | /** 113 | * Check if the value is a Yjs type. It includes Y.Doc and Y.AbstractType. 114 | * 115 | * See also {@link isYAbstractType} 116 | */ 117 | export function isYShape( 118 | value: unknown, 119 | ): value is Y.AbstractType | Y.Doc { 120 | return or(isYDoc, isYAbstractType)(value); 121 | } 122 | 123 | export function parseYShape( 124 | value: Y.AbstractType | Y.Doc, 125 | { showDelta }: { showDelta: boolean } = { showDelta: true }, 126 | ): unknown[] | Record | string | Y.AbstractType { 127 | if (isYDoc(value)) { 128 | const yDoc = value; 129 | const keys = Array.from(yDoc.share.keys()); 130 | const obj = keys.reduce( 131 | (acc, key) => { 132 | const value = yDoc.get(key); 133 | const type = guessType(value); 134 | acc[key] = yDoc.get(key, type); 135 | return acc; 136 | }, 137 | {} as Record, 138 | ); 139 | return obj; 140 | } 141 | 142 | if (isYMap(value)) { 143 | const yMap = value; 144 | const keys = Array.from(yMap.keys()); 145 | const obj = keys.reduce( 146 | (acc, key) => { 147 | acc[key] = yMap.get(key); 148 | return acc; 149 | }, 150 | {} as Record, 151 | ); 152 | return obj; 153 | } 154 | 155 | if (isYArray(value)) { 156 | const yArray = value; 157 | const arr = yArray.toArray(); 158 | return arr; 159 | } 160 | 161 | if (isYText(value)) { 162 | if (showDelta) { 163 | return value.toDelta(); 164 | } 165 | return value.toString(); 166 | } 167 | 168 | if (isYXmlElement(value)) { 169 | return { 170 | nodeName: value.nodeName, 171 | attributes: value.getAttributes(), 172 | "toString()": value.toString(), 173 | }; 174 | } 175 | 176 | if (isYXmlFragment(value)) { 177 | return value.toJSON(); 178 | } 179 | 180 | if (isYXmlText(value)) { 181 | if (showDelta) { 182 | return value.toDelta(); 183 | } 184 | return value.toString(); 185 | } 186 | 187 | return value; 188 | } 189 | 190 | export const NATIVE_UNIQ_IDENTIFIER = "$yjs:internal:native$"; 191 | 192 | export function yShapeToJSON( 193 | value: any, 194 | ): object | string | number | boolean | null | undefined { 195 | if (!isYShape(value)) { 196 | return value; 197 | } 198 | const typeName = getYTypeName(value); 199 | 200 | if (isYDoc(value)) { 201 | const yDoc = value; 202 | const keys = Array.from(yDoc.share.keys()); 203 | const obj = keys.reduce( 204 | (acc, key) => { 205 | const val = yDoc.get(key); 206 | const type = guessType(val); 207 | acc[key] = yShapeToJSON(yDoc.get(key, type)); 208 | return acc; 209 | }, 210 | { 211 | [NATIVE_UNIQ_IDENTIFIER]: typeName, 212 | } as Record, 213 | ); 214 | return obj; 215 | } 216 | if (isYMap(value)) { 217 | const yMap = value; 218 | const keys = Array.from(yMap.keys()); 219 | const obj = keys.reduce( 220 | (acc, key) => { 221 | acc[key] = yShapeToJSON(yMap.get(key)); 222 | return acc; 223 | }, 224 | { 225 | [NATIVE_UNIQ_IDENTIFIER]: typeName, 226 | } as Record, 227 | ); 228 | return obj; 229 | } 230 | if (isYArray(value)) { 231 | return { 232 | [NATIVE_UNIQ_IDENTIFIER]: typeName, 233 | value: value.toArray().map((value) => yShapeToJSON(value)), 234 | }; 235 | } 236 | if (isYText(value)) { 237 | return { 238 | [NATIVE_UNIQ_IDENTIFIER]: typeName, 239 | delta: value.toDelta(), 240 | }; 241 | } 242 | if (isYXmlElement(value)) { 243 | return { 244 | [NATIVE_UNIQ_IDENTIFIER]: typeName, 245 | nodeName: value.nodeName, 246 | attributes: value.getAttributes(), 247 | }; 248 | } 249 | if (isYXmlFragment(value)) { 250 | return { 251 | [NATIVE_UNIQ_IDENTIFIER]: typeName, 252 | value: value.toJSON(), 253 | }; 254 | } 255 | if (isYAbstractType(value)) { 256 | console.error("Unsupported Yjs type: " + typeName, value); 257 | throw new Error("Unsupported Yjs type: " + typeName); 258 | } 259 | console.error("Unknown Yjs type", value); 260 | unreachable(value, "Unknown Yjs type"); 261 | } 262 | 263 | export function getYTypeFromPath(yDoc: Y.Doc, path: Path): unknown { 264 | return getPathValue(yDoc, path, (obj: unknown, key) => { 265 | if (isYDoc(obj)) { 266 | const keyExists = obj.share.has(key + ""); 267 | if (!keyExists) { 268 | return undefined; 269 | } 270 | return obj.get(key + ""); 271 | } 272 | if (isYMap(obj)) { 273 | return obj.get(key + ""); 274 | } 275 | if (isYArray(obj)) { 276 | if (typeof key !== "number") { 277 | console.error("Invalid key", path, key); 278 | return undefined; 279 | } 280 | return obj.get(key); 281 | } 282 | if (obj === undefined) { 283 | return undefined; 284 | } 285 | return (obj as any)[key]; 286 | }); 287 | } 288 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "lib": ["ES2020", "DOM", "DOM.Iterable", "ESNext"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": true, 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "noEmit": true, 15 | "jsx": "react-jsx", 16 | 17 | /* Linting */ 18 | "strict": true, 19 | "noUnusedLocals": true, 20 | "noUnusedParameters": true, 21 | "noFallthroughCasesInSwitch": true, 22 | 23 | "baseUrl": ".", 24 | "paths": { 25 | "@/*": ["./src/*"] 26 | } 27 | }, 28 | "include": ["src"], 29 | "references": [{ "path": "./tsconfig.node.json" }] 30 | } 31 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "skipLibCheck": true, 5 | "module": "ESNext", 6 | "moduleResolution": "bundler", 7 | "allowSyntheticDefaultImports": true, 8 | "strict": true 9 | }, 10 | "include": ["vite.config.ts"] 11 | } 12 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import react from "@vitejs/plugin-react"; 2 | import path from "path"; 3 | import Info from "unplugin-info/vite"; 4 | import { defineConfig } from "vite"; 5 | 6 | // https://vitejs.dev/config/ 7 | export default defineConfig({ 8 | plugins: [react(), Info()], 9 | base: "./", 10 | resolve: { 11 | alias: { 12 | "@": path.resolve(__dirname, "./src"), 13 | }, 14 | }, 15 | build: { 16 | sourcemap: true, 17 | }, 18 | }); 19 | --------------------------------------------------------------------------------