diff --git a/src/CharacterSheet.tsx b/src/CharacterSheet.tsx index ff305c2..9933004 100644 --- a/src/CharacterSheet.tsx +++ b/src/CharacterSheet.tsx @@ -133,22 +133,37 @@ const BLANK_BONDS: Bond[] = Array.from({ length: 6 }, () => ({ feelings: [], })); +// URL-safe base64 (RFC 4648 ยง5): avoids +, /, = so the result needs no +// percent-encoding in a query string. Accepts standard base64 on decode too, +// so links shared before this change still load. +function bytesToBase64Url(bytes: Uint8Array) { + let binary = ""; + for (let i = 0; i < bytes.length; i++) + binary += String.fromCharCode(bytes[i]); + return btoa(binary) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ""); +} + +function base64UrlToBytes(b64: string) { + let std = b64.replace(/-/g, "+").replace(/_/g, "/"); + if (std.length % 4) std += "=".repeat(4 - (std.length % 4)); + return Uint8Array.from(atob(std), (c) => c.charCodeAt(0)); +} + async function compressToBase64(str: string) { const stream = new CompressionStream("deflate-raw"); const writer = stream.writable.getWriter(); writer.write(new TextEncoder().encode(str)); writer.close(); const buf = await new Response(stream.readable).arrayBuffer(); - const bytes = new Uint8Array(buf); - let binary = ""; - for (let i = 0; i < bytes.length; i++) - binary += String.fromCharCode(bytes[i]); - return btoa(binary); + return bytesToBase64Url(new Uint8Array(buf)); } async function decompressFromBase64(b64: string) { try { - const bytes = Uint8Array.from(atob(b64), (c) => c.charCodeAt(0)); + const bytes = base64UrlToBytes(b64); const stream = new DecompressionStream("deflate-raw"); const writer = stream.writable.getWriter(); writer.write(bytes); @@ -160,6 +175,16 @@ async function decompressFromBase64(b64: string) { } } +// Drop empty strings and empty arrays before encoding: a sheet is mostly blank +// text fields, and applyData() restores any missing key to its default. +function pruneEmpty(data: Record) { + return Object.fromEntries( + Object.entries(data).filter( + ([, v]) => v !== "" && !(Array.isArray(v) && v.length === 0), + ), + ); +} + export default function CharacterSheet() { const [activeTab, setActiveTab] = useState("main"); const [urlMode, setUrlMode] = useState(false); @@ -452,12 +477,11 @@ export default function CharacterSheet() { ); const copyShareURL = useCallback(async () => { - const encoded = await compressToBase64(JSON.stringify(collectData())); + const encoded = await compressToBase64( + JSON.stringify(pruneEmpty(collectData())), + ); await navigator.clipboard.writeText( - window.location.origin + - window.location.pathname + - "?c=" + - encodeURIComponent(encoded), + window.location.origin + window.location.pathname + "?c=" + encoded, ); setCopyStatus(true); setTimeout(() => setCopyStatus(false), 2000);