GithubHelp home page GithubHelp logo

d1no / excalidraw-spectate Goto Github PK

View Code? Open in Web Editor NEW
2.0 3.0 0.0 55 KB

WIP — Chrome extension that adds "Spectator Mode" to Excalidraw to enable clean video feed recording and improve cross domain collaboration by eliminating overwhelming distractions.

JavaScript 99.48% CSS 0.52%

excalidraw-spectate's Introduction

Technical Design Document

What originally started as a brief look of how to hide a few elements on Excalidraw.com from the outside via a Chrome extension, quickly became a general investigation of how to mutate the behavior of react based web apps from the outside in the form of notes as a architectural case study.



1. Motivation

Excalidraw — an open source whiteboarding web app — is a valuable tool to collaborate with none-technical domain experts on the spot; crucial in i.e. domain modeling and life cycle oriented machine learning (event storming). It is often very valuable to record these sessions, do a few post-edits and leverage them for documentation or explainer videos.

The build in real-time collaboration feature of Excalidraw already allows us to simply open another window and use the real-time nature of that second view as a "Spectator":

  • A perspective that watches the whole action from a global perspective over time, avoiding the disorientation of switching between different point of views.

1.1. Validation

You see the ↓ lower window from the creator perspective: very noisy.

The upper window ↑ however, acts as a "Spectator" with a fixed view that is much more pleasant to watch.

excalidraw_spectator_value_proposition

This separation allows us to easily track back the discovery process, give credit to collaborators in presentations as a time-lapse, and cut out typos and mistakes. For explainer videos, it enables us a priori to zoom in on elements through the magic of video editing.

1.2. Goal: Spectator Mode

The goal is to only show the whiteboards canvas, nothing more. From there, potentially enable to screen record at a much high canvas pixel resolution for post editing and explainer videos with some value added capabilities.

Excalidraw has currently three view modes:

  • Default: Shows all the creation tools.
  • Zen Mode: Hides the editor side bar.
  • View Mode: Hides the top toolbar.

There are still a few things that prevent a 100% distraction free presentation and post video workflow (like zooming in or out in a video editor without running into fixed size UI elements). So we need to hide these elements as well:

  • UI Buttons (🔵): All overlays like the menu and zoom.
  • Collaborator (🔴)
    • Highlights: The outlines around the collaborators selections.
    • Names: The names of the collaborators (privacy).
    • Cursors: The cursors of the collaborators.

specator_mode_delta_status_quo

1.3. Solution: Chrome Extension

The fastest path to verification of the idea is to create a chrome extension that

  • ...injects a few lines of CSS or code to hide the UI elements (🔵, easy since react components).
  • ...injects a few lines of JavaScript to hide the collaborator elements (🔴, much more difficult since inside the canvas).
  • ...provides a simple window that can be recorded with screen recording software like Kap, OBS or in real-time via recorded web meetings.

1.4. Additional Idea: Onion Skinning

After that, an extended feature could be to handle the screen recording directly in the extension via the MediaStream API; not only allowing to record the canvas with a higher resolution and with transparency directly (separating the spectator zoom level from the actual recording), but also generate a multi layer video or project file that enables control over individual elements in post production.

Additionally, for the purpose of well prepared live teaching, elements at a specific opacity could be hidden from the spectator view... yielding as a sort of "onion skinning" for the creator to live redraw complex diagrams without forgetting crucial elements and not being locked in to following a script. Example:

spectator_mode_onion_skinning_idea

At a later point in time it may makes sense to attempt to contribute this functionality to the Excalidraw codebase directly. But for now an independent browser specific extension appears to be a sensible approach for now to avoid forking the codebase. This also mitigates a direct dependency on the Excalidraw team, which appears to be overwhelmed with issues and requests already.


2. Approach Analysis

Excalidraw makes use of ReactJS and the Canvas API to render the whiteboard. The canvas is a 2D rasterized image that is rendered on the GPU and does not expose any DOM elements to the browser.

specator_mode_delta_status_quo

The UI buttons (🔵) are React components that are rendered on top of the canvas. They are not part of the canvas itself and could easily be hidden with CSS added by a Chrome Extension.

The collaborator elements (🔴) however, though still state managed by ReactJS, are rendered directly inside the canvas. They are not DOM elements, cannot be hidden with CSS and therefore need to be controlled with JavaScript by either

  • (A) patch the minified JavaScript code that renders the canvas (hacky, but easy to implement)
  • (B) dispatching state updates directly to the ReactJS fiber tree (elegant, but more difficult to implement)
  • (C) identifying and use Excalidraw globals outside of the ReactJS scope to change relevant states (limited, but less intrusive)

2.1. Understanding the Inner-Workings

When a collaborator session is started, the apps canvas is switched out to an <InteractiveCanvas> react component that takes among others props.appState.collaborators. Inside a useEffect the component loops over the collaborators (user) array object to identify what to render:

  • all elements that are selected, giving them the outline (user.selectedElementIds)
  • cursor position (user.pointer and user.pointer)
  • the username (user.username)
  • the users state (user.userState)

So the code responsible for rendering collaborator (🔴) elements is located inside src/components/canvases/InteractiveCanvas.tsx (here) and driven by render props under appState which follows the type Collaborator[] located in src/types.ts (here).

2.1.1. Addressing Rendering Stage

Since we are only interested in simply "hiding" the visual representation of collaborators (users), we could add a conditional directly to the forEach loop inside the <InteractiveCanvas> to hide it.

// src/components/canvases/InteractiveCanvas.tsx
const InteractiveCanvas = (props: InteractiveCanvasProps) => {
  // ...
  useEffect(() => {
    // ...
    props.appState.collaborators.forEach((user, socketId) => {
      if (!SPECTATOR_MODE) { // <-- new conditional

This however leaves the state lingering, changing the render logic as a side effect.

2.1.2. Addressing State Stage

Intercepting appState.collaborators directly to conditionally allow or block its inclusion via a proxy or dispatching react fiber updates to the retrieved react instance... (todo more investigation where state is stored and where it comes from)

2.2. Approach (A): Patching the Minified JavaScript

This approach hinges on mutating the minified ReactJS JavaScript bundle seen below before it is executed by the browser. In this case, the effect hooks closure would be changed directly by searching for appState.collaborators and replacing it with a variant that includes a conditional to disable the rendering. The transpile version of the code below stems from src/components/canvases/InteractiveCanvas.tsx (here).

b.useEffect)((function() {
  // ...
    e.appState.collaborators.forEach((function(t, n) {
        if (t.selectedElementIds)
            for (var l = 0, c = Object.keys(t.selectedElementIds); l < c.length; l++) {
                var u = c[l];
                u in i || (i[u] = []),
                i[u].push(n)
// ...

2.2.1. Concerns

Though this works in principle, intercepting js scripts and changing them with a chrome extension feels relative intrusive for "simply hiding" a few elements. It likely requires the use of the chrome.declarativeNetRequest API in manifest V3 (webRequestBlocking in V2) which in itself is a very high permission to hand to a chrome extension. So this might be ok for a short term solution, but likely not a public one.

2.3. Approach (B): Dispatching React Fiber Updates

This approach is based on the idea of dispatching updates to the ReactJS fiber tree directly. This is a much more elegant approach, but requires a lot more effort and understanding of the Excalidraw codebase as we need to manually read and dispatch against the react instance. This however, would allow is to interact with the app instead of patching it.

The general approach is to use ReactJS' build in-feature to provide access to the fiber tree. It is the same method the React Developer Tools Chrome Extension uses to inspect components and their state.

Here, the ReactJS instance is exposed via window.__REACT_DEVTOOLS_GLOBAL_HOOK__ and can be accessed via window.__REACT_DEVTOOLS_GLOBAL_HOOK__.renderers which is an object that contains all the ReactJS instances on the page. From there, we can maybe access the Excalidraw instance via something like window.__REACT_DEVTOOLS_GLOBAL_HOOK__.renderers[0].renderer.component.element._owner.stateNode which should be the root component of the Excalidraw app.

2.3.1. Concerns

This approach is much more elegant, but requires a lot more effort in understanding how state is managed in Excalidraw and how to dispatch updates against the ReactJS fiber tree.

At the moment, the App.tsx file alone inside the Excalidraw open source project has over 8k lines of code with almost no comments.... which is unusual even for veteran ReactJS engineers. Therefore maybe unreasonable effort to work through just to hide a few elements.

2.4. Approach (C): Excalidraw Globals

Lets compare what globals are available in the Excalidraw web app in the browser compared to an empty iframe (eliminating standard window globals) by running in the console:

(function () {
  var iframe = document.createElement("iframe");
  document.body.appendChild(iframe);
  var standardGlobals = Object.keys(iframe.contentWindow);
  document.body.removeChild(iframe);
  var allGlobals = Object.keys(window);
  var nonStandardGlobals = allGlobals.filter(function (g) {
    return !(
      standardGlobals.includes(g) ||
      g === "iframe" ||
      g === "standardGlobals" ||
      g === "nonStandardGlobals"
    );
  });
  console.log(nonStandardGlobals);
})();

2.4.1. Globals

All results from 2023-11-21.

2.4.1.1. Excalidraw Local

Cloning the excalidraw repository and running yarn start:production on the master branch.

8 app globals

[
  "EXCALIDRAW_ASSET_PATH",
  "EXCALIDRAW_THROTTLE_RENDER",
  "__EXCALIDRAW_SHA__",
  // ... rest simple analytics and error reporting (sentry)
  "__SENTRY__",
  "scriptEle",
  "sa_event_loaded",
  "sa_loaded",
  "sa_event",
];

2.4.1.2. Excalidraw Local with Collaboration

Running excalidraw in dev mode via yarn start and also cloning the excalidraw-room repository and running it from the master branch in the background via yarn start:dev to have a websocket turn server.

1 additional app global

[
  "collab",
  // ... since dev mode, lots of debug globals
];

collab provides global access to the collaborators state, the excalidrawAPI to retrieve and update scene data and many other methods.

Full list of properties on window.collab
[
    "_reactInternalInstance"
    "_reactInternals",
    "activeIntervalId",
    "beforeUnload",
    "broadcastElements",
    "collaborators",  // <-- collaborators and with selected ElementIDs
    "context",
    "decryptPayload",
    "destroySocketClient",
    "excalidrawAPI",  // <-- ExcalidrawAPI to retrieve & update scene data
    "fallbackInitializationHandler",
    "fetchImageFilesFromFirebase",
    "fileManager",
    "getLastBroadcastedOrReceivedSceneVersion",
    "getSceneElementsIncludingDeleted",
    "handleClose",
    "handleRemoteSceneUpdate",
    "idleTimeoutId",
    "initializeIdleDetector",
    "initializeRoom",
    "isCollaborating",
    "lastBroadcastedOrReceivedSceneVersion",
    "loadImageFiles",
    "onIdleStateChange",
    "onOfflineStatusToggle",
    "onPointerMove",
    "onPointerUpdate",
    "onUnload",
    "onUsernameChange",
    "onVisibilityChange",
    "portal",
    "props",
    "queueBroadcastAllElements",
    "queueSaveToFirebase",
    "reconcileElements",
    "refs",
    "reportActive",
    "reportIdle",
    "saveCollabRoomToFirebase",
    "setIsCollaborating",
    "setLastBroadcastedOrReceivedSceneVersion",
    "setUsername",
    "socketInitializationTimer",
    "startCollaboration",
    "state",
    "stopCollaboration",
    "syncElements",
    "updater",
]

❌ However, it appears that this global is only available during testing and dev and not in production mode excalidraw-app/collab/Collab.tsx (here

if (import.meta.env.MODE === ENV.TEST || import.meta.env.DEV) {
  window.collab = window.collab || ({} as Window["collab"]);
  Object.defineProperties(window, {
    collab: {
// ...

2.4.1.3. Excalidraw.com

Accessing the website excalidraw.com in incognito mode and starting a collaboration session with another window.

❌ No collab. Only the 8 app globals like in the local version.

2.4.2. Thoughts

...potentially re-enable the collab global in production mode via a chrome extension?

...needs re-tracing of the sourcemap to the conditional in the Collab.tsx file.


3. Implementation Research

Understanding the relevant constraints for the implementation. All insights from 2023-11-25.

3.1. Excalidraw

Regarding the inner workings of Excalidraw.

3.1.1. Inner State

3.1.1.1. Source of appState

The app state of Excalidraw is provided via a higher order component called <ExcalidrawAppStateContext.Provider> in App.tsx (here) as context. And is directly provided to the <InteractiveCanvas> component via the appState prop.

<AppContext.Provider value={this}>
  // ...
  {/* Setting the appState via Context */}
  <ExcalidrawSetAppStateContext.Provider value={this.setAppState}>
    {/* Getting the appState from Context */}
    <ExcalidrawAppStateContext.Provider value={this.state}>
      // ...
      <InteractiveCanvas
        // ...
        elements={canvasElements}
        visibleElements={visibleElements}
        selectedElements={selectedElements}
        // ...
        appState={this.state} // <--------- appState from props
        // ...
      />
      // ...
    </ExcalidrawAppStateContext.Provider>
  </ExcalidrawSetAppStateContext.Provider>
  // ...
</AppContext.Provider>

The corresponding react context hooks are provided as useExcalidrawAppState and useExcalidrawSetAppState from App.tsx (here)

// App.tsx

const ExcalidrawAppStateContext = React.createContext<AppState>({
  ...getDefaultAppState(),
  // ...
});
ExcalidrawAppStateContext.displayName = "ExcalidrawAppStateContext";

const ExcalidrawSetAppStateContext = React.createContext<
  React.Component<any, AppState>["setState"]
>(() => {
  console.warn("unitialized ExcalidrawSetAppStateContext context!");
});
ExcalidrawSetAppStateContext.displayName = "ExcalidrawSetAppStateContext";

// ...

export const useExcalidrawAppState = () =>
  useContext(ExcalidrawAppStateContext);
export const useExcalidrawSetAppState = () =>
  useContext(ExcalidrawSetAppStateContext);

3.1.1.2. Setting appState

In App.tsx a method is defined called setAppState (here).

class App extends React.Component<AppProps, AppState> {
  // ...
  setAppState: React.Component<any, AppState>["setState"] = (
    state,
    callback,
  ) => {
    this.setState(state, callback);
  };
  // ...

This is also exposed via the useExcalidrawSetAppState above. The use of this method is however limited to the App.tsx as it seems to just be a co-implementation to the context hooks above.

Many of the internal activities seem to also be done via the ExcalidrawAPI, i.e. in Collab.tsx (here).

3.1.2. Outer Collaboration Protocol (WebSocket, Polling)

Excalidraw uses socketIO to communicate with the websocket turn server. The socket is initialized in Collab.tsx (here).

      this.portal.socket = this.portal.open(
        socketIOClient(socketServerData.url, {
          transports: socketServerData.polling
            ? ["websocket", "polling"]
            : ["websocket"],
        }),
        roomId,
        roomKey,

The protocol transports the information related to the collaborators mouse position, usernames and selected elements simply as MOUSE_LOCATION, as seen in data/index.tsx (here).

export type SocketUpdateDataSource = {
  // ...
  MOUSE_LOCATION: {
    type: "MOUSE_LOCATION";
    payload: {
      socketId: string;
      pointer: { x: number; y: number; tool: "pointer" | "laser" }; // <--
      button: "down" | "up";
      selectedElementIds: AppState["selectedElementIds"]; // <--
      username: string; // <--
    };
  };
  // ...
};

This payload is parsed in Collab.tsx and updated via the excalidrawAPI (here).

  case "MOUSE_LOCATION": {
    const { pointer, button, username, selectedElementIds } =
      decryptedData.payload; // <-------------------From encrypted payload
    const socketId: SocketUpdateDataSource["MOUSE_LOCATION"]["payload"]["socketId"] =
      decryptedData.payload.socketId ||
      // @ts-ignore legacy, see #2094 (#2097)
      decryptedData.payload.socketID;


    const collaborators = new Map(this.collaborators);
    const user = collaborators.get(socketId) || {}!;
    user.pointer = pointer;
    user.button = button;
    user.selectedElementIds = selectedElementIds;
    user.username = username;
    collaborators.set(socketId, user);
    this.excalidrawAPI.updateScene({
      collaborators, // <-------------------------- Updated via ExcalidrawAPI
    });

Blocking the MOUSE_LOCATION payload from the websocket would therefore be sufficient to hide the collaborators elements; problem is that the data is encrypted.

❌ The Problem: The whole payload is a binary blob and not a JSON object. So we cannot simply filter the payload via the chrome extension. We would have to encode and decode again.

3.1.3. Metaprogramming of the Runtime for Event Extensions

Instead of interacting with the state directly (i.e. via ReactJS fiber tree) or interpret the outer protocols, we can adjust the runtime of the application itself to provide event hooks to general runtime functions and adjust its state this way.

Excalidraw makes use of the Map javascript feature to hold key-value pairs of its collaborator state received from its SocketIO connection in Collab.tsx (here):

  case "MOUSE_LOCATION": {
    // ...
    const collaborators = new Map(this.collaborators);
    // ...
    user.pointer = pointer;
    // ...
    user.selectedElementIds = selectedElementIds;
    user.username = username;
    // ...
    collaborators.set(socketId, user); // <------------------------ Set of Map

This runtime feature populates the applications component states with the collaborators pointer and selectedElementIds AppState, which is later used to represent it in the canvas as a cursor and selection outline in InteractiveCanvas.tsx (here):

  props.appState.collaborators.forEach((user, socketId) => { // <-- Get of Map
    if (user.selectedElementIds) {

Map was introduced to Javascript in 2015 with EcmaScript 6 and Excalidraw doesn't transpile its TypeScript code below that. This means, we can use the Map prototype to intercept the set method and filter the pointer and selectedElementIds before they are set on the collaborator state; essentially simply withholding the information for the canvas rendering.

Performance wise, this is a very efficient approach comparative wise as

  • we don't need to traverse the ReactJS fiber tree
  • we don't need to intercept the websocket protocol and run crypto operations
  • we can filter for a very specific key value pair
  • both react itself and excalidraw with its dependencies use Maps`` and .set` sparingly (probably due to the historic effort to stay compatible to older browsers)
  • excalidraw isn't optimized in that regard anyway, as it instantiates new Maps on every update, which doesn't feel like a huge deal thanks to JIT compilation
  • as new Map() operation is 1000x more expensive than a delete key operation on an object

By using JavaScripts new Proxy(target, handler) feature, we can extend the storage of key value pairs with appropriate hooks without having to replicate the whole Map interface.

3.1.4. Changing Excalidraw, Access to API, Collab, Architecture

If we would want to minimally change the codebase to make the integration of a chrome extension easier, we could

  • (A) suggest to provide the excalidrawAPI as a global in production mode, potentially as EXCALIDRAW_API_INSTANCE
  • (B) to allow the collab global to be available in production mode, potentially as EXCALIDRAW_COLLAB_INSTANCE

This would allow use to apply getters and setters on the relevant state directly via the global even though the application doesn't use event sourcing or repository patterns directly. This might be worth a look once we have crossed the proof of concept and validation stage.

3.2. Chrome Extension

Chrome Extension work in a sandboxed environment and cannot access the DOM of the page directly. Instead, they can inject JavaScript and CSS into the page via the content_scripts property in the manifest.json file.

The newer Manifest V3 is the new standard for Chrome Extensions. This extension will require an up-to-date version of Chrome.

3.2.1. Running alongside Excalidraw

Content scripts can in MV3 be directly executed within the javascript context of the page by using the world property. This allows access to globals and instances without the need to inject a script tag into the dom.

This is relevant for checking if EXCALIDRAW is present by i.e. looking for the window.EXCALIDRAW_ASSET_PATH global.

3.2.2. Loading before Excalidraw

Content scripts can be loaded at document start, allowing to add code globals and proxied methods.

This allows us to register react dev tool hooks to retrieve the react instance and fiber tree.

3.2.3. Recording the Canvas

With Chrome version 16 it is possible to handle tab recording in the extension itself via the MediaStream API and an offscreen document

This could allow us to record the canvas with a higher resolution and with transparency directly (separating the spectator zoom level from the actual recording).

3.2.4. Interception of WebSockets

We can potentially intercept the WebSocket connection to the Excalidraw browser by patching the websocket browser method, wrap it in a proxy and simply drop packets related to the collaborators mouse cursor and selected ElementIds. This means, we don't need to interact with react on an instance and fiber basis to influence the specific rendering behavior.

This would be done via a content script, executing before the Excalidraw app is loaded, replacing the default websocket interface with our implementation that filters packets.

3.3. ReactJS Fiber Tree

Noteworthy reads on the ReactJS Fiber Tree in case of interacting with it from the outside with a chrome extension directly.

3.3.1. Projects & Resources

3.3.2. Blog Posts & Articles

4. Solution

Given the implementation angles above, the most promising approach appears to be metaprogramming of the runtime of Map for an initial version. This solution doesn't require extensive permissions for the Chrome Extension and also doesn't require any changes to the Excalidraw codebase.

Interacting with the react fiber tree directly is interesting, but requires a lot more effort in traversing the various higher order components and finding the right life cycle states.

4.1. Chrome Extension

Adding functionality to Excalidraw without changing the codebase itself.

4.1.1. Chrome Extension for Life Cycle Management

We will make use of content scripts and their ability to run in the same context as the page itself to integrate relevant life cycle hooks. Than, outside of the main thread, we can use signals to communicate with the extension itself and freely integrate our features.

4.1.2. Conditionally Render Collaborator Elements

The following example code, when run before the Excalidraw app is loaded, will wrap the Map constructor in a proxy and intercept the set method. This allows us to filter the pointer and selectedElementIds before they are set on the collaborator state; essentially just withholding that information for canvas rendering.

const EXCALIDRAW_HIDE_POINTER = true;
const EXCALIDRAW_HIDE_SELECTED_ELEMENTS = true;

// Check i.e. for a fragment like #spectate to proxy when necessary.

const OriginalMap = Map;

window.Map = new Proxy(OriginalMap, {
  construct(target, args) {
    const originalMapInstance = new target(...args);

    const originalSet = originalMapInstance.set;

    // Wrap the original 'set' method
    originalMapInstance.set = function (key, value) {
      // Check if we need to even take a look at this operation
      if (EXCALIDRAW_HIDE_POINTER || EXCALIDRAW_HIDE_SELECTED_ELEMENTS) {
        // Check if key is string and value is an object
        if (typeof key === "string" && typeof value === "object") {
          // Check if value has pointer and selectedElementIds properties
          // and delete accordingly.

          if (EXCALIDRAW_HIDE_POINTER && "pointer" in value) {
            delete value.pointer;
          }

          if (
            EXCALIDRAW_HIDE_SELECTED_ELEMENTS &&
            "selectedElementIds" in value
          ) {
            delete value.selectedElementIds;
          }
        }
      }

      return originalSet.call(this, key, value);
    };

    // Return the modified map instance directly
    return originalMapInstance;
  },
});

4.1.3. Conditionally Render UI

To hide the UI elements, we can directly identify them via dom nodes and hide them with CSS. Eventually we can fire keyboard shortcuts ourself to activate view and zen mode; limiting the UI elements to be hidden.

4.1.4. Controlling Color

The Collaborator type in src/types.ts (here) has a color property.

export type Collaborator = {
  // ...
  color?: {
    background: string;
    stroke: string;
  };
  // ..
};

This property is however not used in the rendering of the cursor on the local and free version of excalidraw. It is probably used in the plus version in some way. So how is the color determined?

The cursor color, or rather the color of each user, is derived by default from the the client or socketid via a heuristic in clients.ts as getClientColor via an hashToInteger algorithm (here).

function hashToInteger(id: string) {
  let hash = 0;
  if (id.length === 0) {
    return hash;
  }
  for (let i = 0; i < id.length; i++) {
    const char = id.charCodeAt(i);
    hash = (hash << 5) - hash + char;
  }
  return hash;
}

export const getClientColor = (
  /**
   * any uniquely identifying key, such as user id or socket id
   */
  id: string
) => {
  // to get more even distribution in case `id` is not uniformly distributed to
  // begin with, we hash it
  const hash = Math.abs(hashToInteger(id));
  // we want to get a multiple of 10 number in the range of 0-360 (in other
  // words a hue value of step size 10). There are 37 such values including 0.
  const hue = (hash % 37) * 10;
  const saturation = 100;
  const lightness = 83;

  return `hsl(${hue}, ${saturation}%, ${lightness}%)`;
};

This function is than used inside of renderScene.ts to paint the remote cursors with the color derived from the id (here:

  // Paint remote pointers
  for (const clientId in renderConfig.remotePointerViewportCoords) {
    let { x, y } = renderConfig.remotePointerViewportCoords[clientId];

    // ...

    const background = getClientColor(clientId); // <--- Color derived from id

    // ...
    context.strokeStyle = background;
    context.fillStyle = background;

To ensure that always the same color is used for rendering the cursor, we can simply stochastically retrieve an id that falls into a certain hue color block (there are only 37 options). This is especially important for recording videos, to make sure we are not on rainbow road when multiple takes a spliced together (changing cursor colors due to new socket ids).

// Collapsed algorithm from the Excalidraw logic to evaluate against.
function hashToIntegerHueDeciBlock(id: string): number {
  let hash = 0;
  for (let i = 0; i < id.length; i++) {
    const char = id.charCodeAt(i);
    hash = (hash << 5) - hash + char;
  }
  return Math.abs(hash) % 37;
}

// Finds a new ID that has the desired hue color space in blocks of 10 at a time complexity of O(nk) where n is the number of existing IDs and k the number of iterations needed to find a unique ID which depends on the target length.
// This is not deterministic but finding a new ID against 10 existing IDs with a length of 20 takes about 3ms.
function generateUniqueIdForTargetHueDeciBlock(
  targetHueDeciBlock: number,
  existingIds: string[],
  idLength: number,
  debug: boolean = false
): string | null {
  if (targetHueDeciBlock < 0 || targetHueDeciBlock > 36 || idLength < 1) {
    console.error("Out of bounds in targetHueDeciBlock or invalid idLength");
    return null;
  }

  const letters = "abcdefghijklmnopqrstuvwxyz";
  const numbers = "0123456789";
  const allChars = letters + numbers;
  let newId = "";
  let iterationCount = 0;

  let startTime;
  if (debug) {
    startTime = performance.now();
  }

  while (true) {
    iterationCount++;
    // Start with a letter to make sure we don't get any JS type casting surprises
    newId = letters.charAt(Math.floor(Math.random() * letters.length));
    for (let i = 1; i < idLength; i++) {
      // Start from 1 because the first char is already a letter
      newId += allChars.charAt(Math.floor(Math.random() * allChars.length));
    }

    // Check if the new ID is unique to the whole set and has the desired HeuDeci value
    if (
      !existingIds.includes(newId) &&
      hashToIntegerHueDeciBlock(newId) === targetHueDeciBlock
    ) {
      if (debug) {
        const endTime = performance.now();

        console.log(
          `Found for Hue ${targetHueDeciBlock * 10} the new ID: ${newId}`
        );
        console.log(
          `Iterations: ${iterationCount} against ${existingIds.length} existing IDs`
        );
        console.log(`Time taken: ${(endTime - startTime).toFixed(2)} ms`);
      }
      // Leaving the loop
      break;
    }
  }

  // Return the new ID
  return newId;
}

We can shift the key with the same Map hook, memoize it and potentially offer the option to apply a color to specific usernames. As our hook only influencing render states by matching the collaborators shape, we are behind the socketIO life cycle meaning we only interact with its intermediary state representation.

This is obviously a bit insane, but ok for an initial "I'm on an island, can't involve anybody"-version.

4.2. User Experience

To avoid having to pin the extension to the tool bar, we can make use of the chrome.declarativeContent API to automatically show the extension when the user is on the whiteboard.

We can also completely integrate it onto the creators screen (i.e. triangle in the top right corner) that when clicked

  • prompts the user to activate collaboration mode
  • when a #room fragment is detected in the url, offer to create a spectator window (and warn to disable pop-up blockers)
  • that spectator window should be a pop-up since it has smaller bezels
  • open that window with an additional fragment attached to the url to signal the extension to activate spectator mode on that window (and potentially other features)

4.3. Future Ideas

4.3.1. Recording the Canvas

After this, we can explore capturing the canvas element via the MediaStream API and an offscreen document, potentially remapping its size to with a higher resolution.

From a workload perspective, we should be able to do this in a WebWorker allowing us to implement more intelligent capture features like only recording collaborator events, track mouse movements as a separate layer and record the canvas with "infinite" size by chunking recorded bitmaps to current relative coordinates.

4.3.2. Onion Skinning

It is likely possible to track the scenes element state in a similar metaprogramming fashion, allowing us to introduce hooks to change i.e. the transparency of elements in the spectator view to zero if they are below a certain threshold.

excalidraw-spectate's People

Contributors

d1no avatar

Stargazers

 avatar  avatar

Watchers

 avatar  avatar  avatar

excalidraw-spectate's Issues

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.