Case Converter
by sloprun seed
2026-08-14
0 reported it broke · the ▶ count is distinct visitors who ran it, counted once each, anonymously
One box of text, thirteen spellings of it: the splitter reads camelCase, snake_case, kebab-case, RUN-ON CAPS and plain spaces alike, then hands back camelCase, PascalCase, snake_case, kebab-case, CONSTANT_CASE, Title Case and sentence case, each with its own copy button and character count, updating on every keystroke.
The row of word chips under the input is the splitter showing its work — click or hover one and that word lights up inside every identifier below, so you can watch XMLHttpRequest come apart into XML · Http · Request before it is glued back together. Try the "messy" example, then "a list": every line is converted independently, which turns the page into a bulk renamer for a column of field names. Title Case and sentence case leave your punctuation and your shouty acronyms alone, so isn't the iPhone great? survives the trip intact.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Case Converter — sloprun.dev</title>
<style>
/* sloprun design tokens — inline this block into every demo (self-contained rule).
Identity: instrument-panel. Machine facts in mono; human words in sans.
Green is EARNED: only for "it ran / it worked" states, never decoration. */
:root {
--bg: #F6F6F3; /* neutral paper, not cream */
--surface: #FFFFFF;
--ink: #1A1D21;
--muted: #5A6068;
--line: #E3E2DC;
--accent: #C05600; /* phosphor amber, darkened for light bg */
--accent-soft: #FFF3E6;
--run: #1A7F37; /* earned green */
--run-soft: #E7F4EA;
--danger: #C93C37;
--radius: 10px;
--font-sans: system-ui, -apple-system, "Segoe UI", sans-serif;
--font-mono: ui-monospace, "SF Mono", "Cascadia Code", Menlo, Consolas, monospace;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--bg: #14161A; --surface: #1C1F26; --ink: #E8E6E1; --muted: #9AA0A8;
--line: #2A2E36; --accent: #FFB454; --accent-soft: #2A2118;
--run: #3FB950; --run-soft: #16281B; --danger: #F47067;
}
}
:root[data-theme="dark"] {
--bg: #14161A; --surface: #1C1F26; --ink: #E8E6E1; --muted: #9AA0A8;
--line: #2A2E36; --accent: #FFB454; --accent-soft: #2A2118;
--run: #3FB950; --run-soft: #16281B; --danger: #F47067;
}
body { background: var(--bg); color: var(--ink); font-family: var(--font-sans); }
/* ---- derived surfaces (theme-paired, same rules as the tokens above) ---- */
:root { --wash: #F1F0EC; --glow: rgba(192,86,0,.15); --dim: .45; --accent-ink: #9A4500; }
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) { --wash: #15181D; --glow: rgba(255,180,84,.13); --dim: .4; --accent-ink: #FFB454; }
}
:root[data-theme="dark"] { --wash: #15181D; --glow: rgba(255,180,84,.13); --dim: .4; --accent-ink: #FFB454; }
/* ---- shell ---- */
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
body { font-size: 16px; line-height: 1.55; overflow-x: hidden; -webkit-text-size-adjust: 100%; }
.wrap { max-width: 940px; margin: 0 auto; padding: 26px 16px 34px; }
h1 { font-size: 1.5rem; line-height: 1.2; margin: 0 0 7px; letter-spacing: -.015em; }
h2 { font-size: .95rem; margin: 0; }
.lede { margin: 0; color: var(--muted); font-size: .95rem; max-width: 70ch; }
.lede b { color: var(--ink); font-weight: 600; }
.mono { font-family: var(--font-mono); }
.sr-only {
position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0;
}
footer { margin-top: 24px; font-family: var(--font-mono); font-size: .7rem; color: var(--muted); }
.k-lab {
font-family: var(--font-mono); font-size: .64rem; letter-spacing: .11em;
text-transform: uppercase; color: var(--muted);
}
.panel {
background: var(--surface); border: 1px solid var(--line);
border-radius: var(--radius); padding: 14px; margin-top: 16px;
}
.panelhead { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 10px; margin-bottom: 12px; }
.spacer { flex: 1 1 auto; }
.btn {
font-family: var(--font-mono); font-size: .73rem; letter-spacing: .02em;
background: var(--bg); color: var(--ink); border: 1px solid var(--line);
border-radius: 999px; padding: 6px 11px; min-height: 32px; cursor: pointer;
white-space: nowrap;
}
.btn:hover { border-color: var(--accent); color: var(--accent-ink); }
.btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.btn[aria-pressed="true"] { background: var(--accent-soft); border-color: var(--accent); color: var(--accent-ink); }
.btn.ghost { background: transparent; color: var(--muted); }
.btn.ghost:hover { color: var(--accent-ink); }
.btn.ok, .btn.ok:hover { border-color: var(--run); color: var(--run); background: var(--run-soft); }
.btn:disabled { opacity: var(--dim); cursor: not-allowed; }
.btn:disabled:hover { border-color: var(--line); color: var(--ink); }
.presets { display: flex; flex-wrap: wrap; gap: 6px; }
/* ---- input ---- */
textarea#src {
display: block; width: 100%; resize: vertical; min-height: 92px;
font-family: var(--font-mono); font-size: .92rem; line-height: 1.5; color: var(--ink);
background: var(--bg); border: 1px solid var(--line); border-radius: 8px; padding: 10px 11px;
}
textarea#src:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--glow); }
textarea#src:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
.statsrow { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; margin-top: 10px; }
.stat { font-family: var(--font-mono); font-size: .72rem; color: var(--muted); white-space: nowrap; }
.stat b { color: var(--ink); font-weight: 600; font-size: .82rem; }
.hint { font-size: .68rem; color: var(--muted); white-space: nowrap; }
/* ---- word split strip ---- */
.split { margin-top: 14px; padding-top: 12px; border-top: 1px dashed var(--line); }
.splithead { display: flex; flex-wrap: wrap; align-items: baseline; gap: 6px 10px; margin-bottom: 8px; }
.splithead .who { font-family: var(--font-mono); font-size: .7rem; color: var(--muted); margin-left: auto; }
.toks { display: flex; flex-wrap: wrap; align-items: center; gap: 5px; }
.tok {
font-family: var(--font-mono); font-size: .78rem; cursor: pointer;
background: var(--bg); color: var(--ink);
border: 1px solid var(--line); border-radius: 6px; padding: 3px 8px; min-height: 28px;
max-width: 100%; overflow-wrap: anywhere; text-align: left;
}
.tok:hover, .tok.hi { background: var(--accent-soft); border-color: var(--accent); color: var(--accent-ink); }
.tok:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.tok .n { color: var(--muted); font-size: .64rem; margin-right: 5px; }
.tok.hi .n, .tok:hover .n { color: var(--accent-ink); }
.toks .none { font-family: var(--font-mono); font-size: .74rem; color: var(--muted); }
.toks .more { font-family: var(--font-mono); font-size: .72rem; color: var(--muted); }
/* ---- output rows ---- */
.grp { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 10px; margin: 4px 0 8px; }
.grp + .grp, .rows + .grp { margin-top: 18px; }
.grp .why { font-size: .8rem; color: var(--muted); }
.rows { display: grid; gap: 8px; }
.row {
display: grid; align-items: center; gap: 6px 10px;
grid-template-columns: 128px minmax(0, 1fr) 44px auto;
grid-template-areas: "lab out len act";
}
.row[hidden] { display: none; }
.rlab { grid-area: lab; overflow-wrap: anywhere; }
.out {
grid-area: out; min-width: 0;
font-family: var(--font-mono); font-size: .86rem; line-height: 1.5; color: var(--ink);
background: var(--wash); border: 1px solid var(--line); border-radius: 8px;
padding: 8px 10px; white-space: pre-wrap; overflow-wrap: anywhere;
max-height: 232px; overflow: auto;
}
.out:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
.out .sep { font-style: normal; color: var(--accent-ink); }
.out .cap { font-style: normal; color: var(--accent-ink); }
.out .sg { border-radius: 3px; }
.out .sg.hi { background: var(--accent-soft); box-shadow: 0 0 0 1px var(--accent); }
.out.empty { color: var(--muted); }
/* the row label IS the specimen — never re-case it, that's the whole point */
.rlab.k-lab {
text-transform: none; letter-spacing: 0; font-size: .78rem; color: var(--ink);
}
.len { grid-area: len; font-family: var(--font-mono); font-size: .68rem; color: var(--muted); text-align: right; }
.row .copy { grid-area: act; }
.note { margin: 12px 0 0; font-size: .82rem; color: var(--muted); max-width: 70ch; }
.note code { font-family: var(--font-mono); font-size: .78rem; color: var(--ink); }
/* ---- motion (opt-in only) ---- */
@media (prefers-reduced-motion: no-preference) {
.btn, .tok, .out, .out .sg, textarea#src {
transition: background-color .14s ease, border-color .14s ease, color .14s ease, box-shadow .14s ease;
}
}
/* ---- narrow ---- */
@media (max-width: 640px) {
.row { grid-template-columns: minmax(0, 1fr) auto auto; grid-template-areas: "lab len act" "out out out"; }
.len { text-align: left; }
}
@media (max-width: 420px) {
.wrap { padding: 20px 12px 30px; }
.panel { padding: 12px; }
.out { font-size: .8rem; }
}
</style>
</head>
<body>
<main class="wrap">
<header>
<h1>Case Converter</h1>
<p class="lede">Type anything and get it back in <b>every naming style at once</b>. The splitter reads camelCase, snake_case, kebab-case, RUN-ON CAPS and plain spaces, so you can pour one form in and take any other form out. Tap or hover a word below to see where it lands in each line.</p>
</header>
<section class="panel" aria-labelledby="h-in">
<div class="panelhead">
<h2 id="h-in">Your text</h2>
<div class="spacer"></div>
<div class="presets" id="presets" role="group" aria-label="Example inputs"></div>
</div>
<label class="sr-only" for="src">Text to convert</label>
<textarea id="src" rows="3" spellcheck="false" autocomplete="off" autocapitalize="off" autocorrect="off"></textarea>
<div class="statsrow">
<span class="stat"><b id="cChars">0</b> chars</span>
<span class="stat"><b id="cWords">0</b> words</span>
<span class="stat"><b id="cLines">0</b> lines</span>
<div class="spacer"></div>
<button class="btn" id="digitsBtn" type="button" aria-pressed="false" aria-describedby="digitsHint">split digits</button>
<span class="hint mono" id="digitsHint">utf8 → utf_8</span>
<button class="btn ghost" id="clearBtn" type="button">clear</button>
</div>
<div class="split">
<div class="splithead">
<span class="k-lab" id="toksLab">words the splitter found</span>
<span class="who mono" id="lineNote"></span>
</div>
<div class="toks" id="toks" role="group" aria-labelledby="toksLab"></div>
</div>
</section>
<section class="panel" aria-labelledby="h-out">
<div class="panelhead">
<h2 id="h-out">Every case</h2>
<div class="spacer"></div>
<button class="btn ghost" id="moreBtn" type="button" aria-pressed="false">more formats</button>
<button class="btn ghost" id="copyAll" type="button">copy all</button>
</div>
<div class="grp">
<span class="k-lab">joined as one name</span>
<span class="why">words glued together, punctuation dropped</span>
</div>
<div class="rows" id="rowsCode"></div>
<div class="grp">
<span class="k-lab">left as a sentence</span>
<span class="why">commas, apostrophes and question marks stay put — only the capitals move (run-together names get spaced out first)</span>
</div>
<div class="rows" id="rowsProse"></div>
<p class="note" id="proseNote">Sentence case can’t know your proper nouns, so it lowers everything except the start of a sentence — but it keeps run-on capitals like <code>AM</code> and mixed spellings like <code>iPhone</code> exactly as you typed them. Title Case leaves the little words (<code>a</code>, <code>of</code>, <code>at</code>, <code>the</code>) lowercase unless they start or end the line.</p>
</section>
<p class="sr-only" id="live" aria-live="polite"></p>
<footer>demo · sloprun.dev</footer>
</main>
<script>
(function () {
"use strict";
function $(id) { return document.getElementById(id); }
/* ---------- word splitting ---------- */
var NUL = "\u0001"; /* private split marker, never appears in real text */
function splitWords(s, splitDigits) {
var out = [];
var chunks = String(s).replace(/['’]/g, "").split(/[^\p{L}\p{N}]+/u);
for (var i = 0; i < chunks.length; i++) {
var c = chunks[i];
if (!c) continue;
var t = c
.replace(/(\p{Ll}|\p{N})(\p{Lu})/gu, "$1" + NUL + "$2") /* fooBar, 7AM */
.replace(/(\p{Lu}+)(\p{Lu}\p{Ll})/gu, "$1" + NUL + "$2"); /* XMLHttp → XML|Http */
if (splitDigits) {
t = t.replace(/(\p{L})(\p{N})/gu, "$1" + NUL + "$2")
.replace(/(\p{N})(\p{L})/gu, "$1" + NUL + "$2");
}
var parts = t.split(NUL);
for (var j = 0; j < parts.length; j++) if (parts[j]) out.push(parts[j]);
}
return out;
}
/* Expand identifier-looking chunks into plain words, leave real prose alone.
A run-together chunk only counts as an identifier at three words or more, so
brand spellings like iPhone and JavaScript survive untouched. */
function humanize(line, splitDigits) {
return String(line).replace(/\S+/g, function (chunk) {
if (!/^[\p{L}\p{N}_]+$/u.test(chunk)) return chunk;
var under = chunk.indexOf("_") >= 0;
var camel = /\p{Ll}\p{Lu}|\p{N}\p{Lu}|\p{Lu}\p{Lu}\p{Ll}/u.test(chunk);
if (!under && !camel) return chunk;
var w = splitWords(chunk, splitDigits);
if (!w.length) return chunk;
if (!under && w.length < 3) return chunk;
return w.join(" ");
});
}
/* SHOUTING LINES get lowered first, so sentence case isn't a no-op on them. */
function proseInput(line, splitDigits) {
var h = humanize(line, splitDigits);
return /\p{Ll}/u.test(h) ? h : h.toLowerCase();
}
/* ---------- prose transforms ---------- */
var SMALL = {
a:1, an:1, and:1, as:1, at:1, but:1, by:1, "for":1, from:1, "if":1, "in":1, into:1,
nor:1, of:1, off:1, on:1, onto:1, or:1, over:1, per:1, so:1, the:1, to:1, up:1,
via:1, vs:1, "with":1, yet:1
};
function hasInnerUpper(p) { return /\p{Lu}/u.test(p.slice(1)); }
function capPart(p) {
if (!p) return p;
if (hasInnerUpper(p)) return p; /* iPhone, JavaScript, HELLO */
return p.charAt(0).toUpperCase() + p.slice(1);
}
function lowPart(p) {
if (!p) return p;
if (hasInnerUpper(p)) return p;
return p.toLowerCase();
}
function titleCase(s) {
var parts = s.split(/(\s+)/);
var idx = [];
for (var i = 0; i < parts.length; i++) if (parts[i] && !/^\s+$/.test(parts[i])) idx.push(i);
var forceNext = true;
for (var k = 0; k < idx.length; k++) {
var at = idx[k], w = parts[at];
var m = w.match(/^([^\p{L}\p{N}]*)([\s\S]*?)([^\p{L}\p{N}]*)$/u);
var pre = m[1], core = m[2], post = m[3];
if (core) {
var small = !forceNext && k !== idx.length - 1 && SMALL[core.toLowerCase()] === 1;
var fn = small ? lowPart : capPart;
core = core.split("-").map(fn).join("-");
parts[at] = pre + core + post;
forceNext = /[.!?:;…]$/.test(post);
}
}
return parts.join("");
}
function sentenceCase(s) {
var re = /[\p{L}\p{N}][\p{L}\p{N}'’]*/gu, m, out = "", last = 0, start = true;
while ((m = re.exec(s)) !== null) {
var gap = s.slice(last, m.index);
if (/[.!?…]/.test(gap)) start = true;
out += gap;
var w = m[0], nw;
if (hasInnerUpper(w) || /^I(['’]\p{L}+)?$/u.test(w)) nw = w;
else nw = w.toLowerCase();
if (start) nw = nw.charAt(0).toUpperCase() + nw.slice(1);
out += nw;
last = m.index + w.length;
start = false;
}
return out + s.slice(last);
}
/* ---------- format table ---------- */
function lo(t) { return t.toLowerCase(); }
function cap(t) { return t.charAt(0).toUpperCase() + t.slice(1).toLowerCase(); }
var FORMATS = [
{ id: "camel", label: "camelCase", fam: "code", sep: "", word: function (t, i) { return i ? cap(t) : lo(t); } },
{ id: "pascal", label: "PascalCase", fam: "code", sep: "", word: function (t) { return cap(t); } },
{ id: "snake", label: "snake_case", fam: "code", sep: "_", word: lo },
{ id: "kebab", label: "kebab-case", fam: "code", sep: "-", word: lo },
{ id: "constant", label: "CONSTANT_CASE", fam: "code", sep: "_", word: function (t) { return t.toUpperCase(); } },
{ id: "train", label: "Train-Case", fam: "code", sep: "-", word: function (t) { return cap(t); }, extra: true },
{ id: "dot", label: "dot.case", fam: "code", sep: ".", word: lo, extra: true },
{ id: "path", label: "path/case", fam: "code", sep: "/", word: lo, extra: true },
{ id: "flat", label: "flatcase", fam: "code", sep: "", word: lo, extra: true },
{ id: "title", label: "Title Case", fam: "prose", fn: titleCase },
{ id: "sentence", label: "sentence case", fam: "prose", fn: sentenceCase },
{ id: "upper", label: "UPPER CASE", fam: "prose", fn: function (s) { return s.toUpperCase(); }, extra: true },
{ id: "lower", label: "lower case", fam: "prose", fn: function (s) { return s.toLowerCase(); }, extra: true }
];
var PRESETS = [
{ name: "sentence", text: "my favorite coffee shop opens at 7 AM" },
{ name: "camelCase", text: "getUserProfileByEmail" },
{ name: "snake_case", text: "read_the_manual_twice_before_you_start" },
{ name: "run-on caps", text: "the XMLHttpRequest sends a reply" },
{ name: "a list", text: "first name\nlast name\nemail address\nphone number\nfavorite color" },
{ name: "messy", text: " the QUICK brown_fox jumps-over 2 lazy DOGS! " }
];
/* ---------- state ---------- */
var state = {
splitDigits: false,
activeLine: 0,
showExtras: false,
hiLine: -1,
hiIdx: -1,
focusIdx: -1,
tokens: [],
plain: {}
};
var src = $("src"), toks = $("toks"), live = $("live");
var rowEls = {};
function esc(s) {
return String(s).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
}
function say(msg) { live.textContent = msg; }
/* ---------- build rows ---------- */
(function buildRows() {
FORMATS.forEach(function (f) {
var row = document.createElement("div");
row.className = "row";
if (f.extra) { row.hidden = true; }
var labId = "lab-" + f.id;
row.innerHTML =
'<span class="k-lab rlab" id="' + labId + '">' + esc(f.label) + '</span>' +
'<div class="out mono" id="out-' + f.id + '" role="group" aria-labelledby="' + labId + '"></div>' +
'<span class="len" id="len-' + f.id + '" aria-hidden="true"></span>' +
'<button class="btn copy" type="button" data-fmt="' + f.id + '">copy</button>';
(f.fam === "code" ? $("rowsCode") : $("rowsProse")).appendChild(row);
rowEls[f.id] = {
row: row, out: $("out-" + f.id), len: $("len-" + f.id),
btn: row.querySelector("button"), fmt: f
};
rowEls[f.id].btn.setAttribute("aria-label", "copy " + f.label);
});
})();
(function buildPresets() {
var box = $("presets");
PRESETS.forEach(function (p, i) {
var b = document.createElement("button");
b.type = "button";
b.className = "btn";
b.textContent = p.name;
b.setAttribute("aria-pressed", i === 0 ? "true" : "false");
b.addEventListener("click", function () {
src.value = p.text;
markPreset(p.text);
src.focus(); /* fires the caret listener first … */
try { src.setSelectionRange(0, 0); } catch (e) {}
src.scrollTop = 0;
state.activeLine = 0; /* … so pin line 1 afterwards */
state.focusIdx = -1;
setHi(0, -1);
render();
say("example loaded: " + p.name);
});
box.appendChild(b);
});
})();
function markPreset(text) {
var kids = $("presets").children;
for (var i = 0; i < kids.length; i++) {
kids[i].setAttribute("aria-pressed", PRESETS[i] && PRESETS[i].text === text ? "true" : "false");
}
}
/* ---------- rendering ---------- */
function renderCodeLine(f, tokens, lineIdx, rich) {
var html = "", plain = "";
for (var i = 0; i < tokens.length; i++) {
if (i) {
plain += f.sep;
if (rich && f.sep) html += '<i class="sep">' + esc(f.sep) + "</i>";
else html += esc(f.sep);
}
var w = f.word(tokens[i], i);
plain += w;
if (!rich) { html += esc(w); continue; }
var inner;
if (f.sep === "" && /^\p{Lu}/u.test(w)) inner = '<i class="cap">' + esc(w.charAt(0)) + "</i>" + esc(w.slice(1));
else inner = esc(w);
html += '<span class="sg" data-l="' + lineIdx + '" data-i="' + i + '">' + inner + "</span>";
}
return { html: html, plain: plain };
}
function render() {
var text = src.value;
var lines = text.split("\n");
var rich = text.length <= 6000;
var sd = state.splitDigits;
var tokensByLine = lines.map(function (l) { return splitWords(l, sd); });
var totalWords = 0;
tokensByLine.forEach(function (t) { totalWords += t.length; });
$("cChars").textContent = text.length;
$("cWords").textContent = totalWords;
$("cLines").textContent = text.length ? lines.length : 0;
/* nothing to clear when the box is empty */
$("clearBtn").disabled = text.length === 0;
var anyContent = false;
/* outputs */
FORMATS.forEach(function (f) {
var el = rowEls[f.id];
var htmlLines = [], plainLines = [];
for (var li = 0; li < lines.length; li++) {
if (f.fam === "code") {
var r = renderCodeLine(f, tokensByLine[li], li, rich);
htmlLines.push(r.html);
plainLines.push(r.plain);
} else {
var s = f.fn(proseInput(lines[li], sd));
htmlLines.push(esc(s));
plainLines.push(s);
}
}
var plain = plainLines.join("\n");
state.plain[f.id] = plain;
var blank = plain.replace(/\s/g, "") === "";
if (!blank && !rowEls[f.id].row.hidden) anyContent = true;
if (blank) {
el.out.textContent = "—";
el.out.classList.add("empty");
} else {
el.out.classList.remove("empty");
if (rich) el.out.innerHTML = htmlLines.join("\n");
else el.out.textContent = plain;
}
el.len.textContent = blank ? "" : plain.length;
el.btn.disabled = blank;
resetCopyBtn(el.btn);
/* only make the box a tab stop when it actually scrolls */
if (el.out.scrollHeight > el.out.clientHeight + 1) el.out.setAttribute("tabindex", "0");
else el.out.removeAttribute("tabindex");
});
/* "copy all" would otherwise hand over a stack of empty labels */
var all = $("copyAll");
all.disabled = !anyContent;
if (!anyContent) resetCopyBtn(all);
/* split strip for the active line */
if (state.activeLine >= lines.length) state.activeLine = Math.max(0, lines.length - 1);
var tk = tokensByLine[state.activeLine] || [];
state.tokens = tk;
$("lineNote").textContent = (lines.length > 1 ? "line " + (state.activeLine + 1) + " of " + lines.length + " · " : "") +
tk.length + (tk.length === 1 ? " word" : " words");
renderToks(tk);
applyHi();
}
function renderToks(tk) {
toks.textContent = "";
if (!tk.length) {
var none = document.createElement("span");
none.className = "none";
none.textContent = src.value.length ? "no letters or numbers on this line" : "type or paste anything above";
toks.appendChild(none);
return;
}
var shown = Math.min(tk.length, 60);
for (var i = 0; i < shown; i++) {
var b = document.createElement("button");
b.type = "button";
b.className = "tok";
b.dataset.i = String(i);
b.tabIndex = i === Math.max(0, Math.min(state.focusIdx, shown - 1)) ? 0 : -1;
b.innerHTML = '<span class="n">' + (i + 1) + "</span>" + esc(tk[i]);
b.setAttribute("aria-label", "word " + (i + 1) + " of " + tk.length + ": " + tk[i]);
toks.appendChild(b);
}
if (tk.length > shown) {
var more = document.createElement("span");
more.className = "more";
more.textContent = "+" + (tk.length - shown) + " more";
toks.appendChild(more);
}
}
/* ---------- cross-highlight ---------- */
function setHi(line, i) {
if (state.hiLine === line && state.hiIdx === i) return;
state.hiLine = line; state.hiIdx = i;
applyHi();
}
function applyHi() {
var old = document.querySelectorAll(".sg.hi, .tok.hi");
for (var i = 0; i < old.length; i++) old[i].classList.remove("hi");
if (state.hiIdx < 0) return;
var segs = document.querySelectorAll('.out .sg[data-l="' + state.hiLine + '"][data-i="' + state.hiIdx + '"]');
for (var j = 0; j < segs.length; j++) segs[j].classList.add("hi");
if (state.hiLine === state.activeLine) {
var chip = toks.querySelector('.tok[data-i="' + state.hiIdx + '"]');
if (chip) chip.classList.add("hi");
}
}
toks.addEventListener("focusin", function (e) {
var t = e.target.closest ? e.target.closest(".tok") : null;
if (!t) return;
state.focusIdx = +t.dataset.i;
var kids = toks.querySelectorAll(".tok");
for (var i = 0; i < kids.length; i++) kids[i].tabIndex = kids[i] === t ? 0 : -1;
setHi(state.activeLine, state.focusIdx);
});
toks.addEventListener("focusout", function (e) {
if (toks.contains(e.relatedTarget)) return;
state.focusIdx = -1;
setHi(state.activeLine, -1);
});
/* a tap must pin the word even where clicking a button doesn't focus it
(Safari/Firefox) and where there is no hover at all (touch) */
toks.addEventListener("click", function (e) {
var t = e.target.closest ? e.target.closest(".tok") : null;
if (!t) return;
var i = +t.dataset.i;
state.focusIdx = i;
var kids = toks.querySelectorAll(".tok");
for (var k = 0; k < kids.length; k++) kids[k].tabIndex = kids[k] === t ? 0 : -1;
t.focus();
setHi(state.activeLine, i);
say("word " + (i + 1) + ": " + (state.tokens[i] || ""));
});
toks.addEventListener("keydown", function (e) {
var kids = toks.querySelectorAll(".tok");
if (!kids.length) return;
if (e.key === "Escape") { /* let go of the pinned word */
e.preventDefault();
state.focusIdx = -1;
setHi(state.activeLine, -1);
src.focus();
return;
}
var cur = state.focusIdx < 0 ? 0 : state.focusIdx, next = null;
if (e.key === "ArrowRight" || e.key === "ArrowDown") next = Math.min(kids.length - 1, cur + 1);
else if (e.key === "ArrowLeft" || e.key === "ArrowUp") next = Math.max(0, cur - 1);
else if (e.key === "Home") next = 0;
else if (e.key === "End") next = kids.length - 1;
if (next === null) return;
e.preventDefault();
kids[next].focus();
});
/* hovering a word — as a chip, or as a piece of any output — lights it up everywhere */
document.addEventListener("mouseover", function (e) {
var el = e.target;
if (!el || !el.closest) return;
var sg = el.closest(".out .sg");
if (sg) { setHi(+sg.dataset.l, +sg.dataset.i); return; }
var tk = el.closest(".tok");
if (tk) { setHi(state.activeLine, +tk.dataset.i); return; }
setHi(state.activeLine, state.focusIdx);
});
/* ---------- caret → active line ---------- */
function syncActiveLine() {
var pos = src.selectionStart || 0;
var line = src.value.slice(0, pos).split("\n").length - 1;
if (line !== state.activeLine) {
state.activeLine = line;
state.focusIdx = -1;
render();
}
}
["click", "keyup", "select", "focus"].forEach(function (ev) {
src.addEventListener(ev, syncActiveLine);
});
src.addEventListener("input", function () {
var pos = src.selectionStart || 0;
state.activeLine = src.value.slice(0, pos).split("\n").length - 1;
markPreset(src.value);
render();
});
/* ---------- controls ---------- */
$("digitsBtn").addEventListener("click", function () {
state.splitDigits = !state.splitDigits;
this.setAttribute("aria-pressed", state.splitDigits ? "true" : "false");
render();
say(state.splitDigits ? "digits split into their own words" : "digits kept attached to words");
});
$("clearBtn").addEventListener("click", function () {
src.value = "";
markPreset("");
state.activeLine = 0;
state.focusIdx = -1;
render();
src.focus();
say("cleared");
});
$("moreBtn").addEventListener("click", function () {
state.showExtras = !state.showExtras;
this.setAttribute("aria-pressed", state.showExtras ? "true" : "false");
this.textContent = state.showExtras ? "fewer formats" : "more formats";
FORMATS.forEach(function (f) {
if (f.extra) rowEls[f.id].row.hidden = !state.showExtras;
});
var shown = FORMATS.filter(function (f) { return state.showExtras || !f.extra; }).length;
render();
say(state.showExtras ? "showing all " + shown + " formats" : "showing " + shown + " formats");
});
/* ---------- copy ---------- */
function resetCopyBtn(btn) {
if (btn._t) { clearTimeout(btn._t); btn._t = null; }
btn.classList.remove("ok");
if (btn.id === "copyAll") btn.textContent = "copy all";
else btn.textContent = "copy";
}
function flash(btn, label) {
btn.classList.add("ok");
btn.textContent = label;
if (btn._t) clearTimeout(btn._t);
btn._t = setTimeout(function () { resetCopyBtn(btn); }, 1500);
}
function fallbackCopy(text) {
var ta = document.createElement("textarea");
ta.value = text;
ta.setAttribute("readonly", "");
ta.style.position = "fixed";
ta.style.top = "-1000px";
ta.style.opacity = "0";
document.body.appendChild(ta);
ta.select();
var ok = false;
try { ok = document.execCommand("copy"); } catch (e) { ok = false; }
document.body.removeChild(ta);
return ok;
}
/* if the browser refuses to copy for us, at least select the text for them */
function selectEl(el) {
if (!el) return false;
try {
var r = document.createRange();
r.selectNodeContents(el);
var sel = window.getSelection();
sel.removeAllRanges();
sel.addRange(r);
return true;
} catch (e) { return false; }
}
function copyText(text, btn, what, selEl) {
var done = function (ok) {
if (ok) { flash(btn, "copied"); say(what + " copied"); return; }
var picked = selectEl(selEl);
btn.textContent = picked ? "selected · ⌘C" : "⌘C to copy";
say(picked ? what + " selected — press control or command C to copy"
: "this browser blocked the copy; select the text and copy it yourself");
if (btn._t) clearTimeout(btn._t);
btn._t = setTimeout(function () { resetCopyBtn(btn); }, 2600);
};
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(function () { done(true); }, function () { done(fallbackCopy(text)); });
} else {
done(fallbackCopy(text));
}
}
document.addEventListener("click", function (e) {
var btn = e.target.closest ? e.target.closest("button.copy") : null;
if (!btn || btn.disabled) return;
var id = btn.dataset.fmt;
var f = rowEls[id].fmt;
copyText(state.plain[id] || "", btn, f.label, rowEls[id].out);
});
$("copyAll").addEventListener("click", function () {
var visible = FORMATS.filter(function (f) { return !rowEls[f.id].row.hidden; });
var multi = visible.some(function (f) { return (state.plain[f.id] || "").indexOf("\n") >= 0; });
var out;
if (multi) {
out = visible.map(function (f) { return "# " + f.label + "\n" + (state.plain[f.id] || ""); }).join("\n\n");
} else {
var w = 0;
visible.forEach(function (f) { w = Math.max(w, f.label.length); });
out = visible.map(function (f) {
return f.label + new Array(w - f.label.length + 3).join(" ") + (state.plain[f.id] || "");
}).join("\n");
}
copyText(out, this, visible.length + " formats");
});
/* ---------- platform theme ---------- */
window.addEventListener("message", function (e) {
var d = e && e.data;
if (d && d.type === "sloprun:theme" && (d.theme === "light" || d.theme === "dark")) {
document.documentElement.setAttribute("data-theme", d.theme);
}
});
/* ---------- boot ---------- */
src.value = PRESETS[0].text;
render();
})();
</script>
</body>
</html>
source-visible by construction · nothing is published here without its code
Comments
0 totalNo comments yet. If you ran it, say what happened.