1 parent
37fa2b5966
commit
d5642909e0
4 files changed
+157
-7
No files matched your search
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref, watchEffect } from 'vue'
|
import { computed, ref, useTemplateRef, watchEffect } from 'vue'
|
||||||
import { useIssueStore } from '@/stores/issue.js'
|
import { useIssueStore } from '@/stores/issue.js'
|
||||||
import ExpansionPanel from '@/components/ExpansionPanel.vue'
|
import ExpansionPanel from '@/components/ExpansionPanel.vue'
|
||||||
import IssueContent from '@/components/IssueContent.vue'
|
import IssueContent from '@/components/IssueContent.vue'
|
||||||
@@ -11,6 +11,8 @@ import IssueTitle from '@/components/IssueTitle.vue'
|
|||||||
import IssueDate from '@/components/IssueDate.vue'
|
import IssueDate from '@/components/IssueDate.vue'
|
||||||
import CommentsList from '@/components/CommentsList.vue'
|
import CommentsList from '@/components/CommentsList.vue'
|
||||||
import HistoryList from '@/components/HistoryList.vue'
|
import HistoryList from '@/components/HistoryList.vue'
|
||||||
|
import SplitHandle from '@/components/SplitHandle.vue'
|
||||||
|
import { useSplitPane } from '@/composables/useSplitPane.ts'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
ckey: string
|
ckey: string
|
||||||
@@ -29,6 +31,9 @@ const activity = ref()
|
|||||||
|
|
||||||
const { ckey } = defineProps<Props>()
|
const { ckey } = defineProps<Props>()
|
||||||
|
|
||||||
|
const splitContainer = useTemplateRef<HTMLElement>('splitContainer')
|
||||||
|
const { gridTemplate, handleProps } = useSplitPane(splitContainer, { storageKey: 'issue-details' })
|
||||||
|
|
||||||
const createdAtFormatted = computed(() => {
|
const createdAtFormatted = computed(() => {
|
||||||
if (!issue.value?.createdAt) return ''
|
if (!issue.value?.createdAt) return ''
|
||||||
return new Date(issue.value.createdAt).toLocaleString('ru-RU')
|
return new Date(issue.value.createdAt).toLocaleString('ru-RU')
|
||||||
@@ -99,7 +104,7 @@ defineExpose({
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div v-if="issue" class="pa-4 details">
|
<div v-if="issue" ref="splitContainer" class="pa-4 details">
|
||||||
<div class="d-flex flex-column ga-4 details__main">
|
<div class="d-flex flex-column ga-4 details__main">
|
||||||
<div>{{ ckey }}</div>
|
<div>{{ ckey }}</div>
|
||||||
|
|
||||||
@@ -128,6 +133,7 @@ defineExpose({
|
|||||||
<issue-content :text="issue.content" @changed="onContentChanged" />
|
<issue-content :text="issue.content" @changed="onContentChanged" />
|
||||||
</expansion-panel>
|
</expansion-panel>
|
||||||
</div>
|
</div>
|
||||||
|
<split-handle v-bind="handleProps" />
|
||||||
<div class="d-flex flex-column">
|
<div class="d-flex flex-column">
|
||||||
<expansion-panel title="Activity" class="activity">
|
<expansion-panel title="Activity" class="activity">
|
||||||
<v-sheet class="activity__sheet">
|
<v-sheet class="activity__sheet">
|
||||||
@@ -155,8 +161,8 @@ defineExpose({
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: v-bind(gridTemplate);
|
||||||
gap: 1rem;
|
column-gap: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.details > * {
|
.details > * {
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
interface Props {
|
||||||
|
dragging?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const { dragging = false } = defineProps<Props>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="split-handle"
|
||||||
|
:class="{ 'split-handle--active': dragging }"
|
||||||
|
role="separator"
|
||||||
|
aria-orientation="vertical"
|
||||||
|
tabindex="0"
|
||||||
|
title="Drag to resize, double-click to reset"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.split-handle {
|
||||||
|
position: relative;
|
||||||
|
width: 9px;
|
||||||
|
cursor: col-resize;
|
||||||
|
touch-action: none;
|
||||||
|
outline: none;
|
||||||
|
|
||||||
|
// Thin visible line in the middle of a wider grab area
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0 4px;
|
||||||
|
background: rgba(var(--v-border-color), var(--v-border-opacity));
|
||||||
|
transition: background-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover::after,
|
||||||
|
&:focus-visible::after,
|
||||||
|
&--active::after {
|
||||||
|
inset: 0 3px;
|
||||||
|
background: rgb(var(--v-theme-primary));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(body.split-pane--dragging),
|
||||||
|
:global(body.split-pane--dragging *) {
|
||||||
|
cursor: col-resize !important;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { computed, onScopeDispose, ref, type Ref } from 'vue'
|
||||||
|
import { useLocalStorage } from '@vueuse/core'
|
||||||
|
|
||||||
|
interface Options {
|
||||||
|
/** localStorage key for the ratio; the ratio is not persisted without it */
|
||||||
|
storageKey?: string
|
||||||
|
/** Share of the container taken by the first pane, 0..1 */
|
||||||
|
initial?: number
|
||||||
|
min?: number
|
||||||
|
max?: number
|
||||||
|
/** Ratio change per arrow key press */
|
||||||
|
step?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Two panes in a CSS grid split by a draggable handle.
|
||||||
|
* Bind `gridTemplate` to `grid-template-columns` of the container and `handleProps` to the handle between the panes.
|
||||||
|
*/
|
||||||
|
export function useSplitPane(container: Ref<HTMLElement | null | undefined>, options: Options = {}) {
|
||||||
|
const { storageKey, initial = 0.5, min = 0.2, max = 0.8, step = 0.02 } = options
|
||||||
|
|
||||||
|
const clamp = (value: number) => Math.min(max, Math.max(min, value))
|
||||||
|
|
||||||
|
const ratio = storageKey ? useLocalStorage(`split-pane:${storageKey}`, initial) : ref(initial)
|
||||||
|
const dragging = ref(false)
|
||||||
|
|
||||||
|
// fr units share the space left after the auto-sized handle, so the ratio is exact
|
||||||
|
const gridTemplate = computed(() => {
|
||||||
|
const r = clamp(ratio.value)
|
||||||
|
return `minmax(0, ${r}fr) auto minmax(0, ${1 - r}fr)`
|
||||||
|
})
|
||||||
|
|
||||||
|
function onPointerMove(e: PointerEvent) {
|
||||||
|
const el = container.value
|
||||||
|
if (!el) return
|
||||||
|
// Measure against the content box, so the container padding does not shift the handle off the pointer
|
||||||
|
const rect = el.getBoundingClientRect()
|
||||||
|
const style = getComputedStyle(el)
|
||||||
|
const left = rect.left + parseFloat(style.paddingLeft)
|
||||||
|
const width = rect.width - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight)
|
||||||
|
if (width <= 0) return
|
||||||
|
ratio.value = clamp((e.clientX - left) / width)
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopDrag() {
|
||||||
|
dragging.value = false
|
||||||
|
document.body.classList.remove('split-pane--dragging')
|
||||||
|
window.removeEventListener('pointermove', onPointerMove)
|
||||||
|
window.removeEventListener('pointerup', stopDrag)
|
||||||
|
window.removeEventListener('pointercancel', stopDrag)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPointerdown(e: PointerEvent) {
|
||||||
|
if (e.button !== 0) return
|
||||||
|
e.preventDefault()
|
||||||
|
dragging.value = true
|
||||||
|
// Disables text selection and keeps the resize cursor while the pointer is outside the handle
|
||||||
|
document.body.classList.add('split-pane--dragging')
|
||||||
|
window.addEventListener('pointermove', onPointerMove)
|
||||||
|
window.addEventListener('pointerup', stopDrag)
|
||||||
|
window.addEventListener('pointercancel', stopDrag)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKeydown(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'ArrowLeft') ratio.value = clamp(ratio.value - step)
|
||||||
|
else if (e.key === 'ArrowRight') ratio.value = clamp(ratio.value + step)
|
||||||
|
else return
|
||||||
|
e.preventDefault()
|
||||||
|
}
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
ratio.value = initial
|
||||||
|
}
|
||||||
|
|
||||||
|
onScopeDispose(stopDrag)
|
||||||
|
|
||||||
|
const handleProps = computed(() => ({
|
||||||
|
dragging: dragging.value,
|
||||||
|
'aria-valuenow': Math.round(clamp(ratio.value) * 100),
|
||||||
|
'aria-valuemin': Math.round(min * 100),
|
||||||
|
'aria-valuemax': Math.round(max * 100),
|
||||||
|
onPointerdown,
|
||||||
|
onKeydown,
|
||||||
|
onDblclick: reset,
|
||||||
|
}))
|
||||||
|
|
||||||
|
return { ratio, gridTemplate, dragging, handleProps, reset }
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref, watch } from 'vue'
|
import { computed, onMounted, ref, useTemplateRef, watch } from 'vue'
|
||||||
import { storeToRefs } from 'pinia'
|
import { storeToRefs } from 'pinia'
|
||||||
import { useIssueStore } from '@/stores/issue'
|
import { useIssueStore } from '@/stores/issue'
|
||||||
import { useProjectStore } from '@/stores/project.ts'
|
import { useProjectStore } from '@/stores/project.ts'
|
||||||
@@ -10,6 +10,8 @@ import type { Issue } from '@/api/issue.api.ts'
|
|||||||
import IssuesListElement from '@/components/IssuesListElement.vue'
|
import IssuesListElement from '@/components/IssuesListElement.vue'
|
||||||
import IssueStats from '@/components/IssueStats.vue'
|
import IssueStats from '@/components/IssueStats.vue'
|
||||||
import IssueFilter from '@/components/IssueFilter.vue'
|
import IssueFilter from '@/components/IssueFilter.vue'
|
||||||
|
import SplitHandle from '@/components/SplitHandle.vue'
|
||||||
|
import { useSplitPane } from '@/composables/useSplitPane.ts'
|
||||||
|
|
||||||
const issueStore = useIssueStore()
|
const issueStore = useIssueStore()
|
||||||
const issueDetailsRef = ref<InstanceType<typeof IssueDetails>>()
|
const issueDetailsRef = ref<InstanceType<typeof IssueDetails>>()
|
||||||
@@ -90,7 +92,9 @@ onMounted(async () => {
|
|||||||
await load()
|
await load()
|
||||||
await issueStore.getStat(currentProjectId.value)
|
await issueStore.getStat(currentProjectId.value)
|
||||||
})
|
})
|
||||||
const gridTemplate = computed(() => (selected.value.length ? '1fr 1fr' : '1fr'))
|
const splitContainer = useTemplateRef<HTMLElement>('splitContainer')
|
||||||
|
const split = useSplitPane(splitContainer, { storageKey: 'issues-list', min: 0.25, max: 0.75 })
|
||||||
|
const gridTemplate = computed(() => (selected.value.length ? split.gridTemplate.value : '1fr'))
|
||||||
const goToIssue = (key: string[]) => router.push(`/issues/${key[0]}`)
|
const goToIssue = (key: string[]) => router.push(`/issues/${key[0]}`)
|
||||||
|
|
||||||
async function updatePage(val: number) {
|
async function updatePage(val: number) {
|
||||||
@@ -184,7 +188,7 @@ async function onDeleteBatch() {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="d-flex flex-column ga-2 h-100">
|
<div class="d-flex flex-column ga-2 h-100">
|
||||||
<div class="u-grid ga-1">
|
<div ref="splitContainer" class="u-grid">
|
||||||
<div class="u-height">
|
<div class="u-height">
|
||||||
<issue-filter
|
<issue-filter
|
||||||
class="mb-2"
|
class="mb-2"
|
||||||
@@ -274,6 +278,8 @@ async function onDeleteBatch() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<split-handle v-if="selected.length" v-bind="split.handleProps.value" />
|
||||||
|
|
||||||
<v-sheet border v-if="selected.length" class="d-flex flex-column">
|
<v-sheet border v-if="selected.length" class="d-flex flex-column">
|
||||||
<div class="d-flex align-center ga-2 px-4 py-2">
|
<div class="d-flex align-center ga-2 px-4 py-2">
|
||||||
<div>Work item</div>
|
<div>Work item</div>
|
||||||
|
|||||||
Reference in new issue
Block a user