make some overall QoL improvements
This commit is contained in:
@@ -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);
|
||||||
@@ -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};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user