diff --git a/dataoptions.js b/dataoptions.js
index aa067a6..9e9cf07 100644
--- a/dataoptions.js
+++ b/dataoptions.js
@@ -1,18 +1,36 @@
import { PLAINTEXT, LANGUAGES } from "./languages.js";
+const UTF8_ENCODER = new TextEncoder();
+const UTF8_DECODER = new TextDecoder();
+
+class DataOptionsError extends Error { }
+class DecodeError extends DataOptionsError { }
+
/**
* Data options are stored in a binary header before the payload.
*
- * Header format:
+ * The header consists of a fixed section, followed by a dynamic section.
+ * The fixed section consists of bytes with a fixed meaning and positioning.
+ * The dynamic section is based on the contents of the fixed section and
+ * contains data related to the fixed section.
+ *
+ * Fixed section:
*
* Byte 1
* |xxx----y|
- * x = amount of extra bytes (other than this byte) in the header (uint)
+ * x = amount of extra bytes (other than this byte) in the header's fixed
+ * section (uid)
* 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.
*
+ * Byte 3 is number of bytes reserved for the page title.
+ *
+ * Dynamic section parts:
+ *
+ * 1. Bytes of page title (UTF-8).
+ *
* ---
*
* NOTE: If options are set to their default values, the header is minimised
@@ -20,6 +38,7 @@ import { PLAINTEXT, LANGUAGES } from "./languages.js";
*/
export class DataOptions {
language = PLAINTEXT;
+ title = "";
/**
* Parse options from uncompressed bytes.
@@ -28,9 +47,9 @@ export class DataOptions {
*/
parseFrom(data) {
const byte1 = data[0];
- const totalBytes = (byte1 & 0b11100000) >>> 5;
+ const fixedHeaderBytes = ((byte1 & 0b11100000) >>> 5) + 1;
- if (totalBytes >= 1) {
+ if (fixedHeaderBytes >= 2) {
const languageIDLowByte = data[1];
const languageIDHighBit = (byte1 & 0b00000001);
const languageID = (languageIDHighBit << 8) | languageIDLowByte;
@@ -44,7 +63,25 @@ export class DataOptions {
this.language = PLAINTEXT;
}
- return data.subarray(totalBytes + 1);
+ let titleLen = 0;
+
+ if (fixedHeaderBytes >= 3) {
+ titleLen = data[2];
+
+ if (titleLen > data) {
+ throw new DecodeError(
+ `Title length ${titleLen} was bigger than chunk size ${data.length}.`
+ );
+ }
+
+ this.title = UTF8_DECODER.decode(
+ data.subarray(fixedHeaderBytes, fixedHeaderBytes + titleLen)
+ );
+ } else {
+ this.title = "";
+ }
+
+ return data.subarray(fixedHeaderBytes + titleLen);
}
/**
@@ -54,25 +91,35 @@ export class DataOptions {
*/
serializeTo(data) {
let byte1LowBit = null;
- const extra_bytes = [];
+ const fixedBytes = [];
+ const dynamicBytes = [];
- if (this.language !== PLAINTEXT) {
+ const hasTitleBytes = this.title !== "";
+ const hasLanguageBytes = this.language !== PLAINTEXT || hasTitleBytes;
+
+ if (hasLanguageBytes) {
const languageID = LANGUAGES.get(this.language);
const languageIDLowByte = languageID & 0b011111111;
const languageIDHighBit = languageID & 0b100000000;
byte1LowBit = languageIDHighBit >>> 8;
- extra_bytes.unshift(languageIDLowByte);
+ fixedBytes.push(languageIDLowByte);
}
- let byte1 = (extra_bytes.length & 0b00000111) << 5;
+ if (hasTitleBytes) {
+ const languageBytes = UTF8_ENCODER.encode(this.title);
+ fixedBytes.push(languageBytes.length);
+ dynamicBytes.push(...languageBytes);
+ }
+
+ let byte1 = (fixedBytes.length & 0b00000111) << 5;
if (byte1LowBit !== null) {
byte1 |= byte1LowBit;
}
- const headerBytes = new Uint8Array([byte1, ...extra_bytes]);
+ const headerBytes = new Uint8Array([byte1, ...fixedBytes, ...dynamicBytes]);
- const combined = new Uint8Array(1 + extra_bytes.length + data.length);
+ const combined = new Uint8Array(headerBytes.length + data.length);
combined.set(headerBytes, 0);
combined.set(data, headerBytes.length);
diff --git a/index.html b/index.html
index 1a6e155..e7081c5 100644
--- a/index.html
+++ b/index.html
@@ -22,6 +22,7 @@
+
@@ -35,7 +36,7 @@
Waiting...
diff --git a/tahnaroskakori.css b/tahnaroskakori.css
index 589736a..10153ef 100644
--- a/tahnaroskakori.css
+++ b/tahnaroskakori.css
@@ -61,7 +61,8 @@ main {
}
button,
-select {
+select,
+input {
background-color: var(--bg-color);
color: var(--color);
diff --git a/tahnaroskakori.js b/tahnaroskakori.js
index abac08f..cec823c 100644
--- a/tahnaroskakori.js
+++ b/tahnaroskakori.js
@@ -9,6 +9,7 @@ import { DataOptions } from "./dataoptions.js";
import { ViewMode } from "./viewmode.js";
import { getDependenciesForLanguage, getDepsData, LANGUAGES, LANGUAGE_NAMES, PLAINTEXT } from "./languages.js";
+const PROJECT_NAME = "Tahnaroskakori";
const COMPRESS_WAIT = 500;
const ENCODER = new TextEncoder();
const ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789?/-._~$&+=";
@@ -17,6 +18,8 @@ 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;
@@ -28,11 +31,22 @@ 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";
@@ -135,16 +149,35 @@ function maxHashLength() {
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: ${utf8.length} B -> ${compressed.length} B -> ${encoded.length}/${maxHashLength()} chars`;
+ statusEl.textContent = `Length: ${dataWithHeader.length} B -> ${compressed.length} B -> ${encoded.length}/${maxHashLength()} chars`;
if (encoded.length <= maxHashLength()) {
history.replaceState(null, "", `#${encoded}`);
@@ -209,6 +242,8 @@ async function streamDecompress() {
const rest = dataOptions.parseFrom(chunk);
languageSelectEl.value = dataOptions.language;
+ titleInputEl.value = dataOptions.title;
+ setTitle();
controller.enqueue(rest);
this.firstChunk = true;
@@ -226,6 +261,7 @@ async function streamDecompress() {
statusEl.textContent = `Decompressing: ${decompressedChunks} chunks...`;
+ setTitle();
renderCode();
// Delay stream between every chunk to avoid zip bombing
@@ -267,25 +303,24 @@ async function languageSelected() {
await syncCompress();
}
+/**
+ * Callback for title being edited.
+ */
+async function titleEdited() {
+ dataOptions.title = titleInputEl.value;
+
+ setTitle();
+ scheduleCompress();
+}
+
/**
* 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);
+ setTitle();
+ scheduleCompress();
}
async function init() {
@@ -295,6 +330,7 @@ async function init() {
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;
@@ -312,6 +348,7 @@ async function init() {
codeEditEl.addEventListener("input", codeEdited);
viewModeSwitcherEl.addEventListener("click", switchMode);
languageSelectEl.addEventListener("change", languageSelected);
+ titleInputEl.addEventListener("input", titleEdited);
window.addEventListener("hashchange", streamDecompress);
await streamDecompress();