0.2.3: integrate dialog clipboard fix
This commit is contained in:
commit
33f0e01093
5 files changed
+73
-21
No files matched your search
@@ -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:<uid>` 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.
|
- 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:<uid>` 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.
|
||||||
@@ -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
|
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`,
|
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.
|
`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.
|
||||||
+17
-20
@@ -1,35 +1,32 @@
|
|||||||
// Clipboard helper shared by the room screen (invite code / link) and 干员调配 (导出 / 导入 payloads).
|
// Shared by invite links, loadouts and settings exports, including LAN / insecure contexts.
|
||||||
//
|
/** @param {string} text @returns {Promise<boolean>} */
|
||||||
// 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<boolean>}
|
|
||||||
*/
|
|
||||||
export async function copyText(text) {
|
export async function copyText(text) {
|
||||||
try {
|
try {
|
||||||
if (globalThis.navigator?.clipboard && globalThis.isSecureContext) {
|
if (globalThis.navigator?.clipboard && globalThis.isSecureContext) {
|
||||||
await navigator.clipboard.writeText(text);
|
await navigator.clipboard.writeText(text);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
} catch { /* fall through */ }
|
} catch { /* use the selected-text fallback */ }
|
||||||
|
let ta, prev;
|
||||||
try {
|
try {
|
||||||
const ta = document.createElement('textarea');
|
prev = document.activeElement;
|
||||||
|
ta = document.createElement('textarea');
|
||||||
ta.value = text;
|
ta.value = text;
|
||||||
ta.setAttribute('readonly', '');
|
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';
|
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();
|
ta.select();
|
||||||
// iOS Safari ignores select() on a textarea: an explicit range is what it copies (LAN play over http has no
|
ta.setSelectionRange(0, ta.value.length); // iOS Safari ignores select() alone
|
||||||
// navigator.clipboard, so this path is the one iPhones / iPads take)
|
if (document.activeElement !== ta || ta.selectionStart !== 0 || ta.selectionEnd !== ta.value.length) return false;
|
||||||
try { ta.setSelectionRange(0, ta.value.length); } catch { /* ignore */ }
|
return !!document.execCommand('copy');
|
||||||
const ok = document.execCommand('copy');
|
|
||||||
ta.remove();
|
|
||||||
return ok;
|
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
|
} finally {
|
||||||
|
ta?.remove();
|
||||||
|
if (prev?.isConnected) prev.focus?.({ preventScroll: true });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -380,8 +380,9 @@ describe('room: copy the invite without navigator.clipboard (LAN over http, iOS)
|
|||||||
const ta = {
|
const ta = {
|
||||||
style: {}, value: '',
|
style: {}, value: '',
|
||||||
setAttribute(k) { calls.push(['attr', k]); },
|
setAttribute(k) { calls.push(['attr', k]); },
|
||||||
|
focus() { document.activeElement = ta; },
|
||||||
select() { calls.push(['select']); },
|
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']); },
|
remove() { calls.push(['remove']); },
|
||||||
};
|
};
|
||||||
const saved = { document: globalThis.document, secure: globalThis.isSecureContext, nav: Object.getOwnPropertyDescriptor(globalThis, 'navigator') };
|
const saved = { document: globalThis.document, secure: globalThis.isSecureContext, nav: Object.getOwnPropertyDescriptor(globalThis, 'navigator') };
|
||||||
|
|||||||
@@ -90,6 +90,44 @@ describe('Modal keyboard focus', { skip: !ENABLED && 'set SP_E2E=1 and have Chro
|
|||||||
} finally { await page.close(); }
|
} 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 () => {
|
test('no controls: focus the dialog itself; imperative confirm retains autofocus and native Enter', async () => {
|
||||||
const page = await browser.newPage();
|
const page = await browser.newPage();
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in new issue
Block a user