diff --git a/apps/fullstack/components/context/viewer/useWorkerContext.tsx b/apps/fullstack/components/context/viewer/useWorkerContext.tsx index 1f729a5b4829482233a61e64692287ce938d04db..099ce02e9a57b21b0d30d3561c3d8fea7847f204 100644 --- a/apps/fullstack/components/context/viewer/useWorkerContext.tsx +++ b/apps/fullstack/components/context/viewer/useWorkerContext.tsx @@ -34,6 +34,7 @@ export function WorkerContextProvider({ /* Scaling and create the workers dynamically */ useEffect(() => { + if (!socket) return; setWorker((w) => { //Scale up if (w.size < n) { diff --git a/apps/fullstack/components/editor/index.tsx b/apps/fullstack/components/editor/index.tsx index bc5d1bff36c428eeb9ba56e6e5a7615b64c1a7c8..d17a970832572639e11464f56cc912b8631941e1 100644 --- a/apps/fullstack/components/editor/index.tsx +++ b/apps/fullstack/components/editor/index.tsx @@ -74,5 +74,6 @@ function getAllowedTools(perms: Perms) { tools.push("placement"); tools.push("image"); } + tools.push("laserPointer"); return tools; } diff --git a/apps/fullstack/components/editor/tools/doodle/context.tsx b/apps/fullstack/components/editor/tools/doodle/context.tsx index bfad6983fc645065f46f6e4be3ce88bf83f04941..22461660ed5957d0892a728a6d534a7a6b1794c8 100644 --- a/apps/fullstack/components/editor/tools/doodle/context.tsx +++ b/apps/fullstack/components/editor/tools/doodle/context.tsx @@ -56,7 +56,6 @@ export function DoodleToolContextProvider({ children }: ToolContextProps) { const { config } = useUser(true); const mounted = useRef(false); - // TODO: sync with database const [colorList, setColorList] = useState([ "#000000", "#0000ff", diff --git a/apps/fullstack/components/editor/tools/laserPointer/context.tsx b/apps/fullstack/components/editor/tools/laserPointer/context.tsx new file mode 100644 index 0000000000000000000000000000000000000000..fbde324ea5d416da68143e1e30323c9a392648e4 --- /dev/null +++ b/apps/fullstack/components/editor/tools/laserPointer/context.tsx @@ -0,0 +1,29 @@ +import { createContext, useContext } from "react"; + +import { type ID } from "@snip/database/types"; + +import { ToolContextProps } from "../types"; + +export interface PointerContextI { + sendPos: (xy_db: [number, number], pageId: ID) => void; +} + +const PointerContext = createContext<PointerContextI>({ + sendPos: () => {}, +}); + +export function PointerContextProvider({ children }: ToolContextProps) { + function sendPos(xy_db, pageId) { + console.log(pageId); + } + + return ( + <PointerContext.Provider value={{ sendPos }}> + {children} + </PointerContext.Provider> + ); +} + +export function usePointerContext() { + return useContext(PointerContext); +} diff --git a/apps/fullstack/components/editor/tools/laserPointer/index.tsx b/apps/fullstack/components/editor/tools/laserPointer/index.tsx new file mode 100644 index 0000000000000000000000000000000000000000..60d7a83bfb281975f8f4174633f6871c5c5585db --- /dev/null +++ b/apps/fullstack/components/editor/tools/laserPointer/index.tsx @@ -0,0 +1,76 @@ +import { useCallback, useEffect, useRef } from "react"; +import { createPortal } from "react-dom"; +import { TbPoint } from "react-icons/tb"; + +import { Toggle } from "../common/toggle"; +import { ToolComponents, ToolToggleProps } from "../types"; +import { PointerContextProvider, usePointerContext } from "./context"; + +import { useWorkerContext } from "components/context/viewer/useWorkerContext"; +import { useEditorContext } from "components/editor/context"; + +function PointerToggle(props: ToolToggleProps) { + return <Toggle label="Pointer" icon={<TbPoint />} {...props} />; +} + +function PointerHelp() { + return null; +} + +function PointerOverlay() { + const { pageWrappersRef } = useEditorContext(); + const cursorRef = useRef<HTMLDivElement>(); + const { overlayWorker } = useWorkerContext(); + /** The move event gets propagated threw the socket connection + * to other clients which joined the current page. Positions + * are send in db coords. + */ + const handleMove = useCallback( + (e: PointerEvent, idx: number) => { + // Transmit position to other clients in real-time + const xy: [number, number] = [e.offsetX, e.offsetY]; + const w = overlayWorker.get(idx); + if (!w) return; + const xy_db = w.canvasCoords_2_dbCoords(xy); + w.laserPointerHandler.sendPos(xy_db); + }, + [overlayWorker], + ); + + useEffect(() => { + const pages = Array.from( + pageWrappersRef.current?.values(), + (wrapper) => wrapper.children[0] as HTMLDivElement, + ); + + const moveHandlers = pages.map((page, idx) => { + return (e) => handleMove(e, idx); + }); + + pages.forEach((page, idx) => { + page.addEventListener("pointermove", moveHandlers[idx]); + + page.style.cursor = "crosshair"; + }); + + return () => { + pages.forEach((page, idx) => { + page.removeEventListener("pointermove", moveHandlers[idx]); + + page.style.cursor = "default"; + }); + }; + }, [handleMove, pageWrappersRef]); + + return null; +} + +const PointerTool: ToolComponents = { + name: "laserPointer" as const, + Toggle: PointerToggle, + Help: PointerHelp, + Overlay: PointerOverlay, + ContextProvider: PointerContextProvider, +}; + +export default PointerTool; diff --git a/apps/fullstack/components/editor/tools/toolsFactory.tsx b/apps/fullstack/components/editor/tools/toolsFactory.tsx index 82f0a42ceae42f3c813e2d8bd6cd7bb45472e887..b33c59ddc4cba2bf17a27fccfe1d3adebd936b8a 100644 --- a/apps/fullstack/components/editor/tools/toolsFactory.tsx +++ b/apps/fullstack/components/editor/tools/toolsFactory.tsx @@ -2,6 +2,7 @@ import DoodleTool from "./doodle"; import ImageTool from "./imageEditor"; import InteractionTool from "./interaction"; +import PointerTool from "./laserPointer"; import MoveTool from "./move"; import PlacementTool from "./placement"; import TextTool from "./text"; @@ -21,6 +22,8 @@ export function getToolComponents(tool: Tool): ToolComponents { return ImageTool; case "placement": return PlacementTool; + case "laserPointer": + return PointerTool; default: throw new Error("Unknown tool"); } diff --git a/apps/fullstack/components/editor/tools/types.ts b/apps/fullstack/components/editor/tools/types.ts index 8cfadbc803dc8b61ce7a078eead6abeeba70dd5b..39a8e14b1741cab87922e05113f38369b69d7dfe 100644 --- a/apps/fullstack/components/editor/tools/types.ts +++ b/apps/fullstack/components/editor/tools/types.ts @@ -5,7 +5,8 @@ export type Tool = | "snipEditor" | "text" | "image" - | "placement"; + | "placement" + | "laserPointer"; export interface ToolToggleProps { isActive: boolean; diff --git a/apps/fullstack/components/editor/worker/overlayWorker.tsx b/apps/fullstack/components/editor/worker/overlayWorker.tsx index 59cfe9006f154699c692a8c671806e45b48e8b6c..a5915807a2db73137348259f6fec17b1f3d5a3df 100644 --- a/apps/fullstack/components/editor/worker/overlayWorker.tsx +++ b/apps/fullstack/components/editor/worker/overlayWorker.tsx @@ -11,7 +11,7 @@ */ import { Socket } from "socket.io-client"; -import type { PageData } from "@snip/database/types"; +import type { ID, PageData } from "@snip/database/types"; import { BaseData, BaseView, SnipData } from "@snip/snips/general/base"; import { get_snip_from_data } from "@snip/snips/get_snip_from_data"; @@ -21,17 +21,28 @@ const debug = (...args: unknown[]) => { console.debug("[OverlayWorker]", ...args); }; +type RenderFunction = (ctx: CanvasRenderingContext2D) => void; + export class OverlayWorker extends RenderWorker { private ctx?: CanvasRenderingContext2D; - private stack = new Set<(ctx: CanvasRenderingContext2D) => void>(); + private stack = new Set<RenderFunction>(); private socket?: Socket; // Canvas size in db coordinates private max: [number, number] = [1400, 2000]; + public laserPointerHandler: LaserPointerHandler; constructor(socket: Socket) { super(); this.socket = socket; + + if (!socket) { + return; + } + + // Attach socket handler(s) + this.socket.on("snip:preview", this.onSnipPreview); + this.laserPointerHandler = new LaserPointerHandler(this, socket); } public async setupCanvas(canvas: HTMLCanvasElement) { @@ -85,7 +96,7 @@ export class OverlayWorker extends RenderWorker { number, (ctx: CanvasRenderingContext2D) => void >(); - public receivePreviewData(snipData: SnipData<BaseData, BaseView>) { + public onSnipPreview(snipData: SnipData<BaseData, BaseView>) { if (!this.current_page_id || snipData.page_id !== this.current_page_id) return; @@ -192,3 +203,109 @@ export class OverlayWorker extends RenderWorker { return this.canvas.height / 2000; } } + +/** Render handler for the laserpointer tool + * i.e. communicate new positions and + * render pointer events. + */ +class LaserPointerHandler { + worker: OverlayWorker; + socket: Socket; + + // We keep a list of received laser pointer events + laserPointerEvents: { + xy: [number, number]; + timestamp: number; + socket_id: string; + user_email: string; + }[]; + + map: Map<string, { xy: [number, number]; timestamp: number }[]>; + + constructor(worker: OverlayWorker, socket: Socket) { + this.worker = worker; + this.socket = socket; + this.laserPointerEvents = []; + this.map = new Map(); + socket.on("laserPointer:pos", this.onPos.bind(this)); + } + + private onPos( + xy: [number, number], + pageId: ID, + socket_id: string, + user_email: string, + timestamp: number, + ) { + if (pageId != this.worker.current_page_id) return; + + // append to socket data + const socket_data = this.map.get(socket_id) || []; + socket_data.push({ xy, timestamp }); + this.map.set(socket_id, socket_data); + + // Launch render + if (!this.renderFkt) { + this.renderFkt = this.render.bind(this); + this.worker.add(this.renderFkt); + this.worker.rerender(); + } + } + + public sendPos(xy: [number, number]) { + if (!this.socket) return; + this.socket.emit("laserPointer:pos", xy, this.worker.current_page_id); + } + + renderFkt: RenderFunction; + + // Keep lines for 1s + lineDuration = 1000; + // Keep circles for 10s + circleDuration = 5000; + + /** Render all laserPointer events */ + private render(ctx: CanvasRenderingContext2D) { + const now = Date.now(); + + for (const [socket_id, socket_data] of this.map.entries()) { + // For each socket we draw a a line + ctx.beginPath(); + ctx.strokeStyle = "rgba(255, 0, 0, 0.2)"; + ctx.lineCap = "round"; + ctx.lineWidth = 10; + for (let i = socket_data.length - 2; i >= 0; i--) { + ctx.lineTo(socket_data[i].xy[0], socket_data[i].xy[1]); + + // Splice after duration is up + if (socket_data[i].timestamp < now - this.lineDuration) + socket_data.splice(i, 1); + } + ctx.stroke(); + + //We also draw a bigger circle on the current position + const last_data = socket_data.at(-1); + if (!last_data) { + this.map.delete(socket_id); + continue; + } + + ctx.beginPath(); + ctx.fillStyle = "rgba(255,0,0,0.5)"; + ctx.arc(last_data.xy[0], last_data.xy[1], 10, 0, Math.PI * 2); + ctx.fill(); + + // Splice after duration is up + if (last_data.timestamp < now - this.circleDuration) + socket_data.pop(); + } + requestAnimationFrame(() => { + if (this.map.size > 0) { + this.worker.rerender(); + } else { + this.worker.remove(this.renderFkt); + this.renderFkt = null; + } + }); + } +} diff --git a/apps/fullstack/package.json b/apps/fullstack/package.json index 8b372f0f56fdc313235917c5f9a8ead9191bf78a..01cf79eb34f47e922c15c4c642ef0c4bff238bdb 100644 --- a/apps/fullstack/package.json +++ b/apps/fullstack/package.json @@ -52,8 +52,8 @@ "ts-node": "^10.9.2", "tsconfig-paths": "^4.2.0", "tsconfig-paths-jest": "^0.0.1", - "tslib": "^2.6.3", - "typescript": "5.3.3" + "typescript": "^5.5.2", + "tslib": "^2.6.3" }, "scripts": { "lint": "pnpm eslint .", diff --git a/apps/socket/src/prefixes/laserPointer/socket.ts b/apps/socket/src/prefixes/laserPointer/socket.ts new file mode 100644 index 0000000000000000000000000000000000000000..415e122923afb7e720546d4d0d58acfc67af0abe --- /dev/null +++ b/apps/socket/src/prefixes/laserPointer/socket.ts @@ -0,0 +1,28 @@ +import { type SocketData } from "../../types"; +import { allowedPage } from "../../utils/auth"; + +export function setupLaserPointerPrefix({ socket }: SocketData) { + /** Receive a new laser pointer positions and propagate it to all + * other sockets + */ + socket.on("laserPointer:pos", async (xy: [number, number], pageId) => { + if (!allowedPage(socket, pageId)) { + return socket.emit("error", { + message: "Unauthorized access to page", + }); + } + + //logWS(socket, "laserPointer", socket.data.id); + + socket + .to("page-" + String(pageId)) + .emit( + "laserPointer:pos", + xy, + pageId, + socket.id, + socket.data.email, + Date.now(), + ); + }); +} diff --git a/apps/socket/src/prefixes/setupNamespace.ts b/apps/socket/src/prefixes/setupNamespace.ts index cb9185c693785be77470ca443391c29918a94e1d..5e030ee1dfa64835875f56d7915018a533646298 100644 --- a/apps/socket/src/prefixes/setupNamespace.ts +++ b/apps/socket/src/prefixes/setupNamespace.ts @@ -12,6 +12,7 @@ import { } from "../utils/auth"; import { logAPI, logWS } from "../utils/logging"; import { setupBookPrefix } from "./book/socket"; +import { setupLaserPointerPrefix } from "./laserPointer/socket"; import { setupPagesPrefix } from "./page/socket"; import { setupSnipPrefix } from "./snip/socket"; import { setupUsersPrefix } from "./users/socket"; @@ -61,6 +62,11 @@ export function setupNamespaces(server: Server) { * /book-[book_id]/users:[function]/ */ setupUsersPrefix(data); + + /**Setup all functions for laser pointer prefix + * + */ + setupLaserPointerPrefix(data); }); setupServerSpace(server); diff --git a/apps/socket/src/prefixes/snip/socket.ts b/apps/socket/src/prefixes/snip/socket.ts index f55c41baf057389544f4d8b258d6f5023e3bd736..c0925c227e7b7030dd0fb4a2474c5622b1ef93b8 100644 --- a/apps/socket/src/prefixes/snip/socket.ts +++ b/apps/socket/src/prefixes/snip/socket.ts @@ -3,6 +3,7 @@ import { pool_data } from "@snip/database/sql.connection"; import { BaseData, BaseView, SnipData } from "@snip/snips/general/base"; import { type SocketData } from "../../types"; +import { allowedPage } from "../../utils/auth"; import { logWS } from "../../utils/logging"; import { triggerRender } from "../../utils/triggerRender"; @@ -48,6 +49,18 @@ export function setupSnipPrefix({ ); } + const page_id = snip_data.page_id; + // Sanity check for page_id + if (page_id === null || page_id === undefined) { + return socket.emit("error", "Page ID is missing or invalid."); + } + if (!allowedPage(socket, page_id)) { + return socket.emit( + "error", + "You do not have access to this page.", + ); + } + return await update( { socket, book_nsp, server, ...props }, snip_data, @@ -78,7 +91,7 @@ export function setupSnipPrefix({ socket.on( "snip:preview", - async (SnipData: SnipData<BaseData, BaseView>) => { + async (snipData: SnipData<BaseData, BaseView>) => { // Check if user has permission to write to the page if (!(socket.perms.pWrite === true)) { return socket.emit( @@ -88,8 +101,8 @@ export function setupSnipPrefix({ } socket - .to("page-" + String(SnipData.page_id)) - .volatile.emit("snip:preview", SnipData); + .to("page-" + String(snipData.page_id)) + .volatile.emit("snip:preview", snipData); }, ); @@ -103,6 +116,9 @@ export function setupSnipPrefix({ "You do not have access to write to this book.", ); } + if (!allowedPage(socket, page_id)) { + return socket.emit("error", "You do not have access to this page."); + } logWS(socket, "[Page: " + page_id + "] snip:placed " + snip_id); @@ -114,19 +130,13 @@ export function setupSnipPrefix({ } async function insert( - { socket, book_nsp, server }: SocketData, + { socket, server }: SocketData, snip_data: SnipData<BaseData, BaseView>, callback: (id: number | null) => void, ) { const page_id = snip_data.page_id; logWS(socket, "[Page: " + page_id + "] snip:insert"); - // Sanity check for page_id - if (page_id === null || page_id === undefined) { - console.error("[Socket][Book: " + socket.book_id + "] Page id is null"); - return; - } - // Sanity check book id snip_data.book_id = socket.book_id; snip_data.created_by = socket.perms.entity_id; @@ -162,11 +172,6 @@ async function update( ) { const page_id = snip_data.page_id; - // Sanity check for page_id - if (page_id === null || page_id === undefined) { - console.error("[Socket][Book: " + socket.book_id + "] Page id is null"); - return; - } logWS(socket, "[Page: " + page_id + "] snip:update"); // Sanity check book id diff --git a/apps/socket/src/utils/auth.ts b/apps/socket/src/utils/auth.ts index 05fcdce71a7a8db5a969f3aa3ab2fb64f0924e7b..354790f7cba151ee5fc45130b4ff07464a0c6c4f 100644 --- a/apps/socket/src/utils/auth.ts +++ b/apps/socket/src/utils/auth.ts @@ -163,7 +163,7 @@ export function allowedPage( pWrite = false, pDelete = false, ) { - if (!allowed(socket, pRead, pWrite, pDelete)) { + if (!allowed(socket, pRead, pWrite, pDelete) || !page_id) { return false; } return socket.data.allowedPages.includes(page_id); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 757ff6a925259b1400e4a985232992457e2c0ba8..b07413ac585b532bea03cb9dac8ed42d6841a524 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -204,6 +204,9 @@ importers: swr: specifier: ^2.2.5 version: 2.2.5(react@18.3.1) + typescript: + specifier: ^5.5.2 + version: 5.5.2 zod: specifier: ^3.23.8 version: 3.23.8 @@ -246,7 +249,7 @@ importers: version: 1.15.0 ts-node: specifier: ^10.9.2 - version: 10.9.2(@types/node@20.14.8)(typescript@5.3.3) + version: 10.9.2(@types/node@20.14.8)(typescript@5.5.2) tsconfig-paths: specifier: ^4.2.0 version: 4.2.0 @@ -256,9 +259,6 @@ importers: tslib: specifier: ^2.6.3 version: 2.6.3 - typescript: - specifier: 5.3.3 - version: 5.3.3 apps/render: dependencies: @@ -9420,37 +9420,6 @@ packages: yargs-parser: 21.1.1 dev: true - /ts-node@10.9.2(@types/node@20.14.8)(typescript@5.3.3): - resolution: {integrity: sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==} - hasBin: true - peerDependencies: - '@swc/core': '>=1.2.50' - '@swc/wasm': '>=1.2.50' - '@types/node': '*' - typescript: '>=2.7' - peerDependenciesMeta: - '@swc/core': - optional: true - '@swc/wasm': - optional: true - dependencies: - '@cspotcode/source-map-support': 0.8.1 - '@tsconfig/node10': 1.0.11 - '@tsconfig/node12': 1.0.11 - '@tsconfig/node14': 1.0.3 - '@tsconfig/node16': 1.0.4 - '@types/node': 20.14.8 - acorn: 8.12.0 - acorn-walk: 8.3.3 - arg: 4.1.3 - create-require: 1.1.1 - diff: 4.0.2 - make-error: 1.3.6 - typescript: 5.3.3 - v8-compile-cache-lib: 3.0.1 - yn: 3.1.1 - dev: true - /ts-node@10.9.2(@types/node@20.14.8)(typescript@5.5.2): resolution: {integrity: sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==} hasBin: true @@ -9665,12 +9634,6 @@ packages: possible-typed-array-names: 1.0.0 dev: true - /typescript@5.3.3: - resolution: {integrity: sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==} - engines: {node: '>=14.17'} - hasBin: true - dev: true - /typescript@5.5.2: resolution: {integrity: sha512-NcRtPEOsPFFWjobJEtfihkLCZCXZt/os3zf8nTxjVH3RvTSxjrCamJpbExGvYOF+tFHc3pA65qpdwPbzjohhew==} engines: {node: '>=14.17'}