Gantt
Gantt — кастомный компонент, портированный из ReUI (keenthemes/reui, MIT).
Загрузка превью…
src/reui/gantt/Gantt.vue
<script setup lang="ts" generic="TData = unknown">
/**
* Порт ReUI Gantt (registry-reui/bases/radix/reui/gantt/gantt.tsx, MIT).
* Headless-стор — в context.ts (useGanttState); здесь только root-провайдер
* + контейнер, композиция <Gantt><GanttNav/><GanttView/></Gantt>.
*
* Упрощение относительно оригинала: `splitOptions`/`shallowEqualRecord`/
* `viewConfigRef` там существуют ради стабильности ССЫЛКИ на объект
* viewConfig между рендерами React (иначе на каждый инлайновый JSX все
* строки дерева переподписывались бы на новый контекст) — у Vue `computed`
* уже не пересчитывается, пока не изменится реально прочитанное поле, так
* что весь этот механизм не нужен: `viewConfig` ниже — обычный `computed`
* по списку известных ключей.
*
* `apiRef` (императивный доступ снаружи дерева) не портирован — Vue
* заменяет его `defineExpose`, но текущий кейс его не использует; добавить
* тривиально, когда понадобится.
*/
import type { ComputedRef, HTMLAttributes } from "vue"
import { computed, provide } from "vue"
import { Primitive } from "reka-ui"
import { cn } from "@/lib/utils"
import {
DEFAULT_VIEW_CONFIG,
GanttContextKey,
GanttViewConfigContextKey,
useGanttState,
type GanttInstance,
type GanttViewConfig,
type UseGanttStateOptions,
} from "./context"
defineOptions({ inheritAttrs: true })
// withDefaults(..., { field: undefined }) on every top-level `boolean` field:
// Vue casts an ABSENT prop whose type is plain `boolean` to `false` (not
// `undefined`) unless it has an explicit `default` key - `false !== undefined`
// then survives the VIEW_CONFIG_KEYS merge below and silently overwrites
// DEFAULT_VIEW_CONFIG's `true` defaults (see docs/PORTING.md §32, same bug
// already found once in EventCalendar.vue). `default: undefined` suppresses
// the cast so "not passed" reads as `undefined` again, as the type promises.
const props = withDefaults(
defineProps<
UseGanttStateOptions<TData> &
Partial<GanttViewConfig<TData>> & {
class?: HTMLAttributes["class"]
/** Adopt a hoisted useGanttState instance; option props are then ignored. */
calendar?: GanttInstance<TData>
asChild?: boolean
}
>(),
{
loading: undefined,
nowIndicator: undefined,
displayScheduleHint: undefined,
dragCreate: undefined,
displayCreateTaskHint: undefined,
zoomControl: undefined,
wheelZoom: undefined,
offscreenIndicators: undefined,
infiniteScroll: undefined,
stickyNav: undefined,
rowCheckboxes: undefined,
parentScheduling: undefined,
summaryBars: undefined,
offDays: undefined,
asChild: false,
}
)
const VIEW_CONFIG_KEYS = [
"nowIndicator",
"interval",
"scrollbars",
"displayScheduleHint",
"initialCenter",
"displayCreateTaskHint",
"dragCreate",
"zoomControl",
"wheelZoom",
"navButtonVariant",
"navButtonSize",
"offDays",
"columns",
"columnsMenu",
"treePanel",
"metrics",
"timelineLines",
"barLabel",
"offscreenIndicators",
"infiniteScroll",
"zoomRange",
"stickyNav",
"rowCheckboxes",
"selectedRows",
"onSelectedRowsChange",
"collapsedGroups",
"defaultCollapsedGroups",
"onCollapsedGroupsChange",
"zoom",
"defaultZoom",
"onZoomChange",
"parentScheduling",
"summaryBars",
"scheduleMode",
"rowAlign",
"classNames",
"renderEvent",
"renderEventMenu",
"renderResourceLabel",
"renderResourceMenu",
"renderNoResources",
"renderDragPreview",
"renderResizeIndicator",
"renderScheduleHint",
"renderSummary",
"getSummaryProgress",
] as const
const viewConfig = computed<GanttViewConfig<TData>>(() => {
const merged = { ...DEFAULT_VIEW_CONFIG } as unknown as GanttViewConfig<TData>
const source = props as unknown as Record<string, unknown>
for (const key of VIEW_CONFIG_KEYS) {
const value = source[key]
if (value !== undefined) (merged as unknown as Record<string, unknown>)[key] = value
}
return merged
})
// `own` is always constructed (even when adopting `calendar`): it is a plain
// composable call, cheap, and keeps this component free of a conditional
// hook-call footgun. Only its RESULT is discarded when `calendar` is set.
const own = useGanttState<TData>(props as UseGanttStateOptions<TData>)
const instance: GanttInstance<TData> = props.calendar ?? own
provide(GanttContextKey, instance as unknown as GanttInstance<unknown>)
provide(GanttViewConfigContextKey, viewConfig as unknown as ComputedRef<GanttViewConfig<unknown>>)
</script>
<template>
<Primitive
as="div"
:as-child="asChild ?? false"
data-slot="gantt"
:class="cn('text-foreground flex min-h-0 min-w-0 flex-col text-xs', props.class)"
>
<slot />
<div data-slot="gantt-announcer" aria-live="polite" class="sr-only" />
</Primitive>
</template>
src/reui/gantt/GanttBar.vue
<script setup lang="ts" generic="TData = unknown">
/**
* Порт ReUI GanttBar (registry-reui/bases/radix/reui/gantt/gantt-bar.tsx, MIT).
*
* ponytail: перенесены выбор/клики/дефолтный контент/прогресс-заливка/
* ручки ресайза (сама разметка `data-slot="gantt-resize-handle"`, реальные
* `data-*` атрибуты состояния). ЧТО ПРОПУЩЕНО в этом первом срезе и почему:
* - `Tooltip`/`TooltipProvider` (наведение -> диапазон дат) — не влияет на
* статичный скриншот (открывается только по hover), тот же выбор уже
* сделан для `EventCalendarEvent.vue` (см. его шапку, `eventTooltip`).
* Добавить как обёртку из `../../ui/tooltip`, когда появится
* интеракционный кейс.
* - `ContextMenu` (`viewConfig.renderEventMenu`) — рендерится только когда
* потребитель передал `renderEventMenu`; ни один текущий кейс этого не
* делает, поэтому условная ветка не реализована (просто нет `menu`).
* - Перетаскивание (`gestures.beginMove`/`beginResize`) — теперь реальное:
* `./gestures.ts` содержит полный порт `gantt-dnd.tsx` (ADR-002
* неприменим, там нативные pointer-события, не dnd-kit).
* `IconPlaceholder` (docs/PORTING.md §5) заменён инлайновым `<svg>` (пути
* lucide "repeat"/"check" v0.545.0), как и во всех остальных портах.
*/
import { computed, provide, type CSSProperties, type HTMLAttributes } from "vue"
import { Primitive, useForwardExpose } from "reka-ui"
import { cn } from "@/lib/utils"
import {
GanttBarContextKey,
useGantt,
useGanttSelector,
useGanttViewConfig,
type GanttBarContextValue,
} from "./context"
import { useGanttGestures, wasRecentDrag } from "./gestures"
import { flattenResources, toZoned } from "./lib"
import type { GanttSegment } from "./types"
const props = withDefaults(
defineProps<{
class?: HTMLAttributes["class"]
segment: GanttSegment<TData>
/**
* The title renders beside the bar (view-owned), so the default inner
* content is suppressed. Explicit children and renderEvent still win.
*/
labelOutside?: boolean
/** The owning row's title for the aria-label; falls back to a tree lookup. */
rowTitle?: string
asChild?: boolean
}>(),
{ labelOutside: false, asChild: false }
)
defineSlots<{ default?(): unknown }>()
const { forwardRef } = useForwardExpose()
const instance = useGantt<TData>()
const viewConfig = useGanttViewConfig<TData>()
const gestures = useGanttGestures<TData>()
const settings = instance.settings
const occurrence = computed(() => props.segment.occurrence)
const event = computed(() => occurrence.value.event)
const isSelected = useGanttSelector<TData, boolean>(
(state) => state.selection.eventKeys.includes(occurrence.value.key),
{ calendar: instance }
)
const isDragging = useGanttSelector<TData, boolean>(
(state) => state.drag?.occurrence.key === occurrence.value.key,
{ calendar: instance }
)
const dragKind = useGanttSelector<TData, string | null>(
(state) => (state.drag?.occurrence.key === occurrence.value.key ? state.drag.kind : null),
{ calendar: instance }
)
const progress = computed(() =>
typeof event.value.progress === "number"
? Math.min(Math.max(Math.round(event.value.progress), 0), 100)
: null
)
const renderProps = computed(() => ({
occurrence: occurrence.value,
segment: props.segment,
isDragging: isDragging.value,
isSelected: isSelected.value,
}))
const customContent = computed(() => viewConfig.value.renderEvent?.(renderProps.value))
const consumerOwnsContent = computed(() => !!viewConfig.value.renderEvent)
const timeLabel = computed(() =>
settings.value.i18n.functions.formatEventTime(
toZoned(occurrence.value.start, settings.value.timeZone),
toZoned(occurrence.value.end, settings.value.timeZone),
occurrence.value.allDay,
settings.value.locale
)
)
const rowTitle = computed(
() =>
props.rowTitle ??
(event.value.resourceId
? flattenResources(settings.value.resources).find(
({ resource }) => resource.id === event.value.resourceId
)?.resource.title
: undefined)
)
const showResize = computed(() => gestures.canResize(props.segment))
const ariaLabel = computed(() =>
settings.value.i18n.functions.formatEventAriaLabel({
title: event.value.title,
timeLabel: timeLabel.value,
rowTitle: rowTitle.value,
progressLabel:
progress.value !== null ? settings.value.i18n.labels.progress(progress.value) : undefined,
continues: props.segment.continuesBefore || props.segment.continuesAfter,
})
)
const barStyle = computed(
() =>
({
"--gantt-event-color": event.value.color ?? "var(--color-primary)",
}) as CSSProperties
)
function onPointerDown(e: PointerEvent) {
e.stopPropagation()
gestures.beginMove(e, props.segment)
}
function onClick(e: MouseEvent) {
e.stopPropagation()
if (wasRecentDrag()) return
instance.api.selectEvent(occurrence.value.key)
settings.value.onEventClick?.(occurrence.value, e)
}
function onDoubleClick(e: MouseEvent) {
e.stopPropagation()
settings.value.onEventDoubleClick?.(occurrence.value, e)
}
provide(GanttBarContextKey, {
get occurrence() {
return occurrence.value
},
get segment() {
return props.segment
},
get isDragging() {
return isDragging.value
},
get isSelected() {
return isSelected.value
},
} as unknown as GanttBarContextValue<unknown>)
</script>
<template>
<Primitive
:ref="forwardRef"
as="button"
:as-child="asChild"
type="button"
data-slot="gantt-bar"
:data-all-day="occurrence.allDay || undefined"
:data-recurring="occurrence.isRecurring || undefined"
:data-selected="isSelected || undefined"
:data-dragging="isDragging || undefined"
:data-drag-kind="dragKind ?? undefined"
:data-past="occurrence.end.getTime() < Date.now() || undefined"
:data-label-outside="labelOutside || undefined"
:data-progress="progress ?? undefined"
:data-completed="progress === 100 || undefined"
:aria-label="ariaLabel"
:style="barStyle"
:class="
cn(
'group/gantt-bar-group text-foreground @container relative flex w-full min-w-0 cursor-pointer touch-none items-center gap-1.5 overflow-hidden rounded-sm px-1.5 py-0.5 text-start leading-normal select-none',
'focus-visible:ring-ring/50 outline-none focus-visible:ring-2',
'bg-(--gantt-event-color)/20 hover:bg-(--gantt-event-color)/30',
'data-[drag-kind=move]:opacity-0',
'data-[drag-kind=resize-end]:opacity-40 data-[drag-kind=resize-start]:opacity-40',
'data-selected:bg-(--gantt-event-color)/30',
segment.continuesBefore && 'rounded-s-none',
segment.continuesAfter && 'rounded-e-none',
viewConfig.classNames?.event,
props.class
)
"
@pointerdown="onPointerDown"
@click="onClick"
@dblclick="onDoubleClick"
>
<span
v-if="progress !== null"
aria-hidden
data-slot="gantt-bar-progress"
class="pointer-events-none absolute inset-y-0 start-0 border-e border-(--gantt-event-color)/65 bg-(--gantt-event-color)/40 data-full:border-e-0"
:data-full="progress === 100 || undefined"
:style="{ width: `${progress}%` }"
/>
<svg
v-if="progress === 100 && !consumerOwnsContent"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="relative size-2.5 shrink-0 opacity-80"
aria-hidden="true"
><path d="M20 6 9 17l-5-5" /></svg>
<slot>
<component :is="() => customContent" v-if="customContent" />
<template v-else-if="!labelOutside">
<svg
v-if="occurrence.isRecurring"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="size-2.5 shrink-0 opacity-70"
aria-hidden="true"
><path d="m17 2 4 4-4 4" /><path d="M3 11v-1a4 4 0 0 1 4-4h14" /><path d="m7 22-4-4 4-4" /><path d="M21 13v1a4 4 0 0 1-4 4H3" /></svg>
<span class="truncate font-medium">{{ event.title }}</span>
<span
v-if="!occurrence.allDay && segment.isStart"
class="text-muted-foreground hidden truncate @[8rem]:inline"
>{{ timeLabel }}</span>
</template>
</slot>
<template v-if="showResize">
<span
v-if="segment.isStart"
data-slot="gantt-resize-handle"
data-edge="start"
class="absolute inset-y-0 start-0.5 flex w-2 cursor-ew-resize items-center justify-start opacity-0 group-hover/gantt-bar-group:opacity-100 pointer-coarse:opacity-100"
@pointerdown="(e: PointerEvent) => gestures.beginResize(e, segment, 'start')"
>
<span aria-hidden class="bg-foreground/40 h-2.5 w-0.5 rounded-full" />
</span>
<span
v-if="segment.isEnd"
data-slot="gantt-resize-handle"
data-edge="end"
class="absolute inset-y-0 end-0.5 flex w-2 cursor-ew-resize items-center justify-end opacity-0 group-hover/gantt-bar-group:opacity-100 pointer-coarse:opacity-100"
@pointerdown="(e: PointerEvent) => gestures.beginResize(e, segment, 'end')"
>
<span aria-hidden class="bg-foreground/40 h-2.5 w-0.5 rounded-full" />
</span>
</template>
</Primitive>
</template>
src/reui/gantt/GanttCustomDragLayer.vue
<script setup lang="ts" generic="TData = unknown">
/**
* Порт ReUI GanttCustomDragLayer (gantt-view.tsx, MIT).
*
* Consumer-owned drag/resize indicator (renderDragPreview/renderResizeIndicator):
* the dnd engine (gantt-dnd.tsx, not yet ported) adopts this wrapper and
* writes its cursor-tracking transform imperatively — nothing here does that
* yet, so the wrapper renders `visibility: hidden` exactly as the original
* does before its first positioned frame (no functional gap: without
* gantt-dnd.tsx there is no drag to position it for).
*/
import { computed } from "vue"
import { useGanttSelector, useGanttViewConfig } from "./context"
const viewConfig = useGanttViewConfig<TData>()
const drag = useGanttSelector<TData, unknown>((state) => state.drag)
const render = computed(() => {
const d = drag.value as {
kind: "move" | "resize-start" | "resize-end"
occurrence: unknown
proposedStart: Date
proposedEnd: Date
valid: boolean
} | null
if (!d) return null
const renderFn = d.kind === "move" ? viewConfig.value.renderDragPreview : viewConfig.value.renderResizeIndicator
if (!renderFn) return null
return {
slot: d.kind === "move" ? "gantt-drag-overlay" : "gantt-resize-indicator",
content: renderFn({
occurrence: d.occurrence,
kind: d.kind,
start: d.proposedStart,
end: d.proposedEnd,
valid: d.valid,
} as never),
}
})
</script>
<template>
<div
v-if="render"
:data-slot="render.slot"
data-custom=""
class="pointer-events-none fixed top-0 left-0 z-100 will-change-transform"
style="visibility: hidden"
>
<component :is="() => render!.content" />
</div>
</template>
src/reui/gantt/GanttDatePicker.vue
<script setup lang="ts">
/**
* Порт ReUI GanttDatePicker (gantt-nav.tsx, MIT) — компактный go-to-date
* пикер (Calendar в Popover). Без тултипа по дизайну (см. NavButtonProps
* policy оригинала — открывающая оверлей кнопка тултип не получает).
*
* Открытое состояние (Popover + Calendar) не проверяется дифом по ДВУМ
* уже задокументированным причинам сразу: позиционирование floating-слоя
* (docs/PORTING.md §5 `ui-dropdown-menu-open`) и раскладка сетки дней
* `date-selector`/`ui-calendar` (§5) — оба класса ограничения уже приняты
* для других компонентов. Кейс проверяет только закрытый триггер.
*/
import type { HTMLAttributes } from "vue"
import { ref } from "vue"
import { Button } from "@/components/ui/button"
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
import { Calendar, type CalendarDateRange } from "@/components/ui/calendar"
import { useGanttNavigation, useGanttSettings } from "./context"
import { useGanttNavButtonProps } from "./nav"
import { toZoned } from "./lib"
import { cn } from "@/lib/utils"
const props = defineProps<{ class?: HTMLAttributes["class"] }>()
const { date, goTo } = useGanttNavigation()
const settings = useGanttSettings()
const nav = useGanttNavButtonProps()
const open = ref(false)
// Calendar (mode="single", default) only ever emits a single `Date | undefined`
// here - the wider union is the primitive's general (multiple/range) signature.
function onSelect(next: Date | Date[] | CalendarDateRange | undefined) {
if (next instanceof Date) {
goTo(next)
open.value = false
}
}
</script>
<template>
<Popover v-model:open="open">
<PopoverTrigger as-child>
<Button :variant="nav.variant" :size="nav.iconSize" data-slot="gantt-date-picker" :aria-label="settings.i18n.labels.goToDate" :class="cn(props.class)">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4" aria-hidden="true"><rect width="18" height="18" x="3" y="4" rx="2" ry="2" /><line x1="16" x2="16" y1="2" y2="6" /><line x1="8" x2="8" y1="2" y2="6" /><line x1="3" x2="21" y1="10" y2="10" /></svg>
</Button>
</PopoverTrigger>
<PopoverContent align="start" class="w-auto p-0!">
<Calendar
:model-value="toZoned(date, settings.timeZone)"
:placeholder="toZoned(date, settings.timeZone)"
:week-starts-on="settings.weekStartsOn"
@update:model-value="onSelect"
/>
</PopoverContent>
</Popover>
</template>
src/reui/gantt/GanttNav.vue
<script setup lang="ts">
/**
* Порт ReUI GanttNav (gantt-nav.tsx, MIT) — собранная по умолчанию навигация:
* Today, переключатель шкалы, prev/next, заголовок, спейсер. `TooltipProvider`-
* обёртка не нужна — ни один дочерний нав-компонент тултип не рендерит
* (см. GanttNavToday.vue и др.).
*/
import type { HTMLAttributes } from "vue"
import { Primitive } from "reka-ui"
import { useGanttViewConfig } from "./context"
import { cn } from "@/lib/utils"
import GanttNavToday from "./GanttNavToday.vue"
import GanttNavPrev from "./GanttNavPrev.vue"
import GanttNavNext from "./GanttNavNext.vue"
import GanttTitle from "./GanttTitle.vue"
import GanttScaleSwitcher from "./GanttScaleSwitcher.vue"
const props = withDefaults(defineProps<{ class?: HTMLAttributes["class"]; asChild?: boolean }>(), { asChild: false })
defineSlots<{ default?(): unknown }>()
const viewConfig = useGanttViewConfig()
</script>
<template>
<Primitive
as="div"
:as-child="props.asChild"
data-slot="gantt-nav"
:class="
cn(
'flex min-w-0 flex-wrap items-center gap-2 border-b px-3 py-2',
viewConfig.stickyNav && 'bg-background sticky top-0 z-30',
viewConfig.classNames?.nav,
props.class
)
"
>
<slot>
<GanttNavToday />
<GanttScaleSwitcher />
<div class="flex items-center">
<GanttNavPrev />
<GanttNavNext />
</div>
<GanttTitle />
<div class="grow" />
</slot>
</Primitive>
</template>
src/reui/gantt/GanttNavNext.vue
<script setup lang="ts">
/** Порт ReUI GanttNavNext (gantt-nav.tsx, MIT). Tooltip не перенесён (см. GanttNavToday.vue). */
import type { HTMLAttributes } from "vue"
import { Button } from "@/components/ui/button"
import { useGanttNavigation, useGanttSettings } from "./context"
import { useGanttNavButtonProps } from "./nav"
import { cn } from "@/lib/utils"
const props = withDefaults(defineProps<{ class?: HTMLAttributes["class"]; asChild?: boolean }>(), { asChild: false })
const { next } = useGanttNavigation()
const settings = useGanttSettings()
const nav = useGanttNavButtonProps()
</script>
<template>
<Button
:variant="nav.variant"
:size="nav.iconSize"
:as-child="props.asChild"
data-slot="gantt-nav-next"
:aria-label="settings.i18n.labels.next"
:class="cn(props.class)"
@click="next"
>
<slot>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4" aria-hidden="true"><path d="m9 18 6-6-6-6" /></svg>
</slot>
</Button>
</template>
src/reui/gantt/GanttNavPrev.vue
<script setup lang="ts">
/** Порт ReUI GanttNavPrev (gantt-nav.tsx, MIT). Tooltip не перенесён (см. GanttNavToday.vue). */
import type { HTMLAttributes } from "vue"
import { Button } from "@/components/ui/button"
import { useGanttNavigation, useGanttSettings } from "./context"
import { useGanttNavButtonProps } from "./nav"
import { cn } from "@/lib/utils"
const props = withDefaults(defineProps<{ class?: HTMLAttributes["class"]; asChild?: boolean }>(), { asChild: false })
const { prev } = useGanttNavigation()
const settings = useGanttSettings()
const nav = useGanttNavButtonProps()
</script>
<template>
<Button
:variant="nav.variant"
:size="nav.iconSize"
:as-child="props.asChild"
data-slot="gantt-nav-prev"
:aria-label="settings.i18n.labels.previous"
:class="cn(props.class)"
@click="prev"
>
<slot>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4" aria-hidden="true"><path d="m15 18-6-6 6-6" /></svg>
</slot>
</Button>
</template>
src/reui/gantt/GanttNavToday.vue
<script setup lang="ts">
/**
* Порт ReUI GanttNavToday (gantt-nav.tsx, MIT). `Tooltip`-обёртка (наведение
* -> дата) не перенесена — тот же выбор/причина, что и во всём остальном
* порте (`GanttBar`/`GanttOffscreenChips`/`GanttZoomControl`, 16/16 у всех):
* не влияет на статичный скриншот.
*/
import type { HTMLAttributes } from "vue"
import { Button } from "@/components/ui/button"
import { useGanttNavigation, useGanttSettings } from "./context"
import { useGanttNavButtonProps } from "./nav"
import { cn } from "@/lib/utils"
const props = withDefaults(defineProps<{ class?: HTMLAttributes["class"]; asChild?: boolean }>(), { asChild: false })
const { today, isToday } = useGanttNavigation()
const settings = useGanttSettings()
const nav = useGanttNavButtonProps()
</script>
<template>
<Button
:variant="nav.variant"
:size="nav.size"
:as-child="props.asChild"
data-slot="gantt-nav-today"
:data-active="isToday || undefined"
:class="cn(props.class)"
@click="today"
>
<slot>{{ settings.i18n.labels.today }}</slot>
</Button>
</template>
src/reui/gantt/GanttNowDot.vue
<script setup lang="ts">
/**
* Порт ReUI GanttNowDot (gantt-view.tsx, MIT) — now-line's dot cap, pinned
* inside the sticky header at the header/body boundary.
*/
import { computed } from "vue"
import { useNow } from "./use-now"
const props = defineProps<{
rangeStartMs: number
rangeEndMs: number
}>()
const now = useNow()
const visible = computed(() => {
const ms = now.value.getTime()
return ms >= props.rangeStartMs && ms < props.rangeEndMs
})
const fraction = computed(() => (now.value.getTime() - props.rangeStartMs) / (props.rangeEndMs - props.rangeStartMs))
</script>
<template>
<span
v-if="visible"
aria-hidden
data-slot="gantt-now-dot"
class="bg-destructive absolute -bottom-0.75 z-10 size-1.5 -translate-x-1/2 rounded-full"
:style="{ insetInlineStart: `${fraction * 100}%` }"
/>
</template>
src/reui/gantt/GanttNowLine.vue
<script setup lang="ts">
/** Порт ReUI GanttNowLine (gantt-view.tsx, MIT) — red now-line on the axis. */
import { computed } from "vue"
import { useNow } from "./use-now"
const props = defineProps<{
rangeStartMs: number
rangeEndMs: number
}>()
const now = useNow()
const visible = computed(() => {
const ms = now.value.getTime()
return ms >= props.rangeStartMs && ms < props.rangeEndMs
})
const fraction = computed(() => (now.value.getTime() - props.rangeStartMs) / (props.rangeEndMs - props.rangeStartMs))
</script>
<template>
<div
v-if="visible"
data-slot="gantt-now-indicator"
class="from-destructive/80 via-destructive/45 to-destructive/15 absolute inset-y-0 z-10 w-px bg-linear-to-b"
:style="{ insetInlineStart: `${fraction * 100}%` }"
/>
</template>
src/reui/gantt/GanttOffscreenChips.vue
<script setup lang="ts">
/**
* Порт ReUI GanttOffscreenChips (gantt-view.tsx, MIT) — edge chips for rows
* whose bars sit entirely outside the visible timeline; clicking scrolls the
* bar back into view. Reads geometry straight from the DOM (row data
* attributes) so scrolling never re-renders the grid.
*
* ponytail: `Tooltip`/`TooltipProvider` не перенесены — тот же выбор и та же
* причина, что уже задокументирована и проверена (16/16) в `GanttBar.vue`
* (не влияет на статичный скриншот, `renderEventMenu`/hover-only слой).
* `IconPlaceholder` (chevron-left/chevron-right) заменён инлайновым `<svg>`.
*/
import { onBeforeUnmount, onMounted, ref, watch, type Ref } from "vue"
import { useGanttSettings } from "./context"
import { getPaneViewport, getScrollStart } from "./view-lib"
const props = defineProps<{
paneRef: HTMLElement | null
/** Any reactive value whose change should re-measure (occurrences length/identity). */
refreshKey: string
}>()
const settings = useGanttSettings()
interface OffscreenChip {
id: string
side: "start" | "end"
top: number
color?: string
label: string
startMs: number | null
target: number
insetEnd: number
}
function sameChips(a: OffscreenChip[], b: OffscreenChip[]): boolean {
return (
a.length === b.length &&
a.every(
(chip, i) =>
chip.id === b[i]!.id &&
chip.side === b[i]!.side &&
chip.top === b[i]!.top &&
chip.target === b[i]!.target &&
chip.insetEnd === b[i]!.insetEnd
)
)
}
const chips = ref<OffscreenChip[]>([]) as Ref<OffscreenChip[]>
let raf = 0
let observer: ResizeObserver | undefined
let viewportEl: HTMLElement | null = null
function measure() {
raf = 0
const pane = props.paneRef
const viewport = getPaneViewport(pane)
if (!pane || !viewport) return
const paneRect = pane.getBoundingClientRect()
const header = viewport.querySelector<HTMLElement>("[data-slot=gantt-timeline-header]")
const headerBottom = header ? header.getBoundingClientRect().bottom - paneRect.top : 0
const trackW = viewport.scrollWidth
const visibleStart = getScrollStart(viewport)
const visibleEnd = visibleStart + viewport.clientWidth
const zoomEl = pane.querySelector<HTMLElement>("[data-slot=gantt-zoom]")
const zoom = zoomEl
? {
top: zoomEl.getBoundingClientRect().top - paneRect.top - 8,
bottom: zoomEl.getBoundingClientRect().bottom - paneRect.top + 8,
inset: paneRect.right - zoomEl.getBoundingClientRect().left + 8,
}
: null
const next: OffscreenChip[] = []
for (const rowEl of viewport.querySelectorAll<HTMLElement>("[data-gantt-row]")) {
const from = parseFloat(rowEl.dataset.ganttBarMin ?? "")
const to = parseFloat(rowEl.dataset.ganttBarMax ?? "")
if (Number.isNaN(from) || Number.isNaN(to)) continue
const rect = rowEl.getBoundingClientRect()
const top = rect.top - paneRect.top + rect.height / 2
if (top < headerBottom + 10 || top > paneRect.height - 16) continue
const startPx = from * trackW
const endPx = to * trackW
const startMs = parseFloat(rowEl.dataset.ganttBarStartMs ?? "")
const base = {
id: rowEl.dataset.ganttRowId ?? "",
top: Math.round(top),
color: rowEl.dataset.ganttBarColor,
label: rowEl.dataset.ganttBarLabel ?? "",
startMs: Number.isNaN(startMs) ? null : startMs,
}
if (endPx <= visibleStart + 2) {
next.push({ ...base, side: "start", target: startPx - 24, insetEnd: 14 })
} else if (startPx >= visibleEnd - 2) {
const overlapsZoom = zoom && top >= zoom.top && top <= zoom.bottom
next.push({
...base,
side: "end",
target: endPx - viewport.clientWidth + 24,
insetEnd: overlapsZoom ? Math.max(14, zoom.inset) : 14,
})
}
}
if (!sameChips(chips.value, next)) chips.value = next
}
function schedule() {
if (!raf) raf = requestAnimationFrame(measure)
}
function teardown() {
if (viewportEl) viewportEl.removeEventListener("scroll", schedule)
observer?.disconnect()
if (raf) cancelAnimationFrame(raf)
raf = 0
observer = undefined
viewportEl = null
}
function setup() {
teardown()
const pane = props.paneRef
const viewport = getPaneViewport(pane)
if (!pane || !viewport) return
viewportEl = viewport
viewport.addEventListener("scroll", schedule)
observer = new ResizeObserver(schedule)
observer.observe(viewport)
schedule()
}
onMounted(setup)
onBeforeUnmount(teardown)
// refreshKey changes (occurrences/range) or the pane element itself showing
// up later both warrant a fresh measurement pass.
watch(() => [props.paneRef, props.refreshKey], setup)
function scrollTo(chip: OffscreenChip) {
const viewport = getPaneViewport(props.paneRef)
if (!viewport) return
const target = Math.max(0, chip.target)
viewport.scrollTo({
left: getComputedStyle(viewport).direction === "rtl" ? -target : target,
behavior: "smooth",
})
const bar = viewport.querySelector<HTMLElement>(
`[data-gantt-row-id="${CSS.escape(chip.id)}"] [data-slot=gantt-bar]`
)
bar?.focus({ preventScroll: true })
}
</script>
<template>
<div
v-if="chips.length > 0"
data-slot="gantt-offscreen-chips"
class="pointer-events-none absolute inset-0 z-30 overflow-hidden"
>
<button
v-for="chip in chips"
:key="`${chip.id}-${chip.side}`"
type="button"
data-slot="gantt-offscreen-chip"
:data-side="chip.side"
:aria-label="settings.i18n.labels.jumpToBar(chip.label)"
class="bg-background text-muted-foreground hover:text-foreground pointer-events-auto absolute flex size-5 -translate-y-1/2 cursor-pointer items-center justify-center rounded-full border shadow-xs"
:style="{
top: `${chip.top}px`,
...(chip.side === 'start' ? { insetInlineStart: '0.5rem' } : { insetInlineEnd: `${chip.insetEnd}px` }),
}"
@click="scrollTo(chip)"
>
<svg
v-if="chip.side === 'start'"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="size-3"
aria-hidden="true"
><path d="m15 18-6-6 6-6" /></svg>
<svg
v-else
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="size-3"
aria-hidden="true"
><path d="m9 18 6-6-6-6" /></svg>
<span
aria-hidden
class="ring-background absolute -end-px -top-px size-1.5 rounded-full ring-1"
:style="{ background: chip.color ?? 'var(--color-primary)' }"
/>
</button>
</div>
</template>
src/reui/gantt/GanttScaleSwitcher.vue
<script setup lang="ts">
/**
* Порт ReUI GanttScaleSwitcher (gantt-nav.tsx, MIT) — Day/Week/Month/Quarter/
* Year, ghost dropdown-кнопка. `Tooltip` на триггере не перенесён (см.
* GanttNavToday.vue) — контролируемый `open`/`tipOpen` из оригинала тоже не
* нужен без него; `DropdownMenu` остаётся управляемым, чтобы выбор пункта
* закрывал меню в тот же клик.
*/
import type { HTMLAttributes } from "vue"
import { ref } from "vue"
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { useGanttScale, useGanttSettings } from "./context"
import { useGanttNavButtonProps, GANTT_SCALES } from "./nav"
import { cn } from "@/lib/utils"
import type { GanttScale } from "./types"
const props = withDefaults(
defineProps<{
class?: HTMLAttributes["class"]
/** The offered scales, in menu order. Default: all five. */
scales?: GanttScale[]
}>(),
{ scales: () => GANTT_SCALES }
)
const { scale, setScale } = useGanttScale()
const settings = useGanttSettings()
const nav = useGanttNavButtonProps()
const open = ref(false)
function selectScale(next: GanttScale) {
open.value = false
setScale(next)
}
</script>
<template>
<DropdownMenu v-model:open="open">
<DropdownMenuTrigger as-child>
<Button :variant="nav.variant" :size="nav.size" data-slot="gantt-scale-switcher" :aria-label="settings.i18n.labels.selectView" :class="cn('gap-1', props.class)">
<slot>
{{ settings.i18n.labels.scales[scale] }}
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4 opacity-60" aria-hidden="true"><path d="m6 9 6 6 6-6" /></svg>
</slot>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" class="min-w-36">
<DropdownMenuGroup>
<DropdownMenuLabel class="text-muted-foreground font-normal">{{ settings.i18n.labels.selectView }}</DropdownMenuLabel>
<DropdownMenuItem v-for="value in props.scales" :key="value" :data-active="scale === value || undefined" @click="selectScale(value)">
{{ settings.i18n.labels.scales[value] }}
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</template>
src/reui/gantt/GanttTimelineHeader.vue
<script setup lang="ts">
/**
* Порт двухрядного заголовка шкалы времени из GanttView (gantt-view.tsx,
* MIT, разметка строк ~2023-2134). Юниты/группы считает `useGanttTimelineUnits`
* (../timeline-units.ts) — сама модель шкалы (день/квартал/год/неделя/месяц
* с весами по РЕАЛЬНОЙ длительности) уже была перенесена дословно туда.
*
* ponytail: `onPointerDown={beginHeaderPan}` (перетаскивание таймлайна за
* заголовок) не перенесён — это часть секции скролла/бесконечной прокрутки
* (следующий кусок GanttView), заголовок сам по себе от неё не зависит.
* `zoom` принят пропом со значением по умолчанию 1 (сама секция зума ещё не
* портирована) — когда она появится, `GanttView` прокинет реальное значение
* без изменений в этом компоненте.
*/
import { computed } from "vue"
import { useGanttSettings, useGanttViewConfig, resolveTimelineLines } from "./context"
import { useGanttTimelineUnits } from "./timeline-units"
import { computeUnitFractions, createHintStopResolver } from "./view-lib"
import type { GanttDateRange, GanttScale } from "./types"
import { cn } from "@/lib/utils"
const props = withDefaults(
defineProps<{
scale: GanttScale
range: GanttDateRange
/** Day-scale unit interval in minutes; defaults to the interval view config. */
interval?: number
/** Zoom multiplier; the zoom section (not yet ported) will drive this. Default 1. */
zoom?: number
}>(),
{ zoom: 1 }
)
const settings = useGanttSettings()
const viewConfig = useGanttViewConfig()
const scaleRef = computed(() => props.scale)
const rangeRef = computed(() => props.range)
const intervalRef = computed(() => Math.min(Math.max(props.interval ?? viewConfig.value.interval, 15), 240))
const timeline = useGanttTimelineUnits(scaleRef, rangeRef, intervalRef)
const units = computed(() => timeline.value.units)
const groups = computed(() => timeline.value.groups)
const trackRemWidth = computed(() => units.value.length * timeline.value.unitWidthRem * props.zoom)
const trackWidth = computed(() => `${trackRemWidth.value}rem`)
const totalWeight = computed(() => units.value.reduce((sum, unit) => sum + unit.weight, 0))
const uniform = computed(() =>
units.value.every((unit) => Math.abs(unit.weight - (units.value[0]?.weight ?? 0)) < 1e-9)
)
/** Cumulative start/width fractions per unit, for backdrop stripes/lines. */
const unitFractions = computed(() => computeUnitFractions(units.value))
/** Snap-to-unit resolver for the hint tile; GanttTimelineRow consumes this. */
const resolveHintStop = computed(() => createHintStopResolver(unitFractions.value))
defineExpose({ resolveHintStop, trackWidth, trackRemWidth })
/** Group boundary fractions; spans are in unit-weight terms everywhere. */
const groupBoundaries = computed(() => {
const fractions: number[] = []
let acc = 0
for (let i = 0; i < groups.value.length - 1; i++) {
acc += groups.value[i]!.span
fractions.push(acc / totalWeight.value)
}
return fractions
})
const gridLines = computed(() => resolveTimelineLines(viewConfig.value.timelineLines))
const showUnitLines = computed(() => !uniform.value || gridLines.value.vertical !== null)
const offDayClassName = computed(
() => (typeof viewConfig.value.offDays === "object" && viewConfig.value.offDays.className) || "bg-muted/40"
)
const rangeStartMs = computed(() => props.range.start.getTime())
const rangeEndMs = computed(() => props.range.end.getTime())
const snapMin = computed(() => (props.scale === "day" ? settings.value.snapDuration : 24 * 60))
</script>
<template>
<div
data-slot="gantt-timeline-header"
class="bg-background sticky top-0 z-30 shrink-0 border-b"
:style="{ minWidth: trackWidth }"
>
<!-- group sectors; boundaries painted like the body lines -->
<div class="relative h-8 border-b">
<div class="flex h-full">
<div
v-for="group in groups"
:key="group.key"
data-slot="gantt-axis-group"
class="text-muted-foreground flex min-w-0 items-center ps-3 pe-2"
:style="{ flex: `${group.span} 0 0px` }"
>
<span data-slot="gantt-axis-group-label" class="sticky start-3 max-w-full truncate">{{ group.label }}</span>
</div>
</div>
<span
v-for="fraction in groupBoundaries"
:key="fraction"
aria-hidden
class="bg-border absolute inset-y-0 w-px"
:style="{ insetInlineStart: `calc(${fraction * 100}% - 1px)` }"
/>
</div>
<!-- units, engine axis = this row -->
<div
data-gantt-axis=""
:data-gantt-range-start="rangeStartMs"
:data-gantt-range-end="rangeEndMs"
:data-gantt-snap="snapMin"
class="relative h-8"
>
<div aria-hidden class="absolute inset-0">
<span
v-for="{ unit, start, width } in unitFractions.filter((f) => f.unit.isOff)"
:key="unit.key"
:class="cn('absolute inset-y-0', offDayClassName)"
:style="{ insetInlineStart: `${start * 100}%`, width: `${width * 100}%` }"
/>
</div>
<div class="flex h-full">
<div
v-for="unit in units"
:key="unit.key"
:data-today="unit.isToday || undefined"
:data-off="unit.isOff || undefined"
:class="
cn(
'flex min-w-0 items-center justify-center truncate px-1.5 text-center',
'text-muted-foreground',
unit.isToday && 'text-primary font-medium'
)
"
:style="{ flex: `${unit.weight} 0 0px` }"
>
<span v-if="unit.isToday" class="bg-primary/10 truncate rounded-full px-1.5 py-px">{{ unit.label }}</span>
<template v-else>{{ unit.label }}</template>
</div>
</div>
<template v-if="showUnitLines">
<span
v-for="{ unit, start } in unitFractions.slice(1)"
:key="unit.key"
aria-hidden
data-slot="gantt-grid-line"
data-axis="vertical"
:class="
cn(
'absolute inset-y-0 w-px',
gridLines.vertical === 'dashed'
? 'bg-[repeating-linear-gradient(to_bottom,var(--color-border)_0,var(--color-border)_3px,transparent_3px,transparent_6px)]'
: 'bg-border'
)
"
:style="{ insetInlineStart: `calc(${start * 100}% - 1px)` }"
/>
</template>
</div>
</div>
</template>
src/reui/gantt/GanttTimelineRow.vue
<script setup lang="ts" generic="TData = unknown">
/**
* Порт ReUI GanttTimelineRow (gantt-view.tsx, MIT, разметка ~2842-3579) —
* дорожка таймлайна одной строки: бары, drag-create hint (наведение),
* ghost/draft/summary оверлеи.
*
* `ghost`/`draft` (визуальные оверлеи перетаскивания бара/резайза/
* drag-create) читают `instance.state.drag`/`slotDraft`, которые пишет
* `gestures.ts` (полный порт `gantt-dnd.tsx`) во время реального жеста —
* в статичном скриншоте оба всегда `null` (нет активного жеста), поэтому
* оверлеи проверены только интеракционным гейтом
* (`tools/visual-diff/interactions/gantt.mjs`), не визуальным дифом.
*
* Наведение-hint (кружок под курсором над пустым треком) — чистая
* математика указателя (`trackPoint`/`resolveHintStop`, ../view-lib.ts),
* без зависимости от dnd-движка. Клик по нему/по пустому треку коммитит
* через `settings.onSlotClick`/`onSelectSlot` (headless-контракт, не dnd).
* `viewConfig.dragCreate` (press-and-drag вместо click) вызывает
* `gestures.beginCreate` — реальный жест.
*
* `renderSummary`/`renderScheduleHint` (VNodeChild-фактории) рендерятся
* через `<component :is="() => fn(...)" />`, тот же приём, что и в
* `GanttBar.vue`/`filters.tsx` (см. docs/PORTING.md).
*/
import { computed, ref } from "vue"
import {
useGantt,
useGanttSelector,
useGanttSettings,
useGanttViewConfig,
resolveScheduleMode,
} from "./context"
import { useGanttGestures, wasRecentDrag } from "./gestures"
import { trackFraction, trackPoint, type TimelineRowBars, type TimelineRow } from "./view-lib"
import { cn } from "@/lib/utils"
import GanttBar from "./GanttBar.vue"
const props = defineProps<{
row: TimelineRow
rowIndex: number
bars?: TimelineRowBars
rangeStartMs: number
rangeEndMs: number
trackWidth: string
trackRemWidth: number
rowBorder: "solid" | "dashed" | null
selected: boolean
resolveHintStop: (fraction: number) => { index: number; center: number; ms: number; endMs: number } | null
isPanning: boolean
laneHeightRem: number
laneGapRem: number
minRowRem: number
}>()
const instance = useGantt<TData>()
const settings = useGanttSettings<TData>()
const viewConfig = useGanttViewConfig<TData>()
const gestures = useGanttGestures<TData>()
const segments = computed(() => props.bars?.segments ?? [])
const schedulable = computed(() => !props.row.isGroup || viewConfig.value.parentScheduling)
const heightRem = computed(() => props.bars?.heightRem ?? props.minRowRem)
const laneCount = computed(() => props.bars?.laneCount ?? 1)
const singleTrack = computed(
() => resolveScheduleMode(props.row.resource, viewConfig.value.scheduleMode) === "single"
)
const laneOffsetRem = computed(() => props.bars?.laneOffsetRem ?? (props.minRowRem - props.laneHeightRem) / 2)
const hintStop = ref<{ index: number; center: number; ms: number; endMs: number } | null>(null)
const hintSuppressed = useGanttSelector<TData, boolean>(
(state) => hintStop.value !== null && (state.drag !== null || state.slotDraft !== null)
)
const canSchedule = useGanttSelector<TData, boolean>((state) => state.interactions.selectSlot)
const hintFreeLane = computed(() => {
if (!hintStop.value) return 0
const padMs = 30 * 60000 // MIN_PACK_SLOT, matches lowestFreeLane's own pad
const to = Math.max(hintStop.value.endMs, hintStop.value.ms + padMs)
const busy = new Set<number>()
for (const segment of segments.value) {
const segStart = segment.occurrence.start.getTime()
const segEnd = Math.max(segment.occurrence.end.getTime(), segStart + padMs)
if (segStart < to && segEnd > hintStop.value.ms) busy.add(segment.column ?? 0)
}
let lane = 0
while (busy.has(lane)) lane += 1
return lane
})
const hintLane = computed(() => (props.bars?.scheduleMode === "single" ? 0 : hintFreeLane.value))
const hintTopRem = computed(() =>
Math.min(
laneOffsetRem.value + hintLane.value * (props.laneHeightRem + props.laneGapRem) + props.laneHeightRem / 2,
Math.max(heightRem.value - props.laneHeightRem / 2, props.laneHeightRem / 2)
)
)
const hintVisible = computed(
() => hintStop.value !== null && hintFreeLane.value < laneCount.value && !hintSuppressed.value && !props.isPanning
)
const hintLabel = computed(() =>
viewConfig.value.dragCreate ? settings.value.i18n.labels.scheduleHintDrag : settings.value.i18n.labels.scheduleHint
)
const showHint = computed(
() =>
viewConfig.value.displayScheduleHint &&
schedulable.value &&
canSchedule.value &&
!props.isPanning &&
!!(settings.value.onSelectSlot || settings.value.onSlotClick)
)
function fractionOf(ms: number): number {
return Math.min(Math.max((ms - props.rangeStartMs) / (props.rangeEndMs - props.rangeStartMs), 0), 1)
}
const dragTarget = useGanttSelector<TData, "valid" | "invalid" | null>((state) => {
const drag = state.drag
if (!drag || drag.proposedResourceId !== props.row.resource.id) return null
return drag.valid ? "valid" : "invalid"
})
const ghost = useGanttSelector<
TData,
{ from: number; to: number; color?: string; valid: boolean; title: string; kind: string; occurrenceKey: string } | null
>((state) => {
const drag = state.drag
if (!drag || drag.proposedResourceId !== props.row.resource.id) return null
return {
from: fractionOf(drag.proposedStart.getTime()),
to: fractionOf(drag.proposedEnd.getTime()),
color: drag.occurrence.event.color,
valid: drag.valid,
title: drag.occurrence.event.title,
kind: drag.kind,
occurrenceKey: drag.occurrence.key,
}
})
const draft = useGanttSelector<TData, { from: number; to: number; startMs: number; endMs: number } | null>((state) => {
const slotDraft = state.slotDraft
if (!slotDraft || slotDraft.resourceId !== props.row.resource.id) return null
const startMs = slotDraft.start.getTime()
const endMs = slotDraft.end.getTime()
return { from: fractionOf(startMs), to: fractionOf(endMs), startMs, endMs }
})
const draftLane = computed(() => props.bars?.draftLane ?? 0)
const draftTopRem = computed(() =>
Math.min(
laneOffsetRem.value + draftLane.value * (props.laneHeightRem + props.laneGapRem),
Math.max(heightRem.value - props.laneHeightRem, 0)
)
)
const draftLabel = computed(() =>
draft.value
? settings.value.i18n.functions.formatEventTime(
new Date(draft.value.startMs),
new Date(draft.value.endMs),
false,
settings.value.locale
)
: ""
)
function createAt(stop: { ms: number; endMs: number }, e: MouseEvent) {
if (settings.value.onSlotClick) {
settings.value.onSlotClick({ date: new Date(stop.ms), allDay: false, resourceId: props.row.resource.id }, e)
} else {
settings.value.onSelectSlot?.({
start: new Date(stop.ms),
end: new Date(Math.max(stop.endMs, stop.ms + 1)),
allDay: false,
resourceId: props.row.resource.id,
})
}
}
const ghostLane = computed(() => {
if (!ghost.value) return 0
return segments.value.find((s) => s.occurrence.key === ghost.value!.occurrenceKey)?.column ?? 0
})
const ghostLaneOffsetRem = computed(
() => laneOffsetRem.value + ghostLane.value * (props.laneHeightRem + props.laneGapRem) + (props.laneHeightRem - 1.25) / 2
)
function onRowPointerDown(e: PointerEvent) {
const target = e.target as HTMLElement
if (
viewConfig.value.dragCreate &&
e.button === 0 &&
schedulable.value &&
canSchedule.value &&
(target === e.currentTarget || !!target.closest?.("[data-slot=gantt-schedule-hint]")) &&
!!settings.value.onSelectSlot
) {
const stop = props.resolveHintStop(trackFraction(e.currentTarget as HTMLElement, e.clientX))
const allowed =
stop !== null &&
(settings.value.canSelectSlot?.({
start: new Date(stop.ms),
end: new Date(Math.max(stop.endMs, stop.ms + 1)),
allDay: false,
resourceId: props.row.resource.id,
}) ?? true)
if (!allowed) return
e.stopPropagation()
gestures.beginCreate(e)
}
}
function onRowPointerMove(e: PointerEvent) {
const interacting = instance.state.value.drag !== null || instance.state.value.slotDraft !== null
if (!showHint.value || e.pointerType !== "mouse" || interacting) {
if (hintStop.value) hintStop.value = null
return
}
if (e.target !== e.currentTarget) {
if (hintStop.value) hintStop.value = null
return
}
const el = e.currentTarget as HTMLElement
const { fraction, offset } = trackPoint(el, e.clientX)
el.style.setProperty("--gantt-hint-x", `${offset}px`)
const stop = props.resolveHintStop(fraction)
const allowed =
stop !== null &&
(settings.value.canSelectSlot?.({
start: new Date(stop.ms),
end: new Date(Math.max(stop.endMs, stop.ms + 1)),
allDay: false,
resourceId: props.row.resource.id,
}) ?? true)
const next = allowed ? stop : null
if (next?.index !== hintStop.value?.index) hintStop.value = next
}
function onRowPointerLeave() {
if (hintStop.value) hintStop.value = null
}
function onRowClick(e: MouseEvent) {
if (!viewConfig.value.dragCreate || e.target !== e.currentTarget) return
if (!schedulable.value || !canSchedule.value) return
if (wasRecentDrag()) return
if (!settings.value.onSlotClick && !settings.value.onSelectSlot) return
const stop = props.resolveHintStop(trackFraction(e.currentTarget as HTMLElement, e.clientX))
if (!stop) return
const allowed =
settings.value.canSelectSlot?.({
start: new Date(stop.ms),
end: new Date(Math.max(stop.endMs, stop.ms + 1)),
allDay: false,
resourceId: props.row.resource.id,
}) ?? true
if (!allowed) return
createAt(stop, e)
hintStop.value = null
}
function segmentGeometry(segment: TimelineRowBars["segments"][number]) {
const from = fractionOf(props.rangeStartMs + (segment.startMin ?? 0) * 60000)
const to = fractionOf(props.rangeStartMs + (segment.endMin ?? 0) * 60000)
const lane = segment.column ?? 0
const barRemWidth = (to - from) * props.trackRemWidth
const wantsOutside =
viewConfig.value.barLabel === "outside" ||
(viewConfig.value.barLabel === "auto" && barRemWidth < (viewConfig.value.metrics?.autoLabelMin ?? 7))
const placement = !wantsOutside ? "inside" : to <= 0.92 ? "after" : from >= 0.08 ? "before" : "inside"
return { from, to, lane, placement }
}
</script>
<template>
<div
data-gantt-row=""
:data-gantt-resource="row.resource.id"
:data-gantt-row-id="row.resource.id"
:data-gantt-row-static="!schedulable || undefined"
:data-gantt-bar-min="bars?.extent?.from"
:data-gantt-bar-max="bars?.extent?.to"
:data-gantt-bar-color="bars?.extent?.color"
:data-gantt-bar-label="bars?.extent?.label"
:data-gantt-bar-start-ms="bars?.extent?.startMs"
:data-drop-target="dragTarget ?? undefined"
:data-selected="selected || undefined"
:class="
cn(
'data-hover:bg-muted/30 data-selected:bg-primary/5 data-selected:data-hover:bg-primary/5 relative w-full min-w-0',
hintVisible && 'cursor-crosshair',
rowBorder !== null && 'border-b',
rowBorder === 'dashed' && 'border-dashed',
dragTarget === 'valid' && 'bg-muted/40',
dragTarget === 'invalid' && 'bg-destructive/10'
)
"
:style="{ height: `${heightRem}rem`, minWidth: trackWidth }"
@pointerdown="onRowPointerDown"
@pointermove="onRowPointerMove"
@pointerleave="onRowPointerLeave"
@click="onRowClick"
>
<div class="pointer-events-none absolute inset-0" style="content-visibility: auto">
<template v-for="(segment, segmentIndex) in segments" :key="segment.occurrence.key">
<div
v-if="segmentGeometry(segment).to > segmentGeometry(segment).from"
:data-drag-kind="ghost && ghost.occurrenceKey === segment.occurrence.key ? ghost.kind : undefined"
:data-lane="segmentGeometry(segment).lane"
:data-lane-count="laneCount"
class="group/gantt-seg pointer-events-auto absolute px-px data-[drag-kind=move]:opacity-0"
:style="{
insetInlineStart: `${segmentGeometry(segment).from * 100}%`,
width: `${Math.max((segmentGeometry(segment).to - segmentGeometry(segment).from) * 100, 0.5)}%`,
top: `${laneOffsetRem + segmentGeometry(segment).lane * (laneHeightRem + laneGapRem)}rem`,
height: `${laneHeightRem}rem`,
zIndex: segment.occurrence.event.zIndex ?? 10 + (singleTrack ? segmentIndex : segmentGeometry(segment).lane),
}"
>
<GanttBar :segment="segment" :label-outside="segmentGeometry(segment).placement !== 'inside'" :row-title="row.resource.title" class="h-full" />
<span
v-if="segmentGeometry(segment).placement !== 'inside'"
data-slot="gantt-bar-label"
:data-placement="segmentGeometry(segment).placement"
:class="
cn(
'text-foreground pointer-events-none absolute top-1/2 z-10 max-w-60 -translate-y-1/2 truncate font-medium',
'group-data-[drag-kind^=resize]/gantt-seg:opacity-0',
segmentGeometry(segment).placement === 'after' ? 'start-full ms-2' : 'end-full me-2'
)
"
>
{{ segment.occurrence.event.title }}
</span>
</div>
</template>
<div
v-if="bars?.summary && segments.length === 0"
data-slot="gantt-summary"
aria-hidden
class="pointer-events-none absolute top-1/2 -translate-y-1/2"
:style="{
insetInlineStart: `${bars.summary.from * 100}%`,
width: `${Math.max((bars.summary.to - bars.summary.from) * 100, 0.5)}%`,
}"
>
<component
:is="
() =>
viewConfig.renderSummary!({
resource: row.resource,
start: new Date(rangeStartMs + bars!.summary!.from * (rangeEndMs - rangeStartMs)),
end: new Date(rangeStartMs + bars!.summary!.to * (rangeEndMs - rangeStartMs)),
progress: bars!.summary!.progress,
})
"
v-if="viewConfig.renderSummary"
/>
<template v-else>
<span aria-hidden class="bg-muted-foreground/50 absolute start-0 top-1/2 h-3 w-0.5 -translate-y-1/2 rounded-full" />
<span aria-hidden class="bg-muted-foreground/50 absolute end-0 top-1/2 h-3 w-0.5 -translate-y-1/2 rounded-full" />
<div class="bg-muted-foreground/20 relative h-1.5 overflow-hidden rounded-full">
<div
v-if="bars.summary.progress !== null"
data-slot="gantt-summary-progress"
class="bg-muted-foreground/50 absolute inset-y-0 start-0 rounded-full"
:style="{ width: `${bars.summary.progress}%` }"
/>
</div>
<span v-if="bars.summary.progress !== null" class="text-muted-foreground absolute start-full top-1/2 ms-2 -translate-y-1/2 whitespace-nowrap">
{{ bars.summary.progress }}%
</span>
</template>
</div>
<div
v-if="ghost"
data-slot="gantt-drag-ghost"
:data-kind="ghost.kind"
:data-drop-invalid="!ghost.valid || undefined"
:class="
cn(
'pointer-events-none absolute z-40 h-5 rounded-sm border border-dashed font-medium',
!ghost.valid && 'border-destructive bg-destructive/10 text-destructive',
ghost.valid && ghost.kind === 'move' && 'border-(--gantt-event-color)/50 bg-(--gantt-event-color)/8',
ghost.valid && ghost.kind !== 'move' && 'text-foreground border-(--gantt-event-color)/70 bg-(--gantt-event-color)/22'
)
"
:style="{
insetInlineStart: `${ghost.from * 100}%`,
width: `${Math.max((ghost.to - ghost.from) * 100, 0.5)}%`,
top: `${ghostLaneOffsetRem}rem`,
'--gantt-event-color': ghost.color ?? 'var(--color-primary)',
}"
>
<span v-if="ghost.kind !== 'move'" class="pointer-events-none absolute start-full top-1/2 ms-2 max-w-60 -translate-y-1/2 truncate whitespace-nowrap">
{{ ghost.title }}
</span>
</div>
<div
v-if="draft"
data-slot="gantt-slot-draft"
:data-lane="draftLane"
class="border-primary bg-background pointer-events-none absolute z-40 overflow-hidden rounded-sm border border-dashed"
:style="{
insetInlineStart: `${draft.from * 100}%`,
width: `${Math.max((draft.to - draft.from) * 100, 0.5)}%`,
top: `${draftTopRem}rem`,
height: `${laneHeightRem}rem`,
}"
>
<span aria-hidden class="bg-primary/15 absolute inset-0" />
</div>
</div>
<div
v-if="draft && draftLabel"
data-slot="gantt-slot-draft-label"
class="pointer-events-none absolute z-40"
:style="{ insetInlineStart: `${draft.to * 100}%`, top: `${draftTopRem}rem`, height: `${laneHeightRem}rem` }"
>
<span class="bg-foreground text-background absolute start-full top-1/2 ms-2 inline-flex w-max -translate-y-1/2 items-center rounded-md px-2 py-1 text-xs font-medium whitespace-nowrap">
{{ draftLabel }}
<span aria-hidden class="bg-foreground absolute start-0 top-1/2 size-1.5 -translate-x-1/2 -translate-y-1/2 rotate-45 rounded-[1px]" />
</span>
</div>
<div
v-if="hintVisible && hintStop"
data-slot="gantt-schedule-hint"
class="pointer-events-none absolute z-30 -translate-x-1/2 -translate-y-1/2 rtl:translate-x-1/2"
:style="{ insetInlineStart: 'var(--gantt-hint-x, 50%)', top: `${hintTopRem}rem` }"
>
<component :is="() => viewConfig.renderScheduleHint!({ start: new Date(hintStop!.ms), end: new Date(Math.max(hintStop!.endMs, hintStop!.ms + 1)), resource: row.resource })" v-if="viewConfig.renderScheduleHint" />
<template v-else>
<button
type="button"
data-slot="gantt-schedule-hint-tile"
:aria-label="hintLabel"
class="border-primary ring-background/80 pointer-events-none block size-5 rounded-full border-2 bg-transparent ring-1"
@pointerdown="(e) => { if (!viewConfig.dragCreate) e.stopPropagation() }"
@click="(e) => { e.stopPropagation(); if (!wasRecentDrag()) { createAt(hintStop!, e); hintStop = null } }"
/>
<span
data-slot="gantt-schedule-hint-bubble"
:class="
cn(
'bg-foreground text-background absolute start-1/2 inline-flex w-max -translate-x-1/2 items-center rounded-md px-2 py-1 text-xs font-medium whitespace-nowrap',
rowIndex === 0 ? 'top-full mt-2' : 'bottom-full mb-2'
)
"
>
{{ hintLabel }}
<span
aria-hidden
:class="cn('bg-foreground absolute start-1/2 size-2.5 -translate-x-1/2 rotate-45 rounded-[2px]', rowIndex === 0 ? '-top-1' : '-bottom-1')"
/>
</span>
</template>
</div>
</div>
</template>
src/reui/gantt/GanttTitle.vue
<script setup lang="ts">
/** Порт ReUI GanttTitle (gantt-nav.tsx, MIT). */
import type { HTMLAttributes, VNodeChild } from "vue"
import { computed } from "vue"
import { Primitive } from "reka-ui"
import { useGanttNavigation } from "./context"
import { cn } from "@/lib/utils"
const props = withDefaults(
defineProps<{
class?: HTMLAttributes["class"]
asChild?: boolean
format?: (ctx: { title: string }) => VNodeChild
}>(),
{ asChild: false }
)
defineSlots<{ default?(): unknown }>()
const { title } = useGanttNavigation()
const formatted = computed(() => props.format?.({ title: title.value }))
</script>
<template>
<Primitive
as="div"
:as-child="props.asChild"
data-slot="gantt-title"
aria-live="polite"
:class="cn('min-w-0 truncate text-sm font-semibold', props.class)"
>
<slot>
<component :is="() => formatted" v-if="formatted" />
<template v-else>{{ title }}</template>
</slot>
</Primitive>
</template>
src/reui/gantt/GanttToolbar.vue
<script setup lang="ts">
/** Порт ReUI GanttToolbar (gantt-nav.tsx, MIT) — свободный слот для кнопок потребителя, чистая layout-обёртка. */
import type { HTMLAttributes } from "vue"
import { Primitive } from "reka-ui"
import { useGanttViewConfig } from "./context"
import { cn } from "@/lib/utils"
const props = withDefaults(defineProps<{ class?: HTMLAttributes["class"]; asChild?: boolean }>(), { asChild: false })
const viewConfig = useGanttViewConfig()
</script>
<template>
<Primitive
as="div"
:as-child="props.asChild"
data-slot="gantt-toolbar"
:class="cn('flex items-center gap-2', viewConfig.classNames?.toolbar, props.class)"
>
<slot />
</Primitive>
</template>
src/reui/gantt/GanttTreeRow.vue
<script setup lang="ts">
/**
* Порт ReUI GanttTreeRow (gantt-view.tsx, MIT, разметка ~2627-2840) — одна
* строка дерева ресурсов: чекбокс (лист) / шеврон свёртки (группа),
* отступ по глубине, ручка reorder (опционально), доп. колонки.
*
* ponytail: `ContextMenu` (`viewConfig.renderResourceMenu`) не перенесён —
* тот же выбор/причина, что и `GanttBar`/`GanttOffscreenChips`/
* `GanttZoomControl` (16/16 у всех трёх): не влияет на статичный скриншот,
* рендерится только при переданном `renderResourceMenu`. Сама РУЧКА
* reorder рендерится (проп `onGripPointerDown`/эмит `grip-pointerdown"`),
* но жест перетаскивания строки — часть `gantt-dnd.tsx` (последний файл),
* здесь только разметка и проброс события, как и в оригинале (сам
* `GanttTreeRow` тоже не владеет жестом, только зовёт переданный колбэк).
*/
import { computed } from "vue"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import { useGanttSettings, useGanttViewConfig, DEFAULT_ROW_ALIGN, type GanttColumn } from "./context"
import { cn } from "@/lib/utils"
import type { TimelineRow } from "./view-lib"
const DEFAULT_COLUMN_WIDTH = 96
const props = defineProps<{
row: TimelineRow
heightRem: number
bandRem: number
columns: GanttColumn[]
nameWidth: number
dimmed: boolean
selected: boolean
/** Omit to hide the checkbox column entirely (matches `onSelectedChange` absent upstream). */
showCheckbox?: boolean
/** Present -> renders the reorder grip; absent -> no grip (matches `onGripPointerDown` absent upstream). */
reorderable?: boolean
}>()
const emit = defineEmits<{
toggle: [row: TimelineRow]
"selected-change": [id: string, checked: boolean]
"grip-pointerdown": [e: PointerEvent, row: TimelineRow]
}>()
const settings = useGanttSettings()
const viewConfig = useGanttViewConfig()
const ctx = computed(() => ({
resource: props.row.resource,
depth: props.row.depth,
isGroup: props.row.isGroup,
collapsed: props.row.collapsed,
}))
const alignStart = computed(() => (viewConfig.value.rowAlign ?? DEFAULT_ROW_ALIGN) === "start")
const resourceLabel = computed(() => viewConfig.value.renderResourceLabel?.(ctx.value))
function onRowClick(e: MouseEvent) {
if (!settings.value.onResourceClick) return
if ((e.target as HTMLElement).closest("button, [role=checkbox]")) return
settings.value.onResourceClick(ctx.value, e)
}
function onRowDoubleClick(e: MouseEvent) {
if (!settings.value.onResourceDoubleClick) return
if ((e.target as HTMLElement).closest("button, [role=checkbox]")) return
settings.value.onResourceDoubleClick(ctx.value, e)
}
</script>
<template>
<div
data-slot="gantt-row-group"
:data-gantt-row-id="row.resource.id"
:data-selected="selected || undefined"
:class="
cn(
'group/gantt-row data-hover:bg-muted/40 data-selected:bg-primary/5 data-selected:data-hover:bg-primary/5 flex border-b',
dimmed && 'opacity-50'
)
"
:style="{ height: `${heightRem}rem` }"
@click="onRowClick"
@dblclick="onRowDoubleClick"
>
<div class="flex h-full w-full min-w-0">
<div
data-slot="gantt-tree-cell"
:class="cn('flex shrink-0 ps-3 pe-3', alignStart ? 'items-start' : 'items-center', row.isGroup && 'font-medium')"
:style="{ width: `${nameWidth}px` }"
>
<div class="flex w-full min-w-0 items-center" :style="{ height: `${bandRem}rem` }">
<button
v-if="reorderable"
type="button"
data-slot="gantt-row-grip"
:aria-label="settings.i18n.labels.reorder"
class="text-muted-foreground/60 hover:text-foreground -ms-1.5 me-1.5 flex w-3.5 shrink-0 cursor-grab touch-none items-center justify-center opacity-0 group-hover/gantt-row:opacity-100 group-data-hover/gantt-row:opacity-100 focus-visible:opacity-100 pointer-coarse:opacity-100"
@pointerdown="(e) => emit('grip-pointerdown', e, row)"
@click.stop
>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-3" aria-hidden="true"><circle cx="9" cy="5" r="1" /><circle cx="9" cy="12" r="1" /><circle cx="9" cy="19" r="1" /><circle cx="15" cy="5" r="1" /><circle cx="15" cy="12" r="1" /><circle cx="15" cy="19" r="1" /></svg>
</button>
<span aria-hidden class="shrink-0" :style="{ width: `${row.depth * 0.875}rem` }" />
<span class="me-1 flex w-5 shrink-0 items-center justify-start">
<Button
v-if="row.isGroup"
variant="ghost"
size="icon-xs"
:aria-expanded="!row.collapsed"
:aria-label="row.resource.title"
:class="
cn(
'size-5! aria-expanded:bg-transparent!',
row.collapsed ? 'text-foreground' : 'text-muted-foreground aria-expanded:text-muted-foreground! hover:text-foreground!'
)
"
@click="emit('toggle', row)"
>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" :class="cn('size-3.5 transition-transform', !row.collapsed && 'rotate-90')" aria-hidden="true"><path d="m9 18 6-6-6-6" /></svg>
</Button>
<Checkbox
v-else-if="viewConfig.rowCheckboxes && showCheckbox"
data-slot="gantt-row-checkbox"
:model-value="selected"
@update:model-value="(checked) => emit('selected-change', row.resource.id, !!checked)"
:aria-label="row.resource.title"
:class="cn('size-3.5 opacity-0 transition-opacity group-hover/gantt-row:opacity-100 group-data-hover/gantt-row:opacity-100 focus-visible:opacity-100', selected && 'opacity-100')"
/>
</span>
<component :is="() => resourceLabel" v-if="resourceLabel" />
<span v-else class="truncate">{{ row.resource.title }}</span>
</div>
</div>
<div
v-for="column in columns"
:key="column.id"
data-slot="gantt-tree-column-cell"
:data-column="column.id"
:class="cn('flex shrink-0 px-2', alignStart ? 'items-start' : 'items-center', column.className)"
:style="{ width: `${column.width ?? DEFAULT_COLUMN_WIDTH}px` }"
>
<div
:class="cn('flex w-full min-w-0 items-center', column.align === 'center' && 'justify-center', column.align === 'end' && 'justify-end')"
:style="{ height: `${bandRem}rem` }"
>
<component :is="() => column.render?.(ctx)" v-if="column.render" />
</div>
</div>
<div class="min-w-0 flex-1" />
</div>
</div>
</template>
src/reui/gantt/GanttTreeSplitPanes.vue
<script setup lang="ts">
/**
* Порт секции "split panes" из GanttView (gantt-view.tsx, MIT, строки
* ~911-1087 и ~2337-2392) — резинка/сплиттер между деревом ресурсов и
* шкалой времени: ширина панели дерева (управляемая/неуправляемая,
* `treePanel.width`/`onWidthChange`), перетаскивание указателем, изменение
* стрелками с фокуса, сброс по двойному клику, и адаптивное сжатие дерева
* на узком контейнере (`ResizeObserver` на теле, чтобы шкала времени
* никогда не падала ниже `minTimelineWidth`).
*
* ponytail: сама разметка строк дерева (`GanttTreeRow`, ~950 строк:
* чекбоксы, шеврон свёртки, доп. колонки, drag-reorder ресурсов, hint
* "добавить задачу") НЕ входит в этот срез — контент обеих панелей отдаётся
* именованными слотами (`#tree`/`#timeline`), это следующий, самый большой
* кусок `GanttView`. Здесь портирован только механизм сплиттера, который
* от содержимого панелей не зависит.
*
* Живая ширина при перетаскивании пишется напрямую в DOM
* (`treePaneRef.value.style.width`), как и в оригинале — состояние
* коммитится только при отпускании, иначе каждый `pointermove` перерисовал
* бы дерево и шкалу целиком.
*
* Секция 3 (скролл, gantt-view.tsx ~1089-1174): вертикальная синхронизация
* панелей — какая бы панель ни скроллилась, вторая зеркалит `scrollTop`.
* ponytail: обе панели здесь — сами себе "viewport" (`overflow-y-auto` +
* `data-slot="scroll-area-viewport"` прямо на `treePaneRef`/`timelinePaneRef`),
* а не `ScrollArea` -> вложенный viewport, как в оригинале (тот кастомный
* скроллбар появится вместе с реальным содержимым строк). Wheel-блокировка
* "обе панели в одном кадре" (оригинал, комментарий про флик при инерции
* трекпада) не перенесена — она чинит эффект, видимый только в живом
* скролле, а не на статичном скриншоте; сам факт синхронизации (через
* `scroll`-событие) работает и без неё. Добавить, когда появится
* интеракционный кейс. Linked row hover (`data-hover` между близнецами строк
* в двух панелях, gantt-view.tsx ~1176-1209) перенесён туда же, дословно.
*
* Автоцентрирование + infinite-scroll по краю (gantt-view.tsx ~1211-1440,
* ~1442-1548 частично) — портированы, читают/пишут `timelinePaneRef`
* НАПРЯМУЮ как единственный viewport (не через `getPaneViewport`, см.
* секцию 3 выше). `[data-gantt-axis]` уже есть на юнит-ряде
* `GanttTimelineHeader.vue`, `instance.internals.extendRange`/
* `setViewportCenter` уже реальны (context.ts) — обе стороны состояния
* были готовы, не хватало только этого файла.
*
* Переанкеровка при зуме (`anchorZoomCenter`/`anchorZoomPointer`/wheel-zoom,
* gantt-view.tsx ~1490-1622) теперь портирована здесь — этот файл
* единственный владелец `pendingRestore` и реального `timelinePaneRef`,
* которые ей нужны. `zoom` приходит пропом от `GanttView` (единственный
* потребитель, который держит `useGanttZoom()`); `anchorZoomCenter`/
* `anchorZoomPointer` экспонированы через `defineExpose`, чтобы
* `GanttZoomControl` (сосед по дереву, не потомок) мог анкорить перед
* своими кнопками через `GanttView`'s template-ref (тот же приём, что уже
* применён для `GanttOffscreenChips`'s `pane-ref`). Ctrl/Cmd+wheel зум —
* `onWheelZoom`-проп, вызываемый с уже вычисленным (не обязательно
* клэмпнутым — `zoom.ts`'s `setZoom` клэмпит сам) целевым значением;
* сам множитель зума этот файл не хранит, только реагирует на его смену.
*/
import { computed, onBeforeUnmount, onMounted, ref, watch, type HTMLAttributes } from "vue"
import { format } from "date-fns"
import { useGantt, useGanttSettings, useGanttViewConfig, type GanttTreePanelConfig } from "./context"
import { getScrollStart, setScrollStart, trackPoint } from "./view-lib"
import { toZoned } from "./lib"
import { DEFAULT_ZOOM_RANGE } from "./zoom"
import { cn } from "@/lib/utils"
const DEFAULT_TREE_PANEL: Required<GanttTreePanelConfig> = {
width: 288,
minWidth: 180,
maxWidth: 640,
resizable: true,
nameColumnWidth: 208,
onWidthChange: () => {},
}
const props = withDefaults(
defineProps<{
class?: HTMLAttributes["class"]
treePanel?: GanttTreePanelConfig
/** Timeline pane never shrinks below this so it stays usable on narrow screens. */
minTimelineWidth?: number
/** Current effective zoom multiplier (from `useGanttZoom()`, owned by `GanttView`). Drives the zoom re-anchor watcher below. */
zoom?: number
/** Ctrl/Cmd+wheel or pinch zoom target, already computed by the caller (gantt-view.tsx ~1593-1625) - forwarded to `GanttView`'s `useGanttZoom().setZoom`. */
onWheelZoom?: (next: number) => void
}>(),
{ minTimelineWidth: 200, zoom: undefined, onWheelZoom: undefined }
)
defineSlots<{ tree?(): unknown; timeline?(): unknown }>()
const settings = useGanttSettings()
const instance = useGantt()
const viewConfig = useGanttViewConfig()
const treeConfig = computed(() => ({ ...DEFAULT_TREE_PANEL, ...props.treePanel }))
function clampTree(width: number): number {
return Math.min(Math.max(width, treeConfig.value.minWidth), treeConfig.value.maxWidth)
}
const treeWidth = ref(treeConfig.value.width)
const configuredTreeWidth = computed(() => clampTree(treeWidth.value))
const bodyRef = ref<HTMLElement | null>(null)
const treePaneRef = ref<HTMLElement | null>(null)
const timelinePaneRef = ref<HTMLElement | null>(null)
const containerWidth = ref(0)
function clampContainer(width: number, container: number): number {
if (container <= 0) return width
const ceiling = container - props.minTimelineWidth - 1
// The tree's own minWidth is a PREFERENCE, not a licence to squeeze the
// timeline out of existence: cap it by what the container can actually
// spare.
const floor = Math.min(treeConfig.value.minWidth, Math.max(ceiling, 0))
return Math.max(Math.min(width, ceiling), Math.min(floor, container - 1))
}
const clampedTreeWidth = computed(() => clampContainer(configuredTreeWidth.value, containerWidth.value))
let observer: ResizeObserver | undefined
onMounted(() => {
const body = bodyRef.value
if (!body) return
const update = () => {
containerWidth.value = body.clientWidth
}
update()
observer = new ResizeObserver(update)
observer.observe(body)
})
onBeforeUnmount(() => observer?.disconnect())
// Both panes scroll vertically; whichever moves drives the other.
let unlinkScroll: (() => void) | undefined
onMounted(() => {
const treeViewport = treePaneRef.value
const timelineViewport = timelinePaneRef.value
if (!treeViewport || !timelineViewport) return
const link = (source: HTMLElement, target: HTMLElement) => {
// Mirror only when the source's own vertical position changed - assign
// only on drift, so the mirrored handler then no-ops (no loop).
let lastTop = source.scrollTop
const onScroll = () => {
if (source.scrollTop === lastTop) return
lastTop = source.scrollTop
if (target.scrollTop !== source.scrollTop) target.scrollTop = source.scrollTop
}
source.addEventListener("scroll", onScroll)
return () => source.removeEventListener("scroll", onScroll)
}
const unlinkTree = link(treeViewport, timelineViewport)
const unlinkTimeline = link(timelineViewport, treeViewport)
unlinkScroll = () => {
unlinkTree()
unlinkTimeline()
}
})
onBeforeUnmount(() => unlinkScroll?.())
// Linked row hover: mirror data-hover onto the row's twin in the other pane
// (gantt-view.tsx ~1176-1209, dословно).
let unlinkHover: (() => void) | undefined
onMounted(() => {
const body = bodyRef.value
if (!body) return
let current: string | null = null
const apply = (id: string | null) => {
if (id === current) return
if (current) {
body.querySelectorAll(`[data-gantt-row-id="${CSS.escape(current)}"]`).forEach((el) => el.removeAttribute("data-hover"))
}
if (id) {
body.querySelectorAll(`[data-gantt-row-id="${CSS.escape(id)}"]`).forEach((el) => el.setAttribute("data-hover", ""))
}
current = id
}
const onOver = (e: PointerEvent) => {
const row = (e.target as HTMLElement | null)?.closest?.("[data-gantt-row-id]")
apply(row?.getAttribute("data-gantt-row-id") ?? null)
}
const onLeave = () => apply(null)
body.addEventListener("pointerover", onOver)
body.addEventListener("pointerleave", onLeave)
unlinkHover = () => {
body.removeEventListener("pointerover", onOver)
body.removeEventListener("pointerleave", onLeave)
apply(null)
}
})
onBeforeUnmount(() => unlinkHover?.())
// ----- auto-center + infinite-scroll bookkeeping (not template-bound, so
// plain mutable locals rather than ref() - matches the original's useRef,
// which also does not trigger a re-render on write). -----
let lastUserScrollAt = 0
let extendLock = false
let pendingRestore: { ms: number; align: "start" | "center"; offsetPx?: number } | null = null
const manage = { key: "", buffered: false, userTook: false }
// Fine (sub-period) viewport-center instant, refreshed by
// setupViewportCenterReport's measure() on every scroll frame - the anchor
// a consumer-driven (controlled) zoom change falls back to when nothing
// already staged a `pendingRestore` (gantt-view.tsx's `fineCenterRef`,
// ~1470-1503).
let fineCenterMs: number | null = null
let lastZoom: number | null = null
/** Only user gestures may extend the range - programmatic scrolls must never grow it. */
function setupUserScrollIntent() {
const pane = timelinePaneRef.value
if (!pane) return () => {}
const markIntent = () => {
lastUserScrollAt = performance.now()
}
const markWheelIntent = (e: WheelEvent) => {
if (!e.ctrlKey && !e.metaKey) markIntent()
}
const onPointerDown = (e: PointerEvent) => {
const target = e.target as HTMLElement | null
if (target?.closest("[data-slot=scroll-area-scrollbar], [data-slot=gantt-timeline-header], [data-gantt-native-scroll]")) {
markIntent()
}
}
pane.addEventListener("wheel", markWheelIntent, { passive: true })
pane.addEventListener("pointerdown", onPointerDown)
pane.addEventListener("touchstart", markIntent, { passive: true })
pane.addEventListener("keydown", markIntent)
return () => {
pane.removeEventListener("wheel", markWheelIntent)
pane.removeEventListener("pointerdown", onPointerDown)
pane.removeEventListener("touchstart", markIntent)
pane.removeEventListener("keydown", markIntent)
}
}
/** Grow the range near an edge, keeping the position anchored to a timestamp. */
function setupInfiniteScroll() {
if (!viewConfig.value.infiniteScroll) return () => {}
const viewport = timelinePaneRef.value
if (!viewport) return () => {}
const infiniteEdgePx = viewConfig.value.metrics?.infiniteScrollEdge ?? 160
const tryExtend = (direction: "before" | "after") => {
const axis = viewport.querySelector<HTMLElement>("[data-gantt-axis]")
const liveStart = Number(axis?.dataset.ganttRangeStart)
const liveEnd = Number(axis?.dataset.ganttRangeEnd)
if (!axis || Number.isNaN(liveStart) || Number.isNaN(liveEnd)) return
extendLock = true
manage.userTook = true
pendingRestore = {
ms: liveStart + (getScrollStart(viewport) / viewport.scrollWidth) * (liveEnd - liveStart),
align: "start",
}
if (!instance.internals.extendRange(direction)) {
pendingRestore = null
extendLock = false
}
}
const isRtl = getComputedStyle(viewport).direction === "rtl"
const onScroll = () => {
if (extendLock) return
if (performance.now() - lastUserScrollAt > 1200) return
if (viewport.scrollWidth <= viewport.clientWidth + 8) return
const fromStart = getScrollStart(viewport)
const fromEnd = viewport.scrollWidth - fromStart - viewport.clientWidth
const direction = fromStart < infiniteEdgePx ? "before" : fromEnd < infiniteEdgePx ? "after" : null
if (!direction) return
tryExtend(direction)
}
const onWheel = (e: WheelEvent) => {
if (e.ctrlKey || e.metaKey) return
if (extendLock || e.deltaX === 0) return
if (viewport.scrollWidth <= viewport.clientWidth + 8) return
const towardStart = isRtl ? e.deltaX > 0 : e.deltaX < 0
if (towardStart && getScrollStart(viewport) <= 0) tryExtend("before")
else if (!towardStart && getScrollStart(viewport) + viewport.clientWidth >= viewport.scrollWidth - 1) tryExtend("after")
}
viewport.addEventListener("scroll", onScroll)
viewport.addEventListener("wheel", onWheel, { passive: true })
return () => {
viewport.removeEventListener("scroll", onScroll)
viewport.removeEventListener("wheel", onWheel)
}
}
/** Report the visible-center instant so the nav title names what you are looking at. */
function setupViewportCenterReport() {
const viewport = timelinePaneRef.value
if (!viewport) return () => {}
const scale = instance.state.value.scale
const keyFmt =
scale === "day" ? "yyyy-MM-dd" : scale === "week" ? "RRRR-'W'II" : scale === "month" ? "yyyy-MM" : scale === "quarter" ? "yyyy-qqq" : "yyyy"
let raf = 0
let lastKey = ""
const measure = () => {
raf = 0
const axis = viewport.querySelector<HTMLElement>("[data-gantt-axis]")
const liveStart = Number(axis?.dataset.ganttRangeStart)
const liveEnd = Number(axis?.dataset.ganttRangeEnd)
if (!axis || Number.isNaN(liveStart) || Number.isNaN(liveEnd)) return
const fraction = (getScrollStart(viewport) + viewport.clientWidth / 2) / Math.max(1, viewport.scrollWidth)
const centerMs = liveStart + fraction * (liveEnd - liveStart)
fineCenterMs = centerMs
const center = new Date(centerMs)
const key = format(toZoned(center, settings.value.timeZone), keyFmt)
if (key === lastKey) return
lastKey = key
instance.internals.setViewportCenter(center)
}
const schedule = () => {
if (!raf) raf = requestAnimationFrame(measure)
}
viewport.addEventListener("scroll", schedule)
schedule()
return () => {
viewport.removeEventListener("scroll", schedule)
if (raf) cancelAnimationFrame(raf)
}
}
/**
* Re-seat the viewport on its anchored instant (consumes `pendingRestore`,
* set by `setupInfiniteScroll`'s `tryExtend` and by the initial
* auto-centering below). The upstream also sets `pendingRestore` here when
* `zoom` changes (anchoring across a zoom step) - not ported, see file
* header: no zoom-owning component shares this scope yet.
*/
function seat() {
let raf: number | null = null
let attempts = 0
const run = () => {
raf = null
const viewport = timelinePaneRef.value
if (viewport && pendingRestore) {
if (viewport.clientWidth === 0 && attempts++ < 20) {
raf = requestAnimationFrame(run)
return
}
const { ms, align, offsetPx } = pendingRestore
pendingRestore = null
const state = instance.state.value
const rangeStartMs = state.visibleRange.start.getTime()
const rangeEndMs = state.visibleRange.end.getTime()
const fraction = Math.min(Math.max((ms - rangeStartMs) / (rangeEndMs - rangeStartMs), 0), 1)
const offset = offsetPx ?? (align === "center" ? viewport.clientWidth / 2 : 0)
setScrollStart(viewport, Math.max(0, fraction * viewport.scrollWidth - offset))
}
extendLock = false
}
run()
return () => {
if (raf !== null) cancelAnimationFrame(raf)
}
}
/**
* Auto-manage the horizontal position for the current anchor until the user
* scrolls: pre-buffer one period per side (infinite scroll), then centre the
* target instant (now / anchor / an explicit `initialCenter` instant).
*/
function autoCenter() {
const state = instance.state.value
const anchorMs = state.date.getTime()
const initialCenter =
viewConfig.value.initialCenter instanceof Date ? viewConfig.value.initialCenter.getTime() : viewConfig.value.initialCenter
const key = `${state.scale}:${anchorMs}:${viewConfig.value.scrollbars}`
if (manage.key !== key) {
const slideContinuation = manage.userTook && instance.internals.didAnchorSlide()
Object.assign(manage, slideContinuation ? { key, buffered: manage.buffered, userTook: true } : { key, buffered: false, userTook: false })
}
if (manage.userTook) return () => {}
let waiter: ResizeObserver | null = null
const run = () => {
waiter?.disconnect()
waiter = null
const viewport = timelinePaneRef.value
const axis = viewport?.querySelector<HTMLElement>("[data-gantt-axis]")
if (!viewport || !axis) return
if (viewport.clientWidth === 0) {
waiter = new ResizeObserver(() => {
if (viewport.clientWidth > 0) run()
})
waiter.observe(viewport)
return
}
if (viewConfig.value.infiniteScroll && !manage.buffered) {
manage.buffered = true
extendLock = true
instance.internals.extendRange("before")
instance.internals.extendRange("after")
return
}
extendLock = false
if (viewport.scrollWidth <= viewport.clientWidth) return
const active = state.activeRange
let target: number
if (typeof initialCenter === "number") {
target = initialCenter
} else if (initialCenter === "anchor") {
target = anchorMs
} else {
const nowMs = Date.now()
target = nowMs >= active.start.getTime() && nowMs < active.end.getTime() ? nowMs : anchorMs
}
const rangeStartMs = state.visibleRange.start.getTime()
const rangeEndMs = state.visibleRange.end.getTime()
const fraction = Math.min(Math.max((target - rangeStartMs) / (rangeEndMs - rangeStartMs), 0), 1)
setScrollStart(viewport, Math.max(0, fraction * viewport.scrollWidth - viewport.clientWidth / 2))
}
run()
return () => waiter?.disconnect()
}
/** Keep the view centered on the same instant across a zoom step (button click, uncontrolled). Mirrors gantt-view.tsx's `anchorZoomCenter` (~1550-1566). */
function anchorZoomCenter() {
const viewport = timelinePaneRef.value
const axis = viewport?.querySelector<HTMLElement>("[data-gantt-axis]")
if (!viewport || !axis) return
const liveStart = Number(axis.dataset.ganttRangeStart)
const liveEnd = Number(axis.dataset.ganttRangeEnd)
if (Number.isNaN(liveStart) || Number.isNaN(liveEnd)) return
pendingRestore = {
ms: liveStart + ((getScrollStart(viewport) + viewport.clientWidth / 2) / viewport.scrollWidth) * (liveEnd - liveStart),
align: "center",
}
}
/**
* Keep the instant under the POINTER pinned across a zoom step (wheel/pinch
* gesture). Mirrors gantt-view.tsx's `anchorZoomPointer` (~1568-1591).
*/
function anchorZoomPointer(clientX: number) {
const viewport = timelinePaneRef.value
const axis = viewport?.querySelector<HTMLElement>("[data-gantt-axis]")
if (!viewport || !axis) return
const liveStart = Number(axis.dataset.ganttRangeStart)
const liveEnd = Number(axis.dataset.ganttRangeEnd)
if (Number.isNaN(liveStart) || Number.isNaN(liveEnd)) return
const offsetPx = trackPoint(viewport, clientX).offset
pendingRestore = {
ms: liveStart + ((getScrollStart(viewport) + offsetPx) / viewport.scrollWidth) * (liveEnd - liveStart),
align: "start",
offsetPx,
}
}
/**
* ctrl/cmd + wheel (and trackpad pinch) zooms the time range - mirrors
* gantt-view.tsx's `wheelZoomRef` effect (~1593-1625). `passive: false`
* because it must `preventDefault()` (otherwise the page itself zooms).
*/
function setupWheelZoom() {
const viewport = timelinePaneRef.value
if (!viewport) return () => {}
const onWheel = (e: WheelEvent) => {
if (!viewConfig.value.wheelZoom) return
if (!e.ctrlKey && !e.metaKey) return
if (props.zoom === undefined || !props.onWheelZoom) return
const range = { ...DEFAULT_ZOOM_RANGE, ...viewConfig.value.zoomRange }
const clampZoom = (v: number) => Math.min(Math.max(v, range.min), range.max)
const lines = e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? 400 : 1
const next = clampZoom(props.zoom * Math.exp(-e.deltaY * lines * 0.002))
if (Math.abs(next - props.zoom) < 1e-4) return
e.preventDefault()
if (viewConfig.value.zoom === undefined) anchorZoomPointer(e.clientX)
props.onWheelZoom(+next.toFixed(4))
}
viewport.addEventListener("wheel", onWheel, { passive: false })
return () => viewport.removeEventListener("wheel", onWheel)
}
let teardownScrollIntent: (() => void) | undefined
let teardownInfiniteScroll: (() => void) | undefined
let teardownCenterReport: (() => void) | undefined
let teardownSeat: (() => void) | undefined
let teardownAutoCenter: (() => void) | undefined
let teardownWheelZoom: (() => void) | undefined
let teardownZoomSeat: (() => void) | undefined
function rewireAutoScroll() {
teardownInfiniteScroll?.()
teardownCenterReport?.()
teardownSeat?.()
teardownAutoCenter?.()
teardownInfiniteScroll = setupInfiniteScroll()
teardownCenterReport = setupViewportCenterReport()
teardownSeat = seat()
teardownAutoCenter = autoCenter()
}
onMounted(() => {
teardownScrollIntent = setupUserScrollIntent()
teardownWheelZoom = setupWheelZoom()
rewireAutoScroll()
})
onBeforeUnmount(() => {
teardownScrollIntent?.()
teardownInfiniteScroll?.()
teardownCenterReport?.()
teardownSeat?.()
teardownAutoCenter?.()
teardownWheelZoom?.()
teardownZoomSeat?.()
})
// Re-seat on a zoom step (gantt-view.tsx's re-seat useLayoutEffect,
// ~1493-1548, restricted to the `zoom` dependency - the rest of that
// effect's deps are already covered by `rewireAutoScroll`'s watcher above).
// A button/wheel-driven change already staged `pendingRestore` itself
// (anchorZoomCenter/anchorZoomPointer, called before the value changes);
// this only supplies the fallback for a zoom change with no such call - a
// consumer moving `viewConfig.zoom` on its own.
watch(
() => props.zoom,
(zoom) => {
if (zoom === undefined) return
if (lastZoom !== null && lastZoom !== zoom && !pendingRestore && fineCenterMs !== null) {
pendingRestore = { ms: fineCenterMs, align: "center" }
}
lastZoom = zoom
teardownZoomSeat?.()
teardownZoomSeat = seat()
}
)
// Re-run whenever the axis range/scale/scrollbars mode changes - mirrors the
// original's dependency arrays across its several effects.
watch(
() => [
instance.state.value.scale,
instance.state.value.date.getTime(),
instance.state.value.visibleRange.start.getTime(),
instance.state.value.visibleRange.end.getTime(),
viewConfig.value.scrollbars,
viewConfig.value.infiniteScroll,
],
rewireAutoScroll
)
function resetWidth() {
treeWidth.value = treeConfig.value.width
treeConfig.value.onWidthChange?.(treeConfig.value.width)
}
function beginSplit(e: PointerEvent) {
if (e.button !== 0) return
e.preventDefault()
const pointerId = e.pointerId
const startX = e.clientX
const startWidth = clampedTreeWidth.value
const splitter = e.currentTarget as HTMLElement
// in RTL the tree pane sits on the right: pointer deltas invert
const dir = getComputedStyle(splitter).direction === "rtl" ? -1 : 1
splitter.setAttribute("data-resizing", "")
document.body.style.cursor = "col-resize"
document.body.style.userSelect = "none"
let liveWidth = startWidth
const onMove = (ev: PointerEvent) => {
if (ev.pointerId !== pointerId) return
liveWidth = clampContainer(clampTree(startWidth + (ev.clientX - startX) * dir), bodyRef.value?.clientWidth ?? 0)
if (treePaneRef.value) treePaneRef.value.style.width = `${liveWidth}px`
}
const finish = (ev?: PointerEvent) => {
if (ev && ev.pointerId !== pointerId) return
window.removeEventListener("pointermove", onMove)
window.removeEventListener("pointerup", finish)
window.removeEventListener("pointercancel", finish)
splitter.removeAttribute("data-resizing")
document.body.style.cursor = ""
document.body.style.userSelect = ""
if (liveWidth !== startWidth) {
treeWidth.value = liveWidth
treeConfig.value.onWidthChange?.(liveWidth)
}
}
window.addEventListener("pointermove", onMove)
window.addEventListener("pointerup", finish)
window.addEventListener("pointercancel", finish)
}
function onSplitterKeydown(e: KeyboardEvent) {
if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return
e.preventDefault()
const dir = getComputedStyle(e.currentTarget as HTMLElement).direction === "rtl" ? -1 : 1
const delta = (e.key === "ArrowLeft" ? -16 : 16) * dir
const next = clampTree(clampedTreeWidth.value + delta)
treeWidth.value = next
treeConfig.value.onWidthChange?.(next)
}
defineExpose({
treePaneRef,
timelinePaneRef,
bodyRef,
clampedTreeWidth,
nameColumnWidth: computed(() => treeConfig.value.nameColumnWidth),
anchorZoomCenter,
anchorZoomPointer,
})
</script>
<template>
<div ref="bodyRef" data-slot="gantt-body" :class="cn('flex h-full min-h-0 min-w-0', props.class)">
<div
ref="treePaneRef"
data-slot="gantt-tree-pane"
data-gantt-native-scroll=""
class="h-full shrink-0 overflow-y-auto overflow-x-hidden"
:style="{ width: `${clampedTreeWidth}px` }"
>
<slot name="tree" />
</div>
<div
v-if="treeConfig.resizable"
role="separator"
aria-orientation="vertical"
:aria-label="settings.i18n.labels.resizePanel"
:aria-valuenow="Math.round(clampedTreeWidth)"
:aria-valuemin="Math.round(Math.min(treeConfig.minWidth, clampedTreeWidth))"
:aria-valuemax="Math.round(Math.max(treeConfig.maxWidth, clampedTreeWidth))"
tabindex="0"
data-slot="gantt-splitter"
class="group/gantt-splitter bg-border hover:bg-primary/60 data-resizing:bg-primary relative z-30 w-px shrink-0 cursor-col-resize touch-none outline-none focus-visible:ring-ring/50 focus-visible:ring-2 after:absolute after:inset-y-0 after:-start-1 after:-end-1"
@pointerdown="beginSplit"
@dblclick="resetWidth"
@keydown="onSplitterKeydown"
>
<span
aria-hidden
data-slot="gantt-splitter-grip"
class="bg-primary/60 group-data-resizing/gantt-splitter:bg-primary absolute top-1/2 left-1/2 h-6 w-0.75 -translate-x-1/2 -translate-y-1/2 rounded-full opacity-0 transition-opacity duration-150 group-hover/gantt-splitter:opacity-100 group-focus-visible/gantt-splitter:opacity-100 group-data-resizing/gantt-splitter:opacity-100"
/>
</div>
<div v-else aria-hidden class="bg-border w-px shrink-0" />
<div ref="timelinePaneRef" data-slot="gantt-timeline-pane" class="relative h-full min-w-0 flex-1 overflow-auto">
<slot name="timeline" />
</div>
</div>
</template>
src/reui/gantt/GanttView.vue
<script setup lang="ts" generic="TData = unknown">
/**
* Порт (частичный, прагматично упрощённый) корневой сборки GanttView
* (gantt-view.tsx, MIT, ~3800 строк) — минимальная РЕАЛЬНАЯ сборка уже
* портированных кусков (`GanttTreeSplitPanes`, `GanttTreeRow`,
* `GanttTimelineHeader`, `GanttTimelineRow`, `GanttNowLine`/`Dot`,
* `GanttZoomControl`, `GanttOffscreenChips`, `GanttCustomDragLayer`), а не
* построчная копия всей функции. Появилась не по плану "секция за секцией",
* а потому что жестам (`gestures.ts`/`beginGesture`) нужен реальный предок
* `[data-slot="gantt-view"]` (`origin.closest(...)`, см. `collectSurface`) —
* без него `beginMove`/`beginResize`/`beginCreate` находят `viewRoot === null`
* и молча не делают ничего. Ручная сборка кейса (раздельные статичные пропы
* на `GanttTimelineHeader`, ручной перебор `RESOURCES`) этот корень не
* создавала, и это первый компонент во всём порте, которому он
* понадобился для настоящего теста, а не только для статичного скриншота.
*
* Строит `rows`/`rowBars` из РЕАЛЬНЫХ `settings.resources`/
* `useGanttOccurrences()` (не хардкод кейса) — то же дерево-обход с учётом
* `collapsedGroups`, что и в оригинале (~369-386), и та же паковка через
* `packTimedSegments` (lib.ts), что уже используется в кейсе вручную.
*
* Переанкеровка при зуме (`anchorZoomCenter`/`anchorZoomPointer`/wheel-zoom)
* теперь портирована: этот компонент — единственный держатель
* `useGanttZoom()`, поэтому `zoom`/`setZoom` идут пропами в
* `GanttTreeSplitPanes` (владеет `pendingRestore`/реальным осью-элементом),
* а `anchorZoomCenter` того же экземпляра — колбэком в `GanttZoomControl`
* через `splitPanesRef` (соседи по дереву, не предок/потомок).
*
* ponytail — сознательно НЕ портировано (объём/риск дороже пользы в этом
* срезе, но контракт под них уже есть, добавить можно без переделки):
* - Заголовок дерева ("Resources" + доп.колонки в шапке, ~1909-1955),
* drag-reorder ресурсов, hint "добавить задачу", `renderResourceMenu`,
* `renderNoResources` — не входили в предыдущие секции, не вошли и сюда.
* - `loading`-оверлей, `columnsMenu`, `asChild` на корне.
*/
import { computed, ref } from "vue"
import type { HTMLAttributes } from "vue"
import {
useGantt,
useGanttSettings,
useGanttViewConfig,
useGanttOccurrences,
resolveScheduleMode,
} from "./context"
import { useGanttZoom } from "./zoom"
import { packTimedSegments } from "./lib"
import type { TimelineRow, TimelineRowBars } from "./view-lib"
import { cn } from "@/lib/utils"
import GanttTreeSplitPanes from "./GanttTreeSplitPanes.vue"
import GanttTreeRow from "./GanttTreeRow.vue"
import GanttTimelineHeader from "./GanttTimelineHeader.vue"
import GanttTimelineRow from "./GanttTimelineRow.vue"
import GanttNowLine from "./GanttNowLine.vue"
import GanttNowDot from "./GanttNowDot.vue"
import GanttZoomControl from "./GanttZoomControl.vue"
import GanttOffscreenChips from "./GanttOffscreenChips.vue"
import GanttCustomDragLayer from "./GanttCustomDragLayer.vue"
import type { GanttResource, GanttSegment } from "./types"
const LANE_HEIGHT_REM = 1.25
const LANE_GAP_REM = 0.1875
const props = defineProps<{ class?: HTMLAttributes["class"] }>()
const instance = useGantt<TData>()
const settings = useGanttSettings<TData>()
const viewConfig = useGanttViewConfig<TData>()
const occurrences = useGanttOccurrences<TData>()
const { zoom, setZoom } = useGanttZoom<TData>()
// ----- tree: flatten with collapse (uncontrolled unless viewConfig.collapsedGroups) -----
const internalCollapsed = ref<string[]>(viewConfig.value.defaultCollapsedGroups ?? [])
const collapsedIds = computed(() => viewConfig.value.collapsedGroups ?? internalCollapsed.value)
const collapsedSet = computed(() => new Set(collapsedIds.value))
const rows = computed<TimelineRow[]>(() => {
const result: TimelineRow[] = []
const walk = (resources: GanttResource[], depth: number, parentId: string | null) => {
for (const resource of resources) {
const isGroup = !!resource.children?.length
const collapsed = collapsedSet.value.has(resource.id)
result.push({ resource, parentId, depth, isGroup, collapsed })
if (isGroup && !collapsed) walk(resource.children!, depth + 1, resource.id)
}
}
walk(settings.value.resources, 0, null)
return result
})
function onToggle(row: TimelineRow) {
const id = row.resource.id
const next = collapsedIds.value.includes(id) ? collapsedIds.value.filter((x) => x !== id) : [...collapsedIds.value, id]
if (viewConfig.value.collapsedGroups === undefined) internalCollapsed.value = next
viewConfig.value.onCollapsedGroupsChange?.(next)
}
// ----- leaf-row checkbox selection (uncontrolled unless viewConfig.selectedRows) -----
const internalSelected = ref<string[]>([])
const selectedRows = computed(() => viewConfig.value.selectedRows ?? internalSelected.value)
const selectedSet = computed(() => new Set(selectedRows.value))
function onSelectedChange(id: string, checked: boolean) {
const next = checked ? [...selectedRows.value.filter((x) => x !== id), id] : selectedRows.value.filter((x) => x !== id)
if (viewConfig.value.selectedRows === undefined) internalSelected.value = next
viewConfig.value.onSelectedRowsChange?.(next)
}
// ----- per-row bar packing (real packTimedSegments, mirrors the case's manual version) -----
const minRowRem = computed(() => viewConfig.value.metrics?.minRowHeight ?? 2.5)
const rowBars = computed<Map<string, TimelineRowBars>>(() => {
const map = new Map<string, TimelineRowBars>()
const rangeStartMs = instance.state.value.visibleRange.start.getTime()
for (const row of rows.value) {
const mode = resolveScheduleMode(row.resource, viewConfig.value.scheduleMode)
const segments: GanttSegment<TData>[] = occurrences.value
.filter((occ) => occ.event.resourceId === row.resource.id)
.map((occ) => ({
occurrence: occ,
day: occ.start,
isStart: true,
isEnd: true,
continuesBefore: false,
continuesAfter: false,
startMin: (occ.start.getTime() - rangeStartMs) / 60000,
endMin: (occ.end.getTime() - rangeStartMs) / 60000,
}))
packTimedSegments(segments, { mode })
const laneCount = segments.length ? Math.max(...segments.map((s) => s.column ?? 0)) + 1 : 1
const laneOffsetRem = (minRowRem.value - LANE_HEIGHT_REM) / 2
const heightRem = Math.max(minRowRem.value, laneCount * LANE_HEIGHT_REM + (laneCount - 1) * LANE_GAP_REM + laneOffsetRem * 2)
map.set(row.resource.id, {
segments,
laneCount,
draftLane: null,
scheduleMode: mode,
heightRem,
laneOffsetRem,
bandRem: minRowRem.value,
extent: null,
summary: null,
})
}
return map
})
const headerRef = ref<InstanceType<typeof GanttTimelineHeader> | null>(null)
const splitPanesRef = ref<InstanceType<typeof GanttTreeSplitPanes> | null>(null)
</script>
<template>
<div data-slot="gantt-view" :class="cn('relative flex min-h-0 min-w-0 flex-1 flex-col', props.class)">
<GanttCustomDragLayer />
<GanttTreeSplitPanes ref="splitPanesRef" :tree-panel="viewConfig.treePanel" :zoom="zoom" :on-wheel-zoom="setZoom" class="min-h-0 flex-1">
<template #tree>
<div class="h-16" />
<GanttTreeRow
v-for="row in rows"
:key="row.resource.id"
:row="row"
:height-rem="rowBars.get(row.resource.id)?.heightRem ?? minRowRem"
:band-rem="rowBars.get(row.resource.id)?.bandRem ?? minRowRem"
:columns="viewConfig.columns ?? []"
:name-width="viewConfig.treePanel?.nameColumnWidth ?? 208"
:dimmed="false"
:selected="selectedSet.has(row.resource.id)"
:show-checkbox="!row.isGroup"
@selected-change="onSelectedChange"
@toggle="onToggle"
/>
</template>
<template #timeline>
<GanttTimelineHeader ref="headerRef" :scale="instance.state.value.scale" :range="instance.state.value.visibleRange" :zoom="zoom" />
<GanttNowLine :range-start-ms="instance.state.value.visibleRange.start.getTime()" :range-end-ms="instance.state.value.visibleRange.end.getTime()" />
<GanttNowDot :range-start-ms="instance.state.value.visibleRange.start.getTime()" :range-end-ms="instance.state.value.visibleRange.end.getTime()" />
<GanttTimelineRow
v-for="(row, rowIndex) in rows"
:key="row.resource.id"
:row="row"
:row-index="rowIndex"
:bars="rowBars.get(row.resource.id)"
:range-start-ms="instance.state.value.visibleRange.start.getTime()"
:range-end-ms="instance.state.value.visibleRange.end.getTime()"
:track-width="headerRef?.trackWidth ?? '0rem'"
:track-rem-width="headerRef?.trackRemWidth ?? 0"
row-border="solid"
:selected="selectedSet.has(row.resource.id)"
:resolve-hint-stop="headerRef?.resolveHintStop ?? (() => null)"
:is-panning="false"
:lane-height-rem="LANE_HEIGHT_REM"
:lane-gap-rem="LANE_GAP_REM"
:min-row-rem="minRowRem"
/>
<!--
Размещение здесь (сиблинг рядов, не вынесенный в отдельный
нескроллящийся "хром"-слой) — то же упрощение, что уже
задокументировано в кейсе для GanttZoomControl/GanttOffscreenChips:
разделение "хром пейна / скроллируемый контент" внутри
GanttTreeSplitPanes ещё не сделано.
-->
<GanttZoomControl v-if="viewConfig.zoomControl" :anchor="() => splitPanesRef?.anchorZoomCenter()" />
<GanttOffscreenChips
v-if="viewConfig.offscreenIndicators"
:pane-ref="splitPanesRef?.timelinePaneRef ?? null"
:refresh-key="`${instance.state.value.scale}:${instance.state.value.visibleRange.start.getTime()}:${zoom}:${rows.length}`"
/>
</template>
</GanttTreeSplitPanes>
</div>
</template>
src/reui/gantt/GanttZoomControl.vue
<script setup lang="ts">
/**
* Порт плавающего контрола зума из GanttView (gantt-view.tsx, MIT, разметка
* ~2393-2467). Состояние — в `./zoom.ts` (`useGanttZoom`).
*
* `anchor` — опциональный колбэк от `GanttView` (`splitPanesRef.
* anchorZoomCenter`, см. `GanttTreeSplitPanes.vue`), вызываемый ПЕРЕД
* изменением зума, только когда зум неуправляем (`viewConfig.zoom ===
* undefined`) — дословно условие `if (viewConfig.zoom === undefined)
* anchorZoomCenter()` у обеих кнопок в оригинале (~2409-2449). Без него
* (стандалон-использование вне `GanttView`) кнопки по-прежнему меняют
* множитель, просто без переанкеровки видимого диапазона.
*
* ponytail: `Tooltip`/`TooltipProvider` (подпись при наведении) не
* перенесены — тот же выбор и та же причина, что уже задокументирована и
* проверена (16/16) в `GanttBar.vue`/`GanttOffscreenChips.vue`: не влияет
* на статичный скриншот. `IconPlaceholder` (plus/minus) заменён инлайновым
* `<svg>`.
*/
import { useGanttSettings, useGanttViewConfig } from "./context"
import { useGanttZoom } from "./zoom"
import { Button } from "@/components/ui/button"
const props = withDefaults(defineProps<{ anchor?: () => void }>(), { anchor: undefined })
const settings = useGanttSettings()
const viewConfig = useGanttViewConfig()
const { canZoomIn, canZoomOut, zoomIn, zoomOut } = useGanttZoom()
function onZoomIn() {
if (!canZoomIn.value) return
if (viewConfig.value.zoom === undefined) props.anchor?.()
zoomIn()
}
function onZoomOut() {
if (!canZoomOut.value) return
if (viewConfig.value.zoom === undefined) props.anchor?.()
zoomOut()
}
</script>
<template>
<div data-slot="gantt-zoom" class="bg-background absolute end-3 bottom-5 z-40 flex flex-col rounded-md border shadow-sm">
<Button
variant="ghost"
size="icon-xs"
:aria-label="settings.i18n.labels.zoomIn"
:aria-disabled="!canZoomIn || undefined"
class="text-muted-foreground hover:text-foreground size-5! rounded-b-none aria-disabled:cursor-not-allowed aria-disabled:opacity-50 aria-disabled:hover:bg-transparent"
@click="onZoomIn"
>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-3" aria-hidden="true"><path d="M5 12h14" /><path d="M12 5v14" /></svg>
</Button>
<Button
variant="ghost"
size="icon-xs"
:aria-label="settings.i18n.labels.zoomOut"
:aria-disabled="!canZoomOut || undefined"
class="text-muted-foreground hover:text-foreground size-5! rounded-t-none border-t aria-disabled:cursor-not-allowed aria-disabled:opacity-50 aria-disabled:hover:bg-transparent"
@click="onZoomOut"
>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-3" aria-hidden="true"><path d="M5 12h14" /></svg>
</Button>
</div>
</template>
src/reui/gantt/context.ts
/**
* Порт ReUI Gantt headless-стора (registry-reui/bases/radix/reui/gantt/gantt.tsx, MIT).
*
* Оригинал держит состояние во внешнем сторе (`useSyncExternalStore` +
* ручные `notify`/`invalidate`/snapshot-кэш), потому что React больше ничем
* не даёт дешёвую тонкую подписку без лишних ре-рендеров. Vue-реактивность
* (`ref`/`computed`) решает ровно эту задачу нативно: `computed` уже кэширует
* результат до изменения зависимостей и не пересчитывается, пока никто его
* не читает — весь механизм `listeners`/`subscribe`/`getSnapshot`/
* `useGanttSelector`-мемоизации из оригинала здесь не нужен и не портируется
* (см. тот же выбор в `reui/data-grid/context.ts`: `table` из `useVueTable`
* реактивен сам по себе).
*
* `useGanttSelector` тем не менее сохранён как тонкая обёртка над `computed`
* ради совместимости вызывающего кода будущих файлов (gantt-view.tsx и др.),
* которые в оригинале импортируют его по имени.
*/
import type { ComputedRef, InjectionKey, Ref, VNodeChild } from "vue"
import { computed, inject, reactive, ref, watch } from "vue"
import type { Locale } from "date-fns"
import {
buildEventIndex,
defaultEventOrder,
eventsOverlap,
findResource,
getGanttDateRange,
getRangeKey,
stepGanttDate,
toZoned,
type GanttIndex,
type WeekStartsOn,
} from "./lib"
import { mergeGanttI18n, type GanttI18nConfig, type GanttI18nOverrides } from "./i18n"
import type {
GanttBarId,
GanttDateRange,
GanttDragState,
GanttEvent,
GanttInteractions,
GanttOccurrence,
GanttOffDaysConfig,
GanttOverlapPolicy,
GanttProposedUpdate,
GanttRangeInfo,
GanttResource,
GanttResourceReorder,
GanttRowAlign,
GanttScale,
GanttScheduleMode,
GanttSegment,
GanttSelection,
GanttSlotDraft,
GanttSlotInfo,
GanttState,
GanttUpdateResult,
} from "./types"
const DEFAULT_INTERACTIONS: GanttInteractions = {
drag: true,
resize: true,
selectSlot: true,
}
/** Infinite-scroll growth cap, in whole periods per side. */
const MAX_RANGE_WINDOW = 12
/** A node holds as many concurrent schedules as it needs unless told otherwise. */
const DEFAULT_SCHEDULE_MODE: GanttScheduleMode = "multiple"
/** Tree label sits on the first schedule's baseline, not the grown row's middle. */
const DEFAULT_ROW_ALIGN: GanttRowAlign = "start"
/**
* A node's cardinality: its own override wins over the view-level default.
* Shared by the layout pass and the gesture engine so both read one rule.
*/
function resolveScheduleMode(
node: GanttResource | null | undefined,
scheduleMode: GanttScheduleMode | undefined
): GanttScheduleMode {
return node?.scheduleMode ?? scheduleMode ?? DEFAULT_SCHEDULE_MODE
}
const EMPTY_SELECTION: GanttSelection = { eventKeys: [], slot: null }
export interface GanttCallbacks<TData = unknown> {
onEventClick?: (occurrence: GanttOccurrence<TData>, e: MouseEvent) => void
onEventDoubleClick?: (occurrence: GanttOccurrence<TData>, e: MouseEvent) => void
onEventUpdate?: (update: GanttProposedUpdate<TData>) => GanttUpdateResult
canDropEvent?: (update: GanttProposedUpdate<TData>) => boolean
onSlotClick?: (slot: GanttSlotInfo, e: MouseEvent) => void
onSelectSlot?: (slot: GanttSlotDraft) => void
canSelectSlot?: (slot: GanttSlotDraft) => boolean
/** Fires when the "add task" hint is activated; create a new tree row. */
onCreateTask?: (ctx: { parentId: string | null; index: number }) => void
/**
* Gates the "add task" hint. The shipped view offers root-level creation
* only (parentId = null); parentId stays in the contract for group-level
* affordances a consumer builds via its own UI + onCreateTask.
*/
canCreateTask?: (ctx: { parentId: string | null }) => boolean
/** Click on a tree row's surface (chevron/checkbox/grip clicks excluded). */
onResourceClick?: (ctx: GanttColumnContext, e: MouseEvent) => void
onResourceDoubleClick?: (ctx: GanttColumnContext, e: MouseEvent) => void
onRangeChange?: (info: GanttRangeInfo) => void
onScaleChange?: (scale: GanttScale) => void
onDateChange?: (date: Date) => void
onSelectionChange?: (selection: GanttSelection) => void
onInteractionsChange?: (interactions: GanttInteractions) => void
onEventsChange?: (events: GanttEvent<TData>[]) => void
/**
* Commit gate for timeline resource-row drag reorder. Return false to
* reject; apply the move by adopting proposal.resources into your
* `resources` state (controlled - the calendar never self-mutates).
*/
onResourceReorder?: (proposal: GanttResourceReorder) => void | false
/** Live validity predicate while a resource row is being dragged. */
canReorderResource?: (proposal: GanttResourceReorder) => boolean
/**
* Fires when a reorder gesture is released on a position rejected by
* `canReorderResource` (e.g. a pinned row). Use it to explain the rejection
* (a toast) - the destructive drop indicator already shows it live.
*/
onResourceReorderReject?: (proposal: GanttResourceReorder) => void
}
/** Pointer-activation thresholds; unset keys keep the dnd-kit parity defaults. */
export interface GanttActivationConfig {
/** Mouse travel (px) before a bar move starts. Default 5. */
moveDistancePx?: number
/** Mouse travel (px) before a drag-create starts. Default 4. */
createDistancePx?: number
/** Touch long-press delay in ms. Default 250. */
touchDelayMs?: number
/** Touch movement tolerance (px) during the long-press. Default 5. */
touchTolerancePx?: number
}
export interface UseGanttStateOptions<TData = unknown> extends GanttCallbacks<TData> {
events?: GanttEvent<TData>[]
defaultEvents?: GanttEvent<TData>[]
scale?: GanttScale
defaultScale?: GanttScale
date?: Date
defaultDate?: Date
selection?: GanttSelection
defaultSelection?: GanttSelection
interactions?: Partial<GanttInteractions>
defaultInteractions?: Partial<GanttInteractions>
loading?: boolean
timeZone?: string
locale?: Locale
weekStartsOn?: WeekStartsOn
slotDuration?: number
snapDuration?: number
i18n?: GanttI18nOverrides
/** Hard travel bounds for infinite scrolling; either side may be omitted. */
rangeBounds?: { min?: Date; max?: Date }
/** Pointer-activation threshold overrides for drag/resize/create. */
activation?: GanttActivationConfig
/** Infinite-scroll growth cap in whole periods per side. Default 12. */
maxRangeWindow?: number
/** Tree nodes of the gantt (GanttNode is the preferred type name). */
resources?: GanttResource[]
/**
* What a gesture may do when it would overlap another schedule in the SAME
* node: "allow" (default), "clamp" to the neighbour's edge, or "reject".
*/
overlap?: GanttOverlapPolicy
getEventPriority?: (event: GanttEvent<TData>) => number
eventOrder?: (a: GanttOccurrence<TData>, b: GanttOccurrence<TData>) => number
getOccurrences?: (
event: GanttEvent<TData>,
range: GanttDateRange,
ctx: { timeZone: string }
) => Array<{ start: Date; end: Date }> | null
}
/** Resolved configuration; every UseGanttStateOptions field with defaults applied and i18n merged. */
export interface GanttSettings<TData = unknown> extends GanttCallbacks<TData> {
timeZone: string
locale?: Locale
weekStartsOn: WeekStartsOn
slotDuration: number
snapDuration: number
i18n: GanttI18nConfig
rangeBounds?: { min?: Date; max?: Date }
activation?: GanttActivationConfig
maxRangeWindow?: number
resources: GanttResource[]
overlap: GanttOverlapPolicy
getEventPriority: (event: GanttEvent<TData>) => number
eventOrder: (a: GanttOccurrence<TData>, b: GanttOccurrence<TData>) => number
getOccurrences?: (
event: GanttEvent<TData>,
range: GanttDateRange,
ctx: { timeZone: string }
) => Array<{ start: Date; end: Date }> | null
}
export interface GanttApi<TData = unknown> {
next(): void
prev(): void
today(): void
goTo(date: Date): void
setScale(scale: GanttScale): void
getEvents(): GanttEvent<TData>[]
getEvent(id: GanttBarId): GanttEvent<TData> | undefined
setEvents(events: GanttEvent<TData>[]): void
addEvent(event: GanttEvent<TData>): void
updateEvent(id: GanttBarId, patch: Partial<GanttEvent<TData>>): void
removeEvent(id: GanttBarId): void
getOccurrences(range?: GanttDateRange): GanttOccurrence<TData>[]
findOverlapping(candidate: {
start: Date
end: Date
excludeEventId?: string
}): GanttOccurrence<TData>[]
select(selection: Partial<GanttSelection>): void
selectEvent(key: string, opts?: { additive?: boolean }): void
clearSelection(): void
setInteractions(patch: Partial<GanttInteractions>): void
getVisibleRange(): GanttDateRange
getActiveRange(): GanttDateRange
/** TZDate in the gantt's display time zone. */
toZoned(date: Date): Date
}
/** Cross-file plumbing for sibling view/interaction modules; not public API. */
export interface GanttInternals<TData = unknown> {
getIndex(): GanttIndex<TData>
setDrag(drag: GanttDragState<TData> | null): void
setSlotDraft(draft: GanttSlotDraft | null): void
applyProposedUpdate(
update: GanttProposedUpdate<TData>,
extra?: Partial<GanttEvent<TData>>
): boolean
/** Grow visibleRange by whole periods; resets on date/scale changes. */
extendRange(direction: "before" | "after"): boolean
/** True when the LAST anchor-date change was an extendRange window slide. */
didAnchorSlide(): boolean
/** View reports the visible-center instant (or null) for the nav title. */
setViewportCenter(date: Date | null): void
}
export interface GanttInstance<TData = unknown> {
state: ComputedRef<GanttState<TData>>
api: GanttApi<TData>
settings: ComputedRef<GanttSettings<TData>>
internals: GanttInternals<TData>
}
function resolveSettings<TData>(
options: UseGanttStateOptions<TData>
): GanttSettings<TData> {
const getEventPriority =
options.getEventPriority ?? ((event: GanttEvent<TData>) => event.priority ?? 0)
return {
onEventClick: options.onEventClick,
onEventDoubleClick: options.onEventDoubleClick,
onEventUpdate: options.onEventUpdate,
canDropEvent: options.canDropEvent,
onSlotClick: options.onSlotClick,
onSelectSlot: options.onSelectSlot,
canSelectSlot: options.canSelectSlot,
onCreateTask: options.onCreateTask,
canCreateTask: options.canCreateTask,
onResourceClick: options.onResourceClick,
onResourceDoubleClick: options.onResourceDoubleClick,
onRangeChange: options.onRangeChange,
onScaleChange: options.onScaleChange,
onDateChange: options.onDateChange,
onSelectionChange: options.onSelectionChange,
onInteractionsChange: options.onInteractionsChange,
onEventsChange: options.onEventsChange,
onResourceReorder: options.onResourceReorder,
canReorderResource: options.canReorderResource,
onResourceReorderReject: options.onResourceReorderReject,
timeZone: options.timeZone ?? Intl.DateTimeFormat().resolvedOptions().timeZone,
locale: options.locale,
// locale-first default: a de/fr locale gets Monday weeks without also
// having to set weekStartsOn; an explicit weekStartsOn always wins
weekStartsOn:
options.weekStartsOn ?? (options.locale?.options?.weekStartsOn as WeekStartsOn | undefined) ?? 0,
slotDuration: options.slotDuration ?? 30,
snapDuration: options.snapDuration ?? 15,
i18n: mergeGanttI18n(options.i18n),
rangeBounds: options.rangeBounds,
activation: options.activation,
maxRangeWindow: options.maxRangeWindow,
resources: options.resources ?? [],
overlap: options.overlap ?? "allow",
getEventPriority,
// priority-aware default: higher getEventPriority packs/orders first
eventOrder:
options.eventOrder ??
((a, b) => getEventPriority(b.event) - getEventPriority(a.event) || defaultEventOrder(a, b)),
getOccurrences: options.getOccurrences,
}
}
/**
* Headless root store. `options` MUST be the caller's own reactive props
* object (a component's `props`, or a `reactive()`) - every field is read
* inside `computed()`, so Vue's dependency tracking does the rest; a plain
* snapshot object would freeze the gantt at its initial props forever.
*/
function useGanttState<TData = unknown>(
options: UseGanttStateOptions<TData> = {}
): GanttInstance<TData> {
const settings = computed(() => resolveSettings(options))
// Uncontrolled fallback state - only used while the matching option is undefined.
const _scale = ref<GanttScale>(options.defaultScale ?? "day") as Ref<GanttScale>
const _date = ref<Date>(options.defaultDate ?? new Date()) as Ref<Date>
const _events = ref<GanttEvent<TData>[]>(options.defaultEvents ?? []) as Ref<
GanttEvent<TData>[]
>
const _selection = ref<GanttSelection>(options.defaultSelection ?? EMPTY_SELECTION) as Ref<GanttSelection>
const _interactions = ref<GanttInteractions>({
...DEFAULT_INTERACTIONS,
...options.defaultInteractions,
}) as Ref<GanttInteractions>
const _drag = ref<GanttDragState<TData> | null>(null) as Ref<GanttDragState<TData> | null>
const _slotDraft = ref<GanttSlotDraft | null>(null) as Ref<GanttSlotDraft | null>
const _rangeWindow = reactive({ before: 0, after: 0 })
const _viewportCenter = ref<Date | null>(null) as Ref<Date | null>
/** Whether the last anchor change came from an extendRange window slide. */
let lastAnchorChangeWasSlide = false
// External (controlled) anchor changes re-anchor the axis: drop any
// infinite-scroll growth and let the title follow the anchor again, same
// as the uncontrolled setters below - but these fire for a controlled
// `date`/`scale` prop the PARENT changed, which the setters never see.
watch(
() => [options.date?.getTime(), options.scale] as const,
() => {
if (lastAnchorChangeWasSlide) {
// this change is our own extendRange proposal echoed back; consume once
lastAnchorChangeWasSlide = false
return
}
_rangeWindow.before = 0
_rangeWindow.after = 0
_viewportCenter.value = null
}
)
const rangeOpts = () => ({
timeZone: settings.value.timeZone,
weekStartsOn: settings.value.weekStartsOn,
})
const state = computed<GanttState<TData>>(() => {
const scale = options.scale ?? _scale.value
const date = options.date ?? _date.value
const opts = rangeOpts()
const { visibleRange: baseRange, activeRange } = getGanttDateRange(scale, date, opts)
const { before, after } = _rangeWindow
let visibleRange = baseRange
if (before > 0 || after > 0) {
let earlier = date
for (let i = 0; i < before; i++) earlier = stepGanttDate(scale, earlier, -1, opts)
let later = date
for (let i = 0; i < after; i++) later = stepGanttDate(scale, later, 1, opts)
visibleRange = {
start: getGanttDateRange(scale, earlier, opts).visibleRange.start,
end: getGanttDateRange(scale, later, opts).visibleRange.end,
}
}
return {
scale,
date,
visibleRange,
activeRange,
events: options.events ?? _events.value,
selection: options.selection ?? _selection.value,
interactions: options.interactions
? { ...DEFAULT_INTERACTIONS, ...options.interactions }
: _interactions.value,
loading: options.loading ?? false,
drag: _drag.value,
slotDraft: _slotDraft.value,
viewportCenter: _viewportCenter.value,
}
})
let lastEmittedRangeKey: string | null = null
watch(
() => [state.value.scale, getRangeKey(state.value.visibleRange)] as const,
() => {
if (!settings.value.onRangeChange) return
const s = state.value
const key = `${s.scale}:${getRangeKey(s.visibleRange)}:${settings.value.timeZone}`
if (key === lastEmittedRangeKey) return
lastEmittedRangeKey = key
settings.value.onRangeChange({
range: s.visibleRange,
activeRange: s.activeRange,
scale: s.scale,
date: s.date,
timeZone: settings.value.timeZone,
})
},
{ immediate: true }
)
const commitDate = (next: Date) => {
const current = state.value.date
if (current.getTime() === next.getTime()) return
_rangeWindow.before = 0
_rangeWindow.after = 0
_viewportCenter.value = null
lastAnchorChangeWasSlide = false
if (options.date === undefined) _date.value = next
settings.value.onDateChange?.(next)
}
const commitScale = (next: GanttScale) => {
if (state.value.scale === next) return
_rangeWindow.before = 0
_rangeWindow.after = 0
_viewportCenter.value = null
lastAnchorChangeWasSlide = false
if (options.scale === undefined) _scale.value = next
settings.value.onScaleChange?.(next)
}
const commitEvents = (next: GanttEvent<TData>[]) => {
if (options.events === undefined) _events.value = next
settings.value.onEventsChange?.(next)
}
const commitSelection = (next: GanttSelection) => {
if (options.selection === undefined) _selection.value = next
settings.value.onSelectionChange?.(next)
}
const setInteractionsField = (next: GanttInteractions) => {
if (options.interactions === undefined) _interactions.value = next
settings.value.onInteractionsChange?.(next)
}
const applyProposedUpdate = (
update: GanttProposedUpdate<TData>,
extra?: Partial<GanttEvent<TData>>
): boolean => {
const result = settings.value.onEventUpdate?.(update)
if (result === false) return false
const adjusted: Partial<GanttEvent<TData>> =
result && typeof result === "object"
? {
start: result.start ?? update.start,
end: result.end ?? update.end,
allDay: result.allDay ?? update.allDay,
}
: { start: update.start, end: update.end, allDay: update.allDay }
if (update.resourceId !== undefined) adjusted.resourceId = update.resourceId
const merged = extra ? { ...extra, ...adjusted } : adjusted
const next = state.value.events.map((event) =>
event.id === update.event.id ? { ...event, ...merged } : event
)
commitEvents(next)
return true
}
const getIndex = computed<GanttIndex<TData>>(() =>
buildEventIndex(state.value.events, state.value.visibleRange, {
timeZone: settings.value.timeZone,
eventOrder: settings.value.eventOrder,
getOccurrences: settings.value.getOccurrences,
})
)
/** Anchor clamp: navigation may never leave the configured bounds. */
const clampToBounds = (date: Date): Date => {
const bounds = settings.value.rangeBounds
if (!bounds) return date
if (bounds.min && date.getTime() < bounds.min.getTime()) return bounds.min
if (bounds.max && date.getTime() > bounds.max.getTime()) return bounds.max
return date
}
const api: GanttApi<TData> = {
next() {
commitDate(clampToBounds(stepGanttDate(state.value.scale, state.value.date, 1, rangeOpts())))
},
prev() {
commitDate(clampToBounds(stepGanttDate(state.value.scale, state.value.date, -1, rangeOpts())))
},
today() {
commitDate(clampToBounds(new Date()))
},
goTo(date) {
commitDate(clampToBounds(date))
},
setScale(scale) {
commitScale(scale)
},
getEvents() {
return state.value.events
},
getEvent(id) {
return state.value.events.find((event) => event.id === id)
},
setEvents(events) {
commitEvents(events)
},
addEvent(event) {
commitEvents([...state.value.events, event])
},
updateEvent(id, patch) {
const event = api.getEvent(id)
if (!event) return
const merged = { ...event, ...patch }
const timingChanged =
patch.start !== undefined || patch.end !== undefined || patch.allDay !== undefined
if (timingChanged && settings.value.onEventUpdate) {
const rest: Partial<GanttEvent<TData>> = { ...patch }
delete rest.start
delete rest.end
delete rest.allDay
applyProposedUpdate(
{
event: merged,
occurrence: null,
start: merged.start,
end: merged.end,
allDay: merged.allDay ?? false,
source: "api",
},
Object.keys(rest).length > 0 ? rest : undefined
)
return
}
commitEvents(state.value.events.map((e) => (e.id === id ? merged : e)))
},
removeEvent(id) {
commitEvents(state.value.events.filter((event) => event.id !== id))
},
getOccurrences(range) {
if (!range) return getIndex.value.occurrences
const s = state.value
const within = range.start >= s.visibleRange.start && range.end <= s.visibleRange.end
if (within) {
return getIndex.value.occurrences.filter((occ) => eventsOverlap(occ, range))
}
return buildEventIndex(s.events, range, {
timeZone: settings.value.timeZone,
eventOrder: settings.value.eventOrder,
getOccurrences: settings.value.getOccurrences,
}).occurrences
},
findOverlapping({ start, end, excludeEventId }) {
return api.getOccurrences({ start, end }).filter((occ) => occ.eventId !== excludeEventId)
},
select(partial) {
const current = state.value.selection
commitSelection({
eventKeys: partial.eventKeys ?? current.eventKeys,
slot: partial.slot !== undefined ? partial.slot : current.slot,
})
},
selectEvent(key, opts) {
const current = state.value.selection
const eventKeys = opts?.additive
? current.eventKeys.includes(key)
? current.eventKeys.filter((k) => k !== key)
: [...current.eventKeys, key]
: [key]
commitSelection({ ...current, eventKeys })
},
clearSelection() {
commitSelection(EMPTY_SELECTION)
},
setInteractions(patch) {
setInteractionsField({ ...state.value.interactions, ...patch })
},
getVisibleRange() {
return state.value.visibleRange
},
getActiveRange() {
return state.value.activeRange
},
toZoned(date) {
return toZoned(date, settings.value.timeZone)
},
}
const internals: GanttInternals<TData> = {
getIndex() {
return getIndex.value
},
setDrag(drag) {
_drag.value = drag
},
setSlotDraft(draft) {
_slotDraft.value = draft
},
setViewportCenter(date) {
if (_viewportCenter.value?.getTime() === date?.getTime()) return
_viewportCenter.value = date
},
applyProposedUpdate,
extendRange(direction) {
const s = state.value
const bounds = settings.value.rangeBounds
if (
direction === "before" &&
bounds?.min &&
s.visibleRange.start.getTime() <= bounds.min.getTime()
) {
return false
}
if (
direction === "after" &&
bounds?.max &&
s.visibleRange.end.getTime() >= bounds.max.getTime()
) {
return false
}
const cap = Math.max(1, settings.value.maxRangeWindow ?? MAX_RANGE_WINDOW)
const { before, after } = _rangeWindow
const grow = direction === "before" ? before < cap : after < cap
if (grow) {
if (direction === "before") _rangeWindow.before = before + 1
else _rangeWindow.after = after + 1
} else {
// window is at capacity: SLIDE the anchor one period instead, so
// travel stays unbounded while the DOM stays bounded
const next = stepGanttDate(s.scale, s.date, direction === "before" ? -1 : 1, {
timeZone: settings.value.timeZone,
})
if (options.date !== undefined) {
// controlled anchor: propose the slide; nothing changes until the
// parent adopts it
lastAnchorChangeWasSlide = true
settings.value.onDateChange?.(next)
return false
}
_date.value = next
lastAnchorChangeWasSlide = true
settings.value.onDateChange?.(next)
}
return true
},
didAnchorSlide() {
return lastAnchorChangeWasSlide
},
}
return { state, api, settings, internals }
}
export const GanttContextKey: InjectionKey<GanttInstance<unknown>> = Symbol("GanttContext")
/** The stable calendar instance; throws outside <Gantt>. */
function useGantt<TData = unknown>(): GanttInstance<TData> {
const instance = inject(GanttContextKey, undefined)
if (!instance) {
throw new Error("useGantt must be used within <Gantt>")
}
return instance as unknown as GanttInstance<TData>
}
interface UseGanttSelectorOptions<TData> {
calendar?: GanttInstance<TData>
}
/**
* Thin `computed()` wrapper kept for call-site parity with the original
* (gantt-view.tsx and friends import it by name). `isEqual` from the
* original is not needed: Vue's computed caching already skips re-evaluation
* unless a tracked dependency actually changed.
*/
function useGanttSelector<TData = unknown, TSelected = unknown>(
selector: (state: GanttState<TData>) => TSelected,
options?: UseGanttSelectorOptions<TData>
): ComputedRef<TSelected> {
const instance = options?.calendar ?? useGantt<TData>()
return computed(() => selector(instance.state.value))
}
function useGanttScale(): { scale: ComputedRef<GanttScale>; setScale: (scale: GanttScale) => void } {
const instance = useGantt()
return { scale: computed(() => instance.state.value.scale), setScale: instance.api.setScale }
}
export interface GanttNavigation {
date: ComputedRef<Date>
/** i18n.functions.formatTitle output for the current view. */
title: ComputedRef<string>
visibleRange: ComputedRef<GanttDateRange>
activeRange: ComputedRef<GanttDateRange>
next: () => void
prev: () => void
today: () => void
goTo: (date: Date) => void
/** True when the anchor period contains now in the display time zone. */
isToday: ComputedRef<boolean>
}
function useGanttNavigation(): GanttNavigation {
const instance = useGantt()
const { settings } = instance
const title = computed(() => {
const s = instance.state.value
// The title names what you are LOOKING at: the visible-center period
// when the view reports one, otherwise the anchor period.
const titleDate = s.viewportCenter ?? s.date
const titleActive = s.viewportCenter
? getGanttDateRange(s.scale, s.viewportCenter, {
timeZone: settings.value.timeZone,
weekStartsOn: settings.value.weekStartsOn,
}).activeRange
: s.activeRange
return settings.value.i18n.functions.formatTitle(s.scale, {
date: toZoned(titleDate, settings.value.timeZone),
activeRange: titleActive,
visibleRange: s.visibleRange,
locale: settings.value.locale,
})
})
return {
date: computed(() => instance.state.value.date),
title,
visibleRange: computed(() => instance.state.value.visibleRange),
activeRange: computed(() => instance.state.value.activeRange),
next: instance.api.next,
prev: instance.api.prev,
today: instance.api.today,
goTo: instance.api.goTo,
isToday: computed(() => {
const now = new Date()
const s = instance.state.value
return now >= s.activeRange.start && now < s.activeRange.end
}),
}
}
function useGanttSelection(): {
selection: ComputedRef<GanttSelection>
select: (selection: Partial<GanttSelection>) => void
selectEvent: (key: string, opts?: { additive?: boolean }) => void
clearSelection: () => void
} {
const instance = useGantt()
return {
selection: computed(() => instance.state.value.selection),
select: instance.api.select,
selectEvent: instance.api.selectEvent,
clearSelection: instance.api.clearSelection,
}
}
function useGanttInteractions(): {
interactions: ComputedRef<GanttInteractions>
setInteractions: (patch: Partial<GanttInteractions>) => void
} {
const instance = useGantt()
return {
interactions: computed(() => instance.state.value.interactions),
setInteractions: instance.api.setInteractions,
}
}
/** Expanded, sorted occurrences; defaults to the visible range. */
function useGanttOccurrences<TData = unknown>(
range?: GanttDateRange
): ComputedRef<GanttOccurrence<TData>[]> {
const instance = useGantt<TData>()
return computed(() => instance.api.getOccurrences(range))
}
/** Resolved settings incl. merged i18n. */
function useGanttSettings<TData = unknown>(): ComputedRef<GanttSettings<TData>> {
return useGantt<TData>().settings
}
export interface GanttClassNames {
nav?: string
toolbar?: string
/** The gantt body (tree + track). */
view?: string
event?: string
}
/** Row context handed to tree-panel column and label renderers. */
export interface GanttColumnContext {
resource: GanttResource
depth: number
isGroup: boolean
collapsed: boolean
}
/** One extra tree-panel column after the built-in name column. */
export interface GanttColumn {
/** Stable id; doubles as the default header label. */
id: string
/** Header label. */
title?: VNodeChild
/** Fixed column width in px. Default 96. */
width?: number
/** Cell content alignment. Default "start". */
align?: "start" | "center" | "end"
/** Cell content per row; omit or return null for an empty cell. */
render?: (ctx: GanttColumnContext) => VNodeChild
/** Extra classes on every cell of this column (header included). */
className?: string
}
export type GanttGridLine = boolean | "solid" | "dashed"
export interface GanttTimelineLines {
/** Unit boundary lines running down the timeline. Default solid. */
vertical?: GanttGridLine
/** Row separator lines running across the timeline. Default solid. */
horizontal?: GanttGridLine
}
/** Resolved stroke per axis; null means the axis draws nothing. */
export interface GanttResolvedLines {
vertical: "solid" | "dashed" | null
horizontal: "solid" | "dashed" | null
}
/**
* One place decides what the grid draws, so the header lines, the body lines
* and the row separators can never disagree.
*/
function resolveTimelineLines(
value: GanttTimelineLines | "vertical" | "both" | "none" | undefined
): GanttResolvedLines {
if (value === "none") return { vertical: null, horizontal: null }
if (value === "vertical") return { vertical: "solid", horizontal: null }
if (value === "both" || value === undefined) {
return { vertical: "solid", horizontal: "solid" }
}
const stroke = (line: GanttGridLine | undefined) =>
line === false ? null : line === true || line === undefined ? "solid" : line
return { vertical: stroke(value.vertical), horizontal: stroke(value.horizontal) }
}
export interface GanttMetrics {
/** Height of one schedule bar. Default 1.25. */
laneHeight?: number
/** Gap between stacked schedules in one node. Default 0.1875. */
laneGap?: number
/** Vertical inset between the row's edges and its block of schedules. Default 0.5. */
rowPadding?: number
/** Minimum row height. Default 2.5. */
minRowHeight?: number
/** barLabel "auto" flips the title outside below this bar width. Default 7. */
autoLabelMin?: number
/** Unit width at zoom 1, per scale. Day scale = width per interval unit. */
unitWidths?: Partial<Record<GanttScale, number>>
/** Minimum timeline pane width in px. Default 200. */
minTimelineWidth?: number
/** Scroll distance (px) from an edge that grows the range. Default 160. */
infiniteScrollEdge?: number
}
/** Live gesture snapshot handed to the drag/resize indicator render props. */
export interface GanttDragIndicatorProps<TData = unknown> {
occurrence: GanttOccurrence<TData>
kind: "move" | "resize-start" | "resize-end"
/** Proposed (snapped) range of the current gesture step. */
start: Date
end: Date
valid: boolean
}
/** Slot handed to a custom schedule-hint renderer. */
export interface GanttScheduleHintProps {
start: Date
end: Date
resource: GanttResource
}
/** Parent rollup handed to a custom summary renderer. */
export interface GanttSummaryProps {
resource: GanttResource
start: Date
end: Date
progress: number | null
}
/** Left tree-panel sizing and splitter behavior. */
export interface GanttTreePanelConfig {
/** Initial panel width in px. Default 288. */
width?: number
/** Splitter lower bound in px. Default 180. */
minWidth?: number
/** Splitter upper bound in px. Default 640. */
maxWidth?: number
/** Drag/keyboard splitter between the panels. Default true. */
resizable?: boolean
/** Width of the sticky name column in px. Default 208. */
nameColumnWidth?: number
/** Fires after any user resize (drag release, keyboard, double-click reset). */
onWidthChange?: (width: number) => void
}
export interface GanttRenderEventProps<TData = unknown> {
occurrence: GanttOccurrence<TData>
segment: GanttSegment<TData>
isDragging: boolean
isSelected: boolean
}
/**
* View-layer configuration: display props and render overrides. These live on
* <Gantt> (and per-view components), never in the headless options.
*/
export interface GanttViewConfig<TData = unknown> {
/** Red now-line on the axis. */
nowIndicator: boolean
/** Day-scale unit interval in minutes: axis units and gridlines follow it. */
interval: number
/** Scroll implementation for the gantt body: "custom" (shadcn ScrollArea) or "native". */
scrollbars: "custom" | "native"
/** Placement hint over empty timeline track. Default off. */
displayScheduleHint: boolean
/** Where the viewport opens: "now" (default), "anchor", or an explicit instant. */
initialCenter: "now" | "anchor" | Date
/** Empty-track presses on schedulable rows start a drag-create gesture. Default off. */
dragCreate: boolean
/** "Add task" affordance at the foot of the tree. Default off. */
displayCreateTaskHint: boolean
/** Floating zoom in/out control over the track. Default on. */
zoomControl: boolean
/** Ctrl/Cmd + wheel over the timeline zooms the time range. Default on. */
wheelZoom: boolean
/** Nav button variant; all nav buttons follow it. Default "ghost". */
navButtonVariant: "ghost" | "outline" | "secondary" | "default"
/** Nav button size; icon buttons use the icon twin. Default "sm". */
navButtonSize: "sm" | "default"
/** Off-day (non-working day) marking on day/week/month scales. */
offDays?: boolean | GanttOffDaysConfig
/** Extra tree-panel columns after the built-in name column. */
columns?: GanttColumn[]
/** Consumer slot pinned at the end of the tree-panel header. */
columnsMenu?: VNodeChild
/** Tree-panel width, splitter bounds, and resizability. */
treePanel?: GanttTreePanelConfig
/** Timeline gridlines; the object form controls the two axes independently. */
timelineLines: GanttTimelineLines | "vertical" | "both" | "none"
/** Bar title placement: "inside" (default), "outside", or "auto". */
barLabel: "inside" | "outside" | "auto"
/** Edge chips that scroll to bars outside the visible timeline. Default true. */
offscreenIndicators: boolean
/** Extend the timeline into the past/future while scrolling near an edge. Default true. */
infiniteScroll: boolean
/** Zoom bounds and button step for the floating control. Default 0.5 - 3, step 0.25. */
zoomRange?: { min?: number; max?: number; step?: number }
/** Layout metric overrides (row/lane/unit geometry, thresholds). */
metrics?: GanttMetrics
/** Sticky nav bar. Default false. */
stickyNav: boolean
/** Leaf-row selection checkboxes in the tree panel. Default true. */
rowCheckboxes: boolean
/** Controlled selected row ids; pairs with onSelectedRowsChange. */
selectedRows?: string[]
onSelectedRowsChange?: (ids: string[]) => void
/** Controlled collapsed group ids; pairs with onCollapsedGroupsChange. */
collapsedGroups?: string[]
/** Initial collapsed group ids (uncontrolled). */
defaultCollapsedGroups?: string[]
onCollapsedGroupsChange?: (ids: string[]) => void
/** Controlled zoom multiplier; pairs with onZoomChange. */
zoom?: number
/** Initial zoom multiplier (uncontrolled). Default 1. */
defaultZoom?: number
onZoomChange?: (zoom: number) => void
/** Allow drag-create and slot clicks on rows that have children. Default false. */
parentScheduling: boolean
/** Rollup strips on parent rows without bars of their own. Default true. */
summaryBars: boolean
/** How many schedules a tree node may hold. "multiple" (default) or "single". */
scheduleMode: GanttScheduleMode
/** Vertical placement of a row's content once a node holds several lanes. */
rowAlign: GanttRowAlign
classNames?: GanttClassNames
renderEvent?: (props: GanttRenderEventProps<TData>) => VNodeChild
/**
* Right-click menu for a bar: return ContextMenu items. Read the occurrence
* for the subject and drive actions through the gantt api (useGantt) or
* your own state - fully headless. Omit for no menu.
*/
renderEventMenu?: (props: GanttRenderEventProps<TData>) => VNodeChild
/** Tree-node label. Receives the resource with its tree position. */
renderResourceLabel?: (props: {
resource: GanttResource
depth: number
isGroup: boolean
collapsed: boolean
}) => VNodeChild
/** Right-click menu for a tree row (same contract as renderEventMenu). */
renderResourceMenu?: (ctx: GanttColumnContext) => VNodeChild
/** Rendered in the timeline body when there are no resources. */
renderNoResources?: () => VNodeChild
/** Replaces the smooth cursor-following MOVE clone. */
renderDragPreview?: (props: GanttDragIndicatorProps<TData>) => VNodeChild
/** Replaces the RESIZE edge line + status chip. */
renderResizeIndicator?: (props: GanttDragIndicatorProps<TData>) => VNodeChild
/** Replaces the schedule-hint tile + bubble. */
renderScheduleHint?: (props: GanttScheduleHintProps) => VNodeChild
/** Replaces the parent rollup strip (the positioned wrapper stays gantt-owned). */
renderSummary?: (props: GanttSummaryProps) => VNodeChild
/** Replaces the rollup MATH: return 0-100 (or null to hide). */
getSummaryProgress?: (ctx: { resource: GanttResource; events: GanttEvent<TData>[] }) => number | null
}
export const DEFAULT_VIEW_CONFIG: GanttViewConfig = {
nowIndicator: true,
interval: 60,
scrollbars: "custom",
displayScheduleHint: false,
initialCenter: "now",
displayCreateTaskHint: false,
dragCreate: false,
zoomControl: true,
wheelZoom: true,
navButtonVariant: "ghost",
navButtonSize: "sm",
timelineLines: "vertical",
barLabel: "inside",
offscreenIndicators: true,
infiniteScroll: true,
stickyNav: false,
rowCheckboxes: true,
parentScheduling: false,
summaryBars: true,
scheduleMode: "multiple",
rowAlign: "start",
}
export const GanttViewConfigContextKey: InjectionKey<ComputedRef<GanttViewConfig<unknown>>> =
Symbol("GanttViewConfigContext")
const DEFAULT_VIEW_CONFIG_REF = computed(() => DEFAULT_VIEW_CONFIG)
/**
* Root-level display props + render overrides, for view components.
* Returns a `ComputedRef` (like `useGanttSettings`) - read fields as
* `viewConfig.value.xxx`, not `viewConfig.xxx`, so a `<Gantt>` prop change
* (e.g. `zoom`, `metrics`) actually reaches every consumer instead of being
* frozen at the value seen when the component first called this composable.
*/
function useGanttViewConfig<TData = unknown>(): ComputedRef<GanttViewConfig<TData>> {
return (inject(GanttViewConfigContextKey, DEFAULT_VIEW_CONFIG_REF) as ComputedRef<GanttViewConfig<TData>>)
}
export interface GanttNodeSchedules<TData = unknown> {
/** The node itself, or null when the id is not in the tree. */
node: GanttResource | null
/** Cardinality in force for this node (its own override, else the default). */
scheduleMode: GanttScheduleMode
/** The node's occurrences in the visible range, in axis order. */
schedules: GanttOccurrence<TData>[]
/** Pairs of the node's schedules that overlap in time. */
conflicts: Array<[GanttOccurrence<TData>, GanttOccurrence<TData>]>
}
/**
* Everything a consumer needs to MANAGE one node's schedules without
* re-deriving layout: the node, its resolved cardinality, its schedules in
* order, and the pairs that collide.
*/
function useGanttNodeSchedules<TData = unknown>(
nodeId: string
): ComputedRef<GanttNodeSchedules<TData>> {
const settings = useGanttSettings<TData>()
const viewConfig = useGanttViewConfig<TData>()
const occurrences = useGanttOccurrences<TData>()
return computed(() => {
const node = findResource(settings.value.resources, nodeId)
const schedules = occurrences.value.filter((occurrence) => occurrence.event.resourceId === nodeId)
const conflicts: Array<[GanttOccurrence<TData>, GanttOccurrence<TData>]> = []
for (let i = 0; i < schedules.length; i++) {
for (let j = i + 1; j < schedules.length; j++) {
const a = schedules[i]!
const b = schedules[j]!
if (eventsOverlap(a, b)) conflicts.push([a, b])
}
}
return {
node,
scheduleMode: resolveScheduleMode(node, viewConfig.value.scheduleMode),
schedules,
conflicts,
}
})
}
/**
* Часть порта gantt-bar.tsx (MIT) — контекст, не сам бар-компонент
* (GanttBar.vue). Оригинал определяет его в gantt-bar.tsx; здесь он живёт
* рядом с остальными контекстами, тот же приём, что и в reui/kanban/context.ts.
*/
export interface GanttBarContextValue<TData = unknown> {
occurrence: GanttOccurrence<TData>
segment: GanttSegment<TData>
isDragging: boolean
isSelected: boolean
}
export const GanttBarContextKey: InjectionKey<GanttBarContextValue<unknown>> =
Symbol("GanttBarContext")
/** The bar's subject; usable inside renderEvent content and bar children. */
function useGanttBarContext<TData = unknown>(): GanttBarContextValue<TData> {
const ctx = inject(GanttBarContextKey, undefined)
if (!ctx) {
throw new Error("useGanttBarContext must be used within <GanttBar>")
}
return ctx as unknown as GanttBarContextValue<TData>
}
/**
* Effective Tailwind palette presets for bar colors; every entry works on
* light and dark surfaces through the bar's alpha background + accent border.
*/
export const GANTT_COLORS: Array<{ name: string; value: string }> = [
{ name: "Blue", value: "var(--color-blue-500)" },
{ name: "Emerald", value: "var(--color-emerald-500)" },
{ name: "Violet", value: "var(--color-violet-500)" },
{ name: "Rose", value: "var(--color-rose-500)" },
{ name: "Amber", value: "var(--color-amber-500)" },
{ name: "Cyan", value: "var(--color-cyan-500)" },
{ name: "Orange", value: "var(--color-orange-500)" },
{ name: "Pink", value: "var(--color-pink-500)" },
{ name: "Teal", value: "var(--color-teal-500)" },
{ name: "Indigo", value: "var(--color-indigo-500)" },
]
export {
DEFAULT_ROW_ALIGN,
DEFAULT_SCHEDULE_MODE,
resolveScheduleMode,
resolveTimelineLines,
useGantt,
useGanttBarContext,
useGanttInteractions,
useGanttNavigation,
useGanttNodeSchedules,
useGanttOccurrences,
useGanttScale,
useGanttSelection,
useGanttSelector,
useGanttSettings,
useGanttState,
useGanttViewConfig,
}
src/reui/gantt/gestures.ts
/**
* Порт ReUI Gantt dnd-движка (gantt-dnd.tsx, MIT) — ПОЛНЫЙ, не переписывание:
* подтверждено (эта же сессия, event-calendar параллельно пришёл к тому же
* выводу), что файл не использует dnd-kit вообще — чистые pointer-события
* на raw DOM (`window.addEventListener("pointermove"/"pointerup"/...)`,
* `element.setPointerCapture`, ручной оверлей через `document.createElement`).
* ADR-002 (dnd-kit -> Pragmatic Drag and Drop) сюда неприменим: нечего
* заменять, весь `beginGesture` перенесён почти дословно.
*
* Единственные правки relative к оригиналу:
* - `instance.getState()` -> `instance.state.value` (наш `GanttInstance`
* хранит state как `ComputedRef`, не как функцию-геттер).
* - `settings.xxx` -> `settings.value.xxx` (тот же ComputedRef).
* - `React.PointerEvent`/`e.nativeEvent` -> голый DOM `PointerEvent` (Vue
* не оборачивает события в синтетические, `e.currentTarget` работает
* так же, пока обработчик висит прямо в шаблоне на элементе-источнике).
* - `useCallback` не перенесён — не нужен, Vue не помнит идентичность
* функций между рендерами тем же способом, что и React (см. context.ts
* header про `useMemo`/`computed`, тот же приём).
*/
import { useGantt, useGanttViewConfig, resolveScheduleMode, type GanttInstance } from "./context"
import { findResource, snapMinutes, toZoned, zonedStartOfDay } from "./lib"
import type { GanttProposedUpdate, GanttScheduleMode, GanttSegment } from "./types"
import { addDays, differenceInCalendarDays } from "date-fns"
/**
* Activation policy (dnd-kit parity where proven):
* mouse move 5px before a drag starts (below = click), create 4px;
* touch long-press 250ms with 5px tolerance (movement past tolerance
* before the delay cancels the drag so taps stay taps).
*/
const GANTT_ACTIVATION = {
moveDistancePx: 5,
createDistancePx: 4,
touchDelayMs: 250,
touchTolerancePx: 5,
} as const
type GestureKind = "move" | "resize-start" | "resize-end" | "create"
interface GanttSurface {
rect: DOMRect
rangeStart: number
rangeEnd: number
snapMin: number
/** Mirrored axis: in RTL the range START sits at the rect's RIGHT edge. */
isRtl: boolean
rows: Array<{ resourceId: string; rect: DOMRect }>
}
/**
* Pointer x (viewport px) to minutes from the range start, clamped to the
* track. The single place the horizontal axis direction is resolved: every
* gesture mapping (move, both resizes, create, grab offset) goes through it.
*/
function surfaceMinutesAt(tl: GanttSurface, x: number): number {
const clamped = Math.min(Math.max(x, tl.rect.left), tl.rect.right)
const traveled = tl.isRtl ? tl.rect.right - clamped : clamped - tl.rect.left
return (traveled / tl.rect.width) * ((tl.rangeEnd - tl.rangeStart) / 60000)
}
/** Module flag so bar onClick can ignore the click that ends a drag. */
let lastGestureEndedAt = 0
function wasRecentDrag(): boolean {
return performance.now() - lastGestureEndedAt < 250
}
/** Mark a non-dnd gesture (e.g. a timeline pan) so the click it ends is ignored. */
function markGestureEnd(): void {
lastGestureEndedAt = performance.now()
}
/**
* Registry of in-flight gesture cancels. A gesture measures its surface
* (axis + row rects) once at activation, so the VIEW - not the bar, bars
* legitimately unmount mid-gesture - must be able to abort gestures when it
* unmounts or when the measured geometry changes under them (zoom, scale,
* range growth, splitter). Cancel fully reverts: listeners, overlays and the
* body drag state all clear, and no update is committed.
*/
const activeGestureCancels = new Set<() => void>()
/** Cancel (and fully revert) every in-flight gantt pointer gesture. */
function cancelActiveGanttGestures(): void {
for (const cancel of [...activeGestureCancels]) cancel()
}
/**
* View-level teardown: aborts any in-flight gesture so window listeners,
* body-appended overlays and the gantt-dragging body class never outlive
* the gantt. ponytail: not wired to an onUnmount hook yet - no single
* "view root" component owns the gantt's lifecycle end-to-end (GanttView's
* own assembly is still ahead); the function it needs to call already
* exists and works, wiring it is a one-line addition once that root exists.
*/
function useGanttGestureTeardown(): void {
cancelActiveGanttGestures()
}
/**
* Snap a translate offset to the device pixel grid. The cursor-following
* overlays (the move clone and the resize indicator) are their own
* `will-change: transform` compositing layers: the GPU rasterizes their text
* once and repositions that texture each frame, so a subpixel translate
* (getBoundingClientRect and raw clientX/Y are routinely fractional) resamples
* the texture and blurs the text. Rounding each offset to a whole device pixel
* lands the layer on the grid so glyphs stay crisp, without giving up the
* per-frame GPU transform.
*/
function snapToPixel(value: number): number {
const dpr = typeof window !== "undefined" ? window.devicePixelRatio || 1 : 1
return Math.round(value * dpr) / dpr
}
function collectSurface(root: HTMLElement | null): GanttSurface | null {
if (!root) return null
const axis = root.querySelector<HTMLElement>("[data-gantt-axis]")
if (!axis) return null
return {
rect: axis.getBoundingClientRect(),
rangeStart: Number(axis.dataset.ganttRangeStart),
rangeEnd: Number(axis.dataset.ganttRangeEnd),
snapMin: Number(axis.dataset.ganttSnap) || 15,
isRtl: getComputedStyle(axis).direction === "rtl",
rows: [...root.querySelectorAll<HTMLElement>("[data-gantt-row]")]
// static rows (parents that aggregate their subtree) take no drops
.filter((row) => row.dataset.ganttRowStatic === undefined)
.map((row) => ({
resourceId: row.dataset.ganttResource ?? "",
rect: row.getBoundingClientRect(),
})),
}
}
interface BeginGestureConfig<TData> {
instance: GanttInstance<TData>
kind: GestureKind
origin: HTMLElement
startEvent: PointerEvent
segment?: GanttSegment<TData>
/** Consumer renders the move preview (renderDragPreview); the engine only positions it. */
customMoveOverlay?: boolean
/** Consumer renders the resize indicator; the engine only positions it. */
customResizeOverlay?: boolean
/** View-level cardinality default; a node's own scheduleMode wins. */
scheduleMode?: GanttScheduleMode
}
function beginGesture<TData>(config: BeginGestureConfig<TData>) {
const { instance, kind, origin, startEvent, segment, customMoveOverlay, customResizeOverlay } = config
const { settings, internals, api } = instance
const activation = { ...GANTT_ACTIVATION, ...settings.value.activation }
const startX = startEvent.clientX
const startY = startEvent.clientY
const pointerId = startEvent.pointerId
// stable ancestors: bar nodes may be replaced by re-renders mid-gesture
const viewRoot = origin.closest<HTMLElement>("[data-slot=gantt-view]")
const ganttRoot = origin.closest<HTMLElement>("[data-slot=gantt]")
const announcer = ganttRoot?.querySelector<HTMLElement>("[data-slot=gantt-announcer]")
/**
* The cursor-following overlays are appended to document.body so no ancestor
* transform or overflow can clip them - which also cuts them off from the
* gantt root, and the root is what OWNS the type scale (its `text-xs` is
* what every resting label inherits). Without this the clone's label jumps
* to the document default and reads visibly bigger than the bar it left.
* Copying the ROOT's resolved metrics - rather than hardcoding a size -
* keeps the documented contract that one class on the root (e.g.
* className="text-sm") rescales the whole gantt, drag clone included.
*/
const adoptRootTypography = (el: HTMLElement) => {
if (!ganttRoot) return
const rootStyle = getComputedStyle(ganttRoot)
el.style.fontSize = rootStyle.fontSize
el.style.lineHeight = rootStyle.lineHeight
el.style.fontFamily = rootStyle.fontFamily
el.style.letterSpacing = rootStyle.letterSpacing
// physical positioning, logical content: the flex row mirrors so the
// label lands on the same side of the bar as the resting one in RTL
el.style.direction = rootStyle.direction
}
const isTouch = startEvent.pointerType === "touch"
// resize activates immediately on precise pointers; on touch it waits for
// the same long-press as a move, so a stray brush over a bar edge can
// never start an accidental resize
let active = kind.startsWith("resize") && !isTouch
let surface: GanttSurface | null = active ? collectSurface(viewRoot) : null
let lastProposalKey = ""
let touchTimer: ReturnType<typeof setTimeout> | null = null
let lastPointer: PointerEvent = startEvent
const occurrence = segment?.occurrence
// ----- neighbour awareness: the other schedules in the SAME node -----
// A node in "single" mode rejects any concurrency regardless of the
// overlap option; otherwise the option decides. "allow" short-circuits
// everything below, so the default gesture path is untouched.
const nodeId = occurrence?.event.resourceId
const nodeMode = resolveScheduleMode(nodeId === undefined ? null : findResource(settings.value.resources, nodeId), config.scheduleMode)
const overlapPolicy = nodeMode === "single" ? ("reject" as const) : settings.value.overlap
// Read once per gesture: the gantt never mutates events mid-drag, so the
// neighbours cannot move under us.
let neighbourCache: Array<{ start: number; end: number }> | null = null
const getNeighbours = () => {
if (neighbourCache) return neighbourCache
neighbourCache =
!occurrence || nodeId === undefined || overlapPolicy === "allow"
? []
: api
.getOccurrences()
.filter((other) => other.event.resourceId === nodeId && other.key !== occurrence.key)
.map((other) => ({ start: other.start.getTime(), end: other.end.getTime() }))
return neighbourCache
}
const overlapsNeighbour = (start: Date, end: Date) =>
getNeighbours().some((other) => other.start < end.getTime() && other.end > start.getTime())
/**
* Stop the gesture at the neighbour's edge. Runs AFTER snapping so the
* clamp always wins, and only against neighbours that sit clear of the
* bar's CURRENT span - a pre-existing overlap has no edge to stop at.
*/
const clampToNeighbours = (start: Date, end: Date): { start: Date; end: Date } => {
if (overlapPolicy !== "clamp" || !occurrence) return { start, end }
const anchorStart = occurrence.start.getTime()
const anchorEnd = occurrence.end.getTime()
let floor = -Infinity
let ceiling = Infinity
for (const other of getNeighbours()) {
if (other.end <= anchorStart) floor = Math.max(floor, other.end)
else if (other.start >= anchorEnd) ceiling = Math.min(ceiling, other.start)
}
if (floor === -Infinity && ceiling === Infinity) return { start, end }
let from = start.getTime()
let to = end.getTime()
if (kind === "resize-start") {
from = Math.min(Math.max(from, floor), to)
} else if (kind === "resize-end") {
to = Math.max(Math.min(to, ceiling), from)
} else {
// a move keeps its duration and parks against whichever edge it meets
const duration = to - from
if (from < floor) {
from = floor
to = from + duration
}
if (to > ceiling) {
to = ceiling
from = to - duration
}
// window narrower than the bar itself: park at the earlier edge
if (from < floor) {
from = floor
to = from + duration
}
}
return { start: new Date(from), end: new Date(to) }
}
// Set by applyProposal when a "reject" policy refuses the current proposal;
// read on pointerup so the commit is actually blocked, not merely styled.
let overlapRejected = false
// Preserve the grab offset so the bar does not jump to the pointer
let grabOffsetMin = 0
// Smooth cursor-following clone for a move: a real-looking bar that tracks
// the pointer's x via transform (no per-frame re-render), lifted with a shadow.
let overlay: HTMLDivElement | null = null
let grabOffsetPx = 0
let barTop = 0
let barWidth = 0
let barHeight = 0
const createMoveOverlay = () => {
if (kind !== "move" || !occurrence || overlay || barWidth === 0) return
// consumer-rendered preview (renderDragPreview): the view mounts it from
// drag state; positionOverlay adopts it lazily and only writes transforms
if (customMoveOverlay) return
const color = occurrence.event.color ?? "var(--color-primary)"
overlay = document.createElement("div")
overlay.setAttribute("data-slot", "gantt-drag-overlay")
// container: the bar + its label ride together; the label stays OUTSIDE
// the bar (to the right), matching the resting look - no in-bar text
overlay.className =
// physical left-0 anchor: the clone is positioned by translate3d from
// raw clientX, which is physical - a logical start-0 anchor would pin
// it to the RIGHT edge in RTL and fling the clone off screen
"pointer-events-none fixed top-0 left-0 z-100 flex items-center gap-2 will-change-transform"
adoptRootTypography(overlay)
overlay.style.height = `${barHeight}px`
const barEl = document.createElement("div")
barEl.className = "shrink-0 rounded-sm shadow-lg"
barEl.style.width = `${barWidth}px`
barEl.style.height = "100%"
barEl.style.background = `color-mix(in oklab, ${color} 22%, var(--color-background))`
barEl.style.outline = `1px solid color-mix(in oklab, ${color} 55%, transparent)`
overlay.appendChild(barEl)
const label = document.createElement("span")
label.className = "text-foreground truncate font-medium whitespace-nowrap"
label.textContent = occurrence.event.title
overlay.appendChild(label)
document.body.appendChild(overlay)
positionOverlay(lastPointer)
}
const positionOverlay = (e: PointerEvent) => {
if (!overlay && customMoveOverlay && active && kind === "move") {
overlay = document.querySelector<HTMLDivElement>("[data-slot=gantt-drag-overlay][data-custom]")
if (overlay) overlay.style.visibility = "visible"
}
if (!overlay) return
// x follows the pointer freely (smooth); y stays on the bar's own row
overlay.style.transform = `translate3d(${snapToPixel(e.clientX - grabOffsetPx)}px, ${snapToPixel(barTop)}px, 0)`
}
// Resize status indicator: a smooth cursor-following edge line plus a live
// range + duration chip. The dashed ghost still shows the SNAPPED landing;
// this overlay is the continuous feedback between snap steps.
let resizeOverlay: HTMLDivElement | null = null
let resizeLine: HTMLDivElement | null = null
let resizeRange: HTMLSpanElement | null = null
let resizeDot: HTMLSpanElement | null = null
let resizeDuration: HTMLSpanElement | null = null
const positionResizeOverlay = (e: PointerEvent) => {
if (!resizeOverlay && customResizeOverlay && kind.startsWith("resize")) {
resizeOverlay = document.querySelector<HTMLDivElement>("[data-slot=gantt-resize-indicator][data-custom]")
if (resizeOverlay) resizeOverlay.style.visibility = "visible"
}
if (!resizeOverlay || !surface) return
// x follows the pointer freely (clamped to the track); y stays on the bar
const x = Math.min(Math.max(e.clientX, surface.rect.left), surface.rect.right)
resizeOverlay.style.transform = `translate3d(${snapToPixel(x)}px, ${snapToPixel(barTop)}px, 0)`
}
const createResizeOverlay = () => {
if (!kind.startsWith("resize") || !occurrence || resizeOverlay) return
const barEl = origin.closest<HTMLElement>("[data-slot=gantt-bar]")
const rect = (barEl ?? origin).getBoundingClientRect()
barTop = rect.top
barHeight = rect.height
// consumer-rendered indicator: rect capture above still runs (the engine
// positions the consumer's wrapper), only the default DOM is skipped
if (customResizeOverlay) return
const color = occurrence.event.color ?? "var(--color-primary)"
resizeOverlay = document.createElement("div")
resizeOverlay.setAttribute("data-slot", "gantt-resize-indicator")
resizeOverlay.className =
// physical left-0 anchor, same reason as the move clone above
"pointer-events-none fixed top-0 left-0 z-100 will-change-transform"
adoptRootTypography(resizeOverlay)
resizeOverlay.style.height = `${barHeight}px`
resizeLine = document.createElement("div")
resizeLine.className = "h-full w-0.5 -translate-x-1/2 rounded-full"
resizeLine.style.background = color
resizeOverlay.appendChild(resizeLine)
const chip = document.createElement("div")
chip.className =
// physical left-0: centered with a physical translate on a physical anchor
// no text size of its own: it inherits the root scale adopted above, so
// the chip tracks a consumer rescale instead of pinning itself to 12px
"bg-foreground text-background absolute bottom-full left-0 mb-1.5 flex -translate-x-1/2 items-center gap-1.5 rounded-md px-2 py-1 font-medium whitespace-nowrap"
resizeRange = document.createElement("span")
chip.appendChild(resizeRange)
resizeDot = document.createElement("span")
resizeDot.className = "bg-background/40 size-1 shrink-0 rounded-full"
resizeDot.setAttribute("aria-hidden", "true")
chip.appendChild(resizeDot)
resizeDuration = document.createElement("span")
chip.appendChild(resizeDuration)
// The arrow. Every other bubble in the gantt has one pointing at what it
// describes; this chip had none, so a resize looked like a different
// component from the hover hint. Physical left-1/2 to match the chip's own
// physical anchor, and out of flow so the chip's flex gap ignores it.
const chipArrow = document.createElement("span")
chipArrow.setAttribute("aria-hidden", "true")
chipArrow.className = "bg-foreground absolute -bottom-1 left-1/2 size-2.5 -translate-x-1/2 rotate-45 rounded-[2px]"
chip.appendChild(chipArrow)
resizeOverlay.appendChild(chip)
document.body.appendChild(resizeOverlay)
// seed the chip with the CURRENT range so it never flashes empty;
// zoned so the label names the same day the grid shows
resizeRange.textContent = settings.value.i18n.functions.formatEventTime(
toZoned(occurrence.start, settings.value.timeZone),
toZoned(occurrence.end, settings.value.timeZone),
occurrence.allDay ?? false,
settings.value.locale
)
const days = Math.round((occurrence.end.getTime() - occurrence.start.getTime()) / 86_400_000)
if (days >= 1) {
resizeDuration.textContent = settings.value.i18n.labels.durationDays(days)
} else {
resizeDot.style.display = "none"
resizeDuration.style.display = "none"
}
positionResizeOverlay(startEvent)
}
// resize activates immediately, so its indicator mounts with the gesture
if (active) createResizeOverlay()
const activationDistance = kind === "create" ? activation.createDistancePx : activation.moveDistancePx
// Each gesture keeps its own cursor: a resize must stay ew-resize for the
// whole drag (flipping to grabbing reads as a move), a move grabs.
const gestureCursor = kind.startsWith("resize") ? "ew-resize" : "grabbing"
const setBodyDragging = (on: boolean, invalid = false) => {
document.body.classList.toggle("gantt-dragging", on)
document.body.style.cursor = on ? (invalid ? "not-allowed" : gestureCursor) : ""
document.body.style.userSelect = on ? "none" : ""
if (!on) document.body.style.removeProperty("-webkit-user-select")
}
const activate = () => {
if (active) return
active = true
surface = collectSurface(viewRoot)
// touch resize activates here (long-press) instead of at gesture start,
// so its indicator mounts now; the guard inside makes this a no-op for
// every other path
createResizeOverlay()
if (kind === "move" && occurrence && surface) {
const pointerMin = surfaceMinutesAt(surface, startX)
// TRUE start, never clamped to the range: a bar that begins before the
// visible window (negative minutes) must keep its real grab offset, or
// the first snapped proposal teleports its start to the range edge
const occStartMin = (occurrence.start.getTime() - surface.rangeStart) / 60000
grabOffsetMin = pointerMin - occStartMin
const rect = origin.getBoundingClientRect()
barTop = rect.top
barWidth = rect.width
barHeight = rect.height
grabOffsetPx = startX - rect.left
createMoveOverlay()
}
setBodyDragging(true)
}
const computeProposal = (e: PointerEvent): { start: Date; end: Date; allDay: boolean; resourceId?: string } | null => {
if (!surface) return null
const tl = surface
const rangeMinutes = (tl.rangeEnd - tl.rangeStart) / 60000
const minutesAt = (x: number) => surfaceMinutesAt(tl, x)
// Day-grid scales snap to real zoned midnights, not 1440-minute
// multiples from the range start - those drift by an hour across DST
const snapMin = (minutes: number) => {
if (tl.snapMin < 24 * 60) return snapMinutes(minutes, tl.snapMin)
const ms = tl.rangeStart + minutes * 60000
const dayStart = zonedStartOfDay(new Date(ms), settings.value.timeZone)
const dayEnd = zonedStartOfDay(addDays(toZoned(new Date(ms), settings.value.timeZone), 1), settings.value.timeZone)
const snapped = ms - dayStart.getTime() < dayEnd.getTime() - ms ? dayStart : dayEnd
return (snapped.getTime() - tl.rangeStart) / 60000
}
const rowAt = (y: number) => {
let best = tl.rows[0]
for (const row of tl.rows) {
if (y >= row.rect.top && y < row.rect.bottom) return row
if (best && Math.abs(y - (row.rect.top + row.rect.height / 2)) < Math.abs(y - (best.rect.top + best.rect.height / 2))) {
best = row
}
}
return best
}
const at = (minutes: number) => new Date(tl.rangeStart + minutes * 60000)
if (kind === "create") {
const anchorMin = snapMin(minutesAt(startX))
const curMin = snapMin(minutesAt(e.clientX))
const lo = Math.min(anchorMin, curMin)
// a bare click still yields a usable slot: at least slotDuration long
const hi = Math.max(anchorMin, curMin, lo + Math.max(tl.snapMin, settings.value.slotDuration))
return { start: at(lo), end: at(hi), allDay: false, resourceId: rowAt(startY)?.resourceId }
}
if (!occurrence) return null
const midnightAligned = (d: Date) => zonedStartOfDay(d, settings.value.timeZone).getTime() === d.getTime()
if (kind === "move") {
// x-axis only: the bar slides along its OWN row, never across rows.
// The proposal preserves the pointer DELTA - no clamping to the visible
// range, or bars crossing the window edge would teleport to it.
const start = at(snapMin(minutesAt(e.clientX) - grabOffsetMin))
// Day-snapped scales preserve the CALENDAR span for day-aligned bars:
// a 3-day bar dragged across a DST change stays midnight-to-midnight
// (72h +/- 1h), never drifting to a 23:00 end. Sub-day events keep
// their exact ms duration.
let end: Date
if (tl.snapMin >= 24 * 60 && (occurrence.allDay || (midnightAligned(occurrence.start) && midnightAligned(occurrence.end)))) {
const daySpan = Math.max(
differenceInCalendarDays(toZoned(occurrence.end, settings.value.timeZone), toZoned(occurrence.start, settings.value.timeZone)),
1
)
end = zonedStartOfDay(addDays(toZoned(start, settings.value.timeZone), daySpan), settings.value.timeZone)
} else {
end = new Date(start.getTime() + (occurrence.end.getTime() - occurrence.start.getTime()))
}
const bounded = clampToNeighbours(start, end)
return { start: bounded.start, end: bounded.end, allDay: occurrence.allDay, resourceId: occurrence.event.resourceId }
}
const min = snapMin(minutesAt(e.clientX))
if (kind === "resize-start") {
const endMin = (occurrence.end.getTime() - tl.rangeStart) / 60000
// Minimum length = one snap unit; on day grids that unit is the LAST
// zoned midnight before the end (raw 1440-minute arithmetic lands off
// the midnight grid across DST changes).
const maxStartMin =
tl.snapMin >= 24 * 60
? (zonedStartOfDay(
midnightAligned(occurrence.end) ? addDays(toZoned(occurrence.end, settings.value.timeZone), -1) : occurrence.end,
settings.value.timeZone
).getTime() -
tl.rangeStart) /
60000
: endMin - tl.snapMin
const clamped = Math.min(Math.max(min, 0), maxStartMin)
const bounded = clampToNeighbours(at(clamped), occurrence.end)
return { start: bounded.start, end: bounded.end, allDay: occurrence.allDay, resourceId: occurrence.event.resourceId }
}
const startMin = (occurrence.start.getTime() - tl.rangeStart) / 60000
// Mirror of the resize-start bound: the FIRST zoned midnight after the
// start on day grids, plain snap arithmetic otherwise.
const minEndMin =
tl.snapMin >= 24 * 60
? (zonedStartOfDay(addDays(toZoned(occurrence.start, settings.value.timeZone), 1), settings.value.timeZone).getTime() - tl.rangeStart) / 60000
: startMin + tl.snapMin
const clamped = Math.max(Math.min(min, rangeMinutes), minEndMin)
const bounded = clampToNeighbours(occurrence.start, at(clamped))
return { start: bounded.start, end: bounded.end, allDay: occurrence.allDay, resourceId: occurrence.event.resourceId }
}
const applyProposal = (e: PointerEvent) => {
const proposal = computeProposal(e)
if (!proposal) return
const key = `${proposal.start.getTime()}-${proposal.end.getTime()}-${proposal.allDay}-${proposal.resourceId ?? ""}`
if (key === lastProposalKey) return
lastProposalKey = key
if (kind === "create") {
const draft = { ...proposal }
if (settings.value.canSelectSlot && !settings.value.canSelectSlot(draft)) return
internals.setSlotDraft(draft)
return
}
const update: GanttProposedUpdate<TData> = {
event: occurrence!.event,
occurrence: occurrence!,
...proposal,
source: kind === "move" ? "drag" : (kind as "resize-start" | "resize-end"),
}
// "reject" is the one veto the engine owns: it both styles the ghost AND
// blocks the commit below. canDropEvent stays advisory, as documented.
overlapRejected = overlapPolicy === "reject" && overlapsNeighbour(proposal.start, proposal.end)
const valid = !overlapRejected && (settings.value.canDropEvent ? settings.value.canDropEvent(update) : true)
// live status: the indicator chip always names the CURRENT proposed
// range; the edge line flips to destructive on an invalid drop
if (resizeRange && resizeDot && resizeDuration) {
resizeRange.textContent = settings.value.i18n.functions.formatEventTime(
toZoned(proposal.start, settings.value.timeZone),
toZoned(proposal.end, settings.value.timeZone),
proposal.allDay,
settings.value.locale
)
const days = Math.round((proposal.end.getTime() - proposal.start.getTime()) / 86_400_000)
const showDays = days >= 1
resizeDot.style.display = showDays ? "" : "none"
resizeDuration.style.display = showDays ? "" : "none"
if (showDays) {
resizeDuration.textContent = settings.value.i18n.labels.durationDays(days)
}
}
if (resizeLine) {
resizeLine.style.background = valid ? (occurrence!.event.color ?? "var(--color-primary)") : "var(--color-destructive)"
}
setBodyDragging(true, !valid)
internals.setDrag({
kind: kind === "move" ? "move" : (kind as "resize-start" | "resize-end"),
occurrence: occurrence!,
proposedStart: proposal.start,
proposedEnd: proposal.end,
proposedAllDay: proposal.allDay,
proposedResourceId: proposal.resourceId,
valid,
})
}
// ----- edge auto-scroll: pan the timeline while dragging near its edge -----
// The pointer is clamped to the visible track, so without this a bar can
// never travel past the window. Holding the pointer inside the edge zone
// scrolls the viewport (speed eased by proximity), refreshes the track rect
// (the axis moved under the pointer) and re-derives the proposal from the
// same pointer position. Programmatic scrolls never mark user intent, so
// this can never trigger infinite-range growth mid-gesture.
const AUTO_SCROLL_EDGE_PX = 24
const AUTO_SCROLL_MAX_SPEED = 14
let autoScrollRaf = 0
// ponytail: our timeline pane IS the scroll-area-viewport (see
// GanttTreeSplitPanes.vue's section-3 header) - one selector instead of
// upstream's nested "pane [data-slot=scroll-area-viewport]".
const timelineViewport = viewRoot?.querySelector<HTMLElement>("[data-slot=gantt-timeline-pane]")
const autoScrollTick = () => {
autoScrollRaf = 0
if (finished || !active || !surface || !timelineViewport) return
const paneRect = timelineViewport.getBoundingClientRect()
const x = lastPointer.clientX
let speed = 0
if (x < paneRect.left + AUTO_SCROLL_EDGE_PX) {
speed = -((paneRect.left + AUTO_SCROLL_EDGE_PX - x) / AUTO_SCROLL_EDGE_PX) * AUTO_SCROLL_MAX_SPEED
} else if (x > paneRect.right - AUTO_SCROLL_EDGE_PX) {
speed = ((x - (paneRect.right - AUTO_SCROLL_EDGE_PX)) / AUTO_SCROLL_EDGE_PX) * AUTO_SCROLL_MAX_SPEED
}
if (speed === 0) return
const before = timelineViewport.scrollLeft
timelineViewport.scrollLeft = before + speed
if (timelineViewport.scrollLeft === before) return // parked on the end
const axis = viewRoot?.querySelector<HTMLElement>("[data-gantt-axis]")
if (axis) surface.rect = axis.getBoundingClientRect()
applyProposal(lastPointer)
positionResizeOverlay(lastPointer)
scheduleAutoScroll()
}
const scheduleAutoScroll = () => {
if (!autoScrollRaf) autoScrollRaf = requestAnimationFrame(autoScrollTick)
}
// idempotent: pointerup, pointercancel, Escape, blur and the view-level
// teardown can race; whichever lands first wins and the rest no-op
let finished = false
const cleanup = () => {
if (finished) return
finished = true
activeGestureCancels.delete(cancel)
if (autoScrollRaf) cancelAnimationFrame(autoScrollRaf)
try {
origin.releasePointerCapture(pointerId)
} catch {
// capture already released (pointer gone or origin detached)
}
window.removeEventListener("pointermove", onPointerMove)
window.removeEventListener("pointerup", onPointerUp)
window.removeEventListener("pointercancel", onCancel)
window.removeEventListener("blur", onWindowBlur)
window.removeEventListener("keydown", onKeyDown, true)
if (touchTimer) clearTimeout(touchTimer)
// consumer-rendered overlays are owned by the view state; they unmount
// when the drag state clears, so the engine must never removeChild them
if (!customMoveOverlay) overlay?.remove()
overlay = null
if (!customResizeOverlay) resizeOverlay?.remove()
resizeOverlay = null
setBodyDragging(false)
}
const cancel = () => {
cleanup()
if (active) {
lastGestureEndedAt = performance.now()
internals.setDrag(null)
internals.setSlotDraft(null)
}
}
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.stopPropagation()
cancel()
}
}
// focus loss mid-gesture (alt-tab, OS dialogs) means the release may never
// be delivered; treat it as a cancel so the gesture cannot get stuck
const onWindowBlur = () => cancel()
const onPointerMove = (e: PointerEvent) => {
if (e.pointerId !== pointerId) return
lastPointer = e
if (!active) {
const distance = Math.hypot(e.clientX - startX, e.clientY - startY)
if (isTouch) {
// Long-press pending: moving past tolerance means scroll, not drag
if (distance > activation.touchTolerancePx) cancel()
return
}
if (distance < activationDistance) return
activate()
}
applyProposal(e)
positionOverlay(e)
positionResizeOverlay(e)
scheduleAutoScroll()
}
const onPointerUp = (e: PointerEvent) => {
if (e.pointerId !== pointerId) return
cleanup()
if (!active) return
lastGestureEndedAt = performance.now()
const state = instance.state.value
if (kind === "create") {
const draft = state.slotDraft
internals.setSlotDraft(null)
if (draft) {
api.select({ slot: { start: draft.start, end: draft.end, allDay: draft.allDay } })
settings.value.onSelectSlot?.(draft)
}
return
}
const drag = state.drag
internals.setDrag(null)
if (!drag || !occurrence) return
// the node refuses concurrency: revert instead of committing an overlap
if (overlapRejected) return
const unchanged =
drag.proposedStart.getTime() === occurrence.start.getTime() &&
drag.proposedEnd.getTime() === occurrence.end.getTime() &&
(drag.proposedResourceId === undefined || drag.proposedResourceId === occurrence.event.resourceId)
if (unchanged) return
// Commit through the one validation funnel; consumer reject = automatic
// revert because the gantt never mutated during the gesture.
const accepted = internals.applyProposedUpdate({
event: occurrence.event,
occurrence,
start: drag.proposedStart,
end: drag.proposedEnd,
allDay: drag.proposedAllDay,
resourceId: drag.proposedResourceId,
source: kind === "move" ? "drag" : (kind as "resize-start" | "resize-end"),
})
if (accepted && announcer) {
announcer.textContent = `${occurrence.event.title}, ${settings.value.i18n.functions.formatEventTime(
toZoned(drag.proposedStart, settings.value.timeZone),
toZoned(drag.proposedEnd, settings.value.timeZone),
drag.proposedAllDay,
settings.value.locale
)}`
}
}
const onCancel = (e: PointerEvent) => {
if (e.pointerId !== pointerId) return
cancel()
}
window.addEventListener("pointermove", onPointerMove)
window.addEventListener("pointerup", onPointerUp)
window.addEventListener("pointercancel", onCancel)
window.addEventListener("blur", onWindowBlur)
window.addEventListener("keydown", onKeyDown, true)
activeGestureCancels.add(cancel)
// Capture the pointer so a release OUTSIDE the OS window still delivers
// pointerup here instead of leaving the gesture stuck. Captured events keep
// bubbling to the window listeners above, and if the origin node is removed
// mid-gesture the capture auto-releases - behavior then degrades to plain
// window listeners, never worse than before. Guarded: the pointer can
// already be gone by now (fast flicks, synthetic events).
try {
origin.setPointerCapture(pointerId)
} catch {
// capture is an enhancement, never a requirement
}
// Touch: long-press activation (movement past tolerance cancels above)
if (isTouch && !active) {
touchTimer = setTimeout(() => {
activate()
applyProposal(lastPointer)
}, activation.touchDelayMs)
}
}
interface GanttGestures<TData = unknown> {
canDrag: (segment: GanttSegment<TData>) => boolean
canResize: (segment: GanttSegment<TData>) => boolean
beginMove: (e: PointerEvent, segment: GanttSegment<TData>) => void
beginResize: (e: PointerEvent, segment: GanttSegment<TData>, edge: "start" | "end") => void
beginCreate: (e: PointerEvent) => void
}
/** Per-bar / per-row pointer gesture wiring. */
function useGanttGestures<TData = unknown>(): GanttGestures<TData> {
const instance = useGantt<TData>()
const viewConfig = useGanttViewConfig<TData>()
// presence flags only: the engine skips its default overlay DOM and
// positions the consumer-rendered node instead
const customMoveOverlay = () => !!viewConfig.value.renderDragPreview
const customResizeOverlay = () => !!viewConfig.value.renderResizeIndicator
const canDrag = (segment: GanttSegment<TData>): boolean => {
const { interactions } = instance.state.value
const event = segment.occurrence.event
return interactions.drag && !event.readOnly && event.draggable !== false
}
const beginMove = (e: PointerEvent, segment: GanttSegment<TData>) => {
if (e.button !== 0 || !canDrag(segment)) return
beginGesture({
instance,
kind: "move",
origin: e.currentTarget as HTMLElement,
startEvent: e,
segment,
customMoveOverlay: customMoveOverlay(),
scheduleMode: viewConfig.value.scheduleMode,
})
}
const canResize = (segment: GanttSegment<TData>): boolean => {
const { interactions } = instance.state.value
const event = segment.occurrence.event
return interactions.resize && !event.readOnly && event.resizable !== false
}
const beginResize = (e: PointerEvent, segment: GanttSegment<TData>, edge: "start" | "end") => {
if (e.button !== 0 || !canResize(segment)) return
e.stopPropagation()
e.preventDefault()
beginGesture({
instance,
kind: edge === "start" ? "resize-start" : "resize-end",
origin: e.currentTarget as HTMLElement,
startEvent: e,
segment,
customResizeOverlay: customResizeOverlay(),
scheduleMode: viewConfig.value.scheduleMode,
})
}
const beginCreate = (e: PointerEvent) => {
if (e.button !== 0) return
if (!instance.state.value.interactions.selectSlot) return
beginGesture({
instance,
kind: "create",
origin: e.currentTarget as HTMLElement,
startEvent: e,
})
}
return { canDrag, canResize, beginMove, beginResize, beginCreate }
}
export { cancelActiveGanttGestures, GANTT_ACTIVATION, markGestureEnd, useGanttGestures, useGanttGestureTeardown, wasRecentDrag }
export type { GanttGestures }
src/reui/gantt/i18n.ts
// Title: Gantt I18n
// Description: Default UI texts, date-format strings, and formatter functions for the gantt, fully overridable per key.
import type { GanttDateRange, GanttScale } from "./types"
import {
format,
isSameMonth,
isSameYear,
subMilliseconds,
type Locale,
} from "date-fns"
interface GanttI18nConfig {
labels: {
today: string
previous: string
next: string
addEvent: string
/** "Add task" hint at the foot of the tree. */
addTask: string
allDay: string
loading: string
event: string
events: (count: number) => string
week: (weekNumber: number) => string
resources: string
goToDate: string
/** Hover hint over empty row space, click-only create. */
scheduleHint: string
/** Same hint where dragCreate is on and a drag paints a range. */
scheduleHintDrag: string
reorder: string
/** Scale switcher label ("Timeline scale"). */
selectView: string
zoomIn: string
zoomOut: string
/** Aria-label of the tree/timeline splitter. */
resizePanel: string
/** Aria-label of the off-screen bar chips. */
jumpToBar: (title: string) => string
/** Read to screen readers as part of the bar label. */
progress: (percent: number) => string
/** Live duration readout on the resize indicator. */
durationDays: (days: number) => string
/** Appended to the bar aria-label when its segment is clipped by the range. */
continues: string
scales: {
day: string
week: string
month: string
quarter: string
year: string
}
}
/** date-fns format strings, applied with the gantt `locale`. */
formats: {
monthTitle: string
dayTitle: string
timeGutter: string
eventTime: string
}
functions: {
formatTitle: (
scale: GanttScale,
ctx: {
date: Date
activeRange: GanttDateRange
visibleRange: GanttDateRange
locale?: Locale
}
) => string
formatEventTime: (
start: Date,
end: Date,
allDay: boolean,
locale?: Locale
) => string
formatDayRange: (range: GanttDateRange, locale?: Locale) => string
/** Composes the bar's screen-reader label from its localized parts. */
formatEventAriaLabel: (parts: {
title: string
timeLabel: string
rowTitle?: string
progressLabel?: string
continues: boolean
}) => string
}
}
const DEFAULT_LABELS: GanttI18nConfig["labels"] = {
today: "Today",
previous: "Previous",
next: "Next",
addEvent: "Add event",
addTask: "Add task",
allDay: "All day",
loading: "Loading events",
event: "event",
events: (count) => (count === 1 ? "1 event" : `${count} events`),
week: (weekNumber) => `W${weekNumber}`,
resources: "Resources",
goToDate: "Go to date",
scheduleHint: "Click to add a schedule",
scheduleHintDrag: "Click or drag to add a schedule",
reorder: "Reorder",
selectView: "Select view",
zoomIn: "Zoom in",
zoomOut: "Zoom out",
resizePanel: "Resize panel",
jumpToBar: (title) => `Scroll to "${title}"`,
progress: (percent) => `${percent}% complete`,
durationDays: (days) => (days === 1 ? "1 day" : `${days} days`),
continues: "continues",
scales: {
day: "Day",
week: "Week",
month: "Month",
quarter: "Quarter",
year: "Year",
},
}
const DEFAULT_FORMATS: GanttI18nConfig["formats"] = {
monthTitle: "MMMM yyyy",
dayTitle: "EEEE, MMMM d, yyyy",
timeGutter: "h a",
eventTime: "h:mm a",
}
/**
* Default formatting functions BOUND to a config's labels/formats, so that
* `formats` overrides flow into the default renderers (a consumer overriding
* formats.eventTime without replacing formatEventTime still sees it applied).
*/
function makeDefaultGanttFunctions(
cfg: Pick<GanttI18nConfig, "labels" | "formats">
): GanttI18nConfig["functions"] {
return {
formatTitle: (scale, { date, activeRange, locale }) => {
const opts = { locale }
if (scale === "day") {
return format(date, cfg.formats.dayTitle, opts)
}
if (scale === "month") {
return format(date, cfg.formats.monthTitle, opts)
}
if (scale === "quarter") {
return format(date, "QQQ yyyy", opts)
}
if (scale === "year") {
return format(date, "yyyy", opts)
}
// week: smart range label, last day is activeRange.end - 1ms.
// subMilliseconds keeps the zoned date type (a plain new Date(ms)
// would flip the label to the machine zone near midnight)
const rangeEnd = subMilliseconds(activeRange.end, 1)
const start = activeRange.start
if (isSameMonth(start, rangeEnd)) {
return `${format(start, "MMMM d", opts)} - ${format(rangeEnd, "d, yyyy", opts)}`
}
if (isSameYear(start, rangeEnd)) {
return `${format(start, "MMM d", opts)} - ${format(rangeEnd, "MMM d, yyyy", opts)}`
}
return `${format(start, "MMM d, yyyy", opts)} - ${format(rangeEnd, "MMM d, yyyy", opts)}`
},
formatEventTime: (start, end, allDay, locale) => {
const opts = { locale }
if (allDay) {
// a gantt bar is a DATE RANGE: show it, never a bare "All day".
// Ends are exclusive midnights, so the last shown day is end - 1ms;
// subMilliseconds keeps the caller's zoned date type intact.
const last =
end.getTime() - 1 >= start.getTime() ? subMilliseconds(end, 1) : start
const sameDay =
format(start, "yyyy-MM-dd") === format(last, "yyyy-MM-dd")
if (sameDay) return format(start, "MMM d, yyyy", opts)
if (isSameYear(start, last)) {
return `${format(start, "MMM d", opts)} - ${format(last, "MMM d, yyyy", opts)}`
}
return `${format(start, "MMM d, yyyy", opts)} - ${format(last, "MMM d, yyyy", opts)}`
}
const fmt = cfg.formats.eventTime
// Multi-day timed events carry the date on both sides. Compare calendar
// days off the last rendered instant (end is exclusive, so a 14:00 to
// midnight bar still ends on the start day). Elapsed ms would miss an
// exactly-24h bar and a DST day that only runs 23 hours.
const lastInstant =
end.getTime() - 1 >= start.getTime() ? subMilliseconds(end, 1) : start
if (format(start, "yyyy-MM-dd") !== format(lastInstant, "yyyy-MM-dd")) {
return `${format(start, `MMM d, ${fmt}`, opts)} - ${format(end, `MMM d, ${fmt}`, opts)}`
}
return `${format(start, fmt, opts)} - ${format(end, fmt, opts)}`
},
formatDayRange: (range, locale) => {
const opts = { locale }
const rangeEnd = subMilliseconds(range.end, 1)
return `${format(range.start, "MMM d", opts)} - ${format(rangeEnd, "MMM d", opts)}`
},
formatEventAriaLabel: ({
title,
timeLabel,
rowTitle,
progressLabel,
continues,
}) =>
[
title,
timeLabel,
rowTitle,
progressLabel,
continues ? cfg.labels.continues : undefined,
]
.filter(Boolean)
.join(", "),
}
}
const DEFAULT_GANTT_I18N: GanttI18nConfig = {
labels: DEFAULT_LABELS,
formats: DEFAULT_FORMATS,
functions: makeDefaultGanttFunctions({
labels: DEFAULT_LABELS,
formats: DEFAULT_FORMATS,
}),
}
/** Deep-partial override shape: replace individual keys, never sections. */
interface GanttI18nOverrides {
labels?: Partial<Omit<GanttI18nConfig["labels"], "scales">> & {
scales?: Partial<GanttI18nConfig["labels"]["scales"]>
}
formats?: Partial<GanttI18nConfig["formats"]>
functions?: Partial<GanttI18nConfig["functions"]>
}
/**
* Shallow merge per nested object, matching the filters.tsx i18n contract:
* a partial override replaces individual keys, never whole sections. Default
* functions are re-bound to the MERGED labels/formats so a `formats` (or
* `labels.continues`) override reaches the default renderers; explicit
* `functions` overrides still win.
*/
function mergeGanttI18n(overrides?: GanttI18nOverrides): GanttI18nConfig {
if (!overrides) return DEFAULT_GANTT_I18N
const labels = {
...DEFAULT_LABELS,
...overrides.labels,
// nested section: replace individual scale names, never the whole set
scales: {
...DEFAULT_LABELS.scales,
...overrides.labels?.scales,
},
}
const formats = { ...DEFAULT_FORMATS, ...overrides.formats }
return {
labels,
formats,
functions: {
...makeDefaultGanttFunctions({ labels, formats }),
...overrides.functions,
},
}
}
export { DEFAULT_GANTT_I18N, mergeGanttI18n }
export type { GanttI18nConfig, GanttI18nOverrides }
src/reui/gantt/index.ts
// Примечание: lib.ts и recurrence.ts не реэкспортируются целиком — это
// внутренняя календарная математика с непрефиксованными именами
// (buildEventIndex, MAX_OCCURRENCES, parseRRuleString и т.п.), которые
// коллизируют с одноимённым внутренним слоем reui/event-calendar при общем
// барреле `export *` в packages/ui/src/index.ts. Компоненты gantt импортируют
// их напрямую по относительному пути ("./lib", "./recurrence").
export * from "./types"
export * from "./i18n"
export { default as Gantt } from "./Gantt.vue"
export { default as GanttBar } from "./GanttBar.vue"
export { default as GanttNowLine } from "./GanttNowLine.vue"
export { default as GanttNowDot } from "./GanttNowDot.vue"
export { default as GanttCustomDragLayer } from "./GanttCustomDragLayer.vue"
export { default as GanttOffscreenChips } from "./GanttOffscreenChips.vue"
export { default as GanttTreeSplitPanes } from "./GanttTreeSplitPanes.vue"
export { default as GanttTimelineHeader } from "./GanttTimelineHeader.vue"
export { default as GanttZoomControl } from "./GanttZoomControl.vue"
export { default as GanttTreeRow } from "./GanttTreeRow.vue"
export { default as GanttTimelineRow } from "./GanttTimelineRow.vue"
export { default as GanttView } from "./GanttView.vue"
export { default as GanttNav } from "./GanttNav.vue"
export { default as GanttNavToday } from "./GanttNavToday.vue"
export { default as GanttNavPrev } from "./GanttNavPrev.vue"
export { default as GanttNavNext } from "./GanttNavNext.vue"
export { default as GanttTitle } from "./GanttTitle.vue"
export { default as GanttScaleSwitcher } from "./GanttScaleSwitcher.vue"
export { default as GanttDatePicker } from "./GanttDatePicker.vue"
export { default as GanttToolbar } from "./GanttToolbar.vue"
export * from "./nav"
export * from "./timeline-units"
export * from "./zoom"
export * from "./context"
export * from "./gestures"
export * from "./use-now"
src/reui/gantt/lib.ts
// Title: Gantt Lib
// Description: Pure, framework-free calendar math: view ranges, zoned day keys, multi-day segmentation, overlap packing, lane packing, and the event index.
import { expandRecurrence } from "./recurrence"
import type {
GanttDateRange,
GanttEvent,
GanttOccurrence,
GanttOffDaysConfig,
GanttResource,
GanttScale,
GanttSegment,
} from "./types"
import { TZDate } from "@date-fns/tz"
import {
addDays,
addMonths,
addWeeks,
addYears,
differenceInMinutes,
format,
startOfDay,
startOfMonth,
startOfQuarter,
startOfWeek,
startOfYear,
} from "date-fns"
type WeekStartsOn = 0 | 1 | 2 | 3 | 4 | 5 | 6
/**
* Packing-effective minimum in minutes so tiny events do not stack invisibly.
* It is a packing FOOTPRINT, not a render size: two schedules less than this
* apart are treated as concurrent and split into separate lanes even though
* their real ranges do not touch.
*/
const MIN_PACK_SLOT = 30
/** The instant re-expressed in the display time zone (TZDate extends Date). */
function toZoned(date: Date, timeZone: string): TZDate {
return new TZDate(date.getTime(), timeZone)
}
/** Zoned midnight of the day containing the instant. */
function zonedStartOfDay(date: Date, timeZone: string): TZDate {
return startOfDay(toZoned(date, timeZone))
}
/** Stable per-day key in the display time zone. */
function getDayKey(date: Date, timeZone: string): string {
return format(toZoned(date, timeZone), "yyyy-MM-dd")
}
/** Day length in minutes; 1380/1500 on DST transition days - never assume 1440. */
function getDayTotalMinutes(dayStart: Date, timeZone: string): number {
const next = zonedStartOfDay(
addDays(toZoned(dayStart, timeZone), 1),
timeZone
)
return differenceInMinutes(next, dayStart)
}
function snapMinutes(minutes: number, snap: number): number {
return Math.round(minutes / snap) * snap
}
interface ViewRangeOptions {
timeZone: string
weekStartsOn: WeekStartsOn
}
interface ViewDateRanges {
visibleRange: GanttDateRange
activeRange: GanttDateRange
}
/** Axis range for the anchor date at the given scale. */
function getGanttDateRange(
scale: GanttScale,
date: Date,
opts: ViewRangeOptions
): ViewDateRanges {
const { timeZone, weekStartsOn } = opts
const zoned = toZoned(date, timeZone)
if (scale === "week") {
const start = startOfWeek(zoned, { weekStartsOn })
const range = { start, end: addWeeks(start, 1) }
return { activeRange: range, visibleRange: range }
}
if (scale === "month") {
// exact month: no outside days on the horizontal axis
const start = startOfMonth(zoned)
const range = { start, end: startOfMonth(addMonths(zoned, 1)) }
return { activeRange: range, visibleRange: range }
}
if (scale === "quarter") {
// week-aligned so the axis partitions into uniform week units
const quarterStart = startOfQuarter(zoned)
const quarterEnd = startOfQuarter(addMonths(zoned, 3))
const start = startOfWeek(quarterStart, { weekStartsOn })
let end = startOfWeek(quarterEnd, { weekStartsOn })
if (end < quarterEnd) end = addWeeks(end, 1)
return {
activeRange: { start: quarterStart, end: quarterEnd },
visibleRange: { start, end },
}
}
if (scale === "year") {
const start = startOfYear(zoned)
const range = { start, end: startOfYear(addYears(zoned, 1)) }
return { activeRange: range, visibleRange: range }
}
const start = startOfDay(zoned)
const range = { start, end: addDays(start, 1) }
return { activeRange: range, visibleRange: range }
}
/** The anchor date stepped one period forward or backward for the scale. */
function stepGanttDate(
scale: GanttScale,
date: Date,
direction: 1 | -1,
opts: Pick<ViewRangeOptions, "timeZone">
): Date {
const zoned = toZoned(date, opts.timeZone)
if (scale === "week") return addWeeks(zoned, direction)
if (scale === "month") return addMonths(zoned, direction)
if (scale === "quarter") return addMonths(zoned, direction * 3)
if (scale === "year") return addYears(zoned, direction)
return addDays(zoned, direction)
}
function rangesIntersect(a: GanttDateRange, b: GanttDateRange): boolean {
return a.start < b.end && a.end > b.start
}
function eventsOverlap(
a: { start: Date; end: Date },
b: { start: Date; end: Date }
): boolean {
return a.start < b.end && a.end > b.start
}
function spansMultipleDays(occ: { start: Date; end: Date }): boolean {
// An event ending exactly at the next midnight is still single-day
// (exclusive end), so compare against a strictly-later instant.
return occ.end.getTime() - occ.start.getTime() > 24 * 60 * 60 * 1000
}
/**
* Identity of a schedule ACROSS time edits. `occurrence.key` embeds the start
* instant, so it changes the moment a schedule is moved or start-resized -
* useless as lane memory. This key survives the edit: the event id plus, for a
* recurring series, the occurrence's position in it.
*/
function getLaneKey(occurrence: {
eventId: string
recurrenceIndex?: number
}): string {
return `${occurrence.eventId}::${occurrence.recurrenceIndex ?? 0}`
}
/**
* What one schedule held on the previous layout pass. The TIMES are what make
* this more than a lane number: they are how the packer tells the schedule the
* user just edited apart from the ones that merely sat still.
*/
interface GanttLaneMemo {
lane: number
startMs: number
endMs: number
}
interface PackOptions {
/**
* Where each schedule sat on the previous pass, by getLaneKey.
*
* A schedule whose times are UNCHANGED keeps its lane if that lane is still
* free, so editing one schedule never re-indexes the ones around it. A
* schedule whose times CHANGED - the one the user just dragged or resized -
* deliberately forfeits its pin and re-seeks the lowest free lane. That is
* what makes the arrangement live rather than frozen: a schedule dragged
* onto its neighbours stacks DOWN into the first free lane, and one dragged
* clear of them comes back UP inline. Only the edited schedule moves.
*/
preferredLanes?: Map<string, GanttLaneMemo>
/** "single" collapses the row to one track; see GanttScheduleMode. */
mode?: "single" | "multiple"
}
/**
* Overlap packing for one row's timed segments.
* Mutates column/columnCount/columnSpan on the segments, in place.
* z resolution happens at render: event.zIndex verbatim, else 10 + column.
*/
function packTimedSegments<TData>(
segments: GanttSegment<TData>[],
options: PackOptions = {}
): void {
if (segments.length === 0) return
if (options.mode === "single") {
// one track: every schedule shares lane 0 and the row never grows
for (const seg of segments) {
seg.column = 0
seg.columnCount = 1
seg.columnSpan = 1
}
return
}
const preferredLanes = options.preferredLanes
type Working = {
seg: GanttSegment<TData>
startMin: number
effEnd: number
lane: number
/** The occupancy entry this item added, so a settle can take it back. */
interval?: { from: number; to: number }
}
const items: Working[] = segments
.map((seg) => {
const startMin = seg.startMin ?? 0
const endMin = seg.endMin ?? startMin
return {
seg,
startMin,
effEnd: Math.max(endMin, startMin + MIN_PACK_SLOT),
lane: -1,
}
})
.sort(
(a, b) =>
a.startMin - b.startMin ||
b.effEnd - b.startMin - (a.effEnd - a.startMin) ||
a.seg.occurrence.key.localeCompare(b.seg.occurrence.key)
)
// Sweep into connected clusters
const clusters: Working[][] = []
let current: Working[] = []
let clusterEnd = -Infinity
for (const item of items) {
if (item.startMin >= clusterEnd) {
current = []
clusters.push(current)
clusterEnd = -Infinity
}
current.push(item)
clusterEnd = Math.max(clusterEnd, item.effEnd)
}
for (const cluster of clusters) {
// Per-lane occupancy INTERVALS, not a single running end: pass 1 claims
// remembered lanes out of time order, so a lane can be free before an
// occupant and busy after it.
const laneIntervals: Array<Array<{ from: number; to: number }>> = []
const isFree = (lane: number, item: Working) =>
!(laneIntervals[lane] ?? []).some(
(iv) => iv.from < item.effEnd && iv.to > item.startMin
)
const claim = (lane: number, item: Working) => {
while (laneIntervals.length <= lane) laneIntervals.push([])
const interval = { from: item.startMin, to: item.effEnd }
laneIntervals[lane]!.push(interval)
item.lane = lane
item.interval = interval
}
const release = (item: Working) => {
const occupants = laneIntervals[item.lane] ?? []
const at = occupants.indexOf(item.interval!)
if (at >= 0) occupants.splice(at, 1)
}
// pass 1: schedules that did not move keep the lane they had. The one the
// user just edited is NOT pinned - its times differ from the memo, so it
// falls through to pass 2 and re-seeks a lane against its new span.
const pending: Working[] = []
for (const item of cluster) {
const memo = preferredLanes?.get(getLaneKey(item.seg.occurrence))
const untouched =
memo !== undefined &&
memo.startMs === item.seg.occurrence.start.getTime() &&
memo.endMs === item.seg.occurrence.end.getTime()
if (untouched && memo.lane >= 0 && isFree(memo.lane, item)) {
claim(memo.lane, item)
} else {
pending.push(item)
}
}
// pass 2: the rest take the lowest free lane - overlapping goes DOWN into
// the first lane with room, fitting comes back UP to lane 0
for (const item of pending) {
let lane = 0
while (!isFree(lane, item)) lane++
claim(lane, item)
}
// pass 3: nothing floats above an empty lane. A pin only survives while
// something above it still needs the space - once the schedule that was
// there moves away or is deleted, its neighbour settles down into the
// gap. Without this a row keeps a permanently blank top lane and never
// shrinks back. Settling in lane order, and only ever DOWNWARD into space
// that is genuinely free, means two schedules can never trade places -
// so an edit still moves at most the schedule it touched.
const byLane = [...cluster].sort(
(a, b) => a.lane - b.lane || a.startMin - b.startMin
)
for (const item of byLane) {
if (item.lane === 0) continue
let lane = 0
while (lane < item.lane && !isFree(lane, item)) lane++
if (lane < item.lane) {
release(item)
claim(lane, item)
}
}
}
// Lane memory can leave holes (the schedule that held lane 0 was deleted or
// moved away). Collapse the row's USED lanes onto 0..n-1: relative stacking
// order survives, so nothing reshuffles, but the row cannot creep taller
// than the lanes it actually needs.
const used = [...new Set(items.map((item) => item.lane))].sort(
(a, b) => a - b
)
const compacted = new Map(used.map((lane, index) => [lane, index]))
const columnCount = used.length
for (const item of items) {
item.lane = compacted.get(item.lane) ?? 0
item.seg.column = item.lane
item.seg.columnCount = columnCount
}
// Partial-overlap expansion: widen rightward into free lanes
for (const cluster of clusters) {
for (const item of cluster) {
let span = 1
while (item.lane + span < columnCount) {
const blocked = cluster.some(
(other) =>
other !== item &&
other.lane === item.lane + span &&
other.startMin < item.effEnd &&
other.effEnd > item.startMin
)
if (blocked) break
span++
}
item.seg.columnSpan = span
}
}
}
function defaultEventOrder(a: GanttOccurrence, b: GanttOccurrence): number {
return (
a.start.getTime() - b.start.getTime() ||
b.end.getTime() -
b.start.getTime() -
(a.end.getTime() - a.start.getTime()) ||
a.key.localeCompare(b.key)
)
}
interface BuildIndexOptions<TData = unknown> {
timeZone: string
/** Escape hatch for exotic recurrence: return the expanded occurrences. */
getOccurrences?: (
event: GanttEvent<TData>,
range: GanttDateRange,
ctx: { timeZone: string }
) => Array<{ start: Date; end: Date }> | null | undefined
eventOrder?: (a: GanttOccurrence<TData>, b: GanttOccurrence<TData>) => number
}
interface GanttIndex<TData = unknown> {
occurrences: GanttOccurrence<TData>[]
}
function buildEventIndex<TData>(
events: GanttEvent<TData>[],
visibleRange: GanttDateRange,
opts: BuildIndexOptions<TData>
): GanttIndex<TData> {
const { timeZone } = opts
const order = opts.eventOrder ?? defaultEventOrder
// RECURRENCE-ID override replacement: an event carrying recurringEventId +
// originalStart is an edited single occurrence of that series. The parent's
// expansion drops the replaced instant; the override renders as its own
// occurrence through the normal path below.
const overrideTimes = new Map<string, Set<number>>()
for (const event of events) {
if (!event.recurringEventId || !event.originalStart) continue
let times = overrideTimes.get(event.recurringEventId)
if (!times) overrideTimes.set(event.recurringEventId, (times = new Set()))
times.add(event.originalStart.getTime())
}
const occurrences: GanttOccurrence<TData>[] = []
for (const event of events) {
const replaced = overrideTimes.get(event.id)
const custom = opts.getOccurrences?.(event, visibleRange, { timeZone })
if (custom) {
custom.forEach((occ, i) => {
if (replaced?.has(occ.start.getTime())) return
if (!rangesIntersect({ start: occ.start, end: occ.end }, visibleRange))
return
occurrences.push({
key: `${event.id}::${occ.start.toISOString()}`,
eventId: event.id,
event,
start: occ.start,
end: occ.end,
allDay: event.allDay ?? false,
isRecurring: true,
recurrenceIndex: i,
})
})
continue
}
const expanded = expandRecurrence(event, visibleRange, { timeZone })
occurrences.push(
...(replaced
? expanded.filter((occ) => !replaced.has(occ.start.getTime()))
: expanded)
)
}
occurrences.sort(order)
return { occurrences }
}
/** Cache key for index memoization; cheap string compare. */
function getRangeKey(range: GanttDateRange): string {
return `${range.start.getTime()}-${range.end.getTime()}`
}
/** Depth-first flatten of the resource tree (parents included). */
function flattenResources(
resources: GanttResource[],
depth = 0
): Array<{ resource: GanttResource; depth: number }> {
const rows: Array<{ resource: GanttResource; depth: number }> = []
for (const resource of resources) {
rows.push({ resource, depth })
if (resource.children?.length) {
rows.push(...flattenResources(resource.children, depth + 1))
}
}
return rows
}
/** Depth-first lookup of one node in the tree. */
function findResource(
resources: GanttResource[],
id: string
): GanttResource | null {
for (const resource of resources) {
if (resource.id === id) return resource
const found = resource.children?.length
? findResource(resource.children, id)
: null
if (found) return found
}
return null
}
/**
* Pure tree move: removes `resourceId` from wherever it sits and reinserts it
* under `parentId` (null = root) at `index`. Returns a new tree; the original
* is untouched. Returns null for impossible moves (unknown ids, or dropping a
* node into its own subtree).
*/
function reorderResources(
resources: GanttResource[],
resourceId: string,
parentId: string | null,
index: number
): GanttResource[] | null {
let moved: GanttResource | null = null
const strip = (nodes: GanttResource[]): GanttResource[] =>
nodes.flatMap((node) => {
if (node.id === resourceId) {
moved = node
return []
}
if (!node.children?.length) return [node]
return [{ ...node, children: strip(node.children) }]
})
const stripped = strip(resources)
if (!moved) return null
const contains = (node: GanttResource, id: string): boolean =>
node.id === id || !!node.children?.some((child) => contains(child, id))
if (parentId !== null && contains(moved, parentId)) return null
const insert = (nodes: GanttResource[]): GanttResource[] => {
if (parentId === null) {
const next = [...nodes]
next.splice(Math.min(Math.max(index, 0), next.length), 0, moved!)
return next
}
return nodes.map((node) => {
if (node.id === parentId) {
const children = [...(node.children ?? [])]
children.splice(
Math.min(Math.max(index, 0), children.length),
0,
moved!
)
return { ...node, children }
}
if (!node.children?.length) return node
return { ...node, children: insert(node.children) }
})
}
const next = insert(stripped)
// unknown parentId: the node vanished - reject
if (parentId !== null) {
const flat = flattenResources(next)
if (!flat.some(({ resource }) => resource.id === resourceId)) return null
}
return next
}
const DEFAULT_WEEKEND_DAYS = [0, 6]
/** Resolves whether a day is an off day (non-working) in the display zone. */
function resolveOffDay(
day: Date,
timeZone: string,
config: boolean | GanttOffDaysConfig | undefined
): boolean {
if (!config) return false
const resolved: GanttOffDaysConfig = config === true ? {} : config
const weekendDays = resolved.weekendDays ?? DEFAULT_WEEKEND_DAYS
const zoned = toZoned(day, timeZone)
if (weekendDays.includes(zoned.getDay())) return true
if (resolved.dates?.length) {
const key = getDayKey(day, timeZone)
if (resolved.dates.some((date) => getDayKey(date, timeZone) === key)) {
return true
}
}
return resolved.isOffDay?.(day) ?? false
}
export {
buildEventIndex,
defaultEventOrder,
eventsOverlap,
findResource,
flattenResources,
getDayKey,
getDayTotalMinutes,
getGanttDateRange,
getLaneKey,
getRangeKey,
MIN_PACK_SLOT,
packTimedSegments,
rangesIntersect,
reorderResources,
resolveOffDay,
snapMinutes,
spansMultipleDays,
stepGanttDate,
toZoned,
zonedStartOfDay,
}
export type {
BuildIndexOptions,
GanttIndex,
GanttLaneMemo,
PackOptions,
ViewDateRanges,
ViewRangeOptions,
WeekStartsOn,
}
src/reui/gantt/nav.ts
// Title: Gantt Nav shared bits
// Description: GANTT_SCALES + the nav button variant/size composable, shared by every gantt-nav.tsx subcomponent.
import { computed, type ComputedRef } from "vue"
import { useGanttViewConfig } from "./context"
import type { GanttScale } from "./types"
const GANTT_SCALES: GanttScale[] = ["day", "week", "month", "quarter", "year"]
interface GanttNavButtonProps {
variant: "ghost" | "outline" | "secondary" | "default"
size: "sm" | "default"
iconSize: "icon-sm" | "icon"
}
/** Configured nav button variant/size (viewConfig.navButtonVariant/Size). */
function useGanttNavButtonProps<TData = unknown>(): ComputedRef<GanttNavButtonProps> {
const viewConfig = useGanttViewConfig<TData>()
return computed(() => ({
variant: viewConfig.value.navButtonVariant,
size: viewConfig.value.navButtonSize,
iconSize: viewConfig.value.navButtonSize === "sm" ? "icon-sm" : "icon",
}))
}
export { GANTT_SCALES, useGanttNavButtonProps }
src/reui/gantt/recurrence.ts
// Title: Gantt Recurrence
// Description: RFC 5545 subset recurrence expansion for the event calendar - structured rules or raw RRULE strings, with a hard occurrence cap.
import type {
GanttDateRange,
GanttEvent,
GanttOccurrence,
GanttRecurrenceRule,
GanttWeekday,
} from "./types"
import { TZDate } from "@date-fns/tz"
import { addDays, addMonths, addWeeks, addYears } from "date-fns"
/** Guard: max occurrences per event per expansion. */
const MAX_OCCURRENCES = 1000
const WEEKDAYS: GanttWeekday[] = ["SU", "MO", "TU", "WE", "TH", "FR", "SA"]
class GanttRecurrenceError extends Error {
constructor(part: string) {
super(
`Unsupported recurrence part: ${part}. Use the getOccurrences prop to plug a full RRULE engine for exotic rules.`
)
this.name = "GanttRecurrenceError"
}
}
/**
* Parses a raw RRULE line (with or without the "RRULE:" prefix) into the
* structured subset. Pass the display time zone so a floating UNTIL
* (no trailing Z) resolves there instead of in the runtime's local zone.
*/
function parseRRuleString(
input: string,
timeZone?: string
): GanttRecurrenceRule {
const body = input.trim().replace(/^RRULE:/i, "")
const rule: Partial<GanttRecurrenceRule> = {}
for (const pair of body.split(";")) {
if (!pair) continue
const [rawKey, rawValue] = pair.split("=")
const key = rawKey?.toUpperCase()
const value = rawValue ?? ""
switch (key) {
case "FREQ": {
const freq = value.toLowerCase()
if (
freq !== "daily" &&
freq !== "weekly" &&
freq !== "monthly" &&
freq !== "yearly"
) {
throw new GanttRecurrenceError(`FREQ=${value}`)
}
rule.freq = freq
break
}
case "INTERVAL":
rule.interval = Math.max(1, parseInt(value, 10) || 1)
break
case "COUNT":
rule.count = Math.max(1, parseInt(value, 10) || 1)
break
case "UNTIL":
rule.until = parseRRuleDate(value, timeZone)
break
case "BYDAY":
rule.byWeekday = value.split(",").map((token) => {
const match = /^(-?\d+)?(SU|MO|TU|WE|TH|FR|SA)$/.exec(token.trim())
if (!match) throw new GanttRecurrenceError(`BYDAY=${token}`)
const day = match[2] as GanttWeekday
return match[1] ? { day, ordinal: parseInt(match[1], 10) } : day
})
break
case "BYMONTHDAY":
rule.byMonthDay = value.split(",").map((v) => parseInt(v, 10))
break
case "BYMONTH":
rule.byMonth = value.split(",").map((v) => parseInt(v, 10))
break
case "WKST": {
if (!WEEKDAYS.includes(value as GanttWeekday)) {
throw new GanttRecurrenceError(`WKST=${value}`)
}
rule.weekStart = value as GanttWeekday
break
}
default:
throw new GanttRecurrenceError(key ?? pair)
}
}
if (!rule.freq) throw new GanttRecurrenceError("missing FREQ")
return rule as GanttRecurrenceRule
}
function parseRRuleDate(value: string, timeZone?: string): Date {
// RFC 5545 basic formats: YYYYMMDD or YYYYMMDDTHHMMSS(Z)
const match = /^(\d{4})(\d{2})(\d{2})(?:T(\d{2})(\d{2})(\d{2})(Z)?)?$/.exec(
value
)
if (!match) throw new GanttRecurrenceError(`UNTIL=${value}`)
const [, y, m, d, hh = "23", mm = "59", ss = "59", z] = match
// Floating (non-Z) boundaries resolve in the DISPLAY zone when known -
// local-zone parsing would shift the series end per visitor machine.
const date =
!z && timeZone
? new TZDate(+y!, +m! - 1, +d!, +hh, +mm, +ss, timeZone)
: new Date(`${y}-${m}-${d}T${hh}:${mm}:${ss}${z ? "Z" : ""}`)
if (Number.isNaN(date.getTime())) {
throw new GanttRecurrenceError(`UNTIL=${value}`)
}
return new Date(date.getTime())
}
/** Serializes the structured subset back to an RRULE line (without prefix). */
function formatRRuleString(rule: GanttRecurrenceRule): string {
const parts: string[] = [`FREQ=${rule.freq.toUpperCase()}`]
if (rule.interval && rule.interval > 1)
parts.push(`INTERVAL=${rule.interval}`)
if (rule.count) parts.push(`COUNT=${rule.count}`)
if (rule.until) {
const u = rule.until
const pad = (n: number) => String(n).padStart(2, "0")
parts.push(
`UNTIL=${u.getUTCFullYear()}${pad(u.getUTCMonth() + 1)}${pad(u.getUTCDate())}T${pad(u.getUTCHours())}${pad(u.getUTCMinutes())}${pad(u.getUTCSeconds())}Z`
)
}
if (rule.byWeekday?.length) {
parts.push(
`BYDAY=${rule.byWeekday
.map((d) => (typeof d === "string" ? d : `${d.ordinal}${d.day}`))
.join(",")}`
)
}
if (rule.byMonthDay?.length)
parts.push(`BYMONTHDAY=${rule.byMonthDay.join(",")}`)
if (rule.byMonth?.length) parts.push(`BYMONTH=${rule.byMonth.join(",")}`)
if (rule.weekStart) parts.push(`WKST=${rule.weekStart}`)
return parts.join(";")
}
function resolveRule(
recurrence: GanttRecurrenceRule | string,
timeZone?: string
): GanttRecurrenceRule {
return typeof recurrence === "string"
? parseRRuleString(recurrence, timeZone)
: recurrence
}
/**
* Expands one event into its occurrences intersecting the range.
* Non-recurring events yield at most one occurrence. Recurrence iteration is
* wall-time based in the display zone (DST-safe day/week/month steps).
*
* Supported subset: FREQ daily/weekly/monthly/yearly, INTERVAL, COUNT, UNTIL,
* weekly BYDAY (no ordinals). Parsed-but-unimplemented filters (BYMONTHDAY,
* BYMONTH, BYDAY outside weekly) throw a GanttRecurrenceError instead of
* silently mis-expanding; plug the getOccurrences prop for a full engine.
* WKST parses and round-trips; week emission is Sunday-anchored.
*
* exDates remove exactly-matching instants (after COUNT numbering,
* Google-style: an exception still consumes its COUNT slot); rDates add extra
* instants with the same duration. RECURRENCE-ID override replacement lives
* in buildEventIndex, where the override event and its parent series meet.
*/
function expandRecurrence<TData>(
event: GanttEvent<TData>,
range: GanttDateRange,
ctx: { timeZone: string }
): GanttOccurrence<TData>[] {
const allDay = event.allDay ?? false
if (!event.recurrence) {
if (event.start < range.end && event.end > range.start) {
return [
{
key: `${event.id}::${event.start.toISOString()}`,
eventId: event.id,
event,
start: event.start,
end: event.end,
allDay,
isRecurring: false,
},
]
}
return []
}
const rule = resolveRule(event.recurrence, ctx.timeZone)
// Loud contract: silently ignoring a filter would emit WRONG occurrences.
if (rule.byMonthDay?.length) throw new GanttRecurrenceError("BYMONTHDAY")
if (rule.byMonth?.length) throw new GanttRecurrenceError("BYMONTH")
if (rule.byWeekday?.length && rule.freq !== "weekly") {
throw new GanttRecurrenceError("BYDAY outside FREQ=WEEKLY")
}
const interval = Math.max(1, rule.interval ?? 1)
const durationMs = event.end.getTime() - event.start.getTime()
const zonedStart = new TZDate(event.start.getTime(), ctx.timeZone)
// Excluded instants matched exactly; filtering happens at push time so an
// exception still consumes its COUNT slot (Google-style numbering).
const exTimes = new Set((rule.exDates ?? []).map((d) => d.getTime()))
const weeklyDays: number[] | null =
rule.freq === "weekly" && rule.byWeekday?.length
? rule.byWeekday.map((d) => {
if (typeof d !== "string") {
throw new GanttRecurrenceError(
"BYDAY ordinal outside monthly/yearly"
)
}
return WEEKDAYS.indexOf(d)
})
: null
const occurrences: GanttOccurrence<TData>[] = []
let produced = 0
let index = 0
let cursor = zonedStart
const advance = (from: TZDate, steps: number): TZDate =>
rule.freq === "daily"
? addDays(from, steps * interval)
: rule.freq === "weekly"
? addWeeks(from, steps * interval)
: rule.freq === "monthly"
? addMonths(from, steps * interval)
: addYears(from, steps * interval)
// Fast-forward past periods entirely before the range: they produce
// nothing and must not consume the occurrence cap (an old-enough daily
// series would otherwise exhaust MAX_OCCURRENCES before reaching the
// window and silently vanish). COUNT rules jump too: the skipped periods
// are credited to `index`, which is what terminates the series, so the
// count still ends it on exactly the right instant. Leaving them on full
// iteration would hide any series whose count exceeds MAX_OCCURRENCES.
//
// Daily and weekly ONLY. Their step is a fixed wall-time length, so one jump
// of N steps lands exactly where N single steps land. addMonths/addYears
// CLAMP instead: a Jan 31 monthly anchor steps to Feb 28 and never returns to
// the 31st, while a single jump from the anchor clamps at most once. Jumping
// those would make the same occurrence render on a different day depending on
// which window the viewer scrolled in from, so they always iterate.
const canFastForward = rule.freq === "daily" || rule.freq === "weekly"
// weekly BYDAY emits across the cursor's whole Sunday week
const weekSlackMs = weeklyDays ? 6 * 86_400_000 : 0
// divide by the LONGEST possible step so the jump can never overshoot
const maxStepMs =
(rule.freq === "daily"
? 24
: rule.freq === "weekly"
? 7 * 24
: rule.freq === "monthly"
? 31 * 24
: 366 * 24) *
3_600_000 *
interval +
3_600_000
for (let pass = 0; canFastForward && pass < 2; pass++) {
const gap =
range.start.getTime() - durationMs - weekSlackMs - cursor.getTime()
const skip = Math.floor(gap / maxStepMs)
if (skip <= 0) break
cursor = advance(cursor, skip)
index += skip * (weeklyDays ? weeklyDays.length : 1)
}
// close the remainder step by step (bounded by the jump math)
let guard = 0
while (
guard++ < 10_000 &&
!(rule.until && cursor.getTime() > rule.until.getTime()) &&
cursor.getTime() + durationMs + weekSlackMs < range.start.getTime()
) {
cursor = advance(cursor, 1)
index += weeklyDays ? weeklyDays.length : 1
}
// The jump credits a whole week of selected days per skipped week, but full
// iteration never counts the selected days that fall BEFORE the anchor
// inside the anchor's own week. Drop them once so both paths number the
// same instant identically (index counts occurrences at or after the
// anchor, and only those).
if (weeklyDays && index > 0) {
index -= weeklyDays.filter((day) => day < zonedStart.getDay()).length
}
const pushIfVisible = (rawStart: Date) => {
// normalize to a plain instant so consumers never receive zone-carrying
// TZDate instances (mixed-zone formatting bugs)
const start = new Date(rawStart.getTime())
if (exTimes.has(start.getTime())) return
const end = new Date(start.getTime() + durationMs)
if (start < range.end && end > range.start) {
occurrences.push({
key: `${event.id}::${start.toISOString()}`,
eventId: event.id,
event,
start,
end,
allDay,
isRecurring: true,
recurrenceIndex: index,
})
}
}
while (produced < MAX_OCCURRENCES) {
if (rule.until && cursor.getTime() > rule.until.getTime()) break
// COUNT is series-absolute, so it reads `index` (the position in the
// series, fast-forward included) rather than `produced` (emissions in
// this loop, which MAX_OCCURRENCES caps).
if (rule.count !== undefined && index >= rule.count) break
// Past the visible window with no count to honor - stop iterating. For
// weekly BYDAY the WEEK START decides: selected days earlier in the
// anchor's week can still fall before range.end.
const horizonMs = weeklyDays
? addDays(cursor, -cursor.getDay()).getTime()
: cursor.getTime()
if (horizonMs >= range.end.getTime() && rule.count === undefined) {
break
}
if (rule.freq === "weekly" && weeklyDays) {
// Emit each selected weekday within the cursor's week
for (let d = 0; d < 7; d++) {
const candidate = addDays(cursor, d - cursor.getDay())
if (!weeklyDays.includes(candidate.getDay())) continue
if (candidate.getTime() < zonedStart.getTime()) continue
if (rule.until && candidate.getTime() > rule.until.getTime()) continue
// the cap is checked here too, or a week that crosses it mid-loop
// still emits its remaining selected days
if (produced >= MAX_OCCURRENCES) break
if (rule.count !== undefined && index >= rule.count) break
pushIfVisible(candidate)
produced++
index++
}
} else {
pushIfVisible(cursor)
produced++
index++
}
cursor = advance(cursor, 1)
}
// RDATE: extra instants join the set (deduped against generated starts and
// exclusions) with the same wall-time duration. Sorted so direct consumers
// still receive chronological order (buildEventIndex re-sorts regardless).
if (rule.rDates?.length) {
const seen = new Set(occurrences.map((o) => o.start.getTime()))
for (const rDate of rule.rDates) {
const start = new Date(rDate.getTime())
if (seen.has(start.getTime()) || exTimes.has(start.getTime())) continue
const end = new Date(start.getTime() + durationMs)
if (start >= range.end || end <= range.start) continue
seen.add(start.getTime())
occurrences.push({
key: `${event.id}::${start.toISOString()}`,
eventId: event.id,
event,
start,
end,
allDay,
isRecurring: true,
// keep counting past the generated instants: an RDATE with no index
// would fall back to 0 and collide with the series' first occurrence
// in any consumer that identifies an instance by its position
recurrenceIndex: index++,
})
}
occurrences.sort((a, b) => a.start.getTime() - b.start.getTime())
}
return occurrences
}
export {
GanttRecurrenceError,
expandRecurrence,
formatRRuleString,
MAX_OCCURRENCES,
parseRRuleString,
}
src/reui/gantt/timeline-units.ts
// Title: Gantt Timeline Units
// Description: Port of the header/grid unit model from gantt-view.tsx (~lines 388-565): per-scale unit list (day/quarter/year/week/month) with weights, grouping sectors, and the default unit width.
import { computed, type ComputedRef } from "vue"
import { addDays, addMinutes, addMonths, format, getWeek, startOfMonth, startOfQuarter, startOfWeek } from "date-fns"
import { useGanttSettings, useGanttViewConfig } from "./context"
import { getDayKey, resolveOffDay, toZoned, zonedStartOfDay } from "./lib"
import { useTodayKey } from "./use-now"
import type { GanttDateRange, GanttScale } from "./types"
interface TimelineUnit {
key: string
label: string
ms: number
/** Relative width share; uniform scales use 1 (year: days per month). */
weight: number
isToday?: boolean
isOff?: boolean
}
interface TimelineGroup {
key: string
label: string
span: number
}
interface GanttTimelineUnits {
units: TimelineUnit[]
groups: TimelineGroup[]
unitWidthRem: number
}
/**
* Header model: bottom row = units, top row = grouping sectors. Weights are
* proportional to REAL duration (a 23h/25h DST day differs from its
* siblings), so weight-driven gridlines, ms-fraction bar geometry, and the
* dnd pointer math all share one coordinate system.
*/
function useGanttTimelineUnits(
scale: ComputedRef<GanttScale>,
range: ComputedRef<GanttDateRange>,
interval: ComputedRef<number>
): ComputedRef<GanttTimelineUnits> {
const settings = useGanttSettings()
const viewConfig = useGanttViewConfig()
const todayDayKey = useTodayKey(computed(() => settings.value.timeZone))
return computed<GanttTimelineUnits>(() => {
const timeZone = settings.value.timeZone
const rangeEndMs = range.value.end.getTime()
const metrics = viewConfig.value.metrics
const units: TimelineUnit[] = []
const groups: TimelineGroup[] = []
const todayStartMs = zonedStartOfDay(new Date(), timeZone).getTime()
if (scale.value === "day") {
const labelFormat = interval.value % 60 === 0 ? settings.value.i18n.formats.timeGutter : "h:mm"
// walk whole days: infinite scroll can extend the range past one day
let dayCursor = zonedStartOfDay(range.value.start, timeZone)
while (dayCursor.getTime() < rangeEndMs) {
const zonedDay = toZoned(dayCursor, timeZone)
const nextDay = zonedStartOfDay(addDays(zonedDay, 1), timeZone)
const dayMinutes = (nextDay.getTime() - dayCursor.getTime()) / 60000
const dayOff = resolveOffDay(dayCursor, timeZone, viewConfig.value.offDays ?? true)
let span = 0
for (let m = 0; m < dayMinutes; m += interval.value) {
const time = addMinutes(zonedDay, m)
// a DST day whose minutes don't divide evenly leaves a short final
// unit; its weight must be its REAL share or bars drift
const weight = Math.min(interval.value, dayMinutes - m) / interval.value
units.push({
key: `${getDayKey(dayCursor, timeZone)}-m${m}`,
label: format(time, labelFormat, { locale: settings.value.locale }),
ms: time.getTime(),
weight,
isOff: dayOff,
})
span += weight
}
groups.push({
key: getDayKey(dayCursor, timeZone),
label: format(zonedDay, settings.value.i18n.formats.dayTitle, { locale: settings.value.locale }),
span,
})
dayCursor = nextDay
}
return {
units,
groups,
unitWidthRem: metrics?.unitWidths?.day ?? Math.max(2.5, 5 * (interval.value / 60)),
}
}
if (scale.value === "quarter") {
// units are week-aligned weeks (lib aligns the range), groups are months
let cursor = zonedStartOfDay(range.value.start, timeZone)
while (cursor.getTime() < rangeEndMs) {
const zoned = toZoned(cursor, timeZone)
const next = zonedStartOfDay(addDays(zoned, 7), timeZone)
// real week duration / nominal week: 1 except across DST changes
const weight = (next.getTime() - cursor.getTime()) / (7 * 24 * 60 * 60000)
units.push({
key: getDayKey(cursor, timeZone),
label: format(zoned, "MMM d", { locale: settings.value.locale }),
ms: cursor.getTime(),
weight,
isToday: todayStartMs >= cursor.getTime() && todayStartMs < next.getTime(),
})
const monthKey = format(zoned, "yyyy-MM")
const lastGroup = groups[groups.length - 1]
if (lastGroup && lastGroup.key === monthKey) {
lastGroup.span += weight
} else {
groups.push({ key: monthKey, label: format(zoned, "MMMM", { locale: settings.value.locale }), span: weight })
}
cursor = next
}
return { units, groups, unitWidthRem: metrics?.unitWidths?.quarter ?? 8 }
}
if (scale.value === "year") {
// units are calendar months (weight = real duration), groups are quarters
let cursor: Date = startOfMonth(toZoned(range.value.start, timeZone))
while (cursor.getTime() < rangeEndMs) {
const next = startOfMonth(addMonths(cursor, 1))
// nominal-day units so a month reads ~30 wide; real ms keeps DST months true
const weight = (next.getTime() - cursor.getTime()) / (24 * 60 * 60000)
units.push({
key: format(cursor, "yyyy-MM"),
label: format(cursor, "MMM", { locale: settings.value.locale }),
ms: cursor.getTime(),
weight,
isToday: todayStartMs >= cursor.getTime() && todayStartMs < next.getTime(),
})
const quarterStart = startOfQuarter(cursor)
const quarterKey = format(quarterStart, "yyyy-QQQ")
const lastGroup = groups[groups.length - 1]
if (lastGroup && lastGroup.key === quarterKey) {
lastGroup.span += weight
} else {
groups.push({
key: quarterKey,
label: format(quarterStart, "QQQ yyyy", { locale: settings.value.locale }),
span: weight,
})
}
cursor = next
}
return { units, groups, unitWidthRem: metrics?.unitWidths?.year ?? 10 }
}
// week/month: units are days, groups are ISO-ish weeks
let cursor = zonedStartOfDay(range.value.start, timeZone)
while (cursor.getTime() < rangeEndMs) {
const zoned = toZoned(cursor, timeZone)
const nextDay = zonedStartOfDay(addDays(zoned, 1), timeZone)
// real day duration / 24h: 1 except the 23h/25h DST days
const weight = (nextDay.getTime() - cursor.getTime()) / (24 * 60 * 60000)
units.push({
key: getDayKey(cursor, timeZone),
label: format(zoned, "EEE d", { locale: settings.value.locale }),
ms: cursor.getTime(),
weight,
isToday: getDayKey(cursor, timeZone) === todayDayKey.value,
isOff: resolveOffDay(cursor, timeZone, viewConfig.value.offDays ?? true),
})
// locale supplies firstWeekContainsDate so W-numbers match the locale's
// week numbering (ISO in de/fr, US-style otherwise); the explicit
// weekStartsOn keeps the number aligned with the rendered grid
const weekNumber = getWeek(zoned, { locale: settings.value.locale, weekStartsOn: settings.value.weekStartsOn })
// key + label from the true week start: a range that begins midweek
// must not split or mislabel its first group (incl. the Jan 1 week)
const weekStart = startOfWeek(zoned, { weekStartsOn: settings.value.weekStartsOn })
const weekKey = `w-${format(weekStart, "yyyy-MM-dd")}`
const lastGroup = groups[groups.length - 1]
if (lastGroup && lastGroup.key === weekKey) {
lastGroup.span += weight
} else {
groups.push({
key: weekKey,
label: `${settings.value.i18n.labels.week(weekNumber)} ${format(weekStart, "MMM d", { locale: settings.value.locale })} - ${format(addDays(weekStart, 6), "d", { locale: settings.value.locale })}`,
span: weight,
})
}
cursor = nextDay
}
return {
units,
groups,
unitWidthRem: metrics?.unitWidths?.[scale.value] ?? (scale.value === "week" ? 10 : 4),
}
})
}
export { useGanttTimelineUnits }
export type { GanttTimelineUnits, TimelineGroup, TimelineUnit }
src/reui/gantt/types.ts
// Title: Gantt Types
// Description: Public TypeScript contract for the headless gantt: events, occurrences, segments, state, and callbacks.
type GanttBarId = string
/** Horizontal time scale of the gantt axis. */
type GanttScale = "day" | "week" | "month" | "quarter" | "year"
/** Proposal emitted when a timeline resource row is drag-reordered. */
interface GanttResourceReorder {
/** The dragged resource id. */
resourceId: string
/** New parent id, or null for the root level. */
parentId: string | null
/** Insertion index among the new parent's children. */
index: number
/** The full resource tree with the move applied (convenience). */
resources: GanttResource[]
}
/**
* How many schedules one tree node may hold at once.
* - "single": one track. The node never grows a second lane and a gesture that
* would create a concurrent schedule is refused.
* - "multiple": concurrent schedules stack into stable lanes and the row grows.
*/
type GanttScheduleMode = "single" | "multiple"
/**
* Drop policy for a gesture that would overlap another schedule in the SAME
* node. Policy only - overlapping data always renders.
* - "allow" (default): the gesture commits as proposed.
* - "clamp": the gesture stops at the neighbour's edge.
* - "reject": the gesture is marked invalid and never commits.
*/
type GanttOverlapPolicy = "allow" | "reject" | "clamp"
/** Vertical placement of a row's content when the node holds several lanes. */
type GanttRowAlign = "start" | "center"
/**
* One node of the gantt tree: a generic item that carries a title, consumer
* columns, and zero or more schedules. It is not domain-bound - the same node
* expresses a task (one schedule) or a resource lane (many). Nesting via
* children renders as collapsible groups.
*/
interface GanttResource {
id: string
title: string
/** Token or css color used for subtle row/column accents. */
color?: string
/** Per-node cardinality override; falls back to the view-level default. */
scheduleMode?: GanttScheduleMode
children?: GanttResource[]
}
/** Preferred name for a tree node; `GanttResource` is the legacy alias. */
type GanttNode = GanttResource
interface GanttDateRange {
/** Inclusive instant. */
start: Date
/** Exclusive instant. */
end: Date
}
type GanttWeekday = "MO" | "TU" | "WE" | "TH" | "FR" | "SA" | "SU"
interface GanttRecurrenceRule {
freq: "daily" | "weekly" | "monthly" | "yearly"
interval?: number
count?: number
/** Inclusive instant. */
until?: Date
byWeekday?: Array<GanttWeekday | { day: GanttWeekday; ordinal: number }>
byMonthDay?: number[]
byMonth?: number[]
weekStart?: GanttWeekday
exDates?: Date[]
rDates?: Date[]
}
interface GanttEvent<TData = unknown> {
id: GanttBarId
title: string
/** Plain instant; consumers parse ISO strings themselves. */
start: Date
/** Exclusive; must be >= start. */
end: Date
allDay?: boolean
/** Structured rule or a raw "RRULE:..." line. */
recurrence?: GanttRecurrenceRule | string
/** This event is an edited single occurrence of that series. */
recurringEventId?: GanttBarId
/** Which occurrence it replaces (RECURRENCE-ID semantics). */
originalStart?: Date
/** Token or css color; flows to the --gantt-event-color css var. */
color?: string
/** Excluded from drag and resize regardless of interactions state. */
readOnly?: boolean
/** Per-event override; default comes from interactions.drag. */
draggable?: boolean
/** Per-event override; default comes from interactions.resize. */
resizable?: boolean
/** Packing prominence; feeds getEventPriority ordering. */
priority?: number
/** Completion 0-100; renders as a subtle fill inside the bar. */
progress?: number
/** Explicit stacking override; wins over the computed z. */
zIndex?: number
/** Resource row this bar belongs to. */
resourceId?: string
/** Consumer payload, fully generic. */
data?: TData
}
interface GanttOccurrence<TData = unknown> {
/** Stable per instance: `${event.id}::${startISO}`. */
key: string
eventId: GanttBarId
event: GanttEvent<TData>
start: Date
end: Date
allDay: boolean
isRecurring: boolean
recurrenceIndex?: number
}
interface GanttSegment<TData = unknown> {
occurrence: GanttOccurrence<TData>
/** Range-start reference instant of the segment's timeline slice. */
day: Date
isStart: boolean
isEnd: boolean
continuesBefore: boolean
continuesAfter: boolean
/** Minutes from the visible range start, clamped to the range. */
startMin?: number
endMin?: number
/** Row lane packing: 0-based lane index within the node's row. */
column?: number
/** Lanes the node's row resolved to. */
columnCount?: number
columnSpan?: number
}
interface GanttSelection {
eventKeys: string[]
/** Committed slot selection; see GanttSlotDraft for the in-gesture value. */
slot: { start: Date; end: Date; allDay: boolean } | null
}
interface GanttInteractions {
/** Horizontal move within the bar's own row; never across rows. */
drag: boolean
resize: boolean
selectSlot: boolean
}
interface GanttDragState<TData = unknown> {
kind: "move" | "resize-start" | "resize-end"
occurrence: GanttOccurrence<TData>
proposedStart: Date
proposedEnd: Date
proposedAllDay: boolean
/** The bar's own resource; moves are x-axis only and never cross rows. */
proposedResourceId?: string
/** Last canDropEvent verdict; drives data-drop-invalid styling. */
valid: boolean
}
/**
* The in-progress drag-create rectangle ONLY, cleared on commit or cancel.
* The committed slot selection lives in GanttSelection.slot.
*/
interface GanttSlotDraft {
start: Date
end: Date
allDay: boolean
/** Present when the slot was selected inside a resource row. */
resourceId?: string
}
interface GanttState<TData = unknown> {
/** Horizontal axis scale. */
scale: GanttScale
/** Anchor date. */
date: Date
/** Full rendered axis range - fetch remote data for THIS. */
visibleRange: GanttDateRange
/** The logical period (the month/week itself). */
activeRange: GanttDateRange
events: GanttEvent<TData>[]
selection: GanttSelection
interactions: GanttInteractions
loading: boolean
drag: GanttDragState<TData> | null
slotDraft: GanttSlotDraft | null
/**
* Instant at the center of the scrolled viewport; the nav title follows it
* so the header always names what you are looking at. null before the view
* reports a position (falls back to the anchor date).
*/
viewportCenter: Date | null
}
interface GanttRangeInfo {
range: GanttDateRange
activeRange: GanttDateRange
scale: GanttScale
date: Date
timeZone: string
}
interface GanttProposedUpdate<TData = unknown> {
event: GanttEvent<TData>
/** null when source === "api". */
occurrence: GanttOccurrence<TData> | null
start: Date
end: Date
allDay: boolean
/** The bar's own resource (moves stay in-row); set on create/api. */
resourceId?: string
source: "drag" | "resize-start" | "resize-end" | "keyboard" | "api"
}
/** false = reject/revert; void or true = accept; object = accept with adjustment. */
type GanttUpdateResult =
| boolean
| void
| { start?: Date; end?: Date; allDay?: boolean }
/** A click is a point, not a range; `end` is reserved for future gestures. */
interface GanttSlotInfo {
date: Date
end?: Date
allDay: boolean
/** Present when the click happened inside a resource row. */
resourceId?: string
}
/**
* Off-day marking (non-working days). `true` uses the defaults: weekends
* with a muted background. Custom weekday sets, explicit dates, a predicate,
* and a custom class are all supported; marked cells carry `data-off` for
* CSS-selector customization.
*/
interface GanttOffDaysConfig {
/** Weekday numbers treated as off (0 = Sunday). Default [0, 6]. */
weekendDays?: number[]
/** Additional explicit off dates (compared by day in the display zone). */
dates?: Date[]
/** Full custom predicate; runs in addition to weekendDays/dates. */
isOffDay?: (day: Date) => boolean
/** Marker classes; default "bg-muted/40". */
className?: string
}
/**
* External-data contract. v1 ships the type plus docs recipes (Google
* events.list / MS Graph calendarView map to GanttEvent in ~15 lines);
* OAuth, tokens, and sync loops are application backend territory.
*/
interface GanttDataAdapter<TData = unknown> {
getEvents(
range: GanttDateRange,
signal?: AbortSignal
): Promise<GanttEvent<TData>[]>
}
export type {
GanttEvent,
GanttDataAdapter,
GanttDateRange,
GanttDragState,
GanttBarId,
GanttInteractions,
GanttNode,
GanttOccurrence,
GanttOffDaysConfig,
GanttOverlapPolicy,
GanttProposedUpdate,
GanttRangeInfo,
GanttRecurrenceRule,
GanttResource,
GanttRowAlign,
GanttScheduleMode,
GanttSegment,
GanttSelection,
GanttSlotDraft,
GanttSlotInfo,
GanttState,
GanttResourceReorder,
GanttScale,
GanttUpdateResult,
GanttWeekday,
}
src/reui/gantt/use-now.ts
/**
* Часть порта gantt-view.tsx (MIT) — `useNow`/`useTodayKey`, вынесены в
* отдельный файл, т.к. нужны нескольким компонентам/composable'ам
* (GanttNowLine, GanttNowDot, useGanttTimelineUnits).
*/
import { onBeforeUnmount, onMounted, ref, watch, type ComputedRef, type Ref } from "vue"
import { getDayKey } from "./lib"
/** Current time, refreshed on an interval and on tab focus. */
function useNow(intervalMs = 30_000): Ref<Date> {
const now = ref(new Date()) as Ref<Date>
let id: ReturnType<typeof setInterval> | undefined
const tick = () => {
now.value = new Date()
}
onMounted(() => {
id = setInterval(tick, intervalMs)
document.addEventListener("visibilitychange", tick)
window.addEventListener("focus", tick)
})
onBeforeUnmount(() => {
clearInterval(id)
document.removeEventListener("visibilitychange", tick)
window.removeEventListener("focus", tick)
})
return now
}
/**
* Today's zoned day key, re-rendering only at the midnight rollover (and on
* focus/visibility) - the grid needs day granularity, not the 30s now tick.
*/
function useTodayKey(timeZone: ComputedRef<string> | Ref<string>): Ref<string> {
const key = ref(getDayKey(new Date(), timeZone.value)) as Ref<string>
let id: ReturnType<typeof setInterval> | undefined
const tick = () => {
const next = getDayKey(new Date(), timeZone.value)
if (next !== key.value) key.value = next
}
onMounted(() => {
tick()
id = setInterval(tick, 60_000)
document.addEventListener("visibilitychange", tick)
window.addEventListener("focus", tick)
})
onBeforeUnmount(() => {
clearInterval(id)
document.removeEventListener("visibilitychange", tick)
window.removeEventListener("focus", tick)
})
watch(timeZone, tick)
return key
}
export { useNow, useTodayKey }
src/reui/gantt/view-lib.ts
// Title: Gantt View Lib
// Description: Pure, framework-free helpers from gantt-view.tsx (lane packing preview, pointer-to-track math, pane/viewport DOM helpers, row geometry constants). Not re-exported from index.ts for the same reason as lib.ts (see index.ts header).
import type { GanttResource, GanttSegment } from "./types"
import { MIN_PACK_SLOT } from "./lib"
/** One flattened tree row (a resource at its position in the expanded tree). */
interface TimelineRow {
resource: GanttResource
parentId: string | null
depth: number
isGroup: boolean
collapsed: boolean
}
/** Per-row packed bars plus the extents the off-screen chips need. */
interface TimelineRowBars {
segments: GanttSegment[]
laneCount: number
/** Lane a drag-create in flight would land on, or null when none is aimed at this row. */
draftLane: number | null
/** Mode this row was packed under; the draft overlay must honour it. */
scheduleMode: "single" | "multiple"
heightRem: number
/** Gap above the first bar; equal to every other gap in the row. */
laneOffsetRem: number
/** The band the FIRST schedule occupies, its gaps included. */
bandRem: number
/** Envelope of all bars, as track fractions; null when the row is empty. */
extent: {
from: number
to: number
color?: string
label: string
/** First bar start, for the jump-chip tooltip. */
startMs: number
} | null
/** Parent rollup: descendant-bar envelope + duration-weighted progress, present only on group rows without bars of their own. */
summary: { from: number; to: number; progress: number | null } | null
}
/** One unit's cumulative start/width, as fractions of the whole track. */
interface UnitFraction {
unit: { ms: number }
start: number
width: number
}
/** Cumulative start/width fractions per unit, for backdrop stripes/lines/hint snapping. */
function computeUnitFractions<TUnit extends { weight: number; ms: number }>(
units: TUnit[]
): Array<{ unit: TUnit; start: number; width: number }> {
const totalWeight = units.reduce((sum, unit) => sum + unit.weight, 0)
let acc = 0
return units.map((unit) => {
const start = acc / totalWeight
acc += unit.weight
return { unit, start, width: unit.weight / totalWeight }
})
}
/** Snap a track fraction to its unit (hint preview target). */
function createHintStopResolver(unitFractions: UnitFraction[]) {
return (fraction: number): { index: number; center: number; ms: number; endMs: number } | null => {
for (let i = 0; i < unitFractions.length; i++) {
const { unit, start, width } = unitFractions[i]!
if (fraction < start + width || i === unitFractions.length - 1) {
return {
index: i,
center: start + width / 2,
ms: unit.ms,
endMs: unitFractions[i + 1]?.unit.ms ?? unit.ms,
}
}
}
return null
}
}
/**
* Row geometry is three numbers: a bar is LANE_HEIGHT_REM tall, stacked bars
* are separated by LANE_GAP_REM, and the block as a whole is inset from the
* row's edges by ROW_PADDING_REM. Padding and gap are deliberately NOT the
* same value - schedules in one node belong together, so they sit tight, while
* the row still needs real breathing room above and below. Every inter-lane
* gap is identical, which is what keeps a stacked row reading evenly.
*/
const LANE_HEIGHT_REM = 1.25
const LANE_GAP_REM = 0.1875
const ROW_PADDING_REM = 0.5
/** Drop-indicator height (h-5); it is centered inside its lane band. */
const GHOST_HEIGHT_REM = 1.25
/** Bars narrower than this flip their title outside in barLabel "auto". */
const AUTO_LABEL_MIN_REM = 7
/**
* Lowest lane free for [startMs, endMs) among a row's segments, padded by
* MIN_PACK_SLOT exactly as packTimedSegments pads its own occupancy test.
* Comparing raw instants instead lets a sub-slot bar read as clear, so an
* affordance would promise a lane the packer then refuses.
*
* Shared by the hover hint and the drag placeholder on purpose: two copies of
* this is how the ring and the range it paints end up on different tracks.
*/
function lowestFreeLane(segments: GanttSegment[], startMs: number, endMs: number): number {
const padMs = MIN_PACK_SLOT * 60000
const to = Math.max(endMs, startMs + padMs)
const busy = new Set<number>()
for (const segment of segments) {
const segStart = segment.occurrence.start.getTime()
const segEnd = Math.max(segment.occurrence.end.getTime(), segStart + padMs)
if (segStart < to && segEnd > startMs) busy.add(segment.column ?? 0)
}
let lane = 0
while (busy.has(lane)) lane += 1
return lane
}
/**
* Pointer x resolved against the element's TIME axis: the 0..1 fraction and
* the same measurement in CSS pixels from the axis start. Mirrored in RTL,
* where the range start renders at the element's right edge. One rect read
* and one style read, because this runs on every pointer move.
*/
function trackPoint(el: HTMLElement, clientX: number): { fraction: number; offset: number } {
const rect = el.getBoundingClientRect()
const rtl = getComputedStyle(el).direction === "rtl"
const offset = rtl ? rect.right - clientX : clientX - rect.left
// `offset` is exact and drives the time maths. `snapped` is the same value
// biased so that rect start + snapped lands on a WHOLE viewport pixel: the
// row's own edge routinely sits on a half pixel, so rounding the offset
// alone still puts anything placed at it between two pixels.
const snapped = rtl ? rect.right - Math.round(clientX) : Math.round(clientX) - rect.left
return {
fraction: rect.width > 0 ? offset / rect.width : 0,
offset: snapped,
}
}
/** Fraction only, for the call sites that do not place anything. */
function trackFraction(el: HTMLElement, clientX: number): number {
return trackPoint(el, clientX).fraction
}
/** The pane's scrollable viewport (custom ScrollArea or native host). */
function getPaneViewport(pane: HTMLElement | null): HTMLElement | null {
return pane?.querySelector<HTMLElement>("[data-slot=scroll-area-viewport]") ?? null
}
/** Distance scrolled from the inline-start edge (RTL reports negative). */
function getScrollStart(viewport: HTMLElement): number {
return Math.abs(viewport.scrollLeft)
}
/** Write a distance-from-inline-start back as a signed scrollLeft. */
function setScrollStart(viewport: HTMLElement, value: number) {
viewport.scrollLeft = getComputedStyle(viewport).direction === "rtl" ? -value : value
}
export {
AUTO_LABEL_MIN_REM,
GHOST_HEIGHT_REM,
LANE_GAP_REM,
LANE_HEIGHT_REM,
ROW_PADDING_REM,
computeUnitFractions,
createHintStopResolver,
getPaneViewport,
getScrollStart,
lowestFreeLane,
setScrollStart,
trackFraction,
trackPoint,
}
export type { TimelineRow, TimelineRowBars, UnitFraction }
src/reui/gantt/zoom.ts
// Title: Gantt Zoom
// Description: Port of the zoom state slice from gantt-view.tsx (~lines 567-584, 2409-2449): controlled/uncontrolled zoom multiplier, clamped by zoomRange, plus the in/out step used by the floating zoom control.
import { computed, ref, type ComputedRef, type Ref } from "vue"
import { useGanttViewConfig } from "./context"
const DEFAULT_ZOOM_RANGE = { min: 0.5, max: 3, step: 0.25 }
interface GanttZoom {
zoom: ComputedRef<number>
canZoomIn: ComputedRef<boolean>
canZoomOut: ComputedRef<boolean>
zoomIn: () => void
zoomOut: () => void
/** Set an arbitrary (already-computed) target, clamped to zoomRange. Used by wheel/pinch zoom, which steps continuously rather than by `zoomRange.step`. */
setZoom: (next: number) => void
}
/**
* Re-anchoring the viewport across a zoom step (`anchorZoomCenter`/
* `anchorZoomPointer`/wheel-zoom, gantt-view.tsx ~1490-1622) lives in
* `GanttTreeSplitPanes.vue` now (it owns the real scroll axis element,
* `[data-gantt-axis]`, and the `pendingRestore`/`seat()` contract that
* consumes it) - see that file's header. This composable only owns the
* multiplier's value/clamp/step math, same as before.
*/
function useGanttZoom<TData = unknown>(): GanttZoom {
const viewConfig = useGanttViewConfig<TData>()
const zoomRange = computed(() => ({ ...DEFAULT_ZOOM_RANGE, ...viewConfig.value.zoomRange }))
const clampZoom = (value: number) => Math.min(Math.max(value, zoomRange.value.min), zoomRange.value.max)
const internalZoom = ref(viewConfig.value.defaultZoom ?? 1) as Ref<number>
const zoom = computed(() => clampZoom(viewConfig.value.zoom ?? internalZoom.value))
const setZoomValue = (next: number) => {
const clamped = clampZoom(next)
if (viewConfig.value.zoom === undefined) internalZoom.value = clamped
viewConfig.value.onZoomChange?.(clamped)
}
return {
zoom,
canZoomIn: computed(() => zoom.value < zoomRange.value.max - 1e-9),
canZoomOut: computed(() => zoom.value > zoomRange.value.min + 1e-9),
zoomIn: () => setZoomValue(+(zoom.value + (zoomRange.value.step ?? 0.25)).toFixed(2)),
zoomOut: () => setZoomValue(+(zoom.value - (zoomRange.value.step ?? 0.25)).toFixed(2)),
setZoom: setZoomValue,
}
}
export { useGanttZoom, DEFAULT_ZOOM_RANGE }
export type { GanttZoom }