Files
pikasTech-HWLAB/web/hwlab-cloud-web/src/components/layout/SplitWorkspaceLayout.vue
T

196 lines
5.9 KiB
Vue

<!-- Responsibility: Generic bounded split workspace layout for tool pages. -->
<script setup lang="ts">
import { computed, onBeforeUnmount, ref } from "vue";
const props = withDefaults(defineProps<{
leftCollapsed?: boolean;
rightOpen?: boolean;
rightCollapsed?: boolean;
leftWidth?: number;
rightWidth?: number;
minLeftWidth?: number;
maxLeftWidth?: number;
minRightWidth?: number;
maxRightWidth?: number;
leftCollapsedWidth?: number;
rightCollapsedWidth?: number;
leftResizerTestId?: string;
rightResizerTestId?: string;
ariaLabel?: string;
}>(), {
leftCollapsed: false,
rightOpen: false,
rightCollapsed: false,
leftWidth: 30,
rightWidth: 50,
minLeftWidth: 18,
maxLeftWidth: 36,
minRightWidth: 30,
maxRightWidth: 70,
leftCollapsedWidth: 44,
rightCollapsedWidth: 44,
leftResizerTestId: undefined,
rightResizerTestId: undefined,
ariaLabel: "工作区布局"
});
const emit = defineEmits<{
"update:leftWidth": [value: number];
"update:rightWidth": [value: number];
}>();
const layoutRef = ref<HTMLElement | null>(null);
const resizing = ref<"left" | "right" | null>(null);
const clampedLeftWidth = computed(() => clamp(props.leftWidth, props.minLeftWidth, props.maxLeftWidth));
const clampedRightWidth = computed(() => clamp(props.rightWidth, props.minRightWidth, props.maxRightWidth));
const layoutStyle = computed(() => {
const leftColumn = props.leftCollapsed
? `${props.leftCollapsedWidth}px`
: `minmax(${props.leftCollapsedWidth}px, ${clampedLeftWidth.value}%)`;
if (!props.rightOpen) {
return { gridTemplateColumns: `${leftColumn} 6px minmax(0, 1fr)` };
}
const mainShare = props.rightCollapsed ? "minmax(0, 1fr)" : `minmax(0, ${100 - clampedRightWidth.value}fr)`;
const rightResizeColumn = props.rightCollapsed ? "0" : "6px";
const rightColumn = props.rightCollapsed ? `${props.rightCollapsedWidth}px` : `minmax(280px, ${clampedRightWidth.value}fr)`;
return { gridTemplateColumns: `${leftColumn} 6px ${mainShare} ${rightResizeColumn} ${rightColumn}` };
});
onBeforeUnmount(() => stopResize());
function startResize(kind: "left" | "right", event: PointerEvent): void {
if (kind === "left" && props.leftCollapsed) return;
if (kind === "right" && (!props.rightOpen || props.rightCollapsed)) return;
event.preventDefault();
resizing.value = kind;
window.addEventListener("pointermove", resizePane);
window.addEventListener("pointerup", stopResize, { once: true });
}
function resizePane(event: PointerEvent): void {
const rect = layoutRef.value?.getBoundingClientRect();
if (!rect || rect.width <= 0 || !resizing.value) return;
if (resizing.value === "left") {
emit("update:leftWidth", Math.round(clamp(((event.clientX - rect.left) / rect.width) * 100, props.minLeftWidth, props.maxLeftWidth)));
return;
}
const leftPixels = props.leftCollapsed ? props.leftCollapsedWidth : rect.width * (clampedLeftWidth.value / 100);
const remaining = Math.max(320, rect.width - leftPixels - 18);
emit("update:rightWidth", Math.round(clamp(((rect.right - event.clientX) / remaining) * 100, props.minRightWidth, props.maxRightWidth)));
}
function stopResize(): void {
resizing.value = null;
window.removeEventListener("pointermove", resizePane);
window.removeEventListener("pointerup", stopResize);
}
function clamp(value: number, min: number, max: number): number {
if (!Number.isFinite(value)) return min;
return Math.min(max, Math.max(min, value));
}
</script>
<template>
<section
ref="layoutRef"
class="split-workspace-layout"
:style="layoutStyle"
:aria-label="ariaLabel"
:data-left-collapsed="leftCollapsed"
:data-right-open="rightOpen"
:data-right-collapsed="rightCollapsed"
:data-resizing="resizing || undefined"
>
<aside class="split-workspace-pane split-workspace-left" :data-collapsed="leftCollapsed">
<slot name="left" />
</aside>
<div
class="split-workspace-resizer split-workspace-resizer-left"
:data-testid="leftResizerTestId"
role="separator"
aria-orientation="vertical"
:aria-valuenow="Math.round(clampedLeftWidth)"
@pointerdown="startResize('left', $event)"
/>
<main class="split-workspace-main">
<slot />
</main>
<div
v-if="rightOpen"
class="split-workspace-resizer split-workspace-resizer-right"
:data-testid="rightResizerTestId"
role="separator"
aria-orientation="vertical"
:aria-valuenow="Math.round(clampedRightWidth)"
@pointerdown="startResize('right', $event)"
/>
<aside v-if="rightOpen" class="split-workspace-pane split-workspace-right" :data-collapsed="rightCollapsed">
<slot name="right" />
</aside>
</section>
</template>
<style scoped>
.split-workspace-layout {
display: grid;
min-width: 0;
min-height: 0;
height: 100%;
overflow: hidden;
gap: 0;
}
.split-workspace-pane,
.split-workspace-main {
min-width: 0;
min-height: 0;
overflow: hidden;
}
.split-workspace-resizer {
min-width: 0;
border-inline: 1px solid transparent;
border-radius: 999px;
background: linear-gradient(90deg, transparent 1px, #cbd8d2 2px, #cbd8d2 4px, transparent 5px);
cursor: col-resize;
}
.split-workspace-resizer-right {
opacity: 1;
}
.split-workspace-layout[data-left-collapsed="true"] .split-workspace-resizer-left,
.split-workspace-layout[data-right-collapsed="true"] .split-workspace-resizer-right {
pointer-events: none;
opacity: 0;
}
.split-workspace-layout[data-resizing] {
user-select: none;
}
@media (max-width: 720px) {
.split-workspace-layout {
display: grid;
height: 100%;
min-height: 0;
grid-template-columns: 1fr !important;
grid-template-rows: minmax(96px, 28%) minmax(0, 1fr) minmax(120px, 34%);
gap: 10px;
overflow: hidden;
}
.split-workspace-layout:not([data-right-open="true"]) {
grid-template-rows: minmax(96px, 30%) minmax(0, 1fr);
}
.split-workspace-resizer {
display: none;
}
}
</style>