// copied from excalidraw/excalidraw const debounce = (fn, timeout) => { let handle = 0; let lastArgs = null; const ret = (...args) => { lastArgs = args; clearTimeout(handle); handle = window.setTimeout(() => { lastArgs = null; fn(...args); }, timeout); }; ret.flush = () => { clearTimeout(handle); if (lastArgs) { const _lastArgs = lastArgs; lastArgs = null; fn(..._lastArgs); } }; ret.cancel = () => { lastArgs = null; clearTimeout(handle); }; return ret; }; const fetchJSONFile = (path, callback) => { let httpRequest = new XMLHttpRequest(); httpRequest.onreadystatechange = () => { if (httpRequest.readyState === 4) { if (httpRequest.status === 200) { let data = JSON.parse(httpRequest.responseText); if (callback) callback(data); } } }; httpRequest.open("GET", path); httpRequest.send(); }; const getDate = (date) => { const d = new Date(date); const MONTHS = [ "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", ]; return `${d.getDate()} ${MONTHS[d.getMonth()]} ${d.getFullYear()}`; }; const DAY = 24 * 60 * 60 * 1000; const sortByDate = (property) => (a, b) => { const aTime = new Date(a[property]); const bTime = new Date(b[property]); const today = new Date(); const diffA = today.getTime() - aTime.getTime(); const diffB = today.getTime() - bTime.getTime(); return diffB - diffA; }; const sortBy = { default: { label: "Default", func: (items) => { return sortBy.downloadsTotal.func(items); }, }, new: { label: "New", func: (items) => items.sort(sortByDate("created")), }, updates: { label: "Updated", func: (items) => items.sort(sortByDate("updated")), }, downloadsTotal: { label: "Total Downloads", func: (items) => items.sort((a, b) => { return a.downloads.total - b.downloads.total; }), }, downloadsWeek: { label: "Downloads This Week", func: (items) => items.sort((a, b) => { return a.downloads.week - b.downloads.week; }), }, author: { label: "Author", func: (items) => items.sort((a, b) => { return b.authors[0].name.localeCompare(a.authors[0].name); }), }, name: { label: "Name", func: (items) => items.sort((a, b) => { return b.name.localeCompare(a.name); }), }, }; // ----------------------------------------------------------------------------- const APP_NAMES = { "Excalidraw+": "https://app.excalidraw.com", Excalidraw: "https://excalidraw.com", Excalideck: "https://app.excalideck.com", }; let appName = ""; const getAppName = (referrer) => { return (appName = appName || Object.entries(APP_NAMES).find(([appName, domain]) => { return referrer.includes(domain); })?.[0] || "Excalidraw"); }; // ----------------------------------------------------------------------------- let libraries_ = []; let currSort = null; const searchKeys = ["name", "description", "itemNames"]; let IMG_INTERSECTION_OBSERVER = null; const initImageLazyLoading = () => { if (IMG_INTERSECTION_OBSERVER) { IMG_INTERSECTION_OBSERVER.disconnect(); } const lazyImages = [].slice.call(document.querySelectorAll("img.lazy")); if ("IntersectionObserver" in window) { const lazyImageObserver = new IntersectionObserver( function (entries) { entries.forEach(function (entry) { if (entry.isIntersecting) { let lazyImage = entry.target; lazyImage.src = lazyImage.dataset.src; lazyImage.classList.remove("lazy"); lazyImageObserver.unobserve(lazyImage); } }); }, { rootMargin: "0px 0px 500px 0px", }, ); IMG_INTERSECTION_OBSERVER = lazyImageObserver; lazyImages.forEach(function (lazyImage) { lazyImageObserver.observe(lazyImage); }); } else { lazyImages.forEach(function (lazyImage) { lazyImage.src = lazyImage.dataset.src; }); } }; const escapeHTMLAttribute = (str) => { const map = { "&": "&", "<": "<", ">": ">", '"': """, }; if (typeof str !== "string") return ""; return str.replace(/[&<>"]/g, (char) => map[char]); }; const populateLibraryList = (filterQuery = "") => { const items = [ ...document.getElementById("template").parentNode.children, ].filter((x) => x.id !== "template"); items.forEach((x) => x.remove()); filterQuery = filterQuery.trim().toLowerCase(); const hasMatch = (haystackStr) => haystackStr.toLowerCase().includes(filterQuery); let libraries = libraries_; if (filterQuery) { libraries = libraries.filter((library) => searchKeys.some((key) => { const haystack = library[key] || ""; if (Array.isArray(haystack)) { return haystack.some((x) => hasMatch(x)); } else { return hasMatch(haystack); } }), ); } const template = document.getElementById("template"); const searchParams = new URLSearchParams(location.search); const referrer = escapeHTMLAttribute( searchParams.get("referrer") || "https://excalidraw.com", ); const appName = getAppName(referrer); const target = decodeURIComponent( escapeHTMLAttribute(searchParams.get("target")) || "_blank", ); const useHash = searchParams.get("useHash"); const csrfToken = escapeHTMLAttribute(searchParams.get("token")); for (let library of libraries) { const div = document.createElement("div"); div.classList.add("library"); div.setAttribute("id", library.id); let inner = template.innerHTML; const source = `libraries/${library.source}`; let authorsInnerHTML = ""; inner = inner.replace(/\{libraryId\}/g, library.id); inner = inner.replace(/\{name\}/g, library.name); const truncate = (str) => { if (str.length > 300) { str = str.slice(0, 300); return str.split(", ").slice(0, -1).join(", ") + "..."; } return str; }; let description = library.description || ""; if (library.itemNames) { description += `

Items: ${truncate( library.itemNames.join(", "), )}`; } inner = inner.replace(/\{description\}/g, description); inner = inner.replace(/\{source\}/g, source); for (let author of library.authors) { authorsInnerHTML += `@${author.name} `; } inner = inner.replace(/\{authors\}/g, authorsInnerHTML); inner = inner.replace( /\{preview\}/g, `libraries/${library.preview}?v=${library.updated || 0}`, ); inner = inner.replace(/\{created\}/g, getDate(library.created)); if (library.created !== library.updated) { inner = inner.replace(/\{updated\}/g, getDate(library.updated)); } else { inner = inner.replace('

Updated: {updated}

', ""); } inner = inner.replace(/\{appName\}/g, appName); const libraryUrl = encodeURIComponent( `${escapeHTMLAttribute(origin)}/${source}`, ); inner = inner.replace( "{addToLib}", `${referrer}${useHash ? "#" : "?"}addLibrary=${libraryUrl}${ csrfToken ? `&token=${csrfToken}` : "" }`, ); inner = inner.replace("{target}", target); inner = inner.replace(/\{total\}/g, library.downloads.total); inner = inner.replace(/\{week\}/g, library.downloads.week); div.innerHTML = inner; div.setAttribute("data-version", library.version || "1"); template.after(div); } initImageLazyLoading(); }; const handleSort = (sortType) => { const searchParams = new URLSearchParams(location.search); searchParams.set("sort", sortType); history.pushState("", "sort", `?` + searchParams.toString() + location.hash); libraries_ = sortBy[sortType ?? "default"].func(libraries_); populateLibraryList(); if (currSort) { const prev = document.getElementById(currSort); prev.classList.remove("option-selected"); } const curr = document.getElementById(sortType); curr?.classList.add("option-selected"); currSort = sortType; }; const populateSorts = () => { const sortTemplate = document.getElementById("sort-template"); for ([key, value] of Object.entries(sortBy).filter( ([key]) => key !== "default", )) { const spacer = document.createElement("span"); spacer.innerHTML = ` · `; sortTemplate.before(spacer); const el = sortTemplate.cloneNode(true); el.setAttribute("id", key); el.innerText = el.innerText.replace(/\{label\}/g, value.label); el.setAttribute("href", "#"); const handler = (sort) => () => { history.replaceState(null, null, " "); handleSort(sort); }; el.onclick = handler(key); sortTemplate.before(el); } }; const scrollToAnchor = () => { if (location.hash) { const target = location.hash; const element = document.querySelector(target); if (element) { window.scrollTo(0, element.offsetTop); } } }; const handleTheme = (theme) => { const searchParams = new URLSearchParams(location.search); searchParams.set("theme", theme); history.pushState("", "theme", `?` + searchParams.toString() + location.hash); if (theme === "dark") { document.querySelector("html").classList.add("theme--dark"); document.querySelector("#light").classList.remove("is-hidden"); document.querySelector("#dark").classList.add("is-hidden"); } else if (theme === "light") { document.querySelector("#light").classList.add("is-hidden"); document.querySelector("#dark").classList.remove("is-hidden"); document.querySelector("html").classList.remove("theme--dark"); } }; // ----------------------------------------------------------------------------- // init // ----------------------------------------------------------------------------- // Add listeners to handle theme change const themes = document.querySelectorAll("#theme .option"); themes.forEach((theme) => theme.addEventListener("click", () => handleTheme(theme.id)), ); const urlParams = new URLSearchParams(window.location.search); const searchInput = document.getElementById("search-input"); searchInput.addEventListener( "input", debounce((event) => { populateLibraryList(event.target.value); }, 200), ); document.documentElement.addEventListener("keypress", (event) => { if ( !event.altKey && !event.ctrlKey && !event.metaKey && /^[a-z0-9]$/i.test(event.key) ) { if (searchInput !== document.activeElement) { searchInput.select(); } } }); handleTheme(urlParams.get("theme") ?? "light"); populateSorts(); fetchJSONFile("libraries.json", (libraries) => { fetchJSONFile("stats.json", (stats) => { for (let library of libraries) { const replaceText = { "/": "-", ".excalidrawlib": "" }; const libraryId = library.source .toLowerCase() .replace(/\/|.excalidrawlib/g, (match) => replaceText[match]); library["id"] = libraryId; library["downloads"] = { total: libraryId in stats ? stats[libraryId].total : 0, week: libraryId in stats ? stats[libraryId].week : 0, }; libraries_.push(library); } const sort = urlParams.get("sort"); handleSort(sort ?? "default"); scrollToAnchor(); }); }); // update footer with current year const footer = document.getElementById("footer"); footer.innerHTML = footer.innerHTML.replace(/{currentYear}/g, () => new Date().getFullYear(), ); document.addEventListener("click", (event) => { if (event.target.closest(".install-library")) { const libraryItemNode = event.target.closest(".library"); const libraryVersion = parseInt( libraryItemNode.getAttribute("data-version") || "1", ); const referrer = urlParams.get("referrer"); const referrerVersion = parseInt(urlParams.get("version") || "1"); if (referrer && referrerVersion < libraryVersion) { let message = "It seems the Excalidraw editor's version is older than the library version. Installing this library may not work correctly."; if (referrer.includes("excalidraw.com")) { message += `\n\nTo ensure you are on the latest version, hard-reload the excalidraw.com tab (Mac: Cmd-Shift-R, Window: Ctrl-F5). If that doesn't work, ensure you only have a single excalidraw.com tab open.`; } window.alert(message); } } });