From 8a8db0eb942f5a5acc28dbad6537740282bdb156 Mon Sep 17 00:00:00 2001 From: sganggs Date: Fri, 9 Oct 2026 17:11:03 +0800 Subject: [PATCH] Fix clipboard fallback selection inside dialogs (#433) --- docs/design/client.md | 4 ++++ docs/history/0.2.3.md | 12 ++++++++++++ public/js/ui/clipboard.js | 37 +++++++++++++++++-------------------- test/ui/devices.test.js | 3 ++- test/ui/modal.e2e.test.js | 38 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 73 insertions(+), 21 deletions(-) diff --git a/docs/design/client.md b/docs/design/client.md index 168756eb..eb97fed3 100644 --- a/docs/design/client.md +++ b/docs/design/client.md @@ -80,3 +80,7 @@ Target: a **real 3D board layer** rendered with three.js (vendored ESM, no CDN) - Done: Final Assault / Hidden Core prep takes place on your own half of the boss field (research 09 §1.2): pieces shown at boss-field positions (row − 7; right-hand player mirrored col → 20 − col, RIGHT ↔ LEFT), drop targets and wheel directions mapped back to board coordinates (`prepCamera` → `bossPrep` in public/js/ui/gameLogic.js; covered by the playtest2 browser tests). A scout of such a board (`m.field` prep of a boss round) comes in boss-field coordinates with the player's `side` and is framed the same way, the leader standing at its spawn (0.2.0, community report item 55). Both players of a pair are shown together, as in the battle (0.2.0, community report item 51): the partner's board stands on the other half (`m.private.bossMate` units, already in boss-field coordinates: mirrored on the right half, RIGHT ↔ LEFT; read-only `m:` views of `render/app.js syncMates`), the whole boss field is lit, a scout carries both halves (`mate`), and the team panel frames the pair's avatars in the official green (`ui/battle bg_team_border` tinted, a plain ring without the local art; `gameLogic teamFrameIds`) from the round's start through the fight. --- + +Clipboard exports (`ui/clipboard.js`, §28.3) first use the secure Clipboard API. The textarea fallback for LAN +play stays inside the active modal/guide focus boundary; it copies only after focus and the complete selection +are verified, and cleans up / restores focus on both success and failure. Failure exposes the existing manual-copy UI. diff --git a/docs/history/0.2.3.md b/docs/history/0.2.3.md index aed42c9a..36cb5e61 100644 --- a/docs/history/0.2.3.md +++ b/docs/history/0.2.3.md @@ -65,3 +65,15 @@ an unsupported elemental-resistance bypass and an unnecessary on-field check on All 285 golden scenarios were regenerated: only `roster` and `diy` changed, (`roster-019`, `roster-038`, `diy-059`–`diy-140`), because the added module expands loadout coverage and the added DIY operator / Shu module expand and regroup the DIY scenarios. `bonds`, `fields`, `matches` and `standins` remained byte-identical. Full integration and browser checks follow separately. + +### 28.3 Clipboard export inside dialogs — #433 + +Reported by @RushFTK: exporting settings over an insecure/LAN connection focused the language selector and +reported success without copying the export. The fallback textarea was appended outside the active dialog, +whose focus trap redirected selection before `execCommand('copy')`. + +`ui/clipboard.js` now selects its temporary textarea inside the active dialog/guide, validates focus and the +whole selection before copying, and always removes it and restores focus, including after exceptions. +The secure Clipboard API remains first choice; a failed fallback returns false for the existing manual-copy UI. +Shared invite/loadout copying uses the same helper. Device tests passed 16/16; independent interaction probes +passed 12/12. The real nested-dialog regression is included in `test/ui/modal.e2e.test.js` for integration testing. diff --git a/public/js/ui/clipboard.js b/public/js/ui/clipboard.js index 2da33657..bf1873fe 100644 --- a/public/js/ui/clipboard.js +++ b/public/js/ui/clipboard.js @@ -1,35 +1,32 @@ -// Clipboard helper shared by the room screen (invite code / link) and 干员调配 (导出 / 导入 payloads). -// -// Lives here rather than in a screen: `screens/room.js` imports `screens/loadout.js` (the 干员调配 button), so loadout -// importing the helper back from room would be a cycle. - -/** - * Copy text to the clipboard (async API with a textarea fallback for insecure contexts). - * @param {string} text - * @returns {Promise} - */ +// Shared by invite links, loadouts and settings exports, including LAN / insecure contexts. +/** @param {string} text @returns {Promise} */ export async function copyText(text) { try { if (globalThis.navigator?.clipboard && globalThis.isSecureContext) { await navigator.clipboard.writeText(text); return true; } - } catch { /* fall through */ } + } catch { /* use the selected-text fallback */ } + let ta, prev; try { - const ta = document.createElement('textarea'); + prev = document.activeElement; + ta = document.createElement('textarea'); ta.value = text; ta.setAttribute('readonly', ''); - // 16 px: iOS zooms into smaller focused fields; `readonly` keeps the keyboard away + // Keep the temporary selection inside the active focus trap (#433). The 16 px readonly field avoids iOS zoom + // and its keyboard; do not disable the dialog's focus protection or copy a selection redirected to another field. ta.style.cssText = 'position:fixed;left:-9999px;top:0;opacity:0;font-size:16px'; - document.body.appendChild(ta); + const host = prev?.closest?.('[role="dialog"][aria-modal="true"], .guide') || document.body; + host.appendChild(ta); + ta.focus({ preventScroll: true }); ta.select(); - // iOS Safari ignores select() on a textarea: an explicit range is what it copies (LAN play over http has no - // navigator.clipboard, so this path is the one iPhones / iPads take) - try { ta.setSelectionRange(0, ta.value.length); } catch { /* ignore */ } - const ok = document.execCommand('copy'); - ta.remove(); - return ok; + ta.setSelectionRange(0, ta.value.length); // iOS Safari ignores select() alone + if (document.activeElement !== ta || ta.selectionStart !== 0 || ta.selectionEnd !== ta.value.length) return false; + return !!document.execCommand('copy'); } catch { return false; + } finally { + ta?.remove(); + if (prev?.isConnected) prev.focus?.({ preventScroll: true }); } } diff --git a/test/ui/devices.test.js b/test/ui/devices.test.js index d4ecdd31..7c900954 100644 --- a/test/ui/devices.test.js +++ b/test/ui/devices.test.js @@ -380,8 +380,9 @@ describe('room: copy the invite without navigator.clipboard (LAN over http, iOS) const ta = { style: {}, value: '', setAttribute(k) { calls.push(['attr', k]); }, + focus() { document.activeElement = ta; }, select() { calls.push(['select']); }, - setSelectionRange(a, b) { calls.push(['range', a, b]); }, + setSelectionRange(a, b) { this.selectionStart = a; this.selectionEnd = b; calls.push(['range', a, b]); }, remove() { calls.push(['remove']); }, }; const saved = { document: globalThis.document, secure: globalThis.isSecureContext, nav: Object.getOwnPropertyDescriptor(globalThis, 'navigator') }; diff --git a/test/ui/modal.e2e.test.js b/test/ui/modal.e2e.test.js index a830858a..551e5f52 100644 --- a/test/ui/modal.e2e.test.js +++ b/test/ui/modal.e2e.test.js @@ -90,6 +90,44 @@ describe('Modal keyboard focus', { skip: !ENABLED && 'set SP_E2E=1 and have Chro } finally { await page.close(); } }); + test('clipboard fallback stays in the active dialog, copies its payload, restores focus and cleans up on failure', async () => { + const page = await browser.newPage(); + try { + await fixture(page); + await page.click('#open'); + await page.waitForFunction(() => document.activeElement.id === 'first'); + for (const nested of [false, true]) { + if (nested) { + await page.click('#inner-open'); + await page.waitForFunction(() => document.activeElement.id === 'inner-first'); + } + const result = await page.evaluate(async () => { + const { copyText } = await import('/js/ui/clipboard.js'); + Object.defineProperty(window, 'isSecureContext', { configurable: true, value: false }); + const prev = document.activeElement; + const count = document.querySelectorAll('textarea').length; + let copied; + const original = document.execCommand; + document.execCommand = (command) => { + const el = document.activeElement; + if (command !== 'copy') throw new Error('unexpected command'); + copied = el.value.slice(el.selectionStart, el.selectionEnd); + return true; + }; + const ok = await copyText('导出设置:\n{"language":"zh"}'); + const restored = document.activeElement === prev; + document.execCommand = () => { throw new Error('denied'); }; + const denied = await copyText('denied'); + document.execCommand = original; + return { ok, copied, restored, denied, cleaned: document.querySelectorAll('textarea').length === count, + stillFocused: document.activeElement === prev }; + }); + assert.deepEqual(result, { ok: true, copied: '导出设置:\n{"language":"zh"}', restored: true, + denied: false, cleaned: true, stillFocused: true }); + } + } finally { await page.close(); } + }); + test('no controls: focus the dialog itself; imperative confirm retains autofocus and native Enter', async () => { const page = await browser.newPage(); try {