diff --git a/apps/desktop/src/renderer/src/composables/tasks/useTaskColumns.ts b/apps/desktop/src/renderer/src/composables/tasks/useTaskColumns.ts index 3c96e588..df51d439 100644 --- a/apps/desktop/src/renderer/src/composables/tasks/useTaskColumns.ts +++ b/apps/desktop/src/renderer/src/composables/tasks/useTaskColumns.ts @@ -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" @@ -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(null) const landing = shallowRef(null) @@ -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 @@ -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, ) @@ -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("[data-column-list]"), pointer.y) @@ -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(`[data-column-status="${status}"] [data-column-list]`) + const track = list?.querySelector("[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("[data-column-status]")?.scrollIntoView({behavior: "instant", block: "nearest", inline: "nearest"}) + } + }) + onScopeDispose(() => { + geometryRevision += 1 stopPress() window.removeEventListener("click", swallowClick, {capture: true}) }) @@ -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) } diff --git a/apps/desktop/src/renderer/src/composables/useBoardCardGeometry.ts b/apps/desktop/src/renderer/src/composables/useBoardCardGeometry.ts new file mode 100644 index 00000000..8e5524f7 --- /dev/null +++ b/apps/desktop/src/renderer/src/composables/useBoardCardGeometry.ts @@ -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 = {small: 13, normal: 15, large: 17} + const taskView = computed({ + 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} +} diff --git a/apps/desktop/src/renderer/src/constants/ui.ts b/apps/desktop/src/renderer/src/constants/ui.ts index 0aeddf50..111f6293 100644 --- a/apps/desktop/src/renderer/src/constants/ui.ts +++ b/apps/desktop/src/renderer/src/constants/ui.ts @@ -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 diff --git a/apps/desktop/src/renderer/src/ui/modules/TaskBoard/composables/useVirtualColumn.ts b/apps/desktop/src/renderer/src/ui/modules/TaskBoard/composables/useVirtualColumn.ts index 1d3c427d..48bd79c9 100644 --- a/apps/desktop/src/renderer/src/ui/modules/TaskBoard/composables/useVirtualColumn.ts +++ b/apps/desktop/src/renderer/src/ui/modules/TaskBoard/composables/useVirtualColumn.ts @@ -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>, trackRef: Readonly>, @@ -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((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 diff --git a/apps/desktop/src/renderer/src/ui/modules/TaskBoard/{fragments}/TaskCard/TaskCard.vue b/apps/desktop/src/renderer/src/ui/modules/TaskBoard/{fragments}/TaskCard/TaskCard.vue index 297b5442..4a3dc32a 100644 --- a/apps/desktop/src/renderer/src/ui/modules/TaskBoard/{fragments}/TaskCard/TaskCard.vue +++ b/apps/desktop/src/renderer/src/ui/modules/TaskBoard/{fragments}/TaskCard/TaskCard.vue @@ -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" @@ -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" @@ -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() @@ -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) @@ -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", @@ -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", ) } @@ -218,20 +224,29 @@ async function onLinkTask(side: keyof TaskRelationSets, taskId: Task["id"]) {