Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Make RustPad just-enough compatible with EtherPad URLs #79

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 45 additions & 7 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,13 @@ import {
VscGist,
VscRepoPull,
} from "react-icons/vsc";
import useStorage from "use-local-storage-state";
import Editor from "@monaco-editor/react";
import { editor } from "monaco-editor/esm/vs/editor/editor.api";
import rustpadRaw from "../rustpad-server/src/rustpad.rs?raw";
import languages from "./languages.json";
import animals from "./animals.json";
import Rustpad, { UserInfo } from "./rustpad";
import useHash from "./useHash";
import { useHash, useParamOrState, useParamOrStorage } from "./useFromURL";
import ConnectionStatus from "./ConnectionStatus";
import Footer from "./Footer";
import User from "./User";
Expand All @@ -51,19 +50,58 @@ function generateHue() {
return Math.floor(Math.random() * 360);
}

/// Accepted URL parameters should be EtherPad compatible:
/// https://docs.etherpad.org/api/embed_parameters.html
///
/// Parameters corresponding to EtherPad:
/// * userName
/// * userColor (A passed RGB hex value is converted to hue)
/// Parameters supported by EtherPad but not Rustpad:
/// * showLineNumbers (always shown)
/// * showControls (unknown usage)
/// * showChat & alwaysShowChat (no equivalent)
/// * useMonospaceFont (always used)
/// * noColors (unsupported)
/// * lang & rtl (interface is only available in English in LTR direction)
/// * #L / jump to line number
/// Parameters supported by Rustpad but not Etherpad:
/// * contentLang: Programming language for syntax highlighting and suggestions
/// * darkMode: Force Rustpad to use light or dark color scheme

function App() {
const id = useHash(); // Normalizes URL

const toast = useToast();
const [language, setLanguage] = useState("plaintext");
const [language, setLanguage] = useParamOrState("contentLang", "string", "plaintext");
const [connection, setConnection] = useState<
"connected" | "disconnected" | "desynchronized"
>("disconnected");
const [users, setUsers] = useState<Record<number, UserInfo>>({});
const [name, setName] = useStorage("name", generateName);
const [hue, setHue] = useStorage("hue", generateHue);
const [name, setName] = useParamOrStorage("userName", "string", generateName);
const [hue, setHue] = useParamOrStorage("userColor", "string", generateHue, (value) => {
// Extract hue from CSS color
const valueHexParts = value.match(/^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i);
if (valueHexParts !== null) {
let [r, g, b] = [
parseInt(valueHexParts[1], 16) / 255,
parseInt(valueHexParts[2], 16) / 255,
parseInt(valueHexParts[3], 16) / 255,
];
// Source: https://en.wikipedia.org/wiki/Hue#Defining_hue_in_terms_of_RGB
return Math.atan2(Math.sqrt(3) * (g - b), 2 * r - g - b) * 180 / Math.PI;
}

// Accept direct hue value
const hue = parseFloat(value);
if (!isNaN(hue) && hue >= 0 && hue < 360) {
return hue;
}
});
const [editor, setEditor] = useState<editor.IStandaloneCodeEditor>();
const [darkMode, setDarkMode] = useStorage("darkMode", () => false);
const [darkMode, setDarkMode] = useParamOrStorage(
"darkMode", "boolean", () => window.matchMedia("(prefers-color-scheme: dark)")
);
const rustpad = useRef<Rustpad>();
const id = useHash();

useEffect(() => {
if (editor?.getModel()) {
Expand Down
116 changes: 116 additions & 0 deletions src/useFromURL.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { useEffect, useState } from "react";
import useStorage from "use-local-storage-state";

const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
const idLen = 6;

function getHash() {
let newUrl = new URL(window.location);
if (!window.location.hash) {
// Attempt retriving document ID as last part of path, moving it to the fragment
let filename = newUrl.pathname.split("/").pop();
if (filename != "" && filename != "index.html") {
newUrl.pathname = newUrl.pathname.slice(0, newUrl.pathname - filename.length);
newUrl.hash = filename;
} else {
// Generate random document ID as fallback / direct-hit
let id = "";
for (let i = 0; i < idLen; i++) {
id += chars[Math.floor(Math.random() * chars.length)];
}
newUrl.hash = id;
}
}

// Move parameters after document ID if present
if (newUrl.search) {
if (newUrl.hash.includes("?")) {
newUrl.hash += "&" + newUrl.search.slice(1);
} else {
newUrl.hash += newUrl.search;
}
newUrl.search = "";
}

if (newUrl.href != window.location.href) {
window.history.replaceState(null, "", newUrl.href);
}

return newUrl.hash.slice(1).split("?")[0];
}

function useHash() {
const [hash, setHash] = useState(getHash);

useEffect(() => {
const handler = () => setHash(getHash());
window.addEventListener("hashchange", handler);
return () => window.removeEventListener("hashchange", handler);
}, []);

return hash;
}

function getParam<T>(
key: string,
type: "string" | "boolean",
def: T = null,
): T | null
{
let searchString = window.location.hash.split("?")[1];
let searchParams = new URLSearchParams(typeof(searchString) === "string" ? searchString : "");
if (searchParams.has(key)) {
let value = searchParams.get(key);
if (type === "boolean") {
if (value === "true") {
return true;
} else if (value === "false") {
return false;
} else {
return def;
}
}
return value;
} else {
return def;
}
}

function useParamOrElse<T, V>(
key: string,
type: "string" | "boolean",
makeState: () => [V, (V) => null],
transform: (T) => V | null,
): [V, (V) => null]
{
let value = getParam(key, type);
if (value !== null) {
let transformed = transform(value);
if (transformed !== null) {
return useState(transformed);
}
}
return makeState();
}

function useParamOrState<T, V>(
key: string,
type: "string" | "boolean",
generator: () => V,
transform: (T) => V | null = (val) => val,
): [V, (V) => null]
{
return useParamOrElse(key, type, () => useState(generator), transform);
}

function useParamOrStorage<T, V>(
key: string,
type: "string" | "boolean",
generator: () => V,
transform: (T) => V | null = (val) => val,
): [V, (V) => null]
{
return useParamOrElse(key, type, () => useStorage(key, generator), transform);
}

export { useHash, useParamOrState, useParamOrStorage };
29 changes: 0 additions & 29 deletions src/useHash.ts

This file was deleted.

Loading