315 lines
8.4 KiB
JavaScript
315 lines
8.4 KiB
JavaScript
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 } from "./languages.js";
|
|
|
|
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;
|
|
|
|
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 currentCode = "";
|
|
let dataOptions = new DataOptions();
|
|
let viewMode = ViewMode.VIEW;
|
|
|
|
function getLanguageClassName(language) {
|
|
if (language === PLAINTEXT) {
|
|
return "language-plain";
|
|
} else {
|
|
return `language-${language}`;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Render all the language choices in the dropdown.
|
|
*/
|
|
function renderLanguageOptions() {
|
|
for (const language of LANGUAGE_NAMES) {
|
|
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]) {
|
|
await import(`./vendor/prism/components/prism-${dep}.js`)
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* Compress the current code in a synchronous way (all at once) and update the status.
|
|
*/
|
|
async function syncCompress() {
|
|
const utf8 = ENCODER.encode(currentCode);
|
|
const dataWithHeader = dataOptions.serializeTo(utf8);
|
|
const compressed = compress(dataWithHeader, { quality: QUALITY });
|
|
const encoded = BASECODEC.encode(compressed);
|
|
|
|
statusEl.textContent = `Length: ${utf8.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() {
|
|
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(undefined, 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;
|
|
|
|
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...`;
|
|
|
|
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 code being edited.
|
|
*/
|
|
function codeEdited() {
|
|
if (compressTimeout) {
|
|
clearTimeout(compressTimeout);
|
|
}
|
|
|
|
currentCode = codeEditEl.value;
|
|
|
|
if (currentCode === "") {
|
|
history.replaceState(null, "", "#");
|
|
statusEl.textContent = "Waiting...";
|
|
return;
|
|
}
|
|
|
|
compressTimeout = setTimeout(async () => {
|
|
await syncCompress();
|
|
}, COMPRESS_WAIT);
|
|
}
|
|
|
|
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");
|
|
|
|
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);
|
|
window.addEventListener("hashchange", streamDecompress);
|
|
|
|
await streamDecompress();
|
|
}
|
|
|
|
await waitForLoad();
|
|
await init();
|