feedback7: merge fb7-hud — HP-bar readouts (#303 / #378 / #383: ammo cells, 伺夜's wolf-shadow pips, 斩业星熊's 我执 bar), #302 renderer abort, #292 held skill cue, #334 atlas slots, #354 梓兰's per-card frost
- Grok review: MERGE (no defect). Golden unchanged (union OK); broad node --test 5327 (5326 pass, 0 fail, 1 skipped); lint 0 errors.
This commit is contained in:
commit
7d6fefe8f5
33 files changed
+1751
-77
No files matched your search
@@ -577,6 +577,89 @@ describe('AudioManager', () => {
|
||||
} finally { globalThis.fetch = origFetch; }
|
||||
}
|
||||
});
|
||||
|
||||
// 「技能音效有时候不触发」 (PR #292 by @LimitlessHPPK): the sim emits the cast of a deployment that fires inside its own first
|
||||
// tick (`initSp` already at `spCost`, 宴's deploy-timed skill, 银灰 S3 真银斩 …) before the unit's `['spawn', unitInfo]` — the
|
||||
// same batch, or the next — so `handleBattleEvents` used to skip the cue for good: that first cast was silent and every
|
||||
// later one played. The cue is held and answered once, when the unit is tracked (`_track`); it never outlives its field.
|
||||
test('a cast that arrives before its unit is known is held and plays once when the unit is tracked', () => {
|
||||
const vm = { audio: { sfx: { ui: {}, battle: {}, units: { char_a: { skill: '/s/a_skill.mp3' } } },
|
||||
voice: { char_a: { skill3: '/v/a_s3.mp3' } } } };
|
||||
const a = new AudioManager({ win: null, getManifest: () => vm });
|
||||
a.ctx = {}; // unlocked: every path below is the real one
|
||||
const played = [], voiced = [];
|
||||
a._play = (url) => { played.push(url); }; // the cue itself, and the 作战中 line that goes with it
|
||||
a.voice = (charId, slot, o) => { voiced.push([charId, slot, o.unitKey]); return true; };
|
||||
/** UnitInfo of the operator `char_a` (spine = the sfx.units key; skillIndex picks its 作战中N slot). */
|
||||
const info = (id, skillIndex) => ({ id, side: 'ally', spine: 'char_a', kind: 'op', skillIndex });
|
||||
|
||||
// (1) the sim's order inside one batch: the cast, then the unit's spawn — the cast waits, then sounds once
|
||||
a.handleBattleEvents([['skill', 8, 1]]);
|
||||
assert.deepEqual(played, [], 'nothing to play yet: the unit is unknown');
|
||||
a.handleBattleEvents([['spawn', info(8, 2)]]);
|
||||
assert.deepEqual(played, ['/s/a_skill.mp3'], 'the held cast plays as soon as the unit is tracked');
|
||||
assert.deepEqual(voiced, [['char_a', 'skill3', 8]], 'with the 作战中N line its skillIndex picks');
|
||||
a.handleBattleEvents([['skill', 7, 1], ['atk', 7, 8, 'none'], ['spawn', info(7, 0)]]);
|
||||
assert.equal(played.length, 2, 'the same batch: the cast waits for the spawn of its own batch');
|
||||
assert.deepEqual(voiced.at(-1), ['char_a', 'skill1', 7]);
|
||||
// (2) once: a unit that is told about again (a repeated spawn) never replays it; a later cast plays directly
|
||||
a.handleBattleEvents([['spawn', info(8, 2)], ['spawn', info(7, 0)]]);
|
||||
assert.equal(played.length, 2, 'no second cue for the held cast');
|
||||
a.handleBattleEvents([['skill', 8, 1]]);
|
||||
assert.equal(played.length, 3, 'a later cast of a known unit plays through');
|
||||
assert.deepEqual(voiced.at(-1), ['char_a', 'skill3', 8], 'and sounds the same as the replayed one');
|
||||
// (3) skill off (`['skill', id, 0]`) is no cast: nothing is held for it
|
||||
a.handleBattleEvents([['skill', 9, 0]]);
|
||||
a.handleBattleEvents([['spawn', info(9, 0)]]);
|
||||
assert.equal(played.length, 3, 'the off event holds nothing');
|
||||
assert.equal(a.pendingSkill.size, 0);
|
||||
});
|
||||
|
||||
test('a held cast does not outlive its field: a unit of the next field that shares its id does not sound it', () => {
|
||||
const vm = { audio: { sfx: { ui: {}, battle: {}, units: { char_a: { skill: '/s/a_skill.mp3' } } }, voice: {} } };
|
||||
const a = new AudioManager({ win: null, getManifest: () => vm });
|
||||
a.ctx = {};
|
||||
const played = [];
|
||||
a._play = (url) => { played.push(url); };
|
||||
a.voice = () => true;
|
||||
const info = (id) => ({ id, side: 'ally', spine: 'char_a', kind: 'op' });
|
||||
a.handleBattleEvents([['skill', 9, 1]]); // a cast whose unit never appeared on this field
|
||||
assert.equal(a.pendingSkill.size, 1);
|
||||
a.setFieldUnits([info(9)]); // another battle: unit ids are per battle, 9 is a different unit
|
||||
assert.equal(a.pendingSkill.size, 0, 'the field change drops the hold');
|
||||
a.handleBattleEvents([['spawn', info(9)]]);
|
||||
assert.deepEqual(played, [], 'the old field\'s cast never sounds in the new one');
|
||||
// the hold is bounded: casts of units that never appear cannot pile up
|
||||
for (let id = 100; id < 400; id++) a.handleBattleEvents([['skill', id, 1]]);
|
||||
assert.ok(a.pendingSkill.size <= 64, `${a.pendingSkill.size} holds`);
|
||||
assert.ok(a.pendingSkill.has(399) && !a.pendingSkill.has(100), 'the oldest holds go first');
|
||||
});
|
||||
|
||||
test('a real battle: the deploy-tick cast of a deployment-activated skill is heard, once', () => {
|
||||
// The reported shape in 0.2.0's own sim: 宴's kit activates its skill on deployment (activateOnDeploy, PR #109), and
|
||||
// `sim/battle/deploy.js` runs `skill.reset()` (→ `activate` → `['skill', id, 1]`) BEFORE it emits
|
||||
// `['spawn', unitInfo]` — three events earlier in 宴's case (a status and an fx come between). Fed to the audio as
|
||||
// the socket delivers it, that cast used to be dropped for good.
|
||||
const chessId = 'chess_char_1_18_a'; // 宴 (char_337_utage): a deploy-timed skill with a skill sound
|
||||
const h = makeBattle({ seed: 7, autoFinish: false, timeLimit: 5, units: [{ chessId, row: 9, col: 5 }] });
|
||||
h.step(3);
|
||||
const ev = h.events;
|
||||
const iCast = ev.findIndex((e) => e[0] === 'skill' && e[2]);
|
||||
const iSpawn = ev.findIndex((e) => e[0] === 'spawn');
|
||||
assert.ok(iCast >= 0 && iSpawn > iCast, `the sim emits the deploy-tick cast before the unit info (${iCast} < ${iSpawn})`);
|
||||
// the cast's sound, resolved as `unit()` does (the equipped skill's own ON_SKILL_START file, else its `skill`)
|
||||
const info = ev[iSpawn][1];
|
||||
const rec = manifest.audio.sfx.units[info.spine];
|
||||
const url = (Number.isInteger(info.skillIndex) && rec?.skills ? rec.skills[info.skillIndex] : null) ?? rec?.skill;
|
||||
assert.ok(typeof url === 'string', `前提:${info.spine} 有技能音效`);
|
||||
const played = [];
|
||||
const a = new AudioManager({ win: null, getManifest: () => manifest });
|
||||
a.ctx = {};
|
||||
a._play = (u) => { played.push(u); };
|
||||
a.voice = () => true;
|
||||
a.handleBattleEvents(ev);
|
||||
assert.equal(played.filter((u) => u === url).length, 1, 'the cast of the deployment is heard, exactly once');
|
||||
});
|
||||
});
|
||||
|
||||
// user playtest #4 item 6: 纯烬艾雅法拉's skill sound rang outside her skill — her manifest `hit` is her S3 impact
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
// test/ui/feedback7-orchid-freeze.test.js — GitHub #354 (DuolaCCCCat): 「梓兰的主动刷新不会触发主动冻结」. 梓兰's 猎头顾问 (band_orchid) makes
|
||||
// every active refresh a special one: two operators of the same name, and ONE of them frozen — kept to the next prep. The
|
||||
// server did freeze it (slot.frozen, sent in m.private shop.slots[i].frozen) while the shop toggle stayed off; the shop bar
|
||||
// drew the frost from the toggle alone, so the player never saw which card was frozen. Each card now reads its own slot.
|
||||
// The rule itself (a second manual refresh re-rolls the frozen card too, like the toggle) is unchanged.
|
||||
|
||||
import { test, describe } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { makeMatch, DATA } from '../match/harness.js';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
||||
// the browser data store reads the real data files from disk
|
||||
globalThis.fetch = async (url) => {
|
||||
const name = String(url).split('/').pop();
|
||||
try {
|
||||
const body = readFileSync(path.join(ROOT, 'data', name), 'utf8');
|
||||
return { ok: true, status: 200, json: async () => JSON.parse(body) };
|
||||
} catch {
|
||||
return { ok: false, status: 404, json: async () => ({}) };
|
||||
}
|
||||
};
|
||||
const { ChessCard, ItemCard, slotFrozen } = await import('../../public/js/ui/shopBar.js');
|
||||
const { data } = await import('../../public/js/data.js');
|
||||
await data.loadAll('items', 'effects', 'choices', 'assets', 'chess', 'bonds', 'garrisons');
|
||||
|
||||
/** Every vnode of a preact tree (htm output), depth first. */
|
||||
function* walk(v) {
|
||||
if (Array.isArray(v)) { for (const x of v) yield* walk(x); return; }
|
||||
if (!v || typeof v !== 'object') return;
|
||||
yield v;
|
||||
yield* walk(v.props?.children);
|
||||
}
|
||||
const hasClass = (v, c) => typeof v?.props?.class === 'string' && v.props.class.split(/\s+/).includes(c);
|
||||
const iced = (card) => hasClass(card, 'is-frozen') && [...walk(card)].some((n) => hasClass(n, 'scard__ice'));
|
||||
const bare = (card) => !hasClass(card, 'is-frozen') && ![...walk(card)].some((n) => hasClass(n, 'scard__ice'));
|
||||
|
||||
/** A solo match in its first prep with 梓兰's strategy, level 4 shop, funds for a few refreshes. */
|
||||
function orchid(seed = 12) {
|
||||
const h = makeMatch({ mode: 'solo', seed, fake: true }).start();
|
||||
h.toPrep(1);
|
||||
const ps = h.ps('p_0');
|
||||
ps.bandId = 'band_orchid';
|
||||
ps.shop.level = 4;
|
||||
ps.funds = 50;
|
||||
return { h, ps };
|
||||
}
|
||||
|
||||
describe('#354: the card the 猎头顾问 freezes shows its frost', () => {
|
||||
test('the wire: the active refresh freezes one copied card and leaves the shop toggle off; the cards read their own slot', () => {
|
||||
const { h, ps } = orchid();
|
||||
assert.deepEqual(h.m.handle('p_0', { t: 'g.refresh' }), { ok: true });
|
||||
const priv = ps.privateView();
|
||||
assert.equal(priv.shop.frozen, false, 'the 冻结 toggle is off');
|
||||
const chess = priv.shop.slots.filter((s) => s && s.kind === 'chess');
|
||||
const frozen = chess.filter((s) => s.frozen);
|
||||
assert.equal(frozen.length, 1, 'exactly one card is frozen');
|
||||
assert.equal(chess.filter((s) => s.id === frozen[0].id).length, 2, 'one of the two operators of the same name');
|
||||
// the bar's frame and the button follow the toggle (off), each card follows its own slot
|
||||
for (const s of priv.shop.slots.filter(Boolean)) {
|
||||
const props = { slot: s, idx: 0, priv, frozen: slotFrozen(s, priv.shop.frozen), onBuy() {}, onDetail() {} };
|
||||
const card = s.kind === 'item' ? ItemCard(props) : ChessCard(props);
|
||||
assert.equal(s.frozen ? iced(card) : bare(card), true, `${s.id}: ${s.frozen ? 'frost' : 'no frost'}`);
|
||||
}
|
||||
h.m.dispose();
|
||||
});
|
||||
|
||||
test('the toggle still freezes every unsold card, and the helper reads the slot and the toggle', () => {
|
||||
const { h, ps } = orchid(13);
|
||||
h.m.handle('p_0', { t: 'g.freeze' });
|
||||
const priv = ps.privateView();
|
||||
assert.equal(priv.shop.frozen, true);
|
||||
assert.ok(priv.shop.slots.filter(Boolean).every((s) => s.frozen), 'every slot copies the toggle');
|
||||
assert.equal(slotFrozen(priv.shop.slots[0], priv.shop.frozen), true);
|
||||
assert.equal(slotFrozen({ frozen: false }, true), true, 'a frame without per-slot flags: the toggle');
|
||||
assert.equal(slotFrozen({ frozen: true }, false), true, 'a slot of its own: its flag');
|
||||
assert.equal(slotFrozen({ frozen: false }, false), false);
|
||||
assert.equal(slotFrozen(null), false);
|
||||
h.m.dispose();
|
||||
});
|
||||
|
||||
test('the rule stays as it was: the next manual refresh re-rolls the frozen card too and freezes one of the new pair; one refresh then the next prep keeps that card', () => {
|
||||
const { h, ps } = orchid();
|
||||
h.m.handle('p_0', { t: 'g.refresh' });
|
||||
const first = ps.shop.slots.find((s) => s && s.frozen);
|
||||
h.m.handle('p_0', { t: 'g.refresh' });
|
||||
const frozenNow = ps.shop.slots.filter((s) => s && s.frozen);
|
||||
assert.equal(frozenNow.length, 1, 'again exactly one frozen card');
|
||||
assert.equal(ps.shop.frozen, false);
|
||||
assert.ok(first && frozenNow[0], 'both refreshes froze a card');
|
||||
const kept = frozenNow[0].id;
|
||||
h.toPrep(2);
|
||||
assert.ok(ps.shop.slots.some((s) => s && s.id === kept), 'the frozen card is still in the shop in the next prep');
|
||||
assert.equal(ps.shop.frozen, false);
|
||||
h.m.dispose();
|
||||
});
|
||||
|
||||
test('ShopBar passes slotFrozen(slot, toggle) to the operator and item cards; the frame and the 冻结 button stay on the toggle', () => {
|
||||
const src = readFileSync(path.join(ROOT, 'public/js/ui/shopBar.js'), 'utf8');
|
||||
assert.match(src, /<\$\{ChessCard\} key=\$\{`c\$\{i\}:\$\{s\.id\}`\} slot=\$\{s\} idx=\$\{i\} priv=\$\{priv\} frozen=\$\{slotFrozen\(s, frozen\)\}/);
|
||||
assert.match(src, /<\$\{ItemCard\} key=\$\{`i\$\{i\}:\$\{s\.id\}`\} slot=\$\{s\} idx=\$\{i\} frozen=\$\{slotFrozen\(s, frozen\)\}/);
|
||||
assert.match(src, /const frozen = !!shop\.frozen;/);
|
||||
assert.match(src, /cx\('shopbar', frozen && 'is-frozen'/, 'the bar\'s frame is the toggle');
|
||||
assert.match(src, /cx\('toolbtn', 'toolbtn--ice', frozen && 'is-on'\)/, 'the button is the toggle');
|
||||
assert.ok(DATA.bands.band_orchid, 'the band exists in the data');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,158 @@
|
||||
// Field-host ownership under cancellation and timeout. Browser tests cover the actual renderer; this fixture runs
|
||||
// the production orchestration with controlled imports and timers, without a DOM or GPU dependency.
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { getEventListeners } from 'node:events';
|
||||
import vm from 'node:vm';
|
||||
|
||||
const sourcePath = new URL('../../public/js/ui/fieldHost.js', import.meta.url);
|
||||
const source = readFileSync(sourcePath, 'utf8')
|
||||
.replace(/^import .*;$/gm, '').replace(/^export /gm, '')
|
||||
.replace("import('../assets.js')", 'importAssets()')
|
||||
.replace("import('../render/app.js')", 'importApp()');
|
||||
const METHODS = ['setStage', 'setCamera', 'setPrep', 'enterBattle', 'pushSnapshot', 'pushEvents', 'highlightTiles', 'on', 'resize'];
|
||||
const flush = async () => { for (let i = 0; i < 16; i++) await Promise.resolve(); };
|
||||
|
||||
function fixture() {
|
||||
const timers = new Map(), requests = [], fallbacks = [], warnings = [];
|
||||
let timerId = 0;
|
||||
const unrelated = { name: 'unrelated' };
|
||||
const host = {
|
||||
children: [unrelated],
|
||||
get firstChild() { return this.children[0] || null; },
|
||||
appendChild(node) { this.children.push(node); },
|
||||
removeChild(node) { const i = this.children.indexOf(node); if (i >= 0) this.children.splice(i, 1); },
|
||||
};
|
||||
function view(name, valid = true) {
|
||||
const node = { name };
|
||||
host.appendChild(node);
|
||||
return {
|
||||
...(valid ? Object.fromEntries(METHODS.map((k) => [k, () => {}])) : {}),
|
||||
node, destroyCalls: 0,
|
||||
destroy() { this.destroyCalls++; host.removeChild(node); },
|
||||
};
|
||||
}
|
||||
const context = vm.createContext({
|
||||
AbortController, DOMException, URLSearchParams, Promise,
|
||||
console: { warn: (...args) => warnings.push(args) },
|
||||
audio: {}, data: { get() { return null; } }, settingsStore: { get() { return {}; } },
|
||||
setTimeout(fn) { timers.set(++timerId, fn); return timerId; },
|
||||
clearTimeout(id) { timers.delete(id); },
|
||||
importAssets: async () => null,
|
||||
importApp: async () => ({
|
||||
createFieldView(_host, opts) {
|
||||
// Deliberately ignore cancellation: the host must still dispose a factory's late return value.
|
||||
return new Promise((resolve, reject) => requests.push({ resolve, reject, signal: opts.signal }));
|
||||
},
|
||||
}),
|
||||
createFallbackView() {
|
||||
const fallback = view('fallback');
|
||||
fallbacks.push({ view: fallback, rendererAborted: requests.at(-1)?.signal?.aborted });
|
||||
return fallback;
|
||||
},
|
||||
});
|
||||
vm.runInContext(source + '\nglobalThis.mount = mountFieldView;', context, { filename: sourcePath.pathname });
|
||||
return {
|
||||
host, unrelated, timers, requests, fallbacks, warnings, view,
|
||||
mount: (signal) => context.mount(host, { signal }),
|
||||
timeout() { const fn = [...timers.values()].at(-1); assert.ok(fn, 'initialization has a deadline'); fn(); },
|
||||
};
|
||||
}
|
||||
|
||||
function settled(promise) {
|
||||
const result = { state: 'pending', value: null };
|
||||
promise.then((value) => { result.state = 'fulfilled'; result.value = value; }, (error) => { result.state = 'rejected'; result.value = error; });
|
||||
return result;
|
||||
}
|
||||
|
||||
test('successful initialization releases deadlines and cancellation listeners', async () => {
|
||||
const f = fixture(), controller = new AbortController();
|
||||
const pending = f.mount(controller.signal);
|
||||
await flush();
|
||||
const raw = f.view('engine');
|
||||
f.requests[0].resolve(raw);
|
||||
const mounted = await pending;
|
||||
assert.equal(mounted.kind, 'engine');
|
||||
assert.equal(f.timers.size, 0);
|
||||
assert.equal(getEventListeners(controller.signal, 'abort').length, 0);
|
||||
assert.equal(getEventListeners(f.requests[0].signal, 'abort').length, 0);
|
||||
mounted.destroy();
|
||||
assert.equal(raw.destroyCalls, 1);
|
||||
});
|
||||
|
||||
test('cancelling a pending mount rejects without fallback and disposes a late result', async () => {
|
||||
const f = fixture(), controller = new AbortController();
|
||||
const result = settled(f.mount(controller.signal));
|
||||
await flush();
|
||||
controller.abort();
|
||||
await flush();
|
||||
assert.equal(result.state, 'rejected');
|
||||
assert.equal(result.value.name, 'AbortError');
|
||||
assert.equal(f.fallbacks.length, 0);
|
||||
assert.equal(f.warnings.length, 0);
|
||||
assert.equal(f.timers.size, 0);
|
||||
assert.equal(getEventListeners(controller.signal, 'abort').length, 0);
|
||||
const late = f.view('late');
|
||||
f.requests[0].resolve(late);
|
||||
await flush();
|
||||
assert.equal(late.destroyCalls, 1);
|
||||
assert.deepEqual(f.host.children, [f.unrelated]);
|
||||
});
|
||||
|
||||
test('cancellation between factory resolution and mount continuation disposes the resolved view', async () => {
|
||||
const f = fixture(), controller = new AbortController();
|
||||
const result = settled(f.mount(controller.signal));
|
||||
await flush();
|
||||
const raw = f.view('settling');
|
||||
f.requests[0].resolve(raw);
|
||||
await Promise.resolve();
|
||||
controller.abort();
|
||||
await flush();
|
||||
assert.equal(result.state, 'rejected');
|
||||
assert.equal(result.value.name, 'AbortError');
|
||||
assert.equal(raw.destroyCalls, 1);
|
||||
assert.equal(f.fallbacks.length, 0);
|
||||
assert.equal(f.timers.size, 0);
|
||||
assert.deepEqual(f.host.children, [f.unrelated]);
|
||||
});
|
||||
|
||||
test('an invalid renderer API is disposed and aborted before fallback mounts', async () => {
|
||||
const f = fixture();
|
||||
const pending = f.mount();
|
||||
await flush();
|
||||
const invalid = f.view('invalid', false);
|
||||
f.requests[0].resolve(invalid);
|
||||
const fallback = await pending;
|
||||
assert.equal(fallback.kind, 'fallback');
|
||||
assert.equal(invalid.destroyCalls, 1);
|
||||
assert.equal(f.fallbacks[0].rendererAborted, true);
|
||||
assert.equal(f.timers.size, 0);
|
||||
assert.deepEqual(f.host.children, [f.unrelated, f.fallbacks[0].view.node]);
|
||||
fallback.destroy();
|
||||
});
|
||||
|
||||
test('a timed-out factory returning late cannot remove a replacement view or unrelated children', async () => {
|
||||
const f = fixture();
|
||||
const first = f.mount();
|
||||
await flush();
|
||||
f.timeout();
|
||||
const fallback = await first;
|
||||
assert.equal(fallback.kind, 'fallback');
|
||||
fallback.destroy();
|
||||
const replacementPending = f.mount();
|
||||
await flush();
|
||||
const replacement = f.view('replacement');
|
||||
f.requests[1].resolve(replacement);
|
||||
const mounted = await replacementPending;
|
||||
const late = f.view('late');
|
||||
f.requests[0].resolve(late);
|
||||
await flush();
|
||||
assert.equal(late.destroyCalls, 1);
|
||||
assert.equal(replacement.destroyCalls, 0);
|
||||
assert.equal(f.fallbacks[0].rendererAborted, true);
|
||||
assert.equal(f.timers.size, 0);
|
||||
assert.deepEqual(f.host.children, [f.unrelated, replacement.node]);
|
||||
mounted.destroy();
|
||||
});
|
||||
@@ -126,7 +126,7 @@ describe('8: the detail card never covers the selected unit\'s underframe', () =
|
||||
const { readFileSync } = await import('node:fs');
|
||||
const app = readFileSync(new URL('../../public/js/render/app.js', import.meta.url), 'utf8');
|
||||
assert.match(app, /\n {2}canvas\.addEventListener\('click', onTapTarget\);\n/, 'registered with the other canvas listeners');
|
||||
assert.match(app, /\n {6}canvas\.removeEventListener\('click', onTapTarget\);\n/, 'dropped on destroy');
|
||||
assert.match(app, /\n +canvas\.removeEventListener\('click', onTapTarget\);\n/, 'dropped on destroy');
|
||||
assert.match(app, /const onTapTarget = \(\) => \{\};/, 'a no-op: the press itself stays with the pointer events');
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user