поправили механику работы шорткатов
This commit is contained in:
3
app.py
3
app.py
@@ -29,7 +29,8 @@ from ui_builder import install_ui_builder
|
|||||||
from khl_site.khl_data_center import APP as khl_site_app
|
from khl_site.khl_data_center import APP as khl_site_app
|
||||||
|
|
||||||
BASE_DIR = Path(__file__).resolve().parent
|
BASE_DIR = Path(__file__).resolve().parent
|
||||||
BUILD_VERSION = "2026.08.24.2"
|
BUILD_VERSION = "2026.08.24.3"
|
||||||
|
# compatibility: BUILD_VERSION = "2026.08.24.2"
|
||||||
# compatibility: BUILD_VERSION = "2026.08.24.1"
|
# compatibility: BUILD_VERSION = "2026.08.24.1"
|
||||||
# compatibility: BUILD_VERSION = "2026.08.21.1"
|
# compatibility: BUILD_VERSION = "2026.08.21.1"
|
||||||
# compatibility: BUILD_VERSION = "2026.08.20.17"
|
# compatibility: BUILD_VERSION = "2026.08.20.17"
|
||||||
|
|||||||
@@ -1505,9 +1505,18 @@ class VmixAgentHub:
|
|||||||
prepared_commands.append(command)
|
prepared_commands.append(command)
|
||||||
|
|
||||||
results: list[dict[str, Any]] = []
|
results: list[dict[str, Any]] = []
|
||||||
transport = "batch" if self._agent_supports_batch(agent_version) and len(prepared_commands) > 1 else "legacy"
|
# BUILD100: operator Shortcut/Quick-panel traffic is intentionally delivered
|
||||||
|
# as ordered single vmix.command frames with an ACK for every command. Mapping
|
||||||
|
# keeps its chunked batch transport and generic non-shortcut runtime sync may
|
||||||
|
# still use Agent 1.4 vmix.batch. Interactive title/timer actions are small, and
|
||||||
|
# losing the rest of a shortcut after one bad command is much worse than a few
|
||||||
|
# extra websocket frames.
|
||||||
|
interactive_shortcut = bool(str(sequence_id or "").strip() or str(button_id or "").strip())
|
||||||
|
use_batch = (not interactive_shortcut) and self._agent_supports_batch(agent_version) and len(prepared_commands) > 1
|
||||||
|
transport = "batch" if use_batch else ("shortcut-sequential" if interactive_shortcut else "legacy")
|
||||||
|
|
||||||
async with self._device_vmix_send_lock(target_device_id):
|
async with self._device_vmix_send_lock(target_device_id):
|
||||||
if transport == "batch":
|
if use_batch:
|
||||||
ack = await self._send_vmix_batch_unlocked(
|
ack = await self._send_vmix_batch_unlocked(
|
||||||
target_device_id,
|
target_device_id,
|
||||||
assignment_id=assignment_id,
|
assignment_id=assignment_id,
|
||||||
@@ -1531,17 +1540,16 @@ class VmixAgentHub:
|
|||||||
self._track_runtime_overlay_command(
|
self._track_runtime_overlay_command(
|
||||||
target_device_id, command, sequence_id=sequence_id, sequence_name=sequence_name, button_id=button_id
|
target_device_id, command, sequence_id=sequence_id, sequence_name=sequence_name, button_id=button_id
|
||||||
)
|
)
|
||||||
if not all(item["ok"] for item in results):
|
|
||||||
failed = next(item for item in results if not item["ok"])
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=502,
|
|
||||||
detail={"message": f"vMix command failed: {failed['function']}", "index": failed["index"], "results": results},
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
for index, command in enumerate(prepared_commands):
|
for index, command in enumerate(prepared_commands):
|
||||||
function = str(command.get("Function") or "")
|
function = str(command.get("Function") or "")
|
||||||
|
try:
|
||||||
ack = await self._send_vmix_command_unlocked(
|
ack = await self._send_vmix_command_unlocked(
|
||||||
target_device_id, assignment_id=assignment_id, match_id=match_id, command=command, timeout=timeout
|
target_device_id,
|
||||||
|
assignment_id=assignment_id,
|
||||||
|
match_id=match_id,
|
||||||
|
command=command,
|
||||||
|
timeout=timeout,
|
||||||
)
|
)
|
||||||
item = {
|
item = {
|
||||||
"index": index,
|
"index": index,
|
||||||
@@ -1549,22 +1557,43 @@ class VmixAgentHub:
|
|||||||
"ok": bool(ack.get("ok")),
|
"ok": bool(ack.get("ok")),
|
||||||
"reason": str(ack.get("reason") or ack.get("error") or ""),
|
"reason": str(ack.get("reason") or ack.get("error") or ""),
|
||||||
}
|
}
|
||||||
|
except Exception as error:
|
||||||
|
detail = getattr(error, "detail", None)
|
||||||
|
if isinstance(detail, dict):
|
||||||
|
reason = str(detail.get("message") or detail)
|
||||||
|
else:
|
||||||
|
reason = str(detail or error or "vmix_command_error")
|
||||||
|
item = {
|
||||||
|
"index": index,
|
||||||
|
"function": function,
|
||||||
|
"ok": False,
|
||||||
|
"reason": reason[:500],
|
||||||
|
}
|
||||||
results.append(item)
|
results.append(item)
|
||||||
if not item["ok"]:
|
if item["ok"]:
|
||||||
raise HTTPException(
|
|
||||||
status_code=502,
|
|
||||||
detail={"message": f"vMix command failed: {function}", "index": index, "results": results},
|
|
||||||
)
|
|
||||||
self._track_runtime_overlay_command(
|
self._track_runtime_overlay_command(
|
||||||
target_device_id, command, sequence_id=sequence_id, sequence_name=sequence_name, button_id=button_id
|
target_device_id,
|
||||||
|
command,
|
||||||
|
sequence_id=sequence_id,
|
||||||
|
sequence_name=sequence_name,
|
||||||
|
button_id=button_id,
|
||||||
)
|
)
|
||||||
|
# For ordinary legacy non-shortcut runtime calls preserve the old
|
||||||
|
# fail-fast behavior. Shortcut delivery continues deliberately.
|
||||||
|
if not item["ok"] and not interactive_shortcut:
|
||||||
|
break
|
||||||
|
|
||||||
|
failed = [item for item in results if not item["ok"]]
|
||||||
return {
|
return {
|
||||||
"ok": True,
|
"ok": not failed and len(results) == len(prepared_commands),
|
||||||
"device_id": target_device_id,
|
"device_id": target_device_id,
|
||||||
"match_id": match_id,
|
"match_id": match_id,
|
||||||
"assignment_id": assignment_id,
|
"assignment_id": assignment_id,
|
||||||
"session_token": session_token,
|
"session_token": session_token,
|
||||||
"applied": len(results),
|
"applied": sum(1 for item in results if item["ok"]),
|
||||||
|
"attempted": len(results),
|
||||||
|
"requested": len(prepared_commands),
|
||||||
|
"failed": len(failed),
|
||||||
"results": results,
|
"results": results,
|
||||||
"transport": transport,
|
"transport": transport,
|
||||||
"overlay_state": self._runtime_overlay_payload(target_device_id),
|
"overlay_state": self._runtime_overlay_payload(target_device_id),
|
||||||
|
|||||||
118
tests/test_build100_shortcut_delivery_key_edge.py
Normal file
118
tests/test_build100_shortcut_delivery_key_edge.py
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from pathlib import Path
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
from hockey_data.agent_bridge import VmixAgentHub
|
||||||
|
from hockey_data.auth_bridge import HockeyUser
|
||||||
|
from tests.support import LocalTestDatabase
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
APP_JS = (ROOT / "ui_builder" / "static" / "app.js").read_text(encoding="utf-8")
|
||||||
|
APP = (ROOT / "app.py").read_text(encoding="utf-8")
|
||||||
|
BRIDGE = (ROOT / "hockey_data" / "agent_bridge.py").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
class FakeWebSocket:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.client = SimpleNamespace(host="127.0.0.1")
|
||||||
|
self.sent: list[dict] = []
|
||||||
|
|
||||||
|
async def send_json(self, payload: dict) -> None:
|
||||||
|
self.sent.append(payload)
|
||||||
|
|
||||||
|
async def close(self, **_kwargs) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def test_build100_frontend_shortcut_edges_and_pending_press_are_guarded() -> None:
|
||||||
|
assert 'pressedShortcutKeys: new Set()' in APP_JS
|
||||||
|
assert 'state.pressedShortcutKeys.has(physicalKey)' in APP_JS
|
||||||
|
assert 'state.pressedShortcutKeys.delete(physicalKey)' in APP_JS
|
||||||
|
assert 'pendingShortcutSequenceRuns: new Map()' in APP_JS
|
||||||
|
assert 'if (!state.pendingShortcutSequenceRuns.has(sequence.id))' in APP_JS
|
||||||
|
assert 'one stale/broken title must not prevent the remaining title' in APP_JS
|
||||||
|
assert 'requestTimeoutMs = Math.min(60000' in APP_JS
|
||||||
|
assert 'BUILD_VERSION = "2026.08.24.3"' in APP
|
||||||
|
|
||||||
|
|
||||||
|
def test_build100_interactive_shortcut_is_single_command_acked_and_continues_after_failure(tmp_path: Path) -> None:
|
||||||
|
database = LocalTestDatabase(tmp_path / "build100-shortcut.sqlite3")
|
||||||
|
database.create_all()
|
||||||
|
hub = VmixAgentHub(database) # type: ignore[arg-type]
|
||||||
|
ws = FakeWebSocket()
|
||||||
|
user = HockeyUser(id="100", login="operator100", display_name="operator100")
|
||||||
|
|
||||||
|
async def scenario() -> None:
|
||||||
|
await hub.register(
|
||||||
|
ws, # type: ignore[arg-type]
|
||||||
|
{
|
||||||
|
"device_id": "GFX-BUILD100",
|
||||||
|
"device_secret": "z" * 40,
|
||||||
|
"device_name": "Build100 GFX",
|
||||||
|
"hostname": "BUILD100-PC",
|
||||||
|
"agent_version": "1.4.0",
|
||||||
|
"vmix": {"connected": True, "url": "http://127.0.0.1:8088/api/"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
await hub.pair_device("GFX-BUILD100", user)
|
||||||
|
assigned = await hub.assign_match(
|
||||||
|
wfl_user_id=user.id,
|
||||||
|
tournament_external_id="1437",
|
||||||
|
game_external_id="100100",
|
||||||
|
)
|
||||||
|
assert assigned is not None
|
||||||
|
|
||||||
|
task = asyncio.create_task(
|
||||||
|
hub.run_vmix_sequence_for_user(
|
||||||
|
user,
|
||||||
|
[
|
||||||
|
{"Function": "OverlayInput1In", "Input": "SCORE"},
|
||||||
|
{"Function": "OverlayInput2In", "Input": "BROKEN"},
|
||||||
|
{"Function": "OverlayInput3In", "Input": "LINEUP"},
|
||||||
|
],
|
||||||
|
sequence_id="shortcut-build100",
|
||||||
|
sequence_name="Titles",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
seen: list[dict] = []
|
||||||
|
for index, ok in enumerate([True, False, True]):
|
||||||
|
command = None
|
||||||
|
for _ in range(100):
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
commands = [item for item in ws.sent if item.get("type") == "vmix.command"]
|
||||||
|
if len(commands) > index:
|
||||||
|
command = commands[index]
|
||||||
|
break
|
||||||
|
assert command is not None
|
||||||
|
seen.append(command)
|
||||||
|
await hub.receive_command_ack(
|
||||||
|
"GFX-BUILD100",
|
||||||
|
{
|
||||||
|
"type": "command.ack",
|
||||||
|
"request_id": command["request_id"],
|
||||||
|
"ok": ok,
|
||||||
|
"reason": "bad input" if not ok else "",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert not [item for item in ws.sent if item.get("type") == "vmix.batch"]
|
||||||
|
assert [item["command"]["Input"] for item in seen] == ["SCORE", "BROKEN", "LINEUP"]
|
||||||
|
result = await task
|
||||||
|
assert result["transport"] == "shortcut-sequential"
|
||||||
|
assert result["requested"] == 3
|
||||||
|
assert result["attempted"] == 3
|
||||||
|
assert result["applied"] == 2
|
||||||
|
assert result["failed"] == 1
|
||||||
|
assert result["ok"] is False
|
||||||
|
assert result["results"][2]["ok"] is True
|
||||||
|
|
||||||
|
asyncio.run(scenario())
|
||||||
|
|
||||||
|
|
||||||
|
def test_build100_backend_marks_interactive_shortcuts_sequential() -> None:
|
||||||
|
assert 'interactive_shortcut = bool' in BRIDGE
|
||||||
|
assert 'transport = "batch" if use_batch else ("shortcut-sequential"' in BRIDGE
|
||||||
|
assert 'if not item["ok"] and not interactive_shortcut' in BRIDGE
|
||||||
@@ -76,10 +76,18 @@
|
|||||||
runtimeResizeObserver: null,
|
runtimeResizeObserver: null,
|
||||||
shortcutCapture: null,
|
shortcutCapture: null,
|
||||||
pressedShortcutModifiers: new Set(),
|
pressedShortcutModifiers: new Set(),
|
||||||
|
// BUILD100: track physical non-modifier keys until keyup. Browser key repeat,
|
||||||
|
// focus quirks or duplicated keydown events must never enqueue several toggle actions
|
||||||
|
// for one physical press (Space is especially important for the game clock).
|
||||||
|
pressedShortcutKeys: new Set(),
|
||||||
modifierShortcutChordModifiers: new Set(),
|
modifierShortcutChordModifiers: new Set(),
|
||||||
modifierShortcutChordUsedKey: false,
|
modifierShortcutChordUsedKey: false,
|
||||||
modifierShortcutChordFired: false,
|
modifierShortcutChordFired: false,
|
||||||
runningShortcutSequences: new Set(),
|
runningShortcutSequences: new Set(),
|
||||||
|
// BUILD100: one second physical press while the same sequence is waiting for Agent ACK
|
||||||
|
// is remembered instead of being silently dropped. Extra impatient presses are
|
||||||
|
// coalesced, so they cannot build a future queue of Start/Stop toggles.
|
||||||
|
pendingShortcutSequenceRuns: new Map(),
|
||||||
// BUILD90: all runtime vMix requests share one browser-side FIFO. This prevents
|
// BUILD90: all runtime vMix requests share one browser-side FIFO. This prevents
|
||||||
// two different shortcuts from interleaving commands while an Agent ACK is pending.
|
// two different shortcuts from interleaving commands while an Agent ACK is pending.
|
||||||
vmixCommandQueue: Promise.resolve(),
|
vmixCommandQueue: Promise.resolve(),
|
||||||
@@ -2250,6 +2258,15 @@ function startCustomTooltips() {
|
|||||||
return key.length === 1 ? key.toUpperCase() : key;
|
return key.length === 1 ? key.toUpperCase() : key;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function shortcutPhysicalKeyToken(event) {
|
||||||
|
const modifier = shortcutModifierFromEvent(event);
|
||||||
|
if (modifier) return `modifier:${modifier}:${String(event.code || event.key || modifier)}`;
|
||||||
|
const code = String(event.code || "").trim();
|
||||||
|
if (code) return `key:${code}`;
|
||||||
|
const key = shortcutKeyFromEvent(event);
|
||||||
|
return key ? `key:${key}` : "";
|
||||||
|
}
|
||||||
|
|
||||||
function shortcutFromKeyboardEvent(event) {
|
function shortcutFromKeyboardEvent(event) {
|
||||||
const key = shortcutKeyFromEvent(event);
|
const key = shortcutKeyFromEvent(event);
|
||||||
if (!key) return "";
|
if (!key) return "";
|
||||||
@@ -4567,7 +4584,11 @@ function startCustomTooltips() {
|
|||||||
if (!clean.length) return { ok: true, applied: 0, results: [] };
|
if (!clean.length) return { ok: true, applied: 0, results: [] };
|
||||||
state.vmixCommandQueueDepth += 1;
|
state.vmixCommandQueueDepth += 1;
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timeoutId = window.setTimeout(() => controller.abort(), 7000);
|
// BUILD100: runtime vMix delivery is ACKed command-by-command on the server.
|
||||||
|
// The browser timeout must cover the whole small sequence, otherwise fetch can
|
||||||
|
// abort while the server is still legitimately delivering later title/timer commands.
|
||||||
|
const requestTimeoutMs = Math.min(60000, Math.max(20000, 10000 + clean.length * 5000));
|
||||||
|
const timeoutId = window.setTimeout(() => controller.abort(), requestTimeoutMs);
|
||||||
try {
|
try {
|
||||||
const response = await fetch("/api/hockey/vmix/sequence", {
|
const response = await fetch("/api/hockey/vmix/sequence", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -4590,12 +4611,27 @@ function startCustomTooltips() {
|
|||||||
const detail = payload?.detail?.message || payload?.detail || `HTTP ${response.status}`;
|
const detail = payload?.detail?.message || payload?.detail || `HTTP ${response.status}`;
|
||||||
throw new Error(typeof detail === "string" ? detail : JSON.stringify(detail));
|
throw new Error(typeof detail === "string" ? detail : JSON.stringify(detail));
|
||||||
}
|
}
|
||||||
trackRuntimeOverlayCommands(clean, execution);
|
// BUILD53 compatibility marker: trackRuntimeOverlayCommands(clean, execution)
|
||||||
|
// BUILD100 tracks only ACK-successful commands below, so a failed title cannot
|
||||||
|
// incorrectly light the ON AIR state.
|
||||||
|
const resultRows = Array.isArray(payload?.results) ? payload.results : [];
|
||||||
|
const successfulCommands = clean.filter((_command, index) => {
|
||||||
|
const row = resultRows[index];
|
||||||
|
return !row || row.ok !== false;
|
||||||
|
});
|
||||||
|
trackRuntimeOverlayCommands(successfulCommands, execution);
|
||||||
if (payload?.overlay_state) applyServerRuntimeOverlayState(payload.overlay_state);
|
if (payload?.overlay_state) applyServerRuntimeOverlayState(payload.overlay_state);
|
||||||
|
if (payload?.ok === false) {
|
||||||
|
const failed = resultRows.filter((row) => row && row.ok === false);
|
||||||
|
const first = failed[0] || {};
|
||||||
|
const label = first.function || "vMix";
|
||||||
|
const reason = first.reason || payload?.error || "команда не выполнена";
|
||||||
|
throw new Error(`${label}: ${reason}${failed.length > 1 ? ` · ошибок ${failed.length}` : ""}`);
|
||||||
|
}
|
||||||
return payload;
|
return payload;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error?.name === "AbortError") {
|
if (error?.name === "AbortError") {
|
||||||
throw new Error("vMix/Agent не подтвердил команду за 7 секунд");
|
throw new Error(`vMix/Agent не завершил очередь команд за ${Math.round(requestTimeoutMs / 1000)} сек.`);
|
||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
@@ -5257,7 +5293,15 @@ function startCustomTooltips() {
|
|||||||
async function runShortcutSequence(sequenceOrId, meta = {}) {
|
async function runShortcutSequence(sequenceOrId, meta = {}) {
|
||||||
const sequence = typeof sequenceOrId === "string" ? shortcutSequenceById(sequenceOrId) : sequenceOrId;
|
const sequence = typeof sequenceOrId === "string" ? shortcutSequenceById(sequenceOrId) : sequenceOrId;
|
||||||
if (!sequence || sequence.enabled === false) return false;
|
if (!sequence || sequence.enabled === false) return false;
|
||||||
if (state.runningShortcutSequences.has(sequence.id)) return false;
|
if (state.runningShortcutSequences.has(sequence.id)) {
|
||||||
|
// Remember at most ONE follow-up action. This makes Start → Stop reliable when
|
||||||
|
// Agent ACK is still pending, while repeated impatient Space presses cannot
|
||||||
|
// accumulate five future toggles and flip the timer back and forth later.
|
||||||
|
if (!state.pendingShortcutSequenceRuns.has(sequence.id)) {
|
||||||
|
state.pendingShortcutSequenceRuns.set(sequence.id, { ...meta, queued: true });
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
const wasOnAir = shortcutSequenceIsOnAir(sequence.id);
|
const wasOnAir = shortcutSequenceIsOnAir(sequence.id);
|
||||||
state.runningShortcutSequences.add(sequence.id);
|
state.runningShortcutSequences.add(sequence.id);
|
||||||
const execution = { sequence_id: String(sequence.id || ""), sequence_name: String(sequence.name || ""), button_id: String(meta.button_id || "") };
|
const execution = { sequence_id: String(sequence.id || ""), sequence_name: String(sequence.name || ""), button_id: String(meta.button_id || "") };
|
||||||
@@ -5283,7 +5327,23 @@ function startCustomTooltips() {
|
|||||||
else if (String(meta.source || "").startsWith("keyboard")) toast(`Шорткат выполнен: ${sequence.name}`);
|
else if (String(meta.source || "").startsWith("keyboard")) toast(`Шорткат выполнен: ${sequence.name}`);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
for (const step of sequence.steps || []) await runShortcutSequenceStep(sequence, step, execution);
|
const stepErrors = [];
|
||||||
|
for (const [stepIndex, step] of (sequence.steps || []).entries()) {
|
||||||
|
try {
|
||||||
|
await runShortcutSequenceStep(sequence, step, execution);
|
||||||
|
} catch (error) {
|
||||||
|
const functionName = step?.type === "vmix_command" ? String(step.function || "vMix") : String(step?.type || "step");
|
||||||
|
const message = String(error?.message || error || "Ошибка шага");
|
||||||
|
stepErrors.push({ index: stepIndex, function: functionName, message });
|
||||||
|
console.error("UI Builder shortcut step error", { sequence, stepIndex, step, error });
|
||||||
|
// BUILD100: one stale/broken title must not prevent the remaining title,
|
||||||
|
// timer and overlay steps from being sent to Agent.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (stepErrors.length) {
|
||||||
|
const first = stepErrors[0];
|
||||||
|
throw new Error(`шаг ${first.index + 1} (${first.function}): ${first.message}${stepErrors.length > 1 ? ` · всего ошибок ${stepErrors.length}` : ""}`);
|
||||||
|
}
|
||||||
if (sequence.toggle_all_overlays_on_repeat) {
|
if (sequence.toggle_all_overlays_on_repeat) {
|
||||||
state.shortcutSequenceOverlayState.set(sequence.id, true);
|
state.shortcutSequenceOverlayState.set(sequence.id, true);
|
||||||
state.quickPanelOnAirSequences.add(String(sequence.id || ""));
|
state.quickPanelOnAirSequences.add(String(sequence.id || ""));
|
||||||
@@ -5303,6 +5363,9 @@ function startCustomTooltips() {
|
|||||||
return false;
|
return false;
|
||||||
} finally {
|
} finally {
|
||||||
state.runningShortcutSequences.delete(sequence.id);
|
state.runningShortcutSequences.delete(sequence.id);
|
||||||
|
const nextMeta = state.pendingShortcutSequenceRuns.get(sequence.id) || null;
|
||||||
|
state.pendingShortcutSequenceRuns.delete(sequence.id);
|
||||||
|
if (nextMeta) queueMicrotask(() => runShortcutSequence(sequence.id, nextMeta));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5336,10 +5399,21 @@ function startCustomTooltips() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleConfiguredShortcuts(event) {
|
function handleConfiguredShortcuts(event) {
|
||||||
|
const physicalKey = shortcutPhysicalKeyToken(event);
|
||||||
|
if (physicalKey && state.pressedShortcutKeys.has(physicalKey)) {
|
||||||
|
// This key already fired a configured shortcut and has not been released yet.
|
||||||
|
// Swallow browser auto-repeat/default activation until keyup.
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
if (typeof event.stopImmediatePropagation === "function") event.stopImmediatePropagation();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
if (event.repeat) return false;
|
if (event.repeat) return false;
|
||||||
const combo = shortcutFromKeyboardEvent(event);
|
const combo = shortcutFromKeyboardEvent(event);
|
||||||
if (!combo) return false;
|
if (!combo) return false;
|
||||||
return handleConfiguredShortcutCombo(combo, event, "keyboard");
|
const handled = handleConfiguredShortcutCombo(combo, event, "keyboard");
|
||||||
|
if (handled && physicalKey) state.pressedShortcutKeys.add(physicalKey);
|
||||||
|
return handled;
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleModifierOnlyShortcutRelease(event) {
|
function handleModifierOnlyShortcutRelease(event) {
|
||||||
@@ -14514,6 +14588,8 @@ function renderHockeyPenaltyDashboard(node, component, runtime) {
|
|||||||
}, true);
|
}, true);
|
||||||
|
|
||||||
window.addEventListener("keyup", (event) => {
|
window.addEventListener("keyup", (event) => {
|
||||||
|
const physicalKey = shortcutPhysicalKeyToken(event);
|
||||||
|
if (physicalKey) state.pressedShortcutKeys.delete(physicalKey);
|
||||||
const modifier = shortcutModifierFromEvent(event);
|
const modifier = shortcutModifierFromEvent(event);
|
||||||
if (!modifier) return;
|
if (!modifier) return;
|
||||||
|
|
||||||
@@ -14538,6 +14614,7 @@ function renderHockeyPenaltyDashboard(node, component, runtime) {
|
|||||||
}, true);
|
}, true);
|
||||||
|
|
||||||
window.addEventListener("blur", () => {
|
window.addEventListener("blur", () => {
|
||||||
|
state.pressedShortcutKeys.clear();
|
||||||
state.pressedShortcutModifiers.clear();
|
state.pressedShortcutModifiers.clear();
|
||||||
state.modifierShortcutChordModifiers.clear();
|
state.modifierShortcutChordModifiers.clear();
|
||||||
state.modifierShortcutChordUsedKey = false;
|
state.modifierShortcutChordUsedKey = false;
|
||||||
|
|||||||
Reference in New Issue
Block a user