196 lines
5.9 KiB
Vue
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>
|