exchange tast to tactilea

This commit is contained in:
lennlouisgeek
2026-04-03 00:47:36 +08:00
parent a686d19e61
commit 7688986ad7
15 changed files with 1842 additions and 147 deletions

View File

@@ -0,0 +1,833 @@
<script lang="ts">
import { createEventDispatcher, onDestroy, onMount, tick } from "svelte";
import type { FileExplorerEntry, FileExplorerRoot } from "$lib/types/hud";
export let open = false;
export let mode: "open" | "save" = "open";
export let title = "";
export let currentPath = "";
export let parentPath: string | null = null;
export let roots: FileExplorerRoot[] = [];
export let entries: FileExplorerEntry[] = [];
export let selectedPath = "";
export let fileName = "";
export let pathLabel = "Path";
export let fileNameLabel = "File name";
export let cancelLabel = "Cancel";
export let confirmLabel = "Open";
export let emptyHint = "No file entries";
export let csvHint = "*.csv";
export let busyLabel = "Processing...";
export let upLabel = "↑ Up";
export let nameColumnLabel = "Name";
export let sizeColumnLabel = "Size";
export let modifiedColumnLabel = "Modified";
export let isBusy = false;
const dragViewportPadding = 14;
let overlayEl: HTMLDivElement | null = null;
let modalEl: HTMLDivElement | null = null;
let activePointerId: number | null = null;
let dragStartX = 0;
let dragStartY = 0;
let dragOriginX = 0;
let dragOriginY = 0;
let dragModalWidth = 0;
let dragModalHeight = 0;
let modalOffsetX = 0;
let modalOffsetY = 0;
let isDragging = false;
let wasOpen = false;
const dispatch = createEventDispatcher<{
close: void;
navigate: string;
confirm: void;
}>();
$: selectedEntry = entries.find((entry) => entry.path === selectedPath) ?? null;
$: canConfirm =
mode === "open"
? Boolean(selectedEntry && !selectedEntry.isDir && !isBusy)
: Boolean(fileName.trim().length > 0 && !isBusy);
$: if (open && !wasOpen) {
wasOpen = true;
modalOffsetX = 0;
modalOffsetY = 0;
stopDrag();
void tick().then(() => clampModalOffset());
}
$: if (!open && wasOpen) {
wasOpen = false;
stopDrag();
}
function formatFileSize(value: number | null | undefined): string {
if (!Number.isFinite(value ?? Number.NaN) || value == null) {
return "--";
}
if (value < 1024) {
return `${value} B`;
}
if (value < 1024 * 1024) {
return `${(value / 1024).toFixed(1)} KB`;
}
return `${(value / (1024 * 1024)).toFixed(1)} MB`;
}
function formatModifiedTime(value: number | null | undefined): string {
if (!Number.isFinite(value ?? Number.NaN) || value == null) {
return "--";
}
try {
return new Date(Number(value)).toLocaleString();
} catch {
return "--";
}
}
function navigate(path: string): void {
if (!path || isBusy) {
return;
}
dispatch("navigate", path);
}
function selectEntry(entry: FileExplorerEntry): void {
selectedPath = entry.path;
if (mode === "save" && !entry.isDir) {
fileName = entry.name;
}
}
function activateEntry(entry: FileExplorerEntry): void {
if (entry.isDir) {
navigate(entry.path);
return;
}
selectedPath = entry.path;
if (mode === "save") {
fileName = entry.name;
return;
}
if (canConfirm) {
dispatch("confirm");
}
}
function closeModal(): void {
if (isBusy) {
return;
}
dispatch("close");
}
function confirmSelection(): void {
if (!canConfirm) {
return;
}
dispatch("confirm");
}
function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value));
}
function resolveDragRange(modalSize: number, viewportSize: number): { min: number; max: number } {
const centeredGap = (viewportSize - modalSize) / 2;
const min = dragViewportPadding - centeredGap;
const max = centeredGap - dragViewportPadding;
if (!Number.isFinite(min) || !Number.isFinite(max) || min > max) {
return { min: 0, max: 0 };
}
return { min, max };
}
function clampModalOffset(): void {
if (!open || !modalEl) {
return;
}
const viewportWidth = overlayEl?.clientWidth ?? window.innerWidth;
const viewportHeight = overlayEl?.clientHeight ?? window.innerHeight;
const rect = modalEl.getBoundingClientRect();
const xRange = resolveDragRange(rect.width, viewportWidth);
const yRange = resolveDragRange(rect.height, viewportHeight);
modalOffsetX = clamp(modalOffsetX, xRange.min, xRange.max);
modalOffsetY = clamp(modalOffsetY, yRange.min, yRange.max);
}
function stopDrag(): void {
activePointerId = null;
isDragging = false;
window.removeEventListener("pointermove", handlePointerMove);
window.removeEventListener("pointerup", handlePointerUp);
window.removeEventListener("pointercancel", handlePointerUp);
}
function handlePointerMove(event: PointerEvent): void {
if (activePointerId == null || event.pointerId !== activePointerId) {
return;
}
event.preventDefault();
const viewportWidth = overlayEl?.clientWidth ?? window.innerWidth;
const viewportHeight = overlayEl?.clientHeight ?? window.innerHeight;
const xRange = resolveDragRange(dragModalWidth, viewportWidth);
const yRange = resolveDragRange(dragModalHeight, viewportHeight);
const rawX = dragOriginX + (event.clientX - dragStartX);
const rawY = dragOriginY + (event.clientY - dragStartY);
modalOffsetX = clamp(rawX, xRange.min, xRange.max);
modalOffsetY = clamp(rawY, yRange.min, yRange.max);
isDragging = true;
}
function handlePointerUp(event: PointerEvent): void {
if (activePointerId == null || event.pointerId !== activePointerId) {
return;
}
stopDrag();
}
function startDrag(event: PointerEvent): void {
if (event.button !== 0 || !event.isPrimary || !modalEl) {
return;
}
if (event.target instanceof HTMLElement && event.target.closest("button, input, select, textarea, a")) {
return;
}
const rect = modalEl.getBoundingClientRect();
dragModalWidth = rect.width;
dragModalHeight = rect.height;
dragStartX = event.clientX;
dragStartY = event.clientY;
dragOriginX = modalOffsetX;
dragOriginY = modalOffsetY;
activePointerId = event.pointerId;
isDragging = true;
window.addEventListener("pointermove", handlePointerMove, { passive: false });
window.addEventListener("pointerup", handlePointerUp);
window.addEventListener("pointercancel", handlePointerUp);
}
function handleViewportResize(): void {
clampModalOffset();
}
onMount(() => {
window.addEventListener("resize", handleViewportResize);
return () => {
window.removeEventListener("resize", handleViewportResize);
stopDrag();
};
});
onDestroy(() => {
stopDrag();
});
</script>
{#if open}
<div
bind:this={overlayEl}
class="explorer-overlay"
role="presentation"
on:click={(event) => {
if (event.target === event.currentTarget) {
closeModal();
}
}}
>
<div
bind:this={modalEl}
class="explorer-modal"
class:is-dragging={isDragging}
role="dialog"
aria-modal="true"
aria-label={title}
tabindex="-1"
style={`--explorer-drag-x: ${modalOffsetX}px; --explorer-drag-y: ${modalOffsetY}px;`}
on:keydown={(event) => {
if (event.key === "Escape") {
event.preventDefault();
closeModal();
}
}}
>
<header
class="explorer-header"
role="toolbar"
tabindex="-1"
aria-label="Dialog drag bar"
on:pointerdown={startDrag}
>
<div class="explorer-title-wrap">
<span class="title-pulse" aria-hidden="true"></span>
<h3 class="explorer-title">{title}</h3>
</div>
<button type="button" class="header-close-btn" aria-label="Close" on:click={closeModal}>×</button>
</header>
<div class="explorer-toolbar">
<button
type="button"
class="tool-btn"
disabled={!parentPath || isBusy}
on:click={() => parentPath && navigate(parentPath)}
>
{upLabel}
</button>
<div class="path-field" title={currentPath}>
<span class="path-label">{pathLabel}</span>
<span class="path-value">{currentPath}</span>
</div>
</div>
<div class="explorer-content">
<aside class="roots-list" aria-label="Roots">
{#each roots as root (root.path)}
<button
type="button"
class="root-btn"
class:is-active={currentPath === root.path}
on:click={() => navigate(root.path)}
>
{root.label}
</button>
{/each}
</aside>
<section class="entries-wrap" aria-label="Entries">
<div class="entries-head">
<span>{nameColumnLabel}</span>
<span>{sizeColumnLabel}</span>
<span>{modifiedColumnLabel}</span>
</div>
{#if entries.length === 0}
<p class="entries-empty">{emptyHint}</p>
{:else}
<div class="entries-body">
{#each entries as entry (entry.path)}
<button
type="button"
class="entry-row"
class:is-selected={entry.path === selectedPath}
on:click={() => selectEntry(entry)}
on:dblclick={() => activateEntry(entry)}
>
<span class="entry-name">
<span class="entry-icon" aria-hidden="true">{entry.isDir ? "DIR" : "CSV"}</span>
<span class="entry-text">{entry.name}</span>
</span>
<span class="entry-size">{entry.isDir ? "--" : formatFileSize(entry.sizeBytes)}</span>
<span class="entry-time">{formatModifiedTime(entry.modifiedMs)}</span>
</button>
{/each}
</div>
{/if}
</section>
</div>
<footer class="explorer-footer">
{#if mode === "save"}
<label class="name-input-wrap">
<span class="name-label">{fileNameLabel}</span>
<input
class="name-input"
type="text"
bind:value={fileName}
placeholder="joyson_export.csv"
autocomplete="off"
/>
</label>
{:else}
<p class="csv-hint">{csvHint}</p>
{/if}
<div class="footer-actions">
<button type="button" class="action-btn cancel" disabled={isBusy} on:click={closeModal}>{cancelLabel}</button>
<button type="button" class="action-btn confirm" disabled={!canConfirm} on:click={confirmSelection}>
{isBusy ? busyLabel : confirmLabel}
</button>
</div>
</footer>
</div>
</div>
{/if}
<style>
.explorer-overlay {
position: absolute;
inset: 0;
z-index: 20;
display: grid;
place-items: center;
background:
radial-gradient(circle at 30% 12%, rgb(62 232 255 / 0.08), transparent 42%),
radial-gradient(circle at 84% 10%, rgb(133 255 68 / 0.07), transparent 40%),
rgb(0 0 0 / 0.6);
backdrop-filter: blur(3px);
padding: clamp(0.65rem, 2.4vw, 1.25rem);
}
.explorer-modal {
position: relative;
display: grid;
grid-template-rows: auto auto minmax(0, 1fr) auto;
inline-size: min(1020px, 100%);
block-size: min(720px, 100%);
max-inline-size: calc(100% - 2 * clamp(0.65rem, 2.4vw, 1.25rem));
max-block-size: calc(100% - 2 * clamp(0.65rem, 2.4vw, 1.25rem));
border: 1px solid rgb(95 132 158 / 0.34);
border-radius: 0.72rem;
background:
linear-gradient(172deg, rgb(8 12 16 / 0.96) 0%, rgb(4 8 12 / 0.96) 52%, rgb(3 6 10 / 0.98) 100%),
radial-gradient(circle at 18% 0, rgb(62 232 255 / 0.06), transparent 42%),
radial-gradient(circle at 90% 0, rgb(133 255 68 / 0.05), transparent 38%);
box-shadow:
inset 0 1px 0 rgb(192 221 240 / 0.08),
0 22px 50px rgb(0 0 0 / 0.52);
overflow: hidden;
transform: translate3d(var(--explorer-drag-x, 0), var(--explorer-drag-y, 0), 0);
}
.explorer-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.65rem;
padding: 0.72rem 0.85rem 0.65rem;
border-bottom: 1px solid rgb(95 132 158 / 0.28);
background: linear-gradient(180deg, rgb(16 25 32 / 0.6), transparent);
cursor: grab;
user-select: none;
touch-action: none;
}
.explorer-modal.is-dragging .explorer-header {
cursor: grabbing;
}
.explorer-title-wrap {
display: inline-flex;
align-items: center;
gap: 0.5rem;
min-width: 0;
}
.title-pulse {
inline-size: 0.5rem;
block-size: 0.5rem;
border-radius: 999px;
background: var(--hud-lime);
box-shadow: 0 0 0 2px rgb(133 255 68 / 0.14);
}
.explorer-title {
margin: 0;
color: #ecf9ff;
font-size: 0.92rem;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.header-close-btn {
inline-size: 2rem;
block-size: 1.64rem;
border: 1px solid rgb(98 131 156 / 0.36);
border-radius: 0.36rem;
background: rgb(8 13 18 / 0.9);
color: rgb(174 219 244 / 0.9);
font-size: 1rem;
line-height: 1;
cursor: pointer;
transition:
border-color 180ms ease,
color 180ms ease;
}
.header-close-btn:hover {
border-color: rgb(255 91 63 / 0.6);
color: rgb(255 208 198 / 0.96);
}
.explorer-toolbar {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 0.58rem;
padding: 0.62rem 0.85rem;
border-bottom: 1px solid rgb(95 132 158 / 0.22);
background: linear-gradient(90deg, rgb(62 232 255 / 0.03), transparent 44%, rgb(133 255 68 / 0.02));
}
.tool-btn {
min-block-size: 1.95rem;
border: 1px solid rgb(95 132 158 / 0.36);
border-radius: 0.42rem;
padding: 0.25rem 0.65rem;
background: rgb(9 16 21 / 0.86);
color: rgb(213 233 245 / 0.94);
font-size: 0.72rem;
letter-spacing: 0.05em;
cursor: pointer;
transition:
border-color 180ms ease,
box-shadow 180ms ease,
opacity 180ms ease;
}
.tool-btn:hover:not(:disabled) {
border-color: rgb(62 232 255 / 0.46);
box-shadow: inset 0 0 0 1px rgb(178 216 239 / 0.08);
}
.tool-btn:disabled {
opacity: 0.58;
cursor: default;
}
.path-field {
min-width: 0;
display: grid;
grid-template-columns: auto 1fr;
align-items: center;
gap: 0.45rem;
border: 1px solid rgb(95 132 158 / 0.32);
border-radius: 0.42rem;
padding: 0.25rem 0.55rem;
background: rgb(8 14 18 / 0.76);
}
.path-label {
color: rgb(140 163 181 / 0.84);
font-size: 0.63rem;
letter-spacing: 0.08em;
text-transform: uppercase;
white-space: nowrap;
}
.path-value {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: rgb(225 243 255 / 0.97);
font-size: 0.76rem;
letter-spacing: 0.03em;
}
.explorer-content {
min-height: 0;
display: grid;
grid-template-columns: 200px minmax(0, 1fr);
gap: 0.62rem;
padding: 0.72rem 0.85rem;
}
.roots-list {
min-height: 0;
display: grid;
grid-auto-rows: min-content;
gap: 0.35rem;
border: 1px solid rgb(95 132 158 / 0.28);
border-radius: 0.5rem;
padding: 0.42rem;
background: rgb(7 13 18 / 0.78);
overflow: auto;
}
.root-btn {
border: 1px solid transparent;
border-radius: 0.34rem;
padding: 0.35rem 0.45rem;
text-align: left;
background: transparent;
color: rgb(167 189 208 / 0.94);
font-size: 0.74rem;
letter-spacing: 0.04em;
cursor: pointer;
transition:
border-color 180ms ease,
background-color 180ms ease,
color 180ms ease;
}
.root-btn:hover {
border-color: rgb(62 232 255 / 0.3);
color: #e5f5ff;
}
.root-btn.is-active {
border-color: rgb(133 255 68 / 0.46);
background: rgb(24 33 22 / 0.7);
color: rgb(237 255 228 / 0.98);
}
.entries-wrap {
min-height: 0;
display: grid;
grid-template-rows: auto minmax(0, 1fr);
border: 1px solid rgb(95 132 158 / 0.28);
border-radius: 0.5rem;
overflow: hidden;
background: rgb(6 11 16 / 0.78);
}
.entries-head {
display: grid;
grid-template-columns: minmax(0, 1fr) 110px 180px;
gap: 0.45rem;
padding: 0.44rem 0.55rem;
border-bottom: 1px solid rgb(95 132 158 / 0.24);
color: rgb(141 164 183 / 0.88);
font-size: 0.62rem;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.entries-empty {
margin: 0;
display: grid;
place-items: center;
color: rgb(148 171 187 / 0.86);
font-size: 0.75rem;
letter-spacing: 0.03em;
}
.entries-body {
min-height: 0;
overflow: auto;
padding: 0.18rem;
display: grid;
grid-auto-rows: min-content;
gap: 0.18rem;
}
.entry-row {
display: grid;
grid-template-columns: minmax(0, 1fr) 110px 180px;
gap: 0.45rem;
align-items: center;
border: 1px solid transparent;
border-radius: 0.32rem;
padding: 0.32rem 0.4rem;
background: transparent;
color: rgb(204 227 243 / 0.94);
cursor: pointer;
text-align: left;
transition:
border-color 160ms ease,
background-color 160ms ease;
}
.entry-row:hover {
border-color: rgb(62 232 255 / 0.26);
background: rgb(11 18 24 / 0.56);
}
.entry-row.is-selected {
border-color: rgb(133 255 68 / 0.46);
background:
linear-gradient(180deg, rgb(24 33 22 / 0.86), rgb(14 21 14 / 0.78)),
radial-gradient(circle at 6% 50%, rgb(133 255 68 / 0.15), transparent 58%);
box-shadow: inset 0 0 0 1px rgb(230 255 220 / 0.06);
}
.entry-name {
min-width: 0;
display: inline-flex;
align-items: center;
gap: 0.45rem;
}
.entry-icon {
inline-size: 2.15rem;
block-size: 1.2rem;
border: 1px solid rgb(95 132 158 / 0.36);
border-radius: 0.22rem;
display: grid;
place-items: center;
color: rgb(150 177 198 / 0.9);
font-size: 0.54rem;
letter-spacing: 0.08em;
background: rgb(9 16 22 / 0.72);
flex-shrink: 0;
}
.entry-row.is-selected .entry-icon {
border-color: rgb(133 255 68 / 0.44);
color: rgb(214 252 190 / 0.95);
}
.entry-text {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 0.76rem;
letter-spacing: 0.03em;
}
.entry-size,
.entry-time {
color: rgb(152 176 194 / 0.88);
font-size: 0.68rem;
letter-spacing: 0.03em;
white-space: nowrap;
text-align: right;
}
.explorer-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.7rem;
border-top: 1px solid rgb(95 132 158 / 0.24);
padding: 0.68rem 0.85rem 0.76rem;
background: linear-gradient(0deg, rgb(5 10 14 / 0.72), transparent);
}
.name-input-wrap {
min-width: 0;
display: grid;
grid-template-columns: auto minmax(0, 1fr);
align-items: center;
gap: 0.45rem;
flex: 1;
}
.name-label {
color: rgb(140 163 181 / 0.86);
font-size: 0.66rem;
letter-spacing: 0.08em;
text-transform: uppercase;
white-space: nowrap;
}
.name-input {
min-inline-size: 0;
border: 1px solid rgb(95 132 158 / 0.36);
border-radius: 0.36rem;
padding: 0.4rem 0.55rem;
background: rgb(8 14 19 / 0.8);
color: rgb(223 242 255 / 0.97);
font-size: 0.76rem;
letter-spacing: 0.03em;
outline: none;
transition:
border-color 170ms ease,
box-shadow 170ms ease;
}
.name-input:focus-visible {
border-color: rgb(62 232 255 / 0.52);
box-shadow: 0 0 0 2px rgb(62 232 255 / 0.14);
}
.csv-hint {
margin: 0;
color: rgb(150 173 189 / 0.9);
font-size: 0.7rem;
letter-spacing: 0.04em;
}
.footer-actions {
display: inline-flex;
align-items: center;
gap: 0.42rem;
}
.action-btn {
min-block-size: 2rem;
border-radius: 999px;
padding: 0.28rem 0.78rem;
font-size: 0.74rem;
letter-spacing: 0.05em;
cursor: pointer;
transition:
border-color 180ms ease,
opacity 160ms ease,
box-shadow 180ms ease;
}
.action-btn.cancel {
border: 1px solid rgb(95 132 158 / 0.36);
background: rgb(9 16 21 / 0.86);
color: rgb(206 228 244 / 0.94);
}
.action-btn.cancel:hover:not(:disabled) {
border-color: rgb(122 198 255 / 0.48);
}
.action-btn.confirm {
border: 1px solid rgb(133 255 68 / 0.48);
background:
linear-gradient(180deg, rgb(25 35 23 / 0.96), rgb(13 20 13 / 0.92)),
radial-gradient(circle at 50% 0, rgb(133 255 68 / 0.14), transparent 58%);
color: rgb(240 255 233 / 0.98);
}
.action-btn.confirm:hover:not(:disabled) {
border-color: rgb(176 255 132 / 0.62);
box-shadow: 0 0 10px rgb(133 255 68 / 0.14);
}
.action-btn:disabled {
opacity: 0.55;
cursor: default;
}
@media (max-width: 900px) {
.explorer-content {
grid-template-columns: 1fr;
grid-template-rows: 140px minmax(0, 1fr);
}
.roots-list {
grid-auto-flow: column;
grid-auto-columns: minmax(120px, 1fr);
overflow-x: auto;
overflow-y: hidden;
}
}
@media (max-width: 640px) {
.explorer-modal {
block-size: min(760px, 100%);
}
.entries-head,
.entry-row {
grid-template-columns: minmax(0, 1fr) 90px;
}
.entries-head span:last-child,
.entry-time {
display: none;
}
.explorer-footer {
flex-direction: column;
align-items: stretch;
}
.footer-actions {
justify-content: flex-end;
}
}
</style>

View File

@@ -41,7 +41,6 @@
export let isExporting = false;
export let isExportDisabled = false;
export let isWindowMaximized = false;
let csvInputEl: HTMLInputElement | undefined;
const dispatch = createEventDispatcher<{
windowcontrol: WindowControlAction;
@@ -51,7 +50,8 @@
serialrefresh: void;
serialconnect: string;
serialexport: void;
csvimport: File;
csvimport: void;
noticeclear: void;
}>();
const connectionToneByState: Record<ConnectionState, "ok" | "warn" | "idle"> = {
@@ -106,17 +106,12 @@
dispatch("serialexport");
}
function openCsvPicker(): void {
csvInputEl?.click();
function emitCsvImport(): void {
dispatch("csvimport");
}
function emitCsvImport(event: Event): void {
const target = event.currentTarget as HTMLInputElement;
const file = target.files?.[0];
if (file) {
dispatch("csvimport", file);
}
target.value = "";
function emitNoticeClear(): void {
dispatch("noticeclear");
}
</script>
@@ -246,7 +241,7 @@
<span>{exportButtonText}</span>
</button>
<button type="button" class="import-btn" on:click={openCsvPicker}>
<button type="button" class="import-btn" on:click={emitCsvImport}>
<svg viewBox="0 0 16 16" aria-hidden="true">
<path d="M8 10.8V3.6"></path>
<path d="M5.4 6.3L8 3.6l2.6 2.7"></path>
@@ -254,13 +249,6 @@
</svg>
<span>{importActionLabel}</span>
</button>
<input
bind:this={csvInputEl}
class="hidden-input"
type="file"
accept=".csv,text/csv"
on:change={emitCsvImport}
/>
<section class="locale-switch" aria-label="Language">
<button
@@ -283,9 +271,17 @@
</div>
{#if connectionNotice}
<p class="connection-notice tone-{connectionNoticeTone}" role={connectionNoticeTone === "warn" ? "alert" : "status"}>
{connectionNotice}
</p>
<div class="connection-notice tone-{connectionNoticeTone}" role={connectionNoticeTone === "warn" ? "alert" : "status"}>
<p class="connection-notice-text">{connectionNotice}</p>
<button
type="button"
class="notice-close-btn"
aria-label={locale === "zh-CN" ? "关闭提示" : "Dismiss notice"}
on:click={emitNoticeClear}
>
×
</button>
</div>
{/if}
<section class="info-grid">
@@ -724,20 +720,22 @@
0 0 12px rgb(122 198 255 / 0.14);
}
.hidden-input {
position: absolute;
inline-size: 0;
block-size: 0;
opacity: 0;
pointer-events: none;
}
.connection-notice {
margin: 0;
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.6rem;
border: 1px solid rgb(95 132 158 / 0.32);
border-radius: 0.5rem;
padding: 0.45rem 0.7rem;
padding: 0.38rem 0.45rem 0.38rem 0.7rem;
background: rgb(8 14 19 / 0.72);
}
.connection-notice-text {
margin: 0;
flex: 1;
min-width: 0;
color: rgb(214 236 248 / 0.96);
font-size: 0.72rem;
letter-spacing: 0.03em;
@@ -758,7 +756,41 @@
.connection-notice.tone-info {
border-color: rgb(62 232 255 / 0.34);
background: rgb(8 17 22 / 0.76);
color: rgb(214 236 248 / 0.96);
}
.notice-close-btn {
inline-size: 1.36rem;
block-size: 1.36rem;
border: 1px solid rgb(116 151 176 / 0.4);
border-radius: 0.28rem;
background: rgb(7 12 16 / 0.82);
color: rgb(194 225 245 / 0.92);
font-size: 0.92rem;
line-height: 1;
cursor: pointer;
flex-shrink: 0;
transition:
border-color 180ms ease,
color 180ms ease,
background-color 180ms ease;
}
.notice-close-btn:hover {
border-color: rgb(62 232 255 / 0.5);
color: rgb(237 250 255 / 0.98);
background: rgb(9 16 22 / 0.92);
}
.connection-notice.tone-warn .notice-close-btn:hover {
border-color: rgb(255 91 63 / 0.6);
color: rgb(255 227 220 / 0.98);
background: rgb(34 13 12 / 0.9);
}
.connection-notice.tone-ok .notice-close-btn:hover {
border-color: rgb(133 255 68 / 0.56);
color: rgb(236 255 227 / 0.98);
background: rgb(17 28 14 / 0.9);
}
.info-grid {

View File

@@ -49,8 +49,8 @@
const MAX_LABEL_SCALE = 2.45;
const MATRIX_OFFSET_Y = -2.4;
const MATRIX_OFFSET_Z = 12;
const HEIGHT_SCALE = 18.5;
const BASE_HEIGHT = 0.18;
const HEIGHT_SCALE = 10.6;
const BASE_HEIGHT = 0.12;
const GLOW_START = 0.3;
const SMOOTHING_SPEED = 8.2;
const CAMERA_FOV = 36;
@@ -152,7 +152,7 @@
}
function shapeHeightValue(valueNormalized: number): number {
return Math.pow(clamp(valueNormalized, 0, 1), 0.74);
return Math.pow(clamp(valueNormalized, 0, 1), 0.9);
}
function shapeGlowStrength(valueNormalized: number): number {
@@ -170,7 +170,7 @@
const gridSpan = Math.max(boardWidth, boardDepth) + boardPadding * 2;
const gridDivisions = Math.round(clamp(longestEdge * 1.6, MIN_GRID_DIVISIONS, MAX_GRID_DIVISIONS));
const labelScale = clamp(24 / (longestEdge + 2), MIN_LABEL_SCALE, MAX_LABEL_SCALE);
const labelFloatOffset = clamp(cellSpacing * 0.74, 0.64, 2.2);
const labelFloatOffset = clamp(cellSpacing * 0.42, 0.36, 1.12);
return {
cellSpacing,

View File

@@ -99,6 +99,20 @@ export interface HudCopy {
exportActionLabel: string;
exportingActionLabel: string;
importActionLabel: string;
fileExplorerImportTitle: string;
fileExplorerExportTitle: string;
fileExplorerPathLabel: string;
fileExplorerNameLabel: string;
fileExplorerCancelLabel: string;
fileExplorerOpenLabel: string;
fileExplorerSaveLabel: string;
fileExplorerEmptyHint: string;
fileExplorerCsvHint: string;
fileExplorerLoadingLabel: string;
fileExplorerUpLabel: string;
fileExplorerNameColumnLabel: string;
fileExplorerSizeColumnLabel: string;
fileExplorerModifiedColumnLabel: string;
replaySectionLabel: string;
replayPlayLabel: string;
replayPauseLabel: string;
@@ -131,6 +145,11 @@ export interface SerialExportResult {
message: string;
}
export interface SerialRecordStateResult {
hasData: boolean;
frameCount: number;
}
export interface SerialImportFrameResult {
data: number[];
dtsMs: number;
@@ -143,3 +162,23 @@ export interface SerialImportResult {
frames: SerialImportFrameResult[];
message: string;
}
export interface FileExplorerRoot {
label: string;
path: string;
}
export interface FileExplorerEntry {
name: string;
path: string;
isDir: boolean;
sizeBytes: number | null;
modifiedMs: number | null;
}
export interface FileExplorerListResult {
currentPath: string;
parentPath: string | null;
roots: FileExplorerRoot[];
entries: FileExplorerEntry[];
}

View File

@@ -5,10 +5,14 @@
import { LogicalSize, currentMonitor, getCurrentWindow } from "@tauri-apps/api/window";
import HudPanel from "$lib/components/HudPanel.svelte";
import CenterStage from "$lib/components/CenterStage.svelte";
import FileExplorerModal from "$lib/components/FileExplorerModal.svelte";
import { pressureColorPalettes } from "$lib/config/color-map";
import "$lib/styles/theme.css";
import type {
ConnectionState,
FileExplorerEntry,
FileExplorerListResult,
FileExplorerRoot,
HudColorMapOption,
HudCopy,
HudConfigLink,
@@ -21,6 +25,7 @@
LocaleCode,
SerialConnectResult,
SerialExportResult,
SerialRecordStateResult,
SerialImportResult,
SignalTone,
StageStatusTone,
@@ -28,6 +33,8 @@
} from "$lib/types/hud";
type SignalPanelTemplate = Pick<HudSignalPanel, "id" | "code" | "title" | "side">;
type FileExplorerMode = "open" | "save";
interface ReplayFrame {
values: number[];
dtsMs: number;
@@ -65,6 +72,20 @@
exportActionLabel: "导出 CSV",
exportingActionLabel: "导出中",
importActionLabel: "导入 CSV",
fileExplorerImportTitle: "导入 CSV 文件",
fileExplorerExportTitle: "导出 CSV 文件",
fileExplorerPathLabel: "路径",
fileExplorerNameLabel: "文件名",
fileExplorerCancelLabel: "取消",
fileExplorerOpenLabel: "打开",
fileExplorerSaveLabel: "保存",
fileExplorerEmptyHint: "当前目录下没有可用条目",
fileExplorerCsvHint: "仅显示 *.csv 文件",
fileExplorerLoadingLabel: "处理中...",
fileExplorerUpLabel: "↑ 上一级",
fileExplorerNameColumnLabel: "名称",
fileExplorerSizeColumnLabel: "大小",
fileExplorerModifiedColumnLabel: "修改时间",
replaySectionLabel: "回放",
replayPlayLabel: "播放",
replayPauseLabel: "暂停",
@@ -107,6 +128,20 @@
exportActionLabel: "Export CSV",
exportingActionLabel: "Exporting",
importActionLabel: "Import CSV",
fileExplorerImportTitle: "Import CSV File",
fileExplorerExportTitle: "Export CSV File",
fileExplorerPathLabel: "Path",
fileExplorerNameLabel: "File Name",
fileExplorerCancelLabel: "Cancel",
fileExplorerOpenLabel: "Open",
fileExplorerSaveLabel: "Save",
fileExplorerEmptyHint: "No entries in this directory",
fileExplorerCsvHint: "Only *.csv files are listed",
fileExplorerLoadingLabel: "Processing...",
fileExplorerUpLabel: "↑ Up",
fileExplorerNameColumnLabel: "Name",
fileExplorerSizeColumnLabel: "Size",
fileExplorerModifiedColumnLabel: "Modified",
replaySectionLabel: "Replay",
replayPlayLabel: "Play",
replayPauseLabel: "Pause",
@@ -186,6 +221,15 @@
let replayProgress = 0;
let replayFileName = "";
let replayTimerId: number | null = null;
let fileExplorerOpen = false;
let fileExplorerMode: FileExplorerMode = "open";
let fileExplorerBusy = false;
let fileExplorerCurrentPath = "";
let fileExplorerParentPath: string | null = null;
let fileExplorerEntries: FileExplorerEntry[] = [];
let fileExplorerRoots: FileExplorerRoot[] = [];
let fileExplorerSelectedPath = "";
let fileExplorerFileName = "";
$: uiCopy = copyByLocale[locale];
$: configLinks = buildConfigLinks(locale, activeConfigLinkId, isConfigPanelOpen);
@@ -197,6 +241,10 @@
$: rangeScaleStyle = buildRangeScaleStyle(colorMapPreset);
$: replayHasData = replayFrames.length > 0;
$: replayFrameInfo = replayHasData ? `${replayHasDisplayedFrame ? replayCurrentIndex + 1 : 0}/${replayFrames.length}` : "";
$: fileExplorerTitle =
fileExplorerMode === "open" ? uiCopy.fileExplorerImportTitle : uiCopy.fileExplorerExportTitle;
$: fileExplorerConfirmLabel =
fileExplorerMode === "open" ? uiCopy.fileExplorerOpenLabel : uiCopy.fileExplorerSaveLabel;
function isTauriRuntime(): boolean {
return typeof window !== "undefined" && "__TAURI_INTERNALS__" in window;
@@ -353,6 +401,209 @@
return frames;
}
function buildDefaultExportName(): string {
const now = new Date();
const pad = (value: number) => String(value).padStart(2, "0");
return `joyson_export_${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}_${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}.csv`;
}
function ensureCsvSuffix(fileName: string): string {
const trimmed = fileName.trim();
if (!trimmed) {
return "";
}
return trimmed.toLowerCase().endsWith(".csv") ? trimmed : `${trimmed}.csv`;
}
function inferPathSeparator(path: string): string {
return path.includes("\\") ? "\\" : "/";
}
function joinPath(parent: string, fileName: string): string {
const safeParent = parent.trim();
if (!safeParent) {
return fileName;
}
const separator = inferPathSeparator(safeParent);
if (safeParent.endsWith(separator)) {
return `${safeParent}${fileName}`;
}
return `${safeParent}${separator}${fileName}`;
}
function isCsvPath(path: string): boolean {
return path.toLowerCase().endsWith(".csv");
}
function applyImportedFrames(fileName: string, frames: ReplayFrame[], frameCount: number, channelCount: number): void {
if (!frames.length) {
throw new Error("EmptyReplayData");
}
replayFrames = frames;
replayFileName = fileName;
replayCurrentIndex = 0;
replayHasDisplayedFrame = false;
replayProgress = 0;
resetReplayVisualState();
connectionNotice =
locale === "zh-CN"
? `已导入 ${fileName},共 ${frameCount} 帧 / ${channelCount} 通道,可开始回放。`
: `${fileName} loaded (${frameCount} frames / ${channelCount} channels). Ready to replay.`;
connectionNoticeTone = "ok";
}
async function loadFileExplorerDirectory(path?: string): Promise<void> {
if (!isTauriRuntime()) {
return;
}
fileExplorerBusy = true;
try {
const result = await invoke<FileExplorerListResult>("file_explorer_list", {
path,
extensions: fileExplorerMode === "open" ? ["csv"] : undefined
});
fileExplorerCurrentPath = result.currentPath;
fileExplorerParentPath = result.parentPath;
fileExplorerRoots = result.roots;
fileExplorerEntries = result.entries;
const selectedExists = fileExplorerEntries.some((entry) => entry.path === fileExplorerSelectedPath);
if (!selectedExists) {
fileExplorerSelectedPath = "";
}
} catch (error) {
connectionNotice =
locale === "zh-CN" ? "文件浏览器加载失败,请检查目录权限。" : "File explorer failed to load. Check directory permissions.";
connectionNoticeTone = "warn";
console.error("File explorer load failed:", error);
} finally {
fileExplorerBusy = false;
}
}
async function openFileExplorer(mode: FileExplorerMode): Promise<void> {
if (!isTauriRuntime()) {
if (mode === "open") {
await importViaBrowserInput();
return;
}
await runSerialExport();
return;
}
fileExplorerMode = mode;
fileExplorerOpen = true;
fileExplorerBusy = false;
fileExplorerSelectedPath = "";
if (mode === "save") {
fileExplorerFileName = buildDefaultExportName();
} else {
fileExplorerFileName = "";
}
await loadFileExplorerDirectory(fileExplorerCurrentPath || undefined);
}
function closeFileExplorer(): void {
if (fileExplorerBusy) {
return;
}
fileExplorerOpen = false;
}
async function importViaBrowserInput(): Promise<void> {
if (typeof document === "undefined") {
return;
}
const input = document.createElement("input");
input.type = "file";
input.accept = ".csv,text/csv";
const selectedFile = await new Promise<File | null>((resolve) => {
input.onchange = () => resolve(input.files?.[0] ?? null);
input.click();
});
if (!selectedFile) {
return;
}
await importReplayFromFile(selectedFile);
}
async function importReplayFromFile(file: File): Promise<boolean> {
if (!file) {
return false;
}
pauseReplayPlayback();
try {
const text = await file.text();
let frames: ReplayFrame[] = [];
let importedFrameCount = 0;
let importedChannelCount = 0;
if (isTauriRuntime()) {
const result = await invoke<SerialImportResult>("serial_import_csv", {
fileName: file.name,
csvContent: text
});
frames = result.frames.map((frame) => ({
values: frame.data,
dtsMs: frame.dtsMs
}));
importedFrameCount = result.frameCount;
importedChannelCount = result.channelCount;
} else {
frames = parseReplayCsv(text);
importedFrameCount = frames.length;
importedChannelCount = frames[0]?.values.length ?? 0;
}
applyImportedFrames(file.name, frames, importedFrameCount, importedChannelCount);
return true;
} catch (error) {
connectionNotice = resolveImportNotice(error);
connectionNoticeTone = "warn";
console.error("Replay import failed:", error);
return false;
}
}
async function importReplayFromPath(path: string): Promise<boolean> {
pauseReplayPlayback();
try {
const result = await invoke<SerialImportResult>("serial_import_csv_from_path", {
filePath: path
});
const frames = result.frames.map((frame) => ({
values: frame.data,
dtsMs: frame.dtsMs
}));
applyImportedFrames(result.fileName, frames, result.frameCount, result.channelCount);
return true;
} catch (error) {
connectionNotice = resolveImportNotice(error);
connectionNoticeTone = "warn";
console.error("Replay import failed:", error);
return false;
}
}
function stopReplayTimer(): void {
if (replayTimerId == null || typeof window === "undefined") {
return;
@@ -980,81 +1231,118 @@
}
}
async function handleSerialExport(): Promise<void> {
async function runSerialExport(filePath?: string): Promise<boolean> {
if (!isTauriRuntime()) {
console.warn("[serial] Export is only available inside Tauri.");
return;
connectionNotice =
locale === "zh-CN" ? "当前环境不支持导出到本地路径。" : "Current runtime cannot export to local paths.";
connectionNoticeTone = "warn";
return false;
}
isExporting = true;
fileExplorerBusy = true;
try {
const result = await invoke<SerialExportResult>("serial_export_csv");
const result = filePath
? await invoke<SerialExportResult>("serial_export_csv_to_path", { filePath })
: await invoke<SerialExportResult>("serial_export_csv");
connectionNotice =
locale === "zh-CN"
? `CSV 导出成功(${result.frameCount} 帧):${result.path}`
: `CSV exported (${result.frameCount} frames): ${result.path}`;
connectionNoticeTone = "ok";
return true;
} catch (error) {
connectionNotice = resolveExportNotice(error);
connectionNoticeTone = "warn";
console.error("Serial export failed:", error);
return false;
} finally {
isExporting = false;
fileExplorerBusy = false;
}
}
async function handleReplayImport(event: CustomEvent<File>): Promise<void> {
const file = event.detail;
if (!file) {
async function precheckExportRecordData(): Promise<boolean> {
if (!isTauriRuntime()) {
return true;
}
try {
const result = await invoke<SerialRecordStateResult>("serial_has_record_data");
if (result.hasData) {
return true;
}
connectionNotice = resolveExportNotice("NoRecordedData");
connectionNoticeTone = "warn";
return false;
} catch (error) {
connectionNotice = resolveExportNotice(error);
connectionNoticeTone = "warn";
console.error("Export precheck failed:", error);
return false;
}
}
async function handleSerialExportRequest(): Promise<void> {
const hasData = await precheckExportRecordData();
if (!hasData) {
return;
}
pauseReplayPlayback();
await openFileExplorer("save");
}
try {
const text = await file.text();
let frames: ReplayFrame[];
let importedFrameCount = 0;
let importedChannelCount = 0;
async function handleReplayImportRequest(): Promise<void> {
await openFileExplorer("open");
}
if (isTauriRuntime()) {
const result = await invoke<SerialImportResult>("serial_import_csv", {
fileName: file.name,
csvContent: text
});
frames = result.frames.map((frame) => ({
values: frame.data,
dtsMs: frame.dtsMs
}));
importedFrameCount = result.frameCount;
importedChannelCount = result.channelCount;
} else {
frames = parseReplayCsv(text);
importedFrameCount = frames.length;
importedChannelCount = frames[0]?.values.length ?? 0;
async function handleFileExplorerNavigate(event: CustomEvent<string>): Promise<void> {
await loadFileExplorerDirectory(event.detail);
}
async function handleFileExplorerConfirm(): Promise<void> {
if (fileExplorerBusy) {
return;
}
if (fileExplorerMode === "open") {
const selected = fileExplorerEntries.find((entry) => entry.path === fileExplorerSelectedPath);
if (!selected) {
return;
}
if (selected.isDir) {
await loadFileExplorerDirectory(selected.path);
return;
}
if (!isCsvPath(selected.path)) {
connectionNotice = locale === "zh-CN" ? "请先选择 CSV 文件。" : "Please choose a CSV file.";
connectionNoticeTone = "warn";
return;
}
if (!frames.length) {
throw new Error("EmptyReplayData");
fileExplorerBusy = true;
const ok = await importReplayFromPath(selected.path);
fileExplorerBusy = false;
if (ok) {
fileExplorerOpen = false;
}
return;
}
replayFrames = frames;
replayFileName = file.name;
replayCurrentIndex = 0;
replayHasDisplayedFrame = false;
replayProgress = 0;
resetReplayVisualState();
const selected = fileExplorerEntries.find((entry) => entry.path === fileExplorerSelectedPath);
const targetDir = selected?.isDir ? selected.path : fileExplorerCurrentPath;
const csvName = ensureCsvSuffix(fileExplorerFileName || buildDefaultExportName());
if (!csvName) {
return;
}
connectionNotice =
locale === "zh-CN"
? `已导入 ${file.name},共 ${importedFrameCount} 帧 / ${importedChannelCount} 通道,可开始回放。`
: `${file.name} loaded (${importedFrameCount} frames / ${importedChannelCount} channels). Ready to replay.`;
connectionNoticeTone = "ok";
} catch (error) {
connectionNotice = resolveImportNotice(error);
connectionNoticeTone = "warn";
console.error("Replay import failed:", error);
const targetPath = joinPath(targetDir, csvName);
const ok = await runSerialExport(targetPath);
if (ok) {
fileExplorerOpen = false;
}
}
@@ -1228,8 +1516,9 @@
on:configlink={handleConfigLink}
on:serialrefresh={handleSerialRefresh}
on:serialconnect={handleSerialConnect}
on:serialexport={handleSerialExport}
on:csvimport={handleReplayImport}
on:serialexport={handleSerialExportRequest}
on:csvimport={handleReplayImportRequest}
on:noticeclear={() => (connectionNotice = "")}
/>
<CenterStage
@@ -1288,6 +1577,33 @@
</section>
</CenterStage>
</div>
<FileExplorerModal
open={fileExplorerOpen}
mode={fileExplorerMode}
title={fileExplorerTitle}
currentPath={fileExplorerCurrentPath}
parentPath={fileExplorerParentPath}
roots={fileExplorerRoots}
entries={fileExplorerEntries}
bind:selectedPath={fileExplorerSelectedPath}
bind:fileName={fileExplorerFileName}
pathLabel={uiCopy.fileExplorerPathLabel}
fileNameLabel={uiCopy.fileExplorerNameLabel}
cancelLabel={uiCopy.fileExplorerCancelLabel}
confirmLabel={fileExplorerConfirmLabel}
emptyHint={uiCopy.fileExplorerEmptyHint}
csvHint={uiCopy.fileExplorerCsvHint}
busyLabel={uiCopy.fileExplorerLoadingLabel}
upLabel={uiCopy.fileExplorerUpLabel}
nameColumnLabel={uiCopy.fileExplorerNameColumnLabel}
sizeColumnLabel={uiCopy.fileExplorerSizeColumnLabel}
modifiedColumnLabel={uiCopy.fileExplorerModifiedColumnLabel}
isBusy={fileExplorerBusy}
on:close={closeFileExplorer}
on:navigate={handleFileExplorerNavigate}
on:confirm={handleFileExplorerConfirm}
/>
</main>
<style>