This commit is contained in:
parent
40e5379eb3
commit
de63c48da2
9 changed files with 415 additions and 1 deletions
2
Makefile
2
Makefile
|
|
@ -20,7 +20,7 @@ build.wasm:
|
||||||
|
|
||||||
.Phony: build.site
|
.Phony: build.site
|
||||||
build.site:
|
build.site:
|
||||||
cp -r site/* target/.
|
rsync -a --exclude '*.test.mjs' site/ target/
|
||||||
|
|
||||||
.Phony: dev
|
.Phony: dev
|
||||||
dev: build
|
dev: build
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@
|
||||||
<li><a href="/android-icons/">Android Icon Resizer</a></li>
|
<li><a href="/android-icons/">Android Icon Resizer</a></li>
|
||||||
<li><a href="/gradient-image/">Gradient Image</a></li>
|
<li><a href="/gradient-image/">Gradient Image</a></li>
|
||||||
<li><a href="/image-inner-resize/">Image Inner Resize</a></li>
|
<li><a href="/image-inner-resize/">Image Inner Resize</a></li>
|
||||||
|
<li><a href="/queued-pastes/">Queued Pastes</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</main>
|
</main>
|
||||||
</body>
|
</body>
|
||||||
|
|
|
||||||
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