From 149d1053d622ddcbf627f35060e9985e9721de8a Mon Sep 17 00:00:00 2001 From: Mikko Ahlroth Date: Fri, 28 Oct 2022 23:18:56 +0300 Subject: [PATCH] Implement line numbers, code highlighting, protocol header --- .gitmodules | 3 + dataoptions.js | 81 +++++++++++ index.html | 21 ++- languages.js | 345 +++++++++++++++++++++++++++++++++++++++++++++ licenses.txt | 26 ++++ tahnaroskakori.css | 63 ++++++++- tahnaroskakori.js | 229 +++++++++++++++++++++++++----- utils.js | 15 ++ vendor/prism | 1 + viewmode.js | 6 + 10 files changed, 740 insertions(+), 50 deletions(-) create mode 100644 .gitmodules create mode 100644 dataoptions.js create mode 100644 languages.js create mode 100644 utils.js create mode 160000 vendor/prism create mode 100644 viewmode.js diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..236c7c0 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "vendor/prism"] + path = vendor/prism + url = https://github.com/PrismJS/prism.git diff --git a/dataoptions.js b/dataoptions.js new file mode 100644 index 0000000..aa067a6 --- /dev/null +++ b/dataoptions.js @@ -0,0 +1,81 @@ +import { PLAINTEXT, LANGUAGES } from "./languages.js"; + +/** + * Data options are stored in a binary header before the payload. + * + * Header format: + * + * Byte 1 + * |xxx----y| + * x = amount of extra bytes (other than this byte) in the header (uint) + * y = highest bit of language ID (the rest in byte 2) + * + * Byte 2 (+ lowest bit of byte 1) is language ID (uint), i.e. index of the + * language in the LANGUAGE_NAMES list. + * + * --- + * + * NOTE: If options are set to their default values, the header is minimised + * to not include those bytes if possible. + */ +export class DataOptions { + language = PLAINTEXT; + + /** + * Parse options from uncompressed bytes. + * @param {Uint8Array} data + * @returns {Uint8Array} The data without the header. + */ + parseFrom(data) { + const byte1 = data[0]; + const totalBytes = (byte1 & 0b11100000) >>> 5; + + if (totalBytes >= 1) { + const languageIDLowByte = data[1]; + const languageIDHighBit = (byte1 & 0b00000001); + const languageID = (languageIDHighBit << 8) | languageIDLowByte; + + if (LANGUAGES.has(languageID)) { + this.language = LANGUAGES.get(languageID); + } else { + this.language = PLAINTEXT; + } + } else { + this.language = PLAINTEXT; + } + + return data.subarray(totalBytes + 1); + } + + /** + * Serialize options to uncompressed bytes. + * @param {Uint8Array} data + * @returns {Uint8Array} Data with the options in a header. + */ + serializeTo(data) { + let byte1LowBit = null; + const extra_bytes = []; + + if (this.language !== PLAINTEXT) { + const languageID = LANGUAGES.get(this.language); + const languageIDLowByte = languageID & 0b011111111; + const languageIDHighBit = languageID & 0b100000000; + byte1LowBit = languageIDHighBit >>> 8; + + extra_bytes.unshift(languageIDLowByte); + } + + let byte1 = (extra_bytes.length & 0b00000111) << 5; + if (byte1LowBit !== null) { + byte1 |= byte1LowBit; + } + + const headerBytes = new Uint8Array([byte1, ...extra_bytes]); + + const combined = new Uint8Array(1 + extra_bytes.length + data.length); + combined.set(headerBytes, 0); + combined.set(data, headerBytes.length); + + return combined; + } +} diff --git a/index.html b/index.html index 9598205..ebfcf5b 100644 --- a/index.html +++ b/index.html @@ -8,21 +8,34 @@ Tahnaroskakori + + - +
-

Tahnaroskakori

+
+

Tahnaroskakori

+ +
- +
+ +
+ +
Loading...
+ + + + diff --git a/languages.js b/languages.js new file mode 100644 index 0000000..f1991c1 --- /dev/null +++ b/languages.js @@ -0,0 +1,345 @@ +/** + * Language names from Prism.js files + */ +export const LANGUAGE_NAMES = [ + "plaintext", + "abap", + "abnf", + "actionscript", + "ada", + "agda", + "al", + "antlr4", + "apacheconf", + "apex", + "apl", + "applescript", + "aql", + "arduino", + "arff", + "armasm", + "arturo", + "asciidoc", + "asm6502", + "asmatmel", + "aspnet", + "autohotkey", + "autoit", + "avisynth", + "avro-idl", + "awk", + "bash", + "basic", + "batch", + "bbcode", + "bbj", + "bicep", + "birb", + "bison", + "bnf", + "bqn", + "brainfuck", + "brightscript", + "bro", + "bsl", + "c", + "cfscript", + "chaiscript", + "cil", + "cilkc", + "cilkcpp", + "clike", + "clojure", + "cmake", + "cobol", + "coffeescript", + "concurnas", + "cooklang", + "coq", + "cpp", + "crystal", + "csharp", + "cshtml", + "csp", + // "css-extras", + "css", + "csv", + "cue", + "cypher", + "d", + "dart", + "dataweave", + "dax", + "dhall", + "diff", + "django", + "dns-zone-file", + "docker", + "dot", + "ebnf", + "editorconfig", + "eiffel", + "ejs", + "elixir", + "elm", + "erb", + "erlang", + "etlua", + "excel-formula", + "factor", + "false", + "firestore-security-rules", + "flow", + "fortran", + "fsharp", + "ftl", + "gap", + "gcode", + "gdscript", + "gedcom", + "gettext", + "gherkin", + "git", + "glsl", + "gml", + "gn", + "go-module", + "go", + "gradle", + "graphql", + "groovy", + "haml", + "handlebars", + "haskell", + "haxe", + "hcl", + "hlsl", + "hoon", + "hpkp", + "hsts", + "http", + "ichigojam", + "icon", + "icu-message-format", + "idris", + "iecst", + "ignore", + "inform7", + "ini", + "io", + "j", + "java", + "javadoc", + "javadoclike", + "javascript", + "javastacktrace", + "jexl", + "jolie", + "jq", + "js-extras", + "js-templates", + "jsdoc", + "json", + "json5", + "jsonp", + "jsstacktrace", + "jsx", + "julia", + "keepalived", + "keyman", + "kotlin", + "kumir", + "kusto", + "latex", + "latte", + "less", + "lilypond", + "linker-script", + "liquid", + "lisp", + "livescript", + "llvm", + "log", + "lolcode", + "lua", + "magma", + "makefile", + "markdown", + "markup-templating", + "markup", + "mata", + "matlab", + "maxscript", + "mel", + "mermaid", + "metafont", + "mizar", + "mongodb", + "monkey", + "moonscript", + "n1ql", + "n4js", + "nand2tetris-hdl", + "naniscript", + "nasm", + "neon", + "nevod", + "nginx", + "nim", + "nix", + "nsis", + "objectivec", + "ocaml", + "odin", + "opencl", + "openqasm", + "oz", + "parigp", + "parser", + "pascal", + "pascaligo", + "pcaxis", + "peoplecode", + "perl", + // "php-extras", + "php", + "phpdoc", + "plant-uml", + "plsql", + "powerquery", + "powershell", + "processing", + "prolog", + "promql", + "properties", + "protobuf", + "psl", + "pug", + "puppet", + "pure", + "purebasic", + "purescript", + "python", + "q", + "qml", + "qore", + "qsharp", + "r", + "racket", + "reason", + "regex", + "rego", + "renpy", + "rescript", + "rest", + "rip", + "roboconf", + "robotframework", + "ruby", + "rust", + "sas", + "sass", + "scala", + "scheme", + "scss", + "shell-session", + "smali", + "smalltalk", + "smarty", + "sml", + "solidity", + "solution-file", + "soy", + "sparql", + "splunk-spl", + "sqf", + "sql", + "squirrel", + "stan", + "stata", + "stylus", + "supercollider", + "swift", + "systemd", + "t4-cs", + "t4-templating", + "t4-vb", + "tap", + "tcl", + "textile", + "toml", + "tremor", + "tsx", + "tt2", + "turtle", + "twig", + "typescript", + "typoscript", + "unrealscript", + "uorazor", + "uri", + "v", + "vala", + "vbnet", + "velocity", + "verilog", + "vhdl", + "vim", + "visual-basic", + "warpscript", + "wasm", + "web-idl", + "wgsl", + "wiki", + "wolfram", + "wren", + "xeora", + "xml-doc", + "xojo", + "xquery", + "yaml", + "yang", + "zig", +]; + +export const LANGUAGES = new Map(); + +for (let i = 0; i < LANGUAGE_NAMES.length; ++i) { + LANGUAGES.set(i, LANGUAGE_NAMES[i]); + LANGUAGES.set(LANGUAGE_NAMES[i], i); +} + +export const PLAINTEXT = LANGUAGE_NAMES[0]; + +export async function getDepsData() { + const resp = await fetch("./vendor/prism/components.json"); + const depsData = await resp.json(); + const ret = new Map(); + for (const [lang, data] of Object.entries(depsData.languages)) { + if ("require" in data) { + const req = Array.isArray(data.require) ? data.require : [data.require]; + ret.set(lang, req); + } + } + + return ret; +} + +export function getDependenciesForLanguage(depsData, language) { + const ret = []; + const langsToProcess = [language]; + + while (langsToProcess.length > 0) { + const currentLang = langsToProcess.shift(); + const deps = depsData.get(currentLang) ?? []; + + for (const dep of deps) { + langsToProcess.push(dep); + + // Deps have to be loaded first + ret.unshift(dep); + } + } + + return Array.from(new Set(ret)); +} diff --git a/licenses.txt b/licenses.txt index 465d13a..222f4d2 100644 --- a/licenses.txt +++ b/licenses.txt @@ -215,3 +215,29 @@ brotli-wasm WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + +--- + +Prism.js + +MIT LICENSE + +Copyright (c) 2012 Lea Verou + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/tahnaroskakori.css b/tahnaroskakori.css index d6980f6..589736a 100644 --- a/tahnaroskakori.css +++ b/tahnaroskakori.css @@ -1,5 +1,11 @@ @charset "utf-8"; +:root { + --bg-color: #111; + --bg-color-active: #333; + --color: #4c4; +} + html, body { height: 100%; @@ -11,9 +17,9 @@ body { } html, -#code { - background-color: #111; - color: #4c4; +#code-edit { + background-color: var(--bg-color); + color: var(--color); } main { @@ -26,24 +32,67 @@ main { display: flex; flex-direction: column; align-items: stretch; + row-gap: 5px; margin: 10px; } -#code { +#code-view, +#code-edit { flex-grow: 1; - font-size: 1.2rem; - font-family: monospace; - overflow-y: auto; +} + +#code-view { + white-space: pre-wrap; +} + +#code-view-content { + /* Fix line number alignment bug: https://github.com/PrismJS/prism/issues/1132#issue-225969024 */ + display: inline-block; +} + +#code-edit { + display: none; + + font-family: monospace; resize: none; } +button, +select { + background-color: var(--bg-color); + color: var(--color); + + font-family: monospace; +} + +button { + font-size: 1.5rem; + + padding: 0.7rem; + + border: 1px solid var(--color); + border-radius: 5px; +} + +button:active { + background-color: var(--bg-color-active); +} + #length { font-size: 1.2rem; } +header { + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; + flex-wrap: wrap; +} + footer { display: flex; flex-direction: row; diff --git a/tahnaroskakori.js b/tahnaroskakori.js index 11e811b..b6c0621 100644 --- a/tahnaroskakori.js +++ b/tahnaroskakori.js @@ -1,5 +1,13 @@ -import brotliInit, { compress, decompress, DecompressStream, BrotliStreamResult } from "./vendor/brotli_wasm.js"; +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(); @@ -10,25 +18,107 @@ 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 codeEl = 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}`; + } +} /** - * Returns a promise that resolves when the page DOM has been loaded. - * @returns {Promise} + * Render all the language choices in the dropdown. */ -function waitForLoad() { - return new Promise(resolve => { - // If already loaded, fire immediately - if (/complete|interactive|loaded/.test(document.readyState)) { - resolve(); +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`) } - else { - document.addEventListener('DOMContentLoaded', resolve); + + 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(); } /** @@ -46,12 +136,12 @@ function maxHashLength() { } /** - * Compress the given data in a synchronous way (all at once) and update the status. - * @param {string} data + * Compress the current code in a synchronous way (all at once) and update the status. */ -async function syncCompress(data) { - const utf8 = ENCODER.encode(data); - const compressed = compress(utf8, { quality: QUALITY }); +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`; @@ -70,7 +160,9 @@ async function syncCompress(data) { */ async function streamDecompress(data) { statusEl.textContent = "Initializing decompress..."; - codeEl.value = ""; + currentCode = ""; + + let decompressedChunks = 0; const inputStream = new ReadableStream({ start(controller) { @@ -101,16 +193,33 @@ async function streamDecompress(data) { } }); - let decompressedChunks = 0; + 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) { - codeEl.value += 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)); } @@ -118,40 +227,82 @@ async function streamDecompress(data) { await inputStream .pipeThrough(decompressionRunner) + .pipeThrough(optionsPickerStream) .pipeThrough(textDecoderStream) .pipeTo(outputStream); - await syncCompress(codeEl.value); + await syncCompress(); +} + +/** + * 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() { - await brotliInit(); - - codeEl = document.getElementById("code"); + 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"); - codeEl.addEventListener("input", () => { - if (compressTimeout) { - clearTimeout(compressTimeout); - } + renderLanguageOptions(); + renderView(); - if (codeEl.value === "") { - history.replaceState(null, "", "#"); - statusEl.textContent = "Waiting..."; - return; - } + [languageDependencies,] = await Promise.all([ + getDepsData(), + brotliInit(), + ]); - compressTimeout = setTimeout(async () => { - const content = codeEl.value; - await syncCompress(content); - }, COMPRESS_WAIT); - }); + + codeEditEl.addEventListener("input", codeEdited); + viewModeSwitcherEl.addEventListener("click", switchMode); + languageSelectEl.addEventListener("change", languageSelected); if (window.location.hash.length > 1) { try { const bytes = BASECODEC.decode(window.location.hash.substring(1)); await streamDecompress(bytes); } catch (e) { - codeEl.textContent = e.stack; + currentCode = `Unable to open the paste. Perhaps the URL is mistyped?\n\n${e.stack}`; + renderCode(); } } } diff --git a/utils.js b/utils.js new file mode 100644 index 0000000..6751db9 --- /dev/null +++ b/utils.js @@ -0,0 +1,15 @@ +/** + * Returns a promise that resolves when the page DOM has been loaded. + * @returns {Promise} + */ +export function waitForLoad() { + return new Promise(resolve => { + // If already loaded, fire immediately + if (/complete|interactive|loaded/.test(document.readyState)) { + resolve(); + } + else { + document.addEventListener('DOMContentLoaded', resolve); + } + }); +} diff --git a/vendor/prism b/vendor/prism new file mode 160000 index 0000000..59e5a34 --- /dev/null +++ b/vendor/prism @@ -0,0 +1 @@ +Subproject commit 59e5a3471377057de1f401ba38337aca27b80e03 diff --git a/viewmode.js b/viewmode.js new file mode 100644 index 0000000..7efb84f --- /dev/null +++ b/viewmode.js @@ -0,0 +1,6 @@ +export class ViewMode { + static VIEW = Symbol("view"); + static EDIT = Symbol("edit"); +} + +Object.freeze(ViewMode);