Added Queued Pastes
Some checks failed
/ publish (push) Failing after 1m21s

This commit is contained in:
Leon Mika 2026-09-16 21:52:03 +10:00
parent 40e5379eb3
commit de63c48da2
9 changed files with 415 additions and 1 deletions

View file

@ -0,0 +1,42 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<title>Queued Pastes - Tools</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css">
<link rel="stylesheet" href="./style.css">
<script type="module" src="./script.js"></script>
</head>
<body class="container">
<header>
<hgroup>
<h1>Queued Pastes</h1>
<p>Paste queued lines one at a time, in order</p>
</hgroup>
</header>
<main>
<div class="queue-controls">
<label>
<input type="checkbox" id="queueSwitch" role="switch">
Queue pastes
</label>
</div>
<textarea id="pasteField" rows="10" placeholder="Enter the lines to paste, one per line"></textarea>
<div class="controls">
<label>
<input type="checkbox" id="newlineSwitch" role="switch">
Include new-line
</label>
<div class="controls-right">
<label>
<input type="checkbox" id="focusSwitch" role="switch">
Advance on focus
</label>
<button id="advanceBtn" disabled>Advance</button>
</div>
</div>
</main>
</body>
</html>

View file

@ -0,0 +1,27 @@
// Serialization for the Queued Pastes preferences stored in localStorage
// under the key 'queued-paste-prefs'. Pure functions so they can be unit
// tested under Node (see prefs.test.mjs).
const DEFAULTS = { newLines: false, advanceFocus: false };
export function serializePrefs(prefs) {
return JSON.stringify({
'new-lines': Boolean(prefs.newLines),
'advance-focus': Boolean(prefs.advanceFocus),
});
}
export function parsePrefs(json) {
try {
const obj = JSON.parse(json);
if (obj === null || typeof obj !== 'object') {
return { ...DEFAULTS };
}
return {
newLines: obj['new-lines'] === true,
advanceFocus: obj['advance-focus'] === true,
};
} catch {
return { ...DEFAULTS };
}
}

View file

@ -0,0 +1,36 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { serializePrefs, parsePrefs } from './prefs.mjs';
test('serializePrefs writes dashed keys with boolean values', () => {
assert.equal(
serializePrefs({ newLines: true, advanceFocus: false }),
'{"new-lines":true,"advance-focus":false}',
);
});
test('parsePrefs reads dashed keys into camelCase', () => {
assert.deepEqual(
parsePrefs('{"new-lines":true,"advance-focus":false}'),
{ newLines: true, advanceFocus: false },
);
});
test('parsePrefs returns defaults for invalid JSON', () => {
assert.deepEqual(parsePrefs('not json'), { newLines: false, advanceFocus: false });
});
test('parsePrefs returns defaults for null (missing localStorage entry)', () => {
assert.deepEqual(parsePrefs(null), { newLines: false, advanceFocus: false });
});
test('parsePrefs defaults missing keys to false', () => {
assert.deepEqual(parsePrefs('{"new-lines":true}'), { newLines: true, advanceFocus: false });
});
test('parsePrefs defaults non-boolean values to false', () => {
assert.deepEqual(
parsePrefs('{"new-lines":"yes","advance-focus":1}'),
{ newLines: false, advanceFocus: false },
);
});

View file

@ -0,0 +1,44 @@
// Pure queue logic for the Queued Pastes tool. Kept free of DOM and
// clipboard APIs so it can be unit tested under Node (see queue.test.mjs).
// Returns the range of the first line, excluding its trailing newline.
export function firstLineRange(text) {
const newline = text.indexOf('\n');
return { start: 0, end: newline === -1 ? text.length : newline };
}
// Returns the first line of text, without its trailing newline.
export function currentLine(text) {
const { start, end } = firstLineRange(text);
return text.slice(start, end);
}
// Returns the text with the first line (and its trailing newline) removed.
export function advance(text) {
const newline = text.indexOf('\n');
return newline === -1 ? '' : text.slice(newline + 1);
}
// Returns the text to copy for the first line, honoring the
// include-new-line setting.
export function copyText(text, includeNewline) {
const line = currentLine(text);
return includeNewline ? line + '\n' : line;
}
// Returns the clipboard contents adjusted for a toggled include-new-line
// setting, or null if the clipboard does not currently hold the given line
// (in which case it must be left untouched).
export function adjustCopiedLine(clipboardText, line, includeNewline) {
const stripped = clipboardText.endsWith('\n') ? clipboardText.slice(0, -1) : clipboardText;
if (stripped !== line) {
return null;
}
return includeNewline ? line + '\n' : line;
}
// Removes trailing whitespace (spaces, tabs, newlines) from the end of the
// text, so enabling the queue does not leave phantom trailing lines.
export function trimTrailing(text) {
return text.replace(/\s+$/, '');
}

View file

@ -0,0 +1,100 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { firstLineRange, currentLine, advance, copyText, adjustCopiedLine, trimTrailing } from './queue.mjs';
test('currentLine returns the first line without its newline', () => {
assert.equal(currentLine('abc\ndef'), 'abc');
});
test('currentLine returns the whole string when there is no newline', () => {
assert.equal(currentLine('abc'), 'abc');
});
test('currentLine of an empty string is empty', () => {
assert.equal(currentLine(''), '');
});
test('currentLine with a leading newline is an empty string', () => {
assert.equal(currentLine('\ndef'), '');
});
test('firstLineRange covers the first line up to but not including the newline', () => {
assert.deepEqual(firstLineRange('abc\ndef'), { start: 0, end: 3 });
});
test('firstLineRange covers the whole string when there is no newline', () => {
assert.deepEqual(firstLineRange('abc'), { start: 0, end: 3 });
});
test('firstLineRange of an empty string is a zero-width range', () => {
assert.deepEqual(firstLineRange(''), { start: 0, end: 0 });
});
test('advance removes the first line and its newline', () => {
assert.equal(advance('abc\ndef'), 'def');
});
test('advance on a single line without a trailing newline empties the text', () => {
assert.equal(advance('abc'), '');
});
test('advance on text ending with a newline leaves no phantom line', () => {
assert.equal(advance('abc\n'), '');
});
test('advance removes a leading empty line', () => {
assert.equal(advance('\n\n'), '\n');
});
test('advance on an empty string stays empty', () => {
assert.equal(advance(''), '');
});
test('copyText returns the first line when new-line is off', () => {
assert.equal(copyText('abc\ndef', false), 'abc');
});
test('copyText appends a newline when new-line is on', () => {
assert.equal(copyText('abc\ndef', true), 'abc\n');
});
test('copyText appends a newline even without a trailing newline in the text', () => {
assert.equal(copyText('abc', true), 'abc\n');
});
test('adjustCopiedLine appends a newline when enabling and the clipboard matches the line', () => {
assert.equal(adjustCopiedLine('abc', 'abc', true), 'abc\n');
});
test('adjustCopiedLine removes the newline when disabling and the clipboard matches line plus newline', () => {
assert.equal(adjustCopiedLine('abc\n', 'abc', false), 'abc');
});
test('adjustCopiedLine is a no-op result when the clipboard already has the desired form', () => {
assert.equal(adjustCopiedLine('abc\n', 'abc', true), 'abc\n');
assert.equal(adjustCopiedLine('abc', 'abc', false), 'abc');
});
test('adjustCopiedLine returns null when the clipboard holds unrelated text', () => {
assert.equal(adjustCopiedLine('xyz', 'abc', true), null);
});
test('adjustCopiedLine returns null when the clipboard has the line plus more than one newline', () => {
assert.equal(adjustCopiedLine('abc\n\n', 'abc', true), null);
});
test('trimTrailing removes trailing spaces, tabs, and newlines', () => {
assert.equal(trimTrailing('line1\nline2\n\n \t'), 'line1\nline2');
});
test('trimTrailing leaves per-line trailing spaces intact', () => {
assert.equal(trimTrailing('line1 \nline2'), 'line1 \nline2');
});
test('trimTrailing on text without trailing whitespace is unchanged', () => {
assert.equal(trimTrailing('abc'), 'abc');
});
test('trimTrailing on whitespace-only text is empty', () => {
assert.equal(trimTrailing(' \n \n'), '');
});

View file

@ -0,0 +1,125 @@
import { firstLineRange, currentLine, advance, copyText, adjustCopiedLine, trimTrailing } from './queue.mjs';
import { serializePrefs, parsePrefs } from './prefs.mjs';
const pasteField = document.getElementById('pasteField');
const queueSwitch = document.getElementById('queueSwitch');
const advanceBtn = document.getElementById('advanceBtn');
const focusSwitch = document.getElementById('focusSwitch');
const newlineSwitch = document.getElementById('newlineSwitch');
const savedPrefs = parsePrefs(localStorage.getItem('queued-paste-prefs'));
newlineSwitch.checked = savedPrefs.newLines;
focusSwitch.checked = savedPrefs.advanceFocus;
function savePrefs() {
localStorage.setItem('queued-paste-prefs', serializePrefs({
newLines: newlineSwitch.checked,
advanceFocus: focusSwitch.checked,
}));
}
// Focus-based advancing is a heuristic: a paste outside this page cannot be
// observed, so a window blur followed by a refocus is treated as one paste.
// blurredSinceAdvance gates that, and armedAt suppresses the focus events
// fired by clipboard permission prompts right after arming.
let blurredSinceAdvance = false;
let armedAt = 0;
async function copyCurrentLine() {
await navigator.clipboard.writeText(copyText(pasteField.value, newlineSwitch.checked));
}
function selectCurrentLine() {
const { start, end } = firstLineRange(pasteField.value);
pasteField.setSelectionRange(start, end);
}
function setQueueing(on) {
queueSwitch.checked = on;
pasteField.readOnly = on;
pasteField.classList.toggle('paste-active', on);
advanceBtn.disabled = !on;
if (!on) {
pasteField.setSelectionRange(pasteField.value.length, pasteField.value.length);
}
}
async function startQueueing() {
pasteField.value = trimTrailing(pasteField.value);
if (pasteField.value === '') {
// Nothing to queue.
setQueueing(false);
return;
}
try {
await copyCurrentLine();
} catch (err) {
alert('Could not access the clipboard: ' + err.message);
setQueueing(false);
return;
}
setQueueing(true);
selectCurrentLine();
blurredSinceAdvance = false;
armedAt = Date.now();
}
async function advanceQueue() {
pasteField.value = advance(pasteField.value);
if (pasteField.value === '') {
setQueueing(false);
return;
}
try {
await copyCurrentLine();
} catch (err) {
alert('Could not copy the next line: ' + err.message);
setQueueing(false);
return;
}
selectCurrentLine();
}
queueSwitch.addEventListener('change', () => {
if (queueSwitch.checked) {
startQueueing();
} else {
setQueueing(false);
}
});
advanceBtn.addEventListener('click', () => {
advanceQueue();
});
focusSwitch.addEventListener('change', () => {
blurredSinceAdvance = false;
armedAt = Date.now();
savePrefs();
});
newlineSwitch.addEventListener('change', async () => {
savePrefs();
if (!queueSwitch.checked) return;
try {
const clipboardText = await navigator.clipboard.readText();
const adjusted = adjustCopiedLine(clipboardText, currentLine(pasteField.value), newlineSwitch.checked);
if (adjusted !== null) {
await navigator.clipboard.writeText(adjusted);
}
} catch (err) {
alert('Could not update the clipboard: ' + err.message);
}
});
window.addEventListener('blur', () => {
blurredSinceAdvance = true;
});
window.addEventListener('focus', () => {
if (!queueSwitch.checked || !focusSwitch.checked) return;
if (!blurredSinceAdvance) return;
if (Date.now() - armedAt < 1000) return;
blurredSinceAdvance = false;
advanceQueue();
});

View file

@ -0,0 +1,39 @@
.queue-controls {
display: flex;
justify-content: flex-end;
margin-bottom: 16px;
}
.queue-controls label {
margin: 0;
}
.controls {
display: flex;
align-items: center;
justify-content: space-between;
}
.controls label {
margin: 0;
}
.controls-right {
display: flex;
align-items: center;
gap: 1.5rem;
}
.controls-right button {
width: auto;
}
textarea.paste-active::first-line {
background-color: rgb(209, 213, 219);
}
@media (prefers-color-scheme: dark) {
textarea.paste-active::first-line {
background-color: rgb(77, 83, 94);
}
}