feat: Share URLs

Introduces a new share dialog that contains buttons to copy a new share URL

Fixes #33 (closed)

Related to #44

Screenshots

image

New Dependencies

  • canvas to create and render elements server-side (⚠️ This introduces new binary requirements, see project)
  • isbot to identify crawlers

Implementation

A new endpoint is served (/share/X,Y/z=ZOOM?[...]) that will redirect normal browsers to the standard UI (/#x=X&y=Y&...) and bots that were detected by isbot will be served a dummy page with opengraph metadata to render information to the user (eg, when sent in an app like Discord)

Preview images of the canvas are available now at /api/share/preview?x=X&y=Y&zoom=ZOOM and will be cached for 5 minutes server-side and clients are encouraged to cache the image for 15 minutes

URL Schema (/share/...)

  • /share/{x},{y}/z={zoom}?template={template}&x={templatex}&y={templatey}&w={templatewidth}&style={templatestyle}
  • /share/{x},{y}?template={template}&x={templatex}&y={templatey}&w={templatewidth}&style={templatestyle}
Template Type What?
{x} number pixel to center on
{y} number pixel to center on
{zoom} number zoom level
{template} url template image URL, the UI will generate a data: uri for images (easier for static linking)
{templatex} number top left corner of the template placement
{templatey} number top left corner of the template placement
{templatewidth} number width of the template in pixels
{templatestyle} enum what template style to use

Preview Endpoint (/api/share/preview)

  • Server cached by 5 minutes
  • Clients encouraged to cache for 15 minutes (Cache-Control)
Query Type What?
x number pixel to be centered on
y number pixel to be centered on
zoom number? zoom scale (pixels included in each direction = 5 * (zoom + 1))
Edited by Grant

Merge request reports

Loading
+5 −0
Changes for packages/client/src/components/Templating/Template.tsx: 5 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -148,6 +148,11 @@ const $Template = () => {
    instance.current.setOption("style", style);
  }, [style]);

  useEffect(() => {
    instance.current?.setOption("x", x);
    instance.current?.setOption("y", y);
  }, [x, y]);

  return (
    <div
      id="template"
+13 −32
Changes for packages/client/src/components/Toolbar/CanvasMeta.tsx: 13 added lines, 32 removed lines.
Original line number Diff line number Diff line
@@ -11,6 +11,8 @@ 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";

const getTimeLeft = (available: number, config: ClientConfig) => {
  /**
@@ -81,7 +83,7 @@ const OnlineCount = () => {
export const CanvasMeta = () => {
  const [available, setAvailable] = useState(-1);
  const { canvasPosition, cursor, config } = useAppContext<true>();
  const { isOpen, onOpen, onOpenChange } = useDisclosure();
  const Dialog = useDialog();

  useEffect(() => {
    const handle = ({ available }: { available: number }) => {
@@ -101,7 +103,15 @@ export const CanvasMeta = () => {
      <div id="canvas-meta" className="toolbar-box">
        {canvasPosition && (
          <span>
            <button className="btn-link" onClick={onOpen}>
            <button
              className="btn-link"
              onClick={() =>
                Dialog.dispatch({
                  action: "OPEN_DIALOG",
                  dialog: { id: "SHARE_MODAL" },
                })
              }
            >
              ({canvasPosition.x}, {canvasPosition.y})
            </button>
            {cursor.x !== undefined && cursor.y !== undefined && (
@@ -128,36 +138,7 @@ export const CanvasMeta = () => {
          </span>
        </span>
      </div>
      <ShareModal isOpen={isOpen} onOpenChange={onOpenChange} />
    </>
  );
};

const ShareModal = ({
  isOpen,
  onOpenChange,
}: {
  isOpen: boolean;
  onOpenChange: () => void;
}) => {
  // TODO: implement sharemodal
  return (
    <Modal isOpen={isOpen} onOpenChange={onOpenChange}>
      <ModalContent>
        {() => (
          <>
            <ModalHeader className="flex flex-col gap-1">
              share modal
            </ModalHeader>
            <ModalBody>
              <p>share the current zoom level & position as a url</p>
              <p>
                params would be not a hash so the server can generate an oembed
              </p>
            </ModalBody>
      <ShareModal />
    </>
        )}
      </ModalContent>
    </Modal>
  );
};
+151 −0
Changes for packages/client/src/components/Toolbar/ShareModal.tsx: 151 added lines, 0 removed lines.
Original line number Diff line number Diff line
import { useDialog } from "@/contexts/DialogContext";
import { CanvasCore } from "@/lib/canvas";
import { KeybindManager } from "@/lib/keybinds";
import { Template, type ITemplateState } from "@/lib/template";
import {
  Alert,
  Link,
  Modal,
  ModalBody,
  ModalContent,
  ModalHeader,
} from "@heroui/react";
import React, { useCallback, useEffect, useRef, type MouseEvent } from "react";
import { Button } from "../core/Button";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faCopy } from "@fortawesome/free-solid-svg-icons";
import { toast } from "react-toastify";
import { useTemplateContext } from "@/contexts/TemplateContext";

export const ShareModal = () => {
  const Dialog = useDialog();
  const template = useTemplateContext();
  const canvas = useRef<CanvasCore>(null);

  useEffect(() => {
    const open = () => {
      Dialog.dispatch({ action: "OPEN_DIALOG", dialog: { id: "SHARE_MODAL" } });
    };

    KeybindManager.on("SNAPSHOT", open);

    return () => {
      KeybindManager.off("SNAPSHOT", open);
    };
  }, []);

  useEffect(() => {
    canvas.current = CanvasCore.get(false);
  }, [Dialog.state.dialogs]);

  const url = useCallback((withTemplate: boolean) => {
    if (canvas.current) {
      return String(
        ShareURL(
          canvas.current?.panZoomTransformToCanvas(),
          withTemplate ? (Template.getState() ?? undefined) : undefined,
        ),
      );
    }
    return null;
  }, []);

  return (
    <Modal
      isOpen={"SHARE_MODAL" in Dialog.state.dialogs}
      onOpenChange={(open) =>
        open
          ? Dialog.dispatch({
              action: "OPEN_DIALOG",
              dialog: { id: "SHARE_MODAL" },
            })
          : Dialog.dispatch({ action: "CLOSE_DIALOG", dialogId: "SHARE_MODAL" })
      }
    >
      <ModalContent>
        {() => (
          <>
            <ModalHeader className="flex flex-col gap-1">Share</ModalHeader>
            <ModalBody>
              <p>Share your view of the canvas!</p>
              {canvas.current ? (
                <>
                  <CopyButton url={url(false)}>Just position</CopyButton>
                  <CopyButton isDisabled={!template.enable} url={url(true)}>
                    With template
                  </CopyButton>
                </>
              ) : (
                <Alert color="primary">
                  Unable to get canvas context, try refreshing
                </Alert>
              )}
            </ModalBody>
          </>
        )}
      </ModalContent>
    </Modal>
  );
};

const CopyButton = ({
  url,
  ...props
}: React.PropsWithChildren<Parameters<typeof Button>[0]> & {
  url: string | null;
}) => {
  const copy = useCallback(
    (e: MouseEvent) => {
      if (!url) return;
      if (e.ctrlKey || e.altKey || e.shiftKey) return;

      e.preventDefault();

      navigator.clipboard.writeText(url);
      toast.success("Copied to clipboard!");
    },
    [url],
  );

  return (
    <Button
      variant="flat"
      onClick={copy}
      startContent={<FontAwesomeIcon icon={faCopy} />}
      as={Link}
      href={url ?? undefined}
      {...props}
    >
      {props.children}
    </Button>
  );
};

const ShareURL = (
  {
    canvasX: x,
    canvasY: y,
    zoom,
  }: { canvasX: number; canvasY: number; zoom: number },
  template?: ITemplateState,
): URL => {
  let path = `/share/${x},${y}`;

  if (typeof zoom === "number") {
    path += `/z=${zoom}`;
  }

  if (template) {
    const params = new URLSearchParams();

    params.set("template", template.url);
    params.set("x", String(template.x));
    params.set("y", String(template.y));
    params.set("w", String(template.width));
    params.set("style", String(template.style));

    path += "?" + params;
  }

  return new URL(path, window.location.href);
};
+1 −0
Changes for packages/client/src/contexts/DialogContext.tsx: 1 added line, 0 removed lines.
Original line number Diff line number Diff line
@@ -20,6 +20,7 @@ interface Dialog {
    | { mode: "NORMAL"; url: string };
  EVENT_INFO: object;
  KEYBINDS: object;
  SHARE_MODAL: object;
}

interface IDialogContext {
+1 −1
Changes for packages/client/src/lib/canvas.ts: 1 added line, 1 removed line.
Original line number Diff line number Diff line
@@ -506,7 +506,7 @@ export class CanvasCore extends EventEmitter<CanvasEvents> {
    canvasX >>= 0;
    canvasY >>= 0;

    return { canvasX, canvasY };
    return { canvasX, canvasY, zoom };
  }

  debug(x: number, y: number, id?: string) {
Loading
Loading