diff --git a/README.md b/README.md
index 46af2ef8fb8459fd9609fc7e9302f1c59ab86d35..46d72efe27b536bedc504479d399b4cd7d9c8aac 100644
--- a/README.md
+++ b/README.md
@@ -1,7 +1,9 @@
-
+
+
# Canvas
+
[](https://tips.sc07.com)
@@ -24,8 +26,8 @@ Inspired by Reddit's r/Place event from 2017, Canvas is a collaborative pixel ca
## Screenshots
-
-
+{width=500}
+{width=500}
## Roadmap
@@ -68,8 +70,14 @@ Details on contributing can be found in the [CONTRIBUTING.md file](./CONTRIBUTIN
Canvas is licened under the MIT license see `LICENSE`
+Artwork, images, emotes, logos, and other assets may be subject to separate licenses.
+
+See the license files within their respective directories.
+
---
+
-
+
+
diff --git a/eslint.config.mjs b/eslint.config.mjs
index 33086d094f7c97d7fc4a4e946dcf03a2ae082696..8343d1e58b2692e233f2b1d8ea58de8c06372f97 100644
--- a/eslint.config.mjs
+++ b/eslint.config.mjs
@@ -8,8 +8,8 @@ export default defineConfig(
globalIgnores([
"**/node_modules",
"packages/**/dist",
- "packages/**/*",
- "!packages/server/**/*",
+ "packages/admin",
+ "packages/chat",
]),
// -- global config --
@@ -28,8 +28,13 @@ export default defineConfig(
// -- client --
{
- files: ["packages/client/src"],
+ files: ["packages/client/src/**/*.{ts,tsx,js,jsx}"],
plugins: { react },
+ settings: {
+ react: {
+ version: "detect",
+ },
+ },
rules: {
"no-unused-vars": "off",
"@typescript-eslint/no-namespace": "off",
@@ -46,12 +51,20 @@ export default defineConfig(
ignoreRestSiblings: true,
},
],
+
+ "react/function-component-definition": [
+ "error",
+ {
+ namedComponents: "arrow-function",
+ unnamedComponents: "arrow-function",
+ },
+ ],
},
},
// -- server --
{
- files: ["packages/server/src"],
+ files: ["packages/server/src/**/*.{ts,js}"],
rules: {
"no-console": "error",
"no-unused-vars": "off",
diff --git a/package.json b/package.json
index db5eb011229404f8a89975aed8330e900e19ea05..11f1867b2e094c7b66475ad233348a22a795814d 100644
--- a/package.json
+++ b/package.json
@@ -42,6 +42,7 @@
"eslint": "^9.39.2",
"eslint-config-react-app": "^7.0.1",
"eslint-formatter-gitlab": "^7.0.1",
+ "eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-simple-import-sort": "^12.1.1",
"jest-fetch-mock": "^3.0.3",
diff --git a/packages/client/src/Moderator/ModCanvasOverlay.tsx b/packages/client/src/Moderator/ModCanvasOverlay.tsx
index 77bb6806ef43560cbf0596cfacb19b5d3e3bd6f5..7714fa2ffc8e664c409c6e071bd689ec8537245c 100644
--- a/packages/client/src/Moderator/ModCanvasOverlay.tsx
+++ b/packages/client/src/Moderator/ModCanvasOverlay.tsx
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useState } from "react";
-import { KeybindManager } from "../lib/keybinds";
+
import { CanvasCore } from "../lib/canvas";
+import { KeybindManager } from "../lib/keybinds";
import { useModerator } from "./Moderator";
export const ModCanvasOverlay = () => {
diff --git a/packages/client/src/Moderator/ModSidebar.tsx b/packages/client/src/Moderator/ModSidebar.tsx
index 2cc65af695bcaa7c76a78d62f1fae5bf36056d02..6ecc316ffdf796845b1b341e98fdee7c781414e8 100644
--- a/packages/client/src/Moderator/ModSidebar.tsx
+++ b/packages/client/src/Moderator/ModSidebar.tsx
@@ -1,15 +1,16 @@
-import { Hammer } from "lucide-react";
-import { SidebarBase } from "../components/SidebarBase";
-import { useModerator } from "./Moderator";
import { Alert, Chip } from "@heroui/react";
-import { KeybindManager } from "../lib/keybinds";
-import { Keybind } from "../components/KeybindModal";
-import { useCallback, useState } from "react";
-import { handleError, oapi } from "../lib/utils";
import { EError, type RestAPI } from "@sc07-canvas/lib";
-import { UserCard } from "../components/Profile/UserCard";
+import { Hammer } from "lucide-react";
+import { useCallback, useState } from "react";
import { toast } from "react-toastify";
+
import { Button } from "../components/core/Button";
+import { Keybind } from "../components/KeybindModal";
+import { UserCard } from "../components/Profile/UserCard";
+import { SidebarBase } from "../components/SidebarBase";
+import { KeybindManager } from "../lib/keybinds";
+import { handleError, oapi } from "../lib/utils";
+import { useModerator } from "./Moderator";
export const ModSidebar = () => {
const { state, dispatch } = useModerator();
diff --git a/packages/client/src/Moderator/Moderator.tsx b/packages/client/src/Moderator/Moderator.tsx
index c0495d78072370e9c5ce5c5ffc219533dd31d318..d0a9215d0edcf3546bcac73e34a674d5579d72a2 100644
--- a/packages/client/src/Moderator/Moderator.tsx
+++ b/packages/client/src/Moderator/Moderator.tsx
@@ -1,9 +1,18 @@
-import React, { createContext, useContext, useEffect, useReducer } from "react";
-import { UserModSidebar } from "./UserModSidebar";
-import { KeybindManager } from "../lib/keybinds";
-import { ModSidebar } from "./ModSidebar";
+import React, {
+ createContext,
+ useCallback,
+ useContext,
+ useEffect,
+ useLayoutEffect,
+ useReducer,
+} from "react";
+
+import { usePanel } from "../contexts/PanelContext";
import { useHasRole } from "../hooks/useHasRole";
+import { KeybindManager } from "../lib/keybinds";
import { ModeratorModule } from "./ModeratorModule";
+import { ModSidebar } from "./ModSidebar";
+import { UserModSidebar } from "./UserModSidebar";
ModeratorModule.get(); // initialize
@@ -40,18 +49,21 @@ export const useModerator = () => useContext(context);
export const ModeratorContext = ({ children }: React.PropsWithChildren) => {
const isMod = useHasRole("MOD");
+ const Panel = usePanel();
- const [state, dispatch] = useReducer(
+ const [state, reducerDispatch] = useReducer(
(state, data) => {
switch (data.action) {
case "INSPECT_USER":
return {
...state,
inspectUser: data.user,
+ showMenu: data.user ? false : state.showMenu,
};
case "OPEN_MENU":
return {
...state,
+ inspectUser: undefined,
showMenu: true,
};
case "CLOSE_MENU":
@@ -105,6 +117,29 @@ export const ModeratorContext = ({ children }: React.PropsWithChildren) => {
}
);
+ const dispatch = useCallback(
+ (action: ModerationActions) => {
+ if (
+ action.action === "OPEN_MENU" ||
+ (action.action === "INSPECT_USER" && action.user)
+ ) {
+ Panel.dispatch(["info", undefined]);
+ }
+ reducerDispatch(action);
+ },
+ [Panel.dispatch],
+ );
+
+ useLayoutEffect(() => {
+ if (!Panel.state.info) return;
+
+ if (state.showMenu) {
+ reducerDispatch({ action: "CLOSE_MENU" });
+ } else if (state.inspectUser) {
+ reducerDispatch({ action: "INSPECT_USER" });
+ }
+ }, [Panel.state.info, state.inspectUser, state.showMenu]);
+
useEffect(() => {
if (isMod) {
ModeratorModule.get().connect();
diff --git a/packages/client/src/Moderator/UserModSidebar.tsx b/packages/client/src/Moderator/UserModSidebar.tsx
index da3f027e3d855186c7792354d00adda18de49ddb..d171febdd57d00f535285ee1ae6e7f50b6c8ff46 100644
--- a/packages/client/src/Moderator/UserModSidebar.tsx
+++ b/packages/client/src/Moderator/UserModSidebar.tsx
@@ -1,9 +1,3 @@
-import { Hammer } from "lucide-react";
-import { SidebarBase } from "../components/SidebarBase";
-import { ModeratorUtils, useModerator } from "./Moderator";
-import { useCallback, useEffect, useState } from "react";
-import { handleError, oapi, useQuery } from "../lib/utils";
-import { UserCard } from "../components/Profile/UserCard";
import {
Accordion,
AccordionItem,
@@ -13,10 +7,17 @@ import {
Link,
} from "@heroui/react";
import { CalendarDateTime, parseDateTime } from "@internationalized/date";
-import { toast } from "react-toastify";
import { EError } from "@sc07-canvas/lib";
-import { ModeratorModule } from "./ModeratorModule";
+import { Hammer } from "lucide-react";
+import { useCallback, useEffect, useState } from "react";
+import { toast } from "react-toastify";
+
import { Button } from "../components/core/Button";
+import { UserCard } from "../components/Profile/UserCard";
+import { SidebarBase } from "../components/SidebarBase";
+import { handleError, oapi, useQuery } from "../lib/utils";
+import { ModeratorUtils, useModerator } from "./Moderator";
+import { ModeratorModule } from "./ModeratorModule";
export const UserModSidebar = () => {
const { state, dispatch } = useModerator();
diff --git a/packages/client/src/assets/reactions/LICENSE.txt b/packages/client/src/assets/reactions/LICENSE.txt
new file mode 100644
index 0000000000000000000000000000000000000000..66de5c5d2c1edec69ef896d813320cc09e352c67
--- /dev/null
+++ b/packages/client/src/assets/reactions/LICENSE.txt
@@ -0,0 +1,9 @@
+Copyright © 2026 Rincs.
+
+These emote assets are not licensed under the MIT License.
+
+They are included in this repository with permission from the copyright holder
+for use in this project only.
+
+No permission is granted to copy, modify, redistribute, or use these assets
+outside of this project without permission from the copyright holder.
\ No newline at end of file
diff --git a/packages/client/src/assets/reactions/awe.png b/packages/client/src/assets/reactions/awe.png
new file mode 100644
index 0000000000000000000000000000000000000000..e7d7db5cfb419d7b9641ed272bd9460ee217dc60
Binary files /dev/null and b/packages/client/src/assets/reactions/awe.png differ
diff --git a/packages/client/src/assets/reactions/bongo.gif b/packages/client/src/assets/reactions/bongo.gif
new file mode 100644
index 0000000000000000000000000000000000000000..7341737a15d0915e382ba0fe628001fc28b7f209
Binary files /dev/null and b/packages/client/src/assets/reactions/bongo.gif differ
diff --git a/packages/client/src/assets/reactions/cry.png b/packages/client/src/assets/reactions/cry.png
new file mode 100644
index 0000000000000000000000000000000000000000..a2237772115d9abeee763ca8ffbbaf3fda1ea244
Binary files /dev/null and b/packages/client/src/assets/reactions/cry.png differ
diff --git a/packages/client/src/assets/reactions/cute.png b/packages/client/src/assets/reactions/cute.png
new file mode 100644
index 0000000000000000000000000000000000000000..1fa1bc1e3c232edd6eddb8e979d4d7a1830fa73b
Binary files /dev/null and b/packages/client/src/assets/reactions/cute.png differ
diff --git a/packages/client/src/assets/reactions/jam.gif b/packages/client/src/assets/reactions/jam.gif
new file mode 100644
index 0000000000000000000000000000000000000000..309ac28454cc7c5d1898a919d2c8ac0ef6435480
Binary files /dev/null and b/packages/client/src/assets/reactions/jam.gif differ
diff --git a/packages/client/src/assets/reactions/love.png b/packages/client/src/assets/reactions/love.png
new file mode 100644
index 0000000000000000000000000000000000000000..0282ba9e1d572dbfabe259807e1d2cc3ff596610
Binary files /dev/null and b/packages/client/src/assets/reactions/love.png differ
diff --git a/packages/client/src/assets/reactions/pog.png b/packages/client/src/assets/reactions/pog.png
new file mode 100644
index 0000000000000000000000000000000000000000..c081c2b561319c958d2d9f5b3bdc2169a6b2f33a
Binary files /dev/null and b/packages/client/src/assets/reactions/pog.png differ
diff --git a/packages/client/src/assets/reactions/shocked.png b/packages/client/src/assets/reactions/shocked.png
new file mode 100644
index 0000000000000000000000000000000000000000..9e0f059084b49e2e54317423157c2adf70cc6c33
Binary files /dev/null and b/packages/client/src/assets/reactions/shocked.png differ
diff --git a/packages/client/src/assets/reactions/work.png b/packages/client/src/assets/reactions/work.png
new file mode 100644
index 0000000000000000000000000000000000000000..3ee567073b050a6d797a0cf8eed2fe48f2723b93
Binary files /dev/null and b/packages/client/src/assets/reactions/work.png differ
diff --git a/packages/client/src/assets/reactions/yay.png b/packages/client/src/assets/reactions/yay.png
new file mode 100644
index 0000000000000000000000000000000000000000..51b5628ae977ce00077c1a46ac4fc6f77d49aa12
Binary files /dev/null and b/packages/client/src/assets/reactions/yay.png differ
diff --git a/packages/client/src/components/App.tsx b/packages/client/src/components/App.tsx
index dc0769cab44827b9c56f5e259dc76b154a44293f..178382f1c5cc74bb1072b1adf75fcd79011cf2cf 100644
--- a/packages/client/src/components/App.tsx
+++ b/packages/client/src/components/App.tsx
@@ -1,32 +1,34 @@
-import { Header } from "./Header/Header";
+import "react-toastify/dist/ReactToastify.css";
+
import { useEffect, useState } from "react";
-import { AppContext, useAppContext } from "../contexts/AppContext";
-import { CanvasWrapper } from "./CanvasWrapper";
-import { TemplateContext } from "../contexts/TemplateContext";
-import { SettingsSidebar } from "./Settings/SettingsSidebar";
-import { DebugModal } from "./Debug/DebugModal";
-import { ToolbarWrapper } from "./Toolbar/ToolbarWrapper";
import { SWRConfig } from "swr";
-import "react-toastify/dist/ReactToastify.css";
-import { AuthErrors } from "./AuthErrors";
+import AudioContext from "@/contexts/AudioContext";
+import { CaptureContext } from "@/contexts/CaptureContext";
+import PanelContext, { usePanel } from "@/contexts/PanelContext";
+
+import { AppContext, useAppContext } from "../contexts/AppContext";
+import { DialogContext } from "../contexts/DialogContext";
+import { TemplateContext } from "../contexts/TemplateContext";
import { KeybindManager } from "../lib/keybinds";
-import { PixelWhoisSidebar } from "./PixelSidebar/PixelWhoisSidebar";
-import { KeybindModal } from "./KeybindModal";
-import { ProfileModal } from "./Profile/ProfileModal";
-import { InfoSidebar } from "./Info/InfoSidebar";
-import { DynamicModals } from "./DynamicModals";
-import { ToastWrapper } from "./ToastWrapper";
import { Moderator, ModeratorContext } from "../Moderator/Moderator";
-import { DialogContext } from "../contexts/DialogContext";
+import { AuthErrors } from "./AuthErrors";
+import { CanvasWrapper } from "./CanvasWrapper";
import { ChatInfoDialog } from "./Chat/ChatInfoDialog";
-import { LoginModal } from "./Login/LoginModal";
import { Chat, ChatContext } from "./Chat/utils";
-import { EventInfoModal } from "./EventInfo/EventInfo";
-import AudioContext from "@/contexts/AudioContext";
-import PanelContext, { usePanel } from "@/contexts/PanelContext";
-import { CaptureContext } from "@/contexts/CaptureContext";
import { ColorPickFeedback } from "./ColorPickFeedback";
+import { DebugModal } from "./Debug/DebugModal";
+import { DynamicModals } from "./DynamicModals";
+import { EventInfoModal } from "./EventInfo/EventInfo";
+import { Header } from "./Header/Header";
+import { InfoSidebar } from "./Info/InfoSidebar";
+import { KeybindModal } from "./KeybindModal";
+import { LoginModal } from "./Login/LoginModal";
+import { PixelWhoisSidebar } from "./PixelSidebar/PixelWhoisSidebar";
+import { ProfileModal } from "./Profile/ProfileModal";
+import { SettingsSidebar } from "./Settings/SettingsSidebar";
+import { ToastWrapper } from "./ToastWrapper";
+import { ToolbarWrapper } from "./Toolbar/ToolbarWrapper";
console.log("Client init with version " + __COMMIT_HASH__);
diff --git a/packages/client/src/components/AuthErrors.tsx b/packages/client/src/components/AuthErrors.tsx
index 6b997a91869a2d8dc2d1085d8e4ec0f12f8df6fe..b8aaf601799bd409e2fccbdc16b324683b46d43d 100644
--- a/packages/client/src/components/AuthErrors.tsx
+++ b/packages/client/src/components/AuthErrors.tsx
@@ -7,6 +7,7 @@ import {
ModalHeader,
} from "@heroui/react";
import { useEffect, useState } from "react";
+
import { Button } from "./core/Button";
const Params = {
diff --git a/packages/client/src/components/CanvasWrapper.tsx b/packages/client/src/components/CanvasWrapper.tsx
index c4247d99f8f7da73ab767eda188995799363f88e..b231cc708933604d3acb2262edaece503b3f9217 100644
--- a/packages/client/src/components/CanvasWrapper.tsx
+++ b/packages/client/src/components/CanvasWrapper.tsx
@@ -1,3 +1,11 @@
+import { type IPosition } from "@sc07-canvas/lib/net";
+import {
+ MOUSE_LONG_PRESS_DELAY,
+ PanZoomWrapper,
+ RendererContext,
+ type ViewportMoveEvent,
+} from "@sc07-canvas/lib/renderer";
+import throttle from "lodash.throttle";
import {
type CSSProperties,
useCallback,
@@ -6,41 +14,39 @@ import {
useRef,
useState,
} from "react";
-import { CanvasCore } from "../lib/canvas";
+
+import { useCaptureContext } from "@/contexts/CaptureContext";
+import { usePanel } from "@/contexts/PanelContext";
+
import { useAppContext } from "../contexts/AppContext";
-import {
- MOUSE_LONG_PRESS_DELAY,
- PanZoomWrapper,
- RendererContext,
- type ViewportMoveEvent,
-} from "@sc07-canvas/lib/renderer";
-import throttle from "lodash.throttle";
-import { type IPosition } from "@sc07-canvas/lib/net";
-import { Template } from "./Templating/Template";
-import { Template as TemplateCl } from "../lib/template";
-import { type IRouterData, Router } from "../lib/router";
-import { KeybindManager } from "../lib/keybinds";
-import { HeatmapOverlay } from "./Overlay/HeatmapOverlay";
import { useTemplateContext } from "../contexts/TemplateContext";
-import { PixelPulses } from "./Overlay/PixelPulses";
-import { CanvasUtils } from "../lib/canvas.utils";
-import { ModCanvasOverlay } from "../Moderator/ModCanvasOverlay";
import { useHasRole } from "../hooks/useHasRole";
-import { GridOverlay } from "./Overlay/GridOverlay";
+import { useIsMobile } from "../hooks/useIsMobile";
+import { CanvasCore } from "../lib/canvas";
+import { CanvasUtils } from "../lib/canvas.utils";
+import { KeybindManager } from "../lib/keybinds";
import { PaletteLib } from "../lib/palette";
+import { type IRouterData, Router } from "../lib/router";
+import { Template as TemplateCl } from "../lib/template";
import { getRenderer } from "../lib/utils";
-import { usePanel } from "@/contexts/PanelContext";
-import { useCaptureContext } from "@/contexts/CaptureContext";
+import { ModCanvasOverlay } from "../Moderator/ModCanvasOverlay";
import {
showColorPickFeedback,
showPixelPlacedFeedback,
} from "./ColorPickFeedback";
+import { CursorPresenceLayer } from "./CursorPresenceLayer";
+import { GridOverlay } from "./Overlay/GridOverlay";
+import { HeatmapOverlay } from "./Overlay/HeatmapOverlay";
+import { PixelPulses } from "./Overlay/PixelPulses";
+import { Template } from "./Templating/Template";
const KEYBOARD_ZOOM_STEP = 1;
const KEYBOARD_ZOOM_SMALL_STEP = 0.35;
+const INSPECT_END_ANIMATION_DURATION = 180;
export const CanvasWrapper = () => {
const hasMod = useHasRole("MOD");
+ const isMobile = useIsMobile();
const { config } = useAppContext();
const getInitialPosition = useCallback<
@@ -88,63 +94,158 @@ export const CanvasWrapper = () => {
{config && }
-
+
+ {!isMobile && }
);
-};
+}
-const Cursor = () => {
+const CursorInspectPreview = () => {
const { cursor } = useAppContext();
const PanZoom = useContext(RendererContext);
- const [color, setColor] = useState();
+ const selectedColor =
+ typeof cursor.color === "number"
+ ? PaletteLib.getColor(cursor.color)?.hex
+ : undefined;
+ const hasSelectedColor = Boolean(
+ selectedColor && selectedColor !== "transparent",
+ );
+ const color = hasSelectedColor ? selectedColor!.replace(/^#/, "") : "ffffff";
const [inspectHold, setInspectHold] = useState<
- "idle" | "holding" | "ready"
+ "idle" | "holding" | "ready" | "ending"
>("idle");
+ const [inspectPosition, setInspectPosition] = useState<
+ { x: number; y: number } | undefined
+ >(undefined);
+ const inspectPointerDown = useRef<
+ { clientX: number; clientY: number } | undefined
+ >(undefined);
+ const inspectElement = useRef(null);
+ const inspectStartedAt = useRef(0);
+ const inspectEndTimer = useRef(undefined);
useEffect(() => {
- if (typeof cursor.color === "number") {
- const color = PaletteLib.getColor(cursor.color);
- setColor(color?.hex);
- } else {
- setColor(undefined);
- }
- }, [setColor, cursor.color]);
+ const clearEndTimer = () => {
+ if (inspectEndTimer.current === undefined) return;
+ window.clearTimeout(inspectEndTimer.current);
+ inspectEndTimer.current = undefined;
+ };
+ const updateInspectPosition = () => {
+ const position = inspectPointerDown.current;
+ const canvas = CanvasCore.get(false);
+ if (!canvas || !position) return;
+ const [x, y] = canvas.screenToPos(position.clientX, position.clientY);
+ setInspectPosition({ x, y });
+ };
+ const handlePointerDown = (event: PointerEvent) => {
+ if (
+ event.button !== 0 ||
+ !(event.target instanceof Element) ||
+ !event.target.closest(".board-wrapper")
+ ) {
+ return;
+ }
+ inspectPointerDown.current = {
+ clientX: event.clientX,
+ clientY: event.clientY,
+ };
+ };
+ const handleStart = () => {
+ clearEndTimer();
+ inspectStartedAt.current = performance.now();
+ updateInspectPosition();
+ setInspectHold("holding");
+ };
+ const handleReady = () => {
+ clearEndTimer();
+ updateInspectPosition();
+ setInspectHold("ready");
+ };
+ const handleEnd = () => {
+ clearEndTimer();
+ const elapsed = performance.now() - inspectStartedAt.current;
+ const showedInspectUi = elapsed >= MOUSE_LONG_PRESS_DELAY * 0.2;
+
+ if (!showedInspectUi) {
+ setInspectHold("idle");
+ setInspectPosition(undefined);
+ return;
+ }
+ const element = inspectElement.current;
+ if (element) {
+ const surface = getComputedStyle(element);
+ const fill = getComputedStyle(element, "::before");
+ const question = getComputedStyle(element, "::after");
+ element.style.setProperty("--inspect-release-opacity", surface.opacity);
+ element.style.setProperty(
+ "--inspect-release-shadow",
+ surface.boxShadow,
+ );
+ element.style.setProperty("--inspect-release-fill-height", fill.height);
+ element.style.setProperty(
+ "--inspect-release-fill-opacity",
+ fill.opacity,
+ );
+ element.style.setProperty(
+ "--inspect-release-question-size",
+ question.backgroundSize,
+ );
+ element.style.setProperty(
+ "--inspect-release-question-opacity",
+ question.opacity,
+ );
+ }
- useEffect(() => {
- const handleStart = () => setInspectHold("holding");
- const handleReady = () => setInspectHold("ready");
- const handleEnd = () => setInspectHold("idle");
+ setInspectHold("ending");
+ inspectEndTimer.current = window.setTimeout(() => {
+ inspectEndTimer.current = undefined;
+ setInspectHold("idle");
+ setInspectPosition(undefined);
+ }, INSPECT_END_ANIMATION_DURATION);
+ };
+ document.addEventListener("pointerdown", handlePointerDown, true);
PanZoom.on("inspectHoldStart", handleStart);
PanZoom.on("inspectHoldReady", handleReady);
PanZoom.on("inspectHoldEnd", handleEnd);
return () => {
+ clearEndTimer();
+ document.removeEventListener("pointerdown", handlePointerDown, true);
PanZoom.off("inspectHoldStart", handleStart);
PanZoom.off("inspectHoldReady", handleReady);
PanZoom.off("inspectHoldEnd", handleEnd);
};
}, [PanZoom]);
- if (!color) return <>>;
return (
+ style={
+ {
+ top: inspectPosition?.y ?? cursor.y,
+ left: inspectPosition?.x ?? cursor.x,
+ "--cursor-preview-color": "#" + color,
+ "--inspect-hold-delay": MOUSE_LONG_PRESS_DELAY * 0.2 + "ms",
+ "--inspect-hold-fill-duration": MOUSE_LONG_PRESS_DELAY * 0.7 + "ms",
+ "--inspect-hold-surface-duration":
+ MOUSE_LONG_PRESS_DELAY * 0.27 + "ms",
+ "--inspect-end-duration": INSPECT_END_ANIMATION_DURATION + "ms",
+ } as CSSProperties
+ }
+ />
);
};
@@ -160,12 +261,8 @@ const CanvasInner = () => {
enable: templateEnable,
} = useTemplateContext();
const PanZoom = useContext(RendererContext);
- const {
- captureCanvas,
- captureView,
- captureRegion,
- captureTemplateArea,
- } = useCaptureContext();
+ const { captureCanvas, captureView, captureRegion, captureTemplateArea } =
+ useCaptureContext();
useEffect(() => {
const heldKeys = new Set();
@@ -226,10 +323,8 @@ const CanvasInner = () => {
velocityY += (targetVelocityY - velocityY) * easing;
if (Math.abs(velocityX) > 0.5 || Math.abs(velocityY) > 0.5) {
- PanZoom.transform.x +=
- (velocityX * elapsed) / PanZoom.transform.scale;
- PanZoom.transform.y +=
- (velocityY * elapsed) / PanZoom.transform.scale;
+ PanZoom.transform.x += (velocityX * elapsed) / PanZoom.transform.scale;
+ PanZoom.transform.y += (velocityY * elapsed) / PanZoom.transform.scale;
const visualX = snapToDevicePixel(PanZoom.transform.x);
const visualY = snapToDevicePixel(PanZoom.transform.y);
PanZoom.$move.style.setProperty(
@@ -354,7 +449,10 @@ const CanvasInner = () => {
// .......
const surrounding = canvas.current.getSurroundingPixels(x, y, 3);
- Panel.dispatch(["whois", { x, y, surrounding }]);
+ Panel.dispatch([
+ "whois",
+ { x, y, surrounding, requestId: crypto.randomUUID() },
+ ]);
},
[canvas.current],
);
@@ -401,7 +499,11 @@ const CanvasInner = () => {
}
PaletteLib.setSelectedColor(pixelColor);
- showColorPickFeedback(clientX, clientY);
+ showColorPickFeedback(
+ clientX,
+ clientY,
+ PaletteLib.getColor(pixelColor)?.hex,
+ );
},
[canvas.current],
);
@@ -412,10 +514,17 @@ const CanvasInner = () => {
const handlePixelPlaced = ({
clientX,
clientY,
+ color,
}: {
clientX: number;
clientY: number;
- }) => showPixelPlacedFeedback(clientX, clientY);
+ color: number;
+ }) =>
+ showPixelPlacedFeedback(
+ clientX,
+ clientY,
+ PaletteLib.getColor(color)?.hex,
+ );
canvas.current.on("pixelPlaced", handlePixelPlaced);
canvas.current.on("canvasReady", () => {
@@ -639,7 +748,7 @@ const CanvasInner = () => {
getRenderer().useCanvas($blank, "blank");
return () => {
- CanvasCore.get().setCanvas(null);
+ CanvasCore.get(false)?.setCanvas(null);
$canvas.remove();
getRenderer().removeCanvas("blank");
$blank.remove();
diff --git a/packages/client/src/components/Captcha/CaptchaStatusBar.tsx b/packages/client/src/components/Captcha/CaptchaStatusBar.tsx
index 7c8a36d291d1438ca2f459dd26a7cdbd647edf39..5a4c8551621603019f8f4c184e298517999649a6 100644
--- a/packages/client/src/components/Captcha/CaptchaStatusBar.tsx
+++ b/packages/client/src/components/Captcha/CaptchaStatusBar.tsx
@@ -1,6 +1,7 @@
-import { motion } from "framer-motion";
import { Card, CardBody } from "@heroui/react";
+import { motion } from "framer-motion";
import { useEffect, useRef, useState } from "react";
+
import { CaptchaService, type CaptchaServiceStatus } from "../../lib/captcha";
const Timer = () => {
diff --git a/packages/client/src/components/Chat/ChatInfoDialog.tsx b/packages/client/src/components/Chat/ChatInfoDialog.tsx
index cc75f4bdc18cafa7d2fe6d9fa98f43c797505ea5..f63a36cb0f25c965efa5dc896486ae259c72b968 100644
--- a/packages/client/src/components/Chat/ChatInfoDialog.tsx
+++ b/packages/client/src/components/Chat/ChatInfoDialog.tsx
@@ -9,9 +9,12 @@ import {
ModalHeader,
useDisclosure,
} from "@heroui/react";
-import { useDialog } from "../../contexts/DialogContext";
import { SiDiscord, SiMatrix } from "@icons-pack/react-simple-icons";
import { useCallback, useState } from "react";
+
+import CinnyLogo from "@/assets/cinny.svg";
+
+import { useDialog } from "../../contexts/DialogContext";
import {
DISCORD_INVITE,
MATRIX_ALIAS,
@@ -19,7 +22,6 @@ import {
MATRIX_URL,
} from "../../lib/constants";
import { Button } from "../core/Button";
-import CinnyLogo from "@/assets/cinny.svg";
export const ChatInfoDialog = () => {
const { state, dispatch } = useDialog();
diff --git a/packages/client/src/components/Chat/OpenChatButton.tsx b/packages/client/src/components/Chat/OpenChatButton.tsx
index 0f06684302557f1369d8fccd8ccb719e727d42cf..2217b1ba86679eb84008f0ff9b27fd8a12f01e99 100644
--- a/packages/client/src/components/Chat/OpenChatButton.tsx
+++ b/packages/client/src/components/Chat/OpenChatButton.tsx
@@ -1,5 +1,6 @@
import { type ButtonProps } from "@heroui/react";
import { MessageCircle } from "lucide-react";
+
import { useDialog } from "../../contexts/DialogContext";
import { Button } from "../core/Button";
diff --git a/packages/client/src/components/Chat/utils.tsx b/packages/client/src/components/Chat/utils.tsx
index 7e5f190957c751236ddb037ae0938769825759f9..ed3e5aba6817e0a1fc0ce84edcc25cd91d0956b9 100644
--- a/packages/client/src/components/Chat/utils.tsx
+++ b/packages/client/src/components/Chat/utils.tsx
@@ -1,6 +1,7 @@
-import React, { lazy } from "react";
import { YapContext } from "@sc07-canvas/chat";
+import React, { lazy } from "react";
import { memo } from "react";
+
import { useAppContext } from "../../contexts/AppContext";
const Maybe = ({
diff --git a/packages/client/src/components/ColorPickFeedback.tsx b/packages/client/src/components/ColorPickFeedback.tsx
index 36adb3c29b88e170618b507407243591d84c2c7e..a0c7a20b42cddf18dd9e9ecb02e4b82a5e150340 100644
--- a/packages/client/src/components/ColorPickFeedback.tsx
+++ b/packages/client/src/components/ColorPickFeedback.tsx
@@ -1,4 +1,4 @@
-import { useEffect, useRef, useState } from "react";
+import { type CSSProperties, useEffect, useRef, useState } from "react";
const CANVAS_FEEDBACK_EVENT = "canvas-feedback";
const FEEDBACK_DURATION = 850;
@@ -6,6 +6,8 @@ const FEEDBACK_DURATION = 850;
interface CanvasFeedbackEventDetail {
clientX: number;
clientY: number;
+ color?: string;
+ outline?: string;
message: string;
}
@@ -13,19 +15,54 @@ interface CanvasFeedbackState extends CanvasFeedbackEventDetail {
id: number;
}
-const showFeedback = (message: string, clientX: number, clientY: number) => {
+const contrastOutline = (color: string | undefined) => {
+ const hex = color?.replace("#", "");
+ if (!hex || !/^[0-9a-f]{6}$/i.test(hex)) return "#111111";
+
+ const red = Number.parseInt(hex.slice(0, 2), 16);
+ const green = Number.parseInt(hex.slice(2, 4), 16);
+ const blue = Number.parseInt(hex.slice(4, 6), 16);
+ const brightness = red * 0.299 + green * 0.587 + blue * 0.114;
+ return brightness < 150 ? "#ffffff" : "#111111";
+};
+
+const showFeedback = (
+ message: string,
+ clientX: number,
+ clientY: number,
+ color?: string,
+) => {
+ const normalizedColor =
+ color && color !== "transparent"
+ ? color.startsWith("#")
+ ? color
+ : `#${color}`
+ : undefined;
+
window.dispatchEvent(
new CustomEvent(CANVAS_FEEDBACK_EVENT, {
- detail: { clientX, clientY, message },
+ detail: {
+ clientX,
+ clientY,
+ color: normalizedColor,
+ outline: contrastOutline(normalizedColor),
+ message,
+ },
}),
);
};
-export const showColorPickFeedback = (clientX: number, clientY: number) =>
- showFeedback("Color picked!", clientX, clientY);
+export const showColorPickFeedback = (
+ clientX: number,
+ clientY: number,
+ color?: string,
+) => showFeedback("Color picked!", clientX, clientY, color);
-export const showPixelPlacedFeedback = (clientX: number, clientY: number) =>
- showFeedback("Pixel placed!", clientX, clientY);
+export const showPixelPlacedFeedback = (
+ clientX: number,
+ clientY: number,
+ color?: string,
+) => showFeedback("Pixel placed!", clientX, clientY, color);
export const ColorPickFeedback = () => {
const [feedback, setFeedback] = useState([]);
@@ -34,7 +71,7 @@ export const ColorPickFeedback = () => {
useEffect(() => {
const handleFeedback = (event: Event) => {
- const { clientX, clientY, message } = (
+ const { clientX, clientY, color, outline, message } = (
event as CustomEvent
).detail;
@@ -42,7 +79,7 @@ export const ColorPickFeedback = () => {
const id = nextId.current;
setFeedback((current) => [
...current,
- { id, clientX, clientY, message },
+ { id, clientX, clientY, color, outline, message },
]);
const hideTimer = setTimeout(() => {
@@ -71,7 +108,14 @@ export const ColorPickFeedback = () => {
aria-live="polite"
className="color-picked-feedback"
role="status"
- style={{ left: item.clientX, top: item.clientY }}
+ style={
+ {
+ "--feedback-outline": item.outline,
+ color: item.color,
+ left: item.clientX,
+ top: item.clientY,
+ } as CSSProperties
+ }
>
{item.message}
diff --git a/packages/client/src/components/CursorPresenceLayer.tsx b/packages/client/src/components/CursorPresenceLayer.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..ddfe4f2cc8a8eb082e01b3d9d23ee513c2c8e03d
--- /dev/null
+++ b/packages/client/src/components/CursorPresenceLayer.tsx
@@ -0,0 +1,993 @@
+import {
+ type CursorMove,
+ type CursorReactionCooldown,
+ type CursorView,
+ type CursorWireState,
+ getCursorRegion,
+ getCursorView,
+} from "@sc07-canvas/lib/net";
+import { RendererContext } from "@sc07-canvas/lib/renderer";
+import throttle from "lodash.throttle";
+import {
+ type CSSProperties,
+ useContext,
+ useEffect,
+ useLayoutEffect,
+ useRef,
+} from "react";
+import { createPortal } from "react-dom";
+import { toast } from "react-toastify";
+
+import reactionAwe from "../assets/reactions/awe.png";
+import reactionBongo from "../assets/reactions/bongo.gif";
+import reactionCry from "../assets/reactions/cry.png";
+import reactionCute from "../assets/reactions/cute.png";
+import reactionJam from "../assets/reactions/jam.gif";
+import reactionLove from "../assets/reactions/love.png";
+import reactionPog from "../assets/reactions/pog.png";
+import reactionShocked from "../assets/reactions/shocked.png";
+import reactionWork from "../assets/reactions/work.png";
+import reactionYay from "../assets/reactions/yay.png";
+import { useAppContext } from "../contexts/AppContext";
+import { CanvasCore } from "../lib/canvas";
+import Network from "../lib/network";
+import {
+ colorToCss,
+ outlineForColor,
+ RemoteCursorRenderer,
+} from "./RemoteCursorRenderer";
+
+const CURSOR_FRAME_VERSION = 1;
+const CURSOR_FRAME_HEADER_SIZE = 8;
+const CURSOR_FRAME_RECORD_SIZE = 24;
+const CURSOR_FRAME_KEYFRAME = 1;
+
+const decodeCursorFrame = (frame: ArrayBuffer) => {
+ if (
+ !(frame instanceof ArrayBuffer) ||
+ frame.byteLength < CURSOR_FRAME_HEADER_SIZE
+ ) {
+ return;
+ }
+
+ const view = new DataView(frame);
+ if (view.getUint8(0) !== CURSOR_FRAME_VERSION) return;
+ const count = view.getUint16(2, true);
+ if (
+ frame.byteLength !==
+ CURSOR_FRAME_HEADER_SIZE + count * CURSOR_FRAME_RECORD_SIZE
+ ) {
+ return;
+ }
+
+ const cursors: CursorWireState[] = [];
+ for (let index = 0; index < count; index++) {
+ const offset = CURSOR_FRAME_HEADER_SIZE + index * CURSOR_FRAME_RECORD_SIZE;
+ const color = view.getInt32(offset + 20, true);
+ cursors.push([
+ view.getUint32(offset, true),
+ view.getUint32(offset + 4, true),
+ view.getInt32(offset + 8, true),
+ view.getInt32(offset + 12, true),
+ view.getFloat32(offset + 16, true),
+ color < 0 ? null : color,
+ ]);
+ }
+
+ return {
+ tick: view.getUint32(4, true),
+ keyframe: Boolean(view.getUint8(1) & CURSOR_FRAME_KEYFRAME),
+ cursors,
+ };
+};
+
+const REACTION_DURATION = 1800;
+const REACTION_COOLDOWN_DURATION = 1000;
+const REACTION_COOLDOWN_MESSAGE = "You're on reaction cooldown";
+const PLACEMENT_ANIMATION_DURATION = 280;
+const PLACEMENT_ERROR_DURATION = 650;
+const placementTimers = new WeakMap();
+const REACTIONS = [
+ reactionLove,
+ reactionJam,
+ reactionCute,
+ reactionPog,
+ reactionYay,
+ reactionBongo,
+ reactionShocked,
+ reactionWork,
+ reactionCry,
+ reactionAwe,
+];
+
+const cursorStyle = (
+ color: number | undefined,
+ secondaryColor: number | undefined,
+): CSSProperties =>
+ ({
+ "--cursor-primary": colorToCss(color),
+ "--cursor-secondary": colorToCss(secondaryColor),
+ "--cursor-outline": outlineForColor(color),
+ "--cursor-placement-duration": PLACEMENT_ANIMATION_DURATION + "ms",
+ "--cursor-reaction-duration": REACTION_DURATION + "ms",
+ }) as CSSProperties;
+
+const localCursorCss = (
+ color: number | undefined,
+ secondaryColor: number | undefined,
+ outlineColor: string,
+) => {
+ const primary = colorToCss(color);
+ const secondary = colorToCss(secondaryColor);
+ const secondarySwatch =
+ typeof secondaryColor === "number"
+ ? ` `
+ : "";
+ const svg = ` ${secondarySwatch} `;
+
+ return `url("data:image/svg+xml,${encodeURIComponent(svg)}") 1 1, auto`;
+};
+
+const plainCursorCss = (color: number | undefined, outlineColor: string) => {
+ const primary = colorToCss(color);
+ const svg = ` `;
+
+ return `url("data:image/svg+xml,${encodeURIComponent(svg)}") 1 1, auto`;
+};
+
+const pointerCursorCss = (color: number | undefined, outlineColor: string) => {
+ const primary = colorToCss(color);
+ const svg = ` `;
+
+ return `url("data:image/svg+xml,${encodeURIComponent(svg)}") 6 3, pointer`;
+};
+
+const textCursorCss = (color: number | undefined, outlineColor: string) => {
+ const primary = colorToCss(color);
+ const svg = ` `;
+
+ return `url("data:image/svg+xml,${encodeURIComponent(svg)}") 9 11, text`;
+};
+
+const disabledCursorCss = (color: number | undefined, outlineColor: string) => {
+ const primary = colorToCss(color);
+ const svg = ` `;
+
+ return `url("data:image/svg+xml,${encodeURIComponent(svg)}") 9 11, not-allowed`;
+};
+
+const UI_CURSOR_PROPERTIES = [
+ "--collaborative-cursor-default",
+ "--collaborative-cursor-pointer",
+ "--collaborative-cursor-text",
+ "--collaborative-cursor-disabled",
+] as const;
+
+const applyUiCursors = (color: number | undefined, outlineColor: string) => {
+ document.body.classList.add("collaborative-cursors-active");
+ document.body.style.setProperty(
+ UI_CURSOR_PROPERTIES[0],
+ plainCursorCss(color, outlineColor),
+ );
+ document.body.style.setProperty(
+ UI_CURSOR_PROPERTIES[1],
+ pointerCursorCss(color, outlineColor),
+ );
+ document.body.style.setProperty(
+ UI_CURSOR_PROPERTIES[2],
+ textCursorCss(color, outlineColor),
+ );
+ document.body.style.setProperty(
+ UI_CURSOR_PROPERTIES[3],
+ disabledCursorCss(color, outlineColor),
+ );
+};
+
+const clearUiCursors = () => {
+ document.body.classList.remove("collaborative-cursors-active");
+ for (const property of UI_CURSOR_PROPERTIES) {
+ document.body.style.removeProperty(property);
+ }
+};
+
+const appendReaction = (
+ root: HTMLElement | null,
+ reaction: number,
+ reduceMotion = false,
+) => {
+ const layer = root?.querySelector(
+ ".collaborative-cursor-reactions",
+ );
+ const content = REACTIONS[reaction];
+ if (!layer || !content) return;
+
+ const element = document.createElement("img");
+ element.alt = "";
+ element.className = "collaborative-cursor-reaction";
+ element.draggable = false;
+ element.src = content;
+ if (reduceMotion) element.classList.add("reduce-motion");
+ layer.appendChild(element);
+ window.setTimeout(() => element.remove(), REACTION_DURATION);
+};
+
+const animateCursorPlacement = (element: HTMLElement | null) => {
+ if (!element) return;
+
+ const previousTimer = placementTimers.get(element);
+ if (previousTimer !== undefined) window.clearTimeout(previousTimer);
+
+ element.classList.remove("is-placing");
+ void element.offsetWidth;
+ element.classList.add("is-placing");
+
+ placementTimers.set(
+ element,
+ window.setTimeout(() => {
+ element.classList.remove("is-placing");
+ placementTimers.delete(element);
+ }, PLACEMENT_ANIMATION_DURATION),
+ );
+};
+
+const CursorVisual = ({
+ cursor,
+ className,
+ elementRef,
+}: {
+ cursor: { color?: number; secondaryColor?: number };
+ className: string;
+ elementRef: (element: HTMLDivElement | null) => void;
+}) => (
+
+
+
+
+ {typeof cursor.secondaryColor === "number" && (
+
+ )}
+
+
+
+
+
+
+
+ On cooldown...
+
+
+
+
+
+
+
+);
+
+const wireIsInView = (wire: CursorWireState, view: CursorView) => {
+ const region = getCursorRegion(view.regionSize, wire[2], wire[3]);
+ return (
+ region.x >= view.minRegionX &&
+ region.x <= view.maxRegionX &&
+ region.y >= view.minRegionY &&
+ region.y <= view.maxRegionY
+ );
+};
+
+export const CursorPresenceLayer = () => {
+ const { user, cursor, settings } = useAppContext();
+ const outlineColor = outlineForColor(cursor.color);
+ const PanZoom = useContext(RendererContext);
+ const enabled = settings.get["presence.cursors"];
+ const reactionsEnabled = settings.get["presence.reactions"];
+ const localElement = useRef(null);
+ const cursorCanvas = useRef(null);
+ const reactionLayer = useRef(null);
+ const renderer = useRef(null);
+ const remoteData = useRef(new Map());
+ const latestSequences = useRef(new Map());
+ const actionSequences = useRef(new Map());
+ const selfId = useRef(undefined);
+ const activeView = useRef(undefined);
+ const colorRef = useRef(cursor.color);
+ const secondaryColorRef = useRef(cursor.secondaryColor);
+ const outlineColorRef = useRef(outlineColor);
+ const reactionsEnabledRef = useRef(reactionsEnabled);
+ const localActive = useRef(false);
+ const lastPresence = useRef(null);
+ const requestViewportRef = useRef<(force?: boolean) => void>(() => {});
+
+ colorRef.current = cursor.color;
+ secondaryColorRef.current = cursor.secondaryColor;
+ outlineColorRef.current = outlineColor;
+ reactionsEnabledRef.current = reactionsEnabled;
+
+ useEffect(() => {
+ if (!enabled || !user || !reactionsEnabled) return;
+ for (const source of REACTIONS) {
+ const image = new Image();
+ image.src = source;
+ }
+ }, [enabled, reactionsEnabled, user]);
+
+ useEffect(() => {
+ if (!enabled || !user) return;
+ if (!reactionsEnabled) {
+ document
+ .querySelectorAll(".collaborative-cursor-reaction")
+ .forEach((reaction) => reaction.remove());
+ renderer.current?.clearReactions();
+ localElement.current
+ ?.querySelector(".collaborative-cursor-reaction-cooldown")
+ ?.classList.remove("is-active");
+ }
+ requestViewportRef.current(true);
+ }, [enabled, reactionsEnabled, user]);
+
+ useEffect(() => {
+ const wrapper = document.querySelector(".board-wrapper");
+ const canvas = CanvasCore.get(false);
+ const socket = Network.socket;
+ const surface = cursorCanvas.current;
+
+ if (!enabled || !user || !wrapper || !canvas || !surface) {
+ localElement.current?.style.setProperty("display", "none");
+ wrapper?.style.removeProperty("cursor");
+ clearUiCursors();
+ renderer.current?.dispose();
+ renderer.current = null;
+ remoteData.current.clear();
+ latestSequences.current.clear();
+ actionSequences.current.clear();
+ return;
+ }
+
+ const remoteRenderer = new RemoteCursorRenderer(surface, canvas);
+ renderer.current = remoteRenderer;
+ remoteRenderer.setLocalZoom(PanZoom.transform.scale);
+ wrapper.style.cursor = localCursorCss(
+ colorRef.current,
+ secondaryColorRef.current,
+ outlineColorRef.current,
+ );
+ applyUiCursors(colorRef.current, outlineColorRef.current);
+
+ let mounted = true;
+ let visualFrame: number | undefined;
+ let viewportFrame: number | undefined;
+ let settleTimer: number | undefined;
+ let viewportRetryTimer: number | undefined;
+ let viewportRequest = 0;
+ let viewportSignature = "";
+ let lastReactionAt = -REACTION_COOLDOWN_DURATION;
+ let reactionBlockedUntil = 0;
+ let reactionBlockedCooldown: CursorReactionCooldown | undefined;
+ let reactionCooldownTimer: number | undefined;
+ let lastSurfacePoint:
+ | { active: boolean; clientX: number; clientY: number }
+ | undefined;
+ let pendingVisualPosition: { x: number; y: number } | undefined;
+
+ const sendCursor = throttle(
+ (presence: CursorMove) => {
+ socket.volatile.emit("cursorMove", presence);
+ },
+ 50,
+ { leading: true, trailing: true },
+ );
+ let lastPublishedZoom = PanZoom.transform.scale;
+ const sendZoom = throttle(
+ (zoom: number) => {
+ if (socket.connected) socket.emit("cursorZoom", zoom);
+ },
+ 200,
+ { leading: true, trailing: true },
+ );
+
+ const drawLocalCursor = () => {
+ visualFrame = undefined;
+ if (!pendingVisualPosition) return;
+ const element = localElement.current;
+ if (!element) return;
+ element.style.display = "block";
+ element.style.transform = `translate3d(${pendingVisualPosition.x}px, ${pendingVisualPosition.y}px, 0)`;
+ };
+
+ const queueLocalCursor = (x: number, y: number) => {
+ pendingVisualPosition = { x, y };
+ if (visualFrame === undefined) {
+ visualFrame = requestAnimationFrame(drawLocalCursor);
+ }
+ };
+
+ const clearSettleTimer = () => {
+ if (settleTimer === undefined) return;
+ window.clearTimeout(settleTimer);
+ settleTimer = undefined;
+ };
+
+ const scheduleSettle = () => {
+ clearSettleTimer();
+ settleTimer = window.setTimeout(() => {
+ settleTimer = undefined;
+ if (lastPresence.current) {
+ socket.emit("cursorSettle", lastPresence.current);
+ }
+ }, 120);
+ };
+
+ const publishPresence = (
+ clientX: number,
+ clientY: number,
+ x: number,
+ y: number,
+ ) => {
+ queueLocalCursor(clientX, clientY);
+ const presence: CursorMove = {
+ x,
+ y,
+ color: colorRef.current,
+ zoom: PanZoom.transform.scale,
+ };
+ localActive.current = true;
+ lastPresence.current = presence;
+ sendCursor(presence);
+ scheduleSettle();
+ };
+
+ const clearLocalPresence = () => {
+ if (!localActive.current) return;
+ localActive.current = false;
+ lastPresence.current = null;
+ sendCursor.cancel();
+ clearSettleTimer();
+ socket.emit("cursorHide");
+ };
+
+ const hideLocalCursor = () => {
+ pendingVisualPosition = undefined;
+ if (visualFrame !== undefined) {
+ cancelAnimationFrame(visualFrame);
+ visualFrame = undefined;
+ }
+ localElement.current?.style.setProperty("display", "none");
+ clearLocalPresence();
+ };
+
+ const handleSurfacePointer = ({
+ active,
+ clientX,
+ clientY,
+ x,
+ y,
+ }: {
+ active: boolean;
+ clientX: number;
+ clientY: number;
+ x?: number;
+ y?: number;
+ }) => {
+ lastSurfacePoint = { active, clientX, clientY };
+ if (!active || x === undefined || y === undefined) {
+ const hasActiveReaction = Boolean(
+ localElement.current?.querySelector(".collaborative-cursor-reaction"),
+ );
+ if (hasActiveReaction) {
+ queueLocalCursor(clientX, clientY);
+ clearLocalPresence();
+ } else {
+ hideLocalCursor();
+ }
+ return;
+ }
+ publishPresence(clientX, clientY, x, y);
+ };
+
+ const applyFrame = (frame: ArrayBuffer) => {
+ const decoded = decodeCursorFrame(frame);
+ if (!decoded) return;
+ const receivedIds = new Set();
+
+ for (const wire of decoded.cursors) {
+ if (wire[0] === selfId.current) continue;
+ if (activeView.current && !wireIsInView(wire, activeView.current)) {
+ continue;
+ }
+ const latest = latestSequences.current.get(wire[0]) ?? -1;
+ if (wire[1] < latest) continue;
+ latestSequences.current.set(wire[0], wire[1]);
+ remoteData.current.set(wire[0], wire);
+ receivedIds.add(wire[0]);
+ remoteRenderer.apply(wire);
+ }
+
+ if (decoded.keyframe) {
+ for (const [id, wire] of remoteData.current) {
+ if (
+ id !== selfId.current &&
+ activeView.current &&
+ wireIsInView(wire, activeView.current) &&
+ !receivedIds.has(id)
+ ) {
+ remoteData.current.delete(id);
+ remoteRenderer.remove(id);
+ }
+ }
+ }
+ };
+
+ const handleRemove = (removals: Array<[number, number]>) => {
+ for (const [id, sequence] of removals) {
+ const latest = latestSequences.current.get(id) ?? -1;
+ if (sequence < latest) continue;
+ latestSequences.current.set(id, sequence);
+ remoteData.current.delete(id);
+ remoteRenderer.remove(id);
+ }
+ };
+
+ const acceptAction = (id: number, sequence: number) => {
+ if (id === selfId.current) return false;
+ const latest = actionSequences.current.get(id) ?? 0;
+ if (sequence <= latest) return false;
+ actionSequences.current.set(id, sequence);
+ return true;
+ };
+
+ const handleReaction = ({
+ id,
+ reaction,
+ sequence,
+ }: {
+ id: number;
+ reaction: number;
+ sequence: number;
+ }) => {
+ if (
+ !reactionsEnabledRef.current ||
+ !remoteData.current.has(id) ||
+ !acceptAction(id, sequence)
+ ) {
+ return;
+ }
+ const root = reactionLayer.current;
+ if (!root) return;
+ const anchor = document.createElement("div");
+ anchor.className = "collaborative-cursor-reaction-anchor";
+ anchor.style.setProperty(
+ "--cursor-reaction-duration",
+ REACTION_DURATION + "ms",
+ );
+ const reactions = document.createElement("span");
+ reactions.className = "collaborative-cursor-reactions";
+ anchor.appendChild(reactions);
+ root.appendChild(anchor);
+ const reduceMotion = window.matchMedia(
+ "(prefers-reduced-motion: reduce)",
+ ).matches;
+ appendReaction(anchor, reaction, reduceMotion);
+ remoteRenderer.attachReaction(id, anchor, REACTION_DURATION);
+ };
+
+ const handlePlace = ({
+ id,
+ sequence,
+ }: {
+ id: number;
+ sequence: number;
+ }) => {
+ if (!acceptAction(id, sequence)) return;
+ remoteRenderer.place(id);
+ };
+
+ const calculateViewport = () => {
+ const viewportWrapper = PanZoom.$wrapper;
+ if (!mounted || !canvas.isSurfaceReady() || !viewportWrapper) return;
+ const rect = viewportWrapper.getBoundingClientRect();
+ const [left, top] = canvas.screenToPos(rect.left - 32, rect.top - 32);
+ const [right, bottom] = canvas.screenToPos(
+ rect.right + 32,
+ rect.bottom + 32,
+ );
+ return {
+ left,
+ top,
+ right,
+ bottom,
+ zoom: PanZoom.transform.scale,
+ reactions: reactionsEnabledRef.current,
+ };
+ };
+
+ const requestViewport = (force = false) => {
+ if (!mounted || !socket.connected || !canvas.isSurfaceReady()) return;
+ const viewport = calculateViewport();
+ if (!viewport) return;
+ const view = getCursorView(viewport);
+ if (!view) return;
+ const signature = [
+ view.regionSize,
+ view.minRegionX,
+ view.maxRegionX,
+ view.minRegionY,
+ view.maxRegionY,
+ viewport.reactions ? 1 : 0,
+ ].join(":");
+ if (!force && signature === viewportSignature) return;
+
+ viewportSignature = signature;
+ activeView.current = view;
+ const request = ++viewportRequest;
+ if (viewportRetryTimer !== undefined) {
+ window.clearTimeout(viewportRetryTimer);
+ }
+ viewportRetryTimer = window.setTimeout(() => {
+ if (!mounted || request !== viewportRequest) return;
+ viewportSignature = "";
+ requestViewport(true);
+ }, 1500);
+
+ socket.emit("cursorViewport", viewport, (snapshot) => {
+ if (!mounted || request !== viewportRequest) return;
+ if (viewportRetryTimer !== undefined) {
+ window.clearTimeout(viewportRetryTimer);
+ viewportRetryTimer = undefined;
+ }
+ if (!snapshot) {
+ viewportSignature = "";
+ viewportRetryTimer = window.setTimeout(() => {
+ if (mounted) requestViewport(true);
+ }, 250);
+ return;
+ }
+
+ selfId.current = snapshot.selfId;
+ activeView.current = snapshot.view;
+ remoteData.current.delete(snapshot.selfId);
+ remoteRenderer.remove(snapshot.selfId);
+ applyFrame(snapshot.frame);
+
+ for (const [id, wire] of remoteData.current) {
+ if (!wireIsInView(wire, snapshot.view)) {
+ remoteData.current.delete(id);
+ remoteRenderer.remove(id);
+ }
+ }
+ });
+ };
+ requestViewportRef.current = requestViewport;
+
+ const scheduleViewport = () => {
+ if (viewportFrame !== undefined) return;
+ viewportFrame = requestAnimationFrame(() => {
+ viewportFrame = undefined;
+ if (!mounted || !canvas.isSurfaceReady()) return;
+ requestViewport();
+ });
+ };
+
+ const handleViewportMove = ({ scale }: { scale: number }) => {
+ if (!mounted || !canvas.isSurfaceReady()) return;
+ remoteRenderer.setLocalZoom(scale);
+ if (scale !== lastPublishedZoom) {
+ lastPublishedZoom = scale;
+ sendZoom(scale);
+ }
+ if (localActive.current && lastSurfacePoint?.active) {
+ const [x, y] = canvas.screenToPos(
+ lastSurfacePoint.clientX,
+ lastSurfacePoint.clientY,
+ );
+ publishPresence(
+ lastSurfacePoint.clientX,
+ lastSurfacePoint.clientY,
+ x,
+ y,
+ );
+ }
+ scheduleViewport();
+ };
+
+ const handleInitialize = () => {
+ handleViewportMove({ scale: PanZoom.transform.scale });
+ requestViewport(true);
+ };
+
+ const handleConnect = () => {
+ viewportSignature = "";
+ requestViewport(true);
+ };
+
+ const handleDisconnect = () => {
+ selfId.current = undefined;
+ activeView.current = undefined;
+ remoteData.current.clear();
+ latestSequences.current.clear();
+ actionSequences.current.clear();
+ remoteRenderer.clear();
+ };
+
+ const isTypingTarget = (target: EventTarget | null) =>
+ target instanceof HTMLElement &&
+ Boolean(
+ target.closest(
+ 'input, textarea, select, [contenteditable="true"], [role="textbox"]',
+ ),
+ );
+
+ const showReactionCooldown = (cooldown: CursorReactionCooldown) => {
+ if (cooldown.until <= Date.now()) return;
+ reactionBlockedUntil = Math.max(reactionBlockedUntil, cooldown.until);
+ if (cooldown.level === "second") return;
+
+ reactionBlockedCooldown = cooldown;
+ const bar = localElement.current?.querySelector(
+ ".collaborative-cursor-reaction-cooldown",
+ );
+ if (!bar) return;
+ if (reactionCooldownTimer !== undefined) {
+ window.clearTimeout(reactionCooldownTimer);
+ }
+ bar.classList.remove("is-active");
+ void bar.offsetWidth;
+ bar.classList.add("is-active");
+ reactionCooldownTimer = window.setTimeout(() => {
+ bar.classList.remove("is-active");
+ reactionCooldownTimer = undefined;
+ }, REACTION_DURATION + 50);
+ };
+
+ const handleKeyDown = (event: KeyboardEvent) => {
+ if (
+ !reactionsEnabledRef.current ||
+ !localActive.current ||
+ !socket.connected ||
+ event.repeat ||
+ event.altKey ||
+ event.ctrlKey ||
+ event.metaKey ||
+ isTypingTarget(event.target) ||
+ !/^[0-9]$/.test(event.key)
+ ) {
+ return;
+ }
+
+ const now = performance.now();
+ if (Date.now() < reactionBlockedUntil) {
+ if (reactionBlockedCooldown) {
+ showReactionCooldown(reactionBlockedCooldown);
+ }
+ toast.error(REACTION_COOLDOWN_MESSAGE);
+ event.preventDefault();
+ return;
+ }
+ reactionBlockedCooldown = undefined;
+ if (now - lastReactionAt < REACTION_COOLDOWN_DURATION) {
+ toast.error(REACTION_COOLDOWN_MESSAGE);
+ event.preventDefault();
+ return;
+ }
+
+ lastReactionAt = now;
+ const reaction = event.key === "0" ? 9 : Number(event.key) - 1;
+ socket.emit("cursorReaction", reaction, (result) => {
+ if (!mounted) return;
+ if (!result.success) {
+ if (result.cooldown) {
+ showReactionCooldown(result.cooldown);
+ toast.error(REACTION_COOLDOWN_MESSAGE);
+ }
+ return;
+ }
+
+ const reduceMotion = window.matchMedia(
+ "(prefers-reduced-motion: reduce)",
+ ).matches;
+ appendReaction(localElement.current, reaction, reduceMotion);
+ });
+ event.preventDefault();
+ };
+ const handleResize = () => {
+ remoteRenderer.resize();
+ scheduleViewport();
+ };
+
+ socket.on("cursorFrame", applyFrame);
+ socket.on("cursorRemove", handleRemove);
+ socket.on("cursorReaction", handleReaction);
+ socket.on("cursorPlace", handlePlace);
+ socket.on("connect", handleConnect);
+ socket.on("disconnect", handleDisconnect);
+ canvas.on("surfacePointer", handleSurfacePointer);
+ PanZoom.on("viewportMove", handleViewportMove);
+ PanZoom.on("initialize", handleInitialize);
+ document.addEventListener("keydown", handleKeyDown);
+ window.addEventListener("blur", hideLocalCursor);
+ window.addEventListener("resize", handleResize);
+
+ const resizeObserver = new ResizeObserver(handleResize);
+ resizeObserver.observe(PanZoom.$wrapper || wrapper);
+ handleViewportMove({ scale: PanZoom.transform.scale });
+ if (socket.connected) requestViewport(true);
+
+ return () => {
+ mounted = false;
+ requestViewportRef.current = () => {};
+ sendCursor.cancel();
+ sendZoom.cancel();
+ clearSettleTimer();
+ if (reactionCooldownTimer !== undefined) {
+ window.clearTimeout(reactionCooldownTimer);
+ }
+ localElement.current
+ ?.querySelector(".collaborative-cursor-reaction-cooldown")
+ ?.classList.remove("is-active");
+ if (visualFrame !== undefined) cancelAnimationFrame(visualFrame);
+ if (viewportFrame !== undefined) cancelAnimationFrame(viewportFrame);
+ if (viewportRetryTimer !== undefined) {
+ window.clearTimeout(viewportRetryTimer);
+ }
+ resizeObserver.disconnect();
+ socket.emit("cursorUnsubscribe");
+ socket.off("cursorFrame", applyFrame);
+ socket.off("cursorRemove", handleRemove);
+ socket.off("cursorReaction", handleReaction);
+ socket.off("cursorPlace", handlePlace);
+ socket.off("connect", handleConnect);
+ socket.off("disconnect", handleDisconnect);
+ canvas.off("surfacePointer", handleSurfacePointer);
+ PanZoom.off("viewportMove", handleViewportMove);
+ PanZoom.off("initialize", handleInitialize);
+ document.removeEventListener("keydown", handleKeyDown);
+ window.removeEventListener("blur", hideLocalCursor);
+ window.removeEventListener("resize", handleResize);
+ localActive.current = false;
+ lastPresence.current = null;
+ localElement.current?.style.setProperty("display", "none");
+ wrapper.style.removeProperty("cursor");
+ clearUiCursors();
+ selfId.current = undefined;
+ activeView.current = undefined;
+ remoteData.current.clear();
+ latestSequences.current.clear();
+ actionSequences.current.clear();
+ remoteRenderer.dispose();
+ if (renderer.current === remoteRenderer) renderer.current = null;
+ };
+ }, [enabled, PanZoom, user]);
+
+ useEffect(() => {
+ if (!enabled || !user) return;
+
+ let restoreTimer: number | undefined;
+ const restoreCursor = () => {
+ localElement.current?.classList.remove("is-error");
+ const wrapper = document.querySelector(".board-wrapper");
+ if (!wrapper) return;
+ wrapper.style.cursor = localCursorCss(
+ colorRef.current,
+ secondaryColorRef.current,
+ outlineColorRef.current,
+ );
+ };
+ const scheduleRestore = (duration: number) => {
+ if (restoreTimer !== undefined) window.clearTimeout(restoreTimer);
+ restoreTimer = window.setTimeout(restoreCursor, duration);
+ };
+ const handlePlaceSuccessful = () => {
+ if (
+ !localActive.current ||
+ window.matchMedia("(prefers-reduced-motion: reduce)").matches
+ ) {
+ return;
+ }
+
+ const wrapper = document.querySelector(".board-wrapper");
+ if (!wrapper) return;
+ localElement.current?.classList.remove("is-error");
+ wrapper.style.cursor = "none";
+ animateCursorPlacement(localElement.current);
+ scheduleRestore(PLACEMENT_ANIMATION_DURATION);
+ };
+ const handlePlaceFailed = () => {
+ if (!localActive.current) return;
+ const wrapper = document.querySelector(".board-wrapper");
+ const element = localElement.current;
+ if (!wrapper || !element) return;
+ element.classList.remove("is-placing");
+ element.classList.add("is-error");
+ wrapper.style.cursor = "none";
+ scheduleRestore(PLACEMENT_ERROR_DURATION);
+ };
+
+ Network.on("placeSuccessful", handlePlaceSuccessful);
+ Network.on("placeFailed", handlePlaceFailed);
+ return () => {
+ if (restoreTimer !== undefined) window.clearTimeout(restoreTimer);
+ localElement.current?.classList.remove("is-placing", "is-error");
+ Network.off("placeSuccessful", handlePlaceSuccessful);
+ Network.off("placeFailed", handlePlaceFailed);
+ };
+ }, [enabled, user]);
+
+ useLayoutEffect(() => {
+ const wrapper = document.querySelector(".board-wrapper");
+ if (enabled && user && wrapper) {
+ wrapper.style.cursor = localCursorCss(
+ cursor.color,
+ cursor.secondaryColor,
+ outlineColor,
+ );
+ applyUiCursors(cursor.color, outlineColor);
+ } else {
+ wrapper?.style.removeProperty("cursor");
+ clearUiCursors();
+ }
+
+ if (enabled && user && localActive.current && lastPresence.current) {
+ const presence = {
+ ...lastPresence.current,
+ color: cursor.color,
+ };
+ lastPresence.current = presence;
+ Network.socket.emit("cursorSettle", presence);
+ }
+ }, [cursor.color, cursor.secondaryColor, enabled, outlineColor, user]);
+
+ return createPortal(
+ <>
+
+
+ {
+ localElement.current = element;
+ }}
+ />
+ >,
+ document.body,
+ );
+};
diff --git a/packages/client/src/components/Debug/DebugModal.tsx b/packages/client/src/components/Debug/DebugModal.tsx
index fccb4e1cb63d670848ba49be7f170974081b9d55..48e7572e978d34b65001fb2faf930231b088cc6d 100644
--- a/packages/client/src/components/Debug/DebugModal.tsx
+++ b/packages/client/src/components/Debug/DebugModal.tsx
@@ -1,5 +1,3 @@
-import { useEffect } from "react";
-import { Debug, FlagCategory } from "@sc07-canvas/lib/debug";
import {
Modal,
ModalBody,
@@ -8,6 +6,9 @@ import {
ModalHeader,
useDisclosure,
} from "@heroui/react";
+import { Debug, FlagCategory } from "@sc07-canvas/lib/debug";
+import { useEffect } from "react";
+
import { Button } from "../core/Button";
import { Switch } from "../core/Switch";
diff --git a/packages/client/src/components/DynamicModals.tsx b/packages/client/src/components/DynamicModals.tsx
index c43f6c0acb2cae5fa22d1be96c9c6875147f83d4..606eccf4cec52e60c1acfee55625d284cc91628f 100644
--- a/packages/client/src/components/DynamicModals.tsx
+++ b/packages/client/src/components/DynamicModals.tsx
@@ -1,5 +1,3 @@
-import { useCallback, useEffect, useState } from "react";
-import { DynamicModal, type IDynamicModal } from "../lib/alerts";
import {
Modal,
ModalBody,
@@ -7,6 +5,9 @@ import {
ModalFooter,
ModalHeader,
} from "@heroui/react";
+import { useCallback, useEffect, useState } from "react";
+
+import { DynamicModal, type IDynamicModal } from "../lib/alerts";
import { Button } from "./core/Button";
interface IModal {
diff --git a/packages/client/src/components/EventInfo/EventDatesCard.tsx b/packages/client/src/components/EventInfo/EventDatesCard.tsx
index 6af43e6c4fcb832f2302ba942b6d2af078a7eea8..c87281d585eda8c09ebf41635a75648cd2f61c59 100644
--- a/packages/client/src/components/EventInfo/EventDatesCard.tsx
+++ b/packages/client/src/components/EventInfo/EventDatesCard.tsx
@@ -1,8 +1,9 @@
import { Card, CardBody } from "@heroui/react";
import { formatDate } from "date-fns";
+
import { useCountdown } from "../../hooks/useCountdown";
-import { useEventDates } from "./useEventDates";
import { EVENT_NAME } from ".";
+import { useEventDates } from "./useEventDates";
export const EventDatesCard = () => {
const { state, start, end } = useEventDates();
diff --git a/packages/client/src/components/EventInfo/EventDatesHeader.tsx b/packages/client/src/components/EventInfo/EventDatesHeader.tsx
index 0ccf0b9f479d89aa266ee6f08f045336f20d06ce..34a6438690dc44625373f6c78bdad3431e79cc98 100644
--- a/packages/client/src/components/EventInfo/EventDatesHeader.tsx
+++ b/packages/client/src/components/EventInfo/EventDatesHeader.tsx
@@ -1,10 +1,11 @@
import { Card, CardBody } from "@heroui/react";
import { formatDate } from "date-fns";
import { type ReactNode } from "react";
+
import logo from "../../assets/logo.webp";
import { useCountdown } from "../../hooks/useCountdown";
-import { useEventDates } from "./useEventDates";
import { EVENT_NAME } from ".";
+import { useEventDates } from "./useEventDates";
export const EventDatesHeader = () => {
const { state, start, end } = useEventDates();
diff --git a/packages/client/src/components/EventInfo/EventInfo.tsx b/packages/client/src/components/EventInfo/EventInfo.tsx
index 84b283ca60a6cea07c627ffdcebc8e41c3ba071c..53e32dcd777060831257647ccf1364e1de8fd773 100644
--- a/packages/client/src/components/EventInfo/EventInfo.tsx
+++ b/packages/client/src/components/EventInfo/EventInfo.tsx
@@ -1,4 +1,3 @@
-import { useDialog } from "@/contexts/DialogContext";
import {
Alert,
Badge,
@@ -10,17 +9,20 @@ import {
ModalHeader,
useDisclosure,
} from "@heroui/react";
-import { LemmyButton } from "../Info/buttons/Lemmy";
-import { MastodonButton } from "../Info/buttons/Mastodon";
-import { useCountdown } from "@/hooks/useCountdown";
-import { useEventDates } from "./useEventDates";
-import { useAppContext } from "@/contexts/AppContext";
import type { ClientConfig } from "@sc07-canvas/lib/net";
-import { WithLogin } from "../Login/LoginButton";
import { useCallback, useEffect, useReducer, useState } from "react";
+
+import { useAppContext } from "@/contexts/AppContext";
+import { useDialog } from "@/contexts/DialogContext";
+import { useCountdown } from "@/hooks/useCountdown";
import network from "@/lib/network";
+
import OpenChatButton from "../Chat/OpenChatButton";
import { Button } from "../core/Button";
+import { LemmyButton } from "../Info/buttons/Lemmy";
+import { MastodonButton } from "../Info/buttons/Mastodon";
+import { WithLogin } from "../Login/LoginButton";
+import { useEventDates } from "./useEventDates";
export const EventInfoModal = () => {
const Dialogs = useDialog();
diff --git a/packages/client/src/components/EventInfoOverlay.tsx b/packages/client/src/components/EventInfoOverlay.tsx
index d082fc8bc9c32f0b979ccee9c1c866802eef3040..a85ffd8ab59964865cb8d716a0f9c17836261089 100644
--- a/packages/client/src/components/EventInfoOverlay.tsx
+++ b/packages/client/src/components/EventInfoOverlay.tsx
@@ -1,7 +1,9 @@
import { Link } from "@heroui/react";
-import { Button } from "./core/Button";
+
import { usePanel } from "@/contexts/PanelContext";
+import { Button } from "./core/Button";
+
/**
* *oh god the terrible code*
*
diff --git a/packages/client/src/components/Header/AccountStanding.tsx b/packages/client/src/components/Header/AccountStanding.tsx
index f009c4baebbe686fc20eb635d5d63560c2ee432c..48948c27e3253af479aa7c4db55c9f2551f9f348 100644
--- a/packages/client/src/components/Header/AccountStanding.tsx
+++ b/packages/client/src/components/Header/AccountStanding.tsx
@@ -1,6 +1,3 @@
-import { type IAccountStanding } from "@sc07-canvas/lib/net";
-import { useCallback, useEffect, useState } from "react";
-import network from "../../lib/network";
import {
Modal,
ModalBody,
@@ -8,6 +5,10 @@ import {
ModalFooter,
ModalHeader,
} from "@heroui/react";
+import { type IAccountStanding } from "@sc07-canvas/lib/net";
+import { useCallback, useEffect, useState } from "react";
+
+import network from "../../lib/network";
import { Button } from "../core/Button";
export const AccountStanding = () => {
diff --git a/packages/client/src/components/Header/Header.tsx b/packages/client/src/components/Header/Header.tsx
index 6584562a100aebb31bb3efa694992b08c10f6a3c..653abf1811a65fff5f029fd1ce09bbc26fbdfdef 100644
--- a/packages/client/src/components/Header/Header.tsx
+++ b/packages/client/src/components/Header/Header.tsx
@@ -1,9 +1,10 @@
import { Card, CardBody } from "@heroui/react";
+import type React from "react";
+
import { useAppContext } from "../../contexts/AppContext";
import { EventInfoOverlay } from "../EventInfoOverlay";
import { HeaderLeft } from "./HeaderLeft";
import { HeaderRight } from "./HeaderRight";
-import type React from "react";
export const Header = ({ ...props }: React.HTMLProps) => {
const { connected } = useAppContext();
diff --git a/packages/client/src/components/Header/HeaderLeft.tsx b/packages/client/src/components/Header/HeaderLeft.tsx
index d3c8f057afec8ca3d5554e93830935210bb5f0ad..1c1ac6dfc1a13a904299189d53123edea65b8c5b 100644
--- a/packages/client/src/components/Header/HeaderLeft.tsx
+++ b/packages/client/src/components/Header/HeaderLeft.tsx
@@ -1,14 +1,16 @@
-import { AccountStanding } from "./AccountStanding";
import { Debug } from "@sc07-canvas/lib/debug";
-import { Info, Wrench } from "lucide-react";
-import { EventDatesHeader } from "../EventInfo";
-import { Button } from "../core/Button";
-import { usePanel } from "@/contexts/PanelContext";
import { motion, useReducedMotion } from "framer-motion";
+import { Info, Wrench } from "lucide-react";
+
import { useAppContext } from "@/contexts/AppContext";
+import { usePanel } from "@/contexts/PanelContext";
+
+import { useIsMobile } from "../../hooks/useIsMobile";
+import { Button } from "../core/Button";
+import { EventDatesHeader } from "../EventInfo";
import { SIDEBAR_SPRING } from "../SidebarBase";
+import { AccountStanding } from "./AccountStanding";
import { useSidebarWidth } from "./useSidebarWidth";
-import { useIsMobile } from "../../hooks/useIsMobile";
export const HeaderLeft = () => {
const Panel = usePanel();
diff --git a/packages/client/src/components/Header/HeaderRight.tsx b/packages/client/src/components/Header/HeaderRight.tsx
index d57fd6d28cf07d85219f58093614f7ac13de9073..35ee483ef6ada57df8a41b79c28ee7bb262dcb2f 100644
--- a/packages/client/src/components/Header/HeaderRight.tsx
+++ b/packages/client/src/components/Header/HeaderRight.tsx
@@ -1,5 +1,5 @@
import { Link } from "@heroui/react";
-import { useEffect, useRef, useState } from "react";
+import { Debug } from "@sc07-canvas/lib/debug";
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
import {
ChevronDown,
@@ -15,22 +15,22 @@ import {
Share2,
Wrench,
} from "lucide-react";
+import { useEffect, useRef, useState } from "react";
-import { Debug } from "@sc07-canvas/lib/debug";
-
+import { useAppContext } from "@/contexts/AppContext";
+import { useCaptureContext } from "@/contexts/CaptureContext";
import { useDialog } from "@/contexts/DialogContext";
import { usePanel } from "@/contexts/PanelContext";
-import { useCaptureContext } from "@/contexts/CaptureContext";
+
import { useHasRole } from "../../hooks/useHasRole";
+import { useIsMobile } from "../../hooks/useIsMobile";
import { useModerator } from "../../Moderator/Moderator";
import OpenChatButton from "../Chat/OpenChatButton";
import { Button } from "../core/Button";
+import { SIDEBAR_SPRING } from "../SidebarBase";
import { ThemeSwitcher } from "./ThemeSwitcher";
import { User } from "./User";
-import { useAppContext } from "@/contexts/AppContext";
-import { SIDEBAR_SPRING } from "../SidebarBase";
import { useSidebarWidth } from "./useSidebarWidth";
-import { useIsMobile } from "../../hooks/useIsMobile";
export const HeaderRight = () => {
const reduceMotion = useReducedMotion() ?? false;
diff --git a/packages/client/src/components/Header/ThemeSwitcher.tsx b/packages/client/src/components/Header/ThemeSwitcher.tsx
index c48138b96050721e7349dafb45858ec7b0b19fea..e9a97cf249d695224a08c3bb2697ba9941e4e8dc 100644
--- a/packages/client/src/components/Header/ThemeSwitcher.tsx
+++ b/packages/client/src/components/Header/ThemeSwitcher.tsx
@@ -1,15 +1,16 @@
+import { type ButtonProps } from "@heroui/react";
+import { Moon, Sun } from "lucide-react";
import { useTheme } from "next-themes";
import { useEffect, useState } from "react";
+
import { Button } from "../core/Button";
-import { Moon, Sun } from "lucide-react";
-import { type ButtonProps } from "@heroui/react";
-export function ThemeSwitcher({
+export const ThemeSwitcher = ({
compact,
className,
onPress,
...props
-}: ButtonProps & { compact?: boolean }) {
+}: ButtonProps & { compact?: boolean }) => {
const [mounted, setMounted] = useState(false);
const { theme, setTheme } = useTheme();
const [isToggled, setToggle] = useState(false);
diff --git a/packages/client/src/components/Header/User.tsx b/packages/client/src/components/Header/User.tsx
index 0ab97c234a0fe7134c4e42ca3ad60a03700584f0..79d0f380300bc11eee037b1415e4a49c2ddccaba 100644
--- a/packages/client/src/components/Header/User.tsx
+++ b/packages/client/src/components/Header/User.tsx
@@ -1,4 +1,5 @@
import { Card, User as UserElement } from "@heroui/react";
+
import { useAppContext } from "../../contexts/AppContext";
export const User = () => {
diff --git a/packages/client/src/components/Header/useSidebarWidth.ts b/packages/client/src/components/Header/useSidebarWidth.ts
index 5c3f092c8eb95659003d583f73f24806739cca91..b608d48a62834cc6122f32c082bae8b1e7924fc8 100644
--- a/packages/client/src/components/Header/useSidebarWidth.ts
+++ b/packages/client/src/components/Header/useSidebarWidth.ts
@@ -4,19 +4,41 @@ export const useSidebarWidth = (side: "Left" | "Right") => {
const [width, setWidth] = useState(0);
useEffect(() => {
- const sidebar = document.querySelector(
- `[data-sidebar-side="${side}"]`,
- );
- if (!sidebar) return;
+ const observed = new WeakSet();
+ const selector = `[data-sidebar-side="${side}"]`;
- const updateWidth = () => setWidth(sidebar.getBoundingClientRect().width);
- updateWidth();
+ const updateWidth = () => {
+ const sidebars = Array.from(
+ document.querySelectorAll(selector),
+ );
+ const active = sidebars.find(
+ (sidebar) => sidebar.getAttribute("aria-hidden") !== "true",
+ );
+ setWidth(active?.getBoundingClientRect().width ?? 0);
+ };
+
+ const resizeObserver = new ResizeObserver(updateWidth);
+ const observeSidebars = () => {
+ document.querySelectorAll(selector).forEach((sidebar) => {
+ if (observed.has(sidebar)) return;
+ observed.add(sidebar);
+ resizeObserver.observe(sidebar);
+ });
+ updateWidth();
+ };
- const observer = new ResizeObserver(updateWidth);
- observer.observe(sidebar);
+ const mutationObserver = new MutationObserver(observeSidebars);
+ mutationObserver.observe(document.body, {
+ attributes: true,
+ attributeFilter: ["aria-hidden"],
+ childList: true,
+ subtree: true,
+ });
+ observeSidebars();
return () => {
- observer.disconnect();
+ mutationObserver.disconnect();
+ resizeObserver.disconnect();
};
}, [side]);
diff --git a/packages/client/src/components/Info/InfoButtons.tsx b/packages/client/src/components/Info/InfoButtons.tsx
index 5654fe73cb3e26cc309e16995f8fea6f074f23da..258fd37e54ee3d77b3d64c0db41df7200db82e6f 100644
--- a/packages/client/src/components/Info/InfoButtons.tsx
+++ b/packages/client/src/components/Info/InfoButtons.tsx
@@ -1,7 +1,7 @@
-import { MatrixButton } from "./buttons/Matrix";
import { DiscordButton } from "./buttons/Discord";
import { LemmyButton } from "./buttons/Lemmy";
import { MastodonButton } from "./buttons/Mastodon";
+import { MatrixButton } from "./buttons/Matrix";
import { TipButton } from "./buttons/Tip";
export const InfoButtons = () => {
diff --git a/packages/client/src/components/Info/InfoCommunity.tsx b/packages/client/src/components/Info/InfoCommunity.tsx
index e930c0cb0bad76c45da85788179dba09cf9f1fef..44c58a1617e77b9b8da3465d256a3dee42be810b 100644
--- a/packages/client/src/components/Info/InfoCommunity.tsx
+++ b/packages/client/src/components/Info/InfoCommunity.tsx
@@ -1,8 +1,9 @@
-import { Globe2, Users } from "lucide-react";
import { Link } from "@heroui/react";
-import { InfoSection } from "./components/InfoSection";
+import { Globe2, Users } from "lucide-react";
import { useState } from "react";
+import { InfoSection } from "./components/InfoSection";
+
const SiteFavicon = ({ sources }: { sources: string[] }) => {
const [sourceIndex, setSourceIndex] = useState(0);
const source = sources[sourceIndex];
diff --git a/packages/client/src/components/Info/InfoFAQ.tsx b/packages/client/src/components/Info/InfoFAQ.tsx
index 793b25356a16e759da2e50900b21e342bda6cf35..5b53efef61b9d72a36635a7d6b06fb2695686a89 100644
--- a/packages/client/src/components/Info/InfoFAQ.tsx
+++ b/packages/client/src/components/Info/InfoFAQ.tsx
@@ -1,4 +1,5 @@
import { CircleQuestionMark } from "lucide-react";
+
import { InfoSection } from "./components/InfoSection";
export const InfoFAQ = () => {
diff --git a/packages/client/src/components/Info/InfoPrivacy.tsx b/packages/client/src/components/Info/InfoPrivacy.tsx
index 055651849791b6faaaeb70a3a63ea2bcd065b4d6..d7b23b3b75a404a101641db655c5f2830825ed5b 100644
--- a/packages/client/src/components/Info/InfoPrivacy.tsx
+++ b/packages/client/src/components/Info/InfoPrivacy.tsx
@@ -1,5 +1,6 @@
-import { Shield } from "lucide-react";
import { Link } from "@heroui/react";
+import { Shield } from "lucide-react";
+
import { InfoSection } from "./components/InfoSection";
export const InfoPrivacy = () => {
diff --git a/packages/client/src/components/Info/InfoRules.tsx b/packages/client/src/components/Info/InfoRules.tsx
index a4e76d944699617d3433ebb7cc45313d7ded8423..faa5cbf989a5796e8127b3afdc389904a6c079d2 100644
--- a/packages/client/src/components/Info/InfoRules.tsx
+++ b/packages/client/src/components/Info/InfoRules.tsx
@@ -1,7 +1,8 @@
import { Gavel } from "lucide-react";
-import { type IRule } from "./InfoText";
import useSWR from "swr";
+
import { InfoSection } from "./components/InfoSection";
+import { type IRule } from "./InfoText";
export const InfoRules = () => {
const {
diff --git a/packages/client/src/components/Info/InfoSidebar.tsx b/packages/client/src/components/Info/InfoSidebar.tsx
index a2aa63beb8d35f5e78c7d1f28d50241e7efc7842..2ba1871f6494749d22ad68e0c762177b87f532d9 100644
--- a/packages/client/src/components/Info/InfoSidebar.tsx
+++ b/packages/client/src/components/Info/InfoSidebar.tsx
@@ -1,9 +1,11 @@
-import { InfoText } from "./InfoText";
-import { InfoButtons } from "./InfoButtons";
-import { SidebarBase } from "../SidebarBase";
import { Info } from "lucide-react";
+
import { usePanel } from "@/contexts/PanelContext";
+import { SidebarBase } from "../SidebarBase";
+import { InfoButtons } from "./InfoButtons";
+import { InfoText } from "./InfoText";
+
/**
* Information sidebar
*
diff --git a/packages/client/src/components/Info/buttons/Discord.tsx b/packages/client/src/components/Info/buttons/Discord.tsx
index 23ef4ca81f5c398412db9650a7d0839771db2a16..bcff22f5d08cda2e15d2f3f5cf43a990ab6fbdd5 100644
--- a/packages/client/src/components/Info/buttons/Discord.tsx
+++ b/packages/client/src/components/Info/buttons/Discord.tsx
@@ -1,6 +1,7 @@
import { Link } from "@heroui/react";
-import { DISCORD_INVITE } from "../../../lib/constants";
import { SiDiscord } from "@icons-pack/react-simple-icons";
+
+import { DISCORD_INVITE } from "../../../lib/constants";
import { Button } from "../../core/Button";
export const DiscordButton = () => {
diff --git a/packages/client/src/components/Info/buttons/Lemmy.tsx b/packages/client/src/components/Info/buttons/Lemmy.tsx
index a88207d66857a1572f019240ac818b117c09cb72..2c1154aa2d91c9bdb6fc676704a7808f7956fe60 100644
--- a/packages/client/src/components/Info/buttons/Lemmy.tsx
+++ b/packages/client/src/components/Info/buttons/Lemmy.tsx
@@ -1,5 +1,6 @@
-import { SiLemmy } from "@icons-pack/react-simple-icons";
import { Link } from "@heroui/react";
+import { SiLemmy } from "@icons-pack/react-simple-icons";
+
import { Button } from "../../core/Button";
export const LemmyButton = () => {
diff --git a/packages/client/src/components/Info/buttons/Mastodon.tsx b/packages/client/src/components/Info/buttons/Mastodon.tsx
index 257b3cf3ae95b5ea97e4f8311afb413a0f9ad16e..218d32213267a00c696d5d94e5c6bbd1898fe99f 100644
--- a/packages/client/src/components/Info/buttons/Mastodon.tsx
+++ b/packages/client/src/components/Info/buttons/Mastodon.tsx
@@ -1,5 +1,6 @@
-import { SiMastodon } from "@icons-pack/react-simple-icons";
import { Link } from "@heroui/react";
+import { SiMastodon } from "@icons-pack/react-simple-icons";
+
import { Button } from "../../core/Button";
export const MastodonButton = () => {
diff --git a/packages/client/src/components/Info/buttons/Matrix.tsx b/packages/client/src/components/Info/buttons/Matrix.tsx
index 019ee7eb638e4dd8bb9af658e17179c93d19e00f..aaf596f8e20d5f130c6d426aac2e4688539ee1f7 100644
--- a/packages/client/src/components/Info/buttons/Matrix.tsx
+++ b/packages/client/src/components/Info/buttons/Matrix.tsx
@@ -1,6 +1,7 @@
import { Link } from "@heroui/react";
-import { MATRIX_INVITE } from "../../../lib/constants";
import { SiMatrix } from "@icons-pack/react-simple-icons";
+
+import { MATRIX_INVITE } from "../../../lib/constants";
import { Button } from "../../core/Button";
export const MatrixButton = () => {
diff --git a/packages/client/src/components/Info/buttons/Tip.tsx b/packages/client/src/components/Info/buttons/Tip.tsx
index 1bb5576184fe4d33eb7448365b983efd58f9e4ba..d58217aa08f1a2dfe1ceca1c1b51932ba32ee7f8 100644
--- a/packages/client/src/components/Info/buttons/Tip.tsx
+++ b/packages/client/src/components/Info/buttons/Tip.tsx
@@ -1,5 +1,6 @@
-import { DollarSign } from "lucide-react";
import { type ButtonProps, Link } from "@heroui/react";
+import { DollarSign } from "lucide-react";
+
import { Button } from "../../core/Button";
export const TipButton = ({
diff --git a/packages/client/src/components/KeybindModal.tsx b/packages/client/src/components/KeybindModal.tsx
index 0840aa9d73efa30d6c4ba8e15db260192e5dc000..f3b0cf89ceb7bf0ed90d9cdec7192c1446a67783 100644
--- a/packages/client/src/components/KeybindModal.tsx
+++ b/packages/client/src/components/KeybindModal.tsx
@@ -7,10 +7,12 @@ import {
ModalFooter,
ModalHeader,
} from "@heroui/react";
+import { useEffect } from "react";
+
+import { useDialog } from "@/contexts/DialogContext";
+
import { type IKeybind, KeybindManager } from "../lib/keybinds";
import { Button } from "./core/Button";
-import { useDialog } from "@/contexts/DialogContext";
-import { useEffect } from "react";
export const Keybind = ({ kb }: { kb: IKeybind }) => (
{
diff --git a/packages/client/src/components/Login/LoginModal.tsx b/packages/client/src/components/Login/LoginModal.tsx
index f600f24dfdc15899cdaf59a154a990e2a38d8c7b..1b13fcd9d97c1ba120efa771021eaa164aebd629 100644
--- a/packages/client/src/components/Login/LoginModal.tsx
+++ b/packages/client/src/components/Login/LoginModal.tsx
@@ -5,10 +5,11 @@ import {
ModalHeader,
useDisclosure,
} from "@heroui/react";
-import { Inhibit } from "./Inhibit";
-import { Trust } from "./Trust";
+
import { useDialog } from "../../contexts/DialogContext";
+import { Inhibit } from "./Inhibit";
import { Normal } from "./Normal";
+import { Trust } from "./Trust";
export const LoginModal = () => {
const { state, dispatch } = useDialog();
diff --git a/packages/client/src/components/Login/Normal.tsx b/packages/client/src/components/Login/Normal.tsx
index f2ee23db5c2667626e4edb8a8153a5eff2d374c7..7d106c78459ce4000fe95a61d58661bc3c2bd554 100644
--- a/packages/client/src/components/Login/Normal.tsx
+++ b/packages/client/src/components/Login/Normal.tsx
@@ -1,6 +1,7 @@
import { Link, ModalBody, ModalFooter } from "@heroui/react";
-import { type DialogData } from "../../contexts/DialogContext";
import { useCallback, useState } from "react";
+
+import { type DialogData } from "../../contexts/DialogContext";
import { RECOMMENDED_INSTANCES } from "../../lib/constants";
import { Button } from "../core/Button";
diff --git a/packages/client/src/components/Login/Trust.tsx b/packages/client/src/components/Login/Trust.tsx
index ac0beb77433a6aea47fb32290fd0223b00bafdd2..4e2d226322063190f3c600b4641b5dc6f3bf6807 100644
--- a/packages/client/src/components/Login/Trust.tsx
+++ b/packages/client/src/components/Login/Trust.tsx
@@ -1,4 +1,5 @@
import { Alert, Input } from "@heroui/react";
+
import { Button } from "../core/Button";
export const Trust = () => {
diff --git a/packages/client/src/components/Overlay/GridOverlay.tsx b/packages/client/src/components/Overlay/GridOverlay.tsx
index cda4e9ed160349a3c52f89b6bf1cc5038510b260..8d24f500357627cc6912648b3392d95a47efd11b 100644
--- a/packages/client/src/components/Overlay/GridOverlay.tsx
+++ b/packages/client/src/components/Overlay/GridOverlay.tsx
@@ -1,4 +1,5 @@
import { useEffect, useRef } from "react";
+
import { useAppContext } from "../../contexts/AppContext";
import { KeybindManager } from "../../lib/keybinds";
diff --git a/packages/client/src/components/Overlay/HeatmapOverlay.tsx b/packages/client/src/components/Overlay/HeatmapOverlay.tsx
index 10cddd7113874061c4b35e0a59a14799ddf6e86e..5f01e3f67b559375bb4c01d8c6099ff749308fe1 100644
--- a/packages/client/src/components/Overlay/HeatmapOverlay.tsx
+++ b/packages/client/src/components/Overlay/HeatmapOverlay.tsx
@@ -1,9 +1,10 @@
import { useCallback, useEffect, useRef } from "react";
+import { toast } from "react-toastify";
+
import { useAppContext } from "../../contexts/AppContext";
import { KeybindManager } from "../../lib/keybinds";
-import { api } from "../../lib/utils";
-import { toast } from "react-toastify";
import network from "../../lib/network";
+import { api } from "../../lib/utils";
export const HeatmapOverlay = () => {
const { config, heatmapOverlay, setHeatmapOverlay } = useAppContext();
diff --git a/packages/client/src/components/Overlay/OverlaySettings.tsx b/packages/client/src/components/Overlay/OverlaySettings.tsx
index d6e4d18d84f787f29b5adb72bd3f460a3ee2c611..5fb30e0408bead240dee1701882773752f10a879 100644
--- a/packages/client/src/components/Overlay/OverlaySettings.tsx
+++ b/packages/client/src/components/Overlay/OverlaySettings.tsx
@@ -1,4 +1,5 @@
import { Slider, Spinner } from "@heroui/react";
+
import { useAppContext } from "../../contexts/AppContext";
import { Switch } from "../core/Switch";
diff --git a/packages/client/src/components/Overlay/PixelPulses.tsx b/packages/client/src/components/Overlay/PixelPulses.tsx
index 0fb160683e536365a88c507bf893936b7cc1191e..f169ca4b05c7286a2906d6ab2a690d26251d12ab 100644
--- a/packages/client/src/components/Overlay/PixelPulses.tsx
+++ b/packages/client/src/components/Overlay/PixelPulses.tsx
@@ -1,8 +1,10 @@
+import { type Pixel } from "@sc07-canvas/lib/net";
import { type CSSProperties, type JSX, useEffect, useState } from "react";
+
+import { PaletteLib } from "@/lib/palette";
+
import { useAppContext } from "../../contexts/AppContext";
import network from "../../lib/network";
-import { type Pixel } from "@sc07-canvas/lib/net";
-import { PaletteLib } from "@/lib/palette";
export const PixelPulses = () => {
const { pixelPulses } = useAppContext();
diff --git a/packages/client/src/components/PixelSidebar/PixelWhoisSidebar.tsx b/packages/client/src/components/PixelSidebar/PixelWhoisSidebar.tsx
index cda1e94f8f27f0c5813efe38e429d020a93784e2..f1c30dc32521666dab45586910f1e81ffd0a33c1 100644
--- a/packages/client/src/components/PixelSidebar/PixelWhoisSidebar.tsx
+++ b/packages/client/src/components/PixelSidebar/PixelWhoisSidebar.tsx
@@ -1,15 +1,17 @@
-import { Spinner } from "@heroui/react";
-import { useAppContext } from "../../contexts/AppContext";
+import { Divider, Spinner } from "@heroui/react";
+import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
import { Flag, X } from "lucide-react";
import { useEffect, useState } from "react";
+
+import { usePanel } from "@/contexts/PanelContext";
+
+import { useAppContext } from "../../contexts/AppContext";
import { oapi } from "../../lib/utils";
-import { UserCard } from "../Profile/UserCard";
-import { SmallCanvas } from "./SmallCanvas";
-import { ReportPixelModal } from "./ReportPixelModal";
import { Button } from "../core/Button";
-import { usePanel } from "@/contexts/PanelContext";
-import { motion, useReducedMotion } from "framer-motion";
+import { UserCard } from "../Profile/UserCard";
import { SIDEBAR_SPRING } from "../SidebarBase";
+import { ReportPixelModal } from "./ReportPixelModal";
+import { SmallCanvas } from "./SmallCanvas";
interface IPixel {
id: number;
@@ -47,10 +49,23 @@ const parsePixelResponse = (
};
};
-export const PixelWhoisSidebar = () => {
+interface WhoisTarget {
+ x: number;
+ y: number;
+ surrounding: string[][];
+ requestId: string;
+}
+
+const PixelWhoisPanel = ({
+ target,
+ reduceMotion,
+ onClose,
+}: {
+ target: WhoisTarget;
+ reduceMotion: boolean;
+ onClose: () => void;
+}) => {
const { user } = useAppContext();
- const Panel = usePanel();
- const reduceMotion = useReducedMotion() ?? false;
const [loading, setLoading] = useState(true);
const [whois, setWhois] = useState<{
pixel: IPixel;
@@ -61,125 +76,147 @@ export const PixelWhoisSidebar = () => {
const [report, setReport] = useState(false);
useEffect(() => {
- if (!Boolean(Panel.state.whois)) return;
- setLoading(true);
- setWhois(undefined);
-
- const whois = Panel.state.whois!; // guarenteed in line above
+ let active = true;
oapi
.GET("/canvas/pixel/{x}/{y}", {
params: {
path: {
- x: whois.x,
- y: whois.y,
+ x: target.x,
+ y: target.y,
},
},
})
.then(({ data, error, response }) => {
+ if (!active) return;
if (data) {
- const pixel = parsePixelResponse(data.pixel);
-
setWhois({
- pixel,
+ pixel: parsePixelResponse(data.pixel),
otherPixels: data.otherPixels,
user: data.user,
instance: data.instance,
});
}
- if (error) {
- console.error("Error while fetching whois", response);
- }
+ if (error) console.error("Error while fetching whois", response);
})
.finally(() => {
- setLoading(false);
+ if (active) setLoading(false);
});
- }, [Panel.state.whois]);
+
+ return () => {
+ active = false;
+ };
+ }, [target.x, target.y]);
return (
-
- {loading && (
-
-
-
- Loading
+ <>
+
+
+ {loading && (
+
+ )}
+
- )}
-
- Pixel Whois
-
- Panel.dispatch(["whois", undefined])}
- >
-
-
-
-
-
-
- {whois?.user && (
- <>
-
- >
- )}
-
-
-
-
-
-
- Placed At
-
-
- {whois?.pixel.createdAt?.toLocaleString(undefined, {
- dateStyle: "medium",
- timeStyle: "medium",
- })}
-
-
-
-
- Covered Pixels
-
-
- {whois?.otherPixels}
-
-
-
-
- {user ? (
- setReport(true)}>
-
- Report Pixel
-
- ) : (
-
-
- Report Pixel (logged out)
-
- )}
-
+
+
+
+
+
+
+ Placed At
+
+
+ {whois?.pixel.createdAt?.toLocaleString(undefined, {
+ dateStyle: "medium",
+ timeStyle: "medium",
+ })}
+
+
+
+
+ Covered Pixels
+
+
+ {whois?.otherPixels}
+
+
+
+
+ {user ? (
+ setReport(true)}>
+
+ Report Pixel
+
+ ) : (
+
+
+ Report Pixel (logged out)
+
+ )}
+
-
setReport(v)}
- pixel={whois?.pixel}
- />
-
+
+
+ >
+ );
+};
+
+export const PixelWhoisSidebar = () => {
+ const Panel = usePanel();
+ const reduceMotion = useReducedMotion() ?? false;
+ const target = Panel.state.whois;
+
+ return (
+
+ {target && (
+ Panel.dispatch(["whois", undefined])}
+ />
+ )}
+
);
};
diff --git a/packages/client/src/components/PixelSidebar/ReportPixelModal.tsx b/packages/client/src/components/PixelSidebar/ReportPixelModal.tsx
index 554c60fb02ab30e2a279b9ab30226948a1c02800..ef25b207775f88e4c4f322cf73073bf58a42b7f9 100644
--- a/packages/client/src/components/PixelSidebar/ReportPixelModal.tsx
+++ b/packages/client/src/components/PixelSidebar/ReportPixelModal.tsx
@@ -9,14 +9,15 @@ import {
ModalFooter,
ModalHeader,
} from "@heroui/react";
-import useSWR from "swr";
-import { type IRule } from "../Info/InfoText";
import { useCallback, useEffect, useState } from "react";
+import { toast } from "react-toastify";
+import useSWR from "swr";
+
import { CanvasCore } from "../../lib/canvas";
-import { SmallCanvas } from "./SmallCanvas";
import { api } from "../../lib/utils";
-import { toast } from "react-toastify";
import { Button } from "../core/Button";
+import { type IRule } from "../Info/InfoText";
+import { SmallCanvas } from "./SmallCanvas";
interface IPixel {
id: number;
diff --git a/packages/client/src/components/PixelSidebar/SmallCanvas.tsx b/packages/client/src/components/PixelSidebar/SmallCanvas.tsx
index c7dbca6d4ce539e53da628e2b0322c19e3c36f0c..e3a80bbacd7ba5951d174e36abde477a840dc3bb 100644
--- a/packages/client/src/components/PixelSidebar/SmallCanvas.tsx
+++ b/packages/client/src/components/PixelSidebar/SmallCanvas.tsx
@@ -1,4 +1,4 @@
-import { type ComponentPropsWithoutRef, useRef, useEffect } from "react";
+import { type ComponentPropsWithoutRef, useEffect,useRef } from "react";
export const SmallCanvas = ({
surrounding,
diff --git a/packages/client/src/components/Profile/ProfileModal.tsx b/packages/client/src/components/Profile/ProfileModal.tsx
index e52efd8008612d723f1f8a48e0f2d5be83bc0d99..b4bf0ce107b0e44d785b8242771e3bd39c18b530 100644
--- a/packages/client/src/components/Profile/ProfileModal.tsx
+++ b/packages/client/src/components/Profile/ProfileModal.tsx
@@ -5,12 +5,13 @@ import {
ModalFooter,
ModalHeader,
} from "@heroui/react";
-import { useAppContext } from "../../contexts/AppContext";
+import { EError } from "@sc07-canvas/lib";
import { useEffect, useState } from "react";
-import { type IUser, UserCard } from "./UserCard";
+
+import { useAppContext } from "../../contexts/AppContext";
import { handleError, oapi } from "../../lib/utils";
-import { EError } from "@sc07-canvas/lib";
import { Button } from "../core/Button";
+import { type IUser, UserCard } from "./UserCard";
export const ProfileModal = () => {
const { profile, setProfile } = useAppContext();
diff --git a/packages/client/src/components/Profile/UserCard.tsx b/packages/client/src/components/Profile/UserCard.tsx
index 7939df825bb932f7d8fea8dcce50f26b0e783700..75297ef6487dad5a60e7767d0930830b4fc3eae4 100644
--- a/packages/client/src/components/Profile/UserCard.tsx
+++ b/packages/client/src/components/Profile/UserCard.tsx
@@ -8,6 +8,7 @@ import {
} from "lucide-react";
import { type MouseEvent, useEffect, useRef, useState } from "react";
import { toast } from "react-toastify";
+
import { useAppContext } from "../../contexts/AppContext";
import { useHasRole } from "../../hooks/useHasRole";
import { useModerator } from "../../Moderator/Moderator";
diff --git a/packages/client/src/components/RemoteCursorRenderer.ts b/packages/client/src/components/RemoteCursorRenderer.ts
new file mode 100644
index 0000000000000000000000000000000000000000..f393b85fa740440fca913bd03a5b9759d66a56a5
--- /dev/null
+++ b/packages/client/src/components/RemoteCursorRenderer.ts
@@ -0,0 +1,298 @@
+import type { CursorWireState } from "@sc07-canvas/lib/net";
+
+import { CanvasCore } from "../lib/canvas";
+import { PaletteLib } from "../lib/palette";
+
+const REGULAR_CURSOR_COLOR = "#ffffff";
+const DARK_CURSOR_OUTLINE = "#111111";
+const LIGHT_CURSOR_OUTLINE = "#ffffff";
+const CURSOR_INTERPOLATION_DURATION = 70;
+const PLACEMENT_ANIMATION_DURATION = 280;
+
+interface RenderCursor {
+ id: number;
+ sequence: number;
+ fromX: number;
+ fromY: number;
+ x: number;
+ y: number;
+ transitionStartedAt: number;
+ zoom: number;
+ color?: number;
+ placementStartedAt?: number;
+}
+
+interface ReactionAnchor {
+ element: HTMLElement;
+ expiresAt: number;
+}
+
+export const colorToCss = (color: number | undefined) => {
+ if (typeof color !== "number") return REGULAR_CURSOR_COLOR;
+
+ const hex = PaletteLib.getColor(color)?.hex;
+ if (!hex || hex === "transparent") return REGULAR_CURSOR_COLOR;
+ return hex.startsWith("#") ? hex : `#${hex}`;
+};
+
+const contrastOutline = (color: string) => {
+ const hex = color.replace("#", "");
+ if (!/^[0-9a-f]{6}$/i.test(hex)) return DARK_CURSOR_OUTLINE;
+
+ const red = Number.parseInt(hex.slice(0, 2), 16);
+ const green = Number.parseInt(hex.slice(2, 4), 16);
+ const blue = Number.parseInt(hex.slice(4, 6), 16);
+ const brightness = red * 0.299 + green * 0.587 + blue * 0.114;
+ return brightness < 150 ? LIGHT_CURSOR_OUTLINE : DARK_CURSOR_OUTLINE;
+};
+
+export const outlineForColor = (color: number | undefined) =>
+ contrastOutline(colorToCss(color));
+
+const remoteDisplayScale = (remoteZoom: number, localZoom: number) => {
+ const safeLocalZoom = Math.max(localZoom, 0.01);
+ const safeRemoteZoom = Math.max(remoteZoom, 0.01);
+ const sameLevelScale = Math.min(
+ 1,
+ Math.max(0.5, Math.min(safeLocalZoom, safeRemoteZoom) / 2),
+ );
+ const ratio = safeRemoteZoom / safeLocalZoom;
+
+ if (ratio >= 10) return { opacity: 0, scale: 0 };
+ if (ratio >= 1) {
+ return {
+ opacity: 1,
+ scale: sameLevelScale * (1 - (ratio - 1) / 9),
+ };
+ }
+
+ const inverseRatio = 1 / ratio;
+ if (inverseRatio >= 5) return { opacity: 0, scale: 0 };
+
+ return {
+ opacity: inverseRatio > 4 ? 5 - inverseRatio : 1,
+ scale: sameLevelScale * (1 + (inverseRatio - 1) / 4),
+ };
+};
+
+export class RemoteCursorRenderer {
+ private readonly context: CanvasRenderingContext2D;
+ private readonly cursors = new Map();
+ private readonly reactions = new Map();
+ private frame: number | undefined;
+ private localZoom = 1;
+ private disposed = false;
+
+ constructor(
+ private readonly surface: HTMLCanvasElement,
+ private readonly canvas: CanvasCore,
+ ) {
+ const context = surface.getContext("2d");
+ if (!context) throw new Error("Unable to create remote cursor renderer");
+ this.context = context;
+ this.resize();
+ }
+
+ apply(wire: CursorWireState) {
+ const now = performance.now();
+ const current = this.cursors.get(wire[0]);
+ if (current && current.sequence > wire[1]) return;
+ const position = current
+ ? this.interpolatedPosition(current, now)
+ : { x: wire[2], y: wire[3] };
+
+ this.cursors.set(wire[0], {
+ id: wire[0],
+ sequence: wire[1],
+ fromX: position.x,
+ fromY: position.y,
+ x: wire[2],
+ y: wire[3],
+ transitionStartedAt: now,
+ zoom: wire[4],
+ color: wire[5] ?? undefined,
+ placementStartedAt: current?.placementStartedAt,
+ });
+ this.requestDraw();
+ }
+
+ remove(id: number) {
+ this.cursors.delete(id);
+ this.removeReaction(id);
+ this.requestDraw();
+ }
+
+ retain(ids: Set) {
+ for (const id of this.cursors.keys()) {
+ if (!ids.has(id)) this.remove(id);
+ }
+ }
+
+ place(id: number) {
+ const cursor = this.cursors.get(id);
+ if (!cursor) return;
+ cursor.placementStartedAt = performance.now();
+ this.requestDraw();
+ }
+
+ attachReaction(id: number, element: HTMLElement, duration: number) {
+ this.removeReaction(id);
+ this.reactions.set(id, {
+ element,
+ expiresAt: performance.now() + duration,
+ });
+ this.requestDraw();
+ }
+
+ setLocalZoom(zoom: number) {
+ this.localZoom = zoom;
+ this.requestDraw();
+ }
+
+ resize() {
+ const pixelRatio = window.devicePixelRatio || 1;
+ const width = window.innerWidth;
+ const height = window.innerHeight;
+ this.surface.width = Math.max(1, Math.round(width * pixelRatio));
+ this.surface.height = Math.max(1, Math.round(height * pixelRatio));
+ this.surface.style.width = `${width}px`;
+ this.surface.style.height = `${height}px`;
+ this.context.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
+ this.requestDraw();
+ }
+
+ requestDraw() {
+ if (this.disposed || this.frame !== undefined) return;
+ this.frame = requestAnimationFrame(this.draw);
+ }
+
+ clearReactions() {
+ for (const id of [...this.reactions.keys()]) this.removeReaction(id);
+ }
+
+ clear() {
+ this.cursors.clear();
+ this.clearReactions();
+ this.context.clearRect(0, 0, window.innerWidth, window.innerHeight);
+ }
+
+ dispose() {
+ this.disposed = true;
+ if (this.frame !== undefined) cancelAnimationFrame(this.frame);
+ this.frame = undefined;
+ this.clear();
+ }
+
+ private draw = (now: number) => {
+ this.frame = undefined;
+ if (this.disposed) return;
+ this.context.clearRect(0, 0, window.innerWidth, window.innerHeight);
+ if (!this.canvas.isSurfaceReady()) return;
+ let keepDrawing = false;
+
+ for (const cursor of this.cursors.values()) {
+ const position = this.interpolatedPosition(cursor, now);
+ if (now - cursor.transitionStartedAt < CURSOR_INTERPOLATION_DURATION) {
+ keepDrawing = true;
+ }
+ const placement = this.placementTransform(cursor, now);
+ if (placement.active) keepDrawing = true;
+ this.drawCursor(cursor, position.x, position.y, placement);
+ }
+
+ for (const [id, reaction] of this.reactions) {
+ if (now >= reaction.expiresAt || !this.cursors.has(id)) {
+ this.removeReaction(id);
+ continue;
+ }
+ const cursor = this.cursors.get(id)!;
+ const position = this.interpolatedPosition(cursor, now);
+ const [screenX, screenY] = this.canvas.worldToScreen(
+ position.x,
+ position.y,
+ );
+ const display = remoteDisplayScale(cursor.zoom, this.localZoom);
+ reaction.element.style.opacity = String(display.opacity);
+ reaction.element.style.transform = `translate3d(${screenX}px, ${screenY}px, 0) scale(${display.scale})`;
+ keepDrawing = true;
+ }
+
+ if (keepDrawing) this.requestDraw();
+ };
+
+ private drawCursor(
+ cursor: RenderCursor,
+ x: number,
+ y: number,
+ placement: { active: boolean; rotate: number; scale: number },
+ ) {
+ const [screenX, screenY] = this.canvas.worldToScreen(x, y);
+ const display = remoteDisplayScale(cursor.zoom, this.localZoom);
+ if (display.opacity <= 0 || display.scale <= 0) return;
+
+ const context = this.context;
+ context.save();
+ context.globalAlpha = display.opacity;
+ context.translate(screenX, screenY);
+ context.rotate(placement.rotate);
+ context.scale(
+ display.scale * placement.scale,
+ display.scale * placement.scale,
+ );
+ context.translate(-1, -1);
+ context.beginPath();
+ context.moveTo(1, 1);
+ context.lineTo(1, 16);
+ context.lineTo(5, 12);
+ context.lineTo(8, 19);
+ context.lineTo(10.5, 17.8);
+ context.lineTo(7.5, 11.2);
+ context.lineTo(13, 11.2);
+ context.closePath();
+ context.fillStyle = colorToCss(cursor.color);
+ context.fill();
+ context.strokeStyle = outlineForColor(cursor.color);
+ context.lineWidth = 0.7;
+ context.lineJoin = "round";
+ context.stroke();
+ context.restore();
+ }
+
+ private interpolatedPosition(cursor: RenderCursor, now: number) {
+ const progress = Math.min(
+ 1,
+ Math.max(
+ 0,
+ (now - cursor.transitionStartedAt) / CURSOR_INTERPOLATION_DURATION,
+ ),
+ );
+ return {
+ x: cursor.fromX + (cursor.x - cursor.fromX) * progress,
+ y: cursor.fromY + (cursor.y - cursor.fromY) * progress,
+ };
+ }
+
+ private placementTransform(cursor: RenderCursor, now: number) {
+ if (cursor.placementStartedAt === undefined) {
+ return { active: false, rotate: 0, scale: 1 };
+ }
+ const progress =
+ (now - cursor.placementStartedAt) / PLACEMENT_ANIMATION_DURATION;
+ if (progress >= 1) {
+ cursor.placementStartedAt = undefined;
+ return { active: false, rotate: 0, scale: 1 };
+ }
+ const wave = Math.sin(Math.PI * Math.max(0, progress));
+ return {
+ active: true,
+ rotate: (-14 * Math.PI * wave) / 180,
+ scale: 1 - 0.1 * wave,
+ };
+ }
+
+ private removeReaction(id: number) {
+ const reaction = this.reactions.get(id);
+ reaction?.element.remove();
+ this.reactions.delete(id);
+ }
+}
diff --git a/packages/client/src/components/Settings/AudioSettings.tsx b/packages/client/src/components/Settings/AudioSettings.tsx
index b95891e0fdebedbdbb32b43f32e4c7e067658fde..2774967c715c138492397676a0fdf84acb6fb1db 100644
--- a/packages/client/src/components/Settings/AudioSettings.tsx
+++ b/packages/client/src/components/Settings/AudioSettings.tsx
@@ -1,18 +1,20 @@
+import { useCallback, useEffect, useState } from "react";
+import { useRef } from "react";
import useSound from "use-sound";
-import Coffee1 from "../../sounds/Coffee1.mp3";
-import Coffee2 from "../../sounds/Coffee2.mp3";
+
+import { useAudio } from "@/contexts/AudioContext";
+
+import network from "../../lib/network";
import African1 from "../../sounds/African1.mp3";
import African2 from "../../sounds/African2.mp3";
import African3 from "../../sounds/African3.mp3";
import African4 from "../../sounds/African4.mp3";
+import Coffee1 from "../../sounds/Coffee1.mp3";
+import Coffee2 from "../../sounds/Coffee2.mp3";
import WoodBlock1 from "../../sounds/Wood Block1.mp3";
import WoodBlock2 from "../../sounds/Wood Block2.mp3";
import WoodBlock3 from "../../sounds/Wood Block3.mp3";
-import { useCallback, useEffect, useState } from "react";
-import network from "../../lib/network";
-import { useRef } from "react";
import { Switch } from "../core/Switch";
-import { useAudio } from "@/contexts/AudioContext";
const _ = (v: boolean) => (v ? "enable" : "disable");
diff --git a/packages/client/src/components/Settings/ChatSettings.tsx b/packages/client/src/components/Settings/ChatSettings.tsx
index ea1c0bf2dd948166964f64de2d942fb8eb2ac383..84945eb1d0704f5e0a63abb8f6f8edf3976931a0 100644
--- a/packages/client/src/components/Settings/ChatSettings.tsx
+++ b/packages/client/src/components/Settings/ChatSettings.tsx
@@ -1,5 +1,6 @@
-import { useAppContext } from "../../contexts/AppContext";
import React, { lazy } from "react";
+
+import { useAppContext } from "../../contexts/AppContext";
import { Switch } from "../core/Switch";
const InnerChatSettings = lazy(() => import("../Chat/InnerChatSettings"));
diff --git a/packages/client/src/components/Settings/MiscSettings.tsx b/packages/client/src/components/Settings/MiscSettings.tsx
index db4942284a8c985efff9e039613fe226190ceb0a..01dc5a85ac575e8e49a5b1245139e0f490b1bb91 100644
--- a/packages/client/src/components/Settings/MiscSettings.tsx
+++ b/packages/client/src/components/Settings/MiscSettings.tsx
@@ -1,4 +1,5 @@
import { usePanel } from "@/contexts/PanelContext";
+
import { useAppContext } from "../../contexts/AppContext";
import { useDialog } from "../../contexts/DialogContext";
import { useIsCompactPaletteDefault } from "../../hooks/useCompactPalette";
@@ -31,6 +32,28 @@ export const MiscSettings = () => {
>
Enable Camera Flash
+ {!isMobile && (
+ <>
+ {
+ settings.set({ key: "presence.cursors", value });
+ }}
+ >
+ Enable Cursors
+
+ {settings.get["presence.cursors"] && (
+ {
+ settings.set({ key: "presence.reactions", value });
+ }}
+ >
+ Enable Reactions
+
+ )}
+ >
+ )}
{
diff --git a/packages/client/src/components/Settings/SettingsSidebar.tsx b/packages/client/src/components/Settings/SettingsSidebar.tsx
index 1723692a9ec0de2db68b9d46cbe7f23711049437..13fa26984f2e1d15fc09a38dfcce65ba3fcc9753 100644
--- a/packages/client/src/components/Settings/SettingsSidebar.tsx
+++ b/packages/client/src/components/Settings/SettingsSidebar.tsx
@@ -1,12 +1,14 @@
+import { Divider } from "@heroui/react";
import { Settings } from "lucide-react";
+
+import { usePanel } from "@/contexts/PanelContext";
+
+import { OverlaySettings } from "../Overlay/OverlaySettings";
import { SidebarBase } from "../SidebarBase";
-import { Divider } from "@heroui/react";
-import { TemplateSettings } from "./TemplateSettings";
+import { AudioSettings } from "./AudioSettings";
import { ChatSettings } from "./ChatSettings";
-import { OverlaySettings } from "../Overlay/OverlaySettings";
import { MiscSettings } from "./MiscSettings";
-import { AudioSettings } from "./AudioSettings";
-import { usePanel } from "@/contexts/PanelContext";
+import { TemplateSettings } from "./TemplateSettings";
export const SettingsSidebar = () => {
const Panel = usePanel();
diff --git a/packages/client/src/components/Settings/TemplateSettings.tsx b/packages/client/src/components/Settings/TemplateSettings.tsx
index 08ca707001c4495392a0e3e4e8e1f8ec996e015e..64de162595cd93c26b4be6e691cbcd90ee7dc138 100644
--- a/packages/client/src/components/Settings/TemplateSettings.tsx
+++ b/packages/client/src/components/Settings/TemplateSettings.tsx
@@ -1,12 +1,14 @@
-import { useTemplateContext } from "../../contexts/TemplateContext";
-import { useAppContext } from "../../contexts/AppContext";
import { Alert, Input, Select, SelectItem, Slider } from "@heroui/react";
-import { Switch } from "../core/Switch";
-import { Button } from "../core/Button";
-import { toast } from "react-toastify";
import { LinkIcon } from "lucide-react";
-import { WebGLUtils } from "@/lib/webgl";
+import { toast } from "react-toastify";
+
import { CanvasCore } from "@/lib/canvas";
+import { WebGLUtils } from "@/lib/webgl";
+
+import { useAppContext } from "../../contexts/AppContext";
+import { useTemplateContext } from "../../contexts/TemplateContext";
+import { Button } from "../core/Button";
+import { Switch } from "../core/Switch";
export const TemplateSettings = () => {
const {
diff --git a/packages/client/src/components/SidebarBase.tsx b/packages/client/src/components/SidebarBase.tsx
index 86188ffc96f3669882deb0aa794fa86a19f5a8ed..b3369c7dbc27ad56ced54244e6b2579312ff7025 100644
--- a/packages/client/src/components/SidebarBase.tsx
+++ b/packages/client/src/components/SidebarBase.tsx
@@ -1,7 +1,8 @@
-import { motion, useReducedMotion } from "framer-motion";
import { Divider } from "@heroui/react";
-import { type JSX } from "react";
+import { motion, useReducedMotion } from "framer-motion";
import { type LucideIcon, X } from "lucide-react";
+import { type JSX } from "react";
+
import { Button } from "./core/Button";
export const SIDEBAR_SPRING = { type: "spring", stiffness: 50 } as const;
@@ -51,6 +52,7 @@ export const SidebarBase = ({
}
/>
{
diff --git a/packages/client/src/components/Templating/Template.tsx b/packages/client/src/components/Templating/Template.tsx
index bcf0ce2a929b7dacec7fb128ae411b76c6050a12..7e5e5f11b7cbb4eeae0ff1154cb4266c5b67d0a1 100644
--- a/packages/client/src/components/Templating/Template.tsx
+++ b/packages/client/src/components/Templating/Template.tsx
@@ -1,9 +1,11 @@
import { useEffect, useRef } from "react";
-import { Template as TemplateCl } from "../../lib/template";
+
+import { WebGLUtils } from "@/lib/webgl";
+
import { useAppContext } from "../../contexts/AppContext";
import { useTemplateContext } from "../../contexts/TemplateContext";
import { CanvasCore } from "../../lib/canvas";
-import { WebGLUtils } from "@/lib/webgl";
+import { Template as TemplateCl } from "../../lib/template";
const $Template = () => {
const { config } = useAppContext();
diff --git a/packages/client/src/components/Toolbar/CanvasMeta.tsx b/packages/client/src/components/Toolbar/CanvasMeta.tsx
index 73631f32355b118f7bb2f1e6bd4a9313a7d415dd..efd8fcb4bcd67fde0f7ec641dbd4eb2f5b435456 100644
--- a/packages/client/src/components/Toolbar/CanvasMeta.tsx
+++ b/packages/client/src/components/Toolbar/CanvasMeta.tsx
@@ -1,11 +1,6 @@
import { CanvasLib } from "@sc07-canvas/lib/canvas";
-import { useAppContext } from "../../contexts/AppContext";
-import { CanvasCore } from "../../lib/canvas";
-import { useEffect, useState } from "react";
import { type ClientConfig } from "@sc07-canvas/lib/net";
-import network from "../../lib/network";
-import { ShareModal } from "./ShareModal";
-import { useDialog } from "@/contexts/DialogContext";
+import { motion, useReducedMotion } from "framer-motion";
import {
Brush,
ChevronDown,
@@ -13,16 +8,23 @@ import {
Crosshair,
Users,
} from "lucide-react";
-import { motion, useReducedMotion } from "framer-motion";
+import { useEffect, useState } from "react";
+
+import { useDialog } from "@/contexts/DialogContext";
import { usePanel } from "@/contexts/PanelContext";
-import { SIDEBAR_SPRING } from "../SidebarBase";
-import { useSidebarWidth } from "../Header/useSidebarWidth";
+
+import { useAppContext } from "../../contexts/AppContext";
import {
PALETTE_LAYOUT_SPRING,
useCompactPalette,
} from "../../hooks/useCompactPalette";
import { useIsMobile } from "../../hooks/useIsMobile";
+import { CanvasCore } from "../../lib/canvas";
import { setAvailablePixelsFavicon } from "../../lib/favicon";
+import network from "../../lib/network";
+import { useSidebarWidth } from "../Header/useSidebarWidth";
+import { SIDEBAR_SPRING } from "../SidebarBase";
+import { ShareModal } from "./ShareModal";
const getTimeLeft = (available: number, config: ClientConfig) => {
/**
diff --git a/packages/client/src/components/Toolbar/OverlaySwitcher.tsx b/packages/client/src/components/Toolbar/OverlaySwitcher.tsx
index e194e5ebb7e83caacddf39c35708e7b92a94b0fc..f630951df632ecd989c6d5530826065ebbeb4959 100644
--- a/packages/client/src/components/Toolbar/OverlaySwitcher.tsx
+++ b/packages/client/src/components/Toolbar/OverlaySwitcher.tsx
@@ -1,22 +1,24 @@
-import { useState } from "react";
+import { motion, useReducedMotion } from "framer-motion";
import {
ChevronLeft,
ChevronRight,
Image as ImageIcon,
Layers,
} from "lucide-react";
-import { useAppContext } from "../../contexts/AppContext";
+import { useState } from "react";
+
import { usePanel } from "@/contexts/PanelContext";
-import { motion, useReducedMotion } from "framer-motion";
-import { SIDEBAR_SPRING } from "../SidebarBase";
-import { useSidebarWidth } from "../Header/useSidebarWidth";
+
+import { useAppContext } from "../../contexts/AppContext";
+import { useTemplateContext } from "../../contexts/TemplateContext";
import {
PALETTE_LAYOUT_SPRING,
useCompactPalette,
} from "../../hooks/useCompactPalette";
-import { useTemplateContext } from "../../contexts/TemplateContext";
-import { CanvasCore } from "../../lib/canvas";
import { useIsMobile } from "../../hooks/useIsMobile";
+import { CanvasCore } from "../../lib/canvas";
+import { useSidebarWidth } from "../Header/useSidebarWidth";
+import { SIDEBAR_SPRING } from "../SidebarBase";
export const OverlaySwitcher = () => {
const {
diff --git a/packages/client/src/components/Toolbar/Palette.scss b/packages/client/src/components/Toolbar/Palette.scss
index 4985f7ada7eeda052ada6a96961f8b3695bed395..cbac2b9421983faba445461ee429d868e857d1b3 100644
--- a/packages/client/src/components/Toolbar/Palette.scss
+++ b/packages/client/src/components/Toolbar/Palette.scss
@@ -1,6 +1,7 @@
#toolbar {
width: 100%;
position: relative;
+ z-index: 27;
}
.pick-color-cursor-preview {
diff --git a/packages/client/src/components/Toolbar/Palette.tsx b/packages/client/src/components/Toolbar/Palette.tsx
index aad158533ae734ff43a2e4f12196cb7d8ce796c9..4e9ae0ff5abfd92b4428da6781169c193c7c6cc2 100644
--- a/packages/client/src/components/Toolbar/Palette.tsx
+++ b/packages/client/src/components/Toolbar/Palette.tsx
@@ -1,3 +1,21 @@
+import { Link, Tooltip } from "@heroui/react";
+import { Debug } from "@sc07-canvas/lib/debug";
+import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
+import {
+ Cog,
+ Crop,
+ Eye,
+ Grid2x2,
+ Hammer,
+ Info,
+ Palette as PaletteIcon,
+ Pipette,
+ Scan,
+ Settings,
+ Share2,
+ Wrench,
+ X,
+} from "lucide-react";
import {
type CSSProperties,
type PointerEvent,
@@ -7,8 +25,8 @@ import {
useState,
} from "react";
import { createPortal } from "react-dom";
-import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
-import { Link, Tooltip } from "@heroui/react";
+import useSWR from "swr";
+
import { useAppContext } from "../../contexts/AppContext";
import { useCaptureContext } from "../../contexts/CaptureContext";
import { useDialog } from "../../contexts/DialogContext";
@@ -20,38 +38,21 @@ import {
} from "../../hooks/useCompactPalette";
import { useHasRole } from "../../hooks/useHasRole";
import { useIsMobile } from "../../hooks/useIsMobile";
-import {
- Cog,
- Crop,
- Eye,
- Grid2x2,
- Hammer,
- Info,
- Palette as PaletteIcon,
- Pipette,
- Scan,
- Settings,
- Share2,
- Wrench,
- X,
-} from "lucide-react";
+import { CanvasCore } from "../../lib/canvas";
import { KeybindManager } from "../../lib/keybinds";
-import useSWR from "swr";
import { PaletteLib } from "../../lib/palette";
-import { CanvasCore } from "../../lib/canvas";
-import { Debug } from "@sc07-canvas/lib/debug";
-import { useModerator } from "../../Moderator/Moderator";
-import OpenChatButton from "../Chat/OpenChatButton";
-import { Button } from "../core/Button";
-import { ThemeSwitcher } from "../Header/ThemeSwitcher";
-import { LoginButton } from "../Login/LoginButton";
-import { showColorPickFeedback } from "../ColorPickFeedback";
import {
Template as TemplateRenderer,
- TemplateStyle,
TEMPLATE_STYLE_COLUMNS,
TEMPLATE_STYLE_ROWS,
+ TemplateStyle,
} from "../../lib/template";
+import { useModerator } from "../../Moderator/Moderator";
+import OpenChatButton from "../Chat/OpenChatButton";
+import { showColorPickFeedback } from "../ColorPickFeedback";
+import { Button } from "../core/Button";
+import { ThemeSwitcher } from "../Header/ThemeSwitcher";
+import { LoginButton } from "../Login/LoginButton";
const PaletteColorSurface = ({ hex }: { hex: string }) => {
const canvasRef = useRef(null);
@@ -396,15 +397,15 @@ export const Palette = () => {
target: PickColorTarget,
) => {
const pickedColor = getPickedColor(clientX, clientY);
- if (pickedColor === undefined) return false;
+ if (pickedColor === undefined) return undefined;
if (target === "secondary") {
handleSelectSecondaryColor(pickedColor);
- return true;
+ return pickedColor;
}
handleSelectPrimaryColor(pickedColor);
- return true;
+ return pickedColor;
};
useEffect(() => {
@@ -533,13 +534,17 @@ export const Palette = () => {
);
}
- const didPickColor = handlePickColorAt(
+ const pickedColor = handlePickColorAt(
event.clientX,
event.clientY,
event.button === 2 ? "secondary" : "primary",
);
- if (didPickColor) {
- showColorPickFeedback(event.clientX, event.clientY);
+ if (pickedColor !== undefined) {
+ showColorPickFeedback(
+ event.clientX,
+ event.clientY,
+ PaletteLib.getColor(pickedColor)?.hex,
+ );
}
setPickColorMode(false);
};
diff --git a/packages/client/src/components/Toolbar/ShareModal.tsx b/packages/client/src/components/Toolbar/ShareModal.tsx
index 979ea91726baed0a2723aca8bae615d6b81c2186..feff5f74b036c3696e311d0bb607d4e01b1ab185 100644
--- a/packages/client/src/components/Toolbar/ShareModal.tsx
+++ b/packages/client/src/components/Toolbar/ShareModal.tsx
@@ -1,6 +1,3 @@
-import { useDialog } from "@/contexts/DialogContext";
-import { CanvasCore } from "@/lib/canvas";
-import { Template, type ITemplateState } from "@/lib/template";
import {
Alert,
Link,
@@ -9,11 +6,16 @@ import {
ModalContent,
ModalHeader,
} from "@heroui/react";
-import React, { useCallback, useEffect, useRef, type MouseEvent } from "react";
-import { Button } from "../core/Button";
import { Copy } from "lucide-react";
+import React, { type MouseEvent,useCallback, useEffect, useRef } from "react";
import { toast } from "react-toastify";
+
+import { useDialog } from "@/contexts/DialogContext";
import { useTemplateContext } from "@/contexts/TemplateContext";
+import { CanvasCore } from "@/lib/canvas";
+import { type ITemplateState,Template } from "@/lib/template";
+
+import { Button } from "../core/Button";
export const ShareModal = () => {
const Dialog = useDialog();
diff --git a/packages/client/src/components/Toolbar/ToolbarWrapper.tsx b/packages/client/src/components/Toolbar/ToolbarWrapper.tsx
index 3ccc90763aefa1aae263074cfb2d848e3e68de5c..e8913a8e03ade48b8dc49f35e8ba880e19647086 100644
--- a/packages/client/src/components/Toolbar/ToolbarWrapper.tsx
+++ b/packages/client/src/components/Toolbar/ToolbarWrapper.tsx
@@ -1,4 +1,5 @@
import type React from "react";
+
import { useAppContext } from "../../contexts/AppContext";
import { useTemplateContext } from "../../contexts/TemplateContext";
import { CaptchaStatusBar } from "../Captcha/CaptchaStatusBar";
diff --git a/packages/client/src/components/Toolbar/UndoButton.tsx b/packages/client/src/components/Toolbar/UndoButton.tsx
index d4b62802292af5654cdc06c592a79d06b6a4528d..93764ee15b2b435235483fd2247a0c8493e4dbbc 100644
--- a/packages/client/src/components/Toolbar/UndoButton.tsx
+++ b/packages/client/src/components/Toolbar/UndoButton.tsx
@@ -1,7 +1,8 @@
-import { useAppContext } from "../../contexts/AppContext";
-import network from "../../lib/network";
import { useEffect, useState } from "react";
import { toast } from "react-toastify";
+
+import { useAppContext } from "../../contexts/AppContext";
+import network from "../../lib/network";
import { Button } from "../core/Button";
export const UndoButton = () => {
diff --git a/packages/client/src/components/Welcome/WelcomeModal.tsx b/packages/client/src/components/Welcome/WelcomeModal.tsx
index eb1ec498cc8e0ed1d91422914f455f48f4bbc8ae..dc773dd649a0cdc4bf720b2fc951ca076b5bca59 100644
--- a/packages/client/src/components/Welcome/WelcomeModal.tsx
+++ b/packages/client/src/components/Welcome/WelcomeModal.tsx
@@ -6,8 +6,9 @@ import {
ModalHeader,
useDisclosure,
} from "@heroui/react";
-import { useDialog } from "../../contexts/DialogContext";
import { useEffect } from "react";
+
+import { useDialog } from "../../contexts/DialogContext";
import { Button } from "../core/Button";
const WELCOME_MODAL_VERSION = "new";
diff --git a/packages/client/src/components/core/Button.tsx b/packages/client/src/components/core/Button.tsx
index 698fef176434f82566d4d9621c84f6cbc7dd76cb..accd18f032f11a3b94ee9e17d971038b021bf152 100644
--- a/packages/client/src/components/core/Button.tsx
+++ b/packages/client/src/components/core/Button.tsx
@@ -1,4 +1,5 @@
import { Button as ButtonBase, type ButtonProps } from "@heroui/react";
+
import { useAudio } from "@/contexts/AudioContext";
export const Button = ({
diff --git a/packages/client/src/components/core/Switch.tsx b/packages/client/src/components/core/Switch.tsx
index 6067fe14ac1c2738676f1ab9be68e761c729509a..ab02272fd3f0dfe4edc8d322a397f9c8b7fec99f 100644
--- a/packages/client/src/components/core/Switch.tsx
+++ b/packages/client/src/components/core/Switch.tsx
@@ -1,4 +1,5 @@
import { Switch as SwitchBase, type SwitchProps } from "@heroui/react";
+
import { useAudio } from "@/contexts/AudioContext";
export const Switch = ({
diff --git a/packages/client/src/contexts/AppContext.tsx b/packages/client/src/contexts/AppContext.tsx
index ec656b0716d81e99338490ed4af0002e9237bcfb..1e2a4f625c444c668ed00f4206efdf95cb868520 100644
--- a/packages/client/src/contexts/AppContext.tsx
+++ b/packages/client/src/contexts/AppContext.tsx
@@ -1,24 +1,27 @@
+import { Spinner } from "@heroui/react";
+import { type RestAPI } from "@sc07-canvas/lib";
+import { type AuthSession, type ClientConfig } from "@sc07-canvas/lib/net";
import React, {
- type PropsWithChildren,
createContext,
+ type PropsWithChildren,
useContext,
useEffect,
useReducer,
useState,
} from "react";
-import { type AuthSession, type ClientConfig } from "@sc07-canvas/lib/net";
+
+import { KeybindManager } from "../lib/keybinds";
import Network from "../lib/network";
-import { Spinner } from "@heroui/react";
-import { oapi } from "../lib/utils";
-import { type RestAPI } from "@sc07-canvas/lib";
import { PaletteLib } from "../lib/palette";
-import { KeybindManager } from "../lib/keybinds";
+import { oapi } from "../lib/utils";
type Whoami = RestAPI.components["schemas"]["Whoami"];
interface ISettings {
"capture.cameraFlash"(val: boolean): boolean;
"keybindings.enabled"(val: boolean): boolean;
+ "presence.cursors"(val: boolean): boolean;
+ "presence.reactions"(val: boolean): boolean;
"palette.compact"(val: boolean | undefined): boolean | undefined;
"palette.colors"(val: boolean): boolean;
"palette.symbols"(val: boolean): boolean;
@@ -171,6 +174,9 @@ export const AppContext = ({ children }: PropsWithChildren) => {
{
"capture.cameraFlash": true,
"keybindings.enabled": true,
+ "presence.cursors": localStorage.getItem("presence.cursors") !== "false",
+ "presence.reactions":
+ localStorage.getItem("presence.reactions") !== "false",
"palette.compact": undefined,
"palette.colors": false,
"palette.symbols": false,
@@ -184,6 +190,20 @@ export const AppContext = ({ children }: PropsWithChildren) => {
},
);
+ useEffect(() => {
+ localStorage.setItem(
+ "presence.cursors",
+ String(settings[0]["presence.cursors"]),
+ );
+ }, [settings[0]["presence.cursors"]]);
+
+ useEffect(() => {
+ localStorage.setItem(
+ "presence.reactions",
+ String(settings[0]["presence.reactions"]),
+ );
+ }, [settings[0]["presence.reactions"]]);
+
const state = useReducer(
(state, { action, ...data }) => {
switch (action) {
diff --git a/packages/client/src/contexts/AudioContext.tsx b/packages/client/src/contexts/AudioContext.tsx
index faa3be64323a57684f67a438a8222ef19ee9bc27..1e66931fab520cc85d05eec27a9c38a6d0ccd7df 100644
--- a/packages/client/src/contexts/AudioContext.tsx
+++ b/packages/client/src/contexts/AudioContext.tsx
@@ -5,6 +5,7 @@ import React, {
useReducer,
} from "react";
import useSound from "use-sound";
+
import African3 from "../sounds/African3.mp3";
interface IAudioState {
diff --git a/packages/client/src/contexts/CaptureContext.tsx b/packages/client/src/contexts/CaptureContext.tsx
index 43c2dba000d13f7a1a7c4b44806408ccae88d489..9fdf1c502a095d7969ab017656aaa7439c423f8a 100644
--- a/packages/client/src/contexts/CaptureContext.tsx
+++ b/packages/client/src/contexts/CaptureContext.tsx
@@ -1,3 +1,5 @@
+import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
+import { Scan } from "lucide-react";
import {
createContext,
type PointerEvent,
@@ -10,17 +12,17 @@ import {
useState,
} from "react";
import { createPortal } from "react-dom";
-import { Scan } from "lucide-react";
-import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
import { toast } from "react-toastify";
-import { useAppContext } from "./AppContext";
-import { useTemplateContext } from "./TemplateContext";
-import { Template as TemplateRenderer } from "@/lib/template";
+
import {
- CanvasCore,
type CanvasCaptureArea,
+ CanvasCore,
type CanvasSnapshot,
} from "@/lib/canvas";
+import { Template as TemplateRenderer } from "@/lib/template";
+
+import { useAppContext } from "./AppContext";
+import { useTemplateContext } from "./TemplateContext";
const TEMPLATE_CAPTURE_PADDING = 2;
const REGION_CLICK_THRESHOLD = 4;
diff --git a/packages/client/src/contexts/PanelContext.tsx b/packages/client/src/contexts/PanelContext.tsx
index d61de58b16182e36d08c3130d8cc778929ed81d0..9d8b7742181222ad155c8037c6f05e489d8feea8 100644
--- a/packages/client/src/contexts/PanelContext.tsx
+++ b/packages/client/src/contexts/PanelContext.tsx
@@ -3,7 +3,12 @@ import React, { createContext, useContext, useReducer } from "react";
interface IPanelState {
info?: boolean;
settings?: boolean;
- whois?: { x: number; y: number; surrounding: string[][] };
+ whois?: {
+ x: number;
+ y: number;
+ surrounding: string[][];
+ requestId: string;
+ };
}
type PanelActions = {
diff --git a/packages/client/src/contexts/TemplateContext.tsx b/packages/client/src/contexts/TemplateContext.tsx
index 731a72d2c9d57c2ca467c8a84170ba3bc7832e30..9f4600d8318dbd133c1fb1d35531ac575cbec062 100644
--- a/packages/client/src/contexts/TemplateContext.tsx
+++ b/packages/client/src/contexts/TemplateContext.tsx
@@ -1,15 +1,17 @@
import {
- type PropsWithChildren,
createContext,
+ type PropsWithChildren,
useContext,
useEffect,
useRef,
useState,
} from "react";
-import { type IRouterData, Router } from "../lib/router";
+
+import { WebGLUtils } from "@/lib/webgl";
+
import { KeybindManager } from "../lib/keybinds";
+import { type IRouterData, Router } from "../lib/router";
import { type TemplateStyle } from "../lib/template";
-import { WebGLUtils } from "@/lib/webgl";
import { useAppContext } from "./AppContext";
interface ITemplate {
diff --git a/packages/client/src/hooks/useCompactPalette.ts b/packages/client/src/hooks/useCompactPalette.ts
index c03b83378ab873ffd91b625d9589b48bbf4746ca..b61c7b1bd2abbd04ec7dac59ba66bb31b82c40ef 100644
--- a/packages/client/src/hooks/useCompactPalette.ts
+++ b/packages/client/src/hooks/useCompactPalette.ts
@@ -1,4 +1,5 @@
import { useEffect, useState } from "react";
+
import { useAppContext } from "../contexts/AppContext";
import { usePanel } from "../contexts/PanelContext";
import { useModerator } from "../Moderator/Moderator";
diff --git a/packages/client/src/index.tsx b/packages/client/src/index.tsx
index a8c9469ec60b3fcad637c40947ca42a80c0936fd..a1e8e5e9b251e055f282f4fe19d21924f03d57d2 100644
--- a/packages/client/src/index.tsx
+++ b/packages/client/src/index.tsx
@@ -1,13 +1,14 @@
import "./lib/sentry";
-import React from "react";
-import { createRoot } from "react-dom/client";
-import { HeroUIProvider } from "@heroui/react";
-import { ThemeProvider } from "next-themes";
-import App from "./components/App";
-
import "./lib/captcha";
import "./lib/api";
+
+import { HeroUIProvider } from "@heroui/react";
import * as Sentry from "@sentry/react";
+import { ThemeProvider } from "next-themes";
+import React from "react";
+import { createRoot } from "react-dom/client";
+
+import App from "./components/App";
import { printBanner } from "./lib/banner";
let ErrorBoundary = ({ children }: React.PropsWithChildren) => <>{children}>;
diff --git a/packages/client/src/lib/api.ts b/packages/client/src/lib/api.ts
index 4b3b9dd6148620124daae31e462055a74503a1e0..3ce367202fb80784a57a68d21374f460dec69938 100644
--- a/packages/client/src/lib/api.ts
+++ b/packages/client/src/lib/api.ts
@@ -32,5 +32,5 @@ export const API = {
},
};
-// @ts-expect-error
+// @ts-expect-error global export
window.API = API;
diff --git a/packages/client/src/lib/canvas.ts b/packages/client/src/lib/canvas.ts
index 8d520754b8ad55dbe901b2a1e5045697efc2172b..043d71c6155646489d897c5267d4230b485a9952 100644
--- a/packages/client/src/lib/canvas.ts
+++ b/packages/client/src/lib/canvas.ts
@@ -1,21 +1,22 @@
-import EventEmitter from "eventemitter3";
import {
type ClientConfig,
type IPosition,
type Pixel,
} from "@sc07-canvas/lib/net";
-import Network from "./network";
import {
type ClickEvent,
type HoverEvent,
PanZoom,
} from "@sc07-canvas/lib/renderer";
+import EventEmitter from "eventemitter3";
import { toast } from "react-toastify";
-import { KeybindManager } from "./keybinds";
-import { getRenderer } from "./utils";
-import { type CanvasPixel } from "./canvasRenderer";
+
import { CanvasUtils } from "./canvas.utils";
+import { type CanvasPixel } from "./canvasRenderer";
+import { KeybindManager } from "./keybinds";
+import Network from "./network";
import { PaletteLib } from "./palette";
+import { getRenderer } from "./utils";
const padSnapshotDatePart = (value: number) =>
value.toString().padStart(2, "0");
@@ -77,10 +78,21 @@ interface CanvasEvents {
* @returns
*/
cursorPos: (position: IPosition) => void;
+ surfacePointer: (position: {
+ active: boolean;
+ clientX: number;
+ clientY: number;
+ x?: number;
+ y?: number;
+ }) => void;
canvasReady: () => void;
snapshot: (snapshot: CanvasSnapshot) => void;
pixelsChanged: () => void;
- pixelPlaced: (position: { clientX: number; clientY: number }) => void;
+ pixelPlaced: (position: {
+ clientX: number;
+ clientY: number;
+ color: number;
+ }) => void;
}
export class CanvasCore extends EventEmitter {
@@ -97,6 +109,8 @@ export class CanvasCore extends EventEmitter {
lastPlace: number | undefined;
private bypassCooldown = false;
+ private placementInFlight = false;
+ private destroyed = false;
// private _delayedLoad: ReturnType;
static get(): CanvasCore;
@@ -124,11 +138,18 @@ export class CanvasCore extends EventEmitter {
}
destroy() {
+ this.destroyed = true;
+ this.setPanZoom(null);
+ this.setCanvas(null);
getRenderer().off("ready");
Network.off("pixel", this.handlePixel);
Network.off("square", this.handleSquare);
Network.off("pixelLastPlaced", this.handlePixelLastPlaced);
+
+ if (CanvasCore.instance === this) {
+ CanvasCore.instance = undefined;
+ }
}
setCanvas(canvas: HTMLCanvasElement | null) {
@@ -145,6 +166,13 @@ export class CanvasCore extends EventEmitter {
}
}
+ isSurfaceReady() {
+ return (
+ !this.destroyed &&
+ Boolean(this.canvas && this.PanZoom && this.PanZoom.$wrapper)
+ );
+ }
+
private emitSnapshot(snapshot: Omit) {
let saved = false;
@@ -529,6 +557,8 @@ export class CanvasCore extends EventEmitter {
};
handleMouseDown = (e: ClickEvent) => {
+ if (this.destroyed) return;
+
const noMods = !e.alt && !e.ctrl && !e.meta && !e.shift;
if (e.button === "MCLICK") {
@@ -551,16 +581,18 @@ export class CanvasCore extends EventEmitter {
if (noMods && (e.button === "LCLICK" || e.button === "RCLICK")) {
const [x, y] = this.screenToPos(e.clientX, e.clientY);
- const placement =
+ const color =
e.button === "LCLICK"
- ? this.place(x, y)
- : this.place(x, y, PaletteLib.getSecondaryColor()?.id);
+ ? PaletteLib.getSelectedColor()?.id
+ : PaletteLib.getSecondaryColor()?.id;
+ const placement = this.place(x, y, color);
void placement.then((placed) => {
- if (placed) {
+ if (placed && color !== undefined) {
this.emit("pixelPlaced", {
clientX: e.clientX,
clientY: e.clientY,
+ color,
});
}
});
@@ -593,6 +625,26 @@ export class CanvasCore extends EventEmitter {
}
this.emit("cursorPos", this.cursor);
+
+ const overSurface =
+ e.target instanceof Node &&
+ Boolean(this.PanZoom?.$wrapper.contains(e.target));
+ if (overSurface) {
+ const [x, y] = this.screenToPos(e.clientX, e.clientY);
+ this.emit("surfacePointer", {
+ active: true,
+ clientX: e.clientX,
+ clientY: e.clientY,
+ x,
+ y,
+ });
+ } else {
+ this.emit("surfacePointer", {
+ active: false,
+ clientX: e.clientX,
+ clientY: e.clientY,
+ });
+ }
};
handleSquare = (
@@ -691,15 +743,11 @@ export class CanvasCore extends EventEmitter {
}
place(x: number, y: number, color = PaletteLib.getSelectedColor()?.id) {
- if (color === undefined) return Promise.resolve(false);
+ if (color === undefined || this.placementInFlight) {
+ return Promise.resolve(false);
+ }
- // TODO: redo this as the server now verifies placements differently
- // if (this.lastPlace) {
- // if (this.lastPlace + this.config.pallete.pixel_cooldown > Date.now()) {
- // console.log("cannot place; cooldown");
- // return;
- // }
- // }
+ this.placementInFlight = true;
return Network.socket
.emitWithAck(
@@ -718,6 +766,9 @@ export class CanvasCore extends EventEmitter {
Network.emit("placeSuccessful");
return true;
} else {
+ if (ack.error === "pixel_already_pending") return false;
+
+ Network.emit("placeFailed");
console.warn(
"Attempted to place pixel",
{ x, y, color },
@@ -734,9 +785,6 @@ export class CanvasCore extends EventEmitter {
case "no_user":
toast.error("You are not logged in.");
break;
- case "pixel_already_pending":
- toast.error("You are already placing a pixel");
- break;
case "palette_color_invalid":
toast.error("This isn't a color that you can use...?");
break;
@@ -752,6 +800,9 @@ export class CanvasCore extends EventEmitter {
return false;
}
+ })
+ .finally(() => {
+ this.placementInFlight = false;
});
}
@@ -819,6 +870,20 @@ export class CanvasCore extends EventEmitter {
document.body.appendChild(el);
}
+ /**
+ * Canvas world position to screen coordinates.
+ */
+ worldToScreen(x: number, y: number) {
+ if (!this.canvas) {
+ throw new Error("CanvasCore#worldToScreen : no ");
+ }
+
+ const rect = this.canvas.getBoundingClientRect();
+ return [
+ rect.left + x * (rect.width / this.canvas.width),
+ rect.top + y * (rect.height / this.canvas.height),
+ ];
+ }
/**
* Screen (clientX, clientY) to Canvas position
* @param x
diff --git a/packages/client/src/lib/keybinds.ts b/packages/client/src/lib/keybinds.ts
index 15a0742f5972816e79b39cbaf5a8779a0870d1f2..c55e4f734897e51a1fe69b14592d13673f64ee5d 100644
--- a/packages/client/src/lib/keybinds.ts
+++ b/packages/client/src/lib/keybinds.ts
@@ -1,4 +1,5 @@
import EventEmitter from "eventemitter3";
+
import { type EnforceObjectType } from "./utils";
export interface IKeybind {
diff --git a/packages/client/src/lib/network.ts b/packages/client/src/lib/network.ts
index 0e6345744bb7976811528f6c35f3b23e3f447fde..16605d59cb52b7e572e6de15d3b49010fec46c76 100644
--- a/packages/client/src/lib/network.ts
+++ b/packages/client/src/lib/network.ts
@@ -1,5 +1,4 @@
-import { Socket, io } from "socket.io-client";
-import EventEmitter from "eventemitter3";
+import { Debug } from "@sc07-canvas/lib/debug";
import {
type AuthSession,
type ClientConfig,
@@ -9,10 +8,12 @@ import {
type ServerToClientEvents,
type Subscription,
} from "@sc07-canvas/lib/net";
+import EventEmitter from "eventemitter3";
import { toast } from "react-toastify";
+import { io,Socket } from "socket.io-client";
+
import { handleAlert, handleDismiss } from "./alerts";
import { CaptchaService } from "./captcha";
-import { Debug } from "@sc07-canvas/lib/debug";
export interface INetworkEvents {
connected: () => void;
@@ -41,6 +42,7 @@ export interface INetworkEvents {
| { available: true; expireAt: number; undo: boolean },
) => void;
placeSuccessful: () => void;
+ placeFailed: () => void;
heatmap: (heatmap: string) => void;
}
diff --git a/packages/client/src/lib/palette.ts b/packages/client/src/lib/palette.ts
index 8a68370cf7cf8a4c86cc37c4090344b45a220b4a..adb42fba52025c8a2638f0ae5eee016f80a87a66 100644
--- a/packages/client/src/lib/palette.ts
+++ b/packages/client/src/lib/palette.ts
@@ -1,7 +1,8 @@
import type { PaletteColor } from "@sc07-canvas/lib/net";
+import EventEmitter from "eventemitter3";
+
import { CanvasCore } from "./canvas";
import type { ExtractMethods } from "./utils";
-import EventEmitter from "eventemitter3";
type GetMethodFromClass<
T extends new (...args: any) => any,
diff --git a/packages/client/src/lib/renderer.ts b/packages/client/src/lib/renderer.ts
index 70e61787225a1773e04a64f108793e71968074af..e1b0ee766b7dec82cceed46d81e78394db693a97 100644
--- a/packages/client/src/lib/renderer.ts
+++ b/packages/client/src/lib/renderer.ts
@@ -1,4 +1,6 @@
+import EventEmitter from "eventemitter3";
import { toast } from "react-toastify";
+
import RenderWorker from "../worker/render.worker?worker";
import {
type CanvasPixel,
@@ -7,7 +9,6 @@ import {
type RendererEvents,
} from "./canvasRenderer";
import { type ExtractMethods } from "./utils";
-import EventEmitter from "eventemitter3";
const hasWorkerSupport =
typeof Worker !== "undefined" && !localStorage.getItem("no_workers");
diff --git a/packages/client/src/lib/router.ts b/packages/client/src/lib/router.ts
index 4cb6fd99d1438240c960b42d77ed37f2be915dae..89c685a533dfaca5355b178e40f11dca35f0f532 100644
--- a/packages/client/src/lib/router.ts
+++ b/packages/client/src/lib/router.ts
@@ -1,7 +1,8 @@
import { PanZoom } from "@sc07-canvas/lib/renderer";
-import { CanvasCore } from "./canvas";
-import throttle from "lodash.throttle";
import EventEmitter from "eventemitter3";
+import throttle from "lodash.throttle";
+
+import { CanvasCore } from "./canvas";
import { type TemplateStyle, TemplateStyles } from "./template";
const CLIENT_PARAMS = {
diff --git a/packages/client/src/lib/template.ts b/packages/client/src/lib/template.ts
index 95f86fda7c1007c903962b6d43d9a1b1fe968086..cab306c8c45ee16b13afa64fe1ff4f7766803259 100644
--- a/packages/client/src/lib/template.ts
+++ b/packages/client/src/lib/template.ts
@@ -1,7 +1,8 @@
-import EventEmitter from "eventemitter3";
-import { WebGLUtils } from "./webgl";
import { type ClientConfig } from "@sc07-canvas/lib/net";
+import EventEmitter from "eventemitter3";
+
import { rgbToHex } from "./utils";
+import { WebGLUtils } from "./webgl";
export interface ITemplateState {
url: string;
diff --git a/packages/client/src/lib/utils.ts b/packages/client/src/lib/utils.ts
index 230cb49349121589cb90fabf03ad366be2b93063..1f8a92a0dc38e9addc6e54aca4feeb029409c87c 100644
--- a/packages/client/src/lib/utils.ts
+++ b/packages/client/src/lib/utils.ts
@@ -1,10 +1,11 @@
-import { toast } from "react-toastify";
-import createClient from "openapi-fetch";
-import { Renderer } from "./renderer";
-import { Debug } from "@sc07-canvas/lib/debug";
import type { EError, RestAPI } from "@sc07-canvas/lib";
+import { Debug } from "@sc07-canvas/lib/debug";
+import createClient from "openapi-fetch";
+import { toast } from "react-toastify";
import { createQueryHook } from "swr-openapi";
+import { Renderer } from "./renderer";
+
let _renderer: Renderer;
/**
diff --git a/packages/client/src/style.scss b/packages/client/src/style.scss
index 8b4ca1e89eef00fa38d875ca9c5b59cb7a1ff57d..9ce0243e6a0f70d6581487719d40d3defe4fb14f 100644
--- a/packages/client/src/style.scss
+++ b/packages/client/src/style.scss
@@ -583,6 +583,343 @@ main {
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.24);
}
+body.collaborative-cursors-active {
+ cursor: var(--collaborative-cursor-default);
+}
+
+body.collaborative-cursors-active a[href]:not([aria-disabled="true"]),
+body.collaborative-cursors-active
+ button:not(:disabled):not([data-disabled="true"]),
+body.collaborative-cursors-active select:not(:disabled),
+body.collaborative-cursors-active input[type="button"]:not(:disabled),
+body.collaborative-cursors-active input[type="submit"]:not(:disabled),
+body.collaborative-cursors-active input[type="reset"]:not(:disabled),
+body.collaborative-cursors-active input[type="checkbox"]:not(:disabled),
+body.collaborative-cursors-active input[type="radio"]:not(:disabled),
+body.collaborative-cursors-active input[type="range"]:not(:disabled),
+body.collaborative-cursors-active input[type="color"]:not(:disabled),
+body.collaborative-cursors-active
+ [role="button"]:not([aria-disabled="true"]):not([data-disabled="true"]),
+body.collaborative-cursors-active
+ [role="menuitem"]:not([aria-disabled="true"]):not([data-disabled="true"]),
+body.collaborative-cursors-active
+ [role="switch"]:not([aria-disabled="true"]):not([data-disabled="true"]),
+body.collaborative-cursors-active
+ [role="tab"]:not([aria-disabled="true"]):not([data-disabled="true"]),
+body.collaborative-cursors-active
+ [role="option"]:not([aria-disabled="true"]):not([data-disabled="true"]),
+body.collaborative-cursors-active
+ [role="checkbox"]:not([aria-disabled="true"]):not([data-disabled="true"]) {
+ cursor: var(--collaborative-cursor-pointer) !important;
+}
+
+body.collaborative-cursors-active
+ input:not([type="button"]):not([type="submit"]):not([type="reset"]):not(
+ [type="checkbox"]
+ ):not([type="radio"]):not([type="range"]):not([type="color"]),
+body.collaborative-cursors-active textarea,
+body.collaborative-cursors-active [contenteditable="true"],
+body.collaborative-cursors-active [role="textbox"] {
+ cursor: var(--collaborative-cursor-text) !important;
+}
+
+body.collaborative-cursors-active button:disabled,
+body.collaborative-cursors-active input:disabled,
+body.collaborative-cursors-active select:disabled,
+body.collaborative-cursors-active [aria-disabled="true"],
+body.collaborative-cursors-active [data-disabled="true"] {
+ cursor: var(--collaborative-cursor-disabled) !important;
+}
+
+.collaborative-cursor-canvas,
+.collaborative-cursor-reaction-layer {
+ position: fixed;
+ inset: 0;
+ z-index: 10;
+ pointer-events: none;
+}
+
+.collaborative-cursor-reaction-layer {
+ overflow: visible;
+}
+
+.collaborative-cursor-reaction-anchor {
+ position: fixed;
+ top: 0;
+ left: 0;
+ width: 0;
+ height: 0;
+ transform-origin: 0 0;
+ pointer-events: none;
+ will-change: transform;
+}
+
+.collaborative-cursor {
+ --cursor-outline: #111111;
+ top: 0;
+ left: 0;
+ width: 0;
+ height: 0;
+ pointer-events: none;
+ user-select: none;
+ will-change: transform;
+}
+
+.collaborative-cursor--local {
+ position: fixed;
+ z-index: 30;
+ display: none;
+}
+
+.collaborative-cursor-visual {
+ position: relative;
+ width: 18px;
+ height: 22px;
+ transform-origin: 0 0;
+}
+
+.collaborative-cursor--local .collaborative-cursor-arrow {
+ display: none;
+}
+
+.collaborative-cursor--local.is-placing .collaborative-cursor-arrow {
+ display: block;
+}
+
+.collaborative-cursor-error {
+ display: none;
+ position: absolute;
+ top: 2px;
+ left: 0;
+ width: 14px;
+ max-width: none;
+ height: 14px;
+ overflow: visible;
+ filter: drop-shadow(0 1px 1px rgb(0 0 0 / 22%));
+}
+
+.collaborative-cursor--local.is-error .collaborative-cursor-error {
+ display: block;
+}
+
+.collaborative-cursor.is-placing .collaborative-cursor-visual {
+ animation: collaborative-cursor-place var(--cursor-placement-duration)
+ cubic-bezier(0.2, 0.8, 0.2, 1);
+}
+
+@keyframes collaborative-cursor-place {
+ 0% {
+ rotate: 0deg;
+ scale: 1;
+ }
+
+ 48% {
+ rotate: -14deg;
+ scale: 0.9;
+ }
+
+ 100% {
+ rotate: 0deg;
+ scale: 1;
+ }
+}
+
+.collaborative-cursor-arrow {
+ position: absolute;
+ top: -1px;
+ left: -1px;
+ width: 18px;
+ max-width: none;
+ height: 22px;
+ overflow: visible;
+}
+
+.collaborative-cursor-reactions {
+ position: absolute;
+ z-index: 3;
+ top: -2px;
+ left: 4px;
+ width: 1px;
+ height: 1px;
+}
+
+.collaborative-cursor-reaction-cooldown {
+ position: absolute;
+ z-index: 2;
+ top: -17px;
+ left: -4px;
+ width: 14px;
+ height: 12px;
+ overflow: visible;
+ pointer-events: none;
+ opacity: 0;
+ transform: translate(5px, 14px) scale(0);
+ transform-origin: 50% 100%;
+}
+
+.collaborative-cursor-reaction-cooldown.is-active {
+ animation: collaborative-cursor-reaction-cooldown-feedback
+ var(--cursor-reaction-duration) linear forwards;
+}
+
+.collaborative-cursor-reaction-cooldown-label {
+ position: absolute;
+ bottom: 5px;
+ left: 50%;
+ color: var(--cursor-primary, #ffffff);
+ font-size: 8px;
+ font-weight: 700;
+ line-height: 1;
+ text-shadow:
+ -0.5px 0 var(--cursor-outline, #111111),
+ 0.5px 0 var(--cursor-outline, #111111),
+ 0 -0.5px var(--cursor-outline, #111111),
+ 0 0.5px var(--cursor-outline, #111111);
+ white-space: nowrap;
+ transform: translateX(-50%);
+}
+
+.collaborative-cursor-reaction-cooldown-track {
+ position: absolute;
+ bottom: 0;
+ left: 0;
+ width: 14px;
+ height: 3px;
+ overflow: hidden;
+ box-sizing: border-box;
+ border: 0.6px solid var(--cursor-outline, #111111);
+ border-radius: 2px;
+ background: rgb(127 127 127 / 35%);
+}
+
+.collaborative-cursor-reaction-cooldown-fill {
+ position: absolute;
+ inset: 0;
+ background: var(--cursor-primary, #ffffff);
+ transform-origin: 0 50%;
+}
+
+.collaborative-cursor-reaction-cooldown.is-active
+ .collaborative-cursor-reaction-cooldown-fill {
+ animation: collaborative-cursor-reaction-cooldown
+ var(--cursor-reaction-duration) linear forwards;
+}
+
+@keyframes collaborative-cursor-reaction-cooldown-feedback {
+ 0% {
+ opacity: 0;
+ transform: translate(5px, 14px) scale(0);
+ animation-timing-function: cubic-bezier(0.16, 0.78, 0.3, 1);
+ }
+
+ 28% {
+ opacity: 1;
+ transform: translate(0, 0) scale(1);
+ }
+
+ 74% {
+ opacity: 1;
+ transform: translate(0, 0) scale(1);
+ animation-timing-function: ease-in;
+ }
+
+ 100% {
+ opacity: 0;
+ transform: translate(0, 0) scale(0);
+ }
+}
+
+@keyframes collaborative-cursor-reaction-cooldown {
+ 0%,
+ 28% {
+ transform: scaleX(1);
+ }
+
+ 100% {
+ transform: scaleX(0);
+ }
+}
+
+.collaborative-cursor-reaction {
+ position: absolute;
+ bottom: 0;
+ left: 0;
+ display: block;
+ width: 22px;
+ max-width: none;
+ height: 22px;
+ object-fit: contain;
+ filter: drop-shadow(0 1px 2px rgb(0 0 0 / 45%));
+ transform-origin: 50% 50%;
+ animation: collaborative-cursor-reaction var(--cursor-reaction-duration)
+ linear forwards;
+}
+
+@keyframes collaborative-cursor-reaction {
+ 0% {
+ opacity: 0;
+ transform: translate(-50%, 23px) scale(0);
+ animation-timing-function: cubic-bezier(0.16, 0.78, 0.3, 1);
+ }
+
+ 30% {
+ opacity: 1;
+ transform: translate(-50%, -5px) scale(1);
+ }
+
+ 74% {
+ opacity: 1;
+ transform: translate(-50%, -6px) scale(1);
+ animation-timing-function: ease-in;
+ }
+
+ 100% {
+ opacity: 0;
+ transform: translate(-50%, -5px) scale(0);
+ }
+}
+
+.collaborative-cursor-reaction.reduce-motion {
+ animation: collaborative-cursor-reaction-reduced 600ms ease-out forwards;
+}
+
+@keyframes collaborative-cursor-reaction-reduced {
+ 0%,
+ 100% {
+ opacity: 0;
+ transform: translate(-50%, -5px);
+ }
+
+ 20%,
+ 80% {
+ opacity: 1;
+ transform: translate(-50%, -6px);
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .collaborative-cursor.is-placing .collaborative-cursor-visual {
+ animation: none;
+ }
+
+ .collaborative-cursor-reaction-cooldown.is-active {
+ animation-name: collaborative-cursor-reaction-cooldown-feedback-reduced;
+ }
+}
+
+@keyframes collaborative-cursor-reaction-cooldown-feedback-reduced {
+ 0%,
+ 74% {
+ opacity: 1;
+ transform: translate(0, 0) scale(1);
+ }
+
+ 100% {
+ opacity: 0;
+ transform: translate(0, 0) scale(1);
+ }
+}
+
.canvas-cursor-preview {
position: absolute;
width: 1px;
@@ -604,44 +941,52 @@ main {
&::before {
content: "";
background: rgba(0, 0, 0, 0.42);
- transform: scaleY(0);
- transform-origin: bottom;
+ inset: auto 0 0;
+ height: 0;
+ opacity: 0;
}
&::after {
- content: "?";
- display: flex;
- align-items: center;
- justify-content: center;
- color: #fff;
- font-size: 0.72px;
- font-weight: 800;
- line-height: 1;
+ content: "";
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 10 14'%3E%3Cpath d='M2 4c.2-2 1.5-3 3.3-3C7.5 1 9 2.2 9 4.2c0 1.7-.9 2.5-2 3.2-1 .7-1.4 1.2-1.4 2.1H3.8c0-1.7.7-2.5 1.8-3.3.9-.7 1.4-1.1 1.4-2 0-.8-.6-1.4-1.7-1.4-1 0-1.5.6-1.6 1.5L2 4Zm1.7 8.2c0-.7.5-1.2 1.2-1.2s1.2.5 1.2 1.2-.5 1.2-1.2 1.2-1.2-.5-1.2-1.2Z' fill='white' stroke='%23111' stroke-width='.65' paint-order='stroke'/%3E%3C/svg%3E");
+ background-position: calc(50% - 0.02px) calc(50% - 0.02px);
+ background-repeat: no-repeat;
+ background-size: 0.68px 0.62px;
+ opacity: 0;
+ transition: none;
+ }
+
+ &.is-no-color {
+ background-color: transparent;
opacity: 0;
- text-shadow: 0 0 0.12px #000;
- transform: scale(0.45);
- transition:
- opacity 0.16s ease,
- transform 0.16s ease;
}
&.is-inspect-holding {
- opacity: 0.78;
- box-shadow:
- 0 0 0 0.07px rgba(255, 255, 255, 0.9),
- 0 0 0 0.14px rgba(0, 0, 0, 0.58);
+ animation: inspect-hold-surface var(--inspect-hold-surface-duration)
+ ease-out var(--inspect-hold-delay) forwards;
&::before {
- animation: inspect-hold-fill var(--inspect-hold-duration) linear forwards;
+ animation: inspect-hold-fill var(--inspect-hold-fill-duration) linear
+ var(--inspect-hold-delay) forwards;
}
}
&.is-inspect-ready {
- opacity: 0.92;
+ &::after {
+ animation: inspect-question-in 120ms ease-out forwards;
+ }
+ }
+
+ &.is-inspect-ending {
+ animation: inspect-hold-out var(--inspect-end-duration) ease-in forwards;
+
+ &::before {
+ animation: inspect-fill-out var(--inspect-end-duration) ease-in forwards;
+ }
&::after {
- opacity: 1;
- transform: scale(1);
+ animation: inspect-question-out var(--inspect-end-duration) ease-in
+ forwards;
}
}
}
@@ -659,8 +1004,11 @@ body.is-picking-color .canvas-cursor-preview {
line-height: 1;
pointer-events: none;
text-shadow:
- 0 1px 1px rgba(0, 0, 0, 0.6),
- 0 0 2px rgba(0, 0, 0, 0.32);
+ -0.6px 0 var(--feedback-outline, #111),
+ 0.6px 0 var(--feedback-outline, #111),
+ 0 -0.6px var(--feedback-outline, #111),
+ 0 0.6px var(--feedback-outline, #111),
+ 0 1px 2px rgba(0, 0, 0, 0.32);
white-space: nowrap;
animation: color-picked-feedback 850ms ease-out forwards;
}
@@ -693,7 +1041,6 @@ body.is-picking-color .canvas-cursor-preview {
.header-button-label,
#main-ui > *,
.canvas-cursor-preview,
- .canvas-cursor-preview::after,
#pallete .pallete-color,
#pallete .pallete-color--deselect,
#pallete .pallete-color--pick,
@@ -712,8 +1059,7 @@ body.is-picking-color .canvas-cursor-preview {
#pallete .pallete-color.secondary,
#overlay-switcher .overlay-switcher-item:hover .overlay-switcher-visual,
#overlay-switcher .overlay-switcher-item.is-active
- .overlay-switcher-visual,
- .canvas-cursor-preview.is-inspect-ready::after {
+ .overlay-switcher-visual {
transform: none !important;
}
@@ -725,16 +1071,108 @@ body.is-picking-color .canvas-cursor-preview {
scale: none !important;
}
- .canvas-cursor-preview.is-inspect-holding::before,
.color-picked-feedback,
.palette-tooltip {
animation: none !important;
}
+
+ .canvas-cursor-preview.is-inspect-ready::after {
+ animation: none;
+ background-size: 0.68px 0.62px;
+ opacity: 1;
+ }
+
+ .canvas-cursor-preview.is-inspect-ending,
+ .canvas-cursor-preview.is-inspect-ending::before,
+ .canvas-cursor-preview.is-inspect-ending::after {
+ animation: none;
+ opacity: 0;
+ }
+}
+
+@keyframes inspect-hold-out {
+ from {
+ opacity: var(--inspect-release-opacity, 0.78);
+ box-shadow: var(
+ --inspect-release-shadow,
+ 0 0 0 0.07px rgba(255, 255, 255, 0.9),
+ 0 0 0 0.14px rgba(0, 0, 0, 0.58)
+ );
+ }
+
+ to {
+ opacity: 0.5;
+ box-shadow:
+ 0 0 0 0.07px rgba(255, 255, 255, 0),
+ 0 0 0 0.14px rgba(0, 0, 0, 0);
+ }
+}
+
+@keyframes inspect-fill-out {
+ from {
+ height: var(--inspect-release-fill-height, 100%);
+ opacity: var(--inspect-release-fill-opacity, 1);
+ }
+
+ to {
+ height: var(--inspect-release-fill-height, 100%);
+ opacity: 0;
+ }
+}
+
+@keyframes inspect-question-out {
+ from {
+ background-size: var(--inspect-release-question-size, 0.68px 0.62px);
+ opacity: var(--inspect-release-question-opacity, 1);
+ }
+
+ to {
+ background-size: 0.5px 0.46px;
+ opacity: 0;
+ }
+}
+
+@keyframes inspect-question-in {
+ from {
+ background-size: 0.34px 0.31px;
+ opacity: 0;
+ }
+
+ to {
+ background-size: 0.68px 0.62px;
+ opacity: 1;
+ }
+}
+
+@keyframes inspect-hold-surface {
+ from {
+ opacity: 0.5;
+ box-shadow:
+ 0 0 0 0.02px rgba(255, 255, 255, 0),
+ 0 0 0 0.04px rgba(0, 0, 0, 0);
+ }
+
+ to {
+ opacity: 0.78;
+ box-shadow:
+ 0 0 0 0.07px rgba(255, 255, 255, 0.9),
+ 0 0 0 0.14px rgba(0, 0, 0, 0.58);
+ }
}
@keyframes inspect-hold-fill {
+ from {
+ height: 0;
+ opacity: 0;
+ }
+
+ 35% {
+ opacity: 1;
+ }
+
to {
- transform: scaleY(1);
+ height: 100%;
+ opacity: 1;
}
}
diff --git a/packages/client/tailwind.config.js b/packages/client/tailwind.config.js
index 3f368175bacad5e0a5b02788950ee6e38ab5313a..4b8938bceb9aa73eba3620ebb10dccf716b6e223 100644
--- a/packages/client/tailwind.config.js
+++ b/packages/client/tailwind.config.js
@@ -1,3 +1,4 @@
+/* eslint-disable */
const { heroui } = require("@heroui/react");
/** @type {import('tailwindcss').Config} */
diff --git a/packages/client/vite.config.js b/packages/client/vite.config.js
index 31c11a40c4252cb6bc986a551cd8d1300f1c09bc..7dc695affa41d06ca87df78ec76c9dc332b3416c 100644
--- a/packages/client/vite.config.js
+++ b/packages/client/vite.config.js
@@ -1,11 +1,13 @@
-import { defineConfig, loadEnv } from "vite";
-import react from "@vitejs/plugin-react";
-import { sentryVitePlugin } from "@sentry/vite-plugin";
-import banner from "vite-plugin-banner";
+/* eslint-disable */
import fs from "node:fs";
-import tailwindcss from "@tailwindcss/vite";
import path from "node:path";
+import { sentryVitePlugin } from "@sentry/vite-plugin";
+import tailwindcss from "@tailwindcss/vite";
+import react from "@vitejs/plugin-react";
+import { defineConfig, loadEnv } from "vite";
+import banner from "vite-plugin-banner";
+
export default defineConfig(({ mode }) => {
process.env = { ...process.env, ...loadEnv(mode, process.cwd(), "") };
diff --git a/packages/lib/package.json b/packages/lib/package.json
index 19cd45a2b7f9201c1d1f746bf60e597f70f81a8b..5d810cef4842491f933c63f70f11e3c097f56df4 100644
--- a/packages/lib/package.json
+++ b/packages/lib/package.json
@@ -2,39 +2,44 @@
"name": "@sc07-canvas/lib",
"version": "1.0.0",
"private": true,
- "main": "dist/index.js",
- "types": "dist/index.d.ts",
+ "main": "./dist/index.cjs",
+ "module": "./dist/index.mjs",
+ "types": "./dist/index.d.ts",
"exports": {
".": {
- "default": "./dist/index.js",
- "types": "./dist/index.d.ts"
+ "types": "./dist/index.d.ts",
+ "import": "./dist/index.mjs",
+ "require": "./dist/index.cjs"
},
"./renderer": {
- "default": "./dist/renderer.js",
- "types": "./dist/renderer.d.ts"
+ "types": "./dist/renderer/index.d.ts",
+ "import": "./dist/renderer.mjs",
+ "require": "./dist/renderer.cjs"
},
"./net": {
- "default": "./dist/net.js",
- "types": "./dist/net.d.ts"
+ "types": "./dist/net.d.ts",
+ "import": "./dist/net.mjs",
+ "require": "./dist/net.cjs"
},
"./debug": {
- "default": "./dist/debug.js",
- "types": "./dist/debug.d.ts"
+ "types": "./dist/debug.d.ts",
+ "import": "./dist/debug.mjs",
+ "require": "./dist/debug.cjs"
},
"./canvas": {
- "default": "./dist/canvas.js",
- "types": "./dist/canvas.d.ts"
+ "types": "./dist/canvas.d.ts",
+ "import": "./dist/canvas.mjs",
+ "require": "./dist/canvas.cjs"
}
},
"files": [
"dist"
],
"scripts": {
- "build": "tsc && rolldown -c rolldown.config.ts"
+ "build": "rolldown -c rolldown.config.ts && tsc"
},
"dependencies": {
- "eventemitter3": "^5.0.4",
- "rolldown-plugin-dts": "^0.23.2"
+ "eventemitter3": "^5.0.4"
},
"devDependencies": {
"@tsconfig/recommended": "^1.0.13",
diff --git a/packages/lib/rolldown.config.ts b/packages/lib/rolldown.config.ts
index abc29788aca28ff98a04bd17fb4d0fdee8986677..19045a8526bb3c4c6fb16bb90ece23c185710cbb 100644
--- a/packages/lib/rolldown.config.ts
+++ b/packages/lib/rolldown.config.ts
@@ -1,20 +1,34 @@
import { defineConfig } from "rolldown";
-import { dts } from "rolldown-plugin-dts";
-export default defineConfig({
- input: {
- index: "src/index.ts",
- renderer: "src/renderer/index.ts",
- net: "src/net.ts",
- debug: "src/debug.ts",
- canvas: "src/canvas.ts",
- },
- plugins: [dts()],
- output: [
- {
+const input = {
+ index: "src/index.ts",
+ renderer: "src/renderer/index.ts",
+ net: "src/net.ts",
+ debug: "src/debug.ts",
+ canvas: "src/canvas.ts",
+};
+
+const external = [/node_modules/, "react", "react-dom", "eventemitter3"];
+
+export default defineConfig([
+ {
+ input,
+ output: {
dir: "dist",
format: "esm",
+ entryFileNames: "[name].mjs",
+ sourcemap: true,
},
- ],
- external: [/node:/, "react", "react-dom"],
-});
+ external,
+ },
+ {
+ input,
+ output: {
+ dir: "dist",
+ format: "cjs",
+ entryFileNames: "[name].cjs",
+ sourcemap: true,
+ },
+ external,
+ },
+]);
diff --git a/packages/lib/src/debug.ts b/packages/lib/src/debug.ts
index 1f8967ee15b1af441280103890acde7a5c02c1da..d0d96579a212f759e8271bed03dba2e232eaddcf 100644
--- a/packages/lib/src/debug.ts
+++ b/packages/lib/src/debug.ts
@@ -6,6 +6,7 @@ interface DebugEvents {
interface DebugArgs {
point: [x: number, y: number, id?: string];
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
text: [str: any];
}
@@ -44,20 +45,20 @@ class FlagManager extends EventEmitter {
new ExperimentFlag(
"PANZOOM_PINCH_TRANSFORM_1",
false,
- FlagCategory.Renderer
+ FlagCategory.Renderer,
),
new ExperimentFlag(
"PANZOOM_PINCH_TRANSFORM_2",
false,
- FlagCategory.Renderer
+ FlagCategory.Renderer,
),
// DEBUG MESSAGES
new ExperimentFlag(
"PANZOOM_PINCH_DEBUG_MESSAGES",
false,
- FlagCategory.DebugMessages
- )
+ FlagCategory.DebugMessages,
+ ),
);
}
@@ -100,11 +101,14 @@ class FlagManager extends EventEmitter {
*/
class Debugcl extends EventEmitter {
readonly flags = new FlagManager();
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
_getRenderer: any;
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
_network: any;
/**
* name -> instance
*/
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
readonly controllers: Map = new Map();
constructor() {
@@ -115,7 +119,7 @@ class Debugcl extends EventEmitter {
const wind = window.open("about:blank", "_blank");
if (!wind) {
alert(
- "Failed to open debug tab. Is your anti-popup too powerful? Or did this get triggered from not a trusted event"
+ "Failed to open debug tab. Is your anti-popup too powerful? Or did this get triggered from not a trusted event",
);
return;
}
@@ -131,7 +135,7 @@ class Debugcl extends EventEmitter {
.map((f) => f.id),
},
null,
- 2
+ 2,
)}
`);
wind.document.close();
@@ -160,7 +164,7 @@ class Debugcl extends EventEmitter {
document.getElementById("debug-" + id)!.style.left = x + "px";
return;
}
- let el = document.createElement("div");
+ const el = document.createElement("div");
if (id) el.id = "debug-" + id;
el.classList.add("debug-point");
el.style.setProperty("top", y + "px");
@@ -173,7 +177,7 @@ class Debugcl extends EventEmitter {
// create debug box in canvas-meta if it doesn't exist
if (!document.getElementById("canvas-meta-debug")) {
- let debugBox = document.createElement("div");
+ const debugBox = document.createElement("div");
debugBox.id = "canvas-meta-debug";
debugBox.style.whiteSpace = "pre";
debugBox.style.unicodeBidi = "embed";
@@ -198,7 +202,7 @@ class Debugcl extends EventEmitter {
const Debug = new Debugcl();
-// @ts-ignore
+// @ts-expect-error global export
window.Debug = Debug;
export { Debug };
diff --git a/packages/lib/src/index.ts b/packages/lib/src/index.ts
index e606a8ad7e53ade946a202bd5a0515194ee4aeaf..283abf12b56ebe5d71c5fc65b27c980cb2c252a2 100644
--- a/packages/lib/src/index.ts
+++ b/packages/lib/src/index.ts
@@ -1,6 +1,6 @@
-import * as net from "./net";
-import { CanvasLib } from "./canvas";
import type * as RestAPI from "./api-schema";
+import { CanvasLib } from "./canvas";
+import * as net from "./net";
/**
* Expanded Error
@@ -31,4 +31,4 @@ class EError extends Error {
}
}
-export { net, CanvasLib, type RestAPI, EError };
+export { CanvasLib, EError,net, type RestAPI };
diff --git a/packages/lib/src/net.ts b/packages/lib/src/net.ts
index f74470db517c95174b1c49d8a48fab983ed12fa7..83cbb0252500c9db1d520c53df1733fed1755b1f 100644
--- a/packages/lib/src/net.ts
+++ b/packages/lib/src/net.ts
@@ -4,6 +4,142 @@ import type { Challenge } from "altcha-lib/types";
export type Subscription = "heatmap";
+export const CANVAS_CHUNK_SIZE = 100;
+export const CURSOR_REGION_SIZES = [CANVAS_CHUNK_SIZE, 200, 400, 800] as const;
+export const CURSOR_MAX_VIEW_REGIONS = 16;
+export const CURSOR_MAX_WORLD_COORDINATE = 1000000;
+
+export type CursorRegionSize = (typeof CURSOR_REGION_SIZES)[number];
+export type CursorId = number;
+
+export interface CursorMove {
+ x: number;
+ y: number;
+ zoom: number;
+ color?: number;
+}
+
+export interface CursorViewport {
+ left: number;
+ top: number;
+ right: number;
+ bottom: number;
+ zoom: number;
+ reactions: boolean;
+}
+
+export type CursorViewMode = "regions" | "overview";
+
+export interface CursorView {
+ mode: CursorViewMode;
+ regionSize: CursorRegionSize;
+ minRegionX: number;
+ maxRegionX: number;
+ minRegionY: number;
+ maxRegionY: number;
+}
+
+export type CursorRemoval = [id: CursorId, sequence: number];
+
+export type CursorWireState = [
+ id: CursorId,
+ sequence: number,
+ x: number,
+ y: number,
+ zoom: number,
+ color: number | null,
+];
+
+export type CursorFrame = ArrayBuffer;
+
+export interface CursorSnapshot {
+ selfId: CursorId;
+ view: CursorView;
+ frame: CursorFrame;
+}
+
+export interface CursorServerRemoval {
+ id: CursorId;
+ sequence: number;
+ x: number;
+ y: number;
+ zoom: number;
+}
+
+export interface CursorServerSync {
+ source: string;
+ keyframe: boolean;
+ cursors: CursorWireState[];
+ removals: CursorServerRemoval[];
+}
+
+export interface CursorServerAction {
+ source: string;
+ type: "reaction" | "place";
+ id: CursorId;
+ sequence: number;
+ x: number;
+ y: number;
+ zoom: number;
+ reaction?: number;
+}
+
+export interface CursorReactionCooldown {
+ level: "second" | "minute" | "hour";
+ until: number;
+}
+
+export type CursorReactionResult =
+ | { success: true; cooldown?: CursorReactionCooldown }
+ | { success: false; cooldown?: CursorReactionCooldown };
+
+export const getCursorRegion = (
+ regionSize: CursorRegionSize,
+ x: number,
+ y: number,
+) => ({
+ x: Math.floor(x / regionSize),
+ y: Math.floor(y / regionSize),
+});
+
+export const getCursorView = (
+ viewport: Pick,
+): CursorView | undefined => {
+ const left = Math.min(viewport.left, viewport.right);
+ const right = Math.max(viewport.left, viewport.right);
+ const top = Math.min(viewport.top, viewport.bottom);
+ const bottom = Math.max(viewport.top, viewport.bottom);
+
+ for (const regionSize of CURSOR_REGION_SIZES) {
+ const minRegionX = Math.floor(left / regionSize);
+ const maxRegionX = Math.floor(right / regionSize);
+ const minRegionY = Math.floor(top / regionSize);
+ const maxRegionY = Math.floor(bottom / regionSize);
+ const count = (maxRegionX - minRegionX + 1) * (maxRegionY - minRegionY + 1);
+
+ if (count <= CURSOR_MAX_VIEW_REGIONS) {
+ return {
+ mode: "regions",
+ regionSize,
+ minRegionX,
+ maxRegionX,
+ minRegionY,
+ maxRegionY,
+ };
+ }
+ }
+
+ const regionSize: CursorRegionSize = 800;
+ return {
+ mode: "regions",
+ regionSize,
+ minRegionX: Math.floor(left / regionSize),
+ maxRegionX: Math.floor(right / regionSize),
+ minRegionY: Math.floor(top / regionSize),
+ maxRegionY: Math.floor(bottom / regionSize),
+ };
+};
+
export interface ServerToClientEvents {
canvas: (
start: [x: number, y: number],
@@ -37,6 +173,15 @@ export interface ServerToClientEvents {
event_hype: (emoji: string, amount: number) => void;
+ cursorFrame: (frame: CursorFrame) => void;
+ cursorRemove: (removals: CursorRemoval[]) => void;
+ cursorReaction: (reaction: {
+ id: CursorId;
+ reaction: number;
+ sequence: number;
+ }) => void;
+ cursorPlace: (placement: { id: CursorId; sequence: number }) => void;
+
/* --- subscribe events --- */
/**
@@ -68,7 +213,7 @@ export interface ClientToServerEvents {
undo: (
ack: (
_: PacketAck<
- {},
+ object,
"canvas_frozen" | "no_user" | "unavailable" | "pixel_covered"
>,
) => void,
@@ -77,6 +222,21 @@ export interface ClientToServerEvents {
subscribe: (topic: Subscription) => void;
unsubscribe: (topic: Subscription) => void;
+ cursorViewport: (
+ viewport: CursorViewport,
+ ack: (snapshot: CursorSnapshot | null) => void,
+ ) => void;
+ cursorUnsubscribe: () => void;
+ cursorMove: (cursor: CursorMove) => void;
+ cursorSettle: (cursor: CursorMove) => void;
+ cursorZoom: (zoom: number) => void;
+ cursorHide: () => void;
+ cursorReaction: (
+ reaction: number,
+ ack: (result: CursorReactionResult) => void,
+ ) => void;
+
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
debug: (key: string, ...rest: any[]) => void;
}
@@ -136,7 +296,7 @@ export interface IAlertKeyedMessages {
*/
until: string;
};
- unbanned: {};
+ unbanned: object;
}
export type IAlert = {
diff --git a/packages/lib/src/renderer/PanZoom.ts b/packages/lib/src/renderer/PanZoom.ts
index da1d83f0bf6d79a0025687137dd986701f5f0763..ccf468c57747791f64bd2d3b58c18c236668fe25 100644
--- a/packages/lib/src/renderer/PanZoom.ts
+++ b/packages/lib/src/renderer/PanZoom.ts
@@ -1,15 +1,13 @@
import EventEmitter from "eventemitter3";
+
+import { Debug } from "../debug";
+import { Panning } from "./lib/panning.utils";
import {
calculatePinchZoom,
calculateTouchMidPoint,
getTouchDistance,
} from "./lib/pinch.utils";
-import {
- checkZoomBounds,
- handleCalculateZoomPositions,
-} from "./lib/zoom.utils";
-import { Panning } from "./lib/panning.utils";
-import { Debug } from "../debug";
+import { checkZoomBounds } from "./lib/zoom.utils";
interface TransformState {
/**
@@ -41,6 +39,11 @@ interface Flags {
* @see https://caniuse.com/css-zoom
*/
useZoom: boolean;
+
+ /**
+ * Unused but defined in detectFlags()
+ */
+ haveImageRendering: boolean;
}
interface TouchState {
@@ -96,7 +99,7 @@ interface MouseState {
}
const MOUSE_CLICK_MOVE_THRESHOLD = 5;
-export const MOUSE_LONG_PRESS_DELAY = 500;
+export const MOUSE_LONG_PRESS_DELAY = 1100;
interface ISetup {
/**
@@ -124,6 +127,7 @@ export interface ClickEvent {
export interface HoverEvent {
clientX: number;
clientY: number;
+ target: EventTarget | null;
}
export interface ViewportMoveEvent {
@@ -147,8 +151,11 @@ interface PanZoomEvents {
export class PanZoom extends EventEmitter {
private initialized = false;
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
public $wrapper: HTMLDivElement = null as any;
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
public $zoom: HTMLDivElement = null as any;
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
public $move: HTMLDivElement = null as any;
public transform: TransformState;
@@ -194,6 +201,7 @@ export class PanZoom extends EventEmitter {
this.flags = {
useZoom: false,
+ haveImageRendering: false,
};
this.detectFlags();
@@ -217,7 +225,7 @@ export class PanZoom extends EventEmitter {
// use initial transform if it is set
// initialTransform is set from #setPosition() when PanZoom is not initalized
- let { x, y, scale } = this.setup.initialTransform;
+ const { x, y, scale } = this.setup.initialTransform;
this.transform.x = x;
this.transform.y = y;
@@ -315,8 +323,8 @@ export class PanZoom extends EventEmitter {
navigator.userAgent.match(/safari/i) &&
!navigator.userAgent.match(/chrome/i);
const msEdge = navigator.userAgent.indexOf("Edge") > -1;
- const possiblyMobile =
- window.innerWidth < 768 && navigator.userAgent.includes("Mobile");
+ // const possiblyMobile =
+ // window.innerWidth < 768 && navigator.userAgent.includes("Mobile");
if (iOSSafari) {
const iOS =
parseFloat(
@@ -343,6 +351,7 @@ export class PanZoom extends EventEmitter {
}
this.flags.useZoom = haveZoomRendering;
+ this.flags.haveImageRendering = haveImageRendering;
}
registerTouchEvents() {
@@ -443,7 +452,11 @@ export class PanZoom extends EventEmitter {
const dx = Math.abs(this.panning.x - touch.clientX);
const dy = Math.abs(this.panning.y - touch.clientY);
- if (Date.now() - this.touch.lastTouch < 500 && dx < 5 && dy < 5) {
+ if (
+ Date.now() - this.touch.lastTouch < MOUSE_LONG_PRESS_DELAY &&
+ dx < 5 &&
+ dy < 5
+ ) {
this.emit("click", {
clientX: touch.clientX,
clientY: touch.clientY,
@@ -455,7 +468,11 @@ export class PanZoom extends EventEmitter {
});
}
- if (Date.now() - this.touch.lastTouch > 500 && dx < 25 && dy < 25) {
+ if (
+ Date.now() - this.touch.lastTouch >= MOUSE_LONG_PRESS_DELAY &&
+ dx < 25 &&
+ dy < 25
+ ) {
this.emit("longPress", this.panning.x, this.panning.y);
}
}
@@ -491,7 +508,7 @@ export class PanZoom extends EventEmitter {
// one finger started from outside the wrapper
if (this.touch.pinchStartDistance === null) return;
- let el: HTMLElement = document.body;
+ const el: HTMLElement = document.body;
// switch (
// (document.getElementById("test-flag")! as HTMLSelectElement).value
// ) {
@@ -724,6 +741,7 @@ export class PanZoom extends EventEmitter {
this.emit("hover", {
clientX: e.clientX,
clientY: e.clientY,
+ target: e.target,
});
}
};
@@ -743,16 +761,14 @@ export class PanZoom extends EventEmitter {
const elapsed = Date.now() - mouseDown;
const didDrag = this.mouse.maxDistance >= MOUSE_CLICK_MOVE_THRESHOLD;
const didLongPress =
- e.button === 0 &&
- elapsed >= MOUSE_LONG_PRESS_DELAY &&
- !didDrag;
+ e.button === 0 && elapsed >= MOUSE_LONG_PRESS_DELAY && !didDrag;
this.mouse.suppressClick = didDrag || didLongPress;
if (elapsed < MOUSE_LONG_PRESS_DELAY && !didDrag) {
- // if the mouse was down for less than a half a second without moving,
+ // if the mouse was released before the long-press delay without moving,
// it's most likely intended to be a click
this.emit("click", {
- button: ["LCLICK", "MCLICK", "RCLICK"][e.button] as any,
+ button: (["LCLICK", "MCLICK", "RCLICK"] as const)[e.button],
clientX: e.clientX,
clientY: e.clientY,
diff --git a/packages/lib/src/renderer/PanZoomWrapper.tsx b/packages/lib/src/renderer/PanZoomWrapper.tsx
index eb9386d66821f393b9241c3a8d576c50ed898f0b..53c68f9965759b9c00e0a07fe3095f69183539d3 100644
--- a/packages/lib/src/renderer/PanZoomWrapper.tsx
+++ b/packages/lib/src/renderer/PanZoomWrapper.tsx
@@ -1,6 +1,7 @@
-import React, { useRef, useState, useEffect } from "react";
-import { RendererContext } from "./RendererContext";
+import React, { useEffect, useRef } from "react";
+
import { PanZoom } from "./PanZoom";
+import { RendererContext } from "./RendererContext";
export const PanZoomWrapper = ({
children,
@@ -36,7 +37,7 @@ export const PanZoomWrapper = ({
y: pos.y,
zoom: pos.zoom || 0,
},
- { suppressEmit: true }
+ { suppressEmit: true },
);
} else {
console.warn("Failed to set position via initialPosition; no pos");
diff --git a/packages/lib/src/renderer/RendererContext.tsx b/packages/lib/src/renderer/RendererContext.tsx
index 284bbea7ae4dd51c72b99592ba0e9d8d1295807c..16a0fa852ecbecedcf949b78f04de7fbb9cca923 100644
--- a/packages/lib/src/renderer/RendererContext.tsx
+++ b/packages/lib/src/renderer/RendererContext.tsx
@@ -1,4 +1,6 @@
import { createContext } from "react";
+
import { PanZoom } from "./PanZoom";
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const RendererContext = createContext(null as any);
diff --git a/packages/lib/src/renderer/index.ts b/packages/lib/src/renderer/index.ts
index 2752a59dee03f205c7b919dc1f3e2380ecb8f1e6..3d6c1d5bf2059aa5915ca19aca117b3e3fd4ad88 100644
--- a/packages/lib/src/renderer/index.ts
+++ b/packages/lib/src/renderer/index.ts
@@ -1,4 +1,3 @@
-export { PanZoomWrapper } from "./PanZoomWrapper";
export {
type ClickEvent,
type HoverEvent,
@@ -6,4 +5,5 @@ export {
PanZoom,
type ViewportMoveEvent,
} from "./PanZoom";
+export { PanZoomWrapper } from "./PanZoomWrapper";
export { RendererContext } from "./RendererContext";
diff --git a/packages/lib/tsconfig.json b/packages/lib/tsconfig.json
index b891969736650edb945fe1d2054ed4b16d972b27..3df34b019cb377b2507b1e6cca6edb5eeecd1d76 100644
--- a/packages/lib/tsconfig.json
+++ b/packages/lib/tsconfig.json
@@ -2,7 +2,8 @@
"extends": "@tsconfig/recommended/tsconfig.json",
"compilerOptions": {
"rootDir": "src",
- "noEmit": true,
+ "outDir": "dist",
+ "emitDeclarationOnly": true,
"inlineSourceMap": true,
"jsx": "react",
"declaration": true,
diff --git a/packages/server/jest.config.js b/packages/server/jest.config.js
index 5a63fa4199a393ed3cd3800141a9758ae471339f..4f395218858b580a55db552c57f964e7142a78be 100644
--- a/packages/server/jest.config.js
+++ b/packages/server/jest.config.js
@@ -1,3 +1,4 @@
+/* eslint-disable */
/** @type {import('ts-jest').JestConfigWithTsJest} **/
module.exports = {
testEnvironment: "node",
diff --git a/packages/server/prisma.config.ts b/packages/server/prisma.config.ts
index dc72f5856e8aa6e091ab040902254d42a4b092f8..201474a0bb4f16be37f01a503e14374400f732f7 100644
--- a/packages/server/prisma.config.ts
+++ b/packages/server/prisma.config.ts
@@ -1,3 +1,4 @@
+/* eslint-disable */
import path from "node:path";
try {
diff --git a/packages/server/src/api/admin/maintenance.ts b/packages/server/src/api/admin/maintenance.ts
index 613a88c3c7cb8b5f4d278736247dd06907d1454a..7a09cbc3fac988993035e18feeb7c05b5f0fca44 100644
--- a/packages/server/src/api/admin/maintenance.ts
+++ b/packages/server/src/api/admin/maintenance.ts
@@ -1,9 +1,6 @@
import { BullMQ_JobManager } from "../../jobs/bullmq";
-import { getLogger } from "../../lib/Logger";
import { Router } from "../lib/router";
-const Logger = getLogger();
-
export class MaintenanceEndpoints extends Router {
@Router.handler("post", "/drain_jobs")
async drainJobs(req: Router.Request, res: Router.Response) {
diff --git a/packages/server/src/api/client/index.ts b/packages/server/src/api/client/index.ts
index 4b77e8d487682c44bd02010e4213bace972bd3ee..53b50776ccadd64abf7696056b4684749678f004 100644
--- a/packages/server/src/api/client/index.ts
+++ b/packages/server/src/api/client/index.ts
@@ -1,14 +1,14 @@
import { Router } from "express";
+import { EventHypeController } from "../../controllers/EventHypeController";
import { OpenIDController } from "../../controllers/OpenIDController";
import { prisma } from "../../lib/prisma";
import { AuthEndpoints } from "./auth";
import { CanvasEndpoints } from "./canvas";
import { ReportEndpoints } from "./reports";
import SentryRouter from "./sentry";
-import { UserEndpoints } from "./user";
-import { EventHypeController } from "../../controllers/EventHypeController";
import ShareEndpoints from "./share";
+import { UserEndpoints } from "./user";
const app = Router();
diff --git a/packages/server/src/api/client/share.ts b/packages/server/src/api/client/share.ts
index 4678f158a74c32ab8ef419c073fa8b819a5bb39b..b8622637c9ab86b6556d67ee43c388b8849f80ed 100644
--- a/packages/server/src/api/client/share.ts
+++ b/packages/server/src/api/client/share.ts
@@ -1,4 +1,5 @@
import { Router } from "express";
+
import { getOrCached } from "../../controllers/ShareURLController";
import { Settings } from "../../settings/Settings";
diff --git a/packages/server/src/controllers/CanvasController.ts b/packages/server/src/controllers/CanvasController.ts
index 2e50d3f9d694b4e806e6a4cfae900798ebf0c080..5218c95f6fca6adf1ff5a8d70038e2d07bf87c0f 100644
--- a/packages/server/src/controllers/CanvasController.ts
+++ b/packages/server/src/controllers/CanvasController.ts
@@ -1,5 +1,6 @@
import { Pixel } from "@prisma/client";
import {
+ CANVAS_CHUNK_SIZE,
CanvasConfig,
ClientToServerEvents,
ServerToClientEvents,
@@ -16,7 +17,7 @@ import { SocketController } from "./SocketController";
const Logger = getLogger("CANVAS");
-const canvasSectionSize = [100, 100]; // TODO: config maybe?
+const canvasSectionSize = [CANVAS_CHUNK_SIZE, CANVAS_CHUNK_SIZE];
export class CanvasController {
private static instance: CanvasController | undefined;
@@ -378,9 +379,9 @@ export class CanvasController {
const pixels: (Pixel | null)[][] = [];
- for (var ly = 0; ly <= height; ly++) {
+ for (let ly = 0; ly <= height; ly++) {
pixels[ly] = [];
- for (var lx = 0; lx <= width; lx++) {
+ for (let lx = 0; lx <= width; lx++) {
pixels[ly][lx] =
allPixels.find((p) => p.x === x + ly && p.y === y + lx) || null;
}
diff --git a/packages/server/src/controllers/CursorPresenceController.ts b/packages/server/src/controllers/CursorPresenceController.ts
new file mode 100644
index 0000000000000000000000000000000000000000..69f8824f63309e37756378fc2019067e8c926b47
--- /dev/null
+++ b/packages/server/src/controllers/CursorPresenceController.ts
@@ -0,0 +1,884 @@
+import { randomUUID } from "node:crypto";
+
+import {
+ CURSOR_MAX_WORLD_COORDINATE,
+ type CursorMove,
+ type CursorReactionCooldown,
+ type CursorReactionResult,
+ type CursorServerAction,
+ type CursorServerRemoval,
+ type CursorServerSync,
+ type CursorSnapshot,
+ type CursorView,
+ type CursorViewport,
+ type CursorWireState,
+ getCursorRegion,
+ getCursorView,
+} from "@sc07-canvas/lib/net";
+
+import { Settings } from "../settings/Settings";
+import { Redis } from "./RedisController";
+import { type Socket, SocketController } from "./SocketController";
+
+const CURSOR_FRAME_VERSION = 1;
+const CURSOR_FRAME_HEADER_SIZE = 8;
+const CURSOR_FRAME_RECORD_SIZE = 24;
+const CURSOR_FRAME_KEYFRAME = 1;
+
+const encodeCursorFrame = (
+ cursors: CursorWireState[],
+ tick: number,
+ keyframe = false,
+) => {
+ const count = Math.min(cursors.length, 65535);
+ const buffer = new ArrayBuffer(
+ CURSOR_FRAME_HEADER_SIZE + count * CURSOR_FRAME_RECORD_SIZE,
+ );
+ const view = new DataView(buffer);
+ view.setUint8(0, CURSOR_FRAME_VERSION);
+ view.setUint8(1, keyframe ? CURSOR_FRAME_KEYFRAME : 0);
+ view.setUint16(2, count, true);
+ view.setUint32(4, tick >>> 0, true);
+
+ for (let index = 0; index < count; index++) {
+ const cursor = cursors[index];
+ const offset = CURSOR_FRAME_HEADER_SIZE + index * CURSOR_FRAME_RECORD_SIZE;
+ view.setUint32(offset, cursor[0] >>> 0, true);
+ view.setUint32(offset + 4, cursor[1] >>> 0, true);
+ view.setInt32(offset + 8, cursor[2], true);
+ view.setInt32(offset + 12, cursor[3], true);
+ view.setFloat32(offset + 16, cursor[4], true);
+ view.setInt32(offset + 20, cursor[5] ?? -1, true);
+ }
+
+ return buffer;
+};
+
+const CURSOR_TICK_INTERVAL = 67;
+const CURSOR_MOVE_MIN_INTERVAL = 35;
+const CURSOR_REACTION_COOLDOWN = 1000;
+const CURSOR_REACTION_MINUTE_LIMIT = 5;
+const CURSOR_REACTION_MINUTE_WINDOW = 60000;
+const CURSOR_REACTION_HOUR_LIMIT = 50;
+const CURSOR_REACTION_HOUR_WINDOW = 3600000;
+const CURSOR_REACTION_RATE_SCRIPT = `
+local key = KEYS[1]
+local now = tonumber(ARGV[1])
+local member = ARGV[2]
+local secondWindow = tonumber(ARGV[3])
+local minuteWindow = tonumber(ARGV[4])
+local minuteLimit = tonumber(ARGV[5])
+local hourWindow = tonumber(ARGV[6])
+local hourLimit = tonumber(ARGV[7])
+
+redis.call("ZREMRANGEBYSCORE", key, "-inf", now - hourWindow)
+
+local blockedUntil = 0
+local blockedLevel = 0
+local latest = redis.call("ZREVRANGE", key, 0, 0, "WITHSCORES")
+if #latest > 0 then
+ local secondUntil = tonumber(latest[2]) + secondWindow
+ if secondUntil > blockedUntil then
+ blockedUntil = secondUntil
+ blockedLevel = 1
+ end
+end
+
+local minuteEntries = redis.call(
+ "ZRANGEBYSCORE",
+ key,
+ "(" .. tostring(now - minuteWindow),
+ "+inf",
+ "WITHSCORES"
+)
+local minuteCount = #minuteEntries / 2
+if minuteCount >= minuteLimit then
+ local minuteUntil = tonumber(minuteEntries[2]) + minuteWindow
+ if minuteUntil > blockedUntil then
+ blockedUntil = minuteUntil
+ blockedLevel = 2
+ end
+end
+
+local hourCount = tonumber(redis.call("ZCARD", key))
+local hourEntries = redis.call("ZRANGE", key, 0, 0, "WITHSCORES")
+if hourCount >= hourLimit and #hourEntries > 0 then
+ local hourUntil = tonumber(hourEntries[2]) + hourWindow
+ if hourUntil > blockedUntil then
+ blockedUntil = hourUntil
+ blockedLevel = 3
+ end
+end
+
+if blockedUntil > now then
+ return { 0, blockedLevel, blockedUntil }
+end
+
+redis.call("ZADD", key, now, member)
+redis.call("PEXPIRE", key, hourWindow)
+minuteCount = minuteCount + 1
+hourCount = hourCount + 1
+
+local activeUntil = 0
+local activeLevel = 0
+if minuteCount >= minuteLimit then
+ local minuteUntil = tonumber(minuteEntries[2]) + minuteWindow
+ if minuteUntil > activeUntil then
+ activeUntil = minuteUntil
+ activeLevel = 2
+ end
+end
+if hourCount >= hourLimit and #hourEntries > 0 then
+ local hourUntil = tonumber(hourEntries[2]) + hourWindow
+ if hourUntil > activeUntil then
+ activeUntil = hourUntil
+ activeLevel = 3
+ end
+end
+
+return { 1, activeLevel, activeUntil }
+`;
+const CURSOR_REPAIR_TICKS = 30;
+const CURSOR_REMOTE_TTL = 6500;
+const CURSOR_OVERVIEW_COVERAGE = 0.75;
+
+interface LocalCursorState {
+ authorized: boolean;
+ userSub?: string;
+ connected: boolean;
+ enabled: boolean;
+ reactionsEnabled: boolean;
+ id?: number;
+ sequence: number;
+ actionSequence: number;
+ subscriptionVersion: number;
+ lastMoveReceivedAt: number;
+ lastReactionAt: number;
+ view?: CursorView;
+ viewport?: CursorViewport;
+ wire?: CursorWireState;
+ operationQueue: Promise;
+}
+
+interface CursorRecord {
+ wire: CursorWireState;
+ source: string;
+ seenAt: number;
+}
+
+interface CursorFrameGroup {
+ view: CursorView;
+ zoom: number;
+ sockets: Socket[];
+}
+
+export class CursorPresenceController {
+ private static instance: CursorPresenceController | undefined;
+
+ private readonly source = randomUUID();
+ private readonly states = new WeakMap();
+ private readonly subscribers = new Set();
+ private readonly cursors = new Map();
+ private readonly clientDirty = new Map();
+ private readonly localDirty = new Map();
+ private readonly tickTimer: NodeJS.Timeout;
+ private tick = 0;
+
+ private constructor() {
+ SocketController.hook("socketConnection", (socket, userSub) => {
+ this.attach(socket, userSub);
+ });
+ SocketController.hook("pixelPlaced", (socket) => {
+ this.notifyPlacement(socket);
+ });
+
+ const io = SocketController.get().io;
+ io.on("cursorPresenceSync", (sync) => this.receiveServerSync(sync));
+ io.on("cursorPresenceAction", (action) => this.receiveServerAction(action));
+
+ this.tickTimer = setInterval(() => this.flush(), CURSOR_TICK_INTERVAL);
+ this.tickTimer.unref();
+ }
+
+ static initialize() {
+ if (this.instance) {
+ throw new Error("CursorPresenceController already initialized");
+ }
+ this.instance = new CursorPresenceController();
+ }
+
+ static get() {
+ if (!this.instance) {
+ throw new Error("CursorPresenceController is not initialized");
+ }
+ return this.instance;
+ }
+
+ private attach(socket: Socket, userSub?: string) {
+ const state: LocalCursorState = {
+ authorized: Boolean(userSub),
+ userSub,
+ connected: true,
+ enabled: false,
+ reactionsEnabled: false,
+ sequence: 0,
+ actionSequence: 0,
+ subscriptionVersion: 0,
+ lastMoveReceivedAt: 0,
+ lastReactionAt: 0,
+ operationQueue: Promise.resolve(),
+ };
+ this.states.set(socket, state);
+
+ socket.on("cursorViewport", (viewport, ack) => {
+ if (typeof ack !== "function") return;
+ const subscriptionVersion = ++state.subscriptionVersion;
+ this.enqueue(state, async () => {
+ try {
+ const snapshot = await this.updateViewport(
+ socket,
+ state,
+ viewport,
+ subscriptionVersion,
+ );
+ ack(snapshot);
+ } catch {
+ ack(null);
+ }
+ });
+ });
+ socket.on("cursorMove", (cursor) => {
+ const now = Date.now();
+ if (now - state.lastMoveReceivedAt < CURSOR_MOVE_MIN_INTERVAL) return;
+ state.lastMoveReceivedAt = now;
+ this.updateCursor(state, cursor);
+ });
+ socket.on("cursorSettle", (cursor) => {
+ this.updateCursor(state, cursor);
+ });
+ socket.on("cursorZoom", (zoom) => {
+ this.updateZoom(socket, state, zoom);
+ });
+ socket.on("cursorHide", () => {
+ this.hideCursor(state);
+ });
+ socket.on("cursorReaction", (reaction, ack) => {
+ void this.sendReaction(state, reaction).then((result) => {
+ if (typeof ack === "function") ack(result);
+ });
+ });
+ socket.on("cursorUnsubscribe", () => {
+ state.subscriptionVersion++;
+ this.unsubscribe(socket, state);
+ });
+ socket.on("disconnecting", () => {
+ state.connected = false;
+ state.subscriptionVersion++;
+ this.unsubscribe(socket, state);
+ });
+ }
+
+ private async updateViewport(
+ socket: Socket,
+ state: LocalCursorState,
+ viewport: CursorViewport,
+ subscriptionVersion: number,
+ ): Promise {
+ if (
+ !state.connected ||
+ !state.authorized ||
+ subscriptionVersion !== state.subscriptionVersion ||
+ !this.validViewport(viewport)
+ ) {
+ return null;
+ }
+
+ const view = getCursorView(viewport);
+ if (!view) return null;
+ view.mode = this.isOverview(viewport) ? "overview" : "regions";
+
+ if (state.id === undefined) {
+ const redis = await Redis.getClient();
+ state.id = await redis.incr(Redis.key("cursor_id"));
+ }
+ if (
+ !state.connected ||
+ state.id === undefined ||
+ subscriptionVersion !== state.subscriptionVersion
+ ) {
+ return null;
+ }
+
+ state.enabled = true;
+ state.reactionsEnabled = viewport.reactions;
+ state.viewport = viewport;
+ state.view = view;
+ this.subscribers.add(socket);
+
+ const visible = this.visibleCursors(
+ view,
+ this.allCursorWires(),
+ viewport.zoom,
+ ).filter((cursor) => cursor[0] !== state.id);
+ return {
+ selfId: state.id,
+ view,
+ frame: encodeCursorFrame(visible, this.tick, true),
+ };
+ }
+
+ private updateCursor(state: LocalCursorState, cursor: CursorMove) {
+ if (!state.connected || !state.authorized || !state.enabled) return;
+ if (state.id === undefined || !this.validCursor(cursor)) return;
+
+ state.sequence++;
+ const wire: CursorWireState = [
+ state.id,
+ state.sequence,
+ cursor.x,
+ cursor.y,
+ cursor.zoom,
+ cursor.color ?? null,
+ ];
+
+ const previous = this.cursors.get(state.id)?.wire;
+ if (previous) this.dispatchDeparture(previous, wire);
+ state.wire = wire;
+ this.cursors.set(state.id, {
+ wire,
+ source: this.source,
+ seenAt: Date.now(),
+ });
+ this.localDirty.set(state.id, wire);
+ this.clientDirty.set(state.id, wire);
+ }
+
+ private updateZoom(socket: Socket, state: LocalCursorState, zoom: number) {
+ if (
+ !state.connected ||
+ !state.authorized ||
+ !state.enabled ||
+ !state.viewport ||
+ !this.validZoom(zoom) ||
+ state.viewport.zoom === zoom
+ ) {
+ return;
+ }
+
+ state.viewport.zoom = zoom;
+ if (state.wire) {
+ const previous = state.wire;
+ state.sequence++;
+ const wire: CursorWireState = [
+ previous[0],
+ state.sequence,
+ previous[2],
+ previous[3],
+ zoom,
+ previous[5],
+ ];
+ this.dispatchDeparture(previous, wire);
+ state.wire = wire;
+ this.cursors.set(wire[0], {
+ wire,
+ source: this.source,
+ seenAt: Date.now(),
+ });
+ this.localDirty.set(wire[0], wire);
+ this.clientDirty.set(wire[0], wire);
+ }
+
+ this.dispatchSnapshot(socket, state);
+ }
+
+ private hideCursor(state: LocalCursorState) {
+ const wire = state.wire;
+ if (!wire) return;
+
+ state.sequence++;
+ const removal: CursorServerRemoval = {
+ id: wire[0],
+ sequence: state.sequence,
+ x: wire[2],
+ y: wire[3],
+ zoom: wire[4],
+ };
+ this.cursors.delete(wire[0]);
+ this.localDirty.delete(wire[0]);
+ this.clientDirty.delete(wire[0]);
+ state.wire = undefined;
+
+ this.dispatchRemoval(removal);
+ SocketController.get().io.serverSideEmit("cursorPresenceSync", {
+ source: this.source,
+ keyframe: false,
+ cursors: [],
+ removals: [removal],
+ });
+ }
+
+ private unsubscribe(socket: Socket, state: LocalCursorState) {
+ this.hideCursor(state);
+ this.subscribers.delete(socket);
+ state.enabled = false;
+ state.reactionsEnabled = false;
+ state.view = undefined;
+ state.viewport = undefined;
+ }
+
+ private flush() {
+ this.tick = (this.tick + 1) >>> 0;
+ this.removeStaleRemoteCursors();
+
+ const keyframe = this.tick % CURSOR_REPAIR_TICKS === 0;
+ const localCursors = keyframe
+ ? this.localCursorWires()
+ : [...this.localDirty.values()];
+ if (localCursors.length > 0 || keyframe) {
+ SocketController.get().io.serverSideEmit("cursorPresenceSync", {
+ source: this.source,
+ keyframe,
+ cursors: localCursors,
+ removals: [],
+ });
+ }
+
+ const clientCursors = keyframe
+ ? this.allCursorWires()
+ : [...this.clientDirty.values()];
+ if (clientCursors.length > 0 || keyframe) {
+ this.dispatchFrames(clientCursors, keyframe);
+ }
+
+ this.localDirty.clear();
+ this.clientDirty.clear();
+ }
+
+ private dispatchFrames(cursors: CursorWireState[], keyframe: boolean) {
+ const groups = new Map();
+ for (const socket of this.subscribers) {
+ const state = this.states.get(socket);
+ if (
+ !state?.connected ||
+ !state.enabled ||
+ !state.view ||
+ !state.viewport
+ ) {
+ continue;
+ }
+ const key = this.viewKey(state.view, state.viewport.zoom);
+ let group = groups.get(key);
+ if (!group) {
+ group = { view: state.view, zoom: state.viewport.zoom, sockets: [] };
+ groups.set(key, group);
+ }
+ group.sockets.push(socket);
+ }
+
+ for (const group of groups.values()) {
+ const visible = this.visibleCursors(group.view, cursors, group.zoom);
+ if (visible.length === 0 && !keyframe) continue;
+ const frame = encodeCursorFrame(visible, this.tick, keyframe);
+ for (const socket of group.sockets) {
+ if (keyframe) {
+ socket.emit("cursorFrame", frame);
+ } else {
+ socket.volatile.emit("cursorFrame", frame);
+ }
+ }
+ }
+ }
+
+ private dispatchSnapshot(socket: Socket, state: LocalCursorState) {
+ if (!state.view || !state.viewport) return;
+ const visible = this.visibleCursors(
+ state.view,
+ this.allCursorWires(),
+ state.viewport.zoom,
+ );
+ socket.emit("cursorFrame", encodeCursorFrame(visible, this.tick, true));
+ }
+
+ private receiveServerSync(sync: CursorServerSync) {
+ if (!sync || sync.source === this.source) return;
+ const now = Date.now();
+
+ for (const removal of sync.removals) {
+ const current = this.cursors.get(removal.id);
+ if (current?.source !== sync.source) continue;
+ if (current.wire[1] > removal.sequence) continue;
+ this.cursors.delete(removal.id);
+ this.clientDirty.delete(removal.id);
+ this.dispatchRemoval(removal);
+ }
+
+ const keyframeIds = new Set();
+ for (const wire of sync.cursors) {
+ if (!this.validWire(wire)) continue;
+ keyframeIds.add(wire[0]);
+ const current = this.cursors.get(wire[0]);
+ if (current && current.wire[1] > wire[1]) continue;
+ if (current) this.dispatchDeparture(current.wire, wire);
+ this.cursors.set(wire[0], { wire, source: sync.source, seenAt: now });
+ this.clientDirty.set(wire[0], wire);
+ }
+ if (sync.keyframe) {
+ for (const [id, record] of this.cursors) {
+ if (record.source !== sync.source || keyframeIds.has(id)) continue;
+ this.cursors.delete(id);
+ this.clientDirty.delete(id);
+ this.dispatchRemoval({
+ id,
+ sequence: record.wire[1] + 1,
+ x: record.wire[2],
+ y: record.wire[3],
+ zoom: record.wire[4],
+ });
+ }
+ }
+ }
+ private dispatchDeparture(
+ previous: CursorWireState,
+ current: CursorWireState,
+ ) {
+ for (const socket of this.subscribers) {
+ const state = this.states.get(socket);
+ if (
+ !state?.connected ||
+ !state.enabled ||
+ !state.view ||
+ !state.viewport ||
+ state.id === current[0] ||
+ !this.cursorIsVisible(previous, state.view, state.viewport.zoom) ||
+ this.cursorIsVisible(current, state.view, state.viewport.zoom)
+ ) {
+ continue;
+ }
+ socket.emit("cursorRemove", [[current[0], current[1]]]);
+ }
+ }
+ private dispatchRemoval(removal: CursorServerRemoval) {
+ for (const socket of this.subscribers) {
+ const state = this.states.get(socket);
+ if (
+ !state?.connected ||
+ !state.enabled ||
+ !state.view ||
+ !state.viewport ||
+ state.id === removal.id ||
+ !this.positionIsInView(removal.x, removal.y, state.view) ||
+ !this.zoomIsVisible(removal.zoom, state.viewport.zoom)
+ ) {
+ continue;
+ }
+ socket.emit("cursorRemove", [[removal.id, removal.sequence]]);
+ }
+ }
+
+ private removeStaleRemoteCursors() {
+ const now = Date.now();
+ for (const [id, record] of this.cursors) {
+ if (
+ record.source === this.source ||
+ now - record.seenAt <= CURSOR_REMOTE_TTL
+ ) {
+ continue;
+ }
+ this.cursors.delete(id);
+ this.clientDirty.delete(id);
+ this.dispatchRemoval({
+ id,
+ sequence: record.wire[1] + 1,
+ x: record.wire[2],
+ y: record.wire[3],
+ zoom: record.wire[4],
+ });
+ }
+ }
+
+ private async sendReaction(
+ state: LocalCursorState,
+ reaction: number,
+ ): Promise {
+ if (
+ !state.enabled ||
+ !state.reactionsEnabled ||
+ !state.wire ||
+ !state.userSub ||
+ !Number.isSafeInteger(reaction) ||
+ reaction < 0 ||
+ reaction > 9
+ ) {
+ return { success: false };
+ }
+
+ const now = Date.now();
+ if (now - state.lastReactionAt < CURSOR_REACTION_COOLDOWN) {
+ return {
+ success: false,
+ cooldown: {
+ level: "second",
+ until: state.lastReactionAt + CURSOR_REACTION_COOLDOWN,
+ },
+ };
+ }
+ state.lastReactionAt = now;
+
+ const result = await this.takeReactionSlot(state, now);
+ if (!result.success) return result;
+
+ const wire = state.wire;
+ if (
+ !state.connected ||
+ !state.enabled ||
+ !state.reactionsEnabled ||
+ !wire
+ ) {
+ return { success: false };
+ }
+
+ state.actionSequence++;
+ this.publishAction({
+ source: this.source,
+ type: "reaction",
+ id: wire[0],
+ sequence: state.actionSequence,
+ x: wire[2],
+ y: wire[3],
+ zoom: wire[4],
+ reaction,
+ });
+ return result;
+ }
+
+ private async takeReactionSlot(
+ state: LocalCursorState,
+ now: number,
+ ): Promise {
+ try {
+ const redis = await Redis.getClient();
+ const raw = await redis.eval(CURSOR_REACTION_RATE_SCRIPT, {
+ keys: [`CANVAS:CURSOR_REACTIONS:${encodeURIComponent(state.userSub!)}`],
+ arguments: [
+ String(now),
+ `${now}:${this.source}:${state.id}:${state.actionSequence + 1}`,
+ String(CURSOR_REACTION_COOLDOWN),
+ String(CURSOR_REACTION_MINUTE_WINDOW),
+ String(CURSOR_REACTION_MINUTE_LIMIT),
+ String(CURSOR_REACTION_HOUR_WINDOW),
+ String(CURSOR_REACTION_HOUR_LIMIT),
+ ],
+ });
+ if (!Array.isArray(raw)) return { success: false };
+
+ const success = Number(raw[0]) === 1;
+ const level = Number(raw[1]);
+ const until = Number(raw[2]);
+ const levelName: CursorReactionCooldown["level"] =
+ level === 1 ? "second" : level === 2 ? "minute" : "hour";
+ const cooldown: CursorReactionCooldown | undefined =
+ level > 0 && level <= 3 && Number.isFinite(until) && until > now
+ ? { level: levelName, until }
+ : undefined;
+
+ return success
+ ? { success: true, cooldown }
+ : { success: false, cooldown };
+ } catch {
+ return { success: false };
+ }
+ }
+
+ private notifyPlacement(socket: Socket) {
+ const state = this.states.get(socket);
+ if (!state?.enabled || !state.wire) return;
+ state.actionSequence++;
+ this.publishAction({
+ source: this.source,
+ type: "place",
+ id: state.wire[0],
+ sequence: state.actionSequence,
+ x: state.wire[2],
+ y: state.wire[3],
+ zoom: state.wire[4],
+ });
+ }
+
+ private publishAction(action: CursorServerAction) {
+ this.dispatchAction(action);
+ SocketController.get().io.serverSideEmit("cursorPresenceAction", action);
+ }
+
+ private receiveServerAction(action: CursorServerAction) {
+ if (!action || action.source === this.source) return;
+ this.dispatchAction(action);
+ }
+
+ private dispatchAction(action: CursorServerAction) {
+ for (const socket of this.subscribers) {
+ const state = this.states.get(socket);
+ if (
+ !state?.connected ||
+ !state.enabled ||
+ !state.view ||
+ !state.viewport ||
+ state.id === action.id ||
+ !this.positionIsInView(action.x, action.y, state.view) ||
+ !this.zoomIsVisible(action.zoom, state.viewport.zoom) ||
+ (action.type === "reaction" && !state.reactionsEnabled)
+ ) {
+ continue;
+ }
+
+ if (action.type === "reaction" && action.reaction !== undefined) {
+ socket.emit("cursorReaction", {
+ id: action.id,
+ reaction: action.reaction,
+ sequence: action.sequence,
+ });
+ } else if (action.type === "place") {
+ socket.emit("cursorPlace", {
+ id: action.id,
+ sequence: action.sequence,
+ });
+ }
+ }
+ }
+
+ private visibleCursors(
+ view: CursorView,
+ cursors: CursorWireState[],
+ viewerZoom: number,
+ ) {
+ return cursors.filter((cursor) =>
+ this.cursorIsVisible(cursor, view, viewerZoom),
+ );
+ }
+
+ private cursorIsVisible(
+ cursor: CursorWireState,
+ view: CursorView,
+ viewerZoom: number,
+ ) {
+ return (
+ this.positionIsInView(cursor[2], cursor[3], view) &&
+ this.zoomIsVisible(cursor[4], viewerZoom)
+ );
+ }
+
+ private zoomIsVisible(cursorZoom: number, viewerZoom: number) {
+ const ratio = cursorZoom / viewerZoom;
+ return ratio > 0.2 && ratio < 10;
+ }
+
+ private positionIsInView(x: number, y: number, view: CursorView) {
+ const region = getCursorRegion(view.regionSize, x, y);
+ return (
+ region.x >= view.minRegionX &&
+ region.x <= view.maxRegionX &&
+ region.y >= view.minRegionY &&
+ region.y <= view.maxRegionY
+ );
+ }
+
+ private viewKey(view: CursorView, zoom: number) {
+ return [
+ view.regionSize,
+ view.minRegionX,
+ view.maxRegionX,
+ view.minRegionY,
+ view.maxRegionY,
+ zoom,
+ ].join(":");
+ }
+
+ private isOverview(viewport: CursorViewport) {
+ const canvas = Settings.get("canvas.size");
+ const left = Math.max(0, Math.min(viewport.left, viewport.right));
+ const right = Math.min(
+ canvas.width,
+ Math.max(viewport.left, viewport.right),
+ );
+ const top = Math.max(0, Math.min(viewport.top, viewport.bottom));
+ const bottom = Math.min(
+ canvas.height,
+ Math.max(viewport.top, viewport.bottom),
+ );
+ const visibleArea = Math.max(0, right - left) * Math.max(0, bottom - top);
+ return (
+ visibleArea / (canvas.width * canvas.height) >= CURSOR_OVERVIEW_COVERAGE
+ );
+ }
+
+ private allCursorWires() {
+ return [...this.cursors.values()].map((record) => record.wire);
+ }
+
+ private localCursorWires() {
+ return [...this.cursors.values()]
+ .filter((record) => record.source === this.source)
+ .map((record) => record.wire);
+ }
+
+ private validCursor(cursor: CursorMove) {
+ if (!cursor || typeof cursor !== "object") return false;
+ const validColor =
+ cursor.color === undefined ||
+ (Number.isSafeInteger(cursor.color) &&
+ cursor.color >= 0 &&
+ cursor.color <= 65535);
+
+ return (
+ Number.isSafeInteger(cursor.x) &&
+ Number.isSafeInteger(cursor.y) &&
+ Math.abs(cursor.x) <= CURSOR_MAX_WORLD_COORDINATE &&
+ Math.abs(cursor.y) <= CURSOR_MAX_WORLD_COORDINATE &&
+ this.validZoom(cursor.zoom) &&
+ validColor
+ );
+ }
+
+ private validWire(wire: CursorWireState) {
+ return (
+ Array.isArray(wire) &&
+ wire.length === 6 &&
+ Number.isSafeInteger(wire[0]) &&
+ Number.isSafeInteger(wire[1]) &&
+ this.validCursor({
+ x: wire[2],
+ y: wire[3],
+ zoom: wire[4],
+ color: wire[5] ?? undefined,
+ })
+ );
+ }
+
+ private validViewport(viewport: CursorViewport) {
+ if (!viewport || typeof viewport !== "object") return false;
+ const bounds = [
+ viewport.left,
+ viewport.top,
+ viewport.right,
+ viewport.bottom,
+ ];
+ return (
+ typeof viewport.reactions === "boolean" &&
+ this.validZoom(viewport.zoom) &&
+ bounds.every(
+ (value) =>
+ Number.isFinite(value) &&
+ Math.abs(value) <= CURSOR_MAX_WORLD_COORDINATE,
+ )
+ );
+ }
+
+ private validZoom(zoom: number) {
+ return Number.isFinite(zoom) && zoom >= 0.01 && zoom <= 1000;
+ }
+
+ private enqueue(state: LocalCursorState, task: () => Promise) {
+ state.operationQueue = state.operationQueue
+ .then(task, task)
+ .catch(() => {});
+ }
+}
diff --git a/packages/server/src/controllers/EventHypeController.ts b/packages/server/src/controllers/EventHypeController.ts
index 6247148487f5e2f0d294e02a2ef80625f828a9ba..95d933dd19577a20a05bae79c3870b3bbaba1965 100644
--- a/packages/server/src/controllers/EventHypeController.ts
+++ b/packages/server/src/controllers/EventHypeController.ts
@@ -1,4 +1,3 @@
-import { createClient } from "redis";
import { Settings } from "../settings/Settings";
import { Redis } from "./RedisController";
import { SocketController } from "./SocketController";
@@ -26,7 +25,7 @@ export class EventHypeController {
async overview() {
const redis = await Redis.getClient();
- let counts: { [emoji: string]: number } = {};
+ const counts: { [emoji: string]: number } = {};
for (const emoji of this.emojis) {
counts[emoji] = await redis.sCard(Redis.key("event_hype", emoji));
@@ -51,7 +50,7 @@ export class EventHypeController {
redis
.sRem(Redis.key("event_hype", emoji), handle)
.then((removed) =>
- Boolean(removed) ? this.broadcast(emoji) : Promise.resolve(),
+ removed ? this.broadcast(emoji) : Promise.resolve(),
),
),
);
diff --git a/packages/server/src/controllers/ExpressController.ts b/packages/server/src/controllers/ExpressController.ts
index c1ea912c8de85bb493785aef828b8851558e9491..fb05efd1133b47a6029915e2d7da4db07a82aa36 100644
--- a/packages/server/src/controllers/ExpressController.ts
+++ b/packages/server/src/controllers/ExpressController.ts
@@ -8,10 +8,10 @@ import express, { type Express } from "express";
import expressSession from "express-session";
import APIRoutes from "../api/router";
-import ShareURLController from "./ShareURLController";
import { getLogger } from "../lib/Logger";
import { handleMetricsEndpoint } from "../lib/Prometheus";
import { Redis } from "./RedisController";
+import ShareURLController from "./ShareURLController";
const Logger = getLogger("HTTP");
diff --git a/packages/server/src/controllers/RedisController.ts b/packages/server/src/controllers/RedisController.ts
index c01bdb4ab036b4800069a72a2661ff801ffeb129..eb4fd198c41291ba57bc3ca2c226eda7a295aed5 100644
--- a/packages/server/src/controllers/RedisController.ts
+++ b/packages/server/src/controllers/RedisController.ts
@@ -21,6 +21,7 @@ interface IRedisKeys {
// users
socketToSub(socketId: string): string;
+ cursor_id(): string;
// pub/sub channels
channel_heatmap(): string;
@@ -46,6 +47,7 @@ const RedisKeys: IRedisKeys = {
`CANVAS:PIXELS:${start.join(",")}:${end.join(",")}`,
canvas_cache_write_queue: (workerId) => `CANVAS:CACHE_QUEUE:${workerId}`,
socketToSub: (socketId: string) => `CANVAS:SOCKET:${socketId}`,
+ cursor_id: () => `CANVAS:CURSOR_ID`,
channel_heatmap: () => `CANVAS:HEATMAP`,
lock: (id: string) => `CANVAS:LOCK:${id}`,
config_part: (key: string) => `CANVAS:CONFIG_PART:${key}`,
diff --git a/packages/server/src/controllers/ShareURLController.ts b/packages/server/src/controllers/ShareURLController.ts
index 326e331fb759bccac282f5e73a4aa460ca0d99a8..75cdb09c1dd60f62049427e2a64af6888c8d371d 100644
--- a/packages/server/src/controllers/ShareURLController.ts
+++ b/packages/server/src/controllers/ShareURLController.ts
@@ -1,6 +1,7 @@
+import { createCanvas } from "canvas";
import { Router } from "express";
import { isbot } from "isbot";
-import { createCanvas } from "canvas";
+
import { CanvasController } from "./CanvasController";
import { Redis } from "./RedisController";
@@ -24,9 +25,9 @@ router.use("/", (req, res) => {
};
if (parts.at(0)) {
- let coordParts = parts.at(0)!.split(",");
- let x = parseInt(coordParts[0]);
- let y = parseInt(coordParts[1]);
+ const coordParts = parts.at(0)!.split(",");
+ const x = parseInt(coordParts[0]);
+ const y = parseInt(coordParts[1]);
if (!isNaN(x) && !isNaN(y)) {
parsed.x = x;
@@ -35,7 +36,7 @@ router.use("/", (req, res) => {
if (parts.at(1) && parts.at(1)?.startsWith("z=")) {
// zoom being defined depends on coords being defined
- let zoom = parseInt(parts.at(1)!.replace("z=", ""));
+ const zoom = parseInt(parts.at(1)!.replace("z=", ""));
if (!isNaN(zoom)) {
parsed.zoom = zoom;
}
@@ -175,13 +176,13 @@ export const generateImage = async (
pixelsAround * 2,
);
- for (var ly = 0; ly < pixels.length; ly++) {
- for (var lx = 0; lx < pixels[ly].length; lx++) {
+ for (let ly = 0; ly < pixels.length; ly++) {
+ for (let lx = 0; lx < pixels[ly].length; lx++) {
const pixel = pixels[ly][lx];
if (!pixel) continue;
- let width = SCALE;
- let height = SCALE;
+ const width = SCALE;
+ const height = SCALE;
// if (lx + 1 >= pixels[ly].length) width = WIDTH - lx * SCALE;
// if (ly + 1 >= pixels.length) height = WIDTH - ly * SCALE;
diff --git a/packages/server/src/controllers/SocketController.ts b/packages/server/src/controllers/SocketController.ts
index 8b7e213fcc130d120525d6211d4eaff01c53fb67..07106686b0b050984d068ac5ef5ba37e7228b36b 100644
--- a/packages/server/src/controllers/SocketController.ts
+++ b/packages/server/src/controllers/SocketController.ts
@@ -2,6 +2,8 @@ import http from "node:http";
import {
ClientToServerEvents,
+ CursorServerAction,
+ CursorServerSync,
ModClientToServerEvents,
ModServerToClientEvents,
Pixel,
@@ -32,10 +34,13 @@ export type RemoteSocket = RawRemoteSocket;
interface ServerToServerEvents {
userStackUpdated: (sub: string, stack: number) => void;
+ cursorPresenceSync: (sync: CursorServerSync) => void;
+ cursorPresenceAction: (action: CursorServerAction) => void;
}
interface Hooks {
- socketConnection: (socket: Socket) => void;
+ socketConnection: (socket: Socket, userSub?: string) => void;
+ pixelPlaced: (socket: Socket, pixel: Pixel) => void;
}
type HooksMap = { [k in keyof Hooks]: Hooks[k][] };
@@ -44,6 +49,7 @@ export class SocketController {
private static instance: SocketController | undefined;
private static hooks: HooksMap = {
socketConnection: [],
+ pixelPlaced: [],
};
private static debugHooks: { [k: string]: (...rest: any[]) => void } = {};
io: Server;
@@ -355,6 +361,7 @@ export class SocketController {
data: newPixel,
});
socket.broadcast.emit("pixel", newPixel);
+ SocketController.execHook("pixelPlaced", socket, newPixel);
this.MOD_NS.to(`user:${user.sub}`).emit(
"pixelPlace",
socket.id,
@@ -452,7 +459,7 @@ export class SocketController {
});
}
- SocketController.execHook("socketConnection", socket);
+ SocketController.execHook("socketConnection", socket, user?.sub);
}
/**
diff --git a/packages/server/src/index.main.ts b/packages/server/src/index.main.ts
index e3266458e1107fcc5e9a3ea8cc5ac1f8bc969296..e7d4d6e915f0cff55ec82fd76f076886eb712d6a 100644
--- a/packages/server/src/index.main.ts
+++ b/packages/server/src/index.main.ts
@@ -1,5 +1,6 @@
import { CanvasController } from "./controllers/CanvasController";
import { CaptchaController } from "./controllers/CaptchaController";
+import { CursorPresenceController } from "./controllers/CursorPresenceController";
import { EventHypeController } from "./controllers/EventHypeController";
import { ExpressController } from "./controllers/ExpressController";
import { OpenIDController } from "./controllers/OpenIDController";
@@ -34,6 +35,7 @@ export default () => {
EventHypeController.initialize();
ExpressController.initialize();
SocketController.initialize();
+ CursorPresenceController.initialize();
CaptchaController.initialize();
}),
),
diff --git a/packages/server/src/jobs/canvas.ts b/packages/server/src/jobs/canvas.ts
index be2163fff4118411cb71daead77396bc59cec712..b666163cd7ba58e0f977f984dd244521012548cb 100644
--- a/packages/server/src/jobs/canvas.ts
+++ b/packages/server/src/jobs/canvas.ts
@@ -34,6 +34,8 @@ export const Job_Canvas = new (class Job_Canvas {
this.setupScheduler();
generateHeatmap().catch((e) => {
+ // TODO: properly report stack traces from worker job (#255)
+ // eslint-disable-next-line no-console
console.error(e);
Logger.warn("Failed to generate heatmap");
});
@@ -74,6 +76,8 @@ export const Job_Canvas = new (class Job_Canvas {
switch (job.name) {
case "heatmap":
await generateHeatmap().catch((e) => {
+ // TODO: properly report stack traces from worker job (#255)
+ // eslint-disable-next-line no-console
console.error(e);
Logger.warn("Failed to generate heatmap");
});
diff --git a/packages/server/src/lib/prisma.ts b/packages/server/src/lib/prisma.ts
index cd6d599062351a27c638a96ba5cf963a4786547f..47bbcf3d28ee2daea753b3916d8d2f42a31589f6 100644
--- a/packages/server/src/lib/prisma.ts
+++ b/packages/server/src/lib/prisma.ts
@@ -1,5 +1,5 @@
-import { PrismaClient } from "@prisma/client";
import { PrismaPg } from "@prisma/adapter-pg";
+import { PrismaClient } from "@prisma/client";
export const prisma = new PrismaClient({
adapter: new PrismaPg({ connectionString: `${process.env.DATABASE_URL}` }),
diff --git a/packages/server/src/workers/canvas_cache.ts b/packages/server/src/workers/canvas_cache.ts
index 0aa2fde3ab4e5632a096aca4dd68bde5253614be..5e6033e9c27b0bf3a6fcc68a8bc577f9c187bbf3 100644
--- a/packages/server/src/workers/canvas_cache.ts
+++ b/packages/server/src/workers/canvas_cache.ts
@@ -6,13 +6,13 @@
import { parentPort } from "node:worker_threads";
+import { CANVAS_CHUNK_SIZE } from "@sc07-canvas/lib/net";
+
import { Redis } from "../controllers/RedisController";
import { getLogger } from "../lib/Logger";
import { prisma } from "../lib/prisma";
-// TODO: config maybe?
-// this value is hardcoded in #getCanvasSectionFromCoords
-const canvasSectionSize = [100, 100];
+const canvasSectionSize = [CANVAS_CHUNK_SIZE, CANVAS_CHUNK_SIZE];
type Message =
| { type: "id"; workerId: number }
@@ -60,7 +60,7 @@ parentPort?.on("message", (msg: Message) => {
/**
* Get canvas section from coordinates
*
- * @note This is hardcoded to expect the section size to be 100x100 pixels
+ * Uses the shared canvas chunk size.
*
* @param x
* @param y
@@ -69,14 +69,15 @@ const getCanvasSectionFromCoords = (
x: number,
y: number
): { start: [x: number, y: number]; end: [x: number, y: number] } => {
- // since we are assuming the section size is 100x100
- // we can get the start position based on the hundreds position
- const baseX = Math.floor((x % 1000) / 100); // get the hundreds
- const baseY = Math.floor((y % 1000) / 100); // get the hundreds
+ const baseX = Math.floor(x / CANVAS_CHUNK_SIZE);
+ const baseY = Math.floor(y / CANVAS_CHUNK_SIZE);
return {
- start: [baseX * 100, baseY * 100],
- end: [baseX * 100 + 100, baseY * 100 + 100],
+ start: [baseX * CANVAS_CHUNK_SIZE, baseY * CANVAS_CHUNK_SIZE],
+ end: [
+ (baseX + 1) * CANVAS_CHUNK_SIZE,
+ (baseY + 1) * CANVAS_CHUNK_SIZE,
+ ],
};
};
diff --git a/yarn.lock b/yarn.lock
index 38793f94c7f90cb22a61fff1e340da827cc9a0c2..7310694c5749f04393d94e9c5956b6fa3e585ec4 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -121,20 +121,6 @@ __metadata:
languageName: node
linkType: hard
-"@babel/generator@npm:8.0.0-rc.3":
- version: 8.0.0-rc.3
- resolution: "@babel/generator@npm:8.0.0-rc.3"
- dependencies:
- "@babel/parser": "npm:^8.0.0-rc.3"
- "@babel/types": "npm:^8.0.0-rc.3"
- "@jridgewell/gen-mapping": "npm:^0.3.12"
- "@jridgewell/trace-mapping": "npm:^0.3.28"
- "@types/jsesc": "npm:^2.5.0"
- jsesc: "npm:^3.0.2"
- checksum: 10c0/43363ccd8c5e18ea4c1c198f17158acabb4074f12ae64c5772f97fe58b9a377de445d9fd0b403812bc10dadff44fe356a07f6017f25732b021bba3654241a78e
- languageName: node
- linkType: hard
-
"@babel/generator@npm:^7.27.5, @babel/generator@npm:^7.29.0":
version: 7.29.1
resolution: "@babel/generator@npm:7.29.1"
@@ -314,20 +300,6 @@ __metadata:
languageName: node
linkType: hard
-"@babel/helper-string-parser@npm:^8.0.0-rc.3, @babel/helper-string-parser@npm:^8.0.0-rc.4":
- version: 8.0.0-rc.4
- resolution: "@babel/helper-string-parser@npm:8.0.0-rc.4"
- checksum: 10c0/8e9950d36b8cd5fb5d03c56cd2cce91cf98efa093110d519e160a948a9e77eade71f292204d7395212c5e763af23b2b6687550fbcaa281a1efd3494a46bd99ca
- languageName: node
- linkType: hard
-
-"@babel/helper-validator-identifier@npm:8.0.0-rc.3":
- version: 8.0.0-rc.3
- resolution: "@babel/helper-validator-identifier@npm:8.0.0-rc.3"
- checksum: 10c0/03236675006da83b8530ef95896042d5246989e2fdc8283a60882a14c7ce86dc18db6a6b12f18b638d6722adc5f1e721142889a331e12a6f7c0fba3e307fdc7f
- languageName: node
- linkType: hard
-
"@babel/helper-validator-identifier@npm:^7.28.5":
version: 7.28.5
resolution: "@babel/helper-validator-identifier@npm:7.28.5"
@@ -335,13 +307,6 @@ __metadata:
languageName: node
linkType: hard
-"@babel/helper-validator-identifier@npm:^8.0.0-rc.3, @babel/helper-validator-identifier@npm:^8.0.0-rc.4":
- version: 8.0.0-rc.4
- resolution: "@babel/helper-validator-identifier@npm:8.0.0-rc.4"
- checksum: 10c0/dc523bdc206cff0b2a4db97459e7cf31d315d60fa7dff1fdcc8101568dc9e6a929c2126eb96a078b1e8eb26b6b4978526bbf8b5f50d91d809c77956a3b84064f
- languageName: node
- linkType: hard
-
"@babel/helper-validator-option@npm:^7.27.1":
version: 7.27.1
resolution: "@babel/helper-validator-option@npm:7.27.1"
@@ -370,17 +335,6 @@ __metadata:
languageName: node
linkType: hard
-"@babel/parser@npm:8.0.0-rc.3":
- version: 8.0.0-rc.3
- resolution: "@babel/parser@npm:8.0.0-rc.3"
- dependencies:
- "@babel/types": "npm:^8.0.0-rc.3"
- bin:
- parser: ./bin/babel-parser.js
- checksum: 10c0/666f954d5744261e4fbfa32170ca0034bb96d624d1c0936eb6a5a76e196773e93b480a99c87621cc35bee00015fd27318a5bd0542efd747cb0499ad5d3e58b75
- languageName: node
- linkType: hard
-
"@babel/parser@npm:^7.1.0, @babel/parser@npm:^7.20.7, @babel/parser@npm:^7.23.9, @babel/parser@npm:^7.24.4, @babel/parser@npm:^7.28.6, @babel/parser@npm:^7.29.0, @babel/parser@npm:^7.29.2":
version: 7.29.2
resolution: "@babel/parser@npm:7.29.2"
@@ -392,17 +346,6 @@ __metadata:
languageName: node
linkType: hard
-"@babel/parser@npm:^8.0.0-beta.4, @babel/parser@npm:^8.0.0-rc.3":
- version: 8.0.0-rc.4
- resolution: "@babel/parser@npm:8.0.0-rc.4"
- dependencies:
- "@babel/types": "npm:^8.0.0-rc.4"
- bin:
- parser: ./bin/babel-parser.js
- checksum: 10c0/3f3c6c7b62e2c8256237f39d78b7449c316797390a3ac8af1698995f1c3a7375082033d50cbfc18109b07cb8ef9c4e11e8bba394ec13165ba92a22622244bb1f
- languageName: node
- linkType: hard
-
"@babel/plugin-bugfix-firefox-class-in-computed-class-key@npm:^7.28.5":
version: 7.28.5
resolution: "@babel/plugin-bugfix-firefox-class-in-computed-class-key@npm:7.28.5"
@@ -1670,16 +1613,6 @@ __metadata:
languageName: node
linkType: hard
-"@babel/types@npm:8.0.0-rc.3":
- version: 8.0.0-rc.3
- resolution: "@babel/types@npm:8.0.0-rc.3"
- dependencies:
- "@babel/helper-string-parser": "npm:^8.0.0-rc.3"
- "@babel/helper-validator-identifier": "npm:^8.0.0-rc.3"
- checksum: 10c0/ffc29e2453bb3c25defbc49b243e09e10374bad571c7f27d9fed09a92f43a25624c55edfed5c8d4e45cac0b39efa040e9b6b2aef7cd7076f04311c31a2a2c8bf
- languageName: node
- linkType: hard
-
"@babel/types@npm:^7.0.0, @babel/types@npm:^7.20.7, @babel/types@npm:^7.27.1, @babel/types@npm:^7.27.3, @babel/types@npm:^7.28.2, @babel/types@npm:^7.28.5, @babel/types@npm:^7.28.6, @babel/types@npm:^7.29.0, @babel/types@npm:^7.4.4":
version: 7.29.0
resolution: "@babel/types@npm:7.29.0"
@@ -1690,16 +1623,6 @@ __metadata:
languageName: node
linkType: hard
-"@babel/types@npm:^8.0.0-rc.3, @babel/types@npm:^8.0.0-rc.4":
- version: 8.0.0-rc.4
- resolution: "@babel/types@npm:8.0.0-rc.4"
- dependencies:
- "@babel/helper-string-parser": "npm:^8.0.0-rc.4"
- "@babel/helper-validator-identifier": "npm:^8.0.0-rc.4"
- checksum: 10c0/fd6ba6dcebe79c3f79273da371ec7a5f479a310342f046126f54839b951b00816e1cad8ccefd326ef7cbd62b96ca49f7805c4351904b83e7388f122ff0bb7512
- languageName: node
- linkType: hard
-
"@bcoe/v8-coverage@npm:^0.2.3":
version: 0.2.3
resolution: "@bcoe/v8-coverage@npm:0.2.3"
@@ -11536,7 +11459,6 @@ __metadata:
altcha-lib: "npm:^1.4.1"
eventemitter3: "npm:^5.0.4"
rolldown: "npm:^1.0.0-rc.18"
- rolldown-plugin-dts: "npm:^0.23.2"
typescript: "npm:^5.9.3"
peerDependencies:
react: ^19
@@ -12868,13 +12790,6 @@ __metadata:
languageName: node
linkType: hard
-"@types/jsesc@npm:^2.5.0":
- version: 2.5.1
- resolution: "@types/jsesc@npm:2.5.1"
- checksum: 10c0/12ba7bf5968aeeb36408269f4b5a39718efc6411fa197cf0f5e967ba36ad7b7d555b78787fc480db43ce63ebe6ab0ffe5fd9f64b1ea3b0d073877f0747491b30
- languageName: node
- linkType: hard
-
"@types/json-schema@npm:^7.0.15, @types/json-schema@npm:^7.0.9":
version: 7.0.15
resolution: "@types/json-schema@npm:7.0.15"
@@ -14069,17 +13984,6 @@ __metadata:
languageName: node
linkType: hard
-"ast-kit@npm:^3.0.0-beta.1":
- version: 3.0.0-beta.1
- resolution: "ast-kit@npm:3.0.0-beta.1"
- dependencies:
- "@babel/parser": "npm:^8.0.0-beta.4"
- estree-walker: "npm:^3.0.3"
- pathe: "npm:^2.0.3"
- checksum: 10c0/27a0309d495100e088c6aa6449e2bb79fdf6321c42bb73c196d646935ff788c2202d8dfc19e04499c623f0676cbe5d6baaeb645ede4b349fac2bd5c276419d5a
- languageName: node
- linkType: hard
-
"ast-types-flow@npm:^0.0.8":
version: 0.0.8
resolution: "ast-types-flow@npm:0.0.8"
@@ -14375,13 +14279,6 @@ __metadata:
languageName: node
linkType: hard
-"birpc@npm:^4.0.0":
- version: 4.0.0
- resolution: "birpc@npm:4.0.0"
- checksum: 10c0/61f4e893ff4c5948b2c587c971c04883af0d8b2658d4632c8e77073db9f9e8b040402f985d56308021890b2ad32ef8392e36a8335cab1e3771d99e1b025d1af6
- languageName: node
- linkType: hard
-
"bl@npm:^4.0.3":
version: 4.1.0
resolution: "bl@npm:4.1.0"
@@ -15483,18 +15380,6 @@ __metadata:
languageName: node
linkType: hard
-"dts-resolver@npm:^2.1.3":
- version: 2.1.3
- resolution: "dts-resolver@npm:2.1.3"
- peerDependencies:
- oxc-resolver: ">=11.0.0"
- peerDependenciesMeta:
- oxc-resolver:
- optional: true
- checksum: 10c0/bf589ba9bfacdb23ff9c075948175f5a21ae0bccb2ca36f8315bff2729358902256ee7aca972f5b259641f08a4b5973034e082a730113d5af76e64062e45fe3a
- languageName: node
- linkType: hard
-
"dunder-proto@npm:^1.0.0, dunder-proto@npm:^1.0.1":
version: 1.0.1
resolution: "dunder-proto@npm:1.0.1"
@@ -16350,15 +16235,6 @@ __metadata:
languageName: node
linkType: hard
-"estree-walker@npm:^3.0.3":
- version: 3.0.3
- resolution: "estree-walker@npm:3.0.3"
- dependencies:
- "@types/estree": "npm:^1.0.0"
- checksum: 10c0/c12e3c2b2642d2bcae7d5aa495c60fa2f299160946535763969a1c83fc74518ffa9c2cd3a8b69ac56aea547df6a8aac25f729a342992ef0bbac5f1c73e78995d
- languageName: node
- linkType: hard
-
"esutils@npm:^2.0.2":
version: 2.0.3
resolution: "esutils@npm:2.0.3"
@@ -16969,15 +16845,6 @@ __metadata:
languageName: node
linkType: hard
-"get-tsconfig@npm:^4.13.7":
- version: 4.14.0
- resolution: "get-tsconfig@npm:4.14.0"
- dependencies:
- resolve-pkg-maps: "npm:^1.0.0"
- checksum: 10c0/abc2b9275468eb589079a0b7a95eb5107c14fdd0ca6dda1bff116fe774ea1f79975421dcb22a0c86b4f820fcc69a7655dddf9b6d6a8a2c06fcb59e19794c0724
- languageName: node
- linkType: hard
-
"get-tsconfig@npm:^4.7.5":
version: 4.13.0
resolution: "get-tsconfig@npm:4.13.0"
@@ -20219,13 +20086,6 @@ __metadata:
languageName: node
linkType: hard
-"obug@npm:^2.1.1":
- version: 2.1.1
- resolution: "obug@npm:2.1.1"
- checksum: 10c0/59dccd7de72a047e08f8649e94c1015ec72f94eefb6ddb57fb4812c4b425a813bc7e7cd30c9aca20db3c59abc3c85cc7a62bb656a968741d770f4e8e02bc2e78
- languageName: node
- linkType: hard
-
"ohash@npm:^2.0.11":
version: 2.0.11
resolution: "ohash@npm:2.0.11"
@@ -21554,39 +21414,6 @@ __metadata:
languageName: node
linkType: hard
-"rolldown-plugin-dts@npm:^0.23.2":
- version: 0.23.2
- resolution: "rolldown-plugin-dts@npm:0.23.2"
- dependencies:
- "@babel/generator": "npm:8.0.0-rc.3"
- "@babel/helper-validator-identifier": "npm:8.0.0-rc.3"
- "@babel/parser": "npm:8.0.0-rc.3"
- "@babel/types": "npm:8.0.0-rc.3"
- ast-kit: "npm:^3.0.0-beta.1"
- birpc: "npm:^4.0.0"
- dts-resolver: "npm:^2.1.3"
- get-tsconfig: "npm:^4.13.7"
- obug: "npm:^2.1.1"
- picomatch: "npm:^4.0.4"
- peerDependencies:
- "@ts-macro/tsc": ^0.3.6
- "@typescript/native-preview": ">=7.0.0-dev.20260325.1"
- rolldown: ^1.0.0-rc.12
- typescript: ^5.0.0 || ^6.0.0
- vue-tsc: ~3.2.0
- peerDependenciesMeta:
- "@ts-macro/tsc":
- optional: true
- "@typescript/native-preview":
- optional: true
- typescript:
- optional: true
- vue-tsc:
- optional: true
- checksum: 10c0/083359757ecc238e6234f3f63fddf90ef40c7a6c917206aa4e6bed6a244121b7104e6707af9bcca23179d82ac56866028b5b3602f4f4f8e26af0368878c5989c
- languageName: node
- linkType: hard
-
"rolldown@npm:1.0.0-beta.41":
version: 1.0.0-beta.41
resolution: "rolldown@npm:1.0.0-beta.41"
@@ -22149,6 +21976,7 @@ __metadata:
eslint: "npm:^9.39.2"
eslint-config-react-app: "npm:^7.0.1"
eslint-formatter-gitlab: "npm:^7.0.1"
+ eslint-plugin-react: "npm:^7.37.5"
eslint-plugin-react-hooks: "npm:^7.0.1"
eslint-plugin-simple-import-sort: "npm:^12.1.1"
jest-fetch-mock: "npm:^3.0.3"