{"id":24024944,"url":"https://github.com/tigerabrodi/gojo","last_synced_at":"2025-04-16T04:25:41.240Z","repository":{"id":222145959,"uuid":"756158289","full_name":"tigerabrodi/gojo","owner":"tigerabrodi","description":"A real-time multiplayer brainstorming web app built with Remix and Liveblocks.","archived":false,"fork":false,"pushed_at":"2024-04-09T16:02:15.000Z","size":1520,"stargazers_count":58,"open_issues_count":0,"forks_count":6,"subscribers_count":3,"default_branch":"main","last_synced_at":"2025-03-29T04:51:15.017Z","etag":null,"topics":["collaboration","conform","figma","googledocs","liveblocks","postgres","prisma","radix","railway","remix","typescript","web"],"latest_commit_sha":null,"homepage":"https://gojo-kakashi.vercel.app/","language":"HTML","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":null,"status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/tigerabrodi.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":null,"code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null}},"created_at":"2024-02-12T04:40:33.000Z","updated_at":"2024-12-30T22:29:10.000Z","dependencies_parsed_at":"2024-03-31T16:44:32.775Z","dependency_job_id":null,"html_url":"https://github.com/tigerabrodi/gojo","commit_stats":null,"previous_names":["narutosstudent/gojo","tigerabrodi/gojo"],"tags_count":0,"template":false,"template_full_name":null,"repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/tigerabrodi%2Fgojo","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/tigerabrodi%2Fgojo/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/tigerabrodi%2Fgojo/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/tigerabrodi%2Fgojo/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/tigerabrodi","download_url":"https://codeload.github.com/tigerabrodi/gojo/tar.gz/refs/heads/main","host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":249194756,"owners_count":21228051,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2022-07-04T15:15:14.044Z","host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["collaboration","conform","figma","googledocs","liveblocks","postgres","prisma","radix","railway","remix","typescript","web"],"created_at":"2025-01-08T15:36:23.333Z","updated_at":"2025-04-16T04:25:41.218Z","avatar_url":"https://github.com/tigerabrodi.png","language":"HTML","funding_links":[],"categories":[],"sub_categories":[],"readme":"# Gojo\n\nA real-time collaborative brainstorming web app built with [Remix](https://remix.run/) and [Liveblocks](https://liveblocks.io/).\n\nSome notes:\n\n- Double click on board to create a card.\n- Click once to \"focus\" on card. Click again to begin entering text.\n- Focusing on a card brings it to the front.\n- When sharing, you can also copy link similar to Google Docs. Anyone with the link gets instant access.\n\nhttps://github.com/tigerabrodi/gojo/assets/49603590/6bab85b4-e0cd-484b-ae87-c32e203b15cf\n\n# Get it running locally\n\n1. Clone or fork it.\n2. Run `npm install`\n3. Create a `.env` file in root. You're gonna need three environment variables: `COOKIE_SECRET`, `LIVEBLOCKS_SECRET_KEY` and `DATABASE_URL`.\n4. Run `npm run dev`\n\n## Environment variables\n\n`COOKIE_SECRET` -\u003e can be whatever you want, I'd recommend generating a random string.\n`LIVEBLOCKS_SECRET_KEY` -\u003e setup account on Liveblocks and copy the secret private key from development environment.\n`DATABASE_URL` -\u003e URL of a Postgres DB, I setup mine on [Railway](https://railway.app/), it's super easy.\n\n# Features explained\n\n\u003cdetails\u003e\n  \u003csummary\u003e🍿 Add someone as Editor via Email\u003c/summary\u003e\n\n---\n\nAt the moment, you can only add someone as editor. Supporting other roles shouldn't be too hard, but I left it out for now.\n\nTo make this work, we keep track of the roles for every board.\n\n```tsx\nmodel BoardRole {\n  id       String   @id @default(uuid())\n  role     String // owner, editor\n  board    Board    @relation(fields: [boardId], references: [id], onDelete: Cascade)\n  boardId  String\n  user     User     @relation(fields: [userId], references: [id])\n  userId   String\n  addedAt DateTime @default(now())\n\n  @@unique([boardId, userId]) // Ensure one role per user per board\n}\n```\n\n\u003c/details\u003e\n\n\u003cdetails\u003e\n  \u003csummary\u003e🍿 zIndex management\u003c/summary\u003e\n\n---\n\nWhen focusing on a card, we bring it to the front. The order of zIndex is kept via `zIndexOrderListWithCardIds` in the liveblocks storage.\n\nIn the liveblocks storage, we have an array of the cardIds `zIndexOrderListWithCardIds`. The last card has the highest zIndex in this list.\n\nWe get the zIndex for every card by simply calling `indexOf` using the card's id.\n\nLiveblocks storage type code:\n\n```tsx\ntype Storage = {\n  cards: LiveList\u003cLiveObject\u003cCardType\u003e\u003e\n  zIndexOrderListWithCardIds: LiveList\u003cstring\u003e\n  boardName: string\n}\n```\n\nCode inside Card component for bringing cards to the front:\n\n```tsx\nconst bringCardToFront = useMutation(({ storage }, cardId: string) =\u003e {\n  const zIndexOrderListWithCardIds = storage.get('zIndexOrderListWithCardIds')\n  const index = zIndexOrderListWithCardIds.findIndex((id) =\u003e id === cardId)\n\n  if (index !== -1) {\n    zIndexOrderListWithCardIds.delete(index)\n    zIndexOrderListWithCardIds.push(cardId)\n  }\n}, [])\n```\n\n## Side note\n\nThis is a simple way of managing zIndex. It's not the most efficient way, because e.g. adding something to beginning of the array is O(n) time complexity. Arrays are stored as a continuous block of memory, so adding something to the beginning means we have to shift everything else to the right, if there is no space available, we'd have to allocate a new block of memory and copy everything over.\n\nIf you were building something like Figma from scratch (no liveblocks) where milliseconds matter, you would probably want to consider a different approach.\n\n\u003c/details\u003e\n\n\u003cdetails\u003e\n  \u003csummary\u003e🍿 Share access via link with secret Id\u003c/summary\u003e\n\n---\n\nThere is also the option to copy a share link on share dialog.\n\nYou can simply copy it and share it with a friend.\n\nWhen they enter the link, they will instantly get access.\n\nFor every board, we create a secretId. The link appends this secretId as query parameter on the board's url. If it exists, we verify it's the correct one before creating a role for the new user. However, the user may already exist, so we're using `upsert` here in prisma.\n\nBoard model code:\n\n```tsx\nmodel Board {\n  id       String      @id @default(uuid())\n  name     String\n  secretId String      @default(uuid()) // secret Id\n  roles    BoardRole[]\n  lastOpenedAt DateTime?\n  createdAt DateTime   @default(now())\n  updatedAt DateTime   @updatedAt\n}\n```\n\nBoard route loader function, this runs on the server before client renders anything:\n\n```tsx\nexport async function loader({ params, request }: LoaderFunctionArgs) {\n  const userId = await requireAuthCookie(request);\n  const boardId = params.id;\n\n  invariant(boardId, \"No board ID provided\");\n\n  const currentUrl = new URL(request.url);\n  const secretId = currentUrl.searchParams.get(\"secretId\");\n\n  if (secretId) {\n    const isUserAllowedToEnterBoard =\n      await checkUserAllowedToEnterBoardWithSecretId({\n        boardId,\n        secretId,\n      });\n\n    if (!isUserAllowedToEnterBoard) {\n      throw redirectWithError(\"/boards\", {\n        message: \"You are not allowed on this board.\",\n      });\n    }\n\n    await upsertUserBoardRole({\n      userId,\n      boardId,\n    });\n  }\n// ...\n```\n\n\u003c/details\u003e\n\n\u003cdetails\u003e\n  \u003csummary\u003e🍿 Real-time cursors\u003c/summary\u003e\n\n---\n\nThis seems hard, and honestly, it is, but Liveblocks makes things simple to implement. There is a `useOthers` hook that gives us access to see the `presence` info of other users on the board in real time.\n\nCode for mapping out the cursor component:\n\n```tsx\n{\n  others.map(({ connectionId, presence }) =\u003e {\n    if (presence.cursor === null) {\n      return null\n    }\n\n    return (\n      \u003cCursor\n        key={`cursor-${connectionId}`}\n        color={getColorWithId(connectionId)}\n        x={presence.cursor.x}\n        y={presence.cursor.y}\n        name={presence.name}\n      /\u003e\n    )\n  })\n}\n```\n\nWe make sure to update the user's own presence when they're moving around the page:\n\n```tsx\n      \u003cmain\n        onDoubleClick={createNewCard}\n        onPointerMove={(event) =\u003e {\n          updateMyPresence({\n            cursor: {\n              x: Math.round(event.clientX),\n              y: Math.round(event.clientY),\n            },\n          });\n        }}\n        onPointerLeave={() =\u003e\n          updateMyPresence({\n            cursor: null,\n          })\n        }\n      \u003e\n// ...\n```\n\nGet color with id function:\n\n```tsx\nexport function getColorWithId(id: number) {\n  return COLORS[id % COLORS.length]\n}\n```\n\nAt scale where we expect many users on a single board, we'd need to make sure to have many more colors. Currently, COLORS contains 15 colors.\n\nCursor component:\n\n```tsx\nimport type { LinksFunction } from '@vercel/remix'\nimport cursorStyles from './Cursor.css'\n\ntype Props = {\n  color: string\n  name: string\n  x: number\n  y: number\n}\n\nexport const cursorLinks: LinksFunction = () =\u003e [\n  { rel: 'stylesheet', href: cursorStyles },\n]\n\nexport function Cursor({ color, name, x, y }: Props) {\n  return (\n    \u003cdiv\n      className=\"cursor\"\n      style={{\n        transform: `translateX(${x}px) translateY(${y}px)`,\n        '--colors-cursor': color,\n      }}\n    \u003e\n      \u003csvg xmlns=\"http://www.w3.org/2000/svg\" fill=\"none\" viewBox=\"0 0 15 22\"\u003e\n        \u003cpath\n          fill={color}\n          stroke=\"#162137\"\n          strokeWidth={1.5}\n          d=\"M6.937 15.03h-.222l-.165.158L1 20.5v-19l13 13.53H6.937Z\"\n        /\u003e\n      \u003c/svg\u003e\n      \u003cspan\u003e{name}\u003c/span\u003e\n    \u003c/div\u003e\n  )\n}\n```\n\n\u003c/details\u003e\n\n\u003cdetails\u003e\n  \u003csummary\u003e🍿 Moving and editing the card + showing who is doing what in real time\u003c/summary\u003e\n\n---\n\nThis was hard. I actually struggled with this for several hours, trying to figure out how to get it to work properly.\n\nI had a flickering bug due to card's on blur function running whenever you click the second time to begin entering the text.\n\nMy main learning: onBlur runs whenever the focus leaves the component, EVEN if the focus leaves the component for an element inside the component. It was really hard to debug because it was like a deep assumption I've always had. 😅\n\nWe also have to keep track of whether the card was clicked already or not, if it wasn't clicked, we don't yet want to focus on the editable content inside the card.\n\nCode when clicking on the card:\n\n```tsx\nfunction onCardClick() {\n  const isCardContentCurrentlyFocused =\n    document.activeElement === cardContentRef.current\n\n  if (isCardContentCurrentlyFocused) return\n\n  if (!hasCardBeenClickedBefore) {\n    setHasCardBeenClickedBefore(true)\n    return\n  }\n\n  if (cardContentRef.current) {\n    cardContentRef.current.focus()\n    moveCursorToEnd(cardContentRef.current)\n    setIsCardContentFocused(true)\n    scrollToTheBottomOfCardContent()\n    updateMyPresence({ isTyping: true })\n  }\n}\n```\n\nNow, this is where it gets funky.\n\nWhen we focus we need to right away update the presence for other users, telling them we're focusing on the card. This gotta be done via `onFocus` and not `onClick`. Because onClick doesn't trigger till the finger leaves the mouse button.\n\nCode for focusing on card:\n\n```tsx\nfunction onCardFocus() {\n  updateMyPresence({\n    selectedCardId: card.id,\n  })\n}\n```\n\nWhen blurring the card, things also get interesting. There are several things we wanna do, and we ONLY want the blur logic to proceed if we're not about to edit the content.\n\nLike I said before, blur happens when the focus leaves the element, even if the focus leaves an element for another one that's inside of it.\n\nThis is where I learned about `relatedTarget`, taken from [MDN](https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent/relatedTarget): \"The MouseEvent.relatedTarget read-only property is the secondary target for the mouse event, if there is one.\"\n\nThis is similar to mouseleave event (referring to the MDN document), `relatedTarget` points to the element it enters.\n\nCode for card blur:\n\n```tsx\nfunction onCardBlur(event: FocusEvent\u003cHTMLDivElement\u003e) {\n  // If we're focusing on card content, card's blur should not be triggered\n  if (event.relatedTarget === cardContentRef.current) return\n\n  cardContentRef.current?.blur()\n  setIsCardContentFocused(false)\n  setHasCardBeenClickedBefore(false)\n  updateMyPresence({ isTyping: false, selectedCardId: null })\n}\n```\n\nHow do we know someone is selecting what card?\n\nWe get that from the `useOthers` hook.\n\n```js\nconst others = useOthers()\nconst personFocusingOnThisCard = others.find(\n  (person) =\u003e person.presence.selectedCardId === card.id\n)\n```\n\nWhat's the UI for showing who is editing what card?\n\nIf someone else is focusing on a card, we update the styling and also display the name tag for the card:\n\n```tsx\n{\n  personFocusingOnThisCard \u0026\u0026 (\n    \u003cdiv\n      className=\"card-presence-name\"\n      style={{\n        backgroundColor: getColorWithId(personFocusingOnThisCard.connectionId),\n      }}\n    \u003e\n      {personFocusingOnThisCard.presence.name}\n    \u003c/div\u003e\n  )\n}\n```\n\n\u003c/details\u003e\n\n\u003cdetails\u003e\n  \u003csummary\u003e🍿 Moving card with arrow keys\u003c/summary\u003e\n\n---\n\nWhen a card is focused, you can move it with arrow keys.\n\nHowever, we don't want this to happen if you're editing the text. That would otherwise be a very confusing experience.\n\nCode for moving the card with arrow keys:\n\n```tsx\nfunction handleCardMove(direction: 'up' | 'down' | 'left' | 'right') {\n  let newX = card.positionX\n  let newY = card.positionY\n\n  switch (direction) {\n    case 'up':\n      newY -= 10\n      break\n    case 'down':\n      newY += 10\n      break\n    case 'left':\n      newX -= 10\n      break\n    case 'right':\n      newX += 10\n      break\n    default:\n      break\n  }\n\n  updateCardPosition(card.id, newX, newY)\n}\n\nfunction onCardKeyDown(event: KeyboardEvent\u003cHTMLDivElement\u003e) {\n  if (event.key === 'Escape' \u0026\u0026 cardContentRef.current) {\n    cardContentRef.current.blur()\n    return\n  }\n\n  // If user editing text, moving card with arrow keys should not be triggered\n  if (cardContentRef.current === document.activeElement) return\n\n  const arrowKey = ARROW_KEYS[event.key as keyof typeof ARROW_KEYS]\n\n  if (arrowKey) {\n    switch (event.key) {\n      case 'ArrowUp':\n        handleCardMove('up')\n        break\n      case 'ArrowDown':\n        handleCardMove('down')\n        break\n      case 'ArrowLeft':\n        handleCardMove('left')\n        break\n      case 'ArrowRight':\n        handleCardMove('right')\n        break\n      default:\n        break\n    }\n\n    // Prevent the page from scrolling when using arrow keys\n    event.preventDefault()\n  }\n}\n```\n\n\u003c/details\u003e\n\n\u003cdetails\u003e\n  \u003csummary\u003e🍿 Card's content\u003c/summary\u003e\n\n---\n\nFor the content, we're using a contenteditable div. We're storing the actual HTML content because we want to preserve the formatting.\n\nI'm using DOMPurify to sanitize the HTML content before saving it to the database. This ensures that we're not saving any malicious code.\n\n```tsx\nfunction handleInput(event: React.FormEvent\u003cHTMLSpanElement\u003e) {\n  const newHtml = event.currentTarget.innerHTML || ''\n  const purifiedHtml = DOMPurify.sanitize(newHtml)\n  setContent(purifiedHtml)\n  updateCardContent(card.id, purifiedHtml)\n}\n```\n\n\u003c/details\u003e\n\n\u003cdetails\u003e\n  \u003csummary\u003e🍿 Resizing the card\u003c/summary\u003e\n\n---\n\nThis was a bit of an adventure. I first needed to figure out how to make the card resizable, then figure out how to preserve the aspect ratio while resizing.\n\nTo take you through this, let me first show you the entire code, and then we'll break it down.\n\n```tsx\nfunction handleResizeMouseDown(\n  resizeHandlerMoustDownEvent: React.MouseEvent\u003cHTMLDivElement\u003e,\n  corner: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'\n) {\n  // Needed to prevent card from being dragged when resizing\n  resizeHandlerMoustDownEvent.stopPropagation()\n\n  const startWidth = card.width\n  const startHeight = card.height\n\n  const startX = resizeHandlerMoustDownEvent.clientX\n  const startY = resizeHandlerMoustDownEvent.clientY\n\n  const startPosX = card.positionX\n  const startPosY = card.positionY\n\n  function handleMouseMove(mouseMoveEvent: MouseEvent) {\n    let newWidth = startWidth\n    let newHeight = startHeight\n    let newX = startPosX\n    let newY = startPosY\n\n    const widthDiff = mouseMoveEvent.clientX - startX\n    const heightDiff = mouseMoveEvent.clientY - startY\n\n    switch (corner) {\n      case 'top-left': {\n        newWidth = Math.max(CARD_DIMENSIONS.width, startWidth - widthDiff)\n        newHeight = Math.max(CARD_DIMENSIONS.height, startHeight - heightDiff)\n\n        const maxNewWidthAndHeight = Math.max(newWidth, newHeight)\n        newWidth = maxNewWidthAndHeight\n        newHeight = maxNewWidthAndHeight\n\n        newX = startPosX + (startWidth - maxNewWidthAndHeight)\n        newY = startPosY + (startHeight - maxNewWidthAndHeight)\n        break\n      }\n\n      case 'top-right': {\n        newWidth = Math.max(CARD_DIMENSIONS.width, startWidth + widthDiff)\n        newHeight = Math.max(CARD_DIMENSIONS.height, startHeight - heightDiff)\n\n        const maxNewWidthAndHeight = Math.max(newWidth, newHeight)\n        newWidth = maxNewWidthAndHeight\n        newHeight = maxNewWidthAndHeight\n\n        newY = startPosY + (startHeight - maxNewWidthAndHeight)\n        break\n      }\n      case 'bottom-left': {\n        newWidth = Math.max(CARD_DIMENSIONS.width, startWidth - widthDiff)\n        newHeight = Math.max(CARD_DIMENSIONS.height, startHeight + heightDiff)\n\n        const maxNewWidthAndHeight = Math.max(newWidth, newHeight)\n        newWidth = maxNewWidthAndHeight\n        newHeight = maxNewWidthAndHeight\n\n        newX = startPosX + (startWidth - maxNewWidthAndHeight)\n        break\n      }\n      case 'bottom-right': {\n        newWidth = Math.max(CARD_DIMENSIONS.width, startWidth + widthDiff)\n        newHeight = Math.max(CARD_DIMENSIONS.height, startHeight + heightDiff)\n\n        const maxNewWidthAndHeight = Math.max(newWidth, newHeight)\n        newWidth = maxNewWidthAndHeight\n        newHeight = maxNewWidthAndHeight\n\n        break\n      }\n    }\n\n    updateCardSize(card.id, newWidth, newHeight)\n    updateCardPosition(card.id, newX, newY)\n  }\n\n  function handleMouseUp() {\n    window.removeEventListener('mousemove', handleMouseMove)\n    window.removeEventListener('mouseup', handleMouseUp)\n  }\n\n  window.addEventListener('mousemove', handleMouseMove)\n  window.addEventListener('mouseup', handleMouseUp)\n}\n```\n\nLet's now try to break it down and understand what's happening.\n\nI think we can start by focusing on everything besides `handleMouseMove`. For now, we assume `handleMouseMove` is just a black box that does some magic resizing.\n\n```tsx\nfunction handleResizeMouseDown(\n  resizeHandlerMoustDownEvent: React.MouseEvent\u003cHTMLDivElement\u003e,\n  corner: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'\n) {\n  // Needed to prevent card from being dragged when resizing\n  resizeHandlerMoustDownEvent.stopPropagation()\n\n  // Width and height of the card when resizing starts\n  const startWidth = card.width\n  const startHeight = card.height\n\n  // Starting position of the mouse when resizing starts\n  // This will be one of the corners of the card aka the resize handlers\n  const startX = resizeHandlerMoustDownEvent.clientX\n  const startY = resizeHandlerMoustDownEvent.clientY\n\n  // This represents the starting position of the card\n  // The coordinates of the top left corner of the card\n  const startPosX = card.positionX\n  const startPosY = card.positionY\n\n  function handleMouseMove(mouseMoveEvent: MouseEvent) {\n    // ...\n  }\n\n  // When done resizing, remove the event listeners\n  // If we don't do this, the card will keep resizing even after we let go of the mouse button\n  function handleMouseUp() {\n    window.removeEventListener('mousemove', handleMouseMove)\n    window.removeEventListener('mouseup', handleMouseUp)\n  }\n\n  // Add event listeners for mouse move and mouse up\n  // As you can see, we only do this when resizing starts\n  // aka in our `handleResizeMouseDown` function\n  window.addEventListener('mousemove', handleMouseMove)\n  window.addEventListener('mouseup', handleMouseUp)\n}\n```\n\nNow, with that out of the way, let's focus on `handleMouseMove`.\n\nI feel like it could be broken down into two parts:\n\n1. Resizing.\n2. Preserving aspect ratio.\n\nLet's focus on resizing first.\n\n```tsx\nfunction handleMouseMove(mouseMoveEvent: MouseEvent) {\n  // This is the card's width\n  let newWidth = startWidth\n\n  // This is the card's height\n  let newHeight = startHeight\n\n  // We initialize the new position of the card to be the same as the starting position\n  let newX = startPosX\n  let newY = startPosY\n\n  // The difference between the starting position of the mouse and the current position\n  // The starting position is where the mouse was when resizing started\n  // This will be one of the corners of the card aka the resize handlers\n  const widthDiff = mouseMoveEvent.clientX - startX\n  const heightDiff = mouseMoveEvent.clientY - startY\n\n  switch (corner) {\n    case 'top-left': {\n      newWidth = Math.max(150, startWidth - widthDiff)\n      newHeight = Math.max(150, startHeight - heightDiff)\n\n      newX = startPosX + (startWidth - newWidth)\n      newY = startPosY + (startHeight - newHeight)\n      break\n    }\n\n    case 'top-right': {\n      newWidth = Math.max(150, startWidth + widthDiff)\n      newHeight = Math.max(150, startHeight - heightDiff)\n\n      newY = startPosY + (startHeight - newHeight)\n      break\n    }\n    case 'bottom-left': {\n      newWidth = Math.max(150, startWidth - widthDiff)\n      newHeight = Math.max(150, startHeight + heightDiff)\n\n      newX = startPosX + (startWidth - newWidth)\n      break\n    }\n    case 'bottom-right': {\n      newWidth = Math.max(150, startWidth + widthDiff)\n      newHeight = Math.max(150, startHeight + heightDiff)\n\n      break\n    }\n  }\n\n  updateCardSize(card.id, newWidth, newHeight)\n  updateCardPosition(card.id, newX, newY)\n}\n```\n\nThis can be tricky to understand, so let's go over it slowly.\n\n## What are clientX and clientY?\n\nLet's start by looking at the card's `positionX` and `positionY`. These are the coordinates of the top left corner of the card.\n\nYou may wonder what coordinates? Well, `positionX` is how many pixels from the left edge of the screen the card is, and `positionY` is how many pixels from the top edge of the screen the card is. That's how the browser calculates the position of elements.\n\nThe same goes for `clientX` and `clientY`. These are the coordinates of the mouse pointer when the event happened. `clientX` is how many pixels from the left edge of the screen the mouse pointer is, and `clientY` is how many pixels from the top edge of the screen the mouse pointer is.\n\n## Difference calculation\n\n```tsx\nconst widthDiff = mouseMoveEvent.clientX - startX\nconst heightDiff = mouseMoveEvent.clientY - startY\n```\n\nLet's say the mouse was at `clientX` 100 when resizing started, and now it's at 150. The difference would be 50. This is how we calculate how much the mouse has moved. If `clientX` has increased, it means the mouse moved to the right. If it has decreased, it means the mouse moved to the left.\n\nIf `clientY` has increased, it means the mouse moved down. If it has decreased, it means the mouse moved up.\n\nSo if the difference for e.g. `clientX` is negative, it means `clientX` has decreased, and the mouse moved to the left, because we started at a position much further to the right.\n\nNow, with that out of the way, let's look at each case!\n\n## Top left corner\n\nFor the top left corner, we know that if we resize the card, we want to not just calculate the new width and height, but also the new position of the card. Because the position of the card is the top left corner, we need to adjust the position of the card as we resize it.\n\n```tsx\nnewWidth = Math.max(150, startWidth - widthDiff)\nnewHeight = Math.max(150, startHeight - heightDiff)\n\nnewX = startPosX + (startWidth - newWidth)\nnewY = startPosY + (startHeight - newHeight)\n```\n\nWe are using `max` to make sure the card does not get too small. We do not want the card to be smaller than 150 pixels. This applies to all cases.\n\nWe can get the new width by subtracting the difference from the starting width. To understand this, we need some math. If the width difference is negative, it means the mouse moved to the left. Because we are dragging from the top left corner, we know that if we drag towards the left, the card should get wider. So if the width difference is negative, it would be e.g. `startWidth - (-50)`, which is the same as `startWidth + 50`. Minus and minus is a plus in math.\n\nWhat about the height?\n\nFor the height, it is the same thing. If the height difference is negative, it means the mouse moved up. If the mouse moved up, the card should get taller. So if the height difference is negative, it would be e.g. `startHeight - (-50)`, which is the same as `startHeight + 50`.\n\nDo you start to see how it works?\n\nIt just logic and basic math. We need to think about every corner and how it should behave when resizing.\n\nHow do we calculcate the new top left corner position of the card: `newX` and `newY`?\n\nWe know that the top left corner of the card is at `startPosX` and `startPosY`. We need to both adjust the offset from the left and the offset from the top.\n\n`newX = startPosX + (startWidth - newWidth)` -\u003e This is how we calculate the new `x` position of the card. Say the startPosX is 400, and the startWidth is 200, and the newWidth is 150. We would get `400 + (200 - 150)`, which is `400 + 50`, which is `450`. This is what we want here because more towards the right means a higher `x` value, which would mean the card shrunk.\n\nLet's do another example. Let's say the startPosX is 400, and the startWidth is 200, and the newWidth is 250. We would get `400 + (200 - 250)`, which is `400 - 50`, which is `350`. This is what we want here because more towards the left means a lower `x` value, which would mean the card grew.\n\nRemember, this is how it works for the top left corner. Case by case, the calculations are different.\n\n`newY = startPosY + (startHeight - newHeight)` -\u003e This is how we calculate the new `y` position of the card. Say the startPosY is 400, and the startHeight is 200, and the newHeight is 150. We would get `400 + (200 - 150)`, which is `400 + 50`, which is `450`. This is what we want here because more towards the bottom means a higher `y` value, which would mean the card shrunk.\n\n## Top right corner\n\nWe covered a lot in the past sections, so we will focus on the new things here.\n\nThis is the top right corner.\n\n```tsx\nnewWidth = Math.max(150, startWidth + widthDiff)\nnewHeight = Math.max(150, startHeight - heightDiff)\n\nnewY = startPosY + (startHeight - newHeight)\n```\n\n`newWidth = Math.max(150, startWidth + widthDiff)` -\u003e If widthDiff is negative, it means `clientX` has decreased, and the mouse moved to the left. If the most moved to the left, the card should get smaller because we are dragging from the top right corner. So if the width difference is negative, it would be e.g. `startWidth + (-50)`, which is the same as `startWidth - 50`.\n\n`newHeight = Math.max(150, startHeight - heightDiff)` -\u003e If heightDiff is negative, it means `clientY` has decreased, and the mouse moved up. If the mouse moved up, the card should get taller. So if the height difference is negative, it would be e.g. `startHeight - (-50)`, which is the same as `startHeight + 50`.\n\nBecause we can change the height of the top, which includes the top left corner, we also have to update `newY` which is the card's `y` position.\n\n`newY = startPosY + (startHeight - newHeight)` -\u003e This is how we calculate the new `y` position of the card. Say the startPosY is 400, and the startHeight is 200, and the newHeight is 150. We would get `400 + (200 - 150)`, which is `400 + 50`, which is `450`. This is what we want here because more towards the bottom means a higher `y` value, which would mean the card shrunk.\n\n## Bottom left corner\n\nThis is the bottom left corner.\n\n```tsx\nnewWidth = Math.max(150, startWidth - widthDiff)\nnewHeight = Math.max(150, startHeight + heightDiff)\n\nnewX = startPosX + (startWidth - newWidth)\n```\n\n`newWidth = Math.max(150, startWidth - widthDiff)` -\u003e If widthDiff is negative, it means `clientX` has decreased, and the mouse moved to the left. If the most moved to the left, the card should get wider because we are dragging from the bottom left corner. So if the width difference is negative, it would be e.g. `startWidth - (-50)`, which is the same as `startWidth + 50`.\n\n`newHeight = Math.max(150, startHeight + heightDiff)` -\u003e If heightDiff is negative, it means `clientY` has decreased, and the mouse moved up. If the mouse moved up, the card should get shorter. So if the height difference is negative, it would be e.g. `startHeight + (-50)`, which is the same as `startHeight - 50`.\n\nBecause we can change the left side, which includes the top left corner, we also have to update `newX` which is the card's `x` position.\n\n`newX = startPosX + (startWidth - newWidth)` -\u003e Say the startPosX is 400, and the startWidth is 200, and the newWidth is 150. We would get `400 + (200 - 150)`, which is `400 + 50`, which is `450`. This is what we want here because more towards the right means a higher `x` value, which would mean the card shrunk.\n\n## Bottom right corner\n\nThis is the bottom right corner.\n\n```tsx\nnewWidth = Math.max(150, startWidth + widthDiff)\nnewHeight = Math.max(150, startHeight + heightDiff)\n```\n\n`newWidth = Math.max(150, startWidth + widthDiff)` -\u003e If widthDiff is negative, it means `clientX` has decreased, and the mouse moved to the left. If the mouse moved to the left, the card should get smaller because we are dragging from the bottom right corner. So if the width difference is negative, it would be e.g. `startWidth + (-50)`, which is the same as `startWidth - 50`.\n\n`newHeight = Math.max(150, startHeight + heightDiff)` -\u003e If heightDiff is negative, it means `clientY` has decreased, and the mouse moved up. If the mouse moved up, the card should get shorter. So if the height difference is negative, it would be e.g. `startHeight + (-50)`, which is the same as `startHeight - 50`.\n\n## Preserving aspect ratio\n\nNow that we've gone through the resizing logic, let's talk about preserving the aspect ratio.\n\nWhen we resize the card, we don't want it to get distorted. We want it to remain a square. That's why when you look at the original code, you see that we calculate the new width and height, and then we calculate the maximum of the two. Now, maybe you could take the minimum of those two, but I decided to take the maximum and it works.\n\n```tsx\nconst maxNewWidthAndHeight = Math.max(newWidth, newHeight)\nnewWidth = maxNewWidthAndHeight\nnewHeight = maxNewWidthAndHeight\n```\n\n\u003c/details\u003e\n\n# Liveblocks\n\nLiveblocks is the service I used for the real-time collab stuff.\n\nIt's super neat, I love how it lets me be the one deciding how to authenticate.\n\nRather than being a complete package right away, it gives you the lego blocks for building collaborative web apps, including Browser Dev Tools for an awesome developer experience.\n\nAnother fun thing: It uses Cloudflare Durable objects [under the hood](https://liveblocks.io/docs/platform/websocket-infrastructure). The web socket servers sit on the edge.\n\n# Tech\n\n- Remix -\u003e Fullstack Web Framework\n- Liveblocks -\u003e Real time collaboration service\n- Vercel -\u003e Deployment\n- Railway -\u003e DB hosting (postgres)\n- Conform -\u003e Form validation\n- CSS -\u003e Styling\n- TypeScript -\u003e My love lmao\n- Playwright -\u003e Tests\n- Radix UI\n\n# License\n\nMIT 💞\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Ftigerabrodi%2Fgojo","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Ftigerabrodi%2Fgojo","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Ftigerabrodi%2Fgojo/lists"}