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 | editor.chain().focus().toggleBold().run()}
177 | className={editor.isActive("bold") ? "is-active" : ""}
178 | >
179 | Bold
180 |
181 | editor.chain().focus().toggleItalic().run()}
183 | className={editor.isActive("italic") ? "is-active" : ""}
184 | >
185 | Italic
186 |
187 | editor.chain().focus().toggleStrike().run()}
189 | className={editor.isActive("strike") ? "is-active" : ""}
190 | >
191 | Strike
192 |
193 | editor.chain().focus().toggleBulletList().run()}
195 | className={editor.isActive("bulletList") ? "is-active" : ""}
196 | >
197 | Bullet list
198 |
199 | editor.chain().focus().toggleCode().run()}
201 | className={editor.isActive("code") ? "is-active" : ""}
202 | >
203 | Code
204 |
205 |
206 |
207 |
208 |
209 |
210 |
214 | {" "}
215 |
216 | {status === "connected"
217 | ? `${editor.storage.collaborationCaret.users.length} user${
218 | editor.storage.collaborationCaret.users.length === 1 ? "" : "s"
219 | } online in ${room}`
220 | : "offline"}
221 |
222 |
223 | ✎ {currentUser.name}
224 |
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 |
--------------------------------------------------------------------------------