import brotliInit, { compress, DecompressStream, BrotliStreamResult, } from "./vendor/brotli_wasm.js"; import base from "./vendor/base-x.js"; import "./vendor/prism/prism.js"; import "./vendor/prism/plugins/line-numbers/prism-line-numbers.js"; import "./vendor/prism/dependencies.js"; import { waitForLoad } from "./utils.js"; import { DataOptions } from "./dataoptions.js"; import { ViewMode } from "./viewmode.js"; import { getDependenciesForLanguage, getDepsData, LANGUAGES, LANGUAGE_NAMES, PLAINTEXT, PATCHED_LANGUAGES, } from "./languages.js"; const PROJECT_NAME = "Tahnaroskakori"; const COMPRESS_WAIT = 500; const ENCODER = new TextEncoder(); const ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789?/-._~$&+="; const BASECODEC = base(ALPHABET); const QUALITY = 11; const MAX_URL = 2048; const DECOMPRESS_CHUNK_SIZE = 1000; const DECOMPRESS_CHUNK_TIMEOUT = 100; const TITLE_POSTFIX = ` ยท ${PROJECT_NAME}`; const TITLE_CODE_LENGTH = 40; let languageDependencies = null; let compressTimeout = null; let rootURLSize = 0; let statusEl = null; let codeEditEl = null; let codeViewEl = null; let codeViewContentEl = null; let viewModeSwitcherEl = null; let languageSelectEl = null; let titleInputEl = null; let currentCode = ""; let dataOptions = new DataOptions(); let viewMode = ViewMode.VIEW; function setTitle() { if (dataOptions.title !== "") { document.title = dataOptions.title + TITLE_POSTFIX; } else if (currentCode !== "") { document.title = currentCode.replace("\n", "").substring(0, TITLE_CODE_LENGTH) + TITLE_POSTFIX; } else { document.title = PROJECT_NAME; } } function getLanguageClassName(language) { if (language === PLAINTEXT) { return "language-plain"; } else { return `language-${language}`; } } /** * Render all the language choices in the dropdown. */ function renderLanguageOptions() { const languages = [...LANGUAGE_NAMES]; languages.sort(); // Plain text must be first languages.splice(languages.indexOf(PLAINTEXT), 1); languages.unshift(PLAINTEXT); for (const language of languages) { const option = document.createElement("option"); option.value = language; option.textContent = language; languageSelectEl.appendChild(option); } } /** * Use Prism to highlight the current language. * * The language definition will be imported if necessary. */ async function highlightLanguage() { if (viewMode !== ViewMode.VIEW) { return; } codeViewEl.classList.add(getLanguageClassName(dataOptions.language)); if (dataOptions.language !== PLAINTEXT) { const deps = getDependenciesForLanguage( languageDependencies, dataOptions.language ); for (const dep of [...deps, dataOptions.language]) { const importPath = PATCHED_LANGUAGES.has(dep) ? `./vendor/prism-patches/prism-${dep}.js` : `./vendor/prism/components/prism-${dep}.js`; await import(importPath); } if (viewMode === ViewMode.VIEW) { Prism.highlightElement(codeViewContentEl); } } else { Prism.highlightElement(codeViewContentEl); } } /** * Render the currently active code on the page. */ function renderCode() { if (viewMode === ViewMode.VIEW) { codeViewContentEl.textContent = currentCode; highlightLanguage(); } else { codeEditEl.value = currentCode; } } /** * Render the current view, hiding the other. */ function renderView() { if (viewMode === ViewMode.VIEW) { codeViewEl.style.display = "block"; codeEditEl.style.display = "none"; viewModeSwitcherEl.textContent = "โœ๏ธ"; } else { codeEditEl.style.display = "block"; codeViewEl.style.display = "none"; viewModeSwitcherEl.textContent = "๐Ÿ‘"; } } /** * Switch the mode between editing and viewing. */ function switchMode() { if (viewMode === ViewMode.VIEW) { viewMode = ViewMode.EDIT; } else { viewMode = ViewMode.VIEW; } renderView(); renderCode(); } /** * Calculate the max length of the hash that we can use to avoid overlong URLs. * @returns {number} */ function maxHashLength() { if (rootURLSize === 0) { const rootURL = new URL(window.location.href); rootURL.hash = ""; rootURLSize = rootURL.toString().length; } return MAX_URL - rootURLSize - 1; } /** * Schedule compression to happen after a timeout, avoiding too many compressions. */ function scheduleCompress() { if (compressTimeout) { clearTimeout(compressTimeout); } compressTimeout = setTimeout(async () => { await syncCompress(); }, COMPRESS_WAIT); } /** * Compress the current code in a synchronous way (all at once) and update the status. */ async function syncCompress() { if (currentCode === "") { history.replaceState(null, "", "#"); statusEl.textContent = "Waiting..."; return; } const utf8 = ENCODER.encode(currentCode); const dataWithHeader = dataOptions.serializeTo(utf8); const compressed = compress(dataWithHeader, { quality: QUALITY }); const encoded = BASECODEC.encode(compressed); statusEl.textContent = `Length: ${dataWithHeader.length} B -> ${ compressed.length } B -> ${encoded.length}/${maxHashLength()} chars`; if (encoded.length <= maxHashLength()) { history.replaceState(null, "", `#${encoded}`); } else { statusEl.textContent += " (TOO BIG!)"; } } /** * Decompress the hash data in a streaming way, with pauses between every chunk, to avoid * the user being zip bombed with a short hash that generates gigabytes of output. */ async function streamDecompress() { if (window.location.hash.length <= 1) { currentCode = ""; renderCode(); return; } try { const data = BASECODEC.decode(window.location.hash.substring(1)); statusEl.textContent = "Initializing decompress..."; currentCode = ""; let decompressedChunks = 0; const inputStream = new ReadableStream({ start(controller) { controller.enqueue(data); controller.close(); }, }); const decompressStream = new DecompressStream(); const decompressionRunner = new TransformStream({ start() {}, transform(chunk, controller) { controller.enqueue( decompressStream.decompress(chunk, DECOMPRESS_CHUNK_SIZE) ); let slice = chunk; while ( decompressStream.result() === BrotliStreamResult.NeedsMoreOutput ) { slice = slice.slice(decompressStream.last_input_offset()); controller.enqueue( decompressStream.decompress(slice, DECOMPRESS_CHUNK_SIZE) ); } }, flush(controller) { if (decompressStream.result() === BrotliStreamResult.NeedsMoreInput) { controller.enqueue( decompressStream.decompress("", DECOMPRESS_CHUNK_SIZE) ); } controller.terminate(); }, }); const optionsPickerStream = new TransformStream({ firstChunk: false, start() {}, transform(chunk, controller) { if (!this.firstChunk) { const rest = dataOptions.parseFrom(chunk); languageSelectEl.value = dataOptions.language; titleInputEl.value = dataOptions.title; setTitle(); controller.enqueue(rest); this.firstChunk = true; } else { controller.enqueue(chunk); } }, }); const textDecoderStream = new TextDecoderStream(); const outputStream = new WritableStream({ write(chunk) { currentCode += chunk; ++decompressedChunks; statusEl.textContent = `Decompressing: ${decompressedChunks} chunks...`; setTitle(); renderCode(); // Delay stream between every chunk to avoid zip bombing return new Promise((resolve) => setTimeout(resolve, DECOMPRESS_CHUNK_TIMEOUT) ); }, }); await inputStream .pipeThrough(decompressionRunner) .pipeThrough(optionsPickerStream) .pipeThrough(textDecoderStream) .pipeTo(outputStream); await syncCompress(); } catch (e) { currentCode = `Unable to open the paste. Perhaps the URL is mistyped?\n\n${e}`; console.error(e); renderCode(); } } /** * Callback for language being changed in language selector. */ async function languageSelected() { codeViewEl.classList.remove(getLanguageClassName(dataOptions.language)); codeViewContentEl.classList.remove( getLanguageClassName(dataOptions.language) ); if (LANGUAGES.has(languageSelectEl.value)) { dataOptions.language = languageSelectEl.value; highlightLanguage(); } else { dataOptions.language = PLAINTEXT; highlightLanguage(); } // Ensure language info is stored into URL when it's changed. await syncCompress(); } /** * Callback for title being edited. */ async function titleEdited() { dataOptions.title = titleInputEl.value; setTitle(); scheduleCompress(); } /** * Callback for code being edited. */ function codeEdited() { currentCode = codeEditEl.value; setTitle(); scheduleCompress(); } async function init() { codeEditEl = document.getElementById("code-edit"); codeViewEl = document.getElementById("code-view"); codeViewContentEl = document.getElementById("code-view-content"); statusEl = document.getElementById("length"); viewModeSwitcherEl = document.getElementById("view-mode-switcher"); languageSelectEl = document.getElementById("language-select"); titleInputEl = document.getElementById("title-input"); if (window.location.hash.length <= 1) { viewMode = ViewMode.EDIT; } renderLanguageOptions(); renderView(); [languageDependencies] = await Promise.all([getDepsData(), brotliInit()]); codeEditEl.addEventListener("input", codeEdited); viewModeSwitcherEl.addEventListener("click", switchMode); languageSelectEl.addEventListener("change", languageSelected); titleInputEl.addEventListener("input", titleEdited); window.addEventListener("hashchange", streamDecompress); await streamDecompress(); } await waitForLoad(); await init();