Dev #14

Merged
Fretux merged 8 commits from dev into main 2026-09-30 19:56:46 +02:00
2 changed files with 101 additions and 0 deletions
Showing only changes of commit 20ad835205 - Show all commits
+40
View File
@@ -0,0 +1,40 @@
const focusableSelector = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
const stack = [];
function focusables(modal) {
return [...modal.querySelectorAll(focusableSelector)].filter(element =>
element.getClientRects().length && !element.closest('.hidden, [hidden]'));
}
export function openModal(modal, preferredFocus) {
const previous = document.activeElement;
modal.classList.remove('hidden');
stack.push({modal, previous});
(preferredFocus || focusables(modal)[0] || modal.querySelector('[role="dialog"]'))?.focus();
}
export function closeModal(modal) {
const index = stack.findLastIndex(entry => entry.modal === modal);
if (index < 0) return;
const [{previous}] = stack.splice(index, 1);
modal.classList.add('hidden');
if (index === stack.length) {
const target = stack.length ? focusables(stack.at(-1).modal)[0] : previous;
if (target?.isConnected) target.focus();
}
}
export function topModal() { return stack.at(-1)?.modal || null; }
document.addEventListener('keydown', event => {
if (event.key !== 'Tab' || !stack.length) return;
const modal = topModal();
const elements = focusables(modal);
if (!elements.length) { event.preventDefault(); modal.querySelector('[role="dialog"]')?.focus(); return; }
const first = elements[0], last = elements.at(-1);
if (event.shiftKey && (document.activeElement === first || !modal.contains(document.activeElement))) {
event.preventDefault(); last.focus();
} else if (!event.shiftKey && (document.activeElement === last || !modal.contains(document.activeElement))) {
event.preventDefault(); first.focus();
}
}, true);
+61
View File
@@ -0,0 +1,61 @@
/** Portable workspace archive: a gzip-compressed ustar file with JSON and images. */
import {gzipSync, gunzipSync} from "node:zlib";
export const MAX_ARCHIVE_BYTES = 250 * 1024 * 1024;
function header(name, size) {
const block = Buffer.alloc(512);
block.write(name, 0, 100, "ascii");
block.write("0000644\0", 100, "ascii");
block.write("0000000\0", 108, "ascii");
block.write("0000000\0", 116, "ascii");
block.write(size.toString(8).padStart(11, "0") + "\0", 124, "ascii");
block.write(Math.floor(Date.now() / 1000).toString(8).padStart(11, "0") + "\0", 136, "ascii");
block.fill(32, 148, 156);
block.write("0", 156, "ascii");
block.write("ustar\0", 257, "ascii");
block.write("00", 263, "ascii");
const checksum = block.reduce((sum, byte) => sum + byte, 0);
block.write(checksum.toString(8).padStart(6, "0") + "\0 ", 148, "ascii");
return block;
}
export function createWorkspaceArchive(workspace, images) {
const files = [["workspace.json", Buffer.from(JSON.stringify(workspace, null, 2))], ...images];
const chunks = [];
let total = 1024;
for (const [name, body] of files) {
if (!/^(workspace\.json|images\/[a-f0-9-]{36}\.(png|jpeg|webp))$/.test(name)) throw new Error("Invalid archive entry");
total += 512 + Math.ceil(body.length / 512) * 512;
if (total > MAX_ARCHIVE_BYTES) throw new Error("Workspace archive exceeds 250 MB");
chunks.push(header(name, body.length), body, Buffer.alloc((512 - body.length % 512) % 512));
}
chunks.push(Buffer.alloc(1024));
return gzipSync(Buffer.concat(chunks, total), {level: 1});
}
export function parseWorkspaceArchive(buffer) {
if (!Buffer.isBuffer(buffer) || buffer.length > MAX_ARCHIVE_BYTES) throw new Error("Archive exceeds 250 MB");
const tar = gunzipSync(buffer, {maxOutputLength: MAX_ARCHIVE_BYTES});
const files = new Map();
for (let offset = 0; offset + 512 <= tar.length;) {
const block = tar.subarray(offset, offset + 512);
if (block.every(byte => byte === 0)) break;
const name = block.toString("ascii", 0, 100).split("\0")[0];
if (!/^(workspace\.json|images\/[a-f0-9-]{36}\.(png|jpeg|webp))$/.test(name) || files.has(name) || block.toString("ascii", 156, 157) !== "0") {
throw new Error("Invalid archive entry");
}
const recorded = Number.parseInt(block.toString("ascii", 148, 156).trim(), 8);
const copy = Buffer.from(block); copy.fill(32, 148, 156);
if (recorded !== copy.reduce((sum, byte) => sum + byte, 0)) throw new Error("Invalid archive checksum");
const size = Number.parseInt(block.toString("ascii", 124, 136).replaceAll("\0", "").trim(), 8);
if (!Number.isSafeInteger(size) || size < 0 || offset + 512 + size > tar.length) throw new Error("Invalid archive size");
files.set(name, tar.subarray(offset + 512, offset + 512 + size));
offset += 512 + Math.ceil(size / 512) * 512;
}
if (!files.has("workspace.json")) throw new Error("Archive has no workspace.json");
let workspace;
try { workspace = JSON.parse(files.get("workspace.json").toString("utf8")); }
catch { throw new Error("Archive has invalid workspace.json"); }
return {workspace, images: files};
}