const KEY = `call_scripters:${document.title}`;
const RANKDIR = document.documentElement.dataset.rankdir;
if (!['LR', 'TB'].includes(RANKDIR)) throw new Error(`bundler wrote an unknown rankdir: ${RANKDIR}`);
const DOUBLE_CLICK_MS = 400;
const SCRIPT_TAG = /<\/?script/i; const SPACING_KEY = 'call_scripters:spacing'; const THEME_KEY = 'call_scripters:theme';
const ZOOM_KEY = 'call_scripters:zoom';
const ZOOM_STEP = 1.2;
const [MIN_ZOOM, MAX_ZOOM] = [0.2, 3];
const LABEL_NAME = /^[\w-]+$/;
const COLOR = /^oklch\((\d*\.?\d+) \d*\.?\d+ \d*\.?\d+\)$/; const SWATCHES = [25, 55, 85, 130, 165, 200, 240, 275, 310, 345].flatMap(h => [`oklch(0.62 0.19 ${h})`, `oklch(0.86 0.09 ${h})`]);
const ARROWS = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] };
const ARROW_SPEED = 1200; const randomColor = () => `oklch(${(0.5 + Math.random() * 0.4).toFixed(2)} 0.15 ${Math.floor(Math.random() * 360)})`;
const pristine = document.documentElement.outerHTML;
const bar = document.getElementById('bar');
const canvas = document.getElementById('canvas');
let md = document.getElementById('src').textContent;
let graph = parse(md);
let sel = JSON.parse(sessionStorage.getItem(KEY) ?? '{"variants":{},"path":[],"calling":false}');
let dirty = false;
let live = document.documentElement.dataset.live; let editing = null;
let lastClick = { id: null, t: 0 };
let cards = new Map();
let held = new Set(); let lastFrame = 0;
let shortcuts = new Map(); let spacing = Number(localStorage.getItem(SPACING_KEY) ?? 1);
if (!(spacing > 0)) throw new Error(`corrupt stored spacing: ${localStorage.getItem(SPACING_KEY)}`);
let theme = localStorage.getItem(THEME_KEY) ?? (matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
if (!['light', 'dark'].includes(theme)) throw new Error(`corrupt stored theme: ${theme}`);
document.documentElement.dataset.theme = theme;
let zoom = Number(localStorage.getItem(ZOOM_KEY) ?? 1);
if (!(zoom >= MIN_ZOOM && zoom <= MAX_ZOOM)) throw new Error(`corrupt stored zoom: ${localStorage.getItem(ZOOM_KEY)}`);
canvas.style.zoom = zoom;
let pinch = null; let gestureZoom = null; let labelForm = null; const labelsDialog = el('dialog', 'labels');
document.body.append(labelsDialog);
function el(tag, cls, html) {
const e = document.createElement(tag);
if (cls) e.className = cls;
if (html !== undefined) e.innerHTML = html;
return e;
}
function render() {
const ids = new Set(graph.nodes.map(n => n.id));
sel.path = sel.path.filter(id => ids.has(id));
for (const [k, v] of Object.entries(sel.variants)) if (!graph.dims[k]?.includes(v)) delete sel.variants[k];
sessionStorage.setItem(KEY, JSON.stringify(sel));
renderBar();
if (labelsDialog.open) renderLabels();
canvas.replaceChildren();
cards = new Map();
if (graph.errors.length) {
canvas.append(errorList(graph.errors, e => `line ${e.line}: ${e.msg}`));
canvas.style.width = canvas.style.height = '';
return;
}
const shown = visible(graph, sel);
const nodes = graph.nodes.filter(n => shown.has(n.id));
const edges = graph.edges.filter(e => shown.has(e.from) && shown.has(e.to));
const current = sel.path.at(-1);
const walked = new Set(sel.path.slice(1).map((id, i) => `${sel.path[i]}\n${id}`));
const exits = new Set(edges.map(e => e.from));
for (const n of nodes) {
const card = el('article', 'card');
card.dataset.id = n.id;
card.classList.toggle('visited', sel.path.includes(n.id));
card.classList.toggle('current', n.id === current);
card.classList.toggle('terminal', !exits.has(n.id));
const head = el('header');
for (const [k, v] of Object.entries(n.tags)) head.append(el('span', 'chip', `${k}=${v}`));
for (const name of n.labels) {
const p = pill(graph.labels.find(l => l.name === name));
const x = el('button', 'unlabel', '×');
x.title = `remove ${name}`;
x.addEventListener('click', e => {
e.stopPropagation();
assert(!commit(splice([attrs(n, n.labels.filter(l => l !== name))])), 'removing a label broke the document');
});
p.append(x);
head.append(p);
}
head.append(labelPicker(n));
head.append(el('h2', '', marked.parseInline(n.title)));
const del = el('button', 'delete', '×');
del.title = 'delete card (select, then Backspace)';
del.addEventListener('click', e => {
e.stopPropagation();
remove(n);
});
card.append(del, head, el('div', 'body', marked.parse(n.body)));
card.addEventListener('click', () => click(n));
canvas.append(card);
cards.set(n.id, card);
}
const labels = edges.map(e => {
const label = el('button', 'label', marked.parseInline(e.label));
label.classList.toggle('visited', walked.has(`${e.from}\n${e.to}`));
label.addEventListener('click', () => {
if (editing) return;
go(e.to, e.from);
if (sel.calling) reveal(e.to);
});
canvas.append(label);
return label;
});
const slotOf = new Map(nodes.map(n => [n.id, n.slot]));
const inSlot = e => e.from !== e.to && slotOf.get(e.from) === slotOf.get(e.to);
const slots = new dagre.graphlib.Graph({ multigraph: true });
slots.setGraph({});
for (const n of nodes) slots.setNode(n.slot, {});
edges.forEach((e, i) => slotOf.get(e.from) !== slotOf.get(e.to) && slots.setEdge(slotOf.get(e.from), slotOf.get(e.to), {}, String(i)));
dagre.layout(slots);
const rank = id => slots.node(slotOf.get(id)).rank;
const ranker = lg => {
const top = Math.min(...nodes.map(n => rank(n.id)));
for (const v of lg.nodes()) {
assert(slotOf.has(v) || v === lg.graph().nestingRoot, `dagre ranks an unknown node ${v}`);
lg.node(v).rank = slotOf.has(v) ? rank(v) : top - 1;
}
};
const flipped = edges.map(e => rank(e.to) < rank(e.from));
const g = new dagre.graphlib.Graph({ multigraph: true });
g.setGraph({ rankdir: RANKDIR, ranker, nodesep: 36 * spacing, ranksep: 60 * spacing, edgesep: 16 * spacing, marginx: 48, marginy: 48 });
for (const n of nodes) g.setNode(n.id, { width: cards.get(n.id).offsetWidth, height: cards.get(n.id).offsetHeight });
edges.forEach((e, i) => {
if (inSlot(e)) return;
const [v, w] = flipped[i] ? [e.to, e.from] : [e.from, e.to];
g.setEdge(v, w, { width: labels[i].offsetWidth, height: labels[i].offsetHeight, labelpos: 'c' }, String(i));
});
dagre.layout(g);
const [axis, cross, size, crossSize] = RANKDIR === 'TB' ? ['y', 'x', 'height', 'width'] : ['x', 'y', 'width', 'height'];
const lead = id => g.node(id)[axis] - g.node(id)[size] / 2;
const slotLead = new Map();
for (const n of nodes) slotLead.set(n.slot, Math.min(slotLead.get(n.slot) ?? Infinity, lead(n.id)));
const shift = new Map(nodes.map(n => [n.id, slotLead.get(n.slot) - lead(n.id)])); for (const n of nodes) g.node(n.id)[axis] += shift.get(n.id);
const routes = edges.map((e, i) => {
if (inSlot(e)) {
const [a, b] = [g.node(e.from), g.node(e.to)];
const dir = Math.sign(b[cross] - a[cross]);
const at = slotLead.get(slotOf.get(e.from)) + Math.min(a[size], b[size]) / 2;
const points = [{ [cross]: a[cross] + dir * a[crossSize] / 2, [axis]: at }, { [cross]: b[cross] - dir * b[crossSize] / 2, [axis]: at }];
return { points, x: (points[0].x + points[1].x) / 2, y: (points[0].y + points[1].y) / 2 };
}
const { points, x, y } = g.edge(...(flipped[i] ? [e.to, e.from] : [e.from, e.to]), String(i));
if (flipped[i]) points.reverse();
if (e.from === e.to) {
const label = { x, y };
for (const p of [...points, label]) p[axis] += shift.get(e.from);
return { points, ...label };
}
points[0][axis] += shift.get(e.from);
points.at(-1)[axis] += shift.get(e.to);
return { points, x, y };
});
for (const n of nodes) {
const { x, y, width, height } = g.node(n.id);
Object.assign(cards.get(n.id).style, { left: `${x - width / 2}px`, top: `${y - height / 2}px` });
}
const { width, height } = g.graph();
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('width', width);
svg.setAttribute('height', height);
svg.innerHTML = '<defs><marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0 0L10 5L0 10z" fill="context-stroke"/></marker></defs>';
edges.forEach((e, i) => {
const { points, x, y } = routes[i];
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
let d = `M${points[0].x},${points[0].y}`;
for (let j = 1; j < points.length - 1; j++) {
const [p, q] = [points[j], points[j + 1]];
d += ` Q${p.x},${p.y} ${(p.x + q.x) / 2},${(p.y + q.y) / 2}`;
}
d += ` L${points.at(-1).x},${points.at(-1).y}`;
path.setAttribute('d', d);
path.setAttribute('marker-end', 'url(#arrow)');
path.classList.toggle('visited', walked.has(`${e.from}\n${e.to}`));
svg.append(path);
Object.assign(labels[i].style, { left: `${x - labels[i].offsetWidth / 2}px`, top: `${y - labels[i].offsetHeight / 2}px` });
});
canvas.prepend(svg);
Object.assign(canvas.style, { width: `${width}px`, height: `${height}px` });
}
function renderBar() {
bar.replaceChildren(el('strong', 'title', document.title));
if (dirty) bar.append(el('span', 'dirty', '● unsaved'));
for (const [dim, values] of Object.entries(graph.dims)) {
const select = el('select');
for (const v of ['all', ...values]) select.append(new Option(v, v, false, sel.variants[dim] === v));
if (!(dim in sel.variants)) select.value = 'all';
select.addEventListener('change', () => {
if (select.value === 'all') delete sel.variants[dim];
else sel.variants[dim] = select.value;
render();
});
const label = el('label', 'dim', `${dim} `);
label.append(select);
bar.append(label);
}
const range = el('input');
Object.assign(range, { type: 'range', min: 0.1, max: 3, step: 0.1, value: spacing });
range.addEventListener('change', () => { spacing = Number(range.value);
localStorage.setItem(SPACING_KEY, spacing);
render();
});
const spacingLabel = el('label', 'dim', 'spacing ');
spacingLabel.append(range);
bar.append(spacingLabel);
shortcuts = new Map();
const control = (c, hint, keys) => {
c.title = [hint, ...keys.map(k => `[${k}]`)].join('\n');
for (const k of keys) {
assert(!shortcuts.has(k), `two bar controls bound to ${k}`);
shortcuts.set(k, c);
}
return c;
};
const button = (text, hint, keys, action, parent = bar) => {
const b = control(el('button', '', text), hint, keys);
b.addEventListener('click', action);
parent.append(b);
return b;
};
const center = () => [innerWidth / 2, (bar.offsetHeight + innerHeight) / 2];
button('−', 'zoom out; pinch or Ctrl+scroll zoom too', ['-'], () => zoomTo(zoom / ZOOM_STEP, ...center()));
button('+', 'zoom in', ['+', '='], () => zoomTo(zoom * ZOOM_STEP, ...center()));
button('Labels', 'define the labels cards carry', ['l'], () => {
renderLabels();
labelsDialog.showModal();
}).classList.add('push');
button('New call', 'clear the walked path; the view follows the call until Clear', ['n'], newCall).classList.toggle('on', sel.calling);
button('Clear', 'clear the walked path and end the call', ['Escape'], () => {
sel.path = [];
sel.calling = false;
render();
});
const save = el('details', 'menu');
save.append(control(el('summary', '', 'Save'), `download a copy; Ctrl+S ${live ? 'writes the md' : 'overwrites the opened file'}`, ['s']));
const items = el('div', 'items');
button('.html', 'this page, with the current script', [], () => download(`${document.title}.html`, html(), 'text/html'), items);
button('.md', 'the script alone', [], () => download(`${document.title}.md`, md, 'text/markdown'), items);
save.append(items);
bar.append(save);
button(theme === 'dark' ? '☀' : '☾', 'theme', [], () => {
theme = theme === 'dark' ? 'light' : 'dark';
localStorage.setItem(THEME_KEY, theme);
document.documentElement.dataset.theme = theme;
renderBar();
});
document.body.style.paddingTop = `${bar.offsetHeight}px`; }
function newCall() {
sel.path = [];
sel.calling = true;
render();
scrollTo(0, 0);
}
function glide(t) {
if (!held.size) return;
const step = (ARROW_SPEED * Math.max(0, t - lastFrame)) / 1000; lastFrame = t;
const [dx, dy] = [...held].reduce(([x, y], k) => [x + ARROWS[k][0], y + ARROWS[k][1]], [0, 0]);
scrollBy(dx * step, dy * step);
requestAnimationFrame(glide);
}
function canvasAt(cx, cy) {
const r = canvas.getBoundingClientRect();
return [(cx - r.left) / zoom, (cy - r.top) / zoom];
}
function zoomTo(z, cx, cy, at = canvasAt(cx, cy)) {
zoom = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, z));
localStorage.setItem(ZOOM_KEY, zoom);
canvas.style.zoom = zoom;
const r = canvas.getBoundingClientRect();
scrollBy(r.left + at[0] * zoom - cx, r.top + at[1] * zoom - cy);
}
function pill(label) {
const p = el('span', 'pill');
p.textContent = label.name;
p.title = label.description;
p.style.background = label.color;
p.style.color = Number(COLOR.exec(label.color)[1]) > 0.7 ? 'oklch(0.2 0 0)' : 'oklch(1 0 0)';
return p;
}
function labelPicker(n) {
const pick = el('select', 'add');
pick.title = 'add a label';
pick.append(new Option('+', '', true, true));
pick.options[0].hidden = true;
for (const l of graph.labels) if (!n.labels.includes(l.name)) pick.append(new Option(l.name, l.name));
pick.append(new Option('Edit labels…', ' edit'));
pick.addEventListener('click', e => e.stopPropagation());
pick.addEventListener('change', () => {
if (pick.value === ' edit') {
renderLabels();
labelsDialog.showModal();
pick.value = '';
} else assert(!commit(splice([attrs(n, [...n.labels, pick.value])])), 'adding a defined label broke the document');
});
return pick;
}
function renderLabels() {
const head = el('header');
head.append(el('h2', '', `${graph.labels.length} labels`));
const add = el('button', 'primary', 'New label');
add.addEventListener('click', () => {
labelForm = { orig: null, name: '', description: '', color: randomColor() };
renderLabels();
});
const close = el('button', '', 'Close');
close.addEventListener('click', () => labelsDialog.close());
head.append(add, close);
labelsDialog.replaceChildren(head);
if (labelForm && labelForm.orig === null) labelsDialog.append(labelEditor());
const list = el('ul');
for (const l of graph.labels) {
if (labelForm?.orig === l.name) {
list.append(labelEditor());
continue;
}
const row = el('li');
const uses = graph.nodes.filter(n => n.labels.includes(l.name)).length;
const description = el('span', 'description');
description.textContent = l.description;
row.append(pill(l), description, el('span', 'uses', uses ? `${uses} card${uses > 1 ? 's' : ''}` : ''));
const edit = el('button', 'link', 'Edit');
edit.addEventListener('click', () => {
labelForm = { orig: l.name, name: l.name, description: l.description, color: l.color };
renderLabels();
});
const del = el('button', 'link danger', 'Delete');
del.addEventListener('click', () => {
if (!confirm(`Delete label "${l.name}"?${uses ? ` It is removed from ${uses} card${uses > 1 ? 's' : ''}.` : ''}`)) return;
assert(!commit(splice([{ start: l.start, end: l.end, text: '' }, ...relabel(l.name, null)])), 'deleting a label broke the document');
});
row.append(edit, del);
list.append(row);
}
labelsDialog.append(list);
}
function relabel(from, name) {
return graph.nodes.filter(n => n.labels.includes(from)).map(n => attrs(n, n.labels.flatMap(l => (l !== from ? [l] : name === null ? [] : [name]))));
}
function labelEditor() {
const f = labelForm;
const form = el('form', 'editor');
const preview = el('div', 'preview');
const errors = el('div');
const showPreview = () => preview.replaceChildren(pill({ name: f.name || 'Label preview', description: f.description, color: COLOR.test(f.color) ? f.color : 'oklch(0.93 0 0)' }));
const field = (label, input) => {
const l = el('label', '', label);
l.append(input);
form.append(l);
};
const text = (key, attrsOf) => {
const i = Object.assign(el('input'), { value: f[key], ...attrsOf });
i.addEventListener('input', () => {
f[key] = i.value;
showPreview();
});
return i;
};
form.append(preview);
field('Label name', text('name', { required: true, pattern: LABEL_NAME.source.slice(1, -1), title: 'letters, digits, _ and -' }));
field('Description', text('description', { placeholder: 'Description (optional)' }));
const color = el('div', 'color');
const random = el('button', '', '⟳');
Object.assign(random, { type: 'button', title: 'random color' });
const value = text('color', { required: true, pattern: COLOR.source.slice(1, -1), title: 'oklch(L C H)', size: 20 });
const set = c => {
f.color = value.value = c;
showPreview();
};
random.addEventListener('click', () => set(randomColor()));
const swatches = el('div', 'swatches');
for (const c of SWATCHES) {
const b = el('button');
Object.assign(b, { type: 'button', title: c });
b.style.background = c;
b.addEventListener('click', () => set(c));
swatches.append(b);
}
color.append(random, value);
field('Color', color);
form.append(swatches);
const cancel = el('button', '', 'Cancel');
cancel.type = 'button';
cancel.addEventListener('click', () => {
labelForm = null;
renderLabels();
});
form.append(cancel, el('button', 'primary', f.orig === null ? 'Create label' : 'Save changes'), errors);
form.addEventListener('submit', e => {
e.preventDefault();
const line = `[${f.name}]: <${f.color}>${f.description && ` "${f.description.replace(/[\\"]/g, '\\$&')}"`}\n`;
const def = graph.labels.find(l => l.name === f.orig);
const last = graph.labels.at(-1);
const edits = def ? [{ start: def.start, end: def.end, text: line }, ...(f.name !== f.orig ? relabel(f.orig, f.name) : [])] : [last ? { start: last.end, end: last.end, text: line } : { start: 0, end: 0, text: `${line}\n` }];
labelForm = null;
const failed = commit(splice(edits));
if (failed) {
labelForm = f;
errors.replaceChildren(errorList(failed, e => e.msg));
}
});
showPreview();
return form;
}
function errorList(errors, fmt) {
const ul = el('ul', 'errors');
for (const e of errors) {
const li = el('li');
li.textContent = fmt(e);
ul.append(li);
}
return ul;
}
function click(n) {
if (editing) return;
const now = performance.now();
if (lastClick.id === n.id && now - lastClick.t < DOUBLE_CLICK_MS) {
lastClick = { id: null, t: 0 };
edit(n);
return;
}
lastClick = { id: n.id, t: now };
go(n.id);
if (sel.calling) reveal(n.id);
}
function go(id, from) {
if (from !== undefined && sel.path.at(-1) !== from) sel.path.push(from);
if (sel.path.at(-1) !== id) sel.path.push(id);
Object.assign(sel.variants, graph.nodes.find(n => n.id === id).tags);
render();
}
function reveal(id) {
const targets = graph.edges.filter(e => e.from === id && cards.has(e.to)).map(e => cards.get(e.to));
const rects = [cards.get(id), ...targets].map(c => c.getBoundingClientRect());
const pad = 24;
const top = bar.offsetHeight + pad;
const scrollFor = (lo, hi, min, max) => (hi > max ? Math.min(hi - max, lo - min) : lo < min ? lo - min : 0);
scrollBy({
left: scrollFor(Math.min(...rects.map(r => r.left)), Math.max(...rects.map(r => r.right)), pad, innerWidth - pad),
top: scrollFor(Math.min(...rects.map(r => r.top)), Math.max(...rects.map(r => r.bottom)), top, innerHeight - pad),
});
}
function splice(edits) {
edits.sort((a, b) => b.start - a.start);
edits.forEach((e, i) => i && assert(e.end <= edits[i - 1].start, 'splice edits overlap'));
return edits.reduce((s, e) => s.slice(0, e.start) + e.text + s.slice(e.end), md);
}
function assert(ok, msg) {
if (!ok) throw new Error(msg);
}
function commit(next) {
const g = SCRIPT_TAG.test(next) ? { errors: [{ line: 0, msg: 'a <script> tag cannot be embedded' }] } : parse(next);
if (g.errors.length) return g.errors;
md = next;
graph = g;
dirty = true;
render();
return null;
}
function attrs(n, labels) {
const tokens = [...Object.entries(n.tags).map(([k, v]) => `${k}=${v}`), ...labels.map(l => `.${l}`)];
return { start: n.attrs.start, end: n.attrs.end, text: tokens.length ? ` {${tokens.join(' ')}}` : '' };
}
function remove(n) {
const last = graph.nodes.every(m => m === n || m.slot !== n.slot);
const inside = e => e.start >= n.start && e.end <= n.end;
const responses = last ? graph.edges.filter(e => e.to === n.id && !inside(e)) : [];
if (!confirm(`Delete "${n.title}"?${responses.length ? ` ${responses.length} response${responses.length > 1 ? 's' : ''} leading to it go too.` : ''}`)) return;
const errors = commit(splice([{ start: n.start, end: n.end, text: '' }, ...responses.map(e => ({ start: e.start, end: e.end, text: '' }))]));
if (errors) alert(`Cannot delete "${n.title}":\n${errors.map(e => e.msg).join('\n')}`);
}
function edit(n) {
editing = n.id;
const card = cards.get(n.id);
const ta = el('textarea');
ta.value = md.slice(n.start, n.end).trimEnd();
ta.spellcheck = false;
const errors = el('div');
card.classList.add('editing');
card.replaceChildren(ta, errors);
const fit = () => {
ta.style.height = 'auto';
ta.style.height = `${ta.scrollHeight + 2}px`;
};
ta.addEventListener('input', fit);
ta.addEventListener('click', e => e.stopPropagation());
ta.addEventListener('keydown', e => {
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
const text = ta.value.trimEnd();
const base = md.slice(0, n.start).split('\n').length - 1;
const lines = text.split('\n').length;
editing = null;
const failed = commit(splice([{ start: n.start, end: n.end, text: text && text + (n.end < md.length ? '\n\n' : '\n') }]));
if (failed) {
editing = n.id;
errors.replaceChildren(errorList(failed, e => (e.line > base && e.line <= base + lines ? `line ${e.line - base}: ${e.msg}` : e.line ? `elsewhere, document line ${e.line}: ${e.msg}` : e.msg)));
}
}
});
fit();
ta.focus();
}
function download(name, text, type) {
const a = document.createElement('a');
a.href = URL.createObjectURL(new Blob([text], { type }));
a.download = name;
a.click();
}
function html() {
assert(!SCRIPT_TAG.test(md), 'markdown contains a <script> tag; commit should have rejected it');
const doc = new DOMParser().parseFromString(pristine, 'text/html');
doc.getElementById('src').textContent = md;
delete doc.documentElement.dataset.live; return `<!DOCTYPE html>\n${doc.documentElement.outerHTML}`;
}
async function overwrite() {
if (live) {
const r = await fetch('/', { method: 'PUT', headers: { 'If-Match': live }, body: md });
if (!r.ok) {
alert(await r.text());
return;
}
live = await r.text();
dirty = false;
renderBar();
return;
}
if (!window.showSaveFilePicker) {
alert('This browser cannot overwrite files; download a copy with Save instead.');
return;
}
let handle = await handles('readonly', s => s.get(location.href));
if (handle && (await handle.requestPermission({ mode: 'readwrite' })) !== 'granted') handle = undefined;
if (!handle) {
try {
handle = await showSaveFilePicker({ suggestedName: decodeURIComponent(location.pathname.split('/').at(-1)), types: [{ description: 'html', accept: { 'text/html': ['.html'] } }] });
} catch (e) {
if (e.name === 'AbortError') return; throw e;
}
await handles('readwrite', s => s.put(handle, location.href));
}
const w = await handle.createWritable();
await w.write(html());
await w.close();
dirty = false;
renderBar();
}
function handles(mode, op) {
return new Promise((resolve, reject) => {
const open = indexedDB.open('call_scripters', 1);
open.onupgradeneeded = () => open.result.createObjectStore('handles');
open.onerror = () => reject(open.error);
open.onsuccess = () => {
const req = op(open.result.transaction('handles', mode).objectStore('handles'));
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
};
});
}
document.addEventListener('keydown', e => {
const typing = e.target.closest('input, textarea, select');
const plain = !(e.ctrlKey || e.metaKey || e.altKey);
if (e.key === 's' && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
overwrite();
} else if (labelsDialog.open) {
return;
} else if (e.key === 'Escape') { if (editing) {
editing = null;
render();
} else shortcuts.get('Escape').click();
} else if (editing || typing || !plain) {
return;
} else if (e.key in ARROWS) {
e.preventDefault();
if (!held.size) {
lastFrame = performance.now();
requestAnimationFrame(glide);
}
held.add(e.key);
} else if (shortcuts.has(e.key)) {
shortcuts.get(e.key).click();
} else if (e.key === 'Backspace' && cards.has(sel.path.at(-1))) {
e.preventDefault();
remove(graph.nodes.find(n => n.id === sel.path.at(-1)));
}
});
document.addEventListener('keyup', e => held.delete(e.key));
addEventListener('blur', () => held.clear()); document.addEventListener('click', e => {
for (const d of document.querySelectorAll('details.menu[open]')) if (!d.contains(e.target)) d.open = false;
});
addEventListener(
'wheel',
e => {
if (!e.ctrlKey) return; e.preventDefault();
zoomTo(zoom * Math.exp(-(e.deltaMode ? 16 : 1) * e.deltaY * 0.01), e.clientX, e.clientY);
},
{ passive: false },
);
addEventListener('gesturestart', e => {
e.preventDefault();
gestureZoom = zoom;
});
addEventListener('gesturechange', e => {
e.preventDefault();
if (!pinch) zoomTo(gestureZoom * e.scale, e.clientX, e.clientY); });
const spread = t => Math.hypot(t[0].clientX - t[1].clientX, t[0].clientY - t[1].clientY);
const middle = t => [(t[0].clientX + t[1].clientX) / 2, (t[0].clientY + t[1].clientY) / 2];
addEventListener('touchstart', e => {
if (e.touches.length === 2) pinch = { spread: spread(e.touches), zoom, at: canvasAt(...middle(e.touches)) };
});
addEventListener(
'touchmove',
e => {
if (!pinch || e.touches.length !== 2) return;
e.preventDefault();
zoomTo((pinch.zoom * spread(e.touches)) / pinch.spread, ...middle(e.touches), pinch.at);
},
{ passive: false },
);
for (const t of ['touchend', 'touchcancel']) addEventListener(t, e => e.touches.length < 2 && (pinch = null));
labelsDialog.addEventListener('close', () => (labelForm = null));
addEventListener('beforeunload', e => dirty && e.preventDefault());
if (live)
(async function poll() {
let version;
try {
version = await (await fetch('/version')).text();
} catch (e) {
if (e instanceof TypeError) return; throw e;
}
if (version !== live && !dirty) location.reload(); else setTimeout(poll, 300);
})();
render();