This commit is contained in:
parent
40e5379eb3
commit
de63c48da2
9 changed files with 415 additions and 1 deletions
42
site/queued-pastes/index.html
Normal file
42
site/queued-pastes/index.html
Normal 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>
|
||||
27
site/queued-pastes/prefs.mjs
Normal file
27
site/queued-pastes/prefs.mjs
Normal 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 };
|
||||
}
|
||||
}
|
||||
36
site/queued-pastes/prefs.test.mjs
Normal file
36
site/queued-pastes/prefs.test.mjs
Normal 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 },
|
||||
);
|
||||
});
|
||||
44
site/queued-pastes/queue.mjs
Normal file
44
site/queued-pastes/queue.mjs
Normal 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+$/, '');
|
||||
}
|
||||
100
site/queued-pastes/queue.test.mjs
Normal file
100
site/queued-pastes/queue.test.mjs
Normal 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'), '');
|
||||
});
|
||||
125
site/queued-pastes/script.js
Normal file
125
site/queued-pastes/script.js
Normal 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();
|
||||
});
|
||||
39
site/queued-pastes/style.css
Normal file
39
site/queued-pastes/style.css
Normal 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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue