exchange tast to tactilea
This commit is contained in:
833
src/lib/components/FileExplorerModal.svelte
Normal file
833
src/lib/components/FileExplorerModal.svelte
Normal 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>
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user