t/tahnaroskakori.js

396 lines
9.9 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,
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();