From de63c48da2042a1d5cea2a6b0d04a6727cefc740 Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Wed, 16 Sep 2026 21:52:03 +1000 Subject: [PATCH] Added Queued Pastes --- Makefile | 2 +- site/index.html | 1 + site/queued-pastes/index.html | 42 ++++++++++ site/queued-pastes/prefs.mjs | 27 +++++++ site/queued-pastes/prefs.test.mjs | 36 +++++++++ site/queued-pastes/queue.mjs | 44 +++++++++++ site/queued-pastes/queue.test.mjs | 100 ++++++++++++++++++++++++ site/queued-pastes/script.js | 125 ++++++++++++++++++++++++++++++ site/queued-pastes/style.css | 39 ++++++++++ 9 files changed, 415 insertions(+), 1 deletion(-) create mode 100644 site/queued-pastes/index.html create mode 100644 site/queued-pastes/prefs.mjs create mode 100644 site/queued-pastes/prefs.test.mjs create mode 100644 site/queued-pastes/queue.mjs create mode 100644 site/queued-pastes/queue.test.mjs create mode 100644 site/queued-pastes/script.js create mode 100644 site/queued-pastes/style.css diff --git a/Makefile b/Makefile index 84764fb..be7c676 100644 --- a/Makefile +++ b/Makefile @@ -20,7 +20,7 @@ build.wasm: .Phony: build.site build.site: - cp -r site/* target/. + rsync -a --exclude '*.test.mjs' site/ target/ .Phony: dev dev: build diff --git a/site/index.html b/site/index.html index 9b2fd6b..0c17283 100644 --- a/site/index.html +++ b/site/index.html @@ -35,6 +35,7 @@
  • Android Icon Resizer
  • Gradient Image
  • Image Inner Resize
  • +
  • Queued Pastes
  • diff --git a/site/queued-pastes/index.html b/site/queued-pastes/index.html new file mode 100644 index 0000000..ca9b354 --- /dev/null +++ b/site/queued-pastes/index.html @@ -0,0 +1,42 @@ + + + + + + + Queued Pastes - Tools + + + + + +
    +
    +

    Queued Pastes

    +

    Paste queued lines one at a time, in order

    +
    +
    +
    +
    + +
    + +
    + +
    + + +
    +
    +
    + + diff --git a/site/queued-pastes/prefs.mjs b/site/queued-pastes/prefs.mjs new file mode 100644 index 0000000..3373791 --- /dev/null +++ b/site/queued-pastes/prefs.mjs @@ -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 }; + } +} diff --git a/site/queued-pastes/prefs.test.mjs b/site/queued-pastes/prefs.test.mjs new file mode 100644 index 0000000..29dc457 --- /dev/null +++ b/site/queued-pastes/prefs.test.mjs @@ -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 }, + ); +}); diff --git a/site/queued-pastes/queue.mjs b/site/queued-pastes/queue.mjs new file mode 100644 index 0000000..7af91a1 --- /dev/null +++ b/site/queued-pastes/queue.mjs @@ -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+$/, ''); +} diff --git a/site/queued-pastes/queue.test.mjs b/site/queued-pastes/queue.test.mjs new file mode 100644 index 0000000..888f11e --- /dev/null +++ b/site/queued-pastes/queue.test.mjs @@ -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'), ''); +}); diff --git a/site/queued-pastes/script.js b/site/queued-pastes/script.js new file mode 100644 index 0000000..47af2c1 --- /dev/null +++ b/site/queued-pastes/script.js @@ -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(); +}); diff --git a/site/queued-pastes/style.css b/site/queued-pastes/style.css new file mode 100644 index 0000000..1c83cfd --- /dev/null +++ b/site/queued-pastes/style.css @@ -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); + } +}