fix(mqtt): make payload resizing accessible

This commit is contained in:
jinmo authored and GitHub committed 2026-08-12 20:15:58 +08:00
1 parent 9a30b95a1a
commit 668c5ddadb
10 files changed
+264 -4

No files matched your search

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import { computed, onMounted, onUnmounted, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { mqttPublish } from "@/lib/backend/api";
import { Button } from "@/components/ui/button";
@@ -24,6 +24,18 @@ const encoding = ref<PayloadEncoding>("plaintext");
const loading = ref(false);
const error = ref<string | null>(null);
const success = ref(false);
const publishPanelRef = ref<HTMLElement | null>(null);
const payloadTextareaRef = ref<HTMLTextAreaElement | null>(null);
const payloadHeight = ref(80);
const resizing = ref(false);
const MQTT_PAYLOAD_MIN_HEIGHT_PX = 60;
const MQTT_PAYLOAD_MAX_PANEL_RATIO = 0.5;
const MQTT_PAYLOAD_KEYBOARD_STEP_PX = 10;
const MQTT_PAYLOAD_HEIGHT_STORAGE_KEY = "dbx-mqtt-payload-height";
let resizeStartY = 0;
let resizeStartHeight = 0;
let panelResizeObserver: ResizeObserver | undefined;
const qosLabels = ["QoS 0", "QoS 1", "QoS 2"];
const qosHints = computed(() => [t("connection.mqttQosAtMostOnce"), t("connection.mqttQosAtLeastOnce"), t("connection.mqttQosExactlyOnce")]);
@@ -55,6 +67,89 @@ watch(
},
);
/** 计算消息输入框允许使用的最大高度,避免挤占全部消息列表区域 */
function maxPayloadHeight() {
const containerHeight = publishPanelRef.value?.parentElement?.clientHeight || window.innerHeight || 0;
const panelHeight = publishPanelRef.value?.offsetHeight || 0;
const currentTextareaHeight = payloadTextareaRef.value?.offsetHeight || payloadHeight.value;
const panelChromeHeight = Math.max(0, panelHeight - currentTextareaHeight);
return Math.max(MQTT_PAYLOAD_MIN_HEIGHT_PX, Math.floor(containerHeight * MQTT_PAYLOAD_MAX_PANEL_RATIO - panelChromeHeight));
}
/** 将消息输入框高度限制在可用区域内 */
function clampPayloadHeight(height: number) {
return Math.max(MQTT_PAYLOAD_MIN_HEIGHT_PX, Math.min(maxPayloadHeight(), Math.round(height)));
}
function persistPayloadHeight(height: number) {
payloadHeight.value = clampPayloadHeight(height);
localStorage.setItem(MQTT_PAYLOAD_HEIGHT_STORAGE_KEY, payloadHeight.value.toString());
}
/** 容器尺寸变化时同步收缩输入框,保证消息列表仍可见 */
function handlePanelResize() {
payloadHeight.value = clampPayloadHeight(payloadHeight.value);
}
/** 开始拖动消息输入框顶部的高度调节条 */
function startResize(event: MouseEvent) {
event.preventDefault();
resizing.value = true;
resizeStartY = event.clientY;
resizeStartHeight = payloadHeight.value;
document.addEventListener("mousemove", handleResize);
document.addEventListener("mouseup", stopResize);
document.body.style.userSelect = "none";
document.body.style.cursor = "ns-resize";
}
/** 根据向上拖动的距离实时调整消息输入框高度 */
function handleResize(event: MouseEvent) {
if (!resizing.value) return;
payloadHeight.value = clampPayloadHeight(resizeStartHeight + resizeStartY - event.clientY);
}
function handleResizeKeydown(event: KeyboardEvent) {
const direction = event.key === "ArrowUp" ? 1 : event.key === "ArrowDown" ? -1 : 0;
if (direction === 0) return;
event.preventDefault();
persistPayloadHeight(payloadHeight.value + direction * MQTT_PAYLOAD_KEYBOARD_STEP_PX);
}
/** 结束拖动并记住用户设置的高度 */
function stopResize() {
if (!resizing.value) return;
resizing.value = false;
document.removeEventListener("mousemove", handleResize);
document.removeEventListener("mouseup", stopResize);
document.body.style.userSelect = "";
document.body.style.cursor = "";
persistPayloadHeight(payloadHeight.value);
}
onMounted(() => {
const savedHeight = Number.parseInt(localStorage.getItem(MQTT_PAYLOAD_HEIGHT_STORAGE_KEY) ?? "", 10);
if (Number.isFinite(savedHeight)) payloadHeight.value = clampPayloadHeight(savedHeight);
window.addEventListener("resize", handlePanelResize);
if (typeof ResizeObserver !== "undefined" && publishPanelRef.value?.parentElement) {
panelResizeObserver = new ResizeObserver(handlePanelResize);
panelResizeObserver.observe(publishPanelRef.value.parentElement);
}
});
onUnmounted(() => {
document.removeEventListener("mousemove", handleResize);
document.removeEventListener("mouseup", stopResize);
window.removeEventListener("resize", handlePanelResize);
panelResizeObserver?.disconnect();
if (resizing.value) {
document.body.style.userSelect = "";
document.body.style.cursor = "";
}
});
async function publish() {
const publishTopic = topic.value.trim();
if (!publishTopic) {
@@ -102,7 +197,20 @@ function clearForm() {
</script>
<template>
<div class="publish-panel">
<div ref="publishPanelRef" class="publish-panel">
<div
class="payload-resize-handle"
role="separator"
tabindex="0"
aria-orientation="horizontal"
:aria-label="t('connection.mqttResizePayload')"
:aria-valuemin="MQTT_PAYLOAD_MIN_HEIGHT_PX"
:aria-valuemax="maxPayloadHeight()"
:aria-valuenow="payloadHeight"
:title="t('connection.mqttResizePayload')"
@mousedown="startResize"
@keydown="handleResizeKeydown"
/>
<div class="panel-header">
<span class="panel-title">{{ t("connection.mqttPublish") }}</span>
<Button size="sm" variant="ghost" class="h-6 text-xs" @click="clearForm">{{ t("common.clear") }}</Button>
@@ -150,7 +258,7 @@ function clearForm() {
<!-- Payload -->
<div class="form-group flex-1 flex flex-col min-h-0">
<label class="form-label">{{ t("connection.mqttPayload") }}</label>
<textarea v-model="payloadText" class="form-textarea flex-1 font-mono text-xs" :placeholder="payloadPlaceholder" rows="4" @keydown.ctrl.enter="publish()" />
<textarea ref="payloadTextareaRef" v-model="payloadText" class="form-textarea font-mono text-xs" :style="{ height: `${payloadHeight}px`, maxHeight: `${maxPayloadHeight()}px` }" :placeholder="payloadPlaceholder" @keydown.ctrl.enter="publish()" />
<span class="form-hint">{{ t("connection.mqttPublishShortcut") }}</span>
</div>
@@ -169,6 +277,7 @@ function clearForm() {
<style scoped>
.publish-panel {
position: relative;
display: flex;
flex-direction: column;
gap: 8px;
@@ -177,6 +286,37 @@ function clearForm() {
background: var(--color-background);
}
.payload-resize-handle {
position: absolute;
top: -4px;
left: 0;
right: 0;
z-index: 1;
height: 9px;
cursor: ns-resize;
}
.payload-resize-handle::before {
content: "";
position: absolute;
top: 3px;
left: 0;
right: 0;
height: 1px;
background-color: var(--color-border);
transition: background-color 0.15s ease;
}
.payload-resize-handle:hover::before,
.payload-resize-handle:focus-visible::before {
background-color: color-mix(in srgb, var(--color-text) 20%, transparent);
}
.payload-resize-handle:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: -2px;
}
.panel-header {
display: flex;
align-items: center;
@@ -240,7 +380,7 @@ function clearForm() {
background: var(--color-background);
color: var(--color-text);
outline: none;
resize: vertical;
resize: none;
min-height: 60px;
box-sizing: border-box;
line-height: 1.4;
@@ -0,0 +1,108 @@
// @vitest-environment happy-dom
import { createApp, nextTick, type App } from "vue";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import i18n, { loadLocaleMessages } from "@/i18n";
import MqttPublishDialog from "@/components/mqtt/MqttPublishDialog.vue";
const { mqttPublishMock } = vi.hoisted(() => ({
mqttPublishMock: vi.fn(),
}));
vi.mock("@/lib/backend/api", () => ({
mqttPublish: mqttPublishMock,
}));
const mountedApps: App[] = [];
async function mountPublishDialog() {
const container = document.createElement("div");
document.body.append(container);
const app = createApp(MqttPublishDialog, {
connectionId: "mqtt-connection-1",
initialTopic: "device/status",
});
mountedApps.push(app);
app.use(i18n);
app.mount(container);
await nextTick();
return container;
}
beforeEach(async () => {
mqttPublishMock.mockReset().mockResolvedValue(undefined);
localStorage.clear();
await loadLocaleMessages("zh-CN");
i18n.global.locale.value = "zh-CN";
});
afterEach(() => {
for (const app of mountedApps.splice(0)) app.unmount();
document.body.innerHTML = "";
localStorage.clear();
});
describe("MQTT 消息发布输入框", () => {
it("支持从顶部拖动调节高度并记住设置", async () => {
const container = await mountPublishDialog();
const handle = container.querySelector<HTMLElement>(".payload-resize-handle");
const textarea = container.querySelector<HTMLTextAreaElement>("textarea");
expect(handle?.getAttribute("aria-label")).toBe("拖动调节消息输入框高度");
expect(handle?.getAttribute("role")).toBe("separator");
expect(handle?.getAttribute("aria-orientation")).toBe("horizontal");
handle?.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, clientY: 300 }));
document.dispatchEvent(new MouseEvent("mousemove", { bubbles: true, clientY: 180 }));
document.dispatchEvent(new MouseEvent("mouseup", { bubbles: true, clientY: 180 }));
await nextTick();
expect(textarea?.style.height).toBe("200px");
expect(localStorage.getItem("dbx-mqtt-payload-height")).toBe("200");
});
it("支持键盘调节高度并记住设置", async () => {
const container = await mountPublishDialog();
const handle = container.querySelector<HTMLElement>(".payload-resize-handle");
const textarea = container.querySelector<HTMLTextAreaElement>("textarea");
expect(handle?.tabIndex).toBe(0);
handle?.focus();
expect(document.activeElement).toBe(handle);
handle?.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, cancelable: true, key: "ArrowUp" }));
await nextTick();
expect(textarea?.style.height).toBe("90px");
expect(handle?.getAttribute("aria-valuenow")).toBe("90");
expect(localStorage.getItem("dbx-mqtt-payload-height")).toBe("90");
handle?.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, cancelable: true, key: "ArrowDown" }));
await nextTick();
expect(textarea?.style.height).toBe("80px");
expect(handle?.getAttribute("aria-valuenow")).toBe("80");
expect(localStorage.getItem("dbx-mqtt-payload-height")).toBe("80");
});
it("将键盘调节限制在分隔条声明的高度范围内", async () => {
const container = await mountPublishDialog();
const handle = container.querySelector<HTMLElement>(".payload-resize-handle");
const textarea = container.querySelector<HTMLTextAreaElement>("textarea");
const minHeight = Number(handle?.getAttribute("aria-valuemin"));
const maxHeight = Number(handle?.getAttribute("aria-valuemax"));
for (let i = 0; i < 100; i++) handle?.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "ArrowDown" }));
await nextTick();
expect(textarea?.style.height).toBe(`${minHeight}px`);
expect(handle?.getAttribute("aria-valuenow")).toBe(minHeight.toString());
expect(localStorage.getItem("dbx-mqtt-payload-height")).toBe(minHeight.toString());
for (let i = 0; i < 100; i++) handle?.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "ArrowUp" }));
await nextTick();
expect(textarea?.style.height).toBe(`${maxHeight}px`);
expect(handle?.getAttribute("aria-valuenow")).toBe(maxHeight.toString());
expect(localStorage.getItem("dbx-mqtt-payload-height")).toBe(maxHeight.toString());
});
});
+1
View File
@@ -804,6 +804,7 @@ export default {
mqttRetainMessage: "Retain message",
mqttPayloadEncoding: "Payload encoding",
mqttPayload: "Payload",
mqttResizePayload: "Drag to resize the message input",
mqttPayloadPlaceholderPlaintext: "Enter message content...",
mqttPayloadPlaceholderJson: "Enter JSON, for example {example}",
mqttPayloadPlaceholderBase64: "Enter Base64-encoded data",
+1
View File
@@ -1036,6 +1036,7 @@ export default withEnglishFallback({
mqttRetainMessage: "Retener mensaje",
mqttPayloadEncoding: "Codificación del Payload",
mqttPayload: "Payload",
mqttResizePayload: "Arrastra para ajustar la altura del campo de mensaje",
mqttPayloadPlaceholderPlaintext: "Introduce el contenido del mensaje...",
mqttPayloadPlaceholderJson: "Introduce JSON, por ejemplo {example}",
mqttPayloadPlaceholderBase64: "Introduce datos codificados en Base64",
+1
View File
@@ -1034,6 +1034,7 @@ export default withEnglishFallback({
mqttRetainMessage: "Mantieni messaggio",
mqttPayloadEncoding: "Codifica del Payload",
mqttPayload: "Payload",
mqttResizePayload: "Trascina per regolare l'altezza del campo messaggio",
mqttPayloadPlaceholderPlaintext: "Inserisci il contenuto del messaggio...",
mqttPayloadPlaceholderJson: "Inserisci JSON, ad esempio {example}",
mqttPayloadPlaceholderBase64: "Inserisci dati codificati in Base64",
+1
View File
@@ -1034,6 +1034,7 @@ export default withEnglishFallback({
mqttRetainMessage: "メッセージを保持",
mqttPayloadEncoding: "ペイロードのエンコード",
mqttPayload: "ペイロード",
mqttResizePayload: "ドラッグしてメッセージ入力欄の高さを調整",
mqttPayloadPlaceholderPlaintext: "メッセージ内容を入力...",
mqttPayloadPlaceholderJson: "JSON を入力(例: {example})",
mqttPayloadPlaceholderBase64: "Base64 エンコード済みデータを入力",
+1
View File
@@ -1035,6 +1035,7 @@ export default withEnglishFallback({
mqttRetainMessage: "Reter mensagem",
mqttPayloadEncoding: "Codificação do Payload",
mqttPayload: "Payload",
mqttResizePayload: "Arraste para ajustar a altura do campo de mensagem",
mqttPayloadPlaceholderPlaintext: "Digite o conteúdo da mensagem...",
mqttPayloadPlaceholderJson: "Digite JSON, por exemplo {example}",
mqttPayloadPlaceholderBase64: "Digite dados codificados em Base64",
+1
View File
@@ -729,6 +729,7 @@ export default withEnglishFallback({
mqttRetainMessage: "保留消息",
mqttPayloadEncoding: "Payload 编码",
mqttPayload: "消息内容 (Payload)",
mqttResizePayload: "拖动调节消息输入框高度",
mqttPayloadPlaceholderPlaintext: "输入消息内容...",
mqttPayloadPlaceholderJson: "输入 JSON,例如 {example}",
mqttPayloadPlaceholderBase64: "输入 Base64 编码的数据",
+1
View File
@@ -1034,6 +1034,7 @@ export default withEnglishFallback({
mqttRetainMessage: "保留訊息",
mqttPayloadEncoding: "Payload 編碼",
mqttPayload: "訊息內容 (Payload)",
mqttResizePayload: "拖曳調整訊息輸入框高度",
mqttPayloadPlaceholderPlaintext: "輸入訊息內容...",
mqttPayloadPlaceholderJson: "輸入 JSON,例如 {example}",
mqttPayloadPlaceholderBase64: "輸入 Base64 編碼的資料",