├── .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 | [](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 | 
11 | - Inspect the Yjs document model
12 | 
13 | - Advanced Filters
14 | 
15 | - Edit the Yjs document model.
16 | 
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 |
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 |
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 |
76 | );
77 | }
78 |
79 | if (connectState === "connected") {
80 | return (
81 |
88 | );
89 | }
90 |
91 | return (
92 |
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 |
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 |
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 ;
77 | },
78 | Input: ({ onChange, ...props }) => {
79 | const handleChange = useCallback(
80 | (e: ChangeEvent) => {
81 | onChange?.(e.target.value);
82 | },
83 | [onChange],
84 | );
85 | return ;
86 | },
87 | Select: ({ value, onChange, options = [], className, disabled }) => {
88 | const selectedIdx = options.findIndex((option) => option.value === value);
89 | const handleChange = useCallback(
90 | (value: string) => {
91 | const index = Number(value);
92 | onChange?.(options[index].value);
93 | },
94 | [options, onChange],
95 | );
96 | return (
97 |
113 | );
114 | },
115 | MultipleSelect: ({ value = [], options = [], onChange }) => {
116 | const selectedIndices = value.map((val) =>
117 | String(options.findIndex((option) => option.value === val)),
118 | );
119 | const handleChange = useCallback(
120 | (newVal: string[]) => {
121 | const selectedOptions = Array.from(newVal, (option) => {
122 | const index = Number(option);
123 | const selectedOption = options[index];
124 | if (!selectedOption) return;
125 | return selectedOption.value;
126 | }).filter((i) => i !== undefined);
127 | onChange?.(selectedOptions);
128 | },
129 | [options, onChange],
130 | );
131 |
132 | return (
133 | ({
135 | label: option.label,
136 | value: String(index),
137 | }))}
138 | selected={selectedIndices}
139 | onChange={handleChange}
140 | />
141 | );
142 | },
143 | } satisfies Partial;
144 |
145 | export type SingleFilterRuleProps = {
146 | rule: SingleFilter;
147 | };
148 |
149 | const createFilterWithPreset = () =>
150 | createSingleFilter({
151 | name: "equals",
152 | path: ["type"],
153 | });
154 |
155 | export const createFlattenFilterGroup = () =>
156 | createFilterGroup({
157 | op: "or",
158 | conditions: [
159 | createFilterGroup({
160 | op: "and",
161 | conditions: [createFilterWithPreset()],
162 | }),
163 | ],
164 | });
165 |
166 | const SingleFilterView = ({ rule }: SingleFilterRuleProps) => {
167 | const {
168 | ruleState: { isLastRule, isValid, parentGroup },
169 | removeRule,
170 | appendRule,
171 | } = useFilterRule(rule);
172 | const { rootRule, numberOfRules, setRootRule } = useRootRule();
173 | const { Button: ButtonView } = useView("components");
174 | const { FieldSelect, FilterSelect, FilterDataInput } = useView("templates");
175 |
176 | const isLastRuleInGroup =
177 | isLastRule &&
178 | rootRule.conditions[rootRule.conditions.length - 1]?.id === parentGroup.id;
179 |
180 | return (
181 |
182 |
183 |
184 |
185 |
186 |
{
188 | appendRule();
189 | }}
190 | >
191 | And
192 |
193 | {isLastRuleInGroup && (
194 |
{
196 | rootRule.conditions.push(
197 | createFilterGroup({
198 | op: "and",
199 | conditions: [createFilterWithPreset()],
200 | }),
201 | );
202 | setRootRule(rootRule);
203 | }}
204 | >
205 | Or
206 |
207 | )}
208 | {isValid ? null : (
209 |
210 |
211 |
212 | )}
213 | {numberOfRules > 1 && (
214 |
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 |
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 |
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 |
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 |
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