Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 44 additions & 5 deletions apps/desktop/src/renderer/src/composables/tasks/useTaskColumns.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,11 @@ import {sortTasksByDateThenOrder, sortTasksByOrderIndex} from "@daily/protocol"
import {clamp} from "@daily/std"

import {createSharedComposable} from "@/composables/createSharedComposable"
import {BOARD_CARD_HEIGHT, BOARD_CARD_STEP} from "@/constants/ui"
import {useBoardCardGeometry} from "@/composables/useBoardCardGeometry"
import {useDragDropStore} from "@/stores/dragDrop.store"
import {useFilterStore} from "@/stores/filter.store"
import {useProjectScopeStore} from "@/stores/projectScope.store"
import {useTaskEditorStore} from "@/stores/task-editor"
import {useTasksStore} from "@/stores/tasks"
import {useUIStore} from "@/stores/ui"
import {getActiveTagNames} from "@/utils/tags/getActiveTagNames"
Expand All @@ -31,6 +32,8 @@ export const useTaskColumns = createSharedComposable(() => {
const projectScopeStore = useProjectScopeStore()
const uiStore = useUIStore()
const dragDropStore = useDragDropStore()
const taskEditorStore = useTaskEditorStore()
const {taskView, fontSize, cardHeight, cardStep} = useBoardCardGeometry()

const dropTarget = shallowRef<ColumnSlot | null>(null)
const landing = shallowRef<PlacedTask | null>(null)
Expand All @@ -40,6 +43,7 @@ export const useTaskColumns = createSharedComposable(() => {
let origin: PlacedTask | null = null
let pointer = {x: 0, y: 0}
let bodyUserSelect = ""
let geometryRevision = 0

const milestoneFrameTasks = computed(() => {
if (filterStore.isNoMilestoneActive) return tasksStore.tasksWithoutMilestone
Expand Down Expand Up @@ -113,7 +117,7 @@ export const useTaskColumns = createSharedComposable(() => {
column.scrollIntoView({behavior: "instant", block: "nearest", inline: "nearest"})

const top = clamp(
track.offsetTop + index * BOARD_CARD_STEP + BOARD_CARD_HEIGHT / 2 - list.clientHeight / 2,
track.offsetTop + index * cardStep.value + cardHeight.value / 2 - list.clientHeight / 2,
0,
list.scrollHeight - list.clientHeight,
)
Expand Down Expand Up @@ -253,7 +257,7 @@ export const useTaskColumns = createSharedComposable(() => {
}

const count = tasksByStatus.value[status].filter((task) => task.id !== origin?.task.id).length
const index = getColumnInsertIndex(pointer.y - track.getBoundingClientRect().top, count)
const index = getColumnInsertIndex(pointer.y - track.getBoundingClientRect().top, count, cardStep.value)
if (dropTarget.value?.status !== status || dropTarget.value.index !== index) dropTarget.value = {status, index}

columnScroll.update(column.querySelector<HTMLElement>("[data-column-list]"), pointer.y)
Expand Down Expand Up @@ -322,7 +326,42 @@ export const useTaskColumns = createSharedComposable(() => {
if (!Object.values(byStatus).some((tasks) => tasks.some((task) => task.id === draggedId))) stopPress()
})

watch([taskView, cardHeight, cardStep, fontSize], async (_, [, previousHeight, previousStep]) => {
const revision = ++geometryRevision
stopPress()
const selectedId = taskEditorStore.editingTaskId
const selectedStatus = (Object.keys(tasksByStatus.value) as TaskStatus[]).find((status) =>
tasksByStatus.value[status].some((task) => task.id === selectedId),
)
const anchors = (Object.keys(tasksByStatus.value) as TaskStatus[]).flatMap((status) => {
const list = document.querySelector<HTMLElement>(`[data-column-status="${status}"] [data-column-list]`)
const track = list?.querySelector<HTMLElement>("[data-column-track]")
const tasks = tasksByStatus.value[status]
if (!list || !track || !tasks.length) return []
const top = list.scrollTop - track.offsetTop
let index = clamp(Math.floor(top / previousStep), 0, tasks.length - 1)
if (top - index * previousStep >= previousHeight && index < tasks.length - 1) index += 1
return [{status, list, track, taskId: tasks[index].id, offset: top - index * previousStep}]
})
if (selectedStatus && isColumnCollapsed(selectedStatus)) uiStore.setSectionCollapsed(selectedStatus, false)
await nextTick()
if (revision !== geometryRevision) return
for (const anchor of anchors) {
const tasks = tasksByStatus.value[anchor.status]
const selected = anchor.status === selectedStatus
const index = tasks.findIndex((task) => task.id === (selected ? selectedId : anchor.taskId))
if (index < 0 || !anchor.list.isConnected) continue
const offset = selected ? cardHeight.value / 2 - anchor.list.clientHeight / 2 : Math.min(anchor.offset, cardHeight.value - 1)
const top = anchor.track.offsetTop + index * cardStep.value + offset
anchor.list.scrollTop = clamp(top, 0, Math.max(0, anchor.list.scrollHeight - anchor.list.clientHeight))
anchor.list.dispatchEvent(new Event("scroll"))
if (selected)
anchor.list.closest<HTMLElement>("[data-column-status]")?.scrollIntoView({behavior: "instant", block: "nearest", inline: "nearest"})
}
})

onScopeDispose(() => {
geometryRevision += 1
stopPress()
window.removeEventListener("click", swallowClick, {capture: true})
})
Expand All @@ -347,6 +386,6 @@ function resolveMoveTarget(items: Task[], newIndex: number): {targetTaskId: Task
return {targetTaskId, position}
}

function getColumnInsertIndex(offsetY: number, count: number): number {
return clamp(Math.floor(offsetY / BOARD_CARD_STEP), 0, count)
function getColumnInsertIndex(offsetY: number, count: number, cardStep: number): number {
return clamp(Math.floor(offsetY / cardStep), 0, count)
}
24 changes: 24 additions & 0 deletions apps/desktop/src/renderer/src/composables/useBoardCardGeometry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import {computed} from "vue"

import {BOARD_CARD_GAP, BOARD_CARD_HEIGHT, COMPACT_BOARD_CARD_HEIGHT} from "@/constants/ui"
import {useSettingValue} from "./useSettingValue"

import type {FontSize, TaskView} from "@daily/protocol"

/** Reactive card dimensions shared by presentation, virtual columns, navigation and drag placement. */
export function useBoardCardGeometry() {
const storedTaskView = useSettingValue<"appearance.taskView", TaskView>("appearance.taskView", "regular")
const fontSize = useSettingValue<"typography.fontSize", FontSize>("typography.fontSize", "normal")
const fontSizePx: Record<FontSize, number> = {small: 13, normal: 15, large: 17}
const taskView = computed<TaskView>({
get: () => (storedTaskView.value === "compact" ? "compact" : "regular"),
set: (value) => {
storedTaskView.value = value === "compact" ? "compact" : "regular"
},
})
const isCompact = computed(() => taskView.value === "compact")
const cardHeight = computed(() => (isCompact.value ? (COMPACT_BOARD_CARD_HEIGHT * (fontSizePx[fontSize.value] ?? 15)) / 15 : BOARD_CARD_HEIGHT))
const cardStep = computed(() => cardHeight.value + BOARD_CARD_GAP)

return {taskView, fontSize, isCompact, cardHeight, cardStep}
}
3 changes: 3 additions & 0 deletions apps/desktop/src/renderer/src/constants/ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,3 +44,6 @@ export const BOARD_CARD_HEIGHT = 200

/** A board card plus the gap below it; the virtual columns and the drop index step through a column by it, so it must match the card. */
export const BOARD_CARD_STEP = 206

export const BOARD_CARD_GAP = BOARD_CARD_STEP - BOARD_CARD_HEIGHT
export const COMPACT_BOARD_CARD_HEIGHT = 108
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import {computed, shallowRef, toValue} from "vue"
import {computed, shallowRef, toValue, watch} from "vue"
import {useEventListener} from "@vueuse/core"

import {useBatchedResizeObserver} from "@/composables/useBatchedResizeObserver"
import {BOARD_CARD_HEIGHT, BOARD_CARD_STEP} from "@/constants/ui"
import {useBoardCardGeometry} from "@/composables/useBoardCardGeometry"

import type {ComputedRef, MaybeRefOrGetter, ShallowRef} from "vue"

type ColumnRange = {start: number; end: number}

/** A column renders only the rows this range covers; it needs no measurement, because every card is `BOARD_CARD_HEIGHT` tall. */
/** A column renders only the rows this range covers, using the current shared card dimensions. */
export function useVirtualColumn(
listRef: Readonly<ShallowRef<HTMLElement | null>>,
trackRef: Readonly<ShallowRef<HTMLElement | null>>,
Expand All @@ -17,22 +17,31 @@ export function useVirtualColumn(
const scrollTop = shallowRef(0)
const viewportHeight = shallowRef(0)
const trackTop = shallowRef(0)
const {fontSize, cardHeight, cardStep} = useBoardCardGeometry()

const range = computed<ColumnRange>((previous) => {
const top = scrollTop.value - trackTop.value
const start = Math.max(0, Math.floor(top / BOARD_CARD_STEP) - 3)
const end = Math.min(toValue(count), Math.ceil((top + viewportHeight.value) / BOARD_CARD_STEP) + 3)
const start = Math.min(Math.max(0, toValue(count) - 1), Math.max(0, Math.floor(top / cardStep.value) - 3))
const end = Math.min(toValue(count), Math.ceil((top + viewportHeight.value) / cardStep.value) + 3)

return previous?.start === start && previous.end === end ? previous : {start, end}
})

const trackHeight = computed(() => {
const total = toValue(count)
return total > 0 ? total * BOARD_CARD_STEP - (BOARD_CARD_STEP - BOARD_CARD_HEIGHT) : 0
return total > 0 ? total * cardStep.value - (cardStep.value - cardHeight.value) : 0
})

useEventListener(listRef, "scroll", onScroll, {passive: true})
useBatchedResizeObserver([listRef], {read: readViewport, write: applyViewport})
watch(
[cardStep, fontSize],
() => {
applyViewport(readViewport())
onScroll()
},
{flush: "post"},
)

function onScroll() {
scrollTop.value = listRef.value?.scrollTop ?? 0
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import {computed, toRef, useTemplateRef} from "vue"
import {sortTags} from "@daily/protocol"
import {toDateLabel} from "@daily/std"

import {BOARD_CARD_HEIGHT} from "@/constants/ui"
import {useBoardCardGeometry} from "@/composables/useBoardCardGeometry"
import {useBranchesStore} from "@/stores/branches.store"
import {useFilterStore} from "@/stores/filter.store"
import {useFocusStore} from "@/stores/focus.store"
Expand All @@ -29,6 +29,7 @@ import PriorityIcon from "@/ui/common/priority/PriorityIcon.vue"
import {useConfirmUnsavedModal} from "@/ui/overlays/ConfirmUnsavedModal"
import {toShortDurationLabel} from "@/utils/date/toShortDurationLabel"
import {cn} from "@/utils/ui/tailwindcss"
import {toTaskTitle} from "@shared/utils/tasks/toTaskTitle"
import CardCrumb from "./{fragments}/CardCrumb.vue"
import DeleteMenuItem from "./{fragments}/DeleteMenuItem.vue"
import FocusBorder from "./{fragments}/FocusBorder.vue"
Expand All @@ -41,6 +42,9 @@ import type {Branch, Tag, Task, TaskRelationSets, TaskStatus} from "@daily/proto

const props = defineProps<{task: Task}>()

const {isCompact, cardHeight} = useBoardCardGeometry()
const title = computed(() => toTaskTitle(props.task.content) || "Untitled task")

const tasksStore = useTasksStore()
const tagsStore = useTagsStore()
const taskEditorStore = useTaskEditorStore()
Expand Down Expand Up @@ -70,6 +74,7 @@ const footerDayLabel = computed(() => {
})

const hasTime = computed(() => Boolean(estimateLabel.value) || Boolean(spentLabel.value))
const hasContext = computed(() => hasRelation.value || Boolean(footerDayLabel.value) || commentCount.value > 0)
const hasMetrics = computed(() => hasRelation.value || Boolean(footerDayLabel.value) || hasTime.value || commentCount.value > 0)
const hasPriority = computed(() => props.task.priority !== "none")
const hasFooter = computed(() => tags.value.length > 0 || hasMetrics.value || hasPriority.value)
Expand Down Expand Up @@ -164,7 +169,7 @@ function getStatusClass(status: TaskStatus) {

function getCardClasses(status: TaskStatus, isInSession: boolean) {
return cn(
"bg-base-100 hover:shadow-accent/5 group relative overflow-hidden rounded-2xl border transition-all duration-200 hover:shadow-lg",
"bg-base-100 hover:shadow-accent/5 group relative overflow-hidden rounded-2xl border transition-[border-color,box-shadow] duration-200 hover:shadow-lg",
status === "backlog" && "border-base-content/15 border-dashed",
status === "done" && "border-success/30 hover:border-success/40",
status === "discarded" && "border-warning/30 hover:border-warning/40",
Expand All @@ -183,7 +188,8 @@ function getSpentClasses(hasSpent: boolean) {

function getContentClasses(status: TaskStatus) {
return cn(
"board-card-text relative min-h-0 flex-1 overflow-hidden transition-opacity duration-200",
"relative min-h-0 flex-1 overflow-hidden transition-opacity duration-200",
!isCompact.value && "board-card-text",
(status === "done" || status === "discarded") && "opacity-50",
)
}
Expand Down Expand Up @@ -218,20 +224,29 @@ async function onLinkTask(side: keyof TaskRelationSets, taskId: Task["id"]) {

<template>
<BaseContextMenu ref="contextMenu" :items="menuItems" @select="onSelect">
<div :id="task.id" :class="getCardClasses(task.status, isInSession)" :style="{height: `${BOARD_CARD_HEIGHT}px`}" @click.stop="onCardClick">
<div class="relative z-10 flex h-full w-full flex-col gap-2 px-4 pt-3.5 pb-3">
<div
:id="task.id"
:class="[getCardClasses(task.status, isInSession), {'board-card-compact': isCompact}]"
:style="{height: `${cardHeight}px`}"
@click.stop="onCardClick"
>
<div class="board-card-inner relative z-10 flex h-full w-full flex-col gap-2 px-4 pb-3 pt-3.5">
<CardCrumb v-if="projectName || milestone" :project-name="projectName" :milestone="milestone" />

<div :class="getContentClasses(task.status)">
<MarkdownContent :content="task.content" :minimizable="false" clip-code />
<p v-if="isCompact" class="board-card-title">{{ title }}</p>
<MarkdownContent v-else :content="task.content" :minimizable="false" clip-code />
</div>

<div v-if="hasFooter" class="flex min-h-5 min-w-0 items-center gap-2 text-xs">
<div
v-if="isCompact ? tags.length || hasTime || hasPriority : hasFooter"
class="board-card-footer flex min-h-5 min-w-0 items-center gap-2 text-xs"
>
<TagLine v-if="tags.length" :tags="tags" />

<div v-if="hasMetrics" class="ml-auto flex shrink-0 items-center gap-2.5">
<RelationChip :task-id="task.id" />
<div v-if="footerDayLabel" class="text-base-content/75 inline-flex items-center gap-1 whitespace-nowrap">
<div v-if="isCompact ? hasTime : hasMetrics" class="ml-auto flex shrink-0 items-center gap-2.5">
<RelationChip v-if="!isCompact" :task-id="task.id" />
<div v-if="!isCompact && footerDayLabel" class="text-base-content/75 inline-flex items-center gap-1 whitespace-nowrap">
<BaseIcon name="calendar" class="text-base-content/40 size-3.5" />
<span>{{ footerDayLabel }}</span>
</div>
Expand All @@ -242,13 +257,25 @@ async function onLinkTask(side: keyof TaskRelationSets, taskId: Task["id"]) {
<span class="text-base-content/55"> / {{ estimateLabel || "–" }}</span>
</span>
</div>
<div v-if="commentCount" class="text-base-content/75 inline-flex items-center gap-1 whitespace-nowrap">
<div v-if="!isCompact && commentCount" class="text-base-content/75 inline-flex items-center gap-1 whitespace-nowrap">
<BaseIcon name="message" class="text-base-content/40 size-3.5" />
<span>{{ commentCount }}</span>
</div>
</div>

<PriorityIcon v-if="hasPriority" :priority="task.priority" :class="getPriorityClasses(hasMetrics)" />
<PriorityIcon v-if="hasPriority" :priority="task.priority" :class="getPriorityClasses(isCompact ? hasTime : hasMetrics)" />
</div>

<div v-if="isCompact && hasContext" class="board-card-context flex min-h-5 shrink-0 items-center justify-end gap-2.5 text-xs">
<RelationChip :task-id="task.id" />
<div v-if="footerDayLabel" class="text-base-content/75 inline-flex items-center gap-1 whitespace-nowrap">
<BaseIcon name="calendar" class="text-base-content/40 size-3.5" />
<span>{{ footerDayLabel }}</span>
</div>
<div v-if="commentCount" class="text-base-content/75 inline-flex items-center gap-1 whitespace-nowrap">
<BaseIcon name="message" class="text-base-content/40 size-3.5" />
<span>{{ commentCount }}</span>
</div>
</div>
</div>

Expand Down Expand Up @@ -312,6 +339,38 @@ async function onLinkTask(side: keyof TaskRelationSets, taskId: Task["id"]) {
</template>

<style scoped>
.board-card-compact .board-card-inner {
padding: 0.5rem 0.8rem 0.4rem;
gap: 0.125rem;
}

.board-card-compact .board-card-inner > :deep(.h-4.text-xs) {
height: 0.9rem;
line-height: 1.2;
flex-shrink: 0;
}

.board-card-title {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
margin: 0;
font-size: 0.875rem;
font-weight: 400;
line-height: 1.5;
}

.board-card-compact .board-card-footer {
min-height: 1.15rem;
flex-shrink: 0;
}

.board-card-context {
min-height: 0.9rem;
line-height: 1.2;
}

.board-card-text {
scroll-timeline: --board-card-text y;
}
Expand Down
Loading
Loading