├── .tool-versions ├── test ├── test_helper.exs ├── y_phoenix_web │ ├── controllers │ │ ├── page_controller_test.exs │ │ ├── error_json_test.exs │ │ └── error_html_test.exs │ └── channels │ │ └── y_doc_room_channel_test.exs ├── y_phoenix │ └── my_y_ecto.exs └── support │ ├── channel_case.ex │ ├── conn_case.ex │ └── data_case.ex ├── priv ├── repo │ ├── migrations │ │ ├── .formatter.exs │ │ └── 20240726221235_create_yjs_writings.exs │ └── seeds.exs ├── static │ ├── favicon.ico │ ├── robots.txt │ ├── index.html │ ├── vite.svg │ └── images │ │ └── logo.svg └── gettext │ ├── errors.pot │ └── en │ └── LC_MESSAGES │ └── errors.po ├── lib ├── y_phoenix │ ├── mailer.ex │ ├── repo.ex │ ├── yjs_writings.ex │ ├── my_y_ecto.ex │ ├── application.ex │ └── y_ecto.ex ├── y_phoenix_web │ ├── controllers │ │ ├── page_html │ │ │ ├── lexical.html.heex │ │ │ ├── tiptap.html.heex │ │ │ ├── jsdraw.html.heex │ │ │ ├── excalidraw.html.heex │ │ │ ├── blocknote.html.heex │ │ │ ├── prosemirror.html.heex │ │ │ └── quill.html.heex │ │ ├── page_html.ex │ │ ├── error_json.ex │ │ ├── error_html.ex │ │ └── page_controller.ex │ ├── components │ │ ├── layouts │ │ │ ├── root.html.heex │ │ │ └── app.html.heex │ │ └── layouts.ex │ ├── gettext.ex │ ├── channels │ │ ├── presence.ex │ │ ├── user_socket.ex │ │ ├── y_doc_socket.ex │ │ ├── y_doc_room_channel.ex │ │ └── doc_server.ex │ ├── router.ex │ ├── endpoint.ex │ └── telemetry.ex ├── y_phoenix.ex └── y_phoenix_web.ex ├── assets ├── css │ └── app.css ├── biome.json ├── js │ ├── quill │ │ ├── package.json │ │ ├── quill.ts │ │ └── package-lock.json │ ├── excalidraw │ │ ├── package.json │ │ ├── excalidraw.tsx │ │ └── y-excalidraw.ts │ ├── lexical │ │ ├── package.json │ │ └── lexical.tsx │ ├── js-draw │ │ ├── package.json │ │ ├── js-draw.tsx │ │ ├── js-draw-cursor.ts │ │ ├── y-js-draw.tsx │ │ └── package-lock.json │ ├── blocknote │ │ ├── package.json │ │ └── blocknote.tsx │ ├── prosemirror │ │ ├── package.json │ │ ├── prosemirror.tsx │ │ └── package-lock.json │ └── tiptap │ │ ├── package.json │ │ ├── tiptap.scss │ │ └── tiptap.tsx ├── package.json ├── build.js └── tailwind.config.js ├── .formatter.exs ├── npm └── y-phoenix-channel │ ├── tsup.config.ts │ ├── tsconfig.json │ ├── .gitignore │ ├── README.md │ ├── package.json │ └── src │ └── y-phoenix-channel.ts ├── README.md ├── renovate.json ├── .github └── workflows │ ├── deploy.yml │ └── elixir.yml ├── config ├── prod.exs ├── test.exs ├── config.exs ├── dev.exs └── runtime.exs ├── .gitignore ├── LICENSE ├── mix.exs └── mix.lock /.tool-versions: -------------------------------------------------------------------------------- 1 | erlang 27.3.4 2 | elixir 1.19.4 3 | nodejs 24.11.1 4 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | Ecto.Adapters.SQL.Sandbox.mode(YPhoenix.Repo, :manual) 3 | -------------------------------------------------------------------------------- /priv/repo/migrations/.formatter.exs: -------------------------------------------------------------------------------- 1 | [ 2 | import_deps: [:ecto_sql], 3 | inputs: ["*.exs"] 4 | ] 5 | -------------------------------------------------------------------------------- /priv/static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/satoren/y-phoenix-channel/HEAD/priv/static/favicon.ico -------------------------------------------------------------------------------- /lib/y_phoenix/mailer.ex: -------------------------------------------------------------------------------- 1 | defmodule YPhoenix.Mailer do 2 | use Swoosh.Mailer, otp_app: :y_phoenix 3 | end 4 | -------------------------------------------------------------------------------- /lib/y_phoenix/repo.ex: -------------------------------------------------------------------------------- 1 | defmodule YPhoenix.Repo do 2 | use Ecto.Repo, 3 | otp_app: :y_phoenix, 4 | adapter: Ecto.Adapters.Postgres 5 | end 6 | -------------------------------------------------------------------------------- /assets/css/app.css: -------------------------------------------------------------------------------- 1 | @import "tailwindcss/base"; 2 | @import "tailwindcss/components"; 3 | @import "tailwindcss/utilities"; 4 | 5 | /* This file is for your main application CSS */ 6 | -------------------------------------------------------------------------------- /lib/y_phoenix_web/controllers/page_html/lexical.html.heex: -------------------------------------------------------------------------------- 1 | <.flash_group flash={@flash} /> 2 | 4 |
5 | -------------------------------------------------------------------------------- /priv/static/robots.txt: -------------------------------------------------------------------------------- 1 | # See https://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file 2 | # 3 | # To ban all spiders from the entire site uncomment the next two lines: 4 | # User-agent: * 5 | # Disallow: / 6 | -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | [ 2 | import_deps: [:ecto, :ecto_sql, :phoenix], 3 | subdirectories: ["priv/*/migrations"], 4 | plugins: [Phoenix.LiveView.HTMLFormatter], 5 | inputs: ["*.{heex,ex,exs}", "{config,lib,test}/**/*.{heex,ex,exs}", "priv/*/seeds.exs"] 6 | ] 7 | -------------------------------------------------------------------------------- /lib/y_phoenix_web/controllers/page_html/tiptap.html.heex: -------------------------------------------------------------------------------- 1 | <.flash_group flash={@flash} /> 2 | 3 | 5 |
6 | -------------------------------------------------------------------------------- /test/y_phoenix_web/controllers/page_controller_test.exs: -------------------------------------------------------------------------------- 1 | defmodule YPhoenixWeb.PageControllerTest do 2 | use YPhoenixWeb.ConnCase 3 | 4 | test "GET /", %{conn: conn} do 5 | conn = get(conn, ~p"/") 6 | assert html_response(conn, 200) =~ "" 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /lib/y_phoenix_web/controllers/page_html/jsdraw.html.heex: -------------------------------------------------------------------------------- 1 | <.flash_group flash={@flash} /> 2 | 3 | 4 | 6 |
7 | -------------------------------------------------------------------------------- /lib/y_phoenix_web/controllers/page_html/excalidraw.html.heex: -------------------------------------------------------------------------------- 1 | <.flash_group flash={@flash} /> 2 | 3 | 5 |
6 | -------------------------------------------------------------------------------- /lib/y_phoenix_web/controllers/page_html/blocknote.html.heex: -------------------------------------------------------------------------------- 1 | <.flash_group flash={@flash} /> 2 | 3 | 4 | 6 |
7 | -------------------------------------------------------------------------------- /lib/y_phoenix_web/controllers/page_html/prosemirror.html.heex: -------------------------------------------------------------------------------- 1 | <.flash_group flash={@flash} /> 2 | 3 | 5 |
6 | -------------------------------------------------------------------------------- /assets/biome.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./node_modules/@biomejs/biome/configuration_schema.json", 3 | "linter": { 4 | "enabled": true, 5 | "rules": { "recommended": true } 6 | }, 7 | "formatter": { 8 | "enabled": true, 9 | "indentStyle": "space" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /npm/y-phoenix-channel/tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'tsup' 2 | 3 | export default defineConfig({ 4 | entry: ['src/y-phoenix-channel.ts'], 5 | splitting: false, 6 | sourcemap: true, 7 | clean: true, 8 | minify: false, 9 | dts: true, 10 | format: ['esm', 'cjs'] 11 | }) -------------------------------------------------------------------------------- /lib/y_phoenix.ex: -------------------------------------------------------------------------------- 1 | defmodule YPhoenix do 2 | @moduledoc """ 3 | YPhoenix keeps the contexts that define your domain 4 | and business logic. 5 | 6 | Contexts are also responsible for managing your data, regardless 7 | if it comes from the database, an external API or others. 8 | """ 9 | end 10 | -------------------------------------------------------------------------------- /lib/y_phoenix_web/controllers/page_html/quill.html.heex: -------------------------------------------------------------------------------- 1 | <.flash_group flash={@flash} /> 2 | 3 | 4 | 5 | 7 |
8 | -------------------------------------------------------------------------------- /lib/y_phoenix_web/controllers/page_html.ex: -------------------------------------------------------------------------------- 1 | defmodule YPhoenixWeb.PageHTML do 2 | @moduledoc """ 3 | This module contains pages rendered by PageController. 4 | 5 | See the `page_html` directory for all templates available. 6 | """ 7 | use YPhoenixWeb, :html 8 | 9 | embed_templates "page_html/*" 10 | end 11 | -------------------------------------------------------------------------------- /test/y_phoenix_web/channels/y_doc_room_channel_test.exs: -------------------------------------------------------------------------------- 1 | defmodule YPhoenixWeb.YDocRoomChannelTest do 2 | use YPhoenixWeb.ChannelCase 3 | 4 | setup do 5 | {:ok, _, socket} = 6 | YPhoenixWeb.UserSocket 7 | |> socket("user_id", %{some: :assign}) 8 | |> subscribe_and_join(YPhoenixWeb.YDocRoomChannel, "y_doc_room:lobby") 9 | 10 | %{socket: socket} 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /npm/y-phoenix-channel/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "module": "NodeNext", 5 | "declaration": true, 6 | "outDir": "dist", 7 | "rootDir": "src", 8 | "strict": true, 9 | "esModuleInterop": true, 10 | "skipLibCheck": true, 11 | "moduleResolution": "nodenext", 12 | "isolatedModules": true 13 | }, 14 | "include": ["src/**/*"] 15 | } -------------------------------------------------------------------------------- /priv/repo/seeds.exs: -------------------------------------------------------------------------------- 1 | # Script for populating the database. You can run it as: 2 | # 3 | # mix run priv/repo/seeds.exs 4 | # 5 | # Inside the script, you can read and write to any of your 6 | # repositories directly: 7 | # 8 | # YPhoenix.Repo.insert!(%YPhoenix.SomeSchema{}) 9 | # 10 | # We recommend using the bang functions (`insert!`, `update!` 11 | # and so on) as they will fail if something goes wrong. 12 | -------------------------------------------------------------------------------- /priv/repo/migrations/20240726221235_create_yjs_writings.exs: -------------------------------------------------------------------------------- 1 | defmodule YPhoenix.Repo.Migrations.CreateYjsWritings do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table("yjs-writings") do 6 | add :docName, :string 7 | add :value, :binary 8 | add :version, :string 9 | 10 | timestamps(type: :utc_datetime) 11 | end 12 | 13 | create index("yjs-writings", [:docName, :version]) 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /test/y_phoenix_web/controllers/error_json_test.exs: -------------------------------------------------------------------------------- 1 | defmodule YPhoenixWeb.ErrorJSONTest do 2 | use YPhoenixWeb.ConnCase, async: true 3 | 4 | test "renders 404" do 5 | assert YPhoenixWeb.ErrorJSON.render("404.json", %{}) == %{errors: %{detail: "Not Found"}} 6 | end 7 | 8 | test "renders 500" do 9 | assert YPhoenixWeb.ErrorJSON.render("500.json", %{}) == 10 | %{errors: %{detail: "Internal Server Error"}} 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /npm/y-phoenix-channel/.gitignore: -------------------------------------------------------------------------------- 1 | # Build outputs 2 | dist/ 3 | build/ 4 | lib/ 5 | *.tsbuildinfo 6 | 7 | # Dependency packages 8 | node_modules/ 9 | 10 | # Log files 11 | npm-debug.log* 12 | yarn-debug.log* 13 | yarn-error.log* 14 | pnpm-debug.log* 15 | 16 | # Test coverage 17 | coverage/ 18 | 19 | # Environment variables and settings 20 | .env 21 | .env.* 22 | 23 | # OS and editor specific 24 | .DS_Store 25 | *.swp 26 | *.swo 27 | .idea/ 28 | .vscode/ 29 | *.log 30 | 31 | # Others 32 | *.tgz -------------------------------------------------------------------------------- /test/y_phoenix_web/controllers/error_html_test.exs: -------------------------------------------------------------------------------- 1 | defmodule YPhoenixWeb.ErrorHTMLTest do 2 | use YPhoenixWeb.ConnCase, async: true 3 | 4 | # Bring render_to_string/4 for testing custom views 5 | import Phoenix.Template 6 | 7 | test "renders 404.html" do 8 | assert render_to_string(YPhoenixWeb.ErrorHTML, "404", "html", []) == "Not Found" 9 | end 10 | 11 | test "renders 500.html" do 12 | assert render_to_string(YPhoenixWeb.ErrorHTML, "500", "html", []) == "Internal Server Error" 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /lib/y_phoenix_web/components/layouts/root.html.heex: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | <.live_title suffix=" · Phoenix Framework"> 8 | {assigns[:page_title] || "YPhoenix"} 9 | 10 | 11 | 12 | {@inner_content} 13 | 14 | 15 | -------------------------------------------------------------------------------- /lib/y_phoenix_web/components/layouts.ex: -------------------------------------------------------------------------------- 1 | defmodule YPhoenixWeb.Layouts do 2 | @moduledoc """ 3 | This module holds different layouts used by your application. 4 | 5 | See the `layouts` directory for all templates available. 6 | The "root" layout is a skeleton rendered as part of the 7 | application router. The "app" layout is set as the default 8 | layout on both `use YPhoenixWeb, :controller` and 9 | `use YPhoenixWeb, :live_view`. 10 | """ 11 | use YPhoenixWeb, :html 12 | 13 | embed_templates "layouts/*" 14 | end 15 | -------------------------------------------------------------------------------- /priv/static/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Vite + React + TS 8 | 9 | 10 | 11 | 12 |
13 | 14 | 15 | -------------------------------------------------------------------------------- /lib/y_phoenix/yjs_writings.ex: -------------------------------------------------------------------------------- 1 | defmodule YPhoenix.YjsWritings do 2 | use Ecto.Schema 3 | import Ecto.Changeset 4 | 5 | schema "yjs-writings" do 6 | field :value, :binary 7 | field :version, Ecto.Enum, values: [:v1, :v1_sv] 8 | field :docName, :string 9 | 10 | timestamps(type: :utc_datetime) 11 | end 12 | 13 | @doc false 14 | def changeset(yjs_writings, attrs) do 15 | yjs_writings 16 | |> cast(attrs, [:docName, :value, :version]) 17 | |> validate_required([:docName, :value, :version]) 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /npm/y-phoenix-channel/README.md: -------------------------------------------------------------------------------- 1 | # y-phoenix-channel 2 | 3 | A provider library for integrating Yjs with Phoenix Channel. 4 | 5 | ## Installation 6 | 7 | ``` 8 | npm install y-phoenix-channel 9 | ``` 10 | 11 | ## Usage 12 | 13 | ```ts 14 | import { PhoenixChannelProvider } from "y-phoenix-channel"; 15 | import * as Y from "yjs"; 16 | import { Socket } from "phoenix"; 17 | 18 | const ydoc = new Y.Doc(); 19 | const socket = new Socket("/socket", { params: {} }); 20 | const provider = new PhoenixChannelProvider(socket, "room-name", ydoc); 21 | ``` -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # YPhoenix 2 | Collaborative editing demo app using [y_ex](https://github.com/satoren/y_ex) and [phoenix framework](https://www.phoenixframework.org/). 3 | 4 | ## Demo 5 | 6 | * [excalidraw](https://y-phoenix.gigalixirapp.com/excalidraw) 7 | * [quill](https://y-phoenix.gigalixirapp.com/quill) 8 | * [blocknote](https://y-phoenix.gigalixirapp.com/blocknote) 9 | * [lexical](https://y-phoenix.gigalixirapp.com/lexical) 10 | * [tiptap](https://y-phoenix.gigalixirapp.com/tiptap) 11 | * [prosemirror](https://y-phoenix.gigalixirapp.com/prosemirror) 12 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "config:best-practices", 5 | ":pinAllExceptPeerDependencies" 6 | ], 7 | "packageRules": [ 8 | { 9 | "matchUpdateTypes": [ 10 | "patch", 11 | "pin", 12 | "digest" 13 | ], 14 | "automerge": true 15 | }, 16 | { 17 | "groupName": "blocknote packages", 18 | "matchSourceUrls": [ 19 | "https://github.com/TypeCellOS/BlockNote" 20 | ] 21 | } 22 | ] 23 | } 24 | -------------------------------------------------------------------------------- /assets/js/quill/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "quill-app", 3 | "version": "1.0.0", 4 | "main": "quill.ts", 5 | "license": "MIT", 6 | "description": "", 7 | "scripts": { 8 | "test": "echo \"Error: no test specified\" && exit 1" 9 | }, 10 | "keywords": [], 11 | "author": "", 12 | "type": "module", 13 | "dependencies": { 14 | "friendly-username-generator": "2.0.4", 15 | "phoenix": "1.8.2", 16 | "quill": "2.0.3", 17 | "quill-cursors": "4.0.4", 18 | "y-indexeddb": "9.0.12", 19 | "y-quill": "1.0.0", 20 | "yjs": "13.6.27", 21 | "y-phoenix-channel": "0.1.1" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: Deploy 2 | 3 | on: 4 | push: 5 | branches: [ "main" ] 6 | 7 | permissions: 8 | contents: read 9 | 10 | jobs: 11 | deploy: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6 15 | with: 16 | fetch-depth: 0 17 | - name: git push gigalixir 18 | run: | 19 | git remote add gigalixir https://${{ secrets.GIGALIXIR_EMAIL }}:${{ secrets.GIGALIXIR_API_KEY }}@git.gigalixir.com/${{ secrets.GIGALIXIR_APP_NAME }}.git 20 | git push -f gigalixir HEAD:refs/heads/master 21 | -------------------------------------------------------------------------------- /assets/js/excalidraw/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "excalidraw-app", 3 | "version": "1.0.0", 4 | "main": "excalidraw.tsx", 5 | "license": "MIT", 6 | "description": "", 7 | "scripts": { 8 | "test": "echo \"Error: no test specified\" && exit 1" 9 | }, 10 | "keywords": [], 11 | "author": "", 12 | "type": "module", 13 | "dependencies": { 14 | "@excalidraw/excalidraw": "0.18.0", 15 | "friendly-username-generator": "2.0.4", 16 | "phoenix": "1.8.2", 17 | "y-indexeddb": "9.0.12", 18 | "yjs": "13.6.27", 19 | "y-protocols": "1.0.6", 20 | "y-utility": "0.1.4", 21 | "y-phoenix-channel": "0.1.1" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /assets/js/lexical/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "lexical-app", 3 | "version": "1.0.0", 4 | "main": "lexical.tsx", 5 | "license": "MIT", 6 | "description": "", 7 | "scripts": { 8 | "test": "echo \"Error: no test specified\" && exit 1" 9 | }, 10 | "keywords": [], 11 | "author": "", 12 | "type": "module", 13 | "dependencies": { 14 | "@lexical/react": "0.38.2", 15 | "@lexical/yjs": "0.38.2", 16 | "friendly-username-generator": "2.0.4", 17 | "lexical": "0.38.2", 18 | "phoenix": "1.8.2", 19 | "react": "19.2.1", 20 | "yjs": "13.6.27", 21 | "react-dom": "19.2.1", 22 | "y-phoenix-channel": "0.1.1" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /assets/js/js-draw/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "js-draw-app", 3 | "version": "1.0.0", 4 | "main": "js-draw.tsx", 5 | "license": "MIT", 6 | "description": "", 7 | "scripts": { 8 | "test": "echo \"Error: no test specified\" && exit 1" 9 | }, 10 | "keywords": [], 11 | "author": "", 12 | "type": "module", 13 | "dependencies": { 14 | "fast-deep-equal": "3.1.3", 15 | "@melloware/coloris": "0.25.0", 16 | "friendly-username-generator": "2.0.4", 17 | "js-draw": "1.31.1", 18 | "phoenix": "1.8.2", 19 | "y-indexeddb": "9.0.12", 20 | "yjs": "13.6.27", 21 | "y-protocols": "1.0.6", 22 | "y-phoenix-channel": "0.1.1" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /assets/js/blocknote/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "blocknote-app", 3 | "version": "1.0.0", 4 | "main": "blocknote.tsx", 5 | "license": "MIT", 6 | "description": "", 7 | "scripts": { 8 | "test": "echo \"Error: no test specified\" && exit 1" 9 | }, 10 | "keywords": [], 11 | "author": "", 12 | "type": "module", 13 | "dependencies": { 14 | "@blocknote/core": "0.44.1", 15 | "@blocknote/mantine": "0.44.1", 16 | "@blocknote/react": "0.44.1", 17 | "friendly-username-generator": "2.0.4", 18 | "phoenix": "1.8.2", 19 | "react": "19.2.1", 20 | "react-dom": "19.2.1", 21 | "y-indexeddb": "9.0.12", 22 | "yjs": "13.6.27", 23 | "y-phoenix-channel": "0.1.1" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /lib/y_phoenix_web/controllers/error_json.ex: -------------------------------------------------------------------------------- 1 | defmodule YPhoenixWeb.ErrorJSON do 2 | @moduledoc """ 3 | This module is invoked by your endpoint in case of errors on JSON requests. 4 | 5 | See config/config.exs. 6 | """ 7 | 8 | # If you want to customize a particular status code, 9 | # you may add your own clauses, such as: 10 | # 11 | # def render("500.json", _assigns) do 12 | # %{errors: %{detail: "Internal Server Error"}} 13 | # end 14 | 15 | # By default, Phoenix returns the status message from 16 | # the template name. For example, "404.json" becomes 17 | # "Not Found". 18 | def render(template, _assigns) do 19 | %{errors: %{detail: Phoenix.Controller.status_message_from_template(template)}} 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /test/y_phoenix/my_y_ecto.exs: -------------------------------------------------------------------------------- 1 | defmodule YPhoenix.YEctoTest do 2 | use YPhoenix.DataCase, async: true 3 | 4 | alias YPhoenix.MyYEcto 5 | 6 | test "renders 500.html" do 7 | doc = MyYEcto.get_y_doc("test") 8 | 9 | array = Yex.Doc.get_array(doc, "array") 10 | Yex.Doc.monitor_update(doc) 11 | 12 | for _ <- 1..1000 do 13 | Yex.Array.push(array, "test") 14 | assert_receive {:update_v1, update, _origin, _ydoc} 15 | MyYEcto.insert_update("test", update) 16 | end 17 | 18 | doc = MyYEcto.get_y_doc("test") 19 | assert Yex.Array.length(Yex.Doc.get_array(doc, "array")) == 1000 20 | 21 | doc = MyYEcto.get_y_doc("test") 22 | assert Yex.Array.length(Yex.Doc.get_array(doc, "array")) == 1000 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /assets/js/prosemirror/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "prosemirror-app", 3 | "version": "1.0.0", 4 | "main": "prosemirror.tsx", 5 | "license": "MIT", 6 | "description": "", 7 | "scripts": { 8 | "test": "echo \"Error: no test specified\" && exit 1" 9 | }, 10 | "keywords": [], 11 | "author": "", 12 | "type": "module", 13 | "dependencies": { 14 | "phoenix": "1.8.2", 15 | "prosemirror-example-setup": "1.2.3", 16 | "prosemirror-keymap": "1.2.3", 17 | "prosemirror-schema-basic": "1.2.4", 18 | "prosemirror-state": "1.4.4", 19 | "prosemirror-view": "1.41.4", 20 | "react": "19.2.1", 21 | "react-dom": "19.2.1", 22 | "yjs": "13.6.27", 23 | "y-prosemirror": "1.3.7", 24 | "y-phoenix-channel": "0.1.1" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /lib/y_phoenix/my_y_ecto.ex: -------------------------------------------------------------------------------- 1 | defmodule YPhoenix.MyYEcto do 2 | use YPhoenix.YEcto, repo: YPhoenix.Repo, schema: YPhoenix.YjsWritings 3 | end 4 | 5 | defmodule YPhoenix.EctoPersistence do 6 | @behaviour Yex.Sync.SharedDoc.PersistenceBehaviour 7 | @impl true 8 | def bind(_state, doc_name, doc) do 9 | ecto_doc = YPhoenix.MyYEcto.get_y_doc(doc_name) 10 | 11 | {:ok, new_updates} = Yex.encode_state_as_update(doc) 12 | YPhoenix.MyYEcto.insert_update(doc_name, new_updates) 13 | 14 | Yex.apply_update(doc, Yex.encode_state_as_update!(ecto_doc)) 15 | end 16 | 17 | @impl true 18 | def unbind(_state, _doc_name, _doc) do 19 | end 20 | 21 | @impl true 22 | def update_v1(_state, update, doc_name, _doc) do 23 | YPhoenix.MyYEcto.insert_update(doc_name, update) 24 | :ok 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /config/prod.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | # Note we also include the path to a cache manifest 4 | # containing the digested version of static files. This 5 | # manifest is generated by the `mix assets.deploy` task, 6 | # which you should run after static files are built and 7 | # before starting your production server. 8 | config :y_phoenix, YPhoenixWeb.Endpoint, cache_static_manifest: "priv/static/cache_manifest.json" 9 | 10 | # Configures Swoosh API Client 11 | config :swoosh, api_client: Swoosh.ApiClient.Finch, finch_name: YPhoenix.Finch 12 | 13 | # Disable Swoosh Local Memory Storage 14 | config :swoosh, local: false 15 | 16 | # Do not print debug messages in production 17 | config :logger, level: :info 18 | 19 | # Runtime production configuration, including reading 20 | # of environment variables, is done on config/runtime.exs. 21 | -------------------------------------------------------------------------------- /lib/y_phoenix_web/gettext.ex: -------------------------------------------------------------------------------- 1 | defmodule YPhoenixWeb.Gettext do 2 | @moduledoc """ 3 | A module providing Internationalization with a gettext-based API. 4 | 5 | By using [Gettext](https://hexdocs.pm/gettext), 6 | your module gains a set of macros for translations, for example: 7 | 8 | import YPhoenixWeb.Gettext 9 | 10 | # Simple translation 11 | gettext("Here is the string to translate") 12 | 13 | # Plural translation 14 | ngettext("Here is the string to translate", 15 | "Here are the strings to translate", 16 | 3) 17 | 18 | # Domain-based translation 19 | dgettext("errors", "Here is the error message to translate") 20 | 21 | See the [Gettext Docs](https://hexdocs.pm/gettext) for detailed usage. 22 | """ 23 | use Gettext, otp_app: :y_phoenix 24 | end 25 | -------------------------------------------------------------------------------- /lib/y_phoenix_web/controllers/error_html.ex: -------------------------------------------------------------------------------- 1 | defmodule YPhoenixWeb.ErrorHTML do 2 | @moduledoc """ 3 | This module is invoked by your endpoint in case of errors on HTML requests. 4 | 5 | See config/config.exs. 6 | """ 7 | use YPhoenixWeb, :html 8 | 9 | # If you want to customize your error pages, 10 | # uncomment the embed_templates/1 call below 11 | # and add pages to the error directory: 12 | # 13 | # * lib/y_phoenix_web/controllers/error_html/404.html.heex 14 | # * lib/y_phoenix_web/controllers/error_html/500.html.heex 15 | # 16 | # embed_templates "error_html/*" 17 | 18 | # The default is to render a plain text page based on 19 | # the template name. For example, "404.html" becomes 20 | # "Not Found". 21 | def render(template, _assigns) do 22 | Phoenix.Controller.status_message_from_template(template) 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /assets/js/tiptap/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tiptap-app", 3 | "version": "1.0.0", 4 | "main": "tiptap.tsx", 5 | "license": "MIT", 6 | "type": "module", 7 | "dependencies": { 8 | "@tiptap/extension-collaboration": "3.13.0", 9 | "@tiptap/extension-collaboration-caret": "3.13.0", 10 | "@tiptap/extension-document": "3.13.0", 11 | "@tiptap/extension-highlight": "3.13.0", 12 | "@tiptap/extension-list": "3.13.0", 13 | "@tiptap/extension-paragraph": "3.13.0", 14 | "@tiptap/extension-text": "3.13.0", 15 | "@tiptap/extensions": "3.13.0", 16 | "@tiptap/react": "3.13.0", 17 | "@tiptap/starter-kit": "3.13.0", 18 | "@tiptap/y-tiptap": "3.0.1", 19 | "friendly-username-generator": "2.0.4", 20 | "phoenix": "1.8.2", 21 | "react": "19.2.1", 22 | "react-dom": "19.2.1", 23 | "y-phoenix-channel": "0.1.1", 24 | "yjs": "13.6.27" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /assets/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "assets", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "js/app.js", 6 | "scripts": { 7 | "build": "node build.js --deploy", 8 | "test": "vitest", 9 | "format": "npx @biomejs/biome format --write ./", 10 | "install:all": "npm install && cd js/quill && npm install && cd ../blocknote && npm install && cd ../excalidraw && npm install && cd ../js-draw && npm install && cd ../lexical && npm install && cd ../prosemirror && npm install && cd ../tiptap && npm install" 11 | }, 12 | "type": "module", 13 | "author": "", 14 | "license": "MIT", 15 | "dependencies": { 16 | "topbar": "3.0.0" 17 | }, 18 | "devDependencies": { 19 | "@types/phoenix": "1.6.6", 20 | "@types/react": "19.2.7", 21 | "@types/react-dom": "19.2.3", 22 | "esbuild": "0.27.1", 23 | "esbuild-sass-plugin": "3.3.1", 24 | "vitest": "4.0.15" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /lib/y_phoenix_web/controllers/page_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule YPhoenixWeb.PageController do 2 | use YPhoenixWeb, :controller 3 | 4 | def home(conn, _params) do 5 | render(conn, :quill, layout: false) 6 | end 7 | 8 | def quill(conn, _params) do 9 | render(conn, :quill, layout: false) 10 | end 11 | 12 | def blocknote(conn, _params) do 13 | render(conn, :blocknote, layout: false) 14 | end 15 | 16 | def excalidraw(conn, _params) do 17 | render(conn, :excalidraw, layout: false) 18 | end 19 | 20 | def jsdraw(conn, _params) do 21 | render(conn, :jsdraw, layout: false) 22 | end 23 | 24 | def lexical(conn, _params) do 25 | render(conn, :lexical, layout: false) 26 | end 27 | 28 | def tiptap(conn, _params) do 29 | render(conn, :tiptap, layout: false) 30 | end 31 | 32 | def prosemirror(conn, _params) do 33 | render(conn, :prosemirror, layout: false) 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where 3rd-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Temporary files, for example, from tests. 23 | /tmp/ 24 | 25 | # Ignore package tarball (built via "mix hex.build"). 26 | y_phoenix-*.tar 27 | 28 | # Ignore assets that are produced by build tools. 29 | /priv/static/assets/ 30 | 31 | # Ignore digested assets cache. 32 | /priv/static/cache_manifest.json 33 | 34 | # In case you use Node.js/npm, you want to ignore these. 35 | npm-debug.log 36 | /assets/node_modules/ 37 | 38 | .DS_Store 39 | 40 | node_modules -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 satoren 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 | -------------------------------------------------------------------------------- /test/support/channel_case.ex: -------------------------------------------------------------------------------- 1 | defmodule YPhoenixWeb.ChannelCase do 2 | @moduledoc """ 3 | This module defines the test case to be used by 4 | channel tests. 5 | 6 | Such tests rely on `Phoenix.ChannelTest` and also 7 | import other functionality to make it easier 8 | to build common data structures and query the data layer. 9 | 10 | Finally, if the test case interacts with the database, 11 | we enable the SQL sandbox, so changes done to the database 12 | are reverted at the end of every test. If you are using 13 | PostgreSQL, you can even run database tests asynchronously 14 | by setting `use YPhoenixWeb.ChannelCase, async: true`, although 15 | this option is not recommended for other databases. 16 | """ 17 | 18 | use ExUnit.CaseTemplate 19 | 20 | using do 21 | quote do 22 | # Import conveniences for testing with channels 23 | import Phoenix.ChannelTest 24 | import YPhoenixWeb.ChannelCase 25 | 26 | # The default endpoint for testing 27 | @endpoint YPhoenixWeb.Endpoint 28 | end 29 | end 30 | 31 | setup tags do 32 | YPhoenix.DataCase.setup_sandbox(tags) 33 | :ok 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /lib/y_phoenix_web/components/layouts/app.html.heex: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 | 5 | 6 | 7 |

8 | v{Application.spec(:phoenix, :vsn)} 9 |

10 |
11 | 25 |
26 |
27 |
28 |
29 | <.flash_group flash={@flash} /> 30 | {@inner_content} 31 |
32 |
33 | -------------------------------------------------------------------------------- /test/support/conn_case.ex: -------------------------------------------------------------------------------- 1 | defmodule YPhoenixWeb.ConnCase do 2 | @moduledoc """ 3 | This module defines the test case to be used by 4 | tests that require setting up a connection. 5 | 6 | Such tests rely on `Phoenix.ConnTest` and also 7 | import other functionality to make it easier 8 | to build common data structures and query the data layer. 9 | 10 | Finally, if the test case interacts with the database, 11 | we enable the SQL sandbox, so changes done to the database 12 | are reverted at the end of every test. If you are using 13 | PostgreSQL, you can even run database tests asynchronously 14 | by setting `use YPhoenixWeb.ConnCase, async: true`, although 15 | this option is not recommended for other databases. 16 | """ 17 | 18 | use ExUnit.CaseTemplate 19 | 20 | using do 21 | quote do 22 | # The default endpoint for testing 23 | @endpoint YPhoenixWeb.Endpoint 24 | 25 | use YPhoenixWeb, :verified_routes 26 | 27 | # Import conveniences for testing with connections 28 | import Plug.Conn 29 | import Phoenix.ConnTest 30 | import YPhoenixWeb.ConnCase 31 | end 32 | end 33 | 34 | setup tags do 35 | YPhoenix.DataCase.setup_sandbox(tags) 36 | {:ok, conn: Phoenix.ConnTest.build_conn()} 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /lib/y_phoenix/application.ex: -------------------------------------------------------------------------------- 1 | defmodule YPhoenix.Application do 2 | # See https://hexdocs.pm/elixir/Application.html 3 | # for more information on OTP Applications 4 | @moduledoc false 5 | 6 | use Application 7 | 8 | @impl true 9 | def start(_type, _args) do 10 | children = [ 11 | YPhoenixWeb.Telemetry, 12 | YPhoenix.Repo, 13 | {DNSCluster, query: Application.get_env(:y_phoenix, :dns_cluster_query) || :ignore}, 14 | {Phoenix.PubSub, name: YPhoenix.PubSub}, 15 | YPhoenixWeb.Presence, 16 | # Start the Finch HTTP client for sending emails 17 | {Finch, name: YPhoenix.Finch}, 18 | # Start a worker by calling: YPhoenix.Worker.start_link(arg) 19 | # {YPhoenix.Worker, arg}, 20 | # Start to serve requests, typically the last entry 21 | YPhoenixWeb.Endpoint 22 | ] 23 | 24 | # See https://hexdocs.pm/elixir/Supervisor.html 25 | # for other strategies and supported options 26 | opts = [strategy: :one_for_one, name: YPhoenix.Supervisor] 27 | Supervisor.start_link(children, opts) 28 | end 29 | 30 | # Tell Phoenix to update the endpoint configuration 31 | # whenever the application is updated. 32 | @impl true 33 | def config_change(changed, _new, removed) do 34 | YPhoenixWeb.Endpoint.config_change(changed, removed) 35 | :ok 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /.github/workflows/elixir.yml: -------------------------------------------------------------------------------- 1 | name: Elixir CI 2 | 3 | on: 4 | push: 5 | branches: [ "main" ] 6 | pull_request: 7 | branches: [ "main" ] 8 | 9 | permissions: 10 | contents: read 11 | 12 | jobs: 13 | test: 14 | name: Test 15 | runs-on: ubuntu-latest 16 | services: 17 | db: 18 | image: postgres:latest@sha256:5ec39c188013123927f30a006987c6b0e20f3ef2b54b140dfa96dac6844d883f 19 | ports: ['5432:5432'] 20 | env: 21 | POSTGRES_PASSWORD: postgres 22 | options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 23 | steps: 24 | - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6 25 | - name: Set up Elixir 26 | uses: erlef/setup-beam@e6d7c94229049569db56a7ad5a540c051a010af9 # v1.20.4 27 | with: 28 | elixir-version: '1.15.2' # [Required] Define the Elixir version 29 | otp-version: '26.0' # [Required] Define the Erlang/OTP version 30 | - name: Restore dependencies cache 31 | uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 32 | with: 33 | path: deps 34 | key: ${{ runner.os }}-mix-${{ hashFiles('**/mix.lock') }} 35 | restore-keys: ${{ runner.os }}-mix- 36 | - name: Install dependencies 37 | run: mix setup 38 | - name: Run tests 39 | run: mix test -------------------------------------------------------------------------------- /config/test.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | # Configure your database 4 | # 5 | # The MIX_TEST_PARTITION environment variable can be used 6 | # to provide built-in test partitioning in CI environment. 7 | # Run `mix help test` for more information. 8 | config :y_phoenix, YPhoenix.Repo, 9 | username: "postgres", 10 | password: "postgres", 11 | hostname: "localhost", 12 | database: "y_phoenix_test#{System.get_env("MIX_TEST_PARTITION")}", 13 | pool: Ecto.Adapters.SQL.Sandbox, 14 | pool_size: System.schedulers_online() * 2 15 | 16 | # We don't run a server during test. If one is required, 17 | # you can enable the server option below. 18 | config :y_phoenix, YPhoenixWeb.Endpoint, 19 | http: [ip: {127, 0, 0, 1}, port: 4002], 20 | secret_key_base: "mvHgF26W17sSbjawB2kb8XlkvGw4EHZ3eLn15+p293Qjh7mGu5PzzD4QjbtROTL8", 21 | server: false 22 | 23 | # In test we don't send emails 24 | config :y_phoenix, YPhoenix.Mailer, adapter: Swoosh.Adapters.Test 25 | 26 | # Disable swoosh api client as it is only required for production adapters 27 | config :swoosh, :api_client, false 28 | 29 | # Print only warnings and errors during test 30 | config :logger, level: :warning 31 | 32 | # Initialize plugs at runtime for faster test compilation 33 | config :phoenix, :plug_init_mode, :runtime 34 | 35 | # Enable helpful, but potentially expensive runtime checks 36 | config :phoenix_live_view, 37 | enable_expensive_runtime_checks: true 38 | -------------------------------------------------------------------------------- /npm/y-phoenix-channel/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "y-phoenix-channel", 3 | "version": "0.1.1", 4 | "description": "Phoenix Channel provider for Yjs collaborative editing.", 5 | "main": "dist/y-phoenix-channel.js", 6 | "types": "dist/y-phoenix-channel.d.ts", 7 | "type": "module", 8 | "files": [ 9 | "dist" 10 | ], 11 | "scripts": { 12 | "build": "tsup" 13 | }, 14 | "keywords": [ 15 | "yjs", 16 | "phoenix", 17 | "collaborative", 18 | "websocket" 19 | ], 20 | "author": "satore ", 21 | "license": "MIT", 22 | "repository": { 23 | "type": "git", 24 | "url": "https://github.com/satoren/y-phoenix-channel" 25 | }, 26 | "bugs": { 27 | "url": "https://github.com/satoren/y-phoenix-channel/issues" 28 | }, 29 | "homepage": "https://github.com/satoren/y-phoenix-channel#readme", 30 | "exports": { 31 | ".": { 32 | "types": "./dist/y-phoenix-channel.d.ts", 33 | "import": "./dist/y-phoenix-channel.js", 34 | "require": "./dist/y-phoenix-channel.cjs" 35 | } 36 | }, 37 | "dependencies": { 38 | "@y/protocols": "1.0.6-1", 39 | "lib0": "0.2.114" 40 | }, 41 | "peerDependencies": { 42 | "phoenix": "^1", 43 | "yjs": "^13" 44 | }, 45 | "devDependencies": { 46 | "@types/node": "24.10.1", 47 | "@types/phoenix": "1.6.6", 48 | "tsup": "8.5.1", 49 | "typescript": "5.9.3", 50 | "yjs": "14.0.0-14" 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /priv/static/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /lib/y_phoenix_web/channels/presence.ex: -------------------------------------------------------------------------------- 1 | defmodule YPhoenixWeb.Presence do 2 | use Phoenix.Presence, 3 | otp_app: :y_phoenix, 4 | pubsub_server: YPhoenix.PubSub 5 | 6 | def init(_opts) do 7 | {:ok, %{}} 8 | end 9 | 10 | def fetch(_topic, presences) do 11 | for {key, %{metas: [meta | metas]}} <- presences, into: %{} do 12 | {key, %{metas: [meta | metas]}} 13 | end 14 | end 15 | 16 | def handle_metas(topic, %{joins: joins, leaves: leaves}, presences, state) do 17 | for {user_id, presence} <- joins do 18 | user_data = %{id: user_id, metas: Map.fetch!(presences, user_id)} 19 | msg = {__MODULE__, {:join, user_data}} 20 | Phoenix.PubSub.local_broadcast(YPhoenix.PubSub, "proxy:#{topic}", msg) 21 | end 22 | 23 | for {user_id, presence} <- leaves do 24 | metas = 25 | case Map.fetch(presences, user_id) do 26 | {:ok, presence_metas} -> presence_metas 27 | :error -> [] 28 | end 29 | 30 | user_data = %{id: user_id, metas: metas} 31 | msg = {__MODULE__, {:leave, user_data}} 32 | Phoenix.PubSub.local_broadcast(YPhoenix.PubSub, "proxy:#{topic}", msg) 33 | end 34 | 35 | {:ok, state} 36 | end 37 | 38 | def list_users(topic), do: list(topic) |> Enum.map(fn {_id, presence} -> presence end) 39 | def track_user(topic, name, params), do: track(self(), topic, name, params) 40 | def subscribe(topic), do: Phoenix.PubSub.subscribe(YPhoenix.PubSub, "proxy:#{topic}") 41 | end 42 | -------------------------------------------------------------------------------- /assets/build.js: -------------------------------------------------------------------------------- 1 | import esbuild from "esbuild"; 2 | import { sassPlugin } from "esbuild-sass-plugin"; 3 | 4 | const args = process.argv.slice(2); 5 | const watch = args.includes("--watch"); 6 | const deploy = args.includes("--deploy"); 7 | 8 | const loader = { 9 | ".woff": "file", 10 | ".woff2": "file", 11 | }; 12 | 13 | const plugins = [sassPlugin()]; 14 | 15 | // Define esbuild options 16 | let opts = { 17 | entryPoints: [ 18 | { in: "js/quill/quill.ts", out: "quill" }, 19 | { in: "js/blocknote/blocknote.tsx", out: "blocknote" }, 20 | { in: "js/excalidraw/excalidraw.tsx", out: "excalidraw" }, 21 | { in: "js/js-draw/js-draw.tsx", out: "js-draw" }, 22 | { in: "js/lexical/lexical.tsx", out: "lexical" }, 23 | { in: "js/tiptap/tiptap.tsx", out: "tiptap" }, 24 | { in: "js/prosemirror/prosemirror.tsx", out: "prosemirror" }, 25 | ], 26 | bundle: true, 27 | logLevel: "info", 28 | target: "es2017", 29 | outdir: "../priv/static/assets", 30 | external: ["fonts/*", "images/*"], 31 | conditions: ["production", "style"], 32 | nodePaths: ["../deps"], 33 | loader: loader, 34 | plugins: plugins, 35 | format: "esm", 36 | entryNames: "[name]", 37 | }; 38 | 39 | if (deploy) { 40 | opts = { 41 | ...opts, 42 | minify: true, 43 | }; 44 | } 45 | 46 | if (watch) { 47 | opts = { 48 | ...opts, 49 | sourcemap: "inline", 50 | }; 51 | esbuild 52 | .context(opts) 53 | .then((ctx) => { 54 | ctx.watch(); 55 | }) 56 | .catch((_error) => { 57 | process.exit(1); 58 | }); 59 | } else { 60 | esbuild.build(opts).catch((_error) => { 61 | process.exit(1); 62 | }); 63 | } 64 | -------------------------------------------------------------------------------- /lib/y_phoenix_web/channels/user_socket.ex: -------------------------------------------------------------------------------- 1 | defmodule YPhoenixWeb.UserSocket do 2 | use Phoenix.Socket 3 | 4 | # A Socket handler 5 | # 6 | # It's possible to control the websocket connection and 7 | # assign values that can be accessed by your channel topics. 8 | 9 | ## Channels 10 | 11 | channel "y_doc_room:*", YPhoenixWeb.YDocRoomChannel 12 | channel "y_doc_room_memory_save:*", YPhoenixWeb.YDocRoomChannel 13 | 14 | # Socket params are passed from the client and can 15 | # be used to verify and authenticate a user. After 16 | # verification, you can put default assigns into 17 | # the socket that will be set for all channels, ie 18 | # 19 | # {:ok, assign(socket, :user_id, verified_user_id)} 20 | # 21 | # To deny connection, return `:error` or `{:error, term}`. To control the 22 | # response the client receives in that case, [define an error handler in the 23 | # websocket 24 | # configuration](https://hexdocs.pm/phoenix/Phoenix.Endpoint.html#socket/3-websocket-configuration). 25 | # 26 | # See `Phoenix.Token` documentation for examples in 27 | # performing token verification on connect. 28 | @impl true 29 | def connect(_params, socket, _connect_info) do 30 | {:ok, socket} 31 | end 32 | 33 | # Socket IDs are topics that allow you to identify all sockets for a given user: 34 | # 35 | # def id(socket), do: "user_socket:#{socket.assigns.user_id}" 36 | # 37 | # Would allow you to broadcast a "disconnect" event and terminate 38 | # all active sockets and channels for a given user: 39 | # 40 | # Elixir.YPhoenixWeb.Endpoint.broadcast("user_socket:#{user.id}", "disconnect", %{}) 41 | # 42 | # Returning `nil` makes this socket anonymous. 43 | @impl true 44 | def id(_socket), do: nil 45 | end 46 | -------------------------------------------------------------------------------- /lib/y_phoenix_web/router.ex: -------------------------------------------------------------------------------- 1 | defmodule YPhoenixWeb.Router do 2 | use YPhoenixWeb, :router 3 | 4 | pipeline :browser do 5 | plug :accepts, ["html"] 6 | plug :fetch_session 7 | plug :fetch_live_flash 8 | plug :put_root_layout, html: {YPhoenixWeb.Layouts, :root} 9 | plug :protect_from_forgery 10 | plug :put_secure_browser_headers 11 | end 12 | 13 | pipeline :api do 14 | plug :accepts, ["json"] 15 | end 16 | 17 | scope "/", YPhoenixWeb do 18 | pipe_through :browser 19 | 20 | get "/", PageController, :quill 21 | get "/quill", PageController, :quill 22 | get "/blocknote", PageController, :blocknote 23 | get "/excalidraw", PageController, :excalidraw 24 | get "/js-draw", PageController, :jsdraw 25 | get "/lexical", PageController, :lexical 26 | get "/tiptap", PageController, :tiptap 27 | get "/prosemirror", PageController, :prosemirror 28 | end 29 | 30 | # Other scopes may use custom stacks. 31 | # scope "/api", YPhoenixWeb do 32 | # pipe_through :api 33 | # end 34 | 35 | # Enable LiveDashboard and Swoosh mailbox preview in development 36 | if Application.compile_env(:y_phoenix, :dev_routes) do 37 | # If you want to use the LiveDashboard in production, you should put 38 | # it behind authentication and allow only admins to access it. 39 | # If your application does not have an admins-only section yet, 40 | # you can use Plug.BasicAuth to set up some basic authentication 41 | # as long as you are also using SSL (which you should anyway). 42 | import Phoenix.LiveDashboard.Router 43 | 44 | scope "/dev" do 45 | pipe_through :browser 46 | 47 | live_dashboard "/dashboard", metrics: YPhoenixWeb.Telemetry 48 | forward "/mailbox", Plug.Swoosh.MailboxPreview 49 | end 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | # This file is responsible for configuring your application 2 | # and its dependencies with the aid of the Config module. 3 | # 4 | # This configuration file is loaded before any dependency and 5 | # is restricted to this project. 6 | 7 | # General application configuration 8 | import Config 9 | 10 | config :y_phoenix, 11 | ecto_repos: [YPhoenix.Repo], 12 | generators: [timestamp_type: :utc_datetime] 13 | 14 | # Configures the endpoint 15 | config :y_phoenix, YPhoenixWeb.Endpoint, 16 | url: [host: "localhost"], 17 | adapter: Bandit.PhoenixAdapter, 18 | render_errors: [ 19 | formats: [html: YPhoenixWeb.ErrorHTML, json: YPhoenixWeb.ErrorJSON], 20 | layout: false 21 | ], 22 | pubsub_server: YPhoenix.PubSub, 23 | live_view: [signing_salt: "b418s+18"] 24 | 25 | # Configures the mailer 26 | # 27 | # By default it uses the "Local" adapter which stores the emails 28 | # locally. You can see the emails in your browser, at "/dev/mailbox". 29 | # 30 | # For production it's recommended to configure a different adapter 31 | # at the `config/runtime.exs`. 32 | config :y_phoenix, YPhoenix.Mailer, adapter: Swoosh.Adapters.Local 33 | 34 | # Configure tailwind (the version is required) 35 | config :tailwind, 36 | version: "3.4.3", 37 | y_phoenix: [ 38 | args: ~w( 39 | --config=tailwind.config.js 40 | --input=css/app.css 41 | --output=../priv/static/assets/app.css 42 | ), 43 | cd: Path.expand("../assets", __DIR__) 44 | ] 45 | 46 | # Configures Elixir's Logger 47 | config :logger, :console, 48 | format: "$time $metadata[$level] $message\n", 49 | metadata: [:request_id] 50 | 51 | # Use Jason for JSON parsing in Phoenix 52 | config :phoenix, :json_library, Jason 53 | 54 | # Import environment specific config. This must remain at the bottom 55 | # of this file so it overrides the configuration defined above. 56 | import_config "#{config_env()}.exs" 57 | -------------------------------------------------------------------------------- /test/support/data_case.ex: -------------------------------------------------------------------------------- 1 | defmodule YPhoenix.DataCase do 2 | @moduledoc """ 3 | This module defines the setup for tests requiring 4 | access to the application's data layer. 5 | 6 | You may define functions here to be used as helpers in 7 | your tests. 8 | 9 | Finally, if the test case interacts with the database, 10 | we enable the SQL sandbox, so changes done to the database 11 | are reverted at the end of every test. If you are using 12 | PostgreSQL, you can even run database tests asynchronously 13 | by setting `use YPhoenix.DataCase, async: true`, although 14 | this option is not recommended for other databases. 15 | """ 16 | 17 | use ExUnit.CaseTemplate 18 | 19 | using do 20 | quote do 21 | alias YPhoenix.Repo 22 | 23 | import Ecto 24 | import Ecto.Changeset 25 | import Ecto.Query 26 | import YPhoenix.DataCase 27 | end 28 | end 29 | 30 | setup tags do 31 | YPhoenix.DataCase.setup_sandbox(tags) 32 | :ok 33 | end 34 | 35 | @doc """ 36 | Sets up the sandbox based on the test tags. 37 | """ 38 | def setup_sandbox(tags) do 39 | pid = Ecto.Adapters.SQL.Sandbox.start_owner!(YPhoenix.Repo, shared: not tags[:async]) 40 | on_exit(fn -> Ecto.Adapters.SQL.Sandbox.stop_owner(pid) end) 41 | end 42 | 43 | @doc """ 44 | A helper that transforms changeset errors into a map of messages. 45 | 46 | assert {:error, changeset} = Accounts.create_user(%{password: "short"}) 47 | assert "password is too short" in errors_on(changeset).password 48 | assert %{password: ["password is too short"]} = errors_on(changeset) 49 | 50 | """ 51 | def errors_on(changeset) do 52 | Ecto.Changeset.traverse_errors(changeset, fn {message, opts} -> 53 | Regex.replace(~r"%{(\w+)}", message, fn _, key -> 54 | opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string() 55 | end) 56 | end) 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /assets/js/blocknote/blocknote.tsx: -------------------------------------------------------------------------------- 1 | // blocknote.tsx を blocknote ディレクトリに移動 2 | import * as React from "react"; 3 | import "@blocknote/core/fonts/inter.css"; 4 | import { useCreateBlockNote } from "@blocknote/react"; 5 | import { BlockNoteView } from "@blocknote/mantine"; 6 | import * as Y from "yjs"; 7 | import "@blocknote/mantine/style.css"; 8 | 9 | import { createRoot } from "react-dom/client"; 10 | import { PhoenixChannelProvider } from "y-phoenix-channel"; 11 | import { IndexeddbPersistence } from "y-indexeddb"; 12 | import { Socket } from "phoenix"; 13 | import { generateUsername } from "friendly-username-generator"; 14 | 15 | const socket = new Socket("/socket"); 16 | socket.connect(); 17 | const ydoc = new Y.Doc(); 18 | const docname = `blocknote:${new URLSearchParams(window.location.search).get("docname") ?? "blocknote"}`; 19 | 20 | const provider = new PhoenixChannelProvider( 21 | socket, 22 | `y_doc_room:${docname}`, 23 | ydoc, 24 | ); 25 | const persistence = new IndexeddbPersistence(docname, ydoc); 26 | 27 | const usercolors = [ 28 | "#30bced", 29 | "#6eeb83", 30 | "#ffbc42", 31 | "#ecd444", 32 | "#ee6352", 33 | "#9ac2c9", 34 | "#8acb88", 35 | "#1be7ff", 36 | ]; 37 | 38 | const myColor = usercolors[Math.floor(Math.random() * usercolors.length)]; 39 | export default function App() { 40 | // Creates a new editor instance. 41 | const editor = useCreateBlockNote({ 42 | collaboration: { 43 | provider, 44 | fragment: ydoc.getXmlFragment("document-store"), 45 | user: { 46 | name: generateUsername(), 47 | color: myColor, 48 | }, 49 | }, 50 | // ... 51 | }); 52 | 53 | // Renders the editor instance using a React component. 54 | return ; 55 | } 56 | 57 | const domNode = document.getElementById("root"); 58 | if (!domNode) { 59 | throw new Error("root element not found"); 60 | } 61 | 62 | const root = createRoot(domNode); 63 | root.render(); 64 | -------------------------------------------------------------------------------- /lib/y_phoenix_web/endpoint.ex: -------------------------------------------------------------------------------- 1 | defmodule YPhoenixWeb.Endpoint do 2 | use Phoenix.Endpoint, otp_app: :y_phoenix 3 | 4 | # The session will be stored in the cookie and signed, 5 | # this means its contents can be read but not tampered with. 6 | # Set :encryption_salt if you would also like to encrypt it. 7 | @session_options [ 8 | store: :cookie, 9 | key: "_y_phoenix_key", 10 | signing_salt: "i6S+aVoQ", 11 | same_site: "Lax" 12 | ] 13 | 14 | socket "/live", Phoenix.LiveView.Socket, 15 | websocket: [connect_info: [session: @session_options]], 16 | longpoll: [connect_info: [session: @session_options]] 17 | 18 | socket "/socket", YPhoenixWeb.UserSocket, 19 | websocket: [connect_info: [session: @session_options]], 20 | longpoll: [connect_info: [session: @session_options]] 21 | 22 | # Serve at "/" the static files from "priv/static" directory. 23 | # 24 | # You should set gzip to true if you are running phx.digest 25 | # when deploying your static files in production. 26 | plug Plug.Static, 27 | at: "/", 28 | from: :y_phoenix, 29 | gzip: true, 30 | only: YPhoenixWeb.static_paths() 31 | 32 | # Code reloading can be explicitly enabled under the 33 | # :code_reloader configuration of your endpoint. 34 | if code_reloading? do 35 | socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket 36 | plug Phoenix.LiveReloader 37 | plug Phoenix.CodeReloader 38 | plug Phoenix.Ecto.CheckRepoStatus, otp_app: :y_phoenix 39 | end 40 | 41 | plug Phoenix.LiveDashboard.RequestLogger, 42 | param_key: "request_logger", 43 | cookie_key: "request_logger" 44 | 45 | plug Plug.RequestId 46 | plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint] 47 | 48 | plug Plug.Parsers, 49 | parsers: [:urlencoded, :multipart, :json], 50 | pass: ["*/*"], 51 | json_decoder: Phoenix.json_library() 52 | 53 | plug Plug.MethodOverride 54 | plug Plug.Head 55 | plug Plug.Session, @session_options 56 | plug YPhoenixWeb.Router 57 | end 58 | -------------------------------------------------------------------------------- /assets/js/quill/quill.ts: -------------------------------------------------------------------------------- 1 | import Quill from "quill"; 2 | import QuillCursors from "quill-cursors"; 3 | 4 | import * as Y from "yjs"; 5 | import { QuillBinding } from "y-quill"; 6 | import { IndexeddbPersistence } from "y-indexeddb"; 7 | 8 | import { Socket } from "phoenix"; 9 | import { PhoenixChannelProvider } from "y-phoenix-channel"; 10 | import { generateUsername } from "friendly-username-generator"; 11 | 12 | Quill.register("modules/cursors", QuillCursors); 13 | const socket = new Socket("/socket"); 14 | socket.connect(); 15 | 16 | const ydoc = new Y.Doc(); 17 | 18 | const docname = `quill:${new URLSearchParams(window.location.search).get("docname") ?? "quill"}`; 19 | 20 | const provider = new PhoenixChannelProvider( 21 | socket, 22 | `y_doc_room:${docname}`, 23 | ydoc, 24 | ); 25 | const persistence = new IndexeddbPersistence(docname, ydoc); 26 | 27 | const usercolors = [ 28 | "#30bced", 29 | "#6eeb83", 30 | "#ffbc42", 31 | "#ecd444", 32 | "#ee6352", 33 | "#9ac2c9", 34 | "#8acb88", 35 | "#1be7ff", 36 | ]; 37 | const myColor = usercolors[Math.floor(Math.random() * usercolors.length)]; 38 | provider.awareness.setLocalStateField("user", { 39 | name: generateUsername(), 40 | color: myColor, 41 | }); 42 | 43 | const toolbarOptions = [ 44 | ["bold", "italic", "underline", "strike"], 45 | ["blockquote", "code-block"], 46 | ["link", "image", "video", "formula"], 47 | [{ list: "ordered" }, { list: "bullet" }, { list: "check" }], 48 | [{ script: "sub" }, { script: "super" }], 49 | [{ indent: "-1" }, { indent: "+1" }], 50 | [{ direction: "rtl" }], 51 | [{ header: [1, 2, 3, 4, 5, 6, false] }], 52 | [{ color: [] }, { background: [] }], 53 | [{ font: [] }], 54 | [{ align: [] }], 55 | ["clean"], 56 | ]; 57 | 58 | const quill = new Quill("#editor", { 59 | modules: { 60 | cursors: true, 61 | toolbar: toolbarOptions, 62 | }, 63 | theme: "snow", 64 | }); 65 | 66 | const binding = new QuillBinding( 67 | ydoc.getText("quill"), 68 | quill, 69 | provider.awareness, 70 | ); 71 | -------------------------------------------------------------------------------- /lib/y_phoenix_web/channels/y_doc_socket.ex: -------------------------------------------------------------------------------- 1 | defmodule YPhoenixWeb.YDocSocket do 2 | use Phoenix.Socket 3 | 4 | # A Socket handler 5 | # 6 | # It's possible to control the websocket connection and 7 | # assign values that can be accessed by your channel topics. 8 | 9 | ## Channels 10 | # Uncomment the following line to define a "room:*" topic 11 | # pointing to the `YPhoenixWeb.RoomChannel`: 12 | # 13 | # channel "room:*", YPhoenixWeb.RoomChannel 14 | # 15 | # To create a channel file, use the mix task: 16 | # 17 | # mix phx.gen.channel Room 18 | # 19 | # See the [`Channels guide`](https://hexdocs.pm/phoenix/channels.html) 20 | # for further details. 21 | 22 | # Socket params are passed from the client and can 23 | # be used to verify and authenticate a user. After 24 | # verification, you can put default assigns into 25 | # the socket that will be set for all channels, ie 26 | # 27 | # {:ok, assign(socket, :user_id, verified_user_id)} 28 | # 29 | # To deny connection, return `:error` or `{:error, term}`. To control the 30 | # response the client receives in that case, [define an error handler in the 31 | # websocket 32 | # configuration](https://hexdocs.pm/phoenix/Phoenix.Endpoint.html#socket/3-websocket-configuration). 33 | # 34 | # See `Phoenix.Token` documentation for examples in 35 | # performing token verification on connect. 36 | @impl true 37 | def connect(_params, socket, _connect_info) do 38 | {:ok, socket} 39 | end 40 | 41 | # Socket IDs are topics that allow you to identify all sockets for a given user: 42 | # 43 | # def id(socket), do: "user_socket:#{socket.assigns.user_id}" 44 | # 45 | # Would allow you to broadcast a "disconnect" event and terminate 46 | # all active sockets and channels for a given user: 47 | # 48 | # Elixir.YPhoenixWeb.Endpoint.broadcast("user_socket:#{user.id}", "disconnect", %{}) 49 | # 50 | # Returning `nil` makes this socket anonymous. 51 | @impl true 52 | def id(_socket), do: nil 53 | end 54 | -------------------------------------------------------------------------------- /assets/js/js-draw/js-draw.tsx: -------------------------------------------------------------------------------- 1 | import Editor from "js-draw/Editor"; 2 | import "js-draw/Editor.css"; 3 | import "@melloware/coloris/dist/coloris.css"; 4 | import * as Y from "yjs"; 5 | import { JsDrawBinding } from "./y-js-draw"; 6 | import { PhoenixChannelProvider } from "y-phoenix-channel"; 7 | import { IndexeddbPersistence } from "y-indexeddb"; 8 | import { Socket } from "phoenix"; 9 | import { JsDrawCursor } from "./js-draw-cursor"; 10 | import { generateUsername } from "friendly-username-generator"; 11 | 12 | const socket = new Socket("/socket"); 13 | socket.connect(); 14 | const ydoc = new Y.Doc(); 15 | const docname = `js-draw:${new URLSearchParams(window.location.search).get("docname") ?? "js-draw"}`; 16 | 17 | const provider = new PhoenixChannelProvider( 18 | socket, 19 | `y_doc_room:${docname}`, 20 | ydoc, 21 | ); 22 | const persistence = new IndexeddbPersistence(docname, ydoc); 23 | 24 | const usercolors = [ 25 | "#30bced", 26 | "#6eeb83", 27 | "#ffbc42", 28 | "#ecd444", 29 | "#ee6352", 30 | "#9ac2c9", 31 | "#8acb88", 32 | "#1be7ff", 33 | ]; 34 | 35 | const myColor = usercolors[Math.floor(Math.random() * usercolors.length)]; 36 | provider.awareness.setLocalStateField("user", { 37 | name: generateUsername(), 38 | color: myColor, 39 | }); 40 | 41 | const domNode = document.getElementById("root"); 42 | if (!domNode) { 43 | throw new Error("Root element not found"); 44 | } 45 | 46 | const editorRoot = document.createElement("div"); 47 | editorRoot.style.width = "100%"; 48 | editorRoot.style.height = "100%"; 49 | editorRoot.style.position = "absolute"; 50 | domNode.appendChild(editorRoot); 51 | 52 | const editor = new Editor(editorRoot); 53 | 54 | const overlay = document.createElement("div"); 55 | overlay.style.width = "100%"; 56 | overlay.style.height = "100%"; 57 | overlay.style.position = "absolute"; 58 | overlay.style.pointerEvents = "none"; 59 | domNode.appendChild(overlay); 60 | 61 | const cursors = new JsDrawCursor(editor, overlay); 62 | const a = new JsDrawBinding( 63 | ydoc.getMap("elementMap"), 64 | editor, 65 | provider.awareness, 66 | cursors, 67 | ); 68 | 69 | editor.addToolbar(); 70 | -------------------------------------------------------------------------------- /assets/js/excalidraw/excalidraw.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | import { Excalidraw } from "@excalidraw/excalidraw"; 4 | import "@excalidraw/excalidraw/index.css"; 5 | import * as Y from "yjs"; 6 | import { createRoot } from "react-dom/client"; 7 | import { PhoenixChannelProvider } from "y-phoenix-channel"; 8 | import { IndexeddbPersistence } from "y-indexeddb"; 9 | import { Socket } from "phoenix"; 10 | import { generateUsername } from "friendly-username-generator"; 11 | import { ExcalidrawBinding } from "./y-excalidraw"; 12 | 13 | type ExcalidrawProps = Parameters[0]; 14 | type ExcalidrawImperativeAPI = Parameters< 15 | NonNullable 16 | >[0]; 17 | const socket = new Socket("/socket"); 18 | socket.connect(); 19 | const ydoc = new Y.Doc(); 20 | const docname = `excalidraw:${new URLSearchParams(window.location.search).get("docname") ?? "excalidraw"}`; 21 | 22 | const provider = new PhoenixChannelProvider( 23 | socket, 24 | `y_doc_room:${docname}`, 25 | ydoc, 26 | ); 27 | const persistence = new IndexeddbPersistence(docname, ydoc); 28 | 29 | provider.awareness.setLocalStateField("user", { 30 | name: generateUsername(), 31 | }); 32 | 33 | export default function App() { 34 | const [api, setApi] = React.useState(null); 35 | const [binding, setBindings] = React.useState(null); 36 | 37 | const containerRef = React.useRef(null); 38 | 39 | React.useEffect(() => { 40 | if (!api) return; 41 | 42 | const binding = new ExcalidrawBinding( 43 | ydoc.getArray("elements"), 44 | ydoc.getArray("assets"), 45 | api, 46 | provider.awareness, 47 | { 48 | cursorDisplayTimeout: 5000, 49 | }, 50 | ); 51 | setBindings(binding); 52 | return () => { 53 | setBindings(null); 54 | binding.destroy(); 55 | }; 56 | }, [api]); 57 | 58 | return ( 59 |
60 | 65 |
66 | ); 67 | } 68 | 69 | const domNode = document.getElementById("root"); 70 | if (!domNode) { 71 | throw new Error("root element not found"); 72 | } 73 | 74 | const root = createRoot(domNode); 75 | root.render(); 76 | -------------------------------------------------------------------------------- /assets/js/lexical/lexical.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { LexicalComposer } from "@lexical/react/LexicalComposer"; 3 | import { ContentEditable } from "@lexical/react/LexicalContentEditable"; 4 | import { LexicalErrorBoundary } from "@lexical/react/LexicalErrorBoundary"; 5 | import { HistoryPlugin } from "@lexical/react/LexicalHistoryPlugin"; 6 | import { RichTextPlugin } from "@lexical/react/LexicalRichTextPlugin"; 7 | import { CollaborationPlugin } from "@lexical/react/LexicalCollaborationPlugin"; 8 | import * as Y from "yjs"; 9 | import { Socket } from "phoenix"; 10 | import { useCallback } from "react"; 11 | import { PhoenixChannelProvider } from "y-phoenix-channel"; 12 | import { createRoot } from "react-dom/client"; 13 | import type { Provider } from "@lexical/yjs"; 14 | 15 | const socket = new Socket("/socket"); 16 | socket.connect(); 17 | const docname = `lexical:${new URLSearchParams(window.location.search).get("docname") ?? "lexical"}`; 18 | 19 | function getDocFromMap(id: string, yjsDocMap: Map): Y.Doc { 20 | let doc = yjsDocMap.get(id); 21 | 22 | if (doc === undefined) { 23 | doc = new Y.Doc(); 24 | yjsDocMap.set(id, doc); 25 | } else { 26 | doc.load(); 27 | } 28 | 29 | return doc; 30 | } 31 | 32 | function Editor() { 33 | const initialConfig = { 34 | editorState: null, 35 | namespace: "Demo", 36 | nodes: [], 37 | onError: (error: Error) => { 38 | throw error; 39 | }, 40 | theme: {}, 41 | }; 42 | 43 | const providerFactory = useCallback( 44 | (id: string, yjsDocMap: Map): Provider => { 45 | const doc = getDocFromMap(id, yjsDocMap); 46 | return new PhoenixChannelProvider(socket, `y_doc_room:${docname}`, doc, { 47 | connect: false, 48 | }); 49 | }, 50 | [docname], 51 | ); 52 | 53 | return ( 54 | 55 | } 57 | placeholder={ 58 |
Enter some rich text...
59 | } 60 | ErrorBoundary={LexicalErrorBoundary} 61 | /> 62 | 63 | 68 |
69 | ); 70 | } 71 | 72 | export default function App() { 73 | return ; 74 | } 75 | 76 | const domNode = document.getElementById("root"); 77 | if (!domNode) { 78 | throw new Error("root element not found"); 79 | } 80 | 81 | const root = createRoot(domNode); 82 | root.render(); 83 | -------------------------------------------------------------------------------- /lib/y_phoenix/y_ecto.ex: -------------------------------------------------------------------------------- 1 | defmodule YPhoenix.YEcto do 2 | defmacro __using__(opts) do 3 | repo = opts[:repo] 4 | schema = opts[:schema] 5 | 6 | quote do 7 | import Ecto.Query 8 | 9 | @repo unquote(repo) 10 | @schema unquote(schema) 11 | 12 | @flush_size 400 13 | 14 | def get_y_doc(doc_name) do 15 | ydoc = Yex.Doc.new() 16 | 17 | updates = get_updates(doc_name) 18 | 19 | Yex.Doc.transaction(ydoc, fn -> 20 | Enum.each(updates, fn update -> 21 | Yex.apply_update(ydoc, update.value) 22 | end) 23 | end) 24 | 25 | if length(updates) > @flush_size do 26 | {:ok, u} = Yex.encode_state_as_update(ydoc) 27 | {:ok, sv} = Yex.encode_state_vector(ydoc) 28 | clock = List.last(updates, nil).inserted_at 29 | flush_document(doc_name, u, sv, clock) 30 | end 31 | 32 | ydoc 33 | end 34 | 35 | def insert_update(doc_name, value) do 36 | @repo.insert(%@schema{docName: doc_name, value: value, version: :v1}) 37 | end 38 | 39 | def get_state_vector(doc_name) do 40 | query = 41 | from y in @schema, 42 | where: y.docName == ^doc_name and y.version == :v1_sv, 43 | select: y 44 | 45 | @repo.one(query) 46 | end 47 | 48 | def get_diff(doc_name, sv) do 49 | doc = get_y_doc(doc_name) 50 | Yex.encode_state_as_update(doc, sv) 51 | end 52 | 53 | def clear_document(doc_name) do 54 | query = 55 | from y in @schema, 56 | where: y.docName == ^doc_name 57 | 58 | @repo.delete_all(query) 59 | end 60 | 61 | defp put_state_vector(doc_name, state_vector) do 62 | case get_state_vector(doc_name) do 63 | nil -> %@schema{docName: doc_name, version: :v1_sv} 64 | state_vector -> state_vector 65 | end 66 | |> @schema.changeset(%{value: state_vector}) 67 | |> @repo.insert_or_update() 68 | end 69 | 70 | defp get_updates(doc_name) do 71 | query = 72 | from y in @schema, 73 | where: y.docName == ^doc_name and y.version == :v1, 74 | select: y, 75 | order_by: y.inserted_at 76 | 77 | @repo.all(query) 78 | end 79 | 80 | defp flush_document(doc_name, updates, sv, clock) do 81 | @repo.insert(%@schema{docName: doc_name, value: updates, version: :v1}) 82 | put_state_vector(doc_name, sv) 83 | clear_updates_to(doc_name, clock) 84 | end 85 | 86 | defp clear_updates_to(doc_name, to) do 87 | query = 88 | from y in @schema, 89 | where: y.docName == ^doc_name and y.inserted_at < ^to 90 | 91 | @repo.delete_all(query) 92 | end 93 | end 94 | end 95 | end 96 | -------------------------------------------------------------------------------- /priv/static/images/logo.svg: -------------------------------------------------------------------------------- 1 | 7 | -------------------------------------------------------------------------------- /lib/y_phoenix_web/channels/y_doc_room_channel.ex: -------------------------------------------------------------------------------- 1 | defmodule YPhoenixWeb.YDocRoomChannel do 2 | use YPhoenixWeb, :channel 3 | 4 | require Logger 5 | 6 | alias YPhoenixWeb.DocServer 7 | @impl true 8 | def join("y_doc_room:" <> doc_name = topic, payload, socket) do 9 | if authorized?(payload) do 10 | uid = "#{node()}_#{System.unique_integer()}" 11 | 12 | YPhoenixWeb.Presence.track_user(topic, uid, %{}) 13 | 14 | case start_shared_doc(topic, doc_name) do 15 | {:ok, docpid} -> 16 | Process.monitor(docpid) 17 | {:ok, socket |> assign(doc_name: doc_name, doc_pid: docpid)} 18 | 19 | {:error, reason} -> 20 | {:error, %{reason: reason}} 21 | end 22 | else 23 | {:error, %{reason: "unauthorized"}} 24 | end 25 | end 26 | 27 | @impl true 28 | def handle_in("yjs_sync", {:binary, chunk}, socket) do 29 | server = socket.assigns.doc_pid 30 | 31 | DocServer.process_message_v1(server, chunk, self()) 32 | |> handle_process_message_result(server) 33 | 34 | {:noreply, socket} 35 | end 36 | 37 | def handle_in("yjs", {:binary, chunk}, socket) do 38 | server = socket.assigns.doc_pid 39 | 40 | DocServer.process_message_v1(server, chunk, self()) 41 | |> handle_process_message_result(server) 42 | 43 | {:noreply, socket} 44 | end 45 | 46 | defp handle_process_message_result(result, server) do 47 | case result do 48 | {:ok, replies} -> 49 | Enum.each(replies, fn reply -> 50 | send(self(), {:yjs, reply, server}) 51 | end) 52 | 53 | :ok 54 | 55 | error -> 56 | error 57 | end 58 | end 59 | 60 | @impl true 61 | def handle_info({:yjs, message, _proc}, socket) do 62 | push(socket, "yjs", {:binary, message}) 63 | {:noreply, socket} 64 | end 65 | 66 | @impl true 67 | def handle_info( 68 | {:DOWN, _ref, :process, _pid, _reason}, 69 | socket 70 | ) do 71 | {:stop, {:error, "remote process crash"}, socket} 72 | end 73 | 74 | defp start_shared_doc(topic, doc_name) do 75 | case :global.whereis_name({__MODULE__, doc_name}) do 76 | :undefined -> 77 | DocServer.start([topic: topic, doc_name: doc_name, persistence: YPhoenix.EctoPersistence], 78 | name: {:global, {__MODULE__, doc_name}} 79 | ) 80 | 81 | pid -> 82 | {:ok, pid} 83 | end 84 | |> case do 85 | {:ok, pid} -> 86 | {:ok, pid} 87 | 88 | {:error, {:already_started, pid}} -> 89 | {:ok, pid} 90 | 91 | {:error, reason} -> 92 | Logger.error(""" 93 | Failed to start shareddoc. 94 | Room: #{inspect(doc_name)} 95 | Reason: #{inspect(reason)} 96 | """) 97 | 98 | {:error, %{reason: "failed to start shareddoc"}} 99 | end 100 | end 101 | 102 | # Add authorization logic here as required. 103 | defp authorized?(_payload) do 104 | true 105 | end 106 | end 107 | -------------------------------------------------------------------------------- /lib/y_phoenix_web.ex: -------------------------------------------------------------------------------- 1 | defmodule YPhoenixWeb do 2 | @moduledoc """ 3 | The entrypoint for defining your web interface, such 4 | as controllers, components, channels, and so on. 5 | 6 | This can be used in your application as: 7 | 8 | use YPhoenixWeb, :controller 9 | use YPhoenixWeb, :html 10 | 11 | The definitions below will be executed for every controller, 12 | component, etc, so keep them short and clean, focused 13 | on imports, uses and aliases. 14 | 15 | Do NOT define functions inside the quoted expressions 16 | below. Instead, define additional modules and import 17 | those modules here. 18 | """ 19 | 20 | def static_paths, do: ~w(assets fonts images favicon.ico robots.txt) 21 | 22 | def router do 23 | quote do 24 | use Phoenix.Router, helpers: false 25 | 26 | # Import common connection and controller functions to use in pipelines 27 | import Plug.Conn 28 | import Phoenix.Controller 29 | import Phoenix.LiveView.Router 30 | end 31 | end 32 | 33 | def channel do 34 | quote do 35 | use Phoenix.Channel 36 | end 37 | end 38 | 39 | def controller do 40 | quote do 41 | use Phoenix.Controller, 42 | formats: [:html, :json], 43 | layouts: [html: YPhoenixWeb.Layouts] 44 | 45 | import Plug.Conn 46 | import YPhoenixWeb.Gettext 47 | 48 | unquote(verified_routes()) 49 | end 50 | end 51 | 52 | def live_view do 53 | quote do 54 | use Phoenix.LiveView, 55 | layout: {YPhoenixWeb.Layouts, :app} 56 | 57 | unquote(html_helpers()) 58 | end 59 | end 60 | 61 | def live_component do 62 | quote do 63 | use Phoenix.LiveComponent 64 | 65 | unquote(html_helpers()) 66 | end 67 | end 68 | 69 | def html do 70 | quote do 71 | use Phoenix.Component 72 | 73 | # Import convenience functions from controllers 74 | import Phoenix.Controller, 75 | only: [get_csrf_token: 0, view_module: 1, view_template: 1] 76 | 77 | # Include general helpers for rendering HTML 78 | unquote(html_helpers()) 79 | end 80 | end 81 | 82 | defp html_helpers do 83 | quote do 84 | # HTML escaping functionality 85 | import Phoenix.HTML 86 | # Core UI components and translation 87 | import YPhoenixWeb.CoreComponents 88 | import YPhoenixWeb.Gettext 89 | 90 | # Shortcut for generating JS commands 91 | alias Phoenix.LiveView.JS 92 | 93 | # Routes generation with the ~p sigil 94 | unquote(verified_routes()) 95 | end 96 | end 97 | 98 | def verified_routes do 99 | quote do 100 | use Phoenix.VerifiedRoutes, 101 | endpoint: YPhoenixWeb.Endpoint, 102 | router: YPhoenixWeb.Router, 103 | statics: YPhoenixWeb.static_paths() 104 | end 105 | end 106 | 107 | @doc """ 108 | When used, dispatch to the appropriate controller/live_view/etc. 109 | """ 110 | defmacro __using__(which) when is_atom(which) do 111 | apply(__MODULE__, which, []) 112 | end 113 | end 114 | -------------------------------------------------------------------------------- /priv/gettext/errors.pot: -------------------------------------------------------------------------------- 1 | ## This is a PO Template file. 2 | ## 3 | ## `msgid`s here are often extracted from source code. 4 | ## Add new translations manually only if they're dynamic 5 | ## translations that can't be statically extracted. 6 | ## 7 | ## Run `mix gettext.extract` to bring this file up to 8 | ## date. Leave `msgstr`s empty as changing them here has no 9 | ## effect: edit them in PO (`.po`) files instead. 10 | ## From Ecto.Changeset.cast/4 11 | msgid "can't be blank" 12 | msgstr "" 13 | 14 | ## From Ecto.Changeset.unique_constraint/3 15 | msgid "has already been taken" 16 | msgstr "" 17 | 18 | ## From Ecto.Changeset.put_change/3 19 | msgid "is invalid" 20 | msgstr "" 21 | 22 | ## From Ecto.Changeset.validate_acceptance/3 23 | msgid "must be accepted" 24 | msgstr "" 25 | 26 | ## From Ecto.Changeset.validate_format/3 27 | msgid "has invalid format" 28 | msgstr "" 29 | 30 | ## From Ecto.Changeset.validate_subset/3 31 | msgid "has an invalid entry" 32 | msgstr "" 33 | 34 | ## From Ecto.Changeset.validate_exclusion/3 35 | msgid "is reserved" 36 | msgstr "" 37 | 38 | ## From Ecto.Changeset.validate_confirmation/3 39 | msgid "does not match confirmation" 40 | msgstr "" 41 | 42 | ## From Ecto.Changeset.no_assoc_constraint/3 43 | msgid "is still associated with this entry" 44 | msgstr "" 45 | 46 | msgid "are still associated with this entry" 47 | msgstr "" 48 | 49 | ## From Ecto.Changeset.validate_length/3 50 | msgid "should have %{count} item(s)" 51 | msgid_plural "should have %{count} item(s)" 52 | msgstr[0] "" 53 | msgstr[1] "" 54 | 55 | msgid "should be %{count} character(s)" 56 | msgid_plural "should be %{count} character(s)" 57 | msgstr[0] "" 58 | msgstr[1] "" 59 | 60 | msgid "should be %{count} byte(s)" 61 | msgid_plural "should be %{count} byte(s)" 62 | msgstr[0] "" 63 | msgstr[1] "" 64 | 65 | msgid "should have at least %{count} item(s)" 66 | msgid_plural "should have at least %{count} item(s)" 67 | msgstr[0] "" 68 | msgstr[1] "" 69 | 70 | msgid "should be at least %{count} character(s)" 71 | msgid_plural "should be at least %{count} character(s)" 72 | msgstr[0] "" 73 | msgstr[1] "" 74 | 75 | msgid "should be at least %{count} byte(s)" 76 | msgid_plural "should be at least %{count} byte(s)" 77 | msgstr[0] "" 78 | msgstr[1] "" 79 | 80 | msgid "should have at most %{count} item(s)" 81 | msgid_plural "should have at most %{count} item(s)" 82 | msgstr[0] "" 83 | msgstr[1] "" 84 | 85 | msgid "should be at most %{count} character(s)" 86 | msgid_plural "should be at most %{count} character(s)" 87 | msgstr[0] "" 88 | msgstr[1] "" 89 | 90 | msgid "should be at most %{count} byte(s)" 91 | msgid_plural "should be at most %{count} byte(s)" 92 | msgstr[0] "" 93 | msgstr[1] "" 94 | 95 | ## From Ecto.Changeset.validate_number/3 96 | msgid "must be less than %{number}" 97 | msgstr "" 98 | 99 | msgid "must be greater than %{number}" 100 | msgstr "" 101 | 102 | msgid "must be less than or equal to %{number}" 103 | msgstr "" 104 | 105 | msgid "must be greater than or equal to %{number}" 106 | msgstr "" 107 | 108 | msgid "must be equal to %{number}" 109 | msgstr "" 110 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule YPhoenix.MixProject do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :y_phoenix, 7 | version: "0.1.0", 8 | elixir: "~> 1.14", 9 | elixirc_paths: elixirc_paths(Mix.env()), 10 | start_permanent: Mix.env() == :prod, 11 | aliases: aliases(), 12 | deps: deps() 13 | ] 14 | end 15 | 16 | # Configuration for the OTP application. 17 | # 18 | # Type `mix help compile.app` for more information. 19 | def application do 20 | [ 21 | mod: {YPhoenix.Application, []}, 22 | extra_applications: [:logger, :runtime_tools] 23 | ] 24 | end 25 | 26 | # Specifies which paths to compile per environment. 27 | defp elixirc_paths(:test), do: ["lib", "test/support"] 28 | defp elixirc_paths(_), do: ["lib"] 29 | 30 | # Specifies your project dependencies. 31 | # 32 | # Type `mix help deps` for examples and options. 33 | defp deps do 34 | [ 35 | {:phoenix, "1.8.2"}, 36 | {:phoenix_ecto, "4.7.0"}, 37 | {:ecto_sql, "3.13.2"}, 38 | {:postgrex, "0.21.1"}, 39 | {:ecto_psql_extras, "0.8.8"}, 40 | {:phoenix_html, "4.3.0"}, 41 | {:phoenix_live_reload, "1.6.1", only: :dev}, 42 | # TODO bump on release to {:phoenix_live_view, "~> 1.0.0"}, 43 | {:phoenix_live_view, "1.1.18", override: true}, 44 | {:floki, "0.38.0", only: :test}, 45 | {:phoenix_live_dashboard, "0.8.7"}, 46 | {:tailwind, "0.4.1", runtime: Mix.env() == :dev}, 47 | {:heroicons, 48 | github: "tailwindlabs/heroicons", 49 | tag: "v2.2.0", 50 | sparse: "optimized", 51 | app: false, 52 | compile: false, 53 | depth: 1}, 54 | {:swoosh, "1.19.8"}, 55 | {:finch, "0.20.0"}, 56 | {:telemetry_metrics, "1.1.0"}, 57 | {:telemetry_poller, "1.3.0"}, 58 | {:gettext, "1.0.2"}, 59 | {:jason, "1.4.4"}, 60 | {:dns_cluster, "0.2.0"}, 61 | {:bandit, "1.8.0"}, 62 | {:rustler, "0.37.1"}, 63 | {:y_ex, "== 0.10.1"} 64 | ] 65 | end 66 | 67 | # Aliases are shortcuts or tasks specific to the current project. 68 | # For example, to install project dependencies and perform other setup tasks, run: 69 | # 70 | # $ mix setup 71 | # 72 | # See the documentation for `Mix` for more info on aliases. 73 | defp aliases do 74 | [ 75 | setup: ["deps.get", "ecto.setup", "assets.setup", "assets.build"], 76 | "ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/seeds.exs"], 77 | "ecto.reset": ["ecto.drop", "ecto.setup"], 78 | test: ["ecto.create --quiet", "ecto.migrate --quiet", "test"], 79 | "assets.setup": [ 80 | "tailwind.install --if-missing", 81 | "cmd --cd assets npm install", 82 | "cmd --cd assets npm run install:all" 83 | ], 84 | "assets.build": ["tailwind y_phoenix", "cmd --cd assets node build.js"], 85 | "assets.deploy": [ 86 | "tailwind y_phoenix --minify", 87 | "cmd --cd assets npm install", 88 | "cmd --cd assets npm run install:all", 89 | "cmd --cd assets node build.js --deploy", 90 | "phx.digest" 91 | ] 92 | ] 93 | end 94 | end 95 | -------------------------------------------------------------------------------- /priv/gettext/en/LC_MESSAGES/errors.po: -------------------------------------------------------------------------------- 1 | ## `msgid`s in this file come from POT (.pot) files. 2 | ## 3 | ## Do not add, change, or remove `msgid`s manually here as 4 | ## they're tied to the ones in the corresponding POT file 5 | ## (with the same domain). 6 | ## 7 | ## Use `mix gettext.extract --merge` or `mix gettext.merge` 8 | ## to merge POT files into PO files. 9 | msgid "" 10 | msgstr "" 11 | "Language: en\n" 12 | 13 | ## From Ecto.Changeset.cast/4 14 | msgid "can't be blank" 15 | msgstr "" 16 | 17 | ## From Ecto.Changeset.unique_constraint/3 18 | msgid "has already been taken" 19 | msgstr "" 20 | 21 | ## From Ecto.Changeset.put_change/3 22 | msgid "is invalid" 23 | msgstr "" 24 | 25 | ## From Ecto.Changeset.validate_acceptance/3 26 | msgid "must be accepted" 27 | msgstr "" 28 | 29 | ## From Ecto.Changeset.validate_format/3 30 | msgid "has invalid format" 31 | msgstr "" 32 | 33 | ## From Ecto.Changeset.validate_subset/3 34 | msgid "has an invalid entry" 35 | msgstr "" 36 | 37 | ## From Ecto.Changeset.validate_exclusion/3 38 | msgid "is reserved" 39 | msgstr "" 40 | 41 | ## From Ecto.Changeset.validate_confirmation/3 42 | msgid "does not match confirmation" 43 | msgstr "" 44 | 45 | ## From Ecto.Changeset.no_assoc_constraint/3 46 | msgid "is still associated with this entry" 47 | msgstr "" 48 | 49 | msgid "are still associated with this entry" 50 | msgstr "" 51 | 52 | ## From Ecto.Changeset.validate_length/3 53 | msgid "should have %{count} item(s)" 54 | msgid_plural "should have %{count} item(s)" 55 | msgstr[0] "" 56 | msgstr[1] "" 57 | 58 | msgid "should be %{count} character(s)" 59 | msgid_plural "should be %{count} character(s)" 60 | msgstr[0] "" 61 | msgstr[1] "" 62 | 63 | msgid "should be %{count} byte(s)" 64 | msgid_plural "should be %{count} byte(s)" 65 | msgstr[0] "" 66 | msgstr[1] "" 67 | 68 | msgid "should have at least %{count} item(s)" 69 | msgid_plural "should have at least %{count} item(s)" 70 | msgstr[0] "" 71 | msgstr[1] "" 72 | 73 | msgid "should be at least %{count} character(s)" 74 | msgid_plural "should be at least %{count} character(s)" 75 | msgstr[0] "" 76 | msgstr[1] "" 77 | 78 | msgid "should be at least %{count} byte(s)" 79 | msgid_plural "should be at least %{count} byte(s)" 80 | msgstr[0] "" 81 | msgstr[1] "" 82 | 83 | msgid "should have at most %{count} item(s)" 84 | msgid_plural "should have at most %{count} item(s)" 85 | msgstr[0] "" 86 | msgstr[1] "" 87 | 88 | msgid "should be at most %{count} character(s)" 89 | msgid_plural "should be at most %{count} character(s)" 90 | msgstr[0] "" 91 | msgstr[1] "" 92 | 93 | msgid "should be at most %{count} byte(s)" 94 | msgid_plural "should be at most %{count} byte(s)" 95 | msgstr[0] "" 96 | msgstr[1] "" 97 | 98 | ## From Ecto.Changeset.validate_number/3 99 | msgid "must be less than %{number}" 100 | msgstr "" 101 | 102 | msgid "must be greater than %{number}" 103 | msgstr "" 104 | 105 | msgid "must be less than or equal to %{number}" 106 | msgstr "" 107 | 108 | msgid "must be greater than or equal to %{number}" 109 | msgstr "" 110 | 111 | msgid "must be equal to %{number}" 112 | msgstr "" 113 | -------------------------------------------------------------------------------- /assets/js/prosemirror/prosemirror.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | ySyncPlugin, 3 | yCursorPlugin, 4 | yUndoPlugin, 5 | initProseMirrorDoc, 6 | undo, 7 | redo, 8 | } from "y-prosemirror"; 9 | import { exampleSetup } from "prosemirror-example-setup"; 10 | import { PhoenixChannelProvider } from "y-phoenix-channel"; 11 | import { Socket } from "phoenix"; 12 | import * as Y from "yjs"; 13 | import { EditorState } from "prosemirror-state"; 14 | import { EditorView } from "prosemirror-view"; 15 | import { schema } from "prosemirror-schema-basic"; 16 | import React, { useEffect, useRef } from "react"; 17 | import { createRoot } from "react-dom/client"; 18 | import { keymap } from "prosemirror-keymap"; 19 | 20 | import "prosemirror-view/style/prosemirror.css"; 21 | import "prosemirror-example-setup/style/style.css"; 22 | import "prosemirror-menu/style/menu.css"; 23 | 24 | const socket = new Socket("/socket"); 25 | socket.connect(); 26 | const ydoc = new Y.Doc(); 27 | const docname = `prosemirror:${ 28 | new URLSearchParams(window.location.search).get("docname") ?? "prosemirror" 29 | }`; 30 | 31 | const Editor = ({ ydoc, room }) => { 32 | const editorContainerRef = useRef(null); 33 | 34 | useEffect(() => { 35 | if (!editorContainerRef.current) { 36 | return; 37 | } 38 | 39 | const initialize = async () => { 40 | const provider = new PhoenixChannelProvider( 41 | socket, 42 | `y_doc_room:${docname}`, 43 | ydoc, 44 | ); 45 | if (!provider.synced) { 46 | await new Promise((resolve) => { 47 | provider.once("synced", resolve); 48 | }); 49 | } 50 | 51 | const type = ydoc.get("prosemirror", Y.XmlFragment); 52 | const { doc, mapping } = initProseMirrorDoc(type, schema); 53 | const prosemirrorView = new EditorView(editorContainerRef.current, { 54 | state: EditorState.create({ 55 | doc, 56 | schema, 57 | plugins: [ 58 | ySyncPlugin(type, { mapping }), 59 | yCursorPlugin(provider.awareness), 60 | yUndoPlugin(), 61 | keymap({ 62 | "Mod-z": undo, 63 | "Mod-y": redo, 64 | "Mod-Shift-z": redo, 65 | }), 66 | ...exampleSetup({ schema, history: false }), 67 | ], 68 | }), 69 | }); 70 | 71 | return () => { 72 | provider.destroy(); 73 | prosemirrorView.destroy(); 74 | }; 75 | }; 76 | 77 | const cleanup = initialize(); 78 | 79 | return () => { 80 | cleanup.then((c) => { 81 | c(); 82 | }); 83 | }; 84 | }, []); 85 | 86 | return
; 87 | }; 88 | 89 | const App = () => { 90 | return ( 91 |
92 | 93 |
94 | ); 95 | }; 96 | const domNode = document.getElementById("root"); 97 | if (!domNode) { 98 | throw new Error("root element not found"); 99 | } 100 | 101 | const root = createRoot(domNode); 102 | root.render(); 103 | -------------------------------------------------------------------------------- /config/dev.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | # Configure your database 4 | config :y_phoenix, YPhoenix.Repo, 5 | username: "postgres", 6 | password: "postgres", 7 | hostname: "localhost", 8 | database: "y_phoenix_dev", 9 | stacktrace: true, 10 | show_sensitive_data_on_connection_error: true, 11 | pool_size: 10 12 | 13 | # For development, we disable any cache and enable 14 | # debugging and code reloading. 15 | # 16 | # The watchers configuration can be used to run external 17 | # watchers to your application. For example, we can use it 18 | # to bundle .js and .css sources. 19 | config :y_phoenix, YPhoenixWeb.Endpoint, 20 | # Binding to loopback ipv4 address prevents access from other machines. 21 | # Change to `ip: {0, 0, 0, 0}` to allow access from other machines. 22 | http: [ip: {127, 0, 0, 1}, port: 4000], 23 | check_origin: false, 24 | code_reloader: true, 25 | debug_errors: true, 26 | secret_key_base: "Y9pzyZ52nFk8jwDGMqAn1oozhFCpPUpp/WBa/2N+3RuNxTIVuF8H5nL+m1753hjx", 27 | watchers: [ 28 | # esbuild: {Esbuild, :install_and_run, [:y_phoenix, ~w(--sourcemap=inline --watch)]}, 29 | node: ["build.js", "--watch", cd: Path.expand("../assets", __DIR__)], 30 | tailwind: {Tailwind, :install_and_run, [:y_phoenix, ~w(--watch)]} 31 | ] 32 | 33 | # ## SSL Support 34 | # 35 | # In order to use HTTPS in development, a self-signed 36 | # certificate can be generated by running the following 37 | # Mix task: 38 | # 39 | # mix phx.gen.cert 40 | # 41 | # Run `mix help phx.gen.cert` for more information. 42 | # 43 | # The `http:` config above can be replaced with: 44 | # 45 | # https: [ 46 | # port: 4001, 47 | # cipher_suite: :strong, 48 | # keyfile: "priv/cert/selfsigned_key.pem", 49 | # certfile: "priv/cert/selfsigned.pem" 50 | # ], 51 | # 52 | # If desired, both `http:` and `https:` keys can be 53 | # configured to run both http and https servers on 54 | # different ports. 55 | 56 | # Watch static and templates for browser reloading. 57 | config :y_phoenix, YPhoenixWeb.Endpoint, 58 | live_reload: [ 59 | patterns: [ 60 | ~r"priv/static/(?!uploads/).*(js|css|png|jpeg|jpg|gif|svg)$", 61 | ~r"priv/gettext/.*(po)$", 62 | ~r"lib/y_phoenix_web/(controllers|live|components)/.*(ex|heex)$" 63 | ] 64 | ] 65 | 66 | # Enable dev routes for dashboard and mailbox 67 | config :y_phoenix, dev_routes: true 68 | 69 | # Do not include metadata nor timestamps in development logs 70 | config :logger, :console, format: "[$level] $message\n" 71 | 72 | # Set a higher stacktrace during development. Avoid configuring such 73 | # in production as building large stacktraces may be expensive. 74 | config :phoenix, :stacktrace_depth, 20 75 | 76 | # Initialize plugs at runtime for faster development compilation 77 | config :phoenix, :plug_init_mode, :runtime 78 | 79 | config :phoenix_live_view, 80 | # Include HEEx debug annotations as HTML comments in rendered markup 81 | debug_heex_annotations: true, 82 | # Enable helpful, but potentially expensive runtime checks 83 | enable_expensive_runtime_checks: true 84 | 85 | # Disable swoosh api client as it is only required for production adapters. 86 | config :swoosh, :api_client, false 87 | -------------------------------------------------------------------------------- /assets/tailwind.config.js: -------------------------------------------------------------------------------- 1 | // See the Tailwind configuration guide for advanced usage 2 | // https://tailwindcss.com/docs/configuration 3 | 4 | const plugin = require("tailwindcss/plugin"); 5 | const fs = require("fs"); 6 | const path = require("path"); 7 | 8 | module.exports = { 9 | content: [ 10 | "./js/**/*.js", 11 | "../lib/y_phoenix_web.ex", 12 | "../lib/y_phoenix_web/**/*.*ex", 13 | ], 14 | theme: { 15 | extend: { 16 | colors: { 17 | brand: "#FD4F00", 18 | }, 19 | }, 20 | }, 21 | plugins: [ 22 | require("@tailwindcss/forms"), 23 | // Allows prefixing tailwind classes with LiveView classes to add rules 24 | // only when LiveView classes are applied, for example: 25 | // 26 | //
27 | // 28 | plugin(({ addVariant }) => 29 | addVariant("phx-click-loading", [ 30 | ".phx-click-loading&", 31 | ".phx-click-loading &", 32 | ]), 33 | ), 34 | plugin(({ addVariant }) => 35 | addVariant("phx-submit-loading", [ 36 | ".phx-submit-loading&", 37 | ".phx-submit-loading &", 38 | ]), 39 | ), 40 | plugin(({ addVariant }) => 41 | addVariant("phx-change-loading", [ 42 | ".phx-change-loading&", 43 | ".phx-change-loading &", 44 | ]), 45 | ), 46 | 47 | // Embeds Heroicons (https://heroicons.com) into your app.css bundle 48 | // See your `CoreComponents.icon/1` for more information. 49 | // 50 | plugin(function ({ matchComponents, theme }) { 51 | let iconsDir = path.join(__dirname, "../deps/heroicons/optimized"); 52 | let values = {}; 53 | let icons = [ 54 | ["", "/24/outline"], 55 | ["-solid", "/24/solid"], 56 | ["-mini", "/20/solid"], 57 | ["-micro", "/16/solid"], 58 | ]; 59 | icons.forEach(([suffix, dir]) => { 60 | fs.readdirSync(path.join(iconsDir, dir)).forEach((file) => { 61 | let name = path.basename(file, ".svg") + suffix; 62 | values[name] = { name, fullPath: path.join(iconsDir, dir, file) }; 63 | }); 64 | }); 65 | matchComponents( 66 | { 67 | hero: ({ name, fullPath }) => { 68 | let content = fs 69 | .readFileSync(fullPath) 70 | .toString() 71 | .replace(/\r?\n|\r/g, ""); 72 | let size = theme("spacing.6"); 73 | if (name.endsWith("-mini")) { 74 | size = theme("spacing.5"); 75 | } else if (name.endsWith("-micro")) { 76 | size = theme("spacing.4"); 77 | } 78 | return { 79 | [`--hero-${name}`]: `url('data:image/svg+xml;utf8,${content}')`, 80 | "-webkit-mask": `var(--hero-${name})`, 81 | mask: `var(--hero-${name})`, 82 | "mask-repeat": "no-repeat", 83 | "background-color": "currentColor", 84 | "vertical-align": "middle", 85 | display: "inline-block", 86 | width: size, 87 | height: size, 88 | }; 89 | }, 90 | }, 91 | { values }, 92 | ); 93 | }), 94 | ], 95 | }; 96 | -------------------------------------------------------------------------------- /lib/y_phoenix_web/telemetry.ex: -------------------------------------------------------------------------------- 1 | defmodule YPhoenixWeb.Telemetry do 2 | use Supervisor 3 | import Telemetry.Metrics 4 | 5 | def start_link(arg) do 6 | Supervisor.start_link(__MODULE__, arg, name: __MODULE__) 7 | end 8 | 9 | @impl true 10 | def init(_arg) do 11 | children = [ 12 | # Telemetry poller will execute the given period measurements 13 | # every 10_000ms. Learn more here: https://hexdocs.pm/telemetry_metrics 14 | {:telemetry_poller, measurements: periodic_measurements(), period: 10_000} 15 | # Add reporters as children of your supervision tree. 16 | # {Telemetry.Metrics.ConsoleReporter, metrics: metrics()} 17 | ] 18 | 19 | Supervisor.init(children, strategy: :one_for_one) 20 | end 21 | 22 | def metrics do 23 | [ 24 | # Phoenix Metrics 25 | summary("phoenix.endpoint.start.system_time", 26 | unit: {:native, :millisecond} 27 | ), 28 | summary("phoenix.endpoint.stop.duration", 29 | unit: {:native, :millisecond} 30 | ), 31 | summary("phoenix.router_dispatch.start.system_time", 32 | tags: [:route], 33 | unit: {:native, :millisecond} 34 | ), 35 | summary("phoenix.router_dispatch.exception.duration", 36 | tags: [:route], 37 | unit: {:native, :millisecond} 38 | ), 39 | summary("phoenix.router_dispatch.stop.duration", 40 | tags: [:route], 41 | unit: {:native, :millisecond} 42 | ), 43 | summary("phoenix.socket_connected.duration", 44 | unit: {:native, :millisecond} 45 | ), 46 | summary("phoenix.channel_joined.duration", 47 | unit: {:native, :millisecond} 48 | ), 49 | summary("phoenix.channel_handled_in.duration", 50 | tags: [:event], 51 | unit: {:native, :millisecond} 52 | ), 53 | 54 | # Database Metrics 55 | summary("y_phoenix.repo.query.total_time", 56 | unit: {:native, :millisecond}, 57 | description: "The sum of the other measurements" 58 | ), 59 | summary("y_phoenix.repo.query.decode_time", 60 | unit: {:native, :millisecond}, 61 | description: "The time spent decoding the data received from the database" 62 | ), 63 | summary("y_phoenix.repo.query.query_time", 64 | unit: {:native, :millisecond}, 65 | description: "The time spent executing the query" 66 | ), 67 | summary("y_phoenix.repo.query.queue_time", 68 | unit: {:native, :millisecond}, 69 | description: "The time spent waiting for a database connection" 70 | ), 71 | summary("y_phoenix.repo.query.idle_time", 72 | unit: {:native, :millisecond}, 73 | description: 74 | "The time the connection spent waiting before being checked out for the query" 75 | ), 76 | 77 | # VM Metrics 78 | summary("vm.memory.total", unit: {:byte, :kilobyte}), 79 | summary("vm.total_run_queue_lengths.total"), 80 | summary("vm.total_run_queue_lengths.cpu"), 81 | summary("vm.total_run_queue_lengths.io") 82 | ] 83 | end 84 | 85 | defp periodic_measurements do 86 | [ 87 | # A module, function and arguments to be invoked periodically. 88 | # This function must call :telemetry.execute/3 and a metric must be added above. 89 | # {YPhoenixWeb, :count_users, []} 90 | ] 91 | end 92 | end 93 | -------------------------------------------------------------------------------- /config/runtime.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | # config/runtime.exs is executed for all environments, including 4 | # during releases. It is executed after compilation and before the 5 | # system starts, so it is typically used to load production configuration 6 | # and secrets from environment variables or elsewhere. Do not define 7 | # any compile-time configuration in here, as it won't be applied. 8 | # The block below contains prod specific runtime configuration. 9 | 10 | # ## Using releases 11 | # 12 | # If you use `mix release`, you need to explicitly enable the server 13 | # by passing the PHX_SERVER=true when you start it: 14 | # 15 | # PHX_SERVER=true bin/y_phoenix start 16 | # 17 | # Alternatively, you can use `mix phx.gen.release` to generate a `bin/server` 18 | # script that automatically sets the env var above. 19 | if System.get_env("PHX_SERVER") do 20 | config :y_phoenix, YPhoenixWeb.Endpoint, server: true 21 | end 22 | 23 | if config_env() == :prod do 24 | database_url = 25 | System.get_env("DATABASE_URL") || 26 | raise """ 27 | environment variable DATABASE_URL is missing. 28 | For example: ecto://USER:PASS@HOST/DATABASE 29 | """ 30 | 31 | maybe_ipv6 = if System.get_env("ECTO_IPV6") in ~w(true 1), do: [:inet6], else: [] 32 | 33 | config :y_phoenix, YPhoenix.Repo, 34 | # ssl: true, 35 | url: database_url, 36 | pool_size: String.to_integer(System.get_env("POOL_SIZE") || "10"), 37 | socket_options: maybe_ipv6 38 | 39 | # The secret key base is used to sign/encrypt cookies and other secrets. 40 | # A default value is used in config/dev.exs and config/test.exs but you 41 | # want to use a different value for prod and you most likely don't want 42 | # to check this value into version control, so we use an environment 43 | # variable instead. 44 | secret_key_base = 45 | System.get_env("SECRET_KEY_BASE") || 46 | raise """ 47 | environment variable SECRET_KEY_BASE is missing. 48 | You can generate one by calling: mix phx.gen.secret 49 | """ 50 | 51 | host = System.get_env("PHX_HOST") || "example.com" 52 | port = String.to_integer(System.get_env("PORT") || "4000") 53 | 54 | config :y_phoenix, :dns_cluster_query, System.get_env("DNS_CLUSTER_QUERY") 55 | 56 | config :y_phoenix, YPhoenixWeb.Endpoint, 57 | url: [host: host, port: 443, scheme: "https"], 58 | http: [ 59 | # Enable IPv6 and bind on all interfaces. 60 | # Set it to {0, 0, 0, 0, 0, 0, 0, 1} for local network only access. 61 | # See the documentation on https://hexdocs.pm/bandit/Bandit.html#t:options/0 62 | # for details about using IPv6 vs IPv4 and loopback vs public addresses. 63 | ip: {0, 0, 0, 0, 0, 0, 0, 0}, 64 | port: port 65 | ], 66 | secret_key_base: secret_key_base 67 | 68 | # ## SSL Support 69 | # 70 | # To get SSL working, you will need to add the `https` key 71 | # to your endpoint configuration: 72 | # 73 | # config :y_phoenix, YPhoenixWeb.Endpoint, 74 | # https: [ 75 | # ..., 76 | # port: 443, 77 | # cipher_suite: :strong, 78 | # keyfile: System.get_env("SOME_APP_SSL_KEY_PATH"), 79 | # certfile: System.get_env("SOME_APP_SSL_CERT_PATH") 80 | # ] 81 | # 82 | # The `cipher_suite` is set to `:strong` to support only the 83 | # latest and more secure SSL ciphers. This means old browsers 84 | # and clients may not be supported. You can set it to 85 | # `:compatible` for wider support. 86 | # 87 | # `:keyfile` and `:certfile` expect an absolute path to the key 88 | # and cert in disk or a relative path inside priv, for example 89 | # "priv/ssl/server.key". For all supported SSL configuration 90 | # options, see https://hexdocs.pm/plug/Plug.SSL.html#configure/1 91 | # 92 | # We also recommend setting `force_ssl` in your config/prod.exs, 93 | # ensuring no data is ever sent via http, always redirecting to https: 94 | # 95 | # config :y_phoenix, YPhoenixWeb.Endpoint, 96 | # force_ssl: [hsts: true] 97 | # 98 | # Check `Plug.SSL` for all available options in `force_ssl`. 99 | 100 | # ## Configuring the mailer 101 | # 102 | # In production you need to configure the mailer to use a different adapter. 103 | # Also, you may need to configure the Swoosh API client of your choice if you 104 | # are not using SMTP. Here is an example of the configuration: 105 | # 106 | # config :y_phoenix, YPhoenix.Mailer, 107 | # adapter: Swoosh.Adapters.Mailgun, 108 | # api_key: System.get_env("MAILGUN_API_KEY"), 109 | # domain: System.get_env("MAILGUN_DOMAIN") 110 | # 111 | # For this example you need include a HTTP client required by Swoosh API client. 112 | # Swoosh supports Hackney and Finch out of the box: 113 | # 114 | # config :swoosh, :api_client, Swoosh.ApiClient.Hackney 115 | # 116 | # See https://hexdocs.pm/swoosh/Swoosh.html#module-installation for details. 117 | end 118 | -------------------------------------------------------------------------------- /assets/js/js-draw/js-draw-cursor.ts: -------------------------------------------------------------------------------- 1 | import { type Editor, EditorEventType, Vec3 } from "js-draw"; 2 | 3 | type CursorElementProps = { 4 | canvasX: number; 5 | canvasY: number; 6 | color: string; 7 | name: string; 8 | }; 9 | class CursorElement { 10 | svg: SVGSVGElement; 11 | cursor: SVGPathElement; 12 | text: SVGTextElement; 13 | constructor( 14 | private props: CursorElementProps, 15 | private convert: (pos: { x: number; y: number }) => { 16 | x: number; 17 | y: number; 18 | }, 19 | ) { 20 | const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); 21 | svg.style.position = "absolute"; 22 | svg.style.pointerEvents = "none"; 23 | 24 | const cursor = document.createElementNS( 25 | "http://www.w3.org/2000/svg", 26 | "path", 27 | ); 28 | cursor.setAttribute( 29 | "d", 30 | "M6.0514 13.8265l-.2492-.0802L2.9457 7.2792C.1918 1.0445.092.8038.1699.5806.2228.4288.3482.3056.5341.2228.7984.1049 1.0774.1889 4.671 1.4678c2.1194.7542 5.1566 1.8335 6.7494 2.3984 2.7276.9673 2.9033 1.0434 3.0214 1.3084.079.1773.091.3553.0325.4813-.0943.2028-.263.3007-3.883 2.2529-.7343.396-1.1547.6679-1.2886.8336-.1093.1352-.6487 1.157-1.1987 2.2706-1.2672 2.5658-1.3349 2.6849-1.5911 2.7991-.1167.052-.3243.0585-.4613.0144Z", 31 | ); 32 | cursor.setAttribute("stroke", "black"); 33 | cursor.setAttribute("stroke-width", "2"); 34 | svg.appendChild(cursor); 35 | 36 | const cursorText = document.createElementNS( 37 | "http://www.w3.org/2000/svg", 38 | "text", 39 | ); 40 | cursorText.setAttribute("fill", "black"); 41 | cursorText.setAttribute("stroke", "black"); 42 | cursorText.setAttribute("stroke-width", "0.1"); 43 | cursorText.setAttribute("font-size", "14px"); 44 | cursorText.setAttribute("font-family", "Arial"); 45 | cursorText.setAttribute("font-family", "Arial"); 46 | cursorText.setAttribute("x", "0px"); 47 | cursorText.setAttribute("y", "28px"); 48 | svg.appendChild(cursorText); 49 | 50 | this.svg = svg; 51 | this.cursor = cursor; 52 | this.text = cursorText; 53 | this.update(props); 54 | } 55 | 56 | update(props: CursorElementProps) { 57 | this.props = props; 58 | this.cursor.setAttribute("fill", props.color); 59 | this.text.setAttribute("fill", props.color); 60 | this.text.textContent = props.name; 61 | this.updatePosition(); 62 | } 63 | updatePosition() { 64 | const { x, y } = this.convert({ 65 | x: this.props.canvasX, 66 | y: this.props.canvasY, 67 | }); 68 | this.svg.style.left = `${x}px`; 69 | this.svg.style.top = `${y}px`; 70 | } 71 | } 72 | 73 | type Listener = (pos: { x: number; y: number }) => void; 74 | 75 | export class JsDrawCursor { 76 | cursors: Map = new Map(); 77 | listeners: Listener[] = []; 78 | constructor( 79 | private editor: Editor, 80 | private overlay: HTMLElement, 81 | ) { 82 | const update = throttle((pos: Vec3) => { 83 | const canvasPosition = this.editor.viewport.screenToCanvas(pos); 84 | for (const listner of this.listeners) { 85 | listner({ x: canvasPosition.x, y: canvasPosition.y }); 86 | } 87 | }, 50); 88 | 89 | editor.getRootElement().addEventListener("mousemove", (e) => { 90 | update(Vec3.of(e.x, e.y, 0)); 91 | }); 92 | editor.notifier.on(EditorEventType.ViewportChanged, (e) => { 93 | this.updateViewport(); 94 | }); 95 | } 96 | 97 | addCursorChange(fn: Listener) { 98 | this.listeners.push(fn); 99 | return () => { 100 | this.listeners = this.listeners.filter((f) => f !== fn); 101 | }; 102 | } 103 | updateViewport() { 104 | for (const cursor of this.cursors.values()) { 105 | cursor.updatePosition(); 106 | } 107 | } 108 | 109 | updateCursor( 110 | id: string | number, 111 | cur: { 112 | x: number; 113 | y: number; 114 | color: string; 115 | name: string; 116 | }, 117 | ) { 118 | const c = this.cursors.get(id); 119 | const props = { ...cur, canvasX: cur.x, canvasY: cur.y }; 120 | if (c) { 121 | c.update(props); 122 | return; 123 | } 124 | 125 | const cursor = new CursorElement(props, (pos) => { 126 | return this.editor.viewport.canvasToScreen(Vec3.of(pos.x, pos.y, 0)).xy; 127 | }); 128 | this.overlay.appendChild(cursor.svg); 129 | this.cursors.set(id, cursor); 130 | } 131 | removeCursor(id: string | number) { 132 | const c = this.cursors.get(id); 133 | if (c) { 134 | c.svg.remove(); 135 | this.cursors.delete(id); 136 | } 137 | } 138 | } 139 | 140 | // biome-ignore lint/suspicious/noExplicitAny: any is used to avoid type errors 141 | function throttle void>( 142 | func: T, 143 | limit: number, 144 | ): T { 145 | let lastFunc: ReturnType | null = null; 146 | let lastRan: number | null = null; 147 | 148 | return function (this: ThisParameterType, ...args: Parameters) { 149 | if (lastRan === null) { 150 | func.apply(this, args); 151 | lastRan = Date.now(); 152 | } else { 153 | if (lastFunc) { 154 | clearTimeout(lastFunc); 155 | } 156 | lastFunc = setTimeout( 157 | () => { 158 | if (Date.now() - (lastRan as number) >= limit) { 159 | func.apply(this, args); 160 | lastRan = Date.now(); 161 | } 162 | }, 163 | limit - (Date.now() - lastRan), 164 | ); 165 | } 166 | } as T; 167 | } 168 | -------------------------------------------------------------------------------- /lib/y_phoenix_web/channels/doc_server.ex: -------------------------------------------------------------------------------- 1 | defmodule YPhoenixWeb.DocServer do 2 | use Yex.DocServer 3 | require Logger 4 | alias Yex.Awareness 5 | alias Yex.Sync 6 | alias YPhoenixWeb.Presence 7 | 8 | @persistence YPhoenix.EctoPersistence 9 | @ttl 5_000 10 | 11 | @impl true 12 | def init(option, %{doc: doc} = state) do 13 | topic = Keyword.fetch!(option, :topic) 14 | doc_name = Keyword.fetch!(option, :doc_name) 15 | Logger.info("DocServer for #{doc_name} initialized.") 16 | 17 | persistance_state = @persistence.bind(%{}, doc_name, doc) 18 | 19 | Presence.subscribe(topic) 20 | 21 | user_count = Presence.list_users(topic) |> length() 22 | 23 | {:ok, 24 | state 25 | |> assign(%{ 26 | topic: topic, 27 | doc_name: doc_name, 28 | origin_clients_map: %{}, 29 | user_count: user_count, 30 | persistance_state: persistance_state, 31 | shutdown_timer_ref: nil 32 | })} 33 | end 34 | 35 | @impl true 36 | def handle_update_v1(doc, update, origin, state) do 37 | persistance_state = 38 | @persistence.update_v1( 39 | state.assigns.persistance_state, 40 | update, 41 | state.assigns.doc_name, 42 | doc 43 | ) 44 | 45 | state = assign(state, :persistance_state, persistance_state) 46 | 47 | with {:ok, s} <- Sync.get_update(update), 48 | {:ok, message} <- Sync.message_encode({:sync, s}) do 49 | if origin do 50 | YPhoenixWeb.Endpoint.broadcast_from( 51 | origin, 52 | state.assigns.topic, 53 | "yjs", 54 | {:binary, message} 55 | ) 56 | else 57 | YPhoenixWeb.Endpoint.broadcast(state.assigns.topic, "yjs", {:binary, message}) 58 | end 59 | else 60 | error -> 61 | error 62 | end 63 | 64 | {:noreply, state} 65 | end 66 | 67 | @impl true 68 | def handle_awareness_update( 69 | awareness, 70 | %{removed: removed, added: added, updated: updated}, 71 | origin, 72 | state 73 | ) do 74 | updated_clients = added ++ updated ++ removed 75 | 76 | with {:ok, update} <- Awareness.encode_update(awareness, updated_clients), 77 | {:ok, message} <- Sync.message_encode({:awareness, update}) do 78 | broadcast_awareness_update(origin, state.assigns.topic, message) 79 | 80 | state = 81 | if origin do 82 | monitor_and_update_origin_clients_map(state, origin, added, removed) 83 | else 84 | state 85 | end 86 | 87 | {:noreply, state} 88 | else 89 | error -> 90 | Logger.log(:warning, error) 91 | {:noreply, state} 92 | end 93 | end 94 | 95 | defp broadcast_awareness_update(origin, topic, message) do 96 | if origin do 97 | YPhoenixWeb.Endpoint.broadcast_from(origin, topic, "yjs", {:binary, message}) 98 | else 99 | YPhoenixWeb.Endpoint.broadcast(topic, "yjs", {:binary, message}) 100 | end 101 | end 102 | 103 | defp monitor_and_update_origin_clients_map(state, origin, added, removed) do 104 | origin_clients_map = state.assigns[:origin_clients_map] || %{} 105 | entry = Map.get(origin_clients_map, origin) 106 | # Monitor if not already monitored 107 | ref = 108 | case entry do 109 | nil -> Process.monitor(origin) 110 | %{monitor_ref: r} -> r 111 | end 112 | 113 | # Update client_ids 114 | client_ids = 115 | case entry do 116 | nil -> 117 | added 118 | 119 | %{client_ids: prev} -> 120 | (added ++ prev) |> Enum.uniq() |> Enum.reject(&Enum.member?(removed, &1)) 121 | end 122 | 123 | # Demonitor if no client_ids left 124 | origin_clients_map = 125 | if client_ids == [] do 126 | Process.demonitor(ref, [:flush]) 127 | Map.delete(origin_clients_map, origin) 128 | else 129 | # Update map 130 | Map.put(origin_clients_map, origin, %{monitor_ref: ref, client_ids: client_ids}) 131 | end 132 | 133 | assign(state, %{origin_clients_map: origin_clients_map}) 134 | end 135 | 136 | def handle_info({:DOWN, ref, :process, pid, _reason}, state) do 137 | origin_clients_map = state.assigns[:origin_clients_map] || %{} 138 | 139 | case Map.get(origin_clients_map, pid) do 140 | %{client_ids: ids} -> 141 | Awareness.remove_states(state.awareness, ids) 142 | origin_clients_map = Map.delete(origin_clients_map, pid) 143 | {:noreply, assign(state, %{origin_clients_map: origin_clients_map})} 144 | 145 | _ -> 146 | {:noreply, state} 147 | end 148 | end 149 | 150 | @impl true 151 | def handle_info({Presence, {:join, _presence}}, state) do 152 | state = assign(state, :user_count, state.assigns.user_count + 1) 153 | # Cancel shutdown timer if a user joins 154 | if state.assigns.shutdown_timer_ref do 155 | Process.cancel_timer(state.assigns.shutdown_timer_ref) 156 | end 157 | 158 | {:noreply, assign(state, :shutdown_timer_ref, nil)} 159 | end 160 | 161 | def handle_info({Presence, {:leave, presence}}, state) do 162 | user_count = state.assigns.user_count - 1 163 | state = assign(state, :user_count, user_count) 164 | 165 | # Cancel existing shutdown timer if present 166 | if state.assigns.shutdown_timer_ref do 167 | Process.cancel_timer(state.assigns.shutdown_timer_ref) 168 | end 169 | 170 | # Set new shutdown timer if no users remain 171 | state = 172 | if user_count <= 0 do 173 | ref = Process.send_after(self(), :delayed_shutdown, @ttl) 174 | assign(state, :shutdown_timer_ref, ref) 175 | else 176 | assign(state, :shutdown_timer_ref, nil) 177 | end 178 | 179 | {:noreply, state} 180 | end 181 | 182 | def handle_info(:delayed_shutdown, state) do 183 | if state.assigns.user_count <= 0 do 184 | {:stop, :shutdown, state} 185 | else 186 | {:noreply, assign(state, :shutdown_timer_ref, nil)} 187 | end 188 | end 189 | 190 | @impl true 191 | def terminate(_reason, state) do 192 | @persistence.unbind( 193 | state.assigns.persistance_state, 194 | state.assigns.doc_name, 195 | state.doc 196 | ) 197 | 198 | Logger.info("DocServer for #{state.assigns.doc_name} terminated.") 199 | 200 | :ok 201 | end 202 | end 203 | -------------------------------------------------------------------------------- /assets/js/js-draw/y-js-draw.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | AbstractComponent, 3 | type Editor, 4 | EditorEventType, 5 | Erase, 6 | uniteCommands, 7 | Vec3, 8 | } from "js-draw"; 9 | 10 | import equal from "fast-deep-equal"; 11 | import type * as Y from "yjs"; 12 | import type * as awarenessProtocol from "y-protocols/awareness"; 13 | import { JsDrawCursor } from "./js-draw-cursor"; 14 | 15 | type JsDrawSerializedElement = { 16 | data: string | number | unknown[] | Record; 17 | id: string; 18 | loadSaveData: unknown; 19 | name: string; 20 | zIndex: number; 21 | }; 22 | 23 | export class JsDrawBinding { 24 | yElements: Y.Map; 25 | editor: Editor; 26 | awareness?: awarenessProtocol.Awareness; 27 | undoManager?: Y.UndoManager; 28 | 29 | subscriptions: (() => void)[] = []; 30 | 31 | constructor( 32 | ymap: Y.Map, 33 | editor: Editor, 34 | awareness?: awarenessProtocol.Awareness, 35 | cursorDrawer?: JsDrawCursor, 36 | ) { 37 | this.editor = editor; 38 | this.yElements = ymap; 39 | 40 | if (awareness) { 41 | this.setupAwareness(awareness, cursorDrawer); 42 | } 43 | 44 | this.subscriptions.push( 45 | editor.notifier.on(EditorEventType.CommandDone, (e) => { 46 | setTimeout(() => { 47 | try { 48 | this.syncToYjs(); 49 | } catch (e) { 50 | console.error(e); 51 | this.yElements.clear(); 52 | } 53 | }, 0); 54 | }).remove, 55 | ); 56 | this.subscriptions.push( 57 | editor.notifier.on(EditorEventType.CommandUndone, (e) => { 58 | setTimeout(() => { 59 | try { 60 | this.syncToYjs(); 61 | } catch (e) { 62 | console.error(e); 63 | this.yElements.clear(); 64 | } 65 | }, 0); 66 | }).remove, 67 | ); 68 | ymap.observe((events, txn) => { 69 | if (txn.origin === this) { 70 | return; 71 | } 72 | const commands = [...events.changes.keys.entries()] 73 | .flatMap(([key, event]) => { 74 | if (event.action === "add") { 75 | const data = ymap.get(key); 76 | const element = this.editor.image.lookupElement(key); 77 | if (!equal(data, element?.serialize())) { 78 | const newElement = AbstractComponent.deserialize(data); 79 | return [this.editor.image.addElement(newElement)]; 80 | } 81 | } 82 | if (event.action === "update") { 83 | const data = ymap.get(key); 84 | const element = this.editor.image.lookupElement(key); 85 | if (!equal(data, element?.serialize())) { 86 | const newElement = AbstractComponent.deserialize(data); 87 | if (element) { 88 | return [ 89 | new Erase([element]), 90 | this.editor.image.addElement(newElement), 91 | ]; 92 | } 93 | return [this.editor.image.addElement(newElement)]; 94 | } 95 | } 96 | if (event.action === "delete") { 97 | const element = this.editor.image.lookupElement(key); 98 | if (element) { 99 | return [new Erase([element])]; 100 | } 101 | } 102 | return []; 103 | }) 104 | .filter((command) => command != null); 105 | editor.dispatch(uniteCommands(commands)); 106 | }); 107 | } 108 | 109 | setupAwareness( 110 | awareness: awarenessProtocol.Awareness, 111 | cursorCanvas?: JsDrawCursor, 112 | ) { 113 | if (cursorCanvas) { 114 | cursorCanvas.addCursorChange((pos) => { 115 | awareness.setLocalStateField("cursor", pos); 116 | }); 117 | 118 | awareness.on( 119 | "change", 120 | ({ 121 | added, 122 | updated, 123 | removed, 124 | }: { 125 | added: number[]; 126 | updated: number[]; 127 | removed: number[]; 128 | }) => { 129 | for (const id of added) { 130 | if (id === awareness.clientID) { 131 | continue; 132 | } 133 | const cursor = awareness.getStates().get(id); 134 | if (cursor) { 135 | const { cursor: pos, user } = cursor; 136 | if (pos) { 137 | cursorCanvas.updateCursor(id, { ...pos, ...user }); 138 | } 139 | } 140 | } 141 | for (const id of updated) { 142 | if (id === awareness.clientID) { 143 | continue; 144 | } 145 | const cursor = awareness.getStates().get(id); 146 | if (cursor) { 147 | const { cursor: pos, user } = cursor; 148 | if (pos) { 149 | cursorCanvas.updateCursor(id, { ...pos, ...user }); 150 | } 151 | } 152 | } 153 | for (const id of removed) { 154 | if (id === awareness.clientID) { 155 | continue; 156 | } 157 | cursorCanvas.removeCursor(id); 158 | } 159 | }, 160 | ); 161 | } 162 | } 163 | 164 | syncToYjs() { 165 | const editor = this.editor; 166 | const yElements = this.yElements; 167 | const serializeElements = editor.image 168 | .getAllElements() 169 | .map((element) => element.serialize()); 170 | const elementsIds = serializeElements.map((element) => element.id); 171 | const added = serializeElements.filter( 172 | (element) => !yElements.has(element.id), 173 | ); 174 | const deleted = [...yElements.keys()].filter( 175 | (id) => !elementsIds.includes(id), 176 | ); 177 | const changed = serializeElements.filter((element) => { 178 | const data = yElements.get(element.id); 179 | 180 | return !equal(data, element); 181 | }); 182 | 183 | if (added.length === 0 && deleted.length === 0 && changed.length === 0) { 184 | return; 185 | } 186 | 187 | yElements.doc?.transact(() => { 188 | for (const element of added) { 189 | const data = element; 190 | yElements.set(data.id, data); 191 | } 192 | for (const id of deleted) { 193 | yElements.delete(id); 194 | } 195 | for (const element of changed) { 196 | yElements.set(element.id, element); 197 | } 198 | }, this); 199 | } 200 | } 201 | -------------------------------------------------------------------------------- /assets/js/tiptap/tiptap.scss: -------------------------------------------------------------------------------- 1 | /* Basic editor styles */ 2 | .tiptap { 3 | :first-child { 4 | margin-top: 0; 5 | } 6 | 7 | /* List styles */ 8 | ul, 9 | ol { 10 | padding: 0 1rem; 11 | margin: 1.25rem 1rem 1.25rem 0.4rem; 12 | 13 | li p { 14 | margin-top: 0.25em; 15 | margin-bottom: 0.25em; 16 | } 17 | } 18 | 19 | /* Heading styles */ 20 | h1, 21 | h2, 22 | h3, 23 | h4, 24 | h5, 25 | h6 { 26 | line-height: 1.1; 27 | margin-top: 2.5rem; 28 | text-wrap: pretty; 29 | } 30 | 31 | h1, 32 | h2 { 33 | margin-top: 3.5rem; 34 | margin-bottom: 1.5rem; 35 | } 36 | 37 | h1 { 38 | font-size: 1.4rem; 39 | } 40 | 41 | h2 { 42 | font-size: 1.2rem; 43 | } 44 | 45 | h3 { 46 | font-size: 1.1rem; 47 | } 48 | 49 | h4, 50 | h5, 51 | h6 { 52 | font-size: 1rem; 53 | } 54 | 55 | /* Code and preformatted text styles */ 56 | code { 57 | background-color: #ede9fe; 58 | border-radius: 0.4rem; 59 | color: #0d0d0d; 60 | font-size: 0.85rem; 61 | padding: 0.25em 0.3em; 62 | } 63 | 64 | pre { 65 | background: #0d0d0d; 66 | border-radius: 0.5rem; 67 | color: #fff; 68 | font-family: 'JetBrainsMono', monospace; 69 | margin: 1.5rem 0; 70 | padding: 0.75rem 1rem; 71 | 72 | code { 73 | background: none; 74 | color: inherit; 75 | font-size: 0.8rem; 76 | padding: 0; 77 | } 78 | } 79 | 80 | blockquote { 81 | border-left: 3px solid #d1d5db; 82 | margin: 1.5rem 0; 83 | padding-left: 1rem; 84 | } 85 | 86 | hr { 87 | border: none; 88 | border-top: 1px solid #e5e7eb; 89 | margin: 2rem 0; 90 | } 91 | 92 | /* Highlight specific styles */ 93 | mark { 94 | background-color: #FAF594; 95 | border-radius: 0.4rem; 96 | box-decoration-break: clone; 97 | padding: 0.1rem 0.3rem; 98 | } 99 | 100 | /* Task list specific styles */ 101 | ul[data-type="taskList"] { 102 | list-style: none; 103 | margin-left: 0; 104 | padding: 0; 105 | 106 | li { 107 | align-items: flex-start; 108 | display: flex; 109 | 110 | > label { 111 | flex: 0 0 auto; 112 | margin-right: 0.5rem; 113 | user-select: none; 114 | } 115 | 116 | > div { 117 | flex: 1 1 auto; 118 | } 119 | } 120 | 121 | input[type="checkbox"] { 122 | cursor: pointer; 123 | } 124 | 125 | ul[data-type="taskList"] { 126 | margin: 0; 127 | } 128 | } 129 | 130 | p { 131 | word-break: break-all; 132 | } 133 | 134 | /* Give a remote user a caret */ 135 | .collaboration-cursor__caret { 136 | border-left: 1px solid #0d0d0d; 137 | border-right: 1px solid #0d0d0d; 138 | margin-left: -1px; 139 | margin-right: -1px; 140 | pointer-events: none; 141 | position: relative; 142 | word-break: normal; 143 | } 144 | 145 | /* Render the username above the caret */ 146 | .collaboration-cursor__label { 147 | border-radius: 3px 3px 3px 0; 148 | color: #0d0d0d; 149 | font-size: 12px; 150 | font-style: normal; 151 | font-weight: 600; 152 | left: -1px; 153 | line-height: normal; 154 | padding: 0.1rem 0.3rem; 155 | position: absolute; 156 | top: -1.4em; 157 | user-select: none; 158 | white-space: nowrap; 159 | } 160 | } 161 | 162 | .col-group { 163 | display: flex; 164 | flex-direction: row; 165 | height: 100vh; 166 | 167 | @media (max-width: 540px) { 168 | flex-direction: column; 169 | } 170 | } 171 | 172 | /* Column-half */ 173 | body { 174 | overflow: hidden; 175 | } 176 | 177 | .column-half { 178 | display: flex; 179 | flex-direction: column; 180 | flex: 1; 181 | overflow: auto; 182 | 183 | &:last-child { 184 | border-left: 1px solid #d1d5db; 185 | 186 | @media (max-width: 540px) { 187 | border-left: none; 188 | border-top: 1px solid #d1d5db; 189 | } 190 | } 191 | 192 | & > .main-group { 193 | flex-grow: 1; 194 | } 195 | } 196 | 197 | /* Collaboration status */ 198 | .collab-status-group { 199 | align-items: center; 200 | background-color: #fff; 201 | border-top: 1px solid #d1d5db; 202 | bottom: 0; 203 | color: #6b7280; 204 | display: flex; 205 | flex-direction: row; 206 | font-size: 0.75rem; 207 | font-weight: 400; 208 | gap: 1rem; 209 | justify-content: space-between; 210 | padding: 0.375rem 0.5rem 0.375rem 1rem; 211 | position: sticky; 212 | width: 100%; 213 | z-index: 100; 214 | 215 | button { 216 | -webkit-box-orient: vertical; 217 | -webkit-line-clamp: 1; 218 | align-self: stretch; 219 | background: none; 220 | display: -webkit-box; 221 | flex-shrink: 1; 222 | font-size: 0.75rem; 223 | max-width: 100%; 224 | padding: 0.25rem 0.375rem; 225 | overflow: hidden; 226 | position: relative; 227 | text-overflow: ellipsis; 228 | white-space: nowrap; 229 | 230 | &::before { 231 | background-color: #ede9fe; 232 | border-radius: 0.375rem; 233 | content: ""; 234 | height: 100%; 235 | left: 0; 236 | opacity: 0.5; 237 | position: absolute; 238 | top: 0; 239 | transition: all 0.2s cubic-bezier(0.65,0.05,0.36,1); 240 | width: 100%; 241 | z-index: -1; 242 | } 243 | 244 | &:hover::before { 245 | opacity: 1; 246 | } 247 | } 248 | 249 | label { 250 | align-items: center; 251 | display: flex; 252 | flex-direction: row; 253 | flex-shrink: 0; 254 | gap: 0.375rem; 255 | line-height: 1.1; 256 | 257 | &::before { 258 | border-radius: 50%; 259 | content: " "; 260 | height: 0.35rem; 261 | width: 0.35rem; 262 | } 263 | } 264 | 265 | &[data-state="online"] { 266 | label { 267 | &::before { 268 | background-color: #22c55e; 269 | } 270 | } 271 | } 272 | 273 | &[data-state="offline"] { 274 | label { 275 | &::before { 276 | background-color: #ef4444; 277 | } 278 | } 279 | } 280 | } 281 | 282 | .button-group { 283 | display: flex; 284 | gap: 0.5rem; 285 | margin-bottom: 1rem; 286 | } 287 | 288 | button { 289 | background: #fff; 290 | border: 1px solid #d1d5db; 291 | border-radius: 0.375rem; 292 | color: #0d0d0d; 293 | cursor: pointer; 294 | font-size: 1rem; 295 | font-weight: 500; 296 | padding: 0.4em 1.1em; 297 | transition: background 0.15s, border 0.15s, color 0.15s; 298 | outline: none; 299 | box-shadow: 0 1px 2px rgba(0,0,0,0.01); 300 | } 301 | 302 | button:hover, button:focus { 303 | background: #f3f4f6; 304 | border-color: #9ca3af; 305 | } 306 | 307 | button.is-active, 308 | button .is-active { 309 | background: #ede9fe; /* 薄い紫 */ 310 | color: #5b21b6; /* 濃い紫 */ 311 | border-color: #a78bfa; /* 中間の紫 */ 312 | font-weight: 700; 313 | box-shadow: 0 2px 8px rgba(120, 80, 200, 0.10); 314 | outline: 2px solid #a78bfa; 315 | outline-offset: 1px; 316 | transition: background 0.15s, border 0.15s, color 0.15s, box-shadow 0.15s; 317 | } -------------------------------------------------------------------------------- /assets/js/tiptap/tiptap.tsx: -------------------------------------------------------------------------------- 1 | import { TaskItem } from "@tiptap/extension-list"; 2 | import Collaboration from "@tiptap/extension-collaboration"; 3 | import CollaborationCaret from "@tiptap/extension-collaboration-caret"; 4 | 5 | import { CharacterCount } from "@tiptap/extensions"; 6 | 7 | import { Highlight } from "@tiptap/extension-highlight"; 8 | import { TaskList } from "@tiptap/extension-list"; 9 | import { EditorContent, useEditor } from "@tiptap/react"; 10 | import StarterKit from "@tiptap/starter-kit"; 11 | import React, { useCallback, useEffect, useState } from "react"; 12 | import * as Y from "yjs"; 13 | import "./tiptap.scss"; 14 | import { createRoot } from "react-dom/client"; 15 | 16 | import { PhoenixChannelProvider } from "y-phoenix-channel"; 17 | import { Socket } from "phoenix"; 18 | 19 | const colors = [ 20 | "#958DF1", 21 | "#F98181", 22 | "#FBBC88", 23 | "#FAF594", 24 | "#70CFF8", 25 | "#94FADB", 26 | "#B9F18D", 27 | "#C3E2C2", 28 | "#EAECCC", 29 | "#AFC8AD", 30 | "#EEC759", 31 | "#9BB8CD", 32 | "#FF90BC", 33 | "#FFC0D9", 34 | "#DC8686", 35 | "#7ED7C1", 36 | "#F3EEEA", 37 | "#89B9AD", 38 | "#D0BFFF", 39 | "#FFF8C9", 40 | "#CBFFA9", 41 | "#9BABB8", 42 | "#E3F4F4", 43 | ]; 44 | const names = [ 45 | "Lea Thompson", 46 | "Cyndi Lauper", 47 | "Tom Cruise", 48 | "Madonna", 49 | "Jerry Hall", 50 | "Joan Collins", 51 | "Winona Ryder", 52 | "Christina Applegate", 53 | "Alyssa Milano", 54 | "Molly Ringwald", 55 | "Ally Sheedy", 56 | "Debbie Harry", 57 | "Olivia Newton-John", 58 | "Elton John", 59 | "Michael J. Fox", 60 | "Axl Rose", 61 | "Emilio Estevez", 62 | "Ralph Macchio", 63 | "Rob Lowe", 64 | "Jennifer Grey", 65 | "Mickey Rourke", 66 | "John Cusack", 67 | "Matthew Broderick", 68 | "Justine Bateman", 69 | "Lisa Bonet", 70 | ]; 71 | 72 | const defaultContent = ` 73 |

Hi 👋, this is a collaborative document.

74 |

Feel free to edit and collaborate in real-time!

75 | `; 76 | 77 | const getRandomElement = (list: T[]) => 78 | list[Math.floor(Math.random() * list.length)]; 79 | 80 | const getRandomColor = () => getRandomElement(colors); 81 | const getRandomName = () => getRandomElement(names); 82 | 83 | const getInitialUser = () => { 84 | return { 85 | name: getRandomName(), 86 | color: getRandomColor(), 87 | }; 88 | }; 89 | 90 | const Editor = ({ 91 | ydoc, 92 | provider, 93 | room, 94 | }: { 95 | ydoc: Y.Doc; 96 | provider: PhoenixChannelProvider; 97 | room: string; 98 | }) => { 99 | const [status, setStatus] = useState("connecting"); 100 | const [currentUser, setCurrentUser] = useState(getInitialUser); 101 | 102 | const editor = useEditor({ 103 | enableContentCheck: true, 104 | onContentError: ({ disableCollaboration }) => { 105 | disableCollaboration(); 106 | }, 107 | extensions: [ 108 | StarterKit.configure({ 109 | undoRedo: false, 110 | }), 111 | Highlight, 112 | TaskList, 113 | TaskItem, 114 | CharacterCount.extend().configure({ 115 | limit: 10000, 116 | }), 117 | Collaboration.extend().configure({ 118 | document: ydoc, 119 | }), 120 | CollaborationCaret.configure({ 121 | provider, 122 | user: currentUser, 123 | }), 124 | ], 125 | }); 126 | 127 | useEffect(() => { 128 | // Update status changes 129 | const statusHandler = (event: { 130 | status: "connecting" | "connected" | "disconnected"; 131 | }) => { 132 | setStatus(event.status); 133 | }; 134 | 135 | provider.on("status", statusHandler); 136 | 137 | return () => { 138 | provider.off("status", statusHandler); 139 | }; 140 | }, [provider]); 141 | 142 | useEffect(() => { 143 | provider.on("sync", () => { 144 | // The onSynced callback ensures initial content is set only once using editor.setContent(), preventing repetitive content loading on editor syncs. 145 | 146 | if (!ydoc.getMap("config").get("initialContentLoaded") && editor) { 147 | ydoc.getMap("config").set("initialContentLoaded", true); 148 | 149 | editor.commands.setContent(` 150 |

This is a radically reduced version of Tiptap. It has support for a document, with paragraphs and text. That’s it. It’s probably too much for real minimalists though.

151 |

The paragraph extension is not really required, but you need at least one node. Sure, that node can be something different.

152 | `); 153 | } 154 | }); 155 | }, [provider, editor]); 156 | 157 | const setName = useCallback(() => { 158 | const name = (window.prompt("Name", currentUser.name) || "") 159 | .trim() 160 | .substring(0, 32); 161 | 162 | if (name) { 163 | return setCurrentUser({ ...currentUser, name }); 164 | } 165 | }, [currentUser]); 166 | 167 | if (!editor) { 168 | return null; 169 | } 170 | 171 | return ( 172 |
173 |
174 |
175 | 181 | 187 | 193 | 199 | 205 |
206 |
207 | 208 | 209 | 210 |
214 | {" "} 215 | 222 | 225 |
226 |
227 | ); 228 | }; 229 | 230 | const socket = new Socket("/socket"); 231 | socket.connect(); 232 | const ydoc = new Y.Doc(); 233 | const docname = `tiptap:${ 234 | new URLSearchParams(window.location.search).get("docname") ?? "tiptap" 235 | }`; 236 | 237 | const provider = new PhoenixChannelProvider( 238 | socket, 239 | `y_doc_room:${docname}`, 240 | ydoc, 241 | ); 242 | 243 | const App = () => { 244 | return ( 245 |
246 | 247 |
248 | ); 249 | }; 250 | const domNode = document.getElementById("root"); 251 | if (!domNode) { 252 | throw new Error("root element not found"); 253 | } 254 | 255 | const root = createRoot(domNode); 256 | root.render(); 257 | -------------------------------------------------------------------------------- /assets/js/js-draw/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "js-draw-app", 3 | "version": "1.0.0", 4 | "lockfileVersion": 3, 5 | "requires": true, 6 | "packages": { 7 | "": { 8 | "name": "js-draw-app", 9 | "version": "1.0.0", 10 | "license": "MIT", 11 | "dependencies": { 12 | "@melloware/coloris": "0.25.0", 13 | "fast-deep-equal": "3.1.3", 14 | "friendly-username-generator": "2.0.4", 15 | "js-draw": "1.31.1", 16 | "phoenix": "1.8.2", 17 | "y-indexeddb": "9.0.12", 18 | "y-phoenix-channel": "0.1.1", 19 | "y-protocols": "1.0.6", 20 | "yjs": "13.6.27" 21 | } 22 | }, 23 | "node_modules/@js-draw/math": { 24 | "version": "1.31.1", 25 | "resolved": "https://registry.npmjs.org/@js-draw/math/-/math-1.31.1.tgz", 26 | "integrity": "sha512-ZVWfPY0VDQ+d25mu4PWGK8fVRfYRj0IgUfDhame4Lt2Y1+HbbDmcYoL8nCaD/4q4X3644MHNJW6E7IRfSrdNtg==", 27 | "license": "MIT", 28 | "dependencies": { 29 | "bezier-js": "6.1.3" 30 | } 31 | }, 32 | "node_modules/@melloware/coloris": { 33 | "version": "0.25.0", 34 | "resolved": "https://registry.npmjs.org/@melloware/coloris/-/coloris-0.25.0.tgz", 35 | "integrity": "sha512-RBWVFLjWbup7GRkOXb9g3+ZtR9AevFtJinrRz2cYPLjZ3TCkNRGMWuNbmQWbZ5cF3VU7aQDZwUsYgIY/bGrh2g==", 36 | "license": "MIT" 37 | }, 38 | "node_modules/@y/protocols": { 39 | "version": "1.0.6-1", 40 | "resolved": "https://registry.npmjs.org/@y/protocols/-/protocols-1.0.6-1.tgz", 41 | "integrity": "sha512-6hyVR4Azg+JVqeyCkPQMsg9BMpB7fgAldsIDwb5EqJTPLXkQuk/mqK/j0rvIZUuPvJjlYSDBIOQWNsy92iXQsQ==", 42 | "license": "MIT", 43 | "dependencies": { 44 | "lib0": "^0.2.85" 45 | }, 46 | "engines": { 47 | "node": ">=16.0.0", 48 | "npm": ">=8.0.0" 49 | }, 50 | "funding": { 51 | "type": "GitHub Sponsors ❤", 52 | "url": "https://github.com/sponsors/dmonad" 53 | }, 54 | "peerDependencies": { 55 | "yjs": "^14.0.0-1 || ^14 || ^13" 56 | } 57 | }, 58 | "node_modules/bezier-js": { 59 | "version": "6.1.3", 60 | "resolved": "https://registry.npmjs.org/bezier-js/-/bezier-js-6.1.3.tgz", 61 | "integrity": "sha512-VPFvkyO98oCJ1Tsi+bFBrKEWLdefAj4DJVaWp3xTEsdCbunC7Pt/nTeIgu/UdskBNcmHv8TOfsgdMZb1GsICmg==", 62 | "license": "MIT", 63 | "funding": { 64 | "type": "individual", 65 | "url": "https://github.com/Pomax/bezierjs/blob/master/FUNDING.md" 66 | } 67 | }, 68 | "node_modules/fast-deep-equal": { 69 | "version": "3.1.3", 70 | "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", 71 | "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", 72 | "license": "MIT" 73 | }, 74 | "node_modules/friendly-username-generator": { 75 | "version": "2.0.4", 76 | "resolved": "https://registry.npmjs.org/friendly-username-generator/-/friendly-username-generator-2.0.4.tgz", 77 | "integrity": "sha512-718y2+j8A28eEsR3AOK4Tp0U/69svwnE6CMtXAvleXHubDim/CsOc2EU4MGBkShB1WzMTO4iO/10JrKaiCc2Sw==", 78 | "license": "MIT" 79 | }, 80 | "node_modules/isomorphic.js": { 81 | "version": "0.2.5", 82 | "resolved": "https://registry.npmjs.org/isomorphic.js/-/isomorphic.js-0.2.5.tgz", 83 | "integrity": "sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==", 84 | "license": "MIT", 85 | "funding": { 86 | "type": "GitHub Sponsors ❤", 87 | "url": "https://github.com/sponsors/dmonad" 88 | } 89 | }, 90 | "node_modules/js-draw": { 91 | "version": "1.31.1", 92 | "resolved": "https://registry.npmjs.org/js-draw/-/js-draw-1.31.1.tgz", 93 | "integrity": "sha512-1dun70ZfeAGBhu3avN6z6kH+F3DsnVyCi7nykaHedJz3YrOVx8vhrvvNrSHa4s4AmTXS8vUMKfGbPOE//XB1zw==", 94 | "license": "MIT", 95 | "dependencies": { 96 | "@js-draw/math": "^1.31.1", 97 | "@melloware/coloris": "0.22.0" 98 | } 99 | }, 100 | "node_modules/js-draw/node_modules/@melloware/coloris": { 101 | "version": "0.22.0", 102 | "resolved": "https://registry.npmjs.org/@melloware/coloris/-/coloris-0.22.0.tgz", 103 | "integrity": "sha512-i06nJM0xQrgOkLFUi8d+8mrJjvFgPrU/nTM9vtRGip/T6yHUFIrNV7QgCyps3dBkKVRP/CeJzAeAIvJBSTSbFQ==", 104 | "license": "MIT" 105 | }, 106 | "node_modules/lib0": { 107 | "version": "0.2.114", 108 | "resolved": "https://registry.npmjs.org/lib0/-/lib0-0.2.114.tgz", 109 | "integrity": "sha512-gcxmNFzA4hv8UYi8j43uPlQ7CGcyMJ2KQb5kZASw6SnAKAf10hK12i2fjrS3Cl/ugZa5Ui6WwIu1/6MIXiHttQ==", 110 | "license": "MIT", 111 | "dependencies": { 112 | "isomorphic.js": "^0.2.4" 113 | }, 114 | "bin": { 115 | "0ecdsa-generate-keypair": "bin/0ecdsa-generate-keypair.js", 116 | "0gentesthtml": "bin/gentesthtml.js", 117 | "0serve": "bin/0serve.js" 118 | }, 119 | "engines": { 120 | "node": ">=16" 121 | }, 122 | "funding": { 123 | "type": "GitHub Sponsors ❤", 124 | "url": "https://github.com/sponsors/dmonad" 125 | } 126 | }, 127 | "node_modules/phoenix": { 128 | "version": "1.8.2", 129 | "resolved": "https://registry.npmjs.org/phoenix/-/phoenix-1.8.2.tgz", 130 | "integrity": "sha512-IcKJ4XNviIpWrQEhyg5oZAI4ajwtnbTpNh257aI8hQig85mS6XgL8cC4yoqdLPJPUSskpX2Hjzwa408S1ZTdQA==", 131 | "license": "MIT", 132 | "peer": true 133 | }, 134 | "node_modules/y-indexeddb": { 135 | "version": "9.0.12", 136 | "resolved": "https://registry.npmjs.org/y-indexeddb/-/y-indexeddb-9.0.12.tgz", 137 | "integrity": "sha512-9oCFRSPPzBK7/w5vOkJBaVCQZKHXB/v6SIT+WYhnJxlEC61juqG0hBrAf+y3gmSMLFLwICNH9nQ53uscuse6Hg==", 138 | "license": "MIT", 139 | "dependencies": { 140 | "lib0": "^0.2.74" 141 | }, 142 | "engines": { 143 | "node": ">=16.0.0", 144 | "npm": ">=8.0.0" 145 | }, 146 | "funding": { 147 | "type": "GitHub Sponsors ❤", 148 | "url": "https://github.com/sponsors/dmonad" 149 | }, 150 | "peerDependencies": { 151 | "yjs": "^13.0.0" 152 | } 153 | }, 154 | "node_modules/y-phoenix-channel": { 155 | "version": "0.1.1", 156 | "resolved": "https://registry.npmjs.org/y-phoenix-channel/-/y-phoenix-channel-0.1.1.tgz", 157 | "integrity": "sha512-OZpzq0AviYa/UKbCDnQsKAY3kyKpHVh82bIiEivkQ3JHaqxlF9KmiPdLrut6EGeLqvaxV6KxdAHHlEvTEMeJVA==", 158 | "license": "MIT", 159 | "dependencies": { 160 | "@y/protocols": "^1.0.6-1", 161 | "lib0": "^0.2.102" 162 | }, 163 | "peerDependencies": { 164 | "phoenix": "^1", 165 | "yjs": "^13" 166 | } 167 | }, 168 | "node_modules/y-protocols": { 169 | "version": "1.0.6", 170 | "resolved": "https://registry.npmjs.org/y-protocols/-/y-protocols-1.0.6.tgz", 171 | "integrity": "sha512-vHRF2L6iT3rwj1jub/K5tYcTT/mEYDUppgNPXwp8fmLpui9f7Yeq3OEtTLVF012j39QnV+KEQpNqoN7CWU7Y9Q==", 172 | "license": "MIT", 173 | "dependencies": { 174 | "lib0": "^0.2.85" 175 | }, 176 | "engines": { 177 | "node": ">=16.0.0", 178 | "npm": ">=8.0.0" 179 | }, 180 | "funding": { 181 | "type": "GitHub Sponsors ❤", 182 | "url": "https://github.com/sponsors/dmonad" 183 | }, 184 | "peerDependencies": { 185 | "yjs": "^13.0.0" 186 | } 187 | }, 188 | "node_modules/yjs": { 189 | "version": "13.6.27", 190 | "resolved": "https://registry.npmjs.org/yjs/-/yjs-13.6.27.tgz", 191 | "integrity": "sha512-OIDwaflOaq4wC6YlPBy2L6ceKeKuF7DeTxx+jPzv1FHn9tCZ0ZwSRnUBxD05E3yed46fv/FWJbvR+Ud7x0L7zw==", 192 | "license": "MIT", 193 | "peer": true, 194 | "dependencies": { 195 | "lib0": "^0.2.99" 196 | }, 197 | "engines": { 198 | "node": ">=16.0.0", 199 | "npm": ">=8.0.0" 200 | }, 201 | "funding": { 202 | "type": "GitHub Sponsors ❤", 203 | "url": "https://github.com/sponsors/dmonad" 204 | } 205 | } 206 | } 207 | } 208 | -------------------------------------------------------------------------------- /assets/js/quill/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "quill-app", 3 | "version": "1.0.0", 4 | "lockfileVersion": 3, 5 | "requires": true, 6 | "packages": { 7 | "": { 8 | "name": "quill-app", 9 | "version": "1.0.0", 10 | "license": "MIT", 11 | "dependencies": { 12 | "friendly-username-generator": "2.0.4", 13 | "phoenix": "1.8.2", 14 | "quill": "2.0.3", 15 | "quill-cursors": "4.0.4", 16 | "y-indexeddb": "9.0.12", 17 | "y-phoenix-channel": "0.1.1", 18 | "y-quill": "1.0.0", 19 | "yjs": "13.6.27" 20 | } 21 | }, 22 | "node_modules/@y/protocols": { 23 | "version": "1.0.6-1", 24 | "resolved": "https://registry.npmjs.org/@y/protocols/-/protocols-1.0.6-1.tgz", 25 | "integrity": "sha512-6hyVR4Azg+JVqeyCkPQMsg9BMpB7fgAldsIDwb5EqJTPLXkQuk/mqK/j0rvIZUuPvJjlYSDBIOQWNsy92iXQsQ==", 26 | "license": "MIT", 27 | "dependencies": { 28 | "lib0": "^0.2.85" 29 | }, 30 | "engines": { 31 | "node": ">=16.0.0", 32 | "npm": ">=8.0.0" 33 | }, 34 | "funding": { 35 | "type": "GitHub Sponsors ❤", 36 | "url": "https://github.com/sponsors/dmonad" 37 | }, 38 | "peerDependencies": { 39 | "yjs": "^14.0.0-1 || ^14 || ^13" 40 | } 41 | }, 42 | "node_modules/eventemitter3": { 43 | "version": "5.0.1", 44 | "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", 45 | "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", 46 | "license": "MIT" 47 | }, 48 | "node_modules/fast-diff": { 49 | "version": "1.3.0", 50 | "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz", 51 | "integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==", 52 | "license": "Apache-2.0" 53 | }, 54 | "node_modules/friendly-username-generator": { 55 | "version": "2.0.4", 56 | "resolved": "https://registry.npmjs.org/friendly-username-generator/-/friendly-username-generator-2.0.4.tgz", 57 | "integrity": "sha512-718y2+j8A28eEsR3AOK4Tp0U/69svwnE6CMtXAvleXHubDim/CsOc2EU4MGBkShB1WzMTO4iO/10JrKaiCc2Sw==", 58 | "license": "MIT" 59 | }, 60 | "node_modules/isomorphic.js": { 61 | "version": "0.2.5", 62 | "resolved": "https://registry.npmjs.org/isomorphic.js/-/isomorphic.js-0.2.5.tgz", 63 | "integrity": "sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==", 64 | "license": "MIT", 65 | "funding": { 66 | "type": "GitHub Sponsors ❤", 67 | "url": "https://github.com/sponsors/dmonad" 68 | } 69 | }, 70 | "node_modules/lib0": { 71 | "version": "0.2.114", 72 | "resolved": "https://registry.npmjs.org/lib0/-/lib0-0.2.114.tgz", 73 | "integrity": "sha512-gcxmNFzA4hv8UYi8j43uPlQ7CGcyMJ2KQb5kZASw6SnAKAf10hK12i2fjrS3Cl/ugZa5Ui6WwIu1/6MIXiHttQ==", 74 | "license": "MIT", 75 | "dependencies": { 76 | "isomorphic.js": "^0.2.4" 77 | }, 78 | "bin": { 79 | "0ecdsa-generate-keypair": "bin/0ecdsa-generate-keypair.js", 80 | "0gentesthtml": "bin/gentesthtml.js", 81 | "0serve": "bin/0serve.js" 82 | }, 83 | "engines": { 84 | "node": ">=16" 85 | }, 86 | "funding": { 87 | "type": "GitHub Sponsors ❤", 88 | "url": "https://github.com/sponsors/dmonad" 89 | } 90 | }, 91 | "node_modules/lodash-es": { 92 | "version": "4.17.21", 93 | "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz", 94 | "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==", 95 | "license": "MIT" 96 | }, 97 | "node_modules/lodash.clonedeep": { 98 | "version": "4.5.0", 99 | "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", 100 | "integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==", 101 | "license": "MIT" 102 | }, 103 | "node_modules/lodash.isequal": { 104 | "version": "4.5.0", 105 | "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", 106 | "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==", 107 | "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.", 108 | "license": "MIT" 109 | }, 110 | "node_modules/parchment": { 111 | "version": "3.0.0", 112 | "resolved": "https://registry.npmjs.org/parchment/-/parchment-3.0.0.tgz", 113 | "integrity": "sha512-HUrJFQ/StvgmXRcQ1ftY6VEZUq3jA2t9ncFN4F84J/vN0/FPpQF+8FKXb3l6fLces6q0uOHj6NJn+2xvZnxO6A==", 114 | "license": "BSD-3-Clause" 115 | }, 116 | "node_modules/phoenix": { 117 | "version": "1.8.2", 118 | "resolved": "https://registry.npmjs.org/phoenix/-/phoenix-1.8.2.tgz", 119 | "integrity": "sha512-IcKJ4XNviIpWrQEhyg5oZAI4ajwtnbTpNh257aI8hQig85mS6XgL8cC4yoqdLPJPUSskpX2Hjzwa408S1ZTdQA==", 120 | "license": "MIT", 121 | "peer": true 122 | }, 123 | "node_modules/quill": { 124 | "version": "2.0.3", 125 | "resolved": "https://registry.npmjs.org/quill/-/quill-2.0.3.tgz", 126 | "integrity": "sha512-xEYQBqfYx/sfb33VJiKnSJp8ehloavImQ2A6564GAbqG55PGw1dAWUn1MUbQB62t0azawUS2CZZhWCjO8gRvTw==", 127 | "license": "BSD-3-Clause", 128 | "peer": true, 129 | "dependencies": { 130 | "eventemitter3": "^5.0.1", 131 | "lodash-es": "^4.17.21", 132 | "parchment": "^3.0.0", 133 | "quill-delta": "^5.1.0" 134 | }, 135 | "engines": { 136 | "npm": ">=8.2.3" 137 | } 138 | }, 139 | "node_modules/quill-cursors": { 140 | "version": "4.0.4", 141 | "resolved": "https://registry.npmjs.org/quill-cursors/-/quill-cursors-4.0.4.tgz", 142 | "integrity": "sha512-beHOYwRZ/I+Ift3bsvMnNWZ7gX25upW3b0aREpklUTR273MFJgxsCYmlgd/6otBE0FtFefOfh2/xU6xbkkxgIg==", 143 | "license": "MIT", 144 | "peer": true 145 | }, 146 | "node_modules/quill-delta": { 147 | "version": "5.1.0", 148 | "resolved": "https://registry.npmjs.org/quill-delta/-/quill-delta-5.1.0.tgz", 149 | "integrity": "sha512-X74oCeRI4/p0ucjb5Ma8adTXd9Scumz367kkMK5V/IatcX6A0vlgLgKbzXWy5nZmCGeNJm2oQX0d2Eqj+ZIlCA==", 150 | "license": "MIT", 151 | "dependencies": { 152 | "fast-diff": "^1.3.0", 153 | "lodash.clonedeep": "^4.5.0", 154 | "lodash.isequal": "^4.5.0" 155 | }, 156 | "engines": { 157 | "node": ">= 12.0.0" 158 | } 159 | }, 160 | "node_modules/y-indexeddb": { 161 | "version": "9.0.12", 162 | "resolved": "https://registry.npmjs.org/y-indexeddb/-/y-indexeddb-9.0.12.tgz", 163 | "integrity": "sha512-9oCFRSPPzBK7/w5vOkJBaVCQZKHXB/v6SIT+WYhnJxlEC61juqG0hBrAf+y3gmSMLFLwICNH9nQ53uscuse6Hg==", 164 | "license": "MIT", 165 | "dependencies": { 166 | "lib0": "^0.2.74" 167 | }, 168 | "engines": { 169 | "node": ">=16.0.0", 170 | "npm": ">=8.0.0" 171 | }, 172 | "funding": { 173 | "type": "GitHub Sponsors ❤", 174 | "url": "https://github.com/sponsors/dmonad" 175 | }, 176 | "peerDependencies": { 177 | "yjs": "^13.0.0" 178 | } 179 | }, 180 | "node_modules/y-phoenix-channel": { 181 | "version": "0.1.1", 182 | "resolved": "https://registry.npmjs.org/y-phoenix-channel/-/y-phoenix-channel-0.1.1.tgz", 183 | "integrity": "sha512-OZpzq0AviYa/UKbCDnQsKAY3kyKpHVh82bIiEivkQ3JHaqxlF9KmiPdLrut6EGeLqvaxV6KxdAHHlEvTEMeJVA==", 184 | "license": "MIT", 185 | "dependencies": { 186 | "@y/protocols": "^1.0.6-1", 187 | "lib0": "^0.2.102" 188 | }, 189 | "peerDependencies": { 190 | "phoenix": "^1", 191 | "yjs": "^13" 192 | } 193 | }, 194 | "node_modules/y-protocols": { 195 | "version": "1.0.6", 196 | "resolved": "https://registry.npmjs.org/y-protocols/-/y-protocols-1.0.6.tgz", 197 | "integrity": "sha512-vHRF2L6iT3rwj1jub/K5tYcTT/mEYDUppgNPXwp8fmLpui9f7Yeq3OEtTLVF012j39QnV+KEQpNqoN7CWU7Y9Q==", 198 | "license": "MIT", 199 | "dependencies": { 200 | "lib0": "^0.2.85" 201 | }, 202 | "engines": { 203 | "node": ">=16.0.0", 204 | "npm": ">=8.0.0" 205 | }, 206 | "funding": { 207 | "type": "GitHub Sponsors ❤", 208 | "url": "https://github.com/sponsors/dmonad" 209 | }, 210 | "peerDependencies": { 211 | "yjs": "^13.0.0" 212 | } 213 | }, 214 | "node_modules/y-quill": { 215 | "version": "1.0.0", 216 | "resolved": "https://registry.npmjs.org/y-quill/-/y-quill-1.0.0.tgz", 217 | "integrity": "sha512-WpYBXsFXdofGuaAVyvKpZ3rg+TklWtKtpemUziY044NLhnwud0D+QTX2mdGKMrLON+BshKQeT77FbXa68ZJbcA==", 218 | "license": "MIT", 219 | "dependencies": { 220 | "lib0": "^0.2.93", 221 | "y-protocols": "^1.0.6" 222 | }, 223 | "funding": { 224 | "type": "GitHub Sponsors ❤", 225 | "url": "https://github.com/sponsors/dmonad" 226 | }, 227 | "peerDependencies": { 228 | "quill": "^2.0.0", 229 | "quill-cursors": "^4.0.2", 230 | "yjs": "^13.6.14" 231 | } 232 | }, 233 | "node_modules/yjs": { 234 | "version": "13.6.27", 235 | "resolved": "https://registry.npmjs.org/yjs/-/yjs-13.6.27.tgz", 236 | "integrity": "sha512-OIDwaflOaq4wC6YlPBy2L6ceKeKuF7DeTxx+jPzv1FHn9tCZ0ZwSRnUBxD05E3yed46fv/FWJbvR+Ud7x0L7zw==", 237 | "license": "MIT", 238 | "peer": true, 239 | "dependencies": { 240 | "lib0": "^0.2.99" 241 | }, 242 | "engines": { 243 | "node": ">=16.0.0", 244 | "npm": ">=8.0.0" 245 | }, 246 | "funding": { 247 | "type": "GitHub Sponsors ❤", 248 | "url": "https://github.com/sponsors/dmonad" 249 | } 250 | } 251 | } 252 | } 253 | -------------------------------------------------------------------------------- /assets/js/prosemirror/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "prosemirror-app", 3 | "version": "1.0.0", 4 | "lockfileVersion": 3, 5 | "requires": true, 6 | "packages": { 7 | "": { 8 | "name": "prosemirror-app", 9 | "version": "1.0.0", 10 | "license": "MIT", 11 | "dependencies": { 12 | "phoenix": "1.8.2", 13 | "prosemirror-example-setup": "1.2.3", 14 | "prosemirror-keymap": "1.2.3", 15 | "prosemirror-schema-basic": "1.2.4", 16 | "prosemirror-state": "1.4.4", 17 | "prosemirror-view": "1.41.4", 18 | "react": "19.2.1", 19 | "react-dom": "19.2.1", 20 | "y-phoenix-channel": "0.1.1", 21 | "y-prosemirror": "1.3.7", 22 | "yjs": "13.6.27" 23 | } 24 | }, 25 | "node_modules/@y/protocols": { 26 | "version": "1.0.6-1", 27 | "resolved": "https://registry.npmjs.org/@y/protocols/-/protocols-1.0.6-1.tgz", 28 | "integrity": "sha512-6hyVR4Azg+JVqeyCkPQMsg9BMpB7fgAldsIDwb5EqJTPLXkQuk/mqK/j0rvIZUuPvJjlYSDBIOQWNsy92iXQsQ==", 29 | "license": "MIT", 30 | "dependencies": { 31 | "lib0": "^0.2.85" 32 | }, 33 | "engines": { 34 | "node": ">=16.0.0", 35 | "npm": ">=8.0.0" 36 | }, 37 | "funding": { 38 | "type": "GitHub Sponsors ❤", 39 | "url": "https://github.com/sponsors/dmonad" 40 | }, 41 | "peerDependencies": { 42 | "yjs": "^14.0.0-1 || ^14 || ^13" 43 | } 44 | }, 45 | "node_modules/crelt": { 46 | "version": "1.0.6", 47 | "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz", 48 | "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==", 49 | "license": "MIT" 50 | }, 51 | "node_modules/isomorphic.js": { 52 | "version": "0.2.5", 53 | "resolved": "https://registry.npmjs.org/isomorphic.js/-/isomorphic.js-0.2.5.tgz", 54 | "integrity": "sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==", 55 | "license": "MIT", 56 | "funding": { 57 | "type": "GitHub Sponsors ❤", 58 | "url": "https://github.com/sponsors/dmonad" 59 | } 60 | }, 61 | "node_modules/lib0": { 62 | "version": "0.2.114", 63 | "resolved": "https://registry.npmjs.org/lib0/-/lib0-0.2.114.tgz", 64 | "integrity": "sha512-gcxmNFzA4hv8UYi8j43uPlQ7CGcyMJ2KQb5kZASw6SnAKAf10hK12i2fjrS3Cl/ugZa5Ui6WwIu1/6MIXiHttQ==", 65 | "license": "MIT", 66 | "dependencies": { 67 | "isomorphic.js": "^0.2.4" 68 | }, 69 | "bin": { 70 | "0ecdsa-generate-keypair": "bin/0ecdsa-generate-keypair.js", 71 | "0gentesthtml": "bin/gentesthtml.js", 72 | "0serve": "bin/0serve.js" 73 | }, 74 | "engines": { 75 | "node": ">=16" 76 | }, 77 | "funding": { 78 | "type": "GitHub Sponsors ❤", 79 | "url": "https://github.com/sponsors/dmonad" 80 | } 81 | }, 82 | "node_modules/orderedmap": { 83 | "version": "2.1.1", 84 | "resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz", 85 | "integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==", 86 | "license": "MIT" 87 | }, 88 | "node_modules/phoenix": { 89 | "version": "1.8.2", 90 | "resolved": "https://registry.npmjs.org/phoenix/-/phoenix-1.8.2.tgz", 91 | "integrity": "sha512-IcKJ4XNviIpWrQEhyg5oZAI4ajwtnbTpNh257aI8hQig85mS6XgL8cC4yoqdLPJPUSskpX2Hjzwa408S1ZTdQA==", 92 | "license": "MIT", 93 | "peer": true 94 | }, 95 | "node_modules/prosemirror-commands": { 96 | "version": "1.7.1", 97 | "resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.7.1.tgz", 98 | "integrity": "sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w==", 99 | "license": "MIT", 100 | "dependencies": { 101 | "prosemirror-model": "^1.0.0", 102 | "prosemirror-state": "^1.0.0", 103 | "prosemirror-transform": "^1.10.2" 104 | } 105 | }, 106 | "node_modules/prosemirror-dropcursor": { 107 | "version": "1.8.2", 108 | "resolved": "https://registry.npmjs.org/prosemirror-dropcursor/-/prosemirror-dropcursor-1.8.2.tgz", 109 | "integrity": "sha512-CCk6Gyx9+Tt2sbYk5NK0nB1ukHi2ryaRgadV/LvyNuO3ena1payM2z6Cg0vO1ebK8cxbzo41ku2DE5Axj1Zuiw==", 110 | "license": "MIT", 111 | "dependencies": { 112 | "prosemirror-state": "^1.0.0", 113 | "prosemirror-transform": "^1.1.0", 114 | "prosemirror-view": "^1.1.0" 115 | } 116 | }, 117 | "node_modules/prosemirror-example-setup": { 118 | "version": "1.2.3", 119 | "resolved": "https://registry.npmjs.org/prosemirror-example-setup/-/prosemirror-example-setup-1.2.3.tgz", 120 | "integrity": "sha512-+hXZi8+xbFvYM465zZH3rdZ9w7EguVKmUYwYLZjIJIjPK+I0nPTwn8j0ByW2avchVczRwZmOJGNvehblyIerSQ==", 121 | "license": "MIT", 122 | "dependencies": { 123 | "prosemirror-commands": "^1.0.0", 124 | "prosemirror-dropcursor": "^1.0.0", 125 | "prosemirror-gapcursor": "^1.0.0", 126 | "prosemirror-history": "^1.0.0", 127 | "prosemirror-inputrules": "^1.0.0", 128 | "prosemirror-keymap": "^1.0.0", 129 | "prosemirror-menu": "^1.0.0", 130 | "prosemirror-schema-list": "^1.0.0", 131 | "prosemirror-state": "^1.0.0" 132 | } 133 | }, 134 | "node_modules/prosemirror-gapcursor": { 135 | "version": "1.4.0", 136 | "resolved": "https://registry.npmjs.org/prosemirror-gapcursor/-/prosemirror-gapcursor-1.4.0.tgz", 137 | "integrity": "sha512-z00qvurSdCEWUIulij/isHaqu4uLS8r/Fi61IbjdIPJEonQgggbJsLnstW7Lgdk4zQ68/yr6B6bf7sJXowIgdQ==", 138 | "license": "MIT", 139 | "dependencies": { 140 | "prosemirror-keymap": "^1.0.0", 141 | "prosemirror-model": "^1.0.0", 142 | "prosemirror-state": "^1.0.0", 143 | "prosemirror-view": "^1.0.0" 144 | } 145 | }, 146 | "node_modules/prosemirror-history": { 147 | "version": "1.5.0", 148 | "resolved": "https://registry.npmjs.org/prosemirror-history/-/prosemirror-history-1.5.0.tgz", 149 | "integrity": "sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==", 150 | "license": "MIT", 151 | "dependencies": { 152 | "prosemirror-state": "^1.2.2", 153 | "prosemirror-transform": "^1.0.0", 154 | "prosemirror-view": "^1.31.0", 155 | "rope-sequence": "^1.3.0" 156 | } 157 | }, 158 | "node_modules/prosemirror-inputrules": { 159 | "version": "1.5.1", 160 | "resolved": "https://registry.npmjs.org/prosemirror-inputrules/-/prosemirror-inputrules-1.5.1.tgz", 161 | "integrity": "sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw==", 162 | "license": "MIT", 163 | "dependencies": { 164 | "prosemirror-state": "^1.0.0", 165 | "prosemirror-transform": "^1.0.0" 166 | } 167 | }, 168 | "node_modules/prosemirror-keymap": { 169 | "version": "1.2.3", 170 | "resolved": "https://registry.npmjs.org/prosemirror-keymap/-/prosemirror-keymap-1.2.3.tgz", 171 | "integrity": "sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==", 172 | "license": "MIT", 173 | "dependencies": { 174 | "prosemirror-state": "^1.0.0", 175 | "w3c-keyname": "^2.2.0" 176 | } 177 | }, 178 | "node_modules/prosemirror-menu": { 179 | "version": "1.2.5", 180 | "resolved": "https://registry.npmjs.org/prosemirror-menu/-/prosemirror-menu-1.2.5.tgz", 181 | "integrity": "sha512-qwXzynnpBIeg1D7BAtjOusR+81xCp53j7iWu/IargiRZqRjGIlQuu1f3jFi+ehrHhWMLoyOQTSRx/IWZJqOYtQ==", 182 | "license": "MIT", 183 | "dependencies": { 184 | "crelt": "^1.0.0", 185 | "prosemirror-commands": "^1.0.0", 186 | "prosemirror-history": "^1.0.0", 187 | "prosemirror-state": "^1.0.0" 188 | } 189 | }, 190 | "node_modules/prosemirror-model": { 191 | "version": "1.25.4", 192 | "resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.4.tgz", 193 | "integrity": "sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==", 194 | "license": "MIT", 195 | "peer": true, 196 | "dependencies": { 197 | "orderedmap": "^2.0.0" 198 | } 199 | }, 200 | "node_modules/prosemirror-schema-basic": { 201 | "version": "1.2.4", 202 | "resolved": "https://registry.npmjs.org/prosemirror-schema-basic/-/prosemirror-schema-basic-1.2.4.tgz", 203 | "integrity": "sha512-ELxP4TlX3yr2v5rM7Sb70SqStq5NvI15c0j9j/gjsrO5vaw+fnnpovCLEGIcpeGfifkuqJwl4fon6b+KdrODYQ==", 204 | "license": "MIT", 205 | "dependencies": { 206 | "prosemirror-model": "^1.25.0" 207 | } 208 | }, 209 | "node_modules/prosemirror-schema-list": { 210 | "version": "1.5.1", 211 | "resolved": "https://registry.npmjs.org/prosemirror-schema-list/-/prosemirror-schema-list-1.5.1.tgz", 212 | "integrity": "sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==", 213 | "license": "MIT", 214 | "dependencies": { 215 | "prosemirror-model": "^1.0.0", 216 | "prosemirror-state": "^1.0.0", 217 | "prosemirror-transform": "^1.7.3" 218 | } 219 | }, 220 | "node_modules/prosemirror-state": { 221 | "version": "1.4.4", 222 | "resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz", 223 | "integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==", 224 | "license": "MIT", 225 | "peer": true, 226 | "dependencies": { 227 | "prosemirror-model": "^1.0.0", 228 | "prosemirror-transform": "^1.0.0", 229 | "prosemirror-view": "^1.27.0" 230 | } 231 | }, 232 | "node_modules/prosemirror-transform": { 233 | "version": "1.10.5", 234 | "resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.10.5.tgz", 235 | "integrity": "sha512-RPDQCxIDhIBb1o36xxwsaeAvivO8VLJcgBtzmOwQ64bMtsVFh5SSuJ6dWSxO1UsHTiTXPCgQm3PDJt7p6IOLbw==", 236 | "license": "MIT", 237 | "dependencies": { 238 | "prosemirror-model": "^1.21.0" 239 | } 240 | }, 241 | "node_modules/prosemirror-view": { 242 | "version": "1.41.4", 243 | "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.4.tgz", 244 | "integrity": "sha512-WkKgnyjNncri03Gjaz3IFWvCAE94XoiEgvtr0/r2Xw7R8/IjK3sKLSiDoCHWcsXSAinVaKlGRZDvMCsF1kbzjA==", 245 | "license": "MIT", 246 | "peer": true, 247 | "dependencies": { 248 | "prosemirror-model": "^1.20.0", 249 | "prosemirror-state": "^1.0.0", 250 | "prosemirror-transform": "^1.1.0" 251 | } 252 | }, 253 | "node_modules/react": { 254 | "version": "19.2.1", 255 | "resolved": "https://registry.npmjs.org/react/-/react-19.2.1.tgz", 256 | "integrity": "sha512-DGrYcCWK7tvYMnWh79yrPHt+vdx9tY+1gPZa7nJQtO/p8bLTDaHp4dzwEhQB7pZ4Xe3ok4XKuEPrVuc+wlpkmw==", 257 | "license": "MIT", 258 | "peer": true, 259 | "engines": { 260 | "node": ">=0.10.0" 261 | } 262 | }, 263 | "node_modules/react-dom": { 264 | "version": "19.2.1", 265 | "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.1.tgz", 266 | "integrity": "sha512-ibrK8llX2a4eOskq1mXKu/TGZj9qzomO+sNfO98M6d9zIPOEhlBkMkBUBLd1vgS0gQsLDBzA+8jJBVXDnfHmJg==", 267 | "license": "MIT", 268 | "dependencies": { 269 | "scheduler": "^0.27.0" 270 | }, 271 | "peerDependencies": { 272 | "react": "^19.2.1" 273 | } 274 | }, 275 | "node_modules/rope-sequence": { 276 | "version": "1.3.4", 277 | "resolved": "https://registry.npmjs.org/rope-sequence/-/rope-sequence-1.3.4.tgz", 278 | "integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==", 279 | "license": "MIT" 280 | }, 281 | "node_modules/scheduler": { 282 | "version": "0.27.0", 283 | "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", 284 | "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", 285 | "license": "MIT" 286 | }, 287 | "node_modules/w3c-keyname": { 288 | "version": "2.2.8", 289 | "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", 290 | "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", 291 | "license": "MIT" 292 | }, 293 | "node_modules/y-phoenix-channel": { 294 | "version": "0.1.1", 295 | "resolved": "https://registry.npmjs.org/y-phoenix-channel/-/y-phoenix-channel-0.1.1.tgz", 296 | "integrity": "sha512-OZpzq0AviYa/UKbCDnQsKAY3kyKpHVh82bIiEivkQ3JHaqxlF9KmiPdLrut6EGeLqvaxV6KxdAHHlEvTEMeJVA==", 297 | "license": "MIT", 298 | "dependencies": { 299 | "@y/protocols": "^1.0.6-1", 300 | "lib0": "^0.2.102" 301 | }, 302 | "peerDependencies": { 303 | "phoenix": "^1", 304 | "yjs": "^13" 305 | } 306 | }, 307 | "node_modules/y-prosemirror": { 308 | "version": "1.3.7", 309 | "resolved": "https://registry.npmjs.org/y-prosemirror/-/y-prosemirror-1.3.7.tgz", 310 | "integrity": "sha512-NpM99WSdD4Fx4if5xOMDpPtU3oAmTSjlzh5U4353ABbRHl1HtAFUx6HlebLZfyFxXN9jzKMDkVbcRjqOZVkYQg==", 311 | "license": "MIT", 312 | "dependencies": { 313 | "lib0": "^0.2.109" 314 | }, 315 | "engines": { 316 | "node": ">=16.0.0", 317 | "npm": ">=8.0.0" 318 | }, 319 | "funding": { 320 | "type": "GitHub Sponsors ❤", 321 | "url": "https://github.com/sponsors/dmonad" 322 | }, 323 | "peerDependencies": { 324 | "prosemirror-model": "^1.7.1", 325 | "prosemirror-state": "^1.2.3", 326 | "prosemirror-view": "^1.9.10", 327 | "y-protocols": "^1.0.1", 328 | "yjs": "^13.5.38" 329 | } 330 | }, 331 | "node_modules/y-protocols": { 332 | "version": "1.0.6", 333 | "resolved": "https://registry.npmjs.org/y-protocols/-/y-protocols-1.0.6.tgz", 334 | "integrity": "sha512-vHRF2L6iT3rwj1jub/K5tYcTT/mEYDUppgNPXwp8fmLpui9f7Yeq3OEtTLVF012j39QnV+KEQpNqoN7CWU7Y9Q==", 335 | "license": "MIT", 336 | "peer": true, 337 | "dependencies": { 338 | "lib0": "^0.2.85" 339 | }, 340 | "engines": { 341 | "node": ">=16.0.0", 342 | "npm": ">=8.0.0" 343 | }, 344 | "funding": { 345 | "type": "GitHub Sponsors ❤", 346 | "url": "https://github.com/sponsors/dmonad" 347 | }, 348 | "peerDependencies": { 349 | "yjs": "^13.0.0" 350 | } 351 | }, 352 | "node_modules/yjs": { 353 | "version": "13.6.27", 354 | "resolved": "https://registry.npmjs.org/yjs/-/yjs-13.6.27.tgz", 355 | "integrity": "sha512-OIDwaflOaq4wC6YlPBy2L6ceKeKuF7DeTxx+jPzv1FHn9tCZ0ZwSRnUBxD05E3yed46fv/FWJbvR+Ud7x0L7zw==", 356 | "license": "MIT", 357 | "peer": true, 358 | "dependencies": { 359 | "lib0": "^0.2.99" 360 | }, 361 | "engines": { 362 | "node": ">=16.0.0", 363 | "npm": ">=8.0.0" 364 | }, 365 | "funding": { 366 | "type": "GitHub Sponsors ❤", 367 | "url": "https://github.com/sponsors/dmonad" 368 | } 369 | } 370 | } 371 | } 372 | -------------------------------------------------------------------------------- /assets/js/excalidraw/y-excalidraw.ts: -------------------------------------------------------------------------------- 1 | import type * as awarenessProtocol from "y-protocols/awareness"; 2 | import type * as Y from "yjs"; 3 | 4 | import { YKeyValue } from "y-utility/y-keyvalue"; 5 | 6 | import { 7 | hashElementsVersion, 8 | reconcileElements, 9 | type Excalidraw, 10 | } from "@excalidraw/excalidraw"; 11 | import { FileId } from "@excalidraw/excalidraw/element/types"; 12 | 13 | type ExcalidrawProps = Parameters[0]; 14 | type ExcalidrawImperativeAPI = Parameters< 15 | NonNullable 16 | >[0]; 17 | type UpdateSceneParam = Parameters[0]; 18 | type ExcalidrawElement = NonNullable[0]; 19 | type Collaborators = NonNullable; 20 | type SocketId = Collaborators extends Map ? K : never; 21 | type Collaborator = Collaborators extends Map ? V : never; 22 | type BinaryFileData = Parameters[0][0]; 23 | 24 | export type ExcalidrawBindingElementsStore = Y.Array<{ 25 | key: string; 26 | val: ExcalidrawElement; 27 | }>; 28 | export type ExcalidrawBindingAssetsStore = Y.Array<{ 29 | key: string; 30 | val: BinaryFileData; 31 | }>; 32 | 33 | const isValidElement = (element: ExcalidrawElement) => { 34 | return element.id != null; 35 | }; 36 | 37 | type Option = { 38 | cursorDisplayTimeout?: number; 39 | }; 40 | 41 | /** 42 | * Manages the binding between Excalidraw and Y.js for collaborative drawing 43 | * Handles synchronization of elements, assets, and user awareness 44 | */ 45 | export class ExcalidrawBinding { 46 | #yElements: YKeyValue; 47 | #yAssets: YKeyValue; 48 | #api: ExcalidrawImperativeAPI; 49 | awareness?: awarenessProtocol.Awareness; 50 | cursorDisplayTimeout?: number; // milliseconds 51 | cursorDisplayTimeoutTimer: ReturnType | undefined; // Changed from setTimeout to setInterval 52 | // Record last update time for each collaborator 53 | #lastPointerUpdateTime: Map = new Map(); 54 | 55 | subscriptions: (() => void)[] = []; 56 | collaborators: Collaborators = new Map(); 57 | lastVersion = 0; 58 | 59 | /** 60 | * Initializes the binding between Excalidraw and Y.js 61 | * @param yElements - Y.js array for storing drawing elements 62 | * @param yAssets - Y.js array for storing binary assets 63 | * @param api - Excalidraw imperative API instance 64 | * @param awareness - Optional Y.js awareness instance for user presence 65 | */ 66 | constructor( 67 | yElements: ExcalidrawBindingElementsStore, 68 | yAssets: ExcalidrawBindingAssetsStore, 69 | api: ExcalidrawImperativeAPI, 70 | awareness?: awarenessProtocol.Awareness, 71 | option?: Option, 72 | ) { 73 | this.#yElements = new YKeyValue(yElements); 74 | this.#yAssets = new YKeyValue(yAssets); 75 | this.#api = api; 76 | this.awareness = awareness; 77 | this.cursorDisplayTimeout = option?.cursorDisplayTimeout; 78 | 79 | let init = false; 80 | 81 | const setInitialElements = () => { 82 | // Initialize elements and assets from Y.js state 83 | const initialValue = this.#yElements.yarray 84 | .map(({ val }) => ({ ...val })) 85 | .filter(isValidElement); 86 | 87 | this.lastVersion = hashElementsVersion(initialValue); 88 | this.#api.updateScene({ elements: initialValue, captureUpdate: "NEVER" }); 89 | }; 90 | 91 | // Listen for local changes in Excalidraw and sync to Y.js 92 | this.subscriptions.push( 93 | this.#api.onChange( 94 | throttle((elements, state, files) => { 95 | if (state.isLoading) { 96 | return; 97 | } 98 | if (!init) { 99 | setInitialElements(); 100 | init = true; 101 | return; 102 | } 103 | 104 | const version = hashElementsVersion(elements); 105 | if (version !== this.lastVersion) { 106 | const gcAssetFiles = () => { 107 | const usedFileIds = new Set([ 108 | ...elements 109 | .map((e) => (e.type === "image" ? e.fileId : null)) 110 | .filter((f): f is FileId => f !== null), 111 | ]); 112 | 113 | const deletedFileIds = this.#yAssets.yarray 114 | .map((d) => d.key) 115 | .filter((id) => !usedFileIds.has(id as FileId)); 116 | for (const id of deletedFileIds) { 117 | this.#yAssets.delete(id); 118 | } 119 | }; 120 | 121 | this.#yElements.doc?.transact(() => { 122 | // check deletion 123 | for (const yElem of this.#yElements.yarray) { 124 | const deleted = 125 | elements.find((element) => element.id === yElem.key) 126 | ?.isDeleted ?? true; 127 | if (deleted) { 128 | this.#yElements.delete(yElem.key); 129 | } 130 | } 131 | for (const element of elements) { 132 | const remoteElements = this.#yElements.get(element.id); 133 | if ( 134 | remoteElements?.versionNonce !== element.versionNonce || 135 | remoteElements?.version !== element.version 136 | ) { 137 | this.#yElements.set(element.id, { ...element }); 138 | } 139 | } 140 | }, this); 141 | this.lastVersion = version; 142 | 143 | gcAssetFiles(); 144 | } 145 | if (files) { 146 | const newFiles = Object.entries(files).filter(([id, file]) => { 147 | return this.#yAssets.get(id) == null; 148 | }); 149 | 150 | this.#yAssets.doc?.transact(() => { 151 | for (const [id, file] of newFiles) { 152 | this.#yAssets.set(id, { ...file }); 153 | } 154 | }, this); 155 | } 156 | }, 50), 157 | ), 158 | ); 159 | 160 | setInitialElements(); 161 | 162 | // Listen for remote changes in Y.js elements and sync to Excalidraw 163 | const _remoteElementsChangeHandler = ( 164 | event: Array>, 165 | txn: Y.Transaction, 166 | ) => { 167 | if (txn.origin === this) { 168 | return; 169 | } 170 | 171 | const remoteElements = this.#yElements.yarray 172 | .map(({ val }) => ({ ...val })) 173 | .filter(isValidElement); 174 | const elements = reconcileElements( 175 | this.#api.getSceneElements(), 176 | // @ts-expect-error TODO: 177 | remoteElements, 178 | this.#api.getAppState(), 179 | ); 180 | 181 | this.#api.updateScene({ elements, captureUpdate: "NEVER" }); 182 | }; 183 | this.#yElements.yarray.observeDeep(_remoteElementsChangeHandler); 184 | this.subscriptions.push(() => 185 | this.#yElements.yarray.unobserveDeep(_remoteElementsChangeHandler), 186 | ); 187 | 188 | // Listen for remote changes in Y.js assets and sync to Excalidraw 189 | const _remoteFilesChangeHandler = ( 190 | changes: Map< 191 | string, 192 | | { action: "delete"; oldValue: BinaryFileData } 193 | | { 194 | action: "update"; 195 | oldValue: BinaryFileData; 196 | newValue: BinaryFileData; 197 | } 198 | | { action: "add"; newValue: BinaryFileData } 199 | >, 200 | txn: Y.Transaction, 201 | ) => { 202 | if (txn.origin === this) { 203 | return; 204 | } 205 | 206 | const addedFiles = [...changes.entries()].flatMap(([key, change]) => { 207 | if (change.action === "add") { 208 | return [change.newValue]; 209 | } 210 | return []; 211 | }); 212 | this.#api.addFiles(addedFiles); 213 | }; 214 | this.#yAssets.on("change", _remoteFilesChangeHandler); // only observe and not observe deep as assets are only added/deleted not updated 215 | this.subscriptions.push(() => { 216 | this.#yAssets.off("change", _remoteFilesChangeHandler); 217 | }); 218 | 219 | if (awareness) { 220 | const toCollaborator = (state: { 221 | // biome-ignore lint/suspicious/noExplicitAny: TODO 222 | [x: string]: any; 223 | }): Collaborator => { 224 | return { 225 | pointer: state.pointer, 226 | button: state.button, 227 | selectedElementIds: state.selectedElementIds, 228 | username: state.user?.name, 229 | avatarUrl: state.user?.avatarUrl, 230 | userState: state.user?.state, 231 | isSpeaking: state.user?.isSpeaking, 232 | isMuted: state.user?.isMuted, 233 | isInCall: state.user?.isInCall, 234 | }; 235 | }; 236 | // Handle remote user presence updates 237 | const _remoteAwarenessChangeHandler = ({ 238 | added, 239 | updated, 240 | removed, 241 | }: { 242 | added: number[]; 243 | updated: number[]; 244 | removed: number[]; 245 | }) => { 246 | const states = awareness.getStates(); 247 | 248 | const collaborators = new Map(this.collaborators); 249 | const update = [...added, ...updated]; 250 | for (const id of update) { 251 | const state = states.get(id); 252 | if (!state) { 253 | continue; 254 | } 255 | 256 | const socketId = id.toString() as SocketId; 257 | const newCollaborator = toCollaborator(state); 258 | const existingCollaborator = collaborators.get(socketId); 259 | 260 | // Only record last update time when pointer is updated 261 | if ( 262 | newCollaborator.pointer && 263 | (!existingCollaborator?.pointer || 264 | JSON.stringify(existingCollaborator.pointer) !== 265 | JSON.stringify(newCollaborator.pointer)) 266 | ) { 267 | this.#lastPointerUpdateTime.set(socketId, Date.now()); 268 | } 269 | 270 | collaborators.set(socketId, newCollaborator); 271 | } 272 | for (const id of removed) { 273 | const socketId = id.toString() as SocketId; 274 | collaborators.delete(socketId); 275 | // Remove tracking for deleted collaborators 276 | this.#lastPointerUpdateTime.delete(socketId); 277 | } 278 | collaborators.delete(awareness.clientID.toString() as SocketId); 279 | this.#api.updateScene({ collaborators }); 280 | this.collaborators = collaborators; 281 | }; 282 | awareness.on("change", _remoteAwarenessChangeHandler); 283 | this.subscriptions.push(() => { 284 | this.awareness?.off("change", _remoteAwarenessChangeHandler); 285 | }); 286 | 287 | // Initialize collaborator state 288 | const collaborators: Collaborators = new Map(); 289 | for (const [id, state] of awareness.getStates().entries()) { 290 | if (state) { 291 | const socketId = id.toString() as SocketId; 292 | const collaborator = toCollaborator(state); 293 | collaborators.set(socketId, collaborator); 294 | 295 | // During initialization, record last update time only if pointer exists 296 | if (collaborator.pointer) { 297 | this.#lastPointerUpdateTime.set(socketId, Date.now()); 298 | } 299 | } 300 | } 301 | this.#api.updateScene({ collaborators }); 302 | this.collaborators = collaborators; 303 | 304 | // Set up timeout monitoring during initialization 305 | this.startCursorTimeoutChecker(); 306 | } 307 | 308 | // init assets 309 | const initialAssets = this.#yAssets.yarray.map(({ val }) => val); 310 | 311 | this.#api.addFiles(initialAssets); 312 | } 313 | 314 | public updateLocalState = throttle((state: { [x: string]: unknown }) => { 315 | if (this.awareness) { 316 | this.awareness.setLocalState({ 317 | ...this.awareness.getLocalState(), 318 | ...state, 319 | }); 320 | } 321 | }, 50); 322 | 323 | /** 324 | * Updates pointer position and button state for collaboration 325 | * @param payload - Contains pointer coordinates and button state 326 | */ 327 | public onPointerUpdate = (payload: { 328 | pointer: { 329 | x: number; 330 | y: number; 331 | tool: "pointer" | "laser"; 332 | }; 333 | button: "down" | "up"; 334 | }) => { 335 | this.updateLocalState({ 336 | pointer: payload.pointer, 337 | button: payload.button, 338 | selectedElementIds: this.#api.getAppState().selectedElementIds, 339 | }); 340 | }; 341 | 342 | /** 343 | * Start monitoring pointer timeouts 344 | * Using interval timer to ensure regular checks 345 | */ 346 | private startCursorTimeoutChecker() { 347 | if (!this.cursorDisplayTimeout) { 348 | return; 349 | } 350 | 351 | // Clear existing timer 352 | if (this.cursorDisplayTimeoutTimer) { 353 | clearInterval(this.cursorDisplayTimeoutTimer); 354 | } 355 | 356 | // Check periodically using interval timer 357 | this.cursorDisplayTimeoutTimer = setInterval(() => { 358 | this.checkCursorTimeouts(); 359 | }, 200); 360 | } 361 | 362 | /** 363 | * Check and hide timed-out pointers 364 | */ 365 | private checkCursorTimeouts() { 366 | const cursorDisplayTimeout = this.cursorDisplayTimeout; 367 | if (!cursorDisplayTimeout) { 368 | return; 369 | } 370 | 371 | const now = Date.now(); 372 | const updatedCollaborators = new Map(this.collaborators); 373 | let hasChanges = false; 374 | 375 | // Check each collaborator's pointer 376 | updatedCollaborators.forEach((collaborator, id) => { 377 | const lastUpdateTime = this.#lastPointerUpdateTime.get(id); 378 | 379 | // If pointer exists and hasn't been updated within timeout period 380 | if ( 381 | collaborator.pointer && 382 | lastUpdateTime && 383 | now - lastUpdateTime > cursorDisplayTimeout 384 | ) { 385 | hasChanges = true; 386 | updatedCollaborators.set(id, { 387 | ...collaborator, 388 | pointer: undefined, 389 | }); 390 | // Remove the last update time after timeout 391 | this.#lastPointerUpdateTime.delete(id); 392 | } 393 | }); 394 | 395 | if (hasChanges) { 396 | this.#api.updateScene({ collaborators: updatedCollaborators }); 397 | this.collaborators = updatedCollaborators; 398 | } 399 | } 400 | 401 | /** 402 | * Cleanup method to remove all event listeners 403 | */ 404 | destroy() { 405 | for (const s of this.subscriptions) { 406 | s(); 407 | } 408 | 409 | // Clear timer 410 | if (this.cursorDisplayTimeoutTimer) { 411 | clearInterval(this.cursorDisplayTimeoutTimer); // Changed from clearTimeout to clearInterval 412 | } 413 | } 414 | } 415 | 416 | // biome-ignore lint/suspicious/noExplicitAny: any is used to avoid type errors 417 | function throttle void>( 418 | func: T, 419 | limit: number, 420 | ): T { 421 | let lastFunc: ReturnType | null = null; 422 | let lastRan: number | null = null; 423 | 424 | return function (this: ThisParameterType, ...args: Parameters) { 425 | if (lastRan === null) { 426 | func.apply(this, args); 427 | lastRan = Date.now(); 428 | } else { 429 | if (lastFunc) { 430 | clearTimeout(lastFunc); 431 | } 432 | lastFunc = setTimeout( 433 | () => { 434 | if (Date.now() - (lastRan as number) >= limit) { 435 | func.apply(this, args); 436 | lastRan = Date.now(); 437 | } 438 | }, 439 | limit - (Date.now() - lastRan), 440 | ); 441 | } 442 | } as T; 443 | } 444 | -------------------------------------------------------------------------------- /npm/y-phoenix-channel/src/y-phoenix-channel.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * based on https://github.com/yjs/y-websocket/blob/master/src/y-websocket.js 3 | */ 4 | 5 | /* eslint-env browser */ 6 | 7 | import type * as Y from "yjs"; 8 | import * as bc from "lib0/broadcastchannel.js"; 9 | import * as time from "lib0/time.js"; 10 | import * as encoding from "lib0/encoding.js"; 11 | import * as decoding from "lib0/decoding.js"; 12 | import * as syncProtocol from "@y/protocols/sync.js"; 13 | import * as awarenessProtocol from "@y/protocols/awareness.js"; 14 | import { ObservableV2 } from 'lib0/observable.js' 15 | import * as env from "lib0/environment.js"; 16 | import type { Socket, Channel } from "phoenix"; 17 | 18 | export const messageSync = 0; 19 | export const messageQueryAwareness = 3; 20 | export const messageAwareness = 1; 21 | 22 | /** 23 | * encoder, decoder, provider, emitSynced, messageType 24 | * @type {Array} 25 | */ 26 | const messageHandlers: (( 27 | encoder: encoding.Encoder, 28 | decoder: decoding.Decoder, 29 | PhoenixChannelProvider: PhoenixChannelProvider, 30 | emitSynced: boolean, 31 | messageType: number, 32 | ) => void)[] = []; 33 | 34 | messageHandlers[messageSync] = ( 35 | encoder, 36 | decoder, 37 | provider, 38 | emitSynced, 39 | _messageType, 40 | ) => { 41 | encoding.writeVarUint(encoder, messageSync); 42 | const syncMessageType = syncProtocol.readSyncMessage( 43 | decoder, 44 | encoder, 45 | provider.doc, 46 | provider, 47 | ); 48 | if ( 49 | emitSynced && 50 | syncMessageType === syncProtocol.messageYjsSyncStep2 && 51 | !provider.synced 52 | ) { 53 | provider.synced = true; 54 | } 55 | }; 56 | 57 | messageHandlers[messageQueryAwareness] = ( 58 | encoder, 59 | _decoder, 60 | provider, 61 | _emitSynced, 62 | _messageType, 63 | ) => { 64 | encoding.writeVarUint(encoder, messageAwareness); 65 | encoding.writeVarUint8Array( 66 | encoder, 67 | awarenessProtocol.encodeAwarenessUpdate( 68 | provider.awareness, 69 | Array.from(provider.awareness.getStates().keys()), 70 | ), 71 | ); 72 | }; 73 | 74 | messageHandlers[messageAwareness] = ( 75 | _encoder, 76 | decoder, 77 | provider, 78 | _emitSynced, 79 | _messageType, 80 | ) => { 81 | awarenessProtocol.applyAwarenessUpdate( 82 | provider.awareness, 83 | decoding.readVarUint8Array(decoder), 84 | provider, 85 | ); 86 | }; 87 | 88 | /** 89 | * @param {PhoenixChannelProvider} provider 90 | * @param {Uint8Array} buf 91 | * @param {boolean} emitSynced 92 | * @return {encoding.Encoder} 93 | */ 94 | const readMessage = ( 95 | provider: PhoenixChannelProvider, 96 | buf: Uint8Array, 97 | emitSynced: boolean, 98 | ): encoding.Encoder => { 99 | const decoder = decoding.createDecoder(buf); 100 | const encoder = encoding.createEncoder(); 101 | const messageType = decoding.readVarUint(decoder); 102 | const messageHandler = provider.messageHandlers[messageType]; 103 | if (/** @type {any} */ (messageHandler)) { 104 | messageHandler(encoder, decoder, provider, emitSynced, messageType); 105 | } else { 106 | console.error("Unable to compute message"); 107 | } 108 | return encoder; 109 | }; 110 | 111 | const setupChannel = (provider: PhoenixChannelProvider) => { 112 | if (provider.shouldConnect && provider.channel == null) { 113 | provider.channel = provider.socket.channel( 114 | provider.roomname, 115 | provider.params, 116 | ); 117 | 118 | provider.channel.onError(() => { 119 | provider.emit("status", [ 120 | { 121 | status: "disconnected", 122 | }, 123 | ]); 124 | provider.synced = false; 125 | // update awareness (all users except local left) 126 | awarenessProtocol.removeAwarenessStates( 127 | provider.awareness, 128 | Array.from(provider.awareness.getStates().keys()).filter( 129 | (client) => client !== provider.doc.clientID, 130 | ), 131 | provider, 132 | ); 133 | }); 134 | provider.channel.onClose(() => { 135 | provider.emit("status", [ 136 | { 137 | status: "disconnected", 138 | }, 139 | ]); 140 | provider.synced = false; 141 | // update awareness (all users except local left) 142 | awarenessProtocol.removeAwarenessStates( 143 | provider.awareness, 144 | Array.from(provider.awareness.getStates().keys()).filter( 145 | (client) => client !== provider.doc.clientID, 146 | ), 147 | provider, 148 | ); 149 | }); 150 | 151 | provider.channel.on("yjs", (data) => { 152 | provider.wsLastMessageReceived = time.getUnixTime(); 153 | const encoder = readMessage(provider, new Uint8Array(data), true); 154 | if (encoding.length(encoder) > 1) { 155 | provider.channel?.push("yjs", encoding.toUint8Array(encoder).buffer); 156 | } 157 | }); 158 | 159 | provider.emit("status", [ 160 | { 161 | status: "connecting", 162 | }, 163 | ]); 164 | provider.channel.join().receive("ok", (_resp) => { 165 | provider.emit("status", [ 166 | { 167 | status: "connected", 168 | }, 169 | ]); 170 | 171 | const encoder = encoding.createEncoder(); 172 | encoding.writeVarUint(encoder, messageSync); 173 | syncProtocol.writeSyncStep1(encoder, provider.doc); 174 | 175 | const data = encoding.toUint8Array(encoder); 176 | provider.channel?.push("yjs_sync", data.buffer); 177 | 178 | // broadcast local awareness state 179 | if (provider.awareness.getLocalState() !== null) { 180 | const encoderAwarenessState = encoding.createEncoder(); 181 | encoding.writeVarUint(encoderAwarenessState, messageAwareness); 182 | encoding.writeVarUint8Array( 183 | encoderAwarenessState, 184 | awarenessProtocol.encodeAwarenessUpdate(provider.awareness, [ 185 | provider.doc.clientID, 186 | ]), 187 | ); 188 | provider.channel?.push( 189 | "yjs", 190 | encoding.toUint8Array(encoderAwarenessState).buffer, 191 | ); 192 | } 193 | }); 194 | } 195 | }; 196 | 197 | /** 198 | * @param {PhoenixChannelProvider} provider 199 | * @param {ArrayBuffer} buf 200 | */ 201 | const broadcastMessage = ( 202 | provider: PhoenixChannelProvider, 203 | buf: Uint8Array, 204 | ) => { 205 | const channel = provider.channel; 206 | if (channel?.state === "joined") { 207 | channel.push("yjs", buf.buffer); 208 | } 209 | if (provider.bcconnected) { 210 | bc.publish(provider.bcChannel, buf, provider); 211 | } 212 | }; 213 | 214 | type EventMap = { 215 | 'connection-close': (event: CloseEvent | null, provider: PhoenixChannelProvider) => any, 216 | 'status': (event: { status: 'connected' | 'disconnected' | 'connecting' }) => any, 217 | 'connection-error': (event: Event, provider: PhoenixChannelProvider) => any, 218 | 'sync': (state: boolean) => any 219 | } 220 | 221 | /** 222 | * PhoenixChannelProvider for Yjs. This provider synchronizes Yjs documents using Phoenix Channels. 223 | * The document name is associated with the specified roomname. 224 | * 225 | * @example 226 | * import * as Y from 'yjs' 227 | * import { PhoenixChannelProvider } from 'y-phoenix-channel' 228 | * const doc = new Y.Doc() 229 | * const provider = new PhoenixChannelProvider(socket, 'my-document-name', doc) 230 | * 231 | * @param {Socket} socket - Phoenix Socket instance 232 | * @param {string} roomname - Channel name (document name) 233 | * @param {Y.Doc} doc - Yjs document 234 | * @param {object} opts - Options 235 | * @param {boolean} [opts.connect] - Whether to connect automatically 236 | * @param {awarenessProtocol.Awareness} [opts.awareness] - Awareness instance 237 | * @param {Object} [opts.params] - Channel join parameters 238 | * @param {number} [opts.resyncInterval] - Interval (ms) to resync server state 239 | * @param {boolean} [opts.disableBc] - Disable BroadcastChannel communication 240 | */ 241 | export class PhoenixChannelProvider extends ObservableV2 { 242 | doc: Y.Doc; 243 | awareness: awarenessProtocol.Awareness; 244 | serverUrl: string; 245 | channel: Channel | undefined; 246 | socket: Socket; 247 | bcChannel: string; 248 | params: object; 249 | roomname: string; 250 | bcconnected: boolean; 251 | disableBc: boolean; 252 | wsUnsuccessfulReconnects: number; 253 | messageHandlers: (( 254 | encoder: encoding.Encoder, 255 | decoder: decoding.Decoder, 256 | PhoenixChannelProvider: PhoenixChannelProvider, 257 | emitSynced: boolean, 258 | messageType: number, 259 | ) => void)[]; 260 | _synced: boolean; 261 | wsLastMessageReceived: number; 262 | shouldConnect: boolean; 263 | _resyncInterval: ReturnType | null = null; 264 | _bcSubscriber: (data: any, origin: any) => void; 265 | _updateHandler: (update: any, origin: any) => void; 266 | _awarenessUpdateHandler: ( 267 | { added, updated, removed }: { added: any; updated: any; removed: any }, 268 | _origin: any, 269 | ) => void; 270 | _exitHandler: () => void; 271 | /** 272 | * @param {Socket} socket 273 | * @param {string} roomname 274 | * @param {Y.Doc} doc 275 | * @param {object} opts 276 | * @param {boolean} [opts.connect] 277 | * @param {awarenessProtocol.Awareness} [opts.awareness] 278 | * @param {Object} [opts.params] specify channel join parameters 279 | * @param {number} [opts.resyncInterval] Request server state every `resyncInterval` milliseconds 280 | * @param {boolean} [opts.disableBc] Disable cross-tab BroadcastChannel communication 281 | */ 282 | constructor( 283 | socket: Socket, 284 | roomname: string, 285 | doc: Y.Doc, 286 | { 287 | connect = true, 288 | awareness = new awarenessProtocol.Awareness(doc), 289 | params = {}, 290 | resyncInterval = -1, 291 | disableBc = false, 292 | } = {}, 293 | ) { 294 | super(); 295 | this.socket = socket; 296 | this.serverUrl = socket.endPointURL(); 297 | this.bcChannel = this.serverUrl + "/" + roomname; 298 | /** 299 | * The specified url parameters. This can be safely updated. The changed parameters will be used 300 | * when a new connection is established. 301 | * @type {Object} 302 | */ 303 | this.params = params; 304 | this.roomname = roomname; 305 | this.doc = doc; 306 | this.awareness = awareness; 307 | this.bcconnected = false; 308 | this.disableBc = disableBc; 309 | this.wsUnsuccessfulReconnects = 0; 310 | this.messageHandlers = messageHandlers.slice(); 311 | /** 312 | * @type {boolean} 313 | */ 314 | this._synced = false; 315 | this.wsLastMessageReceived = 0; 316 | /** 317 | * Whether to connect to other peers or not 318 | * @type {boolean} 319 | */ 320 | this.shouldConnect = connect; 321 | 322 | if (resyncInterval > 0) { 323 | this._resyncInterval = 324 | setInterval(() => { 325 | if (this.channel && this.channel.state == "joined") { 326 | // resend sync step 1 327 | const encoder = encoding.createEncoder(); 328 | encoding.writeVarUint(encoder, messageSync); 329 | syncProtocol.writeSyncStep1(encoder, doc); 330 | this.channel.push( 331 | "yjs_sync", 332 | encoding.toUint8Array(encoder).buffer, 333 | ); 334 | } 335 | }, resyncInterval) 336 | } 337 | 338 | /** 339 | * @param {ArrayBuffer} data 340 | * @param {any} origin 341 | */ 342 | this._bcSubscriber = (data, origin) => { 343 | if (origin !== this) { 344 | const encoder = readMessage(this, new Uint8Array(data), false); 345 | if (encoding.length(encoder) > 1) { 346 | bc.publish(this.bcChannel, encoding.toUint8Array(encoder), this); 347 | } 348 | } 349 | }; 350 | /** 351 | * Listens to Yjs updates and sends them to remote peers (ws and broadcastchannel) 352 | * @param {Uint8Array} update 353 | * @param {any} origin 354 | */ 355 | this._updateHandler = (update, origin) => { 356 | if (origin !== this) { 357 | const encoder = encoding.createEncoder(); 358 | encoding.writeVarUint(encoder, messageSync); 359 | syncProtocol.writeUpdate(encoder, update); 360 | broadcastMessage(this, encoding.toUint8Array(encoder)); 361 | } 362 | }; 363 | this.doc.on("update", this._updateHandler); 364 | /** 365 | * @param {any} changed 366 | * @param {any} _origin 367 | */ 368 | this._awarenessUpdateHandler = ({ added, updated, removed }, _origin) => { 369 | const changedClients = added.concat(updated).concat(removed); 370 | const encoder = encoding.createEncoder(); 371 | encoding.writeVarUint(encoder, messageAwareness); 372 | encoding.writeVarUint8Array( 373 | encoder, 374 | awarenessProtocol.encodeAwarenessUpdate(awareness, changedClients), 375 | ); 376 | broadcastMessage(this, encoding.toUint8Array(encoder)); 377 | }; 378 | this._exitHandler = () => { 379 | awarenessProtocol.removeAwarenessStates( 380 | this.awareness, 381 | [doc.clientID], 382 | "app closed", 383 | ); 384 | }; 385 | if (env.isNode && typeof process !== "undefined") { 386 | process.on("exit", this._exitHandler); 387 | } 388 | awareness.on("update", this._awarenessUpdateHandler); 389 | if (connect) { 390 | this.connect(); 391 | } 392 | } 393 | 394 | /** 395 | * @type {boolean} 396 | */ 397 | get synced() { 398 | return this._synced; 399 | } 400 | 401 | set synced(state) { 402 | if (this._synced !== state) { 403 | this._synced = state; 404 | // @ts-expect-error 405 | this.emit("synced", [state]); 406 | this.emit("sync", [state]); 407 | } 408 | } 409 | 410 | destroy() { 411 | if (this._resyncInterval != null) { 412 | clearInterval(this._resyncInterval); 413 | } 414 | this.disconnect(); 415 | if (env.isNode && typeof process !== "undefined") { 416 | process.off("exit", this._exitHandler); 417 | } 418 | this.awareness.off("update", this._awarenessUpdateHandler); 419 | this.doc.off("update", this._updateHandler); 420 | super.destroy(); 421 | } 422 | 423 | connectBc() { 424 | if (this.disableBc) { 425 | return; 426 | } 427 | if (!this.bcconnected) { 428 | bc.subscribe(this.bcChannel, this._bcSubscriber); 429 | this.bcconnected = true; 430 | } 431 | // send sync step1 to bc 432 | // write sync step 1 433 | const encoderSync = encoding.createEncoder(); 434 | encoding.writeVarUint(encoderSync, messageSync); 435 | syncProtocol.writeSyncStep1(encoderSync, this.doc); 436 | bc.publish(this.bcChannel, encoding.toUint8Array(encoderSync), this); 437 | // broadcast local state 438 | const encoderState = encoding.createEncoder(); 439 | encoding.writeVarUint(encoderState, messageSync); 440 | syncProtocol.writeSyncStep2(encoderState, this.doc); 441 | bc.publish(this.bcChannel, encoding.toUint8Array(encoderState), this); 442 | // write queryAwareness 443 | const encoderAwarenessQuery = encoding.createEncoder(); 444 | encoding.writeVarUint(encoderAwarenessQuery, messageQueryAwareness); 445 | bc.publish( 446 | this.bcChannel, 447 | encoding.toUint8Array(encoderAwarenessQuery), 448 | this, 449 | ); 450 | // broadcast local awareness state 451 | const encoderAwarenessState = encoding.createEncoder(); 452 | encoding.writeVarUint(encoderAwarenessState, messageAwareness); 453 | encoding.writeVarUint8Array( 454 | encoderAwarenessState, 455 | awarenessProtocol.encodeAwarenessUpdate(this.awareness, [ 456 | this.doc.clientID, 457 | ]), 458 | ); 459 | bc.publish( 460 | this.bcChannel, 461 | encoding.toUint8Array(encoderAwarenessState), 462 | this, 463 | ); 464 | } 465 | 466 | disconnectBc() { 467 | // broadcast message with local awareness state set to null (indicating disconnect) 468 | const encoder = encoding.createEncoder(); 469 | encoding.writeVarUint(encoder, messageAwareness); 470 | encoding.writeVarUint8Array( 471 | encoder, 472 | awarenessProtocol.encodeAwarenessUpdate( 473 | this.awareness, 474 | [this.doc.clientID], 475 | new Map(), 476 | ), 477 | ); 478 | broadcastMessage(this, encoding.toUint8Array(encoder)); 479 | if (this.bcconnected) { 480 | bc.unsubscribe(this.bcChannel, this._bcSubscriber); 481 | this.bcconnected = false; 482 | } 483 | } 484 | 485 | disconnect() { 486 | this.shouldConnect = false; 487 | this.disconnectBc(); 488 | if (this.channel != null) { 489 | this.channel?.leave(); 490 | } 491 | this.channel = undefined; 492 | } 493 | 494 | connect() { 495 | this.shouldConnect = true; 496 | if (this.channel == null) { 497 | setupChannel(this); 498 | this.connectBc(); 499 | } 500 | } 501 | 502 | } 503 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "bandit": {:hex, :bandit, "1.8.0", "c2e93d7e3c5c794272fa4623124f827c6f24b643acc822be64c826f9447d92fb", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "8458ff4eed20ff2a2ea69d4854883a077c33ea42b51f6811b044ceee0fa15422"}, 3 | "castore": {:hex, :castore, "1.0.17", "4f9770d2d45fbd91dcf6bd404cf64e7e58fed04fadda0923dc32acca0badffa2", [], [], "hexpm", "12d24b9d80b910dd3953e165636d68f147a31db945d2dcb9365e441f8b5351e5"}, 4 | "db_connection": {:hex, :db_connection, "2.8.1", "9abdc1e68c34c6163f6fb96a96532272d13ad7ca45262156ae8b7ec6d9dc4bec", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a61a3d489b239d76f326e03b98794fb8e45168396c925ef25feb405ed09da8fd"}, 5 | "decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"}, 6 | "dns_cluster": {:hex, :dns_cluster, "0.2.0", "aa8eb46e3bd0326bd67b84790c561733b25c5ba2fe3c7e36f28e88f384ebcb33", [:mix], [], "hexpm", "ba6f1893411c69c01b9e8e8f772062535a4cf70f3f35bcc964a324078d8c8240"}, 7 | "ecto": {:hex, :ecto, "3.13.5", "9d4a69700183f33bf97208294768e561f5c7f1ecf417e0fa1006e4a91713a834", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "df9efebf70cf94142739ba357499661ef5dbb559ef902b68ea1f3c1fabce36de"}, 8 | "ecto_psql_extras": {:hex, :ecto_psql_extras, "0.8.8", "aa02529c97f69aed5722899f5dc6360128735a92dd169f23c5d50b1f7fdede08", [:mix], [{:ecto_sql, "~> 3.7", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:postgrex, "> 0.16.0", [hex: :postgrex, repo: "hexpm", optional: false]}, {:table_rex, "~> 3.1.1 or ~> 4.0", [hex: :table_rex, repo: "hexpm", optional: false]}], "hexpm", "04c63d92b141723ad6fed2e60a4b461ca00b3594d16df47bbc48f1f4534f2c49"}, 9 | "ecto_sql": {:hex, :ecto_sql, "3.13.2", "a07d2461d84107b3d037097c822ffdd36ed69d1cf7c0f70e12a3d1decf04e2e1", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "539274ab0ecf1a0078a6a72ef3465629e4d6018a3028095dc90f60a19c371717"}, 10 | "esbuild": {:hex, :esbuild, "0.8.2", "5f379dfa383ef482b738e7771daf238b2d1cfb0222bef9d3b20d4c8f06c7a7ac", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "558a8a08ed78eb820efbfda1de196569d8bfa9b51e8371a1934fbb31345feda7"}, 11 | "expo": {:hex, :expo, "1.1.1", "4202e1d2ca6e2b3b63e02f69cfe0a404f77702b041d02b58597c00992b601db5", [:mix], [], "hexpm", "5fb308b9cb359ae200b7e23d37c76978673aa1b06e2b3075d814ce12c5811640"}, 12 | "file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"}, 13 | "finch": {:hex, :finch, "0.20.0", "5330aefb6b010f424dcbbc4615d914e9e3deae40095e73ab0c1bb0968933cadf", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "2658131a74d051aabfcba936093c903b8e89da9a1b63e430bee62045fa9b2ee2"}, 14 | "floki": {:hex, :floki, "0.38.0", "62b642386fa3f2f90713f6e231da0fa3256e41ef1089f83b6ceac7a3fd3abf33", [:mix], [], "hexpm", "a5943ee91e93fb2d635b612caf5508e36d37548e84928463ef9dd986f0d1abd9"}, 15 | "gettext": {:hex, :gettext, "1.0.2", "5457e1fd3f4abe47b0e13ff85086aabae760497a3497909b8473e0acee57673b", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "eab805501886802071ad290714515c8c4a17196ea76e5afc9d06ca85fb1bfeb3"}, 16 | "heroicons": {:git, "https://github.com/tailwindlabs/heroicons.git", "0435d4ca364a608cc75e2f8683d374e55abbae26", [tag: "v2.2.0", sparse: "optimized", depth: 1]}, 17 | "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, 18 | "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, 19 | "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, 20 | "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, 21 | "mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"}, 22 | "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, 23 | "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, 24 | "phoenix": {:hex, :phoenix, "1.8.2", "75aba5b90081d88a54f2fc6a26453d4e76762ab095ff89be5a3e7cb28bff9300", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "19ea65b4064f17b1ab0515595e4d0ea65742ab068259608d5d7b139a73f47611"}, 25 | "phoenix_ecto": {:hex, :phoenix_ecto, "4.7.0", "75c4b9dfb3efdc42aec2bd5f8bccd978aca0651dbcbc7a3f362ea5d9d43153c6", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "1d75011e4254cb4ddf823e81823a9629559a1be93b4321a6a5f11a5306fbf4cc"}, 26 | "phoenix_html": {:hex, :phoenix_html, "4.3.0", "d3577a5df4b6954cd7890c84d955c470b5310bb49647f0a114a6eeecc850f7ad", [:mix], [], "hexpm", "3eaa290a78bab0f075f791a46a981bbe769d94bc776869f4f3063a14f30497ad"}, 27 | "phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.8.7", "405880012cb4b706f26dd1c6349125bfc903fb9e44d1ea668adaf4e04d4884b7", [:mix], [{:ecto, "~> 3.6.2 or ~> 3.7", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_mysql_extras, "~> 0.5", [hex: :ecto_mysql_extras, repo: "hexpm", optional: true]}, {:ecto_psql_extras, "~> 0.7", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:ecto_sqlite3_extras, "~> 1.1.7 or ~> 1.2.0", [hex: :ecto_sqlite3_extras, repo: "hexpm", optional: true]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.19 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "3a8625cab39ec261d48a13b7468dc619c0ede099601b084e343968309bd4d7d7"}, 28 | "phoenix_live_reload": {:hex, :phoenix_live_reload, "1.6.1", "05df733a09887a005ed0d69a7fc619d376aea2730bf64ce52ac51ce716cc1ef0", [:mix], [{:file_system, "~> 0.2.10 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "74273843d5a6e4fef0bbc17599f33e3ec63f08e69215623a0cd91eea4288e5a0"}, 29 | "phoenix_live_view": {:hex, :phoenix_live_view, "1.1.18", "b5410017b3d4edf261d9c98ebc334e0637d7189457c730720cfc13e206443d43", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0-rc", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "f189b759595feff0420e9a1d544396397f9cf9e2d5a8cb98ba5b6cab01927da0"}, 30 | "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.2.0", "ff3a5616e1bed6804de7773b92cbccfc0b0f473faf1f63d7daf1206c7aeaaa6f", [], [], "hexpm", "adc313a5bf7136039f63cfd9668fde73bba0765e0614cba80c06ac9460ff3e96"}, 31 | "phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"}, 32 | "plug": {:hex, :plug, "1.18.1", "5067f26f7745b7e31bc3368bc1a2b818b9779faa959b49c934c17730efc911cf", [], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "57a57db70df2b422b564437d2d33cf8d33cd16339c1edb190cd11b1a3a546cc2"}, 33 | "plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"}, 34 | "postgrex": {:hex, :postgrex, "0.21.1", "2c5cc830ec11e7a0067dd4d623c049b3ef807e9507a424985b8dcf921224cd88", [:mix], [{:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "27d8d21c103c3cc68851b533ff99eef353e6a0ff98dc444ea751de43eb48bdac"}, 35 | "req": {:hex, :req, "0.5.9", "09072dcd91a70c58734c4dd4fa878a9b6d36527291152885100ec33a5a07f1d6", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "2f027043003275918f5e79e6a4e57b10cb17161a1ab41c959aa40ecfb2142e5a"}, 36 | "rustler": {:hex, :rustler, "0.37.1", "721434020c7f6f8e1cdc57f44f75c490435b01de96384f8ccb96043f12e8a7e0", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "24547e9b8640cf00e6a2071acb710f3e12ce0346692e45098d84d45cdb54fd79"}, 37 | "rustler_precompiled": {:hex, :rustler_precompiled, "0.8.3", "4e741024b0b097fe783add06e53ae9a6f23ddc78df1010f215df0c02915ef5a8", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:rustler, "~> 0.23", [hex: :rustler, repo: "hexpm", optional: true]}], "hexpm", "c23f5f33cb6608542de4d04faf0f0291458c352a4648e4d28d17ee1098cddcc4"}, 38 | "swoosh": {:hex, :swoosh, "1.19.8", "0576f2ea96d1bb3a6e02cc9f79cbd7d497babc49a353eef8dce1a1f9f82d7915", [:mix], [{:bandit, ">= 1.0.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:idna, "~> 6.0", [hex: :idna, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mua, "~> 0.2.3", [hex: :mua, repo: "hexpm", optional: true]}, {:multipart, "~> 0.4", [hex: :multipart, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.5.10 or ~> 0.6 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "d7503c2daf0f9899afd8eba9923eeddef4b62e70816e1d3b6766e4d6c60e94ad"}, 39 | "table_rex": {:hex, :table_rex, "4.1.0", "fbaa8b1ce154c9772012bf445bfb86b587430fb96f3b12022d3f35ee4a68c918", [:mix], [], "hexpm", "95932701df195d43bc2d1c6531178fc8338aa8f38c80f098504d529c43bc2601"}, 40 | "tailwind": {:hex, :tailwind, "0.4.1", "e7bcc222fe96a1e55f948e76d13dd84a1a7653fb051d2a167135db3b4b08d3e9", [:mix], [], "hexpm", "6249d4f9819052911120dbdbe9e532e6bd64ea23476056adb7f730aa25c220d1"}, 41 | "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, 42 | "telemetry_metrics": {:hex, :telemetry_metrics, "1.1.0", "5bd5f3b5637e0abea0426b947e3ce5dd304f8b3bc6617039e2b5a008adc02f8f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e7b79e8ddfde70adb6db8a6623d1778ec66401f366e9a8f5dd0955c56bc8ce67"}, 43 | "telemetry_poller": {:hex, :telemetry_poller, "1.3.0", "d5c46420126b5ac2d72bc6580fb4f537d35e851cc0f8dbd571acf6d6e10f5ec7", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "51f18bed7128544a50f75897db9974436ea9bfba560420b646af27a9a9b35211"}, 44 | "thousand_island": {:hex, :thousand_island, "1.4.2", "735fa783005d1703359bbd2d3a5a3a398075ba4456e5afe3c5b7cf4666303d36", [], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "1c7637f16558fc1c35746d5ee0e83b18b8e59e18d28affd1f2fa1645f8bc7473"}, 45 | "toml": {:hex, :toml, "0.7.0", "fbcd773caa937d0c7a02c301a1feea25612720ac3fa1ccb8bfd9d30d822911de", [:mix], [], "hexpm", "0690246a2478c1defd100b0c9b89b4ea280a22be9a7b313a8a058a2408a2fa70"}, 46 | "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.1", "a48703a25c170eedadca83b11e88985af08d35f37c6f664d6dcfb106a97782fc", [:rebar3], [], "hexpm", "b3a917854ce3ae233619744ad1e0102e05673136776fb2fa76234f3e03b23642"}, 47 | "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, 48 | "websock_adapter": {:hex, :websock_adapter, "0.5.9", "43dc3ba6d89ef5dec5b1d0a39698436a1e856d000d84bf31a3149862b01a287f", [], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "5534d5c9adad3c18a0f58a9371220d75a803bf0b9a3d87e6fe072faaeed76a08"}, 49 | "y_ex": {:hex, :y_ex, "0.10.1", "e08baa2eb03dc77d7e75d87384a037efce9e7caeb8dfb29f913f51a41ed2c19a", [:mix], [{:rustler, ">= 0.0.0", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, ">= 0.6.0", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "87611dcc00ad04a7fff14d81684314485fa2e26763ea6c63e338975ecfb7f688"}, 50 | } 51 | --------------------------------------------------------------------------------