@allbluecn/web-app 0.18.2 → 0.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +66 -51
- package/src/__tests__/components/auth/auth-layout.test.tsx +13 -1
- package/src/__tests__/{knowledge-public → knowledge/public}/canonical-id.test.ts +1 -1
- package/src/__tests__/{knowledge-public → knowledge/public}/favicon.test.ts +1 -1
- package/src/__tests__/{knowledge-public → knowledge/public}/github-sync.test.ts +1 -1
- package/src/__tests__/{knowledge-public → knowledge/public}/share-merge.test.ts +7 -7
- package/src/__tests__/security/xss-sanitize.test.ts +9 -8
- package/src/components/ai/__tests__/chat-thread-id-stability.test.tsx +125 -0
- package/src/components/ai/ai-chat-prompt.tsx +8 -2
- package/src/components/ai/ai-prompt-input/index.tsx +2 -1
- package/src/components/ai/global-harness-sidebar/__tests__/global-ai-prompts.test.ts +1 -2
- package/src/components/ai/global-harness-sidebar/index.tsx +22 -10
- package/src/components/bug/__tests__/board-subgroups.test.tsx +512 -0
- package/src/components/bug/__tests__/board-view.test.tsx +350 -0
- package/src/components/bug/__tests__/bug-list-loading-defer.test.tsx +85 -0
- package/src/components/bug/__tests__/bug-toolbar.test.tsx +7 -1
- package/src/components/bug/__tests__/comment-reactions.test.tsx +182 -0
- package/src/components/bug/__tests__/date-and-lightbox.test.tsx +96 -0
- package/src/components/bug/__tests__/export-entry.test.tsx +217 -0
- package/src/components/bug/__tests__/filters-compat.test.tsx +157 -0
- package/src/components/bug/__tests__/fix-request-race.test.tsx +396 -0
- package/src/components/bug/__tests__/peek-modes.test.tsx +365 -0
- package/src/components/bug/__tests__/saved-views.test.tsx +309 -0
- package/src/components/bug/__tests__/story-bugs-panel.test.tsx +146 -0
- package/src/components/bug/__tests__/subgroup-header.test.tsx +150 -0
- package/src/components/bug/__tests__/toolbar-shell.test.tsx +369 -0
- package/src/components/bug/__tests__/use-paged-bug-groups.test.tsx +58 -0
- package/src/components/bug/__tests__/view-type.test.tsx +247 -0
- package/src/components/bug/bug-list-view.tsx +310 -13
- package/src/components/bug/bug-panel-header.tsx +84 -8
- package/src/components/bug/bug-toolbar.tsx +268 -116
- package/src/components/bug/create-bug-dialog.tsx +1 -1
- package/src/components/bug/detail/__tests__/activity-timeline.test.tsx +1 -0
- package/src/components/bug/detail/__tests__/bug-detail-collab.test.tsx +236 -0
- package/src/components/bug/detail/__tests__/repro-report-card.test.tsx +2 -1
- package/src/components/bug/detail/activity-timeline.tsx +1 -1
- package/src/components/bug/detail/bug-detail-body.tsx +316 -18
- package/src/components/bug/detail/comment-item.tsx +7 -2
- package/src/components/bug/detail/comment-reactions.tsx +138 -0
- package/src/components/bug/detail/error-context.tsx +29 -9
- package/src/components/bug/detail/props-section.tsx +31 -3
- package/src/components/bug/detail/repro-report-card.tsx +1 -1
- package/src/components/bug/display/constants.ts +4 -2
- package/src/components/bug/display/display-dropdown.tsx +76 -280
- package/src/components/bug/display/types.ts +24 -8
- package/src/components/bug/display/use-paged-bug-groups.ts +183 -32
- package/src/components/bug/filters/{filter-value-input.tsx → field-configs.tsx} +218 -106
- package/src/components/bug/filters/index.tsx +14 -5
- package/src/components/bug/inline-editors/assignee-editor.tsx +2 -2
- package/src/components/bug/inline-editors/labels-editor.tsx +1 -1
- package/src/components/bug/inline-editors/severity-editor.tsx +37 -18
- package/src/components/bug/inline-editors/state-editor.tsx +1 -1
- package/src/components/bug/peek-panel.tsx +153 -35
- package/src/components/bug/select-bug-dialog.tsx +500 -0
- package/src/components/bug/story-bugs-panel.tsx +1090 -0
- package/src/components/bug/types.ts +9 -2
- package/src/components/bug/views/board-view.tsx +661 -0
- package/src/components/bug/views/bug-list/batch-toolbar.tsx +25 -34
- package/src/components/bug/views/bug-list/group.tsx +28 -13
- package/src/components/bug/views/bug-list/index.tsx +223 -59
- package/src/components/bug/views/bug-list/row.tsx +7 -1
- package/src/components/bug/views/create-view-dialog.tsx +160 -0
- package/src/components/bug/views/view-list-section.tsx +140 -0
- package/src/components/bug/views/view-selector-button.tsx +145 -0
- package/src/components/bug/views/view-switcher.tsx +8 -6
- package/src/components/bug/views/view-types.ts +162 -0
- package/src/components/collab/__tests__/activity-attribution.test.tsx +211 -0
- package/src/components/collab/__tests__/canvas-collab-panel.test.tsx +118 -0
- package/src/components/collab/__tests__/collab-presence-header.test.tsx +335 -0
- package/src/components/collab/__tests__/entity-presence-badge.test.tsx +226 -0
- package/src/components/collab/activity-attribution.tsx +221 -0
- package/src/components/collab/canvas-collab-panel.tsx +104 -0
- package/src/components/collab/collab-presence-header.tsx +220 -0
- package/src/components/collab/entity-presence-badge.tsx +130 -0
- package/src/components/collab/field-editing-pill.tsx +137 -0
- package/src/components/command/command-palette.tsx +6 -6
- package/src/components/design/canvas/document/__tests__/canvas-tree-fixture-snapshot.ts +589 -0
- package/src/components/design/canvas/document/__tests__/canvas-tree-reconcile.test.ts +159 -0
- package/src/components/design/canvas/panels/toolbar.tsx +3 -2
- package/src/components/design/canvas/plugins/presence/__tests__/canvas-collab-wiring.test.tsx +660 -0
- package/src/components/design/canvas/plugins/presence/__tests__/layout.test.ts +292 -0
- package/src/components/design/canvas/plugins/presence/__tests__/presence-overlay.test.tsx +139 -0
- package/src/components/design/canvas/plugins/presence/layout.ts +222 -0
- package/src/components/design/canvas/plugins/presence/presence-layer.tsx +353 -0
- package/src/components/design/canvas/plugins/presence/presence-overlay.tsx +155 -0
- package/src/components/design/canvas/plugins/presence/viewer-badge.tsx +80 -0
- package/src/components/design/canvas/react/__tests__/hooks.test.tsx +4 -4
- package/src/components/design/editor/collaboration/__tests__/design-agent-panel-regenerate.test.tsx +3 -1
- package/src/components/design/editor/collaboration/__tests__/design-agent-panel-stream.test.tsx +3 -1
- package/src/components/design/editor/collaboration/design-agent-panel.tsx +3 -0
- package/src/components/design/editor/collaboration/design-comment-review-panel.tsx +5 -5
- package/src/components/design/editor/dashboard/create-design-dialog.tsx +1 -1
- package/src/components/design/editor/dashboard/design-dashboard.tsx +1 -10
- package/src/components/design/editor/layout/__tests__/collab-param-wiring.test.tsx +153 -0
- package/src/components/design/editor/layout/__tests__/no-page-save-toast.test.tsx +8 -0
- package/src/components/design/editor/layout/design-editor-layout.tsx +28 -1
- package/src/components/design/editor/layout/design-navigation.tsx +0 -4
- package/src/components/design/editor/layout/hooks/__tests__/use-design-auto-save-collab.test.tsx +130 -0
- package/src/components/design/editor/layout/hooks/use-design-auto-save.ts +18 -1
- package/src/components/design/editor/share/share-comment-panel.tsx +37 -20
- package/src/components/design/editor/share/share-layout.tsx +3 -0
- package/src/components/design/editor/share/share-nav-bar.tsx +7 -1
- package/src/components/design/editor/share/share-theme-toggle.tsx +10 -15
- package/src/components/design/editor/tiptap/node/paragraph-node/paragraph-node.scss +1 -1
- package/src/components/harness/__tests__/composer-draft.test.ts +126 -0
- package/src/components/harness/composer/composer-editor.tsx +64 -6
- package/src/components/harness/composer/harness-composer.tsx +1 -1
- package/src/components/harness/harness-layout.tsx +1 -1
- package/src/components/harness/harness-workbench-page.tsx +6 -1
- package/src/components/harness/turn-group.tsx +1 -1
- package/src/components/hotkeys/shortcut-badge.tsx +4 -4
- package/src/components/knowledge/dialog/knowledge-form-dialog.tsx +1 -1
- package/src/components/knowledge/editor/__tests__/knowledge-doc-editor-collab.test.tsx +542 -0
- package/src/components/knowledge/editor/doc-header.tsx +7 -49
- package/src/components/knowledge/editor/knowledge-doc-editor.tsx +264 -51
- package/src/components/knowledge/editor/mindmap-doc-editor.tsx +3 -19
- package/src/components/knowledge/knowledge-homepage.tsx +3 -3
- package/src/components/knowledge/knowledge-toolbar.tsx +8 -8
- package/src/components/{knowledge-public → knowledge/public}/bookmark-form-dialog.tsx +4 -4
- package/src/components/{knowledge-public → knowledge/public}/knowledge-card.tsx +1 -15
- package/src/components/{knowledge-public → knowledge/public}/knowledge-form-dialog.tsx +9 -9
- package/src/components/{knowledge-public → knowledge/public}/legal-compliance-editor.tsx +35 -28
- package/src/components/{knowledge-public → knowledge/public}/sharers-avatar-group.tsx +3 -2
- package/src/components/layout/layout-responsive-provider.tsx +79 -0
- package/src/components/layout/sidebar-03/__tests__/presence-avatar-stack.test.tsx +148 -0
- package/src/components/layout/sidebar-03/app-sidebar.tsx +9 -3
- package/src/components/layout/sidebar-03/index.tsx +19 -4
- package/src/components/layout/sidebar-03/nav-main.tsx +28 -10
- package/src/components/layout/sidebar-03/presence-avatar-stack.tsx +112 -0
- package/src/components/layout/sidebar-03/user-menu.tsx +3 -3
- package/src/components/project/create-project-dialog.tsx +1 -1
- package/src/components/project/invite-member-dialog.tsx +1 -1
- package/src/components/project/project-activity-list.tsx +2 -2
- package/src/components/project/project-member-manager.tsx +2 -2
- package/src/components/reui/__tests__/kanban-drop-indicator.test.ts +88 -0
- package/src/components/reui/__tests__/kanban-keyboard.test.ts +262 -0
- package/src/components/reui/event-calendar/event-calendar-agenda-view.tsx +236 -0
- package/src/components/reui/event-calendar/event-calendar-content.tsx +80 -0
- package/src/components/reui/event-calendar/event-calendar-dnd.tsx +1062 -0
- package/src/components/reui/event-calendar/event-calendar-event.tsx +555 -0
- package/src/components/reui/event-calendar/event-calendar-i18n.tsx +291 -0
- package/src/components/reui/event-calendar/event-calendar-lib.tsx +677 -0
- package/src/components/reui/event-calendar/event-calendar-month-view.tsx +1287 -0
- package/src/components/reui/event-calendar/event-calendar-nav.tsx +615 -0
- package/src/components/reui/event-calendar/event-calendar-recurrence.tsx +592 -0
- package/src/components/reui/event-calendar/event-calendar-resource-view.tsx +810 -0
- package/src/components/reui/event-calendar/event-calendar-time-grid.tsx +1366 -0
- package/src/components/reui/event-calendar/event-calendar-types.tsx +240 -0
- package/src/components/reui/event-calendar/event-calendar.tsx +1916 -0
- package/src/components/reui/gantt/__tests__/gantt-row-window.test.ts +243 -0
- package/src/components/reui/gantt/__tests__/gantt-view-virtualize.test.tsx +243 -0
- package/src/components/reui/gantt/gantt-bar.tsx +439 -0
- package/src/components/reui/gantt/gantt-dnd.tsx +1000 -0
- package/src/components/reui/gantt/gantt-i18n.tsx +287 -0
- package/src/components/reui/gantt/gantt-lib.tsx +695 -0
- package/src/components/reui/gantt/gantt-nav.tsx +446 -0
- package/src/components/reui/gantt/gantt-recurrence.tsx +402 -0
- package/src/components/reui/gantt/gantt-row-window.ts +119 -0
- package/src/components/reui/gantt/gantt-types.tsx +283 -0
- package/src/components/reui/gantt/gantt-view.tsx +4466 -0
- package/src/components/reui/gantt/gantt.tsx +1822 -0
- package/src/components/reui/gantt/use-gantt-row-window.ts +159 -0
- package/src/components/reui/icon-stack.tsx +90 -0
- package/src/components/reui/kanban-keyboard.ts +268 -0
- package/src/components/reui/kanban.tsx +1227 -0
- package/src/components/settings/personal-team/account/account-client.tsx +3 -2
- package/src/components/settings/personal-team/team/invitation-cards/invitation-history-list.tsx +5 -5
- package/src/components/settings/personal-team/team/team-client.tsx +2 -1
- package/src/components/settings/personal-team/team/team-member-list.tsx +4 -4
- package/src/components/share/share-presence-bar.tsx +182 -0
- package/src/components/shared-kit/auth/auth-client.ts +5 -0
- package/src/components/shared-kit/avatars/__tests__/avatar-shape.test.ts +44 -0
- package/src/components/shared-kit/avatars/__tests__/user-avatar.test.tsx +46 -0
- package/src/components/{tags/tags-collection.ts → shared-kit/avatars/avatar-shape.ts} +14 -20
- package/src/components/shared-kit/avatars/notion-avatar.tsx +62 -11
- package/src/components/shared-kit/avatars/user-avatar.tsx +7 -4
- package/src/components/shared-kit/list-kit/__tests__/batch-toolbar-shell.test.tsx +133 -0
- package/src/components/shared-kit/list-kit/__tests__/frozen-header.test.ts +89 -0
- package/src/components/shared-kit/list-kit/__tests__/level-badge.test.tsx +85 -0
- package/src/components/shared-kit/list-kit/__tests__/toolbar-shell.test.tsx +178 -0
- package/src/components/shared-kit/list-kit/batch-toolbar-shell.tsx +111 -0
- package/src/components/shared-kit/list-kit/csv-download.ts +46 -0
- package/src/components/shared-kit/list-kit/display/__tests__/dropdown.test.tsx +215 -0
- package/src/components/shared-kit/list-kit/display/dropdown.tsx +582 -0
- package/src/components/shared-kit/list-kit/display/types.ts +64 -0
- package/src/components/shared-kit/list-kit/display/values.ts +60 -0
- package/src/components/shared-kit/list-kit/filters/__tests__/create-filters-module.test.tsx +181 -0
- package/src/components/shared-kit/list-kit/filters/__tests__/types.test.ts +133 -0
- package/src/components/shared-kit/list-kit/filters/index.tsx +757 -0
- package/src/components/shared-kit/list-kit/filters/types.ts +68 -0
- package/src/components/shared-kit/list-kit/frozen-header.ts +45 -0
- package/src/components/shared-kit/list-kit/icon-semantics.ts +50 -0
- package/src/components/shared-kit/list-kit/kanban/__tests__/drop-layout.test.ts +139 -0
- package/src/components/shared-kit/list-kit/kanban/__tests__/frozen-header.test.ts +111 -0
- package/src/components/shared-kit/list-kit/kanban/__tests__/instance-key.test.ts +130 -0
- package/src/components/shared-kit/list-kit/kanban/__tests__/kanban-board-view.test.tsx +901 -0
- package/src/components/shared-kit/list-kit/kanban/__tests__/manual-order.test.ts +35 -0
- package/src/components/shared-kit/list-kit/kanban/drop-commit.ts +212 -0
- package/src/components/shared-kit/list-kit/kanban/drop-layout.ts +98 -0
- package/src/components/shared-kit/list-kit/kanban/frozen-header.ts +88 -0
- package/src/components/shared-kit/list-kit/kanban/instance-key.ts +83 -0
- package/src/components/shared-kit/list-kit/kanban/kanban-board-view.tsx +1414 -0
- package/src/components/shared-kit/list-kit/kanban/manual-order.ts +39 -0
- package/src/components/shared-kit/list-kit/level-badge.tsx +71 -0
- package/src/components/shared-kit/list-kit/toolbar-shell.tsx +103 -0
- package/src/components/shared-kit/loading/__tests__/use-deferred-flag.test.tsx +115 -0
- package/src/components/shared-kit/loading/use-deferred-flag.ts +35 -0
- package/src/components/shared-kit/peek/dock-slot.ts +62 -0
- package/src/components/shared-kit/peek/peek-mode.ts +34 -0
- package/src/components/shared-kit/theme/animated-theme-toggler.tsx +13 -7
- package/src/components/shared-kit/theme/theme-toggle.tsx +7 -18
- package/src/components/shared-kit/views/view-icon.tsx +49 -0
- package/src/components/story/__tests__/peek-panel-patch-sync.test.tsx +215 -0
- package/src/components/story/__tests__/story-list-loading-defer.test.tsx +88 -0
- package/src/components/story/__tests__/story-list-view-peek-sync.test.tsx +242 -0
- package/src/components/story/__tests__/story-list-view-restore.test.tsx +45 -3
- package/src/components/story/__tests__/story-list-view-ssr.test.tsx +0 -1
- package/src/components/story/ai/__tests__/action-registry.test.ts +11 -11
- package/src/components/story/ai/action-registry.ts +5 -7
- package/src/components/story/ai/handlers/stream-text.ts +1 -3
- package/src/components/story/ai/story-ai-toolbar.tsx +58 -16
- package/src/components/story/ai/use-toolbar-overflow.ts +4 -7
- package/src/components/story/columns.tsx +2 -2
- package/src/components/story/constants.ts +17 -1
- package/src/components/story/create-story-dialog.tsx +8 -7
- package/src/components/story/detail/__tests__/associated-attachments-panel.test.tsx +38 -4
- package/src/components/story/detail/__tests__/categories.test.tsx +52 -26
- package/src/components/story/detail/__tests__/story-collab.test.tsx +897 -0
- package/src/components/story/detail/activity-tabs.tsx +5 -4
- package/src/components/story/detail/activity-timeline.tsx +15 -3
- package/src/components/story/detail/comment-input.tsx +2 -2
- package/src/components/story/detail/comment-item.tsx +6 -3
- package/src/components/story/detail/comment-thread.tsx +1 -0
- package/src/components/story/detail/reaction-bar.tsx +1 -1
- package/src/components/story/detail/story-detail-body.tsx +501 -76
- package/src/components/story/detail/story-detail-toolbar/__tests__/related-test-cases.test.tsx +173 -10
- package/src/components/story/detail/story-detail-toolbar/associated-attachments-panel.tsx +144 -8
- package/src/components/story/detail/story-detail-toolbar/categories.tsx +82 -38
- package/src/components/story/detail/story-detail-toolbar/children.tsx +254 -174
- package/src/components/story/detail/story-detail-toolbar/dependency-panel.tsx +140 -58
- package/src/components/story/detail/story-detail-toolbar/index.tsx +613 -0
- package/src/components/story/detail/story-detail-toolbar/related-test-cases.tsx +1010 -87
- package/src/components/story/detail/story-detail-toolbar/scroll-hint.tsx +57 -0
- package/src/components/story/detail/story-detail-toolbar/use-panel-frozen-groups.ts +97 -0
- package/src/components/story/detail/story-properties.tsx +9 -5
- package/src/components/story/display/__tests__/display-dropdown-helpers.test.ts +17 -1
- package/src/components/story/display/__tests__/display-dropdown.test.tsx +9 -0
- package/src/components/story/display/__tests__/flatten-story-groups.test.ts +42 -0
- package/src/components/story/display/__tests__/group-fields.test.ts +81 -1
- package/src/components/story/display/__tests__/group-resolver.test.ts +153 -1
- package/src/components/story/display/__tests__/use-collapsed-subgroups.test.ts +158 -0
- package/src/components/story/display/__tests__/use-display-options.test.ts +32 -3
- package/src/components/story/display/__tests__/use-fit-count.test.ts +198 -0
- package/src/components/story/display/__tests__/use-paged-story-groups.test.ts +77 -3
- package/src/components/story/display/constants.ts +23 -2
- package/src/components/story/display/display-dropdown.tsx +95 -397
- package/src/components/story/display/flatten-story-groups.ts +39 -0
- package/src/components/story/display/group-fields.ts +32 -1
- package/src/components/story/display/group-resolver.tsx +128 -5
- package/src/components/story/display/types.ts +23 -42
- package/src/components/story/display/use-collapsed-subgroups.ts +109 -0
- package/src/components/story/display/use-display-options.ts +21 -5
- package/src/components/story/display/use-fit-count.ts +124 -0
- package/src/components/story/display/use-paged-story-groups.ts +174 -31
- package/src/components/story/filters/field-configs.tsx +263 -0
- package/src/components/story/filters/index.tsx +14 -5
- package/src/components/story/inline-editors/__tests__/labels-editor.test.tsx +2 -0
- package/src/components/story/inline-editors/__tests__/priority-editor.test.tsx +15 -0
- package/src/components/story/inline-editors/assignee-editor.tsx +7 -5
- package/src/components/story/inline-editors/date-editor.tsx +7 -2
- package/src/components/story/inline-editors/inline-trigger.tsx +17 -4
- package/src/components/story/inline-editors/labels-editor.tsx +243 -35
- package/src/components/story/inline-editors/parent-story-editor.tsx +1 -1
- package/src/components/story/inline-editors/priority-editor.tsx +6 -4
- package/src/components/story/inline-editors/state-editor.tsx +6 -4
- package/src/components/story/inline-editors/story-select-dialog.tsx +56 -19
- package/src/components/story/peek-panel.tsx +234 -51
- package/src/components/story/relations/__tests__/design-select-dialog.test.tsx +131 -0
- package/src/components/story/relations/attachment-manage-dialog.tsx +1 -1
- package/src/components/story/relations/design-select-dialog.tsx +285 -0
- package/src/components/story/relations/knowledge-select-dialog.tsx +3 -3
- package/src/components/story/relations/link-manage-dialog.tsx +2 -2
- package/src/components/story/story-list-view.tsx +175 -17
- package/src/components/story/story-panel-header.tsx +18 -11
- package/src/components/story/story-toolbar.tsx +177 -126
- package/src/components/story/transfer-story-popover.tsx +1 -1
- package/src/components/story/types.ts +11 -2
- package/src/components/story/views/__tests__/calendar-view.test.tsx +262 -0
- package/src/components/story/views/__tests__/kanban-drop-subgroup.test.ts +68 -0
- package/src/components/story/views/__tests__/kanban-peek-sync.test.tsx +184 -0
- package/src/components/story/views/__tests__/kanban-view-membership-sync.test.tsx +179 -0
- package/src/components/story/views/__tests__/timeline-view.test.tsx +453 -0
- package/src/components/story/views/__tests__/view-icon.test.tsx +45 -0
- package/src/components/story/views/__tests__/view-types.test.ts +0 -8
- package/src/components/story/views/calendar/__tests__/mappers.test.ts +201 -0
- package/src/components/story/views/calendar/__tests__/slot-draft.test.ts +78 -0
- package/src/components/story/views/calendar/drag-blocked.ts +41 -0
- package/src/components/story/views/calendar/i18n.ts +91 -0
- package/src/components/story/views/calendar/mappers.ts +119 -0
- package/src/components/story/views/calendar/slot-draft.ts +48 -0
- package/src/components/story/views/calendar-view.tsx +490 -223
- package/src/components/story/views/create-view-dialog.tsx +6 -6
- package/src/components/story/views/kanban-view.tsx +618 -0
- package/src/components/story/views/{__tests__ → story-list/__tests__}/use-flat-rows.test.ts +1 -1
- package/src/components/story/views/story-list/batch-preview-dialog.tsx +3 -3
- package/src/components/story/views/story-list/batch-toolbar.tsx +17 -34
- package/src/components/story/views/story-list/group.tsx +4 -2
- package/src/components/story/views/story-list/index.tsx +142 -34
- package/src/components/story/views/story-list/row.tsx +4 -0
- package/src/components/story/views/{use-flat-rows.ts → story-list/use-flat-rows.ts} +2 -2
- package/src/components/story/views/timeline/__tests__/mappers.test.ts +742 -0
- package/src/components/story/views/timeline/__tests__/sentinel-geometry.test.ts +76 -0
- package/src/components/story/views/timeline/__tests__/use-date-commit.test.tsx +372 -0
- package/src/components/story/views/timeline/i18n.ts +96 -0
- package/src/components/story/views/timeline/mappers.ts +506 -0
- package/src/components/story/views/timeline/sentinel-geometry.ts +43 -0
- package/src/components/story/views/timeline/use-date-commit.ts +169 -0
- package/src/components/story/views/timeline-view.tsx +593 -222
- package/src/components/story/views/view-list-section.tsx +4 -4
- package/src/components/story/views/view-selector-button.tsx +9 -10
- package/src/components/story/views/view-switcher.tsx +7 -1
- package/src/components/story/views/view-types.ts +1 -5
- package/src/components/testcase/__tests__/batch-toolbar.test.tsx +245 -0
- package/src/components/testcase/__tests__/board-view.test.tsx +241 -0
- package/src/components/testcase/__tests__/case-row.test.tsx +24 -10
- package/src/components/testcase/__tests__/constants.test.ts +151 -0
- package/src/components/testcase/__tests__/create-dialog-scope.test.tsx +173 -0
- package/src/components/testcase/__tests__/detail-properties.test.tsx +19 -0
- package/src/components/testcase/__tests__/display-options.test.tsx +154 -0
- package/src/components/testcase/__tests__/export-entry.test.tsx +279 -0
- package/src/components/testcase/__tests__/filters.test.tsx +280 -0
- package/src/components/testcase/__tests__/list-view-scope.test.tsx +290 -0
- package/src/components/testcase/__tests__/list-view.test.tsx +552 -7
- package/src/components/testcase/__tests__/manual-move.test.ts +69 -0
- package/src/components/testcase/__tests__/peek-panel.test.tsx +89 -8
- package/src/components/testcase/__tests__/toolbar.test.tsx +302 -0
- package/src/components/testcase/__tests__/view-fallback.test.tsx +272 -0
- package/src/components/testcase/__tests__/view-management.test.tsx +179 -0
- package/src/components/testcase/ai/generate-dialog.tsx +1 -1
- package/src/components/testcase/constants.ts +83 -0
- package/src/components/testcase/create-testcase-dialog.tsx +76 -8
- package/src/components/testcase/detail/__tests__/select-cases-dialog.test.tsx +163 -0
- package/src/components/testcase/detail/case-detail-body.tsx +311 -30
- package/src/components/testcase/detail/select-cases-dialog.tsx +465 -0
- package/src/components/testcase/detail/story-link-editor.tsx +1 -1
- package/src/components/testcase/display/display-dropdown.tsx +147 -0
- package/src/components/testcase/display/field-configs.ts +80 -0
- package/src/components/testcase/display/use-testcase-display-options.ts +59 -0
- package/src/components/testcase/filters/field-configs.tsx +748 -0
- package/src/components/testcase/filters/index.tsx +35 -127
- package/src/components/testcase/inline-editors/assignee-editor.tsx +6 -6
- package/src/components/testcase/inline-editors/folder-editor.tsx +9 -9
- package/src/components/testcase/inline-editors/priority-editor.tsx +47 -31
- package/src/components/testcase/inline-editors/state-editor.tsx +14 -12
- package/src/components/testcase/inline-editors/type-editor.tsx +31 -20
- package/src/components/testcase/list/case-row.tsx +60 -2
- package/src/components/testcase/list/grouped-list.tsx +3 -2
- package/src/components/testcase/list-view.tsx +633 -137
- package/src/components/testcase/peek-panel.tsx +69 -11
- package/src/components/testcase/plans/plan-list.tsx +6 -1
- package/src/components/testcase/step-editor.tsx +34 -3
- package/src/components/testcase/testcase-toolbar.tsx +349 -94
- package/src/components/testcase/views/batch-toolbar.tsx +203 -0
- package/src/components/testcase/views/board-view.tsx +546 -0
- package/src/components/testcase/views/create-view-dialog.tsx +26 -14
- package/src/components/testcase/views/move.ts +56 -0
- package/src/components/testcase/views/view-selector-button.tsx +82 -30
- package/src/components/testcase/views/view-switcher.tsx +12 -5
- package/src/components/testcase/views/view-types.ts +50 -27
- package/src/components/tiptap/mindmap/lib/__tests__/shortcuts.test.ts +5 -4
- package/src/components/tiptap/mindmap/lib/shortcuts.ts +4 -3
- package/src/components/tiptap/node/doc-card-node-view.tsx +2 -1
- package/src/components/tiptap/notion-like/editor-extensions.ts +231 -0
- package/src/components/tiptap/notion-like/notion-like-editor.tsx +29 -156
- package/src/components/tiptap/ui/ai-menu/ai-menu.tsx +6 -1
- package/src/components/tiptap/ui/doc-reference-interaction/doc-reference-interaction.tsx +2 -1
- package/src/components/ui/checkbox.tsx +4 -4
- package/src/components/ui/popover.tsx +1 -1
- package/src/components/workspace/dialogs/edit-workspace-dialog.tsx +1 -1
- package/src/components/workspace/kb/workspace-kb-manage-dialog.tsx +1 -1
- package/src/components/workspace/kb/workspace-quick-doc-dialog.tsx +1 -1
- package/src/components/workspace/members/workspace-member-item.tsx +2 -2
- package/src/components/workspace/members/workspace-members-manager.tsx +2 -2
- package/src/config/sidebar-routes.tsx +11 -13
- package/src/design-engine/document/serialize-leafer-json.ts +2 -1
- package/src/design-engine/engine/__tests__/engine-split.test.ts +1 -1
- package/src/design-engine/engine/__tests__/engine.test.ts +20 -0
- package/src/design-engine/engine/app.ts +5 -0
- package/src/design-engine/engine/engine-base.ts +17 -0
- package/src/design-engine/engine/engine-doc.ts +32 -0
- package/src/hooks/__tests__/use-commands.test.tsx +17 -5
- package/src/hooks/use-commands.ts +4 -4
- package/src/hooks/use-toolbar-search.ts +7 -2
- package/src/lib/__tests__/datetime-local.test.ts +46 -0
- package/src/lib/__tests__/datetime.test.ts +7 -0
- package/src/lib/__tests__/layout-responsive.test.ts +123 -0
- package/src/lib/__tests__/sidebar-utils.test.ts +4 -4
- package/src/lib/ai/story-ai-stream.ts +1 -1
- package/src/lib/ai/use-selected-model.ts +15 -5
- package/src/lib/changelog/sdk-versions.ts +22 -22
- package/src/lib/collab/__tests__/bindings-fields.test.ts +496 -0
- package/src/lib/collab/__tests__/bindings-steps.test.ts +332 -0
- package/src/lib/collab/__tests__/presence-store.test.ts +414 -0
- package/src/lib/collab/__tests__/provider-handle-contract.test.ts +89 -0
- package/src/lib/collab/__tests__/provider.test.ts +504 -0
- package/src/lib/collab/__tests__/use-presence-list-refresh.test.tsx +309 -0
- package/src/lib/collab/__tests__/use-presence-report.test.ts +356 -0
- package/src/lib/collab/__tests__/use-share-presence.test.ts +555 -0
- package/src/lib/collab/__tests__/user-color.test.ts +53 -0
- package/src/lib/collab/bindings/__tests__/canvas-local.test.ts +583 -0
- package/src/lib/collab/bindings/__tests__/canvas-presence.test.ts +378 -0
- package/src/lib/collab/bindings/__tests__/canvas-remote.test.ts +454 -0
- package/src/lib/collab/bindings/__tests__/canvas-tree-perf.test.ts +134 -0
- package/src/lib/collab/bindings/canvas-local.ts +415 -0
- package/src/lib/collab/bindings/canvas-presence.ts +420 -0
- package/src/lib/collab/bindings/canvas-remote.ts +226 -0
- package/src/lib/collab/bindings/fields.ts +463 -0
- package/src/lib/collab/bindings/steps.ts +332 -0
- package/src/lib/collab/env.ts +33 -0
- package/src/lib/collab/module-map.ts +131 -0
- package/src/lib/collab/presence-store.ts +350 -0
- package/src/lib/collab/provider.ts +378 -0
- package/src/lib/collab/use-collab-doc.ts +100 -0
- package/src/lib/collab/use-presence-list-refresh.ts +142 -0
- package/src/lib/collab/use-presence-report.ts +176 -0
- package/src/lib/collab/use-self-profile-broadcast.ts +113 -0
- package/src/lib/collab/use-share-presence.ts +561 -0
- package/src/lib/collab/user-color.ts +40 -0
- package/src/lib/collections/index.ts +0 -1
- package/src/lib/collections/reference/bookmark-categories-collection.ts +1 -1
- package/src/lib/commands/__tests__/registry.test.ts +2 -5
- package/src/lib/commands/registry.ts +0 -3
- package/src/lib/commands/types.ts +1 -1
- package/src/lib/datetime.ts +20 -3
- package/src/lib/hooks/__tests__/use-synced-state.test.tsx +54 -0
- package/src/lib/hooks/use-story-panel-open.ts +134 -0
- package/src/lib/hooks/use-synced-state.ts +17 -0
- package/src/lib/hotkeys/__tests__/registry.test.ts +1 -1
- package/src/lib/hotkeys/registry.ts +0 -3
- package/src/lib/layout-responsive.ts +140 -0
- package/src/lib/pacer.ts +10 -1
- package/src/lib/peek-dock.ts +32 -0
- package/src/lib/plugin-runtime/__tests__/plugin-tool-schema-parity.test.ts +140 -0
- package/src/lib/plugin-runtime/plugin-client-tools.ts +28 -46
- package/src/lib/plugin-runtime/plugin-tool-defs.ts +152 -0
- package/src/lib/project-module-meta.ts +1 -8
- package/src/lib/rate-limit.ts +15 -6
- package/src/lib/sanitize.ts +122 -10
- package/src/lib/share-events.ts +20 -11
- package/src/lib/sidebar-utils.ts +1 -1
- package/src/lib/theme/__tests__/theme-mode.test.ts +96 -0
- package/src/lib/theme/theme-mode.ts +77 -0
- package/src/paraglide/messages/_index.js +5312 -1591
- package/src/paraglide/messages/en.js +1880 -685
- package/src/paraglide/messages/zh.js +1877 -682
- package/src/providers/index.tsx +4 -1
- package/src/providers/theme-mode-provider.tsx +135 -0
- package/src/routeTree.gen.ts +78 -101
- package/src/routes/$lang/docs/$/route.lazy.tsx +7 -2
- package/src/routes/-tests/chat-body-validation.test.ts +61 -3
- package/src/routes/__tests__/-edition-routing.test.tsx +12 -8
- package/src/routes/_nav/-components/nav-not-found.tsx +2 -2
- package/src/routes/_nav/bugs/$bugId/route.lazy.tsx +16 -2
- package/src/routes/_nav/bugs/$bugId/route.tsx +11 -2
- package/src/routes/_nav/dashboard/-components/avatar-group.tsx +2 -2
- package/src/routes/_nav/knowledge/$kbId/doc/$docId/route.lazy.tsx +31 -17
- package/src/routes/_nav/knowledge/$kbId/doc/$docId/route.tsx +12 -1
- package/src/routes/_nav/knowledge/$kbId/index.lazy.tsx +4 -2
- package/src/routes/_nav/knowledge/$kbId/route.lazy.tsx +7 -3
- package/src/routes/_nav/knowledge/bookmarks/$id.lazy.tsx +6 -6
- package/src/routes/_nav/knowledge/bookmarks/index.lazy.tsx +10 -10
- package/src/routes/_nav/knowledge/compliance/$id.lazy.tsx +9 -9
- package/src/routes/_nav/knowledge/compliance/edit/$id.lazy.tsx +1 -1
- package/src/routes/_nav/knowledge/compliance/index.lazy.tsx +8 -8
- package/src/routes/_nav/knowledge/dictionaries/$id.lazy.tsx +4 -4
- package/src/routes/_nav/knowledge/dictionaries/index.lazy.tsx +6 -6
- package/src/routes/_nav/knowledge/index.lazy.tsx +1 -1
- package/src/routes/_nav/knowledge/laws/$id.lazy.tsx +9 -9
- package/src/routes/_nav/knowledge/laws/edit/$id.lazy.tsx +1 -1
- package/src/routes/_nav/knowledge/laws/index.lazy.tsx +11 -11
- package/src/routes/_nav/knowledge/summary/index.lazy.tsx +1 -1
- package/src/routes/_nav/projects/$projectId/index.lazy.tsx +14 -6
- package/src/routes/_nav/route.lazy.tsx +11 -6
- package/src/routes/_nav/settings/account/route.tsx +3 -2
- package/src/routes/_nav/stories/$storyId/route.lazy.tsx +52 -8
- package/src/routes/_nav/stories/$storyId/route.tsx +11 -0
- package/src/routes/_nav/stories/-components/story-detail-skeleton.tsx +1 -1
- package/src/{components/tags/pages/tag-detail-page.tsx → routes/_nav/tasks/$taskId/route.lazy.tsx} +20 -10
- package/src/routes/_nav/tasks/$taskId/route.tsx +43 -0
- package/src/routes/_nav/tasks/-components/ai-task-detail.tsx +307 -0
- package/src/routes/_nav/tasks/-components/ai-task-list.tsx +217 -0
- package/src/routes/_nav/tasks/-components/ai-task-status-badge.tsx +46 -0
- package/src/routes/_nav/tasks/-components/assigned-list.tsx +157 -0
- package/src/routes/_nav/tasks/-components/create-ai-task-dialog.tsx +228 -0
- package/src/routes/_nav/tasks/-components/source-ai-exec-dialog.tsx +175 -0
- package/src/routes/_nav/tasks/-components/task-constants.ts +115 -0
- package/src/routes/_nav/tasks/-components/use-task-filter.ts +64 -0
- package/src/routes/_nav/tasks/-tests/create-ai-task-dialog.test.tsx +175 -0
- package/src/routes/_nav/tasks/-tests/task-filter.test.ts +134 -0
- package/src/routes/_nav/tasks/-tests/task-styles.test.tsx +119 -0
- package/src/routes/_nav/tasks/index.lazy.tsx +114 -0
- package/src/routes/_nav/tasks/index.tsx +47 -0
- package/src/routes/_nav/{tags → tasks}/route.tsx +3 -3
- package/src/routes/_nav/test-cases/$caseId/route.lazy.tsx +3 -1
- package/src/routes/_nav/test-cases/$caseId/route.tsx +11 -2
- package/src/routes/api/chat.ts +23 -19
- package/src/routes/api/design/prototype/route.ts +2 -4
- package/src/routes/{_nav/acceptance-criteria/route.tsx → api/internal/collab-reindex.ts} +11 -8
- package/src/routes/api/knowledge/sync-github.ts +1 -1
- package/src/routes/design-files/$projectId/$artboardId/$filename.ts +9 -0
- package/src/routes/invite/$token/route.lazy.tsx +2 -2
- package/src/routes/share/$versionId/route.lazy.tsx +8 -0
- package/src/routes/share/compliance/$id/route.lazy.tsx +2 -2
- package/src/routes/share/compliance/$id/route.tsx +1 -1
- package/src/routes/share/law/$id/route.lazy.tsx +2 -2
- package/src/routes/share/law/$id/route.tsx +1 -1
- package/src/server/__tests__/bug-groups-two-level.test.ts +263 -0
- package/src/server/__tests__/builtin-project.test.ts +45 -1
- package/src/server/__tests__/csv-export.test.ts +319 -0
- package/src/server/__tests__/share-version.test.ts +7 -2
- package/src/server/__tests__/task-assigned-query.test.ts +232 -0
- package/src/server/__tests__/task-context-builder.test.ts +175 -0
- package/src/server/__tests__/task-executor.test.ts +349 -0
- package/src/server/__tests__/task-priority-map.test.ts +41 -0
- package/src/server/__tests__/task-serverfn-crud.test.ts +639 -0
- package/src/server/__tests__/task-serverfn-queries.test.ts +403 -0
- package/src/server/__tests__/testcase-board.test.ts +301 -0
- package/src/server/__tests__/testcase-bulk.test.ts +155 -0
- package/src/server/__tests__/testcase-list-scope.test.ts +215 -0
- package/src/server/ai-gateway/__tests__/chat-handler-prompt-key.test.ts +106 -0
- package/src/server/ai-gateway/__tests__/chat-stream-builder-sandbox.test.ts +9 -3
- package/src/server/ai-gateway/__tests__/conformance.test.ts +4 -3
- package/src/server/ai-gateway/adapter.ts +4 -2
- package/src/server/ai-gateway/chat-handler.ts +13 -5
- package/src/server/ai-gateway/chat-stream-builder.ts +12 -7
- package/src/server/ai-gateway/sandbox/__tests__/harness-sandbox.test.ts +88 -0
- package/src/server/ai-gateway/sandbox/harness-sandbox.ts +1 -1
- package/src/server/ai-gateway/story-handler.ts +7 -2
- package/src/server/ai-gateway/tools/plugin-lifecycle-defs.ts +8 -99
- package/src/server/ai-gateway/tools/plugin-tools.ts +6 -10
- package/src/server/ai-task/assigned-query.ts +167 -0
- package/src/server/ai-task/context-builder.ts +182 -0
- package/src/server/ai-task/executor.ts +296 -0
- package/src/server/ai-task/priority-map.ts +37 -0
- package/src/server/ai-task/types.ts +15 -0
- package/src/server/bug-report/__tests__/report-handler.test.ts +55 -4
- package/src/server/bug-report/report-handler.ts +54 -8
- package/src/server/builtin-project.ts +24 -0
- package/src/server/cloudflare/prisma-wasm-base64-stub.ts +5 -0
- package/src/server/collab/__tests__/collab-permissions.test.ts +412 -0
- package/src/server/collab/__tests__/knowledge-additions.test.ts +121 -0
- package/src/server/collab/__tests__/reindex-endpoint.test.ts +168 -0
- package/src/server/collab/__tests__/resolve.test.ts +249 -0
- package/src/server/collab/__tests__/touch-snapshot.test.ts +58 -0
- package/src/server/collab/__tests__/write-guard-sdk.test.ts +501 -0
- package/src/server/collab/__tests__/write-guard.test.ts +373 -0
- package/src/server/collab/knowledge-additions.ts +37 -0
- package/src/server/collab/reindex-endpoint.ts +75 -0
- package/src/server/collab/resolve.ts +122 -0
- package/src/server/collab/seed.ts +408 -0
- package/src/server/collab/ticket-utils.ts +107 -0
- package/src/server/collab/touch-snapshot.ts +55 -0
- package/src/server/collab/write-guard.ts +258 -0
- package/src/server/collab-permissions.ts +248 -0
- package/src/server/csv.ts +61 -0
- package/src/server/{knowledge-public → knowledge/public}/github-sync.ts +12 -2
- package/src/server/{knowledge-public → knowledge/public}/share-merge.ts +31 -8
- package/src/server/lib/__tests__/sequence-number-retry.test.ts +160 -0
- package/src/server/lib/rate-limit.ts +13 -2
- package/src/server/lib/sequence-number-retry.ts +85 -0
- package/src/server/mcp/__tests__/tools.test.ts +10 -0
- package/src/server/mcp/tools.ts +26 -2
- package/src/server/middlewares/auth-rate-limit.ts +18 -9
- package/src/server/notifications/event-bus.ts +3 -0
- package/src/server/serverFns/__tests__/collab-snapshot.test.ts +665 -0
- package/src/server/serverFns/__tests__/collab.test.ts +459 -0
- package/src/server/serverFns/__tests__/knowledge-delete.test.ts +167 -0
- package/src/server/serverFns/__tests__/ownership-merge.test.ts +34 -2
- package/src/server/serverFns/__tests__/read-write-merge.test.ts +2 -2
- package/src/server/serverFns/__tests__/story-crud-archive-detail.test.ts +12 -2
- package/src/server/serverFns/__tests__/story-crud.test.ts +51 -11
- package/src/server/serverFns/__tests__/story-tree.test.ts +66 -34
- package/src/server/serverFns/__tests__/story-types.test.ts +14 -9
- package/src/server/serverFns/__tests__/update-bug-guard.test.ts +325 -0
- package/src/server/serverFns/__tests__/update-document-guard.test.ts +285 -0
- package/src/server/serverFns/__tests__/update-page-guard.test.ts +276 -0
- package/src/server/serverFns/__tests__/update-story-guard.test.ts +436 -0
- package/src/server/serverFns/__tests__/update-testcase-guard.test.ts +293 -0
- package/src/server/serverFns/bug/__tests__/bug-comments.test.ts +25 -0
- package/src/server/serverFns/bug/__tests__/bug-crud.test.ts +315 -15
- package/src/server/serverFns/bug/__tests__/bug-groups.test.ts +36 -18
- package/src/server/serverFns/bug/__tests__/bug-list-query.test.ts +21 -0
- package/src/server/serverFns/bug/__tests__/bug-reactions.test.ts +335 -0
- package/src/server/serverFns/bug/__tests__/bug-reorder.test.ts +165 -0
- package/src/server/serverFns/bug/__tests__/bug-story.test.ts +4 -1
- package/src/server/serverFns/bug/bug-comments.ts +5 -0
- package/src/server/serverFns/bug/bug-crud.ts +321 -72
- package/src/server/serverFns/bug/bug-display-options.ts +11 -0
- package/src/server/serverFns/bug/bug-export.ts +137 -0
- package/src/server/serverFns/bug/bug-group-query.ts +16 -5
- package/src/server/serverFns/bug/bug-groups.ts +39 -15
- package/src/server/serverFns/bug/bug-list-query.ts +8 -1
- package/src/server/serverFns/bug/bug-reactions.ts +123 -0
- package/src/server/serverFns/bug/bug-reorder.ts +64 -0
- package/src/server/serverFns/bug/bug-story-bulk.ts +280 -0
- package/src/server/serverFns/bug/bug-story.ts +47 -39
- package/src/server/serverFns/bug/index.ts +6 -0
- package/src/server/serverFns/collab-presence.ts +119 -0
- package/src/server/serverFns/collab-snapshot.ts +201 -0
- package/src/server/serverFns/collab.ts +153 -0
- package/src/server/serverFns/design/__tests__/designs.test.ts +0 -2
- package/src/server/serverFns/design/designs.ts +0 -64
- package/src/server/serverFns/design/detail.ts +36 -2
- package/src/server/serverFns/design/pages.ts +73 -10
- package/src/server/serverFns/iteration/iteration-crud.ts +25 -1
- package/src/server/serverFns/{knowledge-public → knowledge/public}/bookmarks/create.ts +3 -3
- package/src/server/serverFns/{knowledge-public → knowledge/public}/bookmarks/detail.ts +2 -2
- package/src/server/serverFns/{knowledge-public → knowledge/public}/bookmarks/list.ts +3 -2
- package/src/server/serverFns/{knowledge-public → knowledge/public}/bookmarks/refresh-favicon.ts +1 -1
- package/src/server/serverFns/{knowledge-public → knowledge/public}/bookmarks/share.ts +1 -1
- package/src/server/serverFns/{knowledge-public → knowledge/public}/bookmarks/sync-github.ts +1 -1
- package/src/server/serverFns/{knowledge-public → knowledge/public}/bookmarks/update.ts +5 -4
- package/src/server/serverFns/{knowledge-public → knowledge/public}/compliance/create.ts +1 -1
- package/src/server/serverFns/{knowledge-public → knowledge/public}/compliance/detail.ts +2 -2
- package/src/server/serverFns/{knowledge-public → knowledge/public}/compliance/list.ts +3 -2
- package/src/server/serverFns/{knowledge-public → knowledge/public}/compliance/share.ts +1 -1
- package/src/server/serverFns/{knowledge-public → knowledge/public}/compliance/update.ts +3 -2
- package/src/server/serverFns/{knowledge-public → knowledge/public}/dictionary/create.ts +1 -1
- package/src/server/serverFns/{knowledge-public → knowledge/public}/dictionary/detail.ts +2 -2
- package/src/server/serverFns/{knowledge-public → knowledge/public}/dictionary/list.ts +3 -2
- package/src/server/serverFns/{knowledge-public → knowledge/public}/dictionary/share.ts +1 -1
- package/src/server/serverFns/{knowledge-public → knowledge/public}/dictionary/update.ts +3 -2
- package/src/server/serverFns/{knowledge-public → knowledge/public}/legal/create.ts +1 -1
- package/src/server/serverFns/{knowledge-public → knowledge/public}/legal/detail.ts +2 -2
- package/src/server/serverFns/{knowledge-public → knowledge/public}/legal/list.ts +3 -2
- package/src/server/serverFns/{knowledge-public → knowledge/public}/legal/share.ts +1 -1
- package/src/server/serverFns/{knowledge-public → knowledge/public}/legal/update.ts +3 -2
- package/src/server/serverFns/knowledge.ts +135 -19
- package/src/server/serverFns/project/module-overview.ts +1 -2
- package/src/server/serverFns/share/version.ts +7 -1
- package/src/server/serverFns/story/__tests__/bulk.test.ts +6 -0
- package/src/server/serverFns/story/__tests__/create-story-internal.test.ts +112 -3
- package/src/server/serverFns/story/__tests__/story-assoc.test.ts +77 -6
- package/src/server/serverFns/story/__tests__/story-crud-date-key.test.ts +187 -0
- package/src/server/serverFns/story/__tests__/story-gantt-deps.test.ts +163 -0
- package/src/server/serverFns/story/__tests__/story-groups.test.ts +4 -0
- package/src/server/serverFns/story/__tests__/story-list-query.test.ts +20 -0
- package/src/server/serverFns/story/__tests__/story-search.test.ts +5 -0
- package/src/server/serverFns/story/__tests__/story-transfer.test.ts +64 -1
- package/src/server/serverFns/story/index.ts +2 -2
- package/src/server/serverFns/story/story-assoc.ts +44 -14
- package/src/server/serverFns/story/story-crud/archive.ts +52 -28
- package/src/server/serverFns/story/story-crud/bulk.ts +14 -4
- package/src/server/serverFns/story/story-crud/create-story-internal.ts +39 -21
- package/src/server/serverFns/story/story-crud.ts +283 -116
- package/src/server/serverFns/story/story-dependencies.ts +31 -0
- package/src/server/serverFns/story/story-groups.ts +15 -11
- package/src/server/serverFns/story/story-list-query.ts +16 -4
- package/src/server/serverFns/story/story-search.ts +9 -6
- package/src/server/serverFns/story/story-transfer.ts +99 -42
- package/src/server/serverFns/story/story-tree.ts +70 -48
- package/src/server/serverFns/story-display-options.ts +21 -10
- package/src/server/serverFns/story-view.ts +2 -2
- package/src/server/serverFns/tasks/task-crud.ts +264 -0
- package/src/server/serverFns/tasks/task-queries.ts +172 -0
- package/src/server/serverFns/testcase/__tests__/generation.test.ts +3 -0
- package/src/server/serverFns/testcase/__tests__/stats.test.ts +18 -4
- package/src/server/serverFns/testcase/__tests__/testcase-board.test.ts +113 -0
- package/src/server/serverFns/testcase/__tests__/testcase-crud.test.ts +62 -14
- package/src/server/serverFns/testcase/__tests__/testcase-groups.test.ts +32 -10
- package/src/server/serverFns/testcase/bulk.ts +195 -0
- package/src/server/serverFns/testcase/generation.ts +10 -0
- package/src/server/serverFns/testcase/stats.ts +22 -8
- package/src/server/serverFns/testcase/testcase-board.ts +198 -0
- package/src/server/serverFns/testcase/testcase-crud.ts +165 -33
- package/src/server/serverFns/testcase/testcase-display-options.ts +166 -0
- package/src/server/serverFns/testcase/testcase-export.ts +125 -0
- package/src/server/serverFns/testcase/testcase-filter-views.ts +36 -9
- package/src/server/serverFns/testcase/testcase-groups.ts +27 -23
- package/src/server/serverFns/testcase/testcase-reorder.ts +66 -0
- package/src/server/story/__tests__/date-key.test.ts +57 -0
- package/src/server/story/date-key.ts +36 -0
- package/src/server/story/types.ts +3 -7
- package/src/server/testcase/__tests__/list-query.test.ts +269 -0
- package/src/server/testcase/list-query.ts +145 -0
- package/src/server-entry.ts +32 -0
- package/src/shared/bug/__tests__/constants.test.ts +44 -0
- package/src/shared/bug/constants.ts +37 -1
- package/src/start.ts +7 -2
- package/src/styles/globals.css +24 -3
- package/src/types/project.ts +2 -3
- package/vite.shared.ts +110 -3
- package/src/__tests__/tag-badge.test.tsx +0 -41
- package/src/components/bug/__tests__/bug-list-table.test.tsx +0 -289
- package/src/components/bug/bug-list-table.tsx +0 -187
- package/src/components/bug/columns.tsx +0 -193
- package/src/components/bug/filters/add-filter-dropdown.tsx +0 -70
- package/src/components/bug/filters/applied-filters.tsx +0 -214
- package/src/components/bug/filters/filter-condition.tsx +0 -151
- package/src/components/bug/filters/filter-row.tsx +0 -104
- package/src/components/bug/filters/filter-toggle.tsx +0 -61
- package/src/components/bug/filters/filter-types.ts +0 -146
- package/src/components/story/detail/story-detail-toolbar.tsx +0 -382
- package/src/components/story/filters/add-filter-dropdown.tsx +0 -70
- package/src/components/story/filters/applied-filters.tsx +0 -225
- package/src/components/story/filters/filter-condition.tsx +0 -152
- package/src/components/story/filters/filter-row.tsx +0 -116
- package/src/components/story/filters/filter-toggle.tsx +0 -60
- package/src/components/story/filters/filter-types.ts +0 -120
- package/src/components/story/filters/filter-value-input.tsx +0 -182
- package/src/components/story/views/kanban-board.tsx +0 -464
- package/src/components/story/views/kanban-card.tsx +0 -100
- package/src/components/tags/__tests__/tags.test.ts +0 -388
- package/src/components/tags/nav-tag.tsx +0 -121
- package/src/components/tags/pages/tag-settings-page.tsx +0 -32
- package/src/components/tags/pages/tags-index-page.tsx +0 -32
- package/src/components/tags/tag-badge.tsx +0 -56
- package/src/components/tags/tag-color-classes.ts +0 -61
- package/src/components/tags/tag-color-picker.tsx +0 -54
- package/src/components/tags/tag-components-index.ts +0 -23
- package/src/components/tags/tag-manager.tsx +0 -208
- package/src/components/tags/tag-selector.tsx +0 -164
- package/src/components/testcase/__tests__/table-view.test.tsx +0 -328
- package/src/components/testcase/plans/add-to-plan-bar.tsx +0 -64
- package/src/components/testcase/table/columns.tsx +0 -170
- package/src/components/testcase/table/table-view.tsx +0 -152
- package/src/lib/tag-colors.ts +0 -35
- package/src/routes/_nav/tags/$id/route.lazy.tsx +0 -23
- package/src/routes/_nav/tags/$id/route.tsx +0 -20
- package/src/routes/_nav/tags/index.lazy.tsx +0 -23
- package/src/routes/_nav/tags/index.tsx +0 -20
- package/src/routes/_nav/tags/settings/route.lazy.tsx +0 -23
- package/src/routes/_nav/tags/settings/route.tsx +0 -23
- package/src/server/serverFns/tags.ts +0 -138
- /package/src/components/{knowledge-public → knowledge/public}/bookmark-card.tsx +0 -0
- /package/src/components/{knowledge-public → knowledge/public}/category-icon.tsx +0 -0
- /package/src/components/{knowledge-public → knowledge/public}/chip-utils.ts +0 -0
- /package/src/components/{knowledge-public → knowledge/public}/knowledge-card-grid.tsx +0 -0
- /package/src/components/{knowledge-public → knowledge/public}/knowledge-filters.tsx +0 -0
- /package/src/components/{knowledge-public → knowledge/public}/knowledge-search-bar.tsx +0 -0
- /package/src/components/{knowledge-public → knowledge/public}/markdown-editor.tsx +0 -0
- /package/src/components/{knowledge-public → knowledge/public}/markdown-view.tsx +0 -0
- /package/src/components/{knowledge-public → knowledge/public}/tag-filter.tsx +0 -0
- /package/src/components/{story/detail → shared-kit/comments}/emoji-picker.tsx +0 -0
- /package/src/components/story/{schemas/story-create.ts → story-create-schema.ts} +0 -0
- /package/src/server/{knowledge-public → knowledge/public}/canonical-id.ts +0 -0
- /package/src/server/{knowledge-public → knowledge/public}/favicon.ts +0 -0
- /package/src/server/serverFns/{knowledge-public → knowledge/public}/bookmarks/categories.ts +0 -0
- /package/src/server/serverFns/{knowledge-public → knowledge/public}/bookmarks/delete.ts +0 -0
- /package/src/server/serverFns/{knowledge-public → knowledge/public}/bookmarks/favorite.ts +0 -0
- /package/src/server/serverFns/{knowledge-public → knowledge/public}/bookmarks/unshare.ts +0 -0
- /package/src/server/serverFns/{knowledge-public → knowledge/public}/compliance/delete.ts +0 -0
- /package/src/server/serverFns/{knowledge-public → knowledge/public}/compliance/public.ts +0 -0
- /package/src/server/serverFns/{knowledge-public → knowledge/public}/compliance/unshare.ts +0 -0
- /package/src/server/serverFns/{knowledge-public → knowledge/public}/dictionary/delete.ts +0 -0
- /package/src/server/serverFns/{knowledge-public → knowledge/public}/dictionary/unshare.ts +0 -0
- /package/src/server/serverFns/{knowledge-public → knowledge/public}/entity-tags.ts +0 -0
- /package/src/server/serverFns/{knowledge-public → knowledge/public}/favorite.ts +0 -0
- /package/src/server/serverFns/{knowledge-public → knowledge/public}/legal/delete.ts +0 -0
- /package/src/server/serverFns/{knowledge-public → knowledge/public}/legal/public.ts +0 -0
- /package/src/server/serverFns/{knowledge-public → knowledge/public}/legal/unshare.ts +0 -0
|
@@ -0,0 +1,4466 @@
|
|
|
1
|
+
import {
|
|
2
|
+
memo,
|
|
3
|
+
useCallback,
|
|
4
|
+
useEffect,
|
|
5
|
+
useLayoutEffect,
|
|
6
|
+
useMemo,
|
|
7
|
+
useRef,
|
|
8
|
+
useState,
|
|
9
|
+
type CSSProperties,
|
|
10
|
+
type RefObject,
|
|
11
|
+
} from "react"
|
|
12
|
+
import {
|
|
13
|
+
DEFAULT_ROW_ALIGN,
|
|
14
|
+
resolveScheduleMode,
|
|
15
|
+
resolveTimelineLines,
|
|
16
|
+
useGantt,
|
|
17
|
+
useGanttSelector,
|
|
18
|
+
useGanttSettings,
|
|
19
|
+
useGanttViewConfig,
|
|
20
|
+
type GanttColumn,
|
|
21
|
+
} from "@/components/reui/gantt/gantt"
|
|
22
|
+
import { GanttBar } from "@/components/reui/gantt/gantt-bar"
|
|
23
|
+
import {
|
|
24
|
+
cancelActiveGanttGestures,
|
|
25
|
+
markGestureEnd,
|
|
26
|
+
useGanttGestures,
|
|
27
|
+
useGanttGestureTeardown,
|
|
28
|
+
wasRecentDrag,
|
|
29
|
+
} from "@/components/reui/gantt/gantt-dnd"
|
|
30
|
+
import {
|
|
31
|
+
buildDependencyPath,
|
|
32
|
+
getBaselineVariance,
|
|
33
|
+
getDayKey,
|
|
34
|
+
getLaneKey,
|
|
35
|
+
getRangeKey,
|
|
36
|
+
MIN_PACK_SLOT,
|
|
37
|
+
packTimedSegments,
|
|
38
|
+
reorderResources,
|
|
39
|
+
resolveEventBaseline,
|
|
40
|
+
resolveOffDay,
|
|
41
|
+
toZoned,
|
|
42
|
+
zonedStartOfDay,
|
|
43
|
+
type GanttLaneMemo,
|
|
44
|
+
} from "@/components/reui/gantt/gantt-lib"
|
|
45
|
+
import type {
|
|
46
|
+
GanttBaseline,
|
|
47
|
+
GanttBaselineVariance,
|
|
48
|
+
GanttDateRange,
|
|
49
|
+
GanttEvent,
|
|
50
|
+
GanttOccurrence,
|
|
51
|
+
GanttResource,
|
|
52
|
+
GanttResourceReorder,
|
|
53
|
+
GanttSegment,
|
|
54
|
+
} from "@/components/reui/gantt/gantt-types"
|
|
55
|
+
import { useGanttRowWindow } from "@/components/reui/gantt/use-gantt-row-window"
|
|
56
|
+
import { mergeProps } from "@base-ui/react/merge-props"
|
|
57
|
+
// Base UI's ScrollArea re-measures its thumb + overflow on mount, viewport
|
|
58
|
+
// resize and scroll, but NOT on a content-size change unless the content sits
|
|
59
|
+
// in a ScrollArea.Content (which carries the content ResizeObserver). ReUI's
|
|
60
|
+
// ScrollArea puts children straight in the Viewport, so the tree/timeline
|
|
61
|
+
// panes wrap their scroll content in this Content to keep the scrollbar in
|
|
62
|
+
// sync when rows expand/collapse or tree columns show/hide. (Radix observes
|
|
63
|
+
// content automatically, so its twin needs no equivalent.)
|
|
64
|
+
import { ScrollArea as ScrollAreaPrimitive } from "@base-ui/react/scroll-area"
|
|
65
|
+
import { useRender } from "@base-ui/react/use-render"
|
|
66
|
+
import {
|
|
67
|
+
addDays,
|
|
68
|
+
addMinutes,
|
|
69
|
+
addMonths,
|
|
70
|
+
format,
|
|
71
|
+
getWeek,
|
|
72
|
+
startOfMonth,
|
|
73
|
+
startOfQuarter,
|
|
74
|
+
startOfWeek,
|
|
75
|
+
type Locale,
|
|
76
|
+
} from "date-fns"
|
|
77
|
+
|
|
78
|
+
import { cn } from "cn"
|
|
79
|
+
import { Button } from "@/components/ui/button"
|
|
80
|
+
import { Checkbox } from "@/components/ui/checkbox"
|
|
81
|
+
import {
|
|
82
|
+
ContextMenu,
|
|
83
|
+
ContextMenuContent,
|
|
84
|
+
ContextMenuTrigger,
|
|
85
|
+
} from "@/components/ui/context-menu"
|
|
86
|
+
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area"
|
|
87
|
+
import {
|
|
88
|
+
Tooltip,
|
|
89
|
+
TooltipContent,
|
|
90
|
+
TooltipProvider,
|
|
91
|
+
TooltipTrigger,
|
|
92
|
+
} from "@/components/ui/tooltip"
|
|
93
|
+
import { PlusIcon, MinusIcon, GripVerticalIcon, ChevronRightIcon, ChevronLeftIcon } from "lucide-react"
|
|
94
|
+
|
|
95
|
+
/** Current time, refreshed on an interval and on tab focus. */
|
|
96
|
+
function useNow(intervalMs = 30_000): Date {
|
|
97
|
+
const [now, setNow] = useState(() => new Date())
|
|
98
|
+
useEffect(() => {
|
|
99
|
+
const tick = () => setNow(new Date())
|
|
100
|
+
const id = setInterval(tick, intervalMs)
|
|
101
|
+
document.addEventListener("visibilitychange", tick)
|
|
102
|
+
window.addEventListener("focus", tick)
|
|
103
|
+
return () => {
|
|
104
|
+
clearInterval(id)
|
|
105
|
+
document.removeEventListener("visibilitychange", tick)
|
|
106
|
+
window.removeEventListener("focus", tick)
|
|
107
|
+
}
|
|
108
|
+
}, [intervalMs])
|
|
109
|
+
return now
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Today's zoned day key, re-rendering only at the midnight rollover (and on
|
|
114
|
+
* focus/visibility) - the grid needs day granularity, not the 30s now tick.
|
|
115
|
+
*/
|
|
116
|
+
function useTodayKey(timeZone: string): string {
|
|
117
|
+
const [key, setKey] = useState(() => getDayKey(new Date(), timeZone))
|
|
118
|
+
useEffect(() => {
|
|
119
|
+
const tick = () =>
|
|
120
|
+
setKey((prev) => {
|
|
121
|
+
const next = getDayKey(new Date(), timeZone)
|
|
122
|
+
return next === prev ? prev : next
|
|
123
|
+
})
|
|
124
|
+
tick()
|
|
125
|
+
const id = setInterval(tick, 60_000)
|
|
126
|
+
document.addEventListener("visibilitychange", tick)
|
|
127
|
+
window.addEventListener("focus", tick)
|
|
128
|
+
return () => {
|
|
129
|
+
clearInterval(id)
|
|
130
|
+
document.removeEventListener("visibilitychange", tick)
|
|
131
|
+
window.removeEventListener("focus", tick)
|
|
132
|
+
}
|
|
133
|
+
}, [timeZone])
|
|
134
|
+
return key
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Lowest lane free for [startMs, endMs) among a row's segments, padded by
|
|
139
|
+
* MIN_PACK_SLOT exactly as packTimedSegments pads its own occupancy test.
|
|
140
|
+
* Comparing raw instants instead lets a sub-slot bar read as clear, so an
|
|
141
|
+
* affordance would promise a lane the packer then refuses.
|
|
142
|
+
*
|
|
143
|
+
* Shared by the hover hint and the drag placeholder on purpose: two copies of
|
|
144
|
+
* this is how the ring and the range it paints end up on different tracks.
|
|
145
|
+
*/
|
|
146
|
+
function lowestFreeLane(
|
|
147
|
+
segments: GanttSegment[],
|
|
148
|
+
startMs: number,
|
|
149
|
+
endMs: number
|
|
150
|
+
): number {
|
|
151
|
+
const padMs = MIN_PACK_SLOT * 60000
|
|
152
|
+
const to = Math.max(endMs, startMs + padMs)
|
|
153
|
+
const busy = new Set<number>()
|
|
154
|
+
for (const segment of segments) {
|
|
155
|
+
const segStart = segment.occurrence.start.getTime()
|
|
156
|
+
const segEnd = Math.max(segment.occurrence.end.getTime(), segStart + padMs)
|
|
157
|
+
if (segStart < to && segEnd > startMs) busy.add(segment.column ?? 0)
|
|
158
|
+
}
|
|
159
|
+
let lane = 0
|
|
160
|
+
while (busy.has(lane)) lane += 1
|
|
161
|
+
return lane
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Pointer x resolved against the element's TIME axis: the 0..1 fraction and
|
|
166
|
+
* the same measurement in CSS pixels from the axis start. Mirrored in RTL,
|
|
167
|
+
* where the range start renders at the element's right edge. One rect read
|
|
168
|
+
* and one style read, because this runs on every pointer move.
|
|
169
|
+
*/
|
|
170
|
+
function trackPoint(
|
|
171
|
+
el: HTMLElement,
|
|
172
|
+
clientX: number
|
|
173
|
+
): { fraction: number; offset: number } {
|
|
174
|
+
const rect = el.getBoundingClientRect()
|
|
175
|
+
const rtl = getComputedStyle(el).direction === "rtl"
|
|
176
|
+
const offset = rtl ? rect.right - clientX : clientX - rect.left
|
|
177
|
+
// `offset` is exact and drives the time maths. `snapped` is the same value
|
|
178
|
+
// biased so that rect start + snapped lands on a WHOLE viewport pixel: the
|
|
179
|
+
// row's own edge routinely sits on a half pixel, so rounding the offset
|
|
180
|
+
// alone still puts anything placed at it between two pixels.
|
|
181
|
+
const snapped = rtl
|
|
182
|
+
? rect.right - Math.round(clientX)
|
|
183
|
+
: Math.round(clientX) - rect.left
|
|
184
|
+
return {
|
|
185
|
+
fraction: rect.width > 0 ? offset / rect.width : 0,
|
|
186
|
+
offset: snapped,
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Fraction only, for the call sites that do not place anything. */
|
|
191
|
+
function trackFraction(el: HTMLElement, clientX: number): number {
|
|
192
|
+
return trackPoint(el, clientX).fraction
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Row geometry is three numbers: a bar is LANE_HEIGHT_REM tall, stacked bars
|
|
197
|
+
* are separated by LANE_GAP_REM, and the block as a whole is inset from the
|
|
198
|
+
* row's edges by ROW_PADDING_REM. Padding and gap are deliberately NOT the
|
|
199
|
+
* same value - schedules in one node belong together, so they sit tight, while
|
|
200
|
+
* the row still needs real breathing room above and below. Every inter-lane
|
|
201
|
+
* gap is identical, which is what keeps a stacked row reading evenly.
|
|
202
|
+
*/
|
|
203
|
+
const LANE_HEIGHT_REM = 1.25
|
|
204
|
+
const LANE_GAP_REM = 0.1875
|
|
205
|
+
const ROW_PADDING_REM = 0.5
|
|
206
|
+
/** Default drop-indicator height, centered inside its lane band; a consumer
|
|
207
|
+
* overrides it with metrics.ghostHeight (published as --gantt-ghost-height). */
|
|
208
|
+
const GHOST_HEIGHT_REM = 1.25
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* The ONE lane-pitch formula: top of lane `lane` inside a row. Every overlay
|
|
212
|
+
* that rides a lane (bar, baseline ghost, drag ghost, hint ring, slot draft)
|
|
213
|
+
* reads it, so their vertical positions can never drift apart.
|
|
214
|
+
*/
|
|
215
|
+
function laneTopRem(
|
|
216
|
+
laneOffsetRem: number,
|
|
217
|
+
lane: number,
|
|
218
|
+
laneHeightRem: number,
|
|
219
|
+
laneGapRem: number
|
|
220
|
+
): number {
|
|
221
|
+
return laneOffsetRem + lane * (laneHeightRem + laneGapRem)
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Height of a stack of `count` lanes, inter-lane gaps included. */
|
|
225
|
+
function laneBlockRem(
|
|
226
|
+
count: number,
|
|
227
|
+
laneHeightRem: number,
|
|
228
|
+
laneGapRem: number
|
|
229
|
+
): number {
|
|
230
|
+
return count * laneHeightRem + (count - 1) * laneGapRem
|
|
231
|
+
}
|
|
232
|
+
/** Bars narrower than this flip their title outside in barLabel "auto". */
|
|
233
|
+
const AUTO_LABEL_MIN_REM = 7
|
|
234
|
+
const DEFAULT_TREE_PANEL = {
|
|
235
|
+
width: 288,
|
|
236
|
+
minWidth: 180,
|
|
237
|
+
maxWidth: 640,
|
|
238
|
+
resizable: true,
|
|
239
|
+
nameColumnWidth: 208,
|
|
240
|
+
}
|
|
241
|
+
const DEFAULT_COLUMN_WIDTH = 96
|
|
242
|
+
const DEFAULT_ZOOM_RANGE = { min: 0.5, max: 3 }
|
|
243
|
+
/** Timeline pane never shrinks below this so it stays usable on narrow screens. */
|
|
244
|
+
const MIN_TIMELINE_WIDTH = 200
|
|
245
|
+
/** Scroll distance from an edge that triggers infinite-range growth. */
|
|
246
|
+
const INFINITE_EDGE_PX = 160
|
|
247
|
+
|
|
248
|
+
interface TimelineUnit {
|
|
249
|
+
key: string
|
|
250
|
+
label: string
|
|
251
|
+
ms: number
|
|
252
|
+
/** Relative width share; uniform scales use 1 (year: days per month). */
|
|
253
|
+
weight: number
|
|
254
|
+
isToday?: boolean
|
|
255
|
+
isOff?: boolean
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
interface TimelineGroup {
|
|
259
|
+
key: string
|
|
260
|
+
label: string
|
|
261
|
+
span: number
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
interface TimelineRow {
|
|
265
|
+
resource: GanttResource
|
|
266
|
+
parentId: string | null
|
|
267
|
+
depth: number
|
|
268
|
+
isGroup: boolean
|
|
269
|
+
collapsed: boolean
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** One planned window resolved against the visible range, ready to position. */
|
|
273
|
+
interface RowBaseline {
|
|
274
|
+
event: GanttEvent
|
|
275
|
+
baseline: GanttBaseline
|
|
276
|
+
/** Minutes from the range start, clamped to the range like segment minutes. */
|
|
277
|
+
startMin: number
|
|
278
|
+
endMin: number
|
|
279
|
+
/** The lane of the event's own bar, so the ghost sits under it. An orphan
|
|
280
|
+
* (its bar slipped out of the range) takes the lowest lane free for its
|
|
281
|
+
* window instead; the layout pass drops it when the row has none. */
|
|
282
|
+
lane: number
|
|
283
|
+
variance: GanttBaselineVariance
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** The NODE's own planned window, resolved against the visible range. */
|
|
287
|
+
interface RowLevelBaseline {
|
|
288
|
+
baseline: GanttBaseline
|
|
289
|
+
startMin: number
|
|
290
|
+
endMin: number
|
|
291
|
+
/** Latest actual end (subtree included for groups) vs the planned end;
|
|
292
|
+
* null when the node has no events to compare. */
|
|
293
|
+
variance: GanttBaselineVariance | null
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** Per-row packed bars plus the extents the off-screen chips need. */
|
|
297
|
+
interface TimelineRowBars {
|
|
298
|
+
segments: GanttSegment[]
|
|
299
|
+
laneCount: number
|
|
300
|
+
/**
|
|
301
|
+
* Planned windows ghosted behind this row's bars. Derived from EVENTS, not
|
|
302
|
+
* occurrences, so a bar that slipped entirely out of the visible range
|
|
303
|
+
* still shows the plan it missed. Empty when baselineBars is off. Never an
|
|
304
|
+
* input to packing or row height - a baseline changes no geometry.
|
|
305
|
+
*/
|
|
306
|
+
baselines: RowBaseline[]
|
|
307
|
+
/** The node's own planned window band; null without one (or with
|
|
308
|
+
* baselineBars off). Same no-geometry rule as the per-event ghosts. */
|
|
309
|
+
rowBaseline: RowLevelBaseline | null
|
|
310
|
+
/**
|
|
311
|
+
* Lane a drag-create in flight would land on, or null when none is aimed at
|
|
312
|
+
* this row. The row reserves the track, so it grows exactly as it will on
|
|
313
|
+
* commit and the placeholder never has to overlap the bar it is going under.
|
|
314
|
+
* Computed here, once, because BOTH panes size themselves from this object -
|
|
315
|
+
* deriving it a second time in the timeline row is how the two would drift.
|
|
316
|
+
*/
|
|
317
|
+
draftLane: number | null
|
|
318
|
+
/** Mode this row was packed under; the draft overlay must honour it. */
|
|
319
|
+
scheduleMode: "single" | "multiple"
|
|
320
|
+
heightRem: number
|
|
321
|
+
/** Gap above the first bar; equal to every other gap in the row. */
|
|
322
|
+
laneOffsetRem: number
|
|
323
|
+
/**
|
|
324
|
+
* The band the FIRST schedule occupies, its gaps included. The tree cell
|
|
325
|
+
* sizes its label box to exactly this, so the label and the first bar share
|
|
326
|
+
* a centerline however many lanes the node grew.
|
|
327
|
+
*/
|
|
328
|
+
bandRem: number
|
|
329
|
+
/** Envelope of all bars, as track fractions; null when the row is empty. */
|
|
330
|
+
extent: {
|
|
331
|
+
from: number
|
|
332
|
+
to: number
|
|
333
|
+
color?: string
|
|
334
|
+
label: string
|
|
335
|
+
/** First bar start, for the jump-chip tooltip. */
|
|
336
|
+
startMs: number
|
|
337
|
+
} | null
|
|
338
|
+
/**
|
|
339
|
+
* Parent rollup: descendant-bar envelope + duration-weighted progress,
|
|
340
|
+
* present only on group rows without bars of their own.
|
|
341
|
+
*/
|
|
342
|
+
summary: { from: number; to: number; progress: number | null } | null
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
interface TimelineReorderState {
|
|
346
|
+
resourceId: string
|
|
347
|
+
/** Insertion offset (px) within the tree pane. */
|
|
348
|
+
top: number
|
|
349
|
+
valid: boolean
|
|
350
|
+
proposal: GanttResourceReorder | null
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** Shared empty set so rows without dependency data never churn on identity. */
|
|
354
|
+
const EMPTY_ID_SET: ReadonlySet<string> = new Set()
|
|
355
|
+
|
|
356
|
+
/** One finish-to-start arrow, both anchors in track rem coordinates. */
|
|
357
|
+
interface DependencyEdge {
|
|
358
|
+
key: string
|
|
359
|
+
/** Predecessor and dependent event ids, so a gesture can re-anchor its own
|
|
360
|
+
* endpoints live. */
|
|
361
|
+
fromId: string
|
|
362
|
+
toId: string
|
|
363
|
+
fromX: number
|
|
364
|
+
fromY: number
|
|
365
|
+
toX: number
|
|
366
|
+
toY: number
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
interface GanttViewProps extends useRender.ComponentProps<"div"> {
|
|
370
|
+
/** Day-scale unit interval in minutes; defaults to the interval view config. */
|
|
371
|
+
interval?: number
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/** The pane's scrollable viewport (custom ScrollArea or native host). */
|
|
375
|
+
function getPaneViewport(pane: HTMLElement | null): HTMLElement | null {
|
|
376
|
+
return (
|
|
377
|
+
pane?.querySelector<HTMLElement>("[data-slot=scroll-area-viewport]") ?? null
|
|
378
|
+
)
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** Distance scrolled from the inline-start edge (RTL reports negative). */
|
|
382
|
+
function getScrollStart(viewport: HTMLElement): number {
|
|
383
|
+
return Math.abs(viewport.scrollLeft)
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/** Write a distance-from-inline-start back as a signed scrollLeft. */
|
|
387
|
+
function setScrollStart(viewport: HTMLElement, value: number) {
|
|
388
|
+
viewport.scrollLeft =
|
|
389
|
+
getComputedStyle(viewport).direction === "rtl" ? -value : value
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function GanttView({
|
|
393
|
+
className,
|
|
394
|
+
render,
|
|
395
|
+
interval: intervalProp,
|
|
396
|
+
...props
|
|
397
|
+
}: GanttViewProps) {
|
|
398
|
+
const instance = useGantt()
|
|
399
|
+
const settings = useGanttSettings()
|
|
400
|
+
const viewConfig = useGanttViewConfig()
|
|
401
|
+
const range = useGanttSelector<unknown, GanttDateRange>(
|
|
402
|
+
(state) => state.visibleRange,
|
|
403
|
+
{ isEqual: (a, b) => getRangeKey(a) === getRangeKey(b) }
|
|
404
|
+
)
|
|
405
|
+
const occurrences = useGanttSelector<unknown, GanttOccurrence[]>(
|
|
406
|
+
() => instance.api.getOccurrences(),
|
|
407
|
+
{
|
|
408
|
+
calendar: instance,
|
|
409
|
+
isEqual: (a, b) =>
|
|
410
|
+
a.length === b.length &&
|
|
411
|
+
a.every(
|
|
412
|
+
(occ, i) =>
|
|
413
|
+
occ.key === b[i]?.key &&
|
|
414
|
+
occ.start.getTime() === b[i]?.start.getTime() &&
|
|
415
|
+
occ.end.getTime() === b[i]?.end.getTime() &&
|
|
416
|
+
occ.event === b[i]?.event
|
|
417
|
+
),
|
|
418
|
+
}
|
|
419
|
+
)
|
|
420
|
+
|
|
421
|
+
// Tree expand/collapse: controlled (collapsedGroups/onCollapsedGroupsChange)
|
|
422
|
+
// or uncontrolled (defaultCollapsedGroups) - same pattern as selectedRows.
|
|
423
|
+
const [internalCollapsed, setInternalCollapsed] = useState<string[]>(
|
|
424
|
+
() => viewConfig.defaultCollapsedGroups ?? []
|
|
425
|
+
)
|
|
426
|
+
const collapsedIds = viewConfig.collapsedGroups ?? internalCollapsed
|
|
427
|
+
const collapsedGroups = useMemo(() => new Set(collapsedIds), [collapsedIds])
|
|
428
|
+
|
|
429
|
+
const scale = useGanttSelector((state) => state.scale)
|
|
430
|
+
const interval = Math.min(
|
|
431
|
+
Math.max(intervalProp ?? viewConfig.interval, 15),
|
|
432
|
+
240
|
|
433
|
+
)
|
|
434
|
+
// Every layout metric is consumer-overridable; unset keys keep defaults.
|
|
435
|
+
const metrics = viewConfig.metrics
|
|
436
|
+
const laneHeightRem = metrics?.laneHeight ?? LANE_HEIGHT_REM
|
|
437
|
+
const rowPaddingRem = metrics?.rowPadding ?? ROW_PADDING_REM
|
|
438
|
+
const laneGapRem = metrics?.laneGap ?? LANE_GAP_REM
|
|
439
|
+
const minRowRem = metrics?.minRowHeight ?? 2.5
|
|
440
|
+
const ghostHeightRem = metrics?.ghostHeight ?? GHOST_HEIGHT_REM
|
|
441
|
+
const minTimelineWidth = metrics?.minTimelineWidth ?? MIN_TIMELINE_WIDTH
|
|
442
|
+
const infiniteEdgePx = metrics?.infiniteScrollEdge ?? INFINITE_EDGE_PX
|
|
443
|
+
const timeZone = settings.timeZone
|
|
444
|
+
const rangeStartMs = range.start.getTime()
|
|
445
|
+
const rangeEndMs = range.end.getTime()
|
|
446
|
+
const rangeKey = getRangeKey(range)
|
|
447
|
+
const snapMin = scale === "day" ? settings.snapDuration : 24 * 60
|
|
448
|
+
// day-granular time input so the today highlight rolls over at midnight
|
|
449
|
+
// without the whole grid re-rendering on the 30s now tick
|
|
450
|
+
const todayDayKey = useTodayKey(timeZone)
|
|
451
|
+
|
|
452
|
+
const rows = useMemo(() => {
|
|
453
|
+
const result: TimelineRow[] = []
|
|
454
|
+
const walk = (
|
|
455
|
+
resources: GanttResource[],
|
|
456
|
+
depth: number,
|
|
457
|
+
parentId: string | null
|
|
458
|
+
) => {
|
|
459
|
+
for (const resource of resources) {
|
|
460
|
+
const isGroup = !!resource.children?.length
|
|
461
|
+
const collapsed = collapsedGroups.has(resource.id)
|
|
462
|
+
result.push({ resource, parentId, depth, isGroup, collapsed })
|
|
463
|
+
if (isGroup && !collapsed)
|
|
464
|
+
walk(resource.children!, depth + 1, resource.id)
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
walk(settings.resources, 0, null)
|
|
468
|
+
return result
|
|
469
|
+
}, [settings.resources, collapsedGroups])
|
|
470
|
+
|
|
471
|
+
// Header model: bottom row = units, top row = grouping sectors.
|
|
472
|
+
// Weights are proportional to REAL duration (a 23h/25h DST day differs from
|
|
473
|
+
// its siblings), so weight-driven gridlines, ms-fraction bar geometry, and
|
|
474
|
+
// the dnd pointer math all share one coordinate system.
|
|
475
|
+
const { units, groups, unitWidthRem } = useMemo(() => {
|
|
476
|
+
const units: TimelineUnit[] = []
|
|
477
|
+
const groups: TimelineGroup[] = []
|
|
478
|
+
// day-start ms for the today window checks; recomputed when the day key
|
|
479
|
+
// rolls over (this memo depends on todayDayKey)
|
|
480
|
+
const todayStartMs = zonedStartOfDay(new Date(), timeZone).getTime()
|
|
481
|
+
if (scale === "day") {
|
|
482
|
+
const labelFormat =
|
|
483
|
+
interval % 60 === 0 ? settings.i18n.formats.timeGutter : "h:mm"
|
|
484
|
+
// walk whole days: infinite scroll can extend the range past one day
|
|
485
|
+
let dayCursor = zonedStartOfDay(range.start, timeZone)
|
|
486
|
+
while (dayCursor.getTime() < rangeEndMs) {
|
|
487
|
+
const zonedDay = toZoned(dayCursor, timeZone)
|
|
488
|
+
const nextDay = zonedStartOfDay(addDays(zonedDay, 1), timeZone)
|
|
489
|
+
const dayMinutes = (nextDay.getTime() - dayCursor.getTime()) / 60000
|
|
490
|
+
const dayOff = resolveOffDay(
|
|
491
|
+
dayCursor,
|
|
492
|
+
timeZone,
|
|
493
|
+
viewConfig.offDays ?? true
|
|
494
|
+
)
|
|
495
|
+
let span = 0
|
|
496
|
+
for (let m = 0; m < dayMinutes; m += interval) {
|
|
497
|
+
const time = addMinutes(zonedDay, m)
|
|
498
|
+
// a DST day whose minutes don't divide evenly leaves a short
|
|
499
|
+
// final unit; its weight must be its REAL share or bars drift
|
|
500
|
+
const weight = Math.min(interval, dayMinutes - m) / interval
|
|
501
|
+
units.push({
|
|
502
|
+
key: `${getDayKey(dayCursor, timeZone)}-m${m}`,
|
|
503
|
+
label: format(time, labelFormat, { locale: settings.locale }),
|
|
504
|
+
ms: time.getTime(),
|
|
505
|
+
weight,
|
|
506
|
+
isOff: dayOff,
|
|
507
|
+
})
|
|
508
|
+
span += weight
|
|
509
|
+
}
|
|
510
|
+
groups.push({
|
|
511
|
+
key: getDayKey(dayCursor, timeZone),
|
|
512
|
+
label: format(zonedDay, settings.i18n.formats.dayTitle, {
|
|
513
|
+
locale: settings.locale,
|
|
514
|
+
}),
|
|
515
|
+
span,
|
|
516
|
+
})
|
|
517
|
+
dayCursor = nextDay
|
|
518
|
+
}
|
|
519
|
+
return {
|
|
520
|
+
units,
|
|
521
|
+
groups,
|
|
522
|
+
unitWidthRem:
|
|
523
|
+
metrics?.unitWidths?.day ?? Math.max(2.5, 5 * (interval / 60)),
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
if (scale === "quarter") {
|
|
527
|
+
// units are week-aligned weeks (lib aligns the range), groups are months
|
|
528
|
+
let cursor = zonedStartOfDay(range.start, timeZone)
|
|
529
|
+
while (cursor.getTime() < rangeEndMs) {
|
|
530
|
+
const zoned = toZoned(cursor, timeZone)
|
|
531
|
+
const next = zonedStartOfDay(addDays(zoned, 7), timeZone)
|
|
532
|
+
// real week duration / nominal week: 1 except across DST changes
|
|
533
|
+
const weight =
|
|
534
|
+
(next.getTime() - cursor.getTime()) / (7 * 24 * 60 * 60000)
|
|
535
|
+
units.push({
|
|
536
|
+
key: getDayKey(cursor, timeZone),
|
|
537
|
+
label: format(zoned, "MMM d", { locale: settings.locale }),
|
|
538
|
+
ms: cursor.getTime(),
|
|
539
|
+
weight,
|
|
540
|
+
isToday:
|
|
541
|
+
todayStartMs >= cursor.getTime() && todayStartMs < next.getTime(),
|
|
542
|
+
})
|
|
543
|
+
const monthKey = format(zoned, "yyyy-MM")
|
|
544
|
+
const lastGroup = groups[groups.length - 1]
|
|
545
|
+
if (lastGroup && lastGroup.key === monthKey) {
|
|
546
|
+
lastGroup.span += weight
|
|
547
|
+
} else {
|
|
548
|
+
groups.push({
|
|
549
|
+
key: monthKey,
|
|
550
|
+
label: format(zoned, "MMMM", { locale: settings.locale }),
|
|
551
|
+
span: weight,
|
|
552
|
+
})
|
|
553
|
+
}
|
|
554
|
+
cursor = next
|
|
555
|
+
}
|
|
556
|
+
return { units, groups, unitWidthRem: metrics?.unitWidths?.quarter ?? 8 }
|
|
557
|
+
}
|
|
558
|
+
if (scale === "year") {
|
|
559
|
+
// units are calendar months (weight = real duration), groups are quarters
|
|
560
|
+
let cursor: Date = startOfMonth(toZoned(range.start, timeZone))
|
|
561
|
+
while (cursor.getTime() < rangeEndMs) {
|
|
562
|
+
const next = startOfMonth(addMonths(cursor, 1))
|
|
563
|
+
// nominal-day units so a month reads ~30 wide; real ms keeps DST months true
|
|
564
|
+
const weight = (next.getTime() - cursor.getTime()) / (24 * 60 * 60000)
|
|
565
|
+
units.push({
|
|
566
|
+
key: format(cursor, "yyyy-MM"),
|
|
567
|
+
label: format(cursor, "MMM", { locale: settings.locale }),
|
|
568
|
+
ms: cursor.getTime(),
|
|
569
|
+
weight,
|
|
570
|
+
isToday:
|
|
571
|
+
todayStartMs >= cursor.getTime() && todayStartMs < next.getTime(),
|
|
572
|
+
})
|
|
573
|
+
const quarterStart = startOfQuarter(cursor)
|
|
574
|
+
const quarterKey = format(quarterStart, "yyyy-QQQ")
|
|
575
|
+
const lastGroup = groups[groups.length - 1]
|
|
576
|
+
if (lastGroup && lastGroup.key === quarterKey) {
|
|
577
|
+
lastGroup.span += weight
|
|
578
|
+
} else {
|
|
579
|
+
groups.push({
|
|
580
|
+
key: quarterKey,
|
|
581
|
+
label: format(quarterStart, "QQQ yyyy", {
|
|
582
|
+
locale: settings.locale,
|
|
583
|
+
}),
|
|
584
|
+
span: weight,
|
|
585
|
+
})
|
|
586
|
+
}
|
|
587
|
+
cursor = next
|
|
588
|
+
}
|
|
589
|
+
return { units, groups, unitWidthRem: metrics?.unitWidths?.year ?? 10 }
|
|
590
|
+
}
|
|
591
|
+
// week/month: units are days, groups are ISO-ish weeks
|
|
592
|
+
let cursor = zonedStartOfDay(range.start, timeZone)
|
|
593
|
+
while (cursor.getTime() < rangeEndMs) {
|
|
594
|
+
const zoned = toZoned(cursor, timeZone)
|
|
595
|
+
const nextDay = zonedStartOfDay(addDays(zoned, 1), timeZone)
|
|
596
|
+
// real day duration / 24h: 1 except the 23h/25h DST days
|
|
597
|
+
const weight = (nextDay.getTime() - cursor.getTime()) / (24 * 60 * 60000)
|
|
598
|
+
units.push({
|
|
599
|
+
key: getDayKey(cursor, timeZone),
|
|
600
|
+
label: format(zoned, "EEE d", { locale: settings.locale }),
|
|
601
|
+
ms: cursor.getTime(),
|
|
602
|
+
weight,
|
|
603
|
+
isToday: getDayKey(cursor, timeZone) === todayDayKey,
|
|
604
|
+
isOff: resolveOffDay(cursor, timeZone, viewConfig.offDays ?? true),
|
|
605
|
+
})
|
|
606
|
+
// locale supplies firstWeekContainsDate so W-numbers match the locale's
|
|
607
|
+
// week numbering (ISO in de/fr, US-style otherwise); the explicit
|
|
608
|
+
// weekStartsOn keeps the number aligned with the rendered grid
|
|
609
|
+
const weekNumber = getWeek(zoned, {
|
|
610
|
+
locale: settings.locale,
|
|
611
|
+
weekStartsOn: settings.weekStartsOn,
|
|
612
|
+
})
|
|
613
|
+
// key + label from the true week start: a range that begins midweek
|
|
614
|
+
// must not split or mislabel its first group (incl. the Jan 1 week)
|
|
615
|
+
const weekStart = startOfWeek(zoned, {
|
|
616
|
+
weekStartsOn: settings.weekStartsOn,
|
|
617
|
+
})
|
|
618
|
+
const weekKey = `w-${format(weekStart, "yyyy-MM-dd")}`
|
|
619
|
+
const lastGroup = groups[groups.length - 1]
|
|
620
|
+
if (lastGroup && lastGroup.key === weekKey) {
|
|
621
|
+
lastGroup.span += weight
|
|
622
|
+
} else {
|
|
623
|
+
groups.push({
|
|
624
|
+
key: weekKey,
|
|
625
|
+
label: `${settings.i18n.labels.week(weekNumber)} ${format(weekStart, "MMM d", { locale: settings.locale })} - ${format(addDays(weekStart, 6), "d", { locale: settings.locale })}`,
|
|
626
|
+
span: weight,
|
|
627
|
+
})
|
|
628
|
+
}
|
|
629
|
+
cursor = nextDay
|
|
630
|
+
}
|
|
631
|
+
return {
|
|
632
|
+
units,
|
|
633
|
+
groups,
|
|
634
|
+
unitWidthRem: metrics?.unitWidths?.[scale] ?? (scale === "week" ? 10 : 4),
|
|
635
|
+
}
|
|
636
|
+
}, [
|
|
637
|
+
scale,
|
|
638
|
+
interval,
|
|
639
|
+
range.start,
|
|
640
|
+
rangeEndMs,
|
|
641
|
+
timeZone,
|
|
642
|
+
settings.i18n,
|
|
643
|
+
settings.locale,
|
|
644
|
+
settings.weekStartsOn,
|
|
645
|
+
viewConfig.offDays,
|
|
646
|
+
todayDayKey,
|
|
647
|
+
metrics,
|
|
648
|
+
])
|
|
649
|
+
|
|
650
|
+
// Zoom multiplies the minimum unit width; the flex track still fills when
|
|
651
|
+
// the zoomed width is narrower than the pane. Controlled (zoom/onZoomChange)
|
|
652
|
+
// or uncontrolled (defaultZoom) - same pattern as selectedRows.
|
|
653
|
+
const zoomRange = { ...DEFAULT_ZOOM_RANGE, ...viewConfig.zoomRange }
|
|
654
|
+
const clampZoom = (value: number) =>
|
|
655
|
+
Math.min(Math.max(value, zoomRange.min), zoomRange.max)
|
|
656
|
+
const [internalZoom, setInternalZoom] = useState(
|
|
657
|
+
() => viewConfig.defaultZoom ?? 1
|
|
658
|
+
)
|
|
659
|
+
const zoom = clampZoom(viewConfig.zoom ?? internalZoom)
|
|
660
|
+
const setZoomValue = (next: number) => {
|
|
661
|
+
const clamped = clampZoom(next)
|
|
662
|
+
if (viewConfig.zoom === undefined) setInternalZoom(clamped)
|
|
663
|
+
viewConfig.onZoomChange?.(clamped)
|
|
664
|
+
}
|
|
665
|
+
const canZoomIn = zoom < zoomRange.max - 1e-9
|
|
666
|
+
const canZoomOut = zoom > zoomRange.min + 1e-9
|
|
667
|
+
const trackRemWidth = units.length * unitWidthRem * zoom
|
|
668
|
+
const trackWidth = `${trackRemWidth}rem`
|
|
669
|
+
// unequal weights (year months, DST-containing day/week ranges) draw
|
|
670
|
+
// boundaries from weight fractions instead of the uniform gradient
|
|
671
|
+
const uniform = units.every(
|
|
672
|
+
(unit) => Math.abs(unit.weight - units[0].weight) < 1e-9
|
|
673
|
+
)
|
|
674
|
+
const totalWeight = units.reduce((sum, unit) => sum + unit.weight, 0)
|
|
675
|
+
/** Cumulative start/width fractions per unit, for backdrop stripes/lines. */
|
|
676
|
+
const unitFractions = useMemo(() => {
|
|
677
|
+
let acc = 0
|
|
678
|
+
return units.map((unit) => {
|
|
679
|
+
const start = acc / totalWeight
|
|
680
|
+
acc += unit.weight
|
|
681
|
+
return { unit, start, width: unit.weight / totalWeight }
|
|
682
|
+
})
|
|
683
|
+
}, [units, totalWeight])
|
|
684
|
+
/** Snap a track fraction to its unit (hint preview target). */
|
|
685
|
+
const resolveHintStop = useMemo(() => {
|
|
686
|
+
return (
|
|
687
|
+
fraction: number
|
|
688
|
+
): { index: number; center: number; ms: number; endMs: number } | null => {
|
|
689
|
+
for (let i = 0; i < unitFractions.length; i++) {
|
|
690
|
+
const { unit, start, width } = unitFractions[i]
|
|
691
|
+
if (fraction < start + width || i === unitFractions.length - 1) {
|
|
692
|
+
return {
|
|
693
|
+
index: i,
|
|
694
|
+
center: start + width / 2,
|
|
695
|
+
ms: unit.ms,
|
|
696
|
+
endMs: unitFractions[i + 1]?.unit.ms ?? unit.ms,
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
return null
|
|
701
|
+
}
|
|
702
|
+
}, [unitFractions])
|
|
703
|
+
|
|
704
|
+
/** Group boundary fractions; spans are in unit-weight terms everywhere. */
|
|
705
|
+
const groupBoundaries = useMemo(() => {
|
|
706
|
+
const fractions: number[] = []
|
|
707
|
+
let acc = 0
|
|
708
|
+
for (let i = 0; i < groups.length - 1; i++) {
|
|
709
|
+
acc += groups[i].span
|
|
710
|
+
fractions.push(acc / totalWeight)
|
|
711
|
+
}
|
|
712
|
+
return fractions
|
|
713
|
+
}, [groups, totalWeight])
|
|
714
|
+
/** Resource id -> every descendant id, for parent rollups. */
|
|
715
|
+
const descendantIds = useMemo(() => {
|
|
716
|
+
const map = new Map<string, string[]>()
|
|
717
|
+
const walk = (resource: GanttResource): string[] => {
|
|
718
|
+
const ids = (resource.children ?? []).flatMap((child) => [
|
|
719
|
+
child.id,
|
|
720
|
+
...walk(child),
|
|
721
|
+
])
|
|
722
|
+
map.set(resource.id, ids)
|
|
723
|
+
return ids
|
|
724
|
+
}
|
|
725
|
+
settings.resources.forEach(walk)
|
|
726
|
+
return map
|
|
727
|
+
}, [settings.resources])
|
|
728
|
+
|
|
729
|
+
// All events (not just visible occurrences) so parent rollup progress is
|
|
730
|
+
// all-time and matches a consumer's own tree rollup, independent of scroll.
|
|
731
|
+
const allEvents = useGanttSelector<unknown, GanttEvent[]>(
|
|
732
|
+
(state) => state.events
|
|
733
|
+
)
|
|
734
|
+
const subtreeProgress = useMemo(() => {
|
|
735
|
+
// consumer-owned rollup math: hand each group its descendant events
|
|
736
|
+
if (viewConfig.getSummaryProgress) {
|
|
737
|
+
const byResource = new Map<string, GanttEvent[]>()
|
|
738
|
+
for (const ev of allEvents) {
|
|
739
|
+
if (!ev.resourceId) continue
|
|
740
|
+
const list = byResource.get(ev.resourceId)
|
|
741
|
+
if (list) list.push(ev)
|
|
742
|
+
else byResource.set(ev.resourceId, [ev])
|
|
743
|
+
}
|
|
744
|
+
const map = new Map<string, number | null>()
|
|
745
|
+
for (const row of rows) {
|
|
746
|
+
if (!row.isGroup) continue
|
|
747
|
+
const events: GanttEvent[] = []
|
|
748
|
+
for (const id of descendantIds.get(row.resource.id) ?? []) {
|
|
749
|
+
const list = byResource.get(id)
|
|
750
|
+
if (list) events.push(...list)
|
|
751
|
+
}
|
|
752
|
+
map.set(
|
|
753
|
+
row.resource.id,
|
|
754
|
+
viewConfig.getSummaryProgress({ resource: row.resource, events })
|
|
755
|
+
)
|
|
756
|
+
}
|
|
757
|
+
return map
|
|
758
|
+
}
|
|
759
|
+
// default: one pass over events -> per-resource aggregates, then a cheap
|
|
760
|
+
// descendant sum per group; never O(rows x events)
|
|
761
|
+
const perResource = new Map<
|
|
762
|
+
string,
|
|
763
|
+
{ weighted: number; total: number; saw: boolean }
|
|
764
|
+
>()
|
|
765
|
+
for (const ev of allEvents) {
|
|
766
|
+
if (!ev.resourceId) continue
|
|
767
|
+
let agg = perResource.get(ev.resourceId)
|
|
768
|
+
if (!agg) {
|
|
769
|
+
agg = { weighted: 0, total: 0, saw: false }
|
|
770
|
+
perResource.set(ev.resourceId, agg)
|
|
771
|
+
}
|
|
772
|
+
const dur = Math.max(ev.end.getTime() - ev.start.getTime(), 1)
|
|
773
|
+
agg.total += dur
|
|
774
|
+
if (typeof ev.progress === "number") {
|
|
775
|
+
agg.saw = true
|
|
776
|
+
agg.weighted += ev.progress * dur
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
const map = new Map<string, number | null>()
|
|
780
|
+
for (const row of rows) {
|
|
781
|
+
if (!row.isGroup) continue
|
|
782
|
+
let weighted = 0
|
|
783
|
+
let weightTotal = 0
|
|
784
|
+
let saw = false
|
|
785
|
+
for (const id of descendantIds.get(row.resource.id) ?? []) {
|
|
786
|
+
const agg = perResource.get(id)
|
|
787
|
+
if (!agg) continue
|
|
788
|
+
weightTotal += agg.total
|
|
789
|
+
if (agg.saw) {
|
|
790
|
+
saw = true
|
|
791
|
+
weighted += agg.weighted
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
map.set(
|
|
795
|
+
row.resource.id,
|
|
796
|
+
saw && weightTotal > 0
|
|
797
|
+
? Math.min(Math.max(Math.round(weighted / weightTotal), 0), 100)
|
|
798
|
+
: null
|
|
799
|
+
)
|
|
800
|
+
}
|
|
801
|
+
return map
|
|
802
|
+
}, [rows, allEvents, descendantIds, viewConfig.getSummaryProgress])
|
|
803
|
+
|
|
804
|
+
// Lane memory across layout passes, keyed by getLaneKey (event identity,
|
|
805
|
+
// NOT the time-stamped occurrence key). It stores the TIMES alongside the
|
|
806
|
+
// lane so the packer can tell the schedule the user just edited apart from
|
|
807
|
+
// the ones that sat still: untouched schedules keep their lane, the edited
|
|
808
|
+
// one re-seeks. Written during the memo below on purpose: the pass is
|
|
809
|
+
// idempotent - feeding its own output back in produces the same assignment -
|
|
810
|
+
// so a StrictMode double render is a no-op.
|
|
811
|
+
const laneMemory = useRef(new Map<string, GanttLaneMemo>())
|
|
812
|
+
const scheduleMode = viewConfig.scheduleMode
|
|
813
|
+
|
|
814
|
+
// Per-row packed bars, hoisted so the tree and timeline rows share heights
|
|
815
|
+
// A drag-create in flight. Reduced to the three fields the layout needs, and
|
|
816
|
+
// compared by value, so this re-runs only when the SNAPPED range moves - the
|
|
817
|
+
// gesture engine already gates setSlotDraft on exactly that, so it is a
|
|
818
|
+
// handful of recomputes per drag rather than one per frame.
|
|
819
|
+
const draftLayout = useGanttSelector<
|
|
820
|
+
unknown,
|
|
821
|
+
{ resourceId: string; startMs: number; endMs: number } | null
|
|
822
|
+
>(
|
|
823
|
+
(state) => {
|
|
824
|
+
const slotDraft = state.slotDraft
|
|
825
|
+
if (!slotDraft?.resourceId) return null
|
|
826
|
+
return {
|
|
827
|
+
resourceId: slotDraft.resourceId,
|
|
828
|
+
startMs: slotDraft.start.getTime(),
|
|
829
|
+
endMs: slotDraft.end.getTime(),
|
|
830
|
+
}
|
|
831
|
+
},
|
|
832
|
+
{
|
|
833
|
+
isEqual: (a, b) =>
|
|
834
|
+
a === b ||
|
|
835
|
+
(a !== null &&
|
|
836
|
+
b !== null &&
|
|
837
|
+
a.resourceId === b.resourceId &&
|
|
838
|
+
a.startMs === b.startMs &&
|
|
839
|
+
a.endMs === b.endMs),
|
|
840
|
+
}
|
|
841
|
+
)
|
|
842
|
+
|
|
843
|
+
const baseRowBars = useMemo(() => {
|
|
844
|
+
const map = new Map<string, TimelineRowBars>()
|
|
845
|
+
// read the lanes the previous pass settled on, write the ones this pass
|
|
846
|
+
// settles on; rebuilding (not mutating) prunes schedules that are gone
|
|
847
|
+
const previousLanes = laneMemory.current
|
|
848
|
+
const nextLanes = new Map<string, GanttLaneMemo>()
|
|
849
|
+
const totalMin = (rangeEndMs - rangeStartMs) / 60000
|
|
850
|
+
// one pass: occurrences grouped by resource, plus per-resource envelopes
|
|
851
|
+
// for the parent rollups (never O(rows x occurrences))
|
|
852
|
+
const byResource = new Map<string, GanttOccurrence[]>()
|
|
853
|
+
const envelopes = new Map<string, { fromMin: number; toMin: number }>()
|
|
854
|
+
for (const occ of occurrences) {
|
|
855
|
+
const rid = occ.event.resourceId
|
|
856
|
+
if (!rid) continue
|
|
857
|
+
const list = byResource.get(rid)
|
|
858
|
+
if (list) list.push(occ)
|
|
859
|
+
else byResource.set(rid, [occ])
|
|
860
|
+
const fromMin = Math.max((occ.start.getTime() - rangeStartMs) / 60000, 0)
|
|
861
|
+
const toMin = Math.min(
|
|
862
|
+
(occ.end.getTime() - rangeStartMs) / 60000,
|
|
863
|
+
totalMin
|
|
864
|
+
)
|
|
865
|
+
const env = envelopes.get(rid)
|
|
866
|
+
if (!env) {
|
|
867
|
+
envelopes.set(rid, { fromMin, toMin })
|
|
868
|
+
} else {
|
|
869
|
+
env.fromMin = Math.min(env.fromMin, fromMin)
|
|
870
|
+
env.toMin = Math.max(env.toMin, toMin)
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
// Planned windows group by node in a pass of their own, over EVENTS: an
|
|
874
|
+
// event whose actual bar slipped clear of the range has no occurrence
|
|
875
|
+
// above, but its plan should still show where it was missed. The same
|
|
876
|
+
// pass records each node's latest actual end (all-time, like the summary
|
|
877
|
+
// rollup) for the row-baseline variance readout.
|
|
878
|
+
const baselinesByResource = new Map<
|
|
879
|
+
string,
|
|
880
|
+
Array<{ event: GanttEvent; baseline: GanttBaseline }>
|
|
881
|
+
>()
|
|
882
|
+
const latestEndByResource = new Map<string, number>()
|
|
883
|
+
if (viewConfig.baselineBars) {
|
|
884
|
+
for (const event of allEvents) {
|
|
885
|
+
if (!event.resourceId) continue
|
|
886
|
+
const endMsActual = event.end.getTime()
|
|
887
|
+
const prev = latestEndByResource.get(event.resourceId)
|
|
888
|
+
if (prev === undefined || endMsActual > prev) {
|
|
889
|
+
latestEndByResource.set(event.resourceId, endMsActual)
|
|
890
|
+
}
|
|
891
|
+
const baseline = resolveEventBaseline(event)
|
|
892
|
+
if (!baseline) continue
|
|
893
|
+
const startMs = baseline.start.getTime()
|
|
894
|
+
const endMs = baseline.end.getTime()
|
|
895
|
+
// half-open like the bars; the closed compare keeps a planned
|
|
896
|
+
// milestone sitting exactly on a range edge visible
|
|
897
|
+
const visible = baseline.milestone
|
|
898
|
+
? startMs >= rangeStartMs && startMs <= rangeEndMs
|
|
899
|
+
: startMs < rangeEndMs && endMs > rangeStartMs
|
|
900
|
+
if (!visible) continue
|
|
901
|
+
const list = baselinesByResource.get(event.resourceId)
|
|
902
|
+
const entry = { event, baseline }
|
|
903
|
+
if (list) list.push(entry)
|
|
904
|
+
else baselinesByResource.set(event.resourceId, [entry])
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
for (const row of rows) {
|
|
908
|
+
const mine = byResource.get(row.resource.id) ?? []
|
|
909
|
+
const segments: GanttSegment[] = mine.map((occurrence) => ({
|
|
910
|
+
occurrence,
|
|
911
|
+
day: new Date(rangeStartMs),
|
|
912
|
+
isStart: occurrence.start.getTime() >= rangeStartMs,
|
|
913
|
+
isEnd: occurrence.end.getTime() <= rangeEndMs,
|
|
914
|
+
continuesBefore: occurrence.start.getTime() < rangeStartMs,
|
|
915
|
+
continuesAfter: occurrence.end.getTime() > rangeEndMs,
|
|
916
|
+
startMin: Math.max(
|
|
917
|
+
(occurrence.start.getTime() - rangeStartMs) / 60000,
|
|
918
|
+
0
|
|
919
|
+
),
|
|
920
|
+
endMin: Math.min(
|
|
921
|
+
(occurrence.end.getTime() - rangeStartMs) / 60000,
|
|
922
|
+
totalMin
|
|
923
|
+
),
|
|
924
|
+
}))
|
|
925
|
+
const mode = resolveScheduleMode(row.resource, scheduleMode)
|
|
926
|
+
packTimedSegments(segments, { mode, preferredLanes: previousLanes })
|
|
927
|
+
for (const segment of segments) {
|
|
928
|
+
nextLanes.set(getLaneKey(segment.occurrence), {
|
|
929
|
+
lane: segment.column ?? 0,
|
|
930
|
+
startMs: segment.occurrence.start.getTime(),
|
|
931
|
+
endMs: segment.occurrence.end.getTime(),
|
|
932
|
+
})
|
|
933
|
+
}
|
|
934
|
+
const laneCount = segments.reduce(
|
|
935
|
+
(max, segment) => Math.max(max, (segment.column ?? 0) + 1),
|
|
936
|
+
1
|
|
937
|
+
)
|
|
938
|
+
// The ghost rides its bar's settled lane so plan and actual read as one
|
|
939
|
+
// task; pure overlay geometry, never an input to packing or laneCount.
|
|
940
|
+
const plans = baselinesByResource.get(row.resource.id)
|
|
941
|
+
const laneByEvent = plans
|
|
942
|
+
? new Map(
|
|
943
|
+
segments.map((segment) => [
|
|
944
|
+
segment.occurrence.eventId,
|
|
945
|
+
segment.column ?? 0,
|
|
946
|
+
])
|
|
947
|
+
)
|
|
948
|
+
: null
|
|
949
|
+
const baselines: RowBaseline[] = plans
|
|
950
|
+
? plans.flatMap(({ event, baseline }) => {
|
|
951
|
+
// An ORPHAN plan (its bar slipped clear of the range) cannot ride
|
|
952
|
+
// its bar's lane. Lane 0 would pin it under whatever unrelated bar
|
|
953
|
+
// sits there, silently mis-attributing the plan, so it takes the
|
|
954
|
+
// lowest lane that is FREE for its window - and when the row has
|
|
955
|
+
// no free lane, it is omitted: absence beats a false attribution.
|
|
956
|
+
// "single" keeps one shared track, so 0 is honest there.
|
|
957
|
+
let lane = laneByEvent?.get(event.id)
|
|
958
|
+
if (lane === undefined) {
|
|
959
|
+
if (mode === "single" || segments.length === 0) {
|
|
960
|
+
lane = 0
|
|
961
|
+
} else {
|
|
962
|
+
const free = lowestFreeLane(
|
|
963
|
+
segments,
|
|
964
|
+
baseline.start.getTime(),
|
|
965
|
+
baseline.end.getTime()
|
|
966
|
+
)
|
|
967
|
+
if (free >= laneCount) return []
|
|
968
|
+
lane = free
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
return [
|
|
972
|
+
{
|
|
973
|
+
event,
|
|
974
|
+
baseline,
|
|
975
|
+
startMin: Math.max(
|
|
976
|
+
(baseline.start.getTime() - rangeStartMs) / 60000,
|
|
977
|
+
0
|
|
978
|
+
),
|
|
979
|
+
endMin: Math.min(
|
|
980
|
+
(baseline.end.getTime() - rangeStartMs) / 60000,
|
|
981
|
+
totalMin
|
|
982
|
+
),
|
|
983
|
+
lane,
|
|
984
|
+
variance: getBaselineVariance(event) ?? "on-time",
|
|
985
|
+
},
|
|
986
|
+
]
|
|
987
|
+
})
|
|
988
|
+
: []
|
|
989
|
+
let from = Infinity
|
|
990
|
+
let to = -Infinity
|
|
991
|
+
for (const segment of segments) {
|
|
992
|
+
from = Math.min(from, (segment.startMin ?? 0) / totalMin)
|
|
993
|
+
to = Math.max(to, (segment.endMin ?? 0) / totalMin)
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
// Parent rollup from the subtree's bars: envelope clamped to the range,
|
|
997
|
+
// progress weighted by each bar's full duration
|
|
998
|
+
let summary: TimelineRowBars["summary"] = null
|
|
999
|
+
if (row.isGroup && segments.length === 0 && viewConfig.summaryBars) {
|
|
1000
|
+
let sumFrom = Infinity
|
|
1001
|
+
let sumTo = -Infinity
|
|
1002
|
+
for (const id of descendantIds.get(row.resource.id) ?? []) {
|
|
1003
|
+
const env = envelopes.get(id)
|
|
1004
|
+
if (!env) continue
|
|
1005
|
+
sumFrom = Math.min(sumFrom, env.fromMin / totalMin)
|
|
1006
|
+
sumTo = Math.max(sumTo, env.toMin / totalMin)
|
|
1007
|
+
}
|
|
1008
|
+
if (sumTo > sumFrom) {
|
|
1009
|
+
summary = {
|
|
1010
|
+
from: sumFrom,
|
|
1011
|
+
to: sumTo,
|
|
1012
|
+
// all-time completion (matches a consumer's tree rollup), not the
|
|
1013
|
+
// visible-range slice - task progress is independent of scroll
|
|
1014
|
+
progress: subtreeProgress.get(row.resource.id) ?? null,
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
// The node's OWN planned window: a band behind all of its lanes.
|
|
1020
|
+
// Variance reads the latest actual end - the subtree's for a group,
|
|
1021
|
+
// matching the summary rollup's all-time philosophy.
|
|
1022
|
+
let rowBaseline: RowLevelBaseline | null = null
|
|
1023
|
+
if (viewConfig.baselineBars) {
|
|
1024
|
+
const plan = resolveEventBaseline(row.resource)
|
|
1025
|
+
if (plan) {
|
|
1026
|
+
const planStartMs = plan.start.getTime()
|
|
1027
|
+
const planEndMs = plan.end.getTime()
|
|
1028
|
+
const planVisible = plan.milestone
|
|
1029
|
+
? planStartMs >= rangeStartMs && planStartMs <= rangeEndMs
|
|
1030
|
+
: planStartMs < rangeEndMs && planEndMs > rangeStartMs
|
|
1031
|
+
if (planVisible) {
|
|
1032
|
+
const ids = row.isGroup
|
|
1033
|
+
? [row.resource.id, ...(descendantIds.get(row.resource.id) ?? [])]
|
|
1034
|
+
: [row.resource.id]
|
|
1035
|
+
let latest: number | undefined
|
|
1036
|
+
for (const id of ids) {
|
|
1037
|
+
const end = latestEndByResource.get(id)
|
|
1038
|
+
if (end !== undefined && (latest === undefined || end > latest)) {
|
|
1039
|
+
latest = end
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
rowBaseline = {
|
|
1043
|
+
baseline: plan,
|
|
1044
|
+
startMin: Math.max((planStartMs - rangeStartMs) / 60000, 0),
|
|
1045
|
+
endMin: Math.min((planEndMs - rangeStartMs) / 60000, totalMin),
|
|
1046
|
+
variance:
|
|
1047
|
+
latest === undefined
|
|
1048
|
+
? null
|
|
1049
|
+
: latest > planEndMs
|
|
1050
|
+
? "late"
|
|
1051
|
+
: latest < planEndMs
|
|
1052
|
+
? "early"
|
|
1053
|
+
: "on-time",
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
// The stack, then the row's own padding around it. Centering the block
|
|
1060
|
+
// in the resulting height gives an equal inset top and bottom, and it
|
|
1061
|
+
// is also what keeps a lone bar on the tree label's centerline when a
|
|
1062
|
+
// short row is held open by minRowHeight.
|
|
1063
|
+
const blockRem = laneBlockRem(laneCount, laneHeightRem, laneGapRem)
|
|
1064
|
+
const heightRem = Math.max(minRowRem, blockRem + 2 * rowPaddingRem)
|
|
1065
|
+
const laneOffsetRem = (heightRem - blockRem) / 2
|
|
1066
|
+
|
|
1067
|
+
map.set(row.resource.id, {
|
|
1068
|
+
segments,
|
|
1069
|
+
laneCount,
|
|
1070
|
+
baselines,
|
|
1071
|
+
rowBaseline,
|
|
1072
|
+
draftLane: null,
|
|
1073
|
+
scheduleMode: mode,
|
|
1074
|
+
heightRem,
|
|
1075
|
+
laneOffsetRem,
|
|
1076
|
+
bandRem: laneHeightRem + laneOffsetRem * 2,
|
|
1077
|
+
extent:
|
|
1078
|
+
segments.length > 0
|
|
1079
|
+
? {
|
|
1080
|
+
from,
|
|
1081
|
+
to,
|
|
1082
|
+
color: segments[0].occurrence.event.color,
|
|
1083
|
+
label:
|
|
1084
|
+
segments.length === 1
|
|
1085
|
+
? segments[0].occurrence.event.title
|
|
1086
|
+
: settings.i18n.labels.events(segments.length),
|
|
1087
|
+
startMs: Math.min(
|
|
1088
|
+
...segments.map((s) => s.occurrence.start.getTime())
|
|
1089
|
+
),
|
|
1090
|
+
}
|
|
1091
|
+
: summary
|
|
1092
|
+
? {
|
|
1093
|
+
from: summary.from,
|
|
1094
|
+
to: summary.to,
|
|
1095
|
+
label: row.resource.title,
|
|
1096
|
+
startMs:
|
|
1097
|
+
rangeStartMs + summary.from * (rangeEndMs - rangeStartMs),
|
|
1098
|
+
}
|
|
1099
|
+
: null,
|
|
1100
|
+
summary,
|
|
1101
|
+
})
|
|
1102
|
+
}
|
|
1103
|
+
laneMemory.current = nextLanes
|
|
1104
|
+
return map
|
|
1105
|
+
}, [
|
|
1106
|
+
rows,
|
|
1107
|
+
occurrences,
|
|
1108
|
+
allEvents,
|
|
1109
|
+
rangeStartMs,
|
|
1110
|
+
rangeEndMs,
|
|
1111
|
+
settings.i18n,
|
|
1112
|
+
viewConfig.summaryBars,
|
|
1113
|
+
viewConfig.baselineBars,
|
|
1114
|
+
descendantIds,
|
|
1115
|
+
subtreeProgress,
|
|
1116
|
+
laneHeightRem,
|
|
1117
|
+
rowPaddingRem,
|
|
1118
|
+
laneGapRem,
|
|
1119
|
+
minRowRem,
|
|
1120
|
+
scheduleMode,
|
|
1121
|
+
])
|
|
1122
|
+
|
|
1123
|
+
// ----- split panes: width state, splitter drag/keyboard, scroll sync -----
|
|
1124
|
+
const treeConfig = { ...DEFAULT_TREE_PANEL, ...viewConfig.treePanel }
|
|
1125
|
+
const clampTree = (width: number) =>
|
|
1126
|
+
Math.min(Math.max(width, treeConfig.minWidth), treeConfig.maxWidth)
|
|
1127
|
+
const [treeWidth, setTreeWidth] = useState(treeConfig.width)
|
|
1128
|
+
const configuredTreeWidth = clampTree(treeWidth)
|
|
1129
|
+
const columns = viewConfig.columns ?? []
|
|
1130
|
+
|
|
1131
|
+
// "Add task" hint at the foot of the tree, gated by validation
|
|
1132
|
+
/**
|
|
1133
|
+
* Reserve the track a drag-create in flight will land on - as a THIN overlay
|
|
1134
|
+
* over the layout above, never as an input to it. Rebuilding the whole map
|
|
1135
|
+
* per snapped step would hand every row a new `bars` object and defeat
|
|
1136
|
+
* GanttTimelineRow's memo across the entire grid, so only the drafted row's
|
|
1137
|
+
* entry is replaced; every other row keeps its identity and never re-renders.
|
|
1138
|
+
*
|
|
1139
|
+
* `laneOffsetRem` and `bandRem` are deliberately carried over UNCHANGED.
|
|
1140
|
+
* Deriving them from the grown track count re-centres the row, which drags
|
|
1141
|
+
* the settled bars and the tree label box with it - a 2px wobble by default
|
|
1142
|
+
* and, under a consumer `metrics.minRowHeight` big enough to swallow the
|
|
1143
|
+
* growth, an 11.5px jerk that snaps back on release. Growing a row must add
|
|
1144
|
+
* space BELOW what is already there and move nothing.
|
|
1145
|
+
*/
|
|
1146
|
+
const rowBars = useMemo(() => {
|
|
1147
|
+
if (!draftLayout) return baseRowBars
|
|
1148
|
+
const base = baseRowBars.get(draftLayout.resourceId)
|
|
1149
|
+
if (!base) return baseRowBars
|
|
1150
|
+
const next = new Map(baseRowBars)
|
|
1151
|
+
// "single" packs every bar onto lane 0, so that is where the draft goes
|
|
1152
|
+
// too and the row never grows. Recorded rather than left null so the
|
|
1153
|
+
// placeholder's data-lane still names a real destination.
|
|
1154
|
+
if (base.scheduleMode === "single") {
|
|
1155
|
+
next.set(draftLayout.resourceId, { ...base, draftLane: 0 })
|
|
1156
|
+
return next
|
|
1157
|
+
}
|
|
1158
|
+
const draftLane = lowestFreeLane(
|
|
1159
|
+
base.segments,
|
|
1160
|
+
draftLayout.startMs,
|
|
1161
|
+
draftLayout.endMs
|
|
1162
|
+
)
|
|
1163
|
+
const trackCount = Math.max(base.laneCount, draftLane + 1)
|
|
1164
|
+
const draftBlockRem = laneBlockRem(trackCount, laneHeightRem, laneGapRem)
|
|
1165
|
+
next.set(draftLayout.resourceId, {
|
|
1166
|
+
...base,
|
|
1167
|
+
draftLane,
|
|
1168
|
+
heightRem: Math.max(
|
|
1169
|
+
base.heightRem,
|
|
1170
|
+
base.laneOffsetRem * 2 + draftBlockRem
|
|
1171
|
+
),
|
|
1172
|
+
})
|
|
1173
|
+
return next
|
|
1174
|
+
}, [baseRowBars, draftLayout, laneHeightRem, laneGapRem])
|
|
1175
|
+
|
|
1176
|
+
// Dependency arrows: resolve every visible bar's anchors in track
|
|
1177
|
+
// coordinates (x rem from the range start, y rem from the rows-block top).
|
|
1178
|
+
// Row heights come from rowBars, so transient draft growth shifts the
|
|
1179
|
+
// arrows with the rows; endpoints without a visible bar are skipped -
|
|
1180
|
+
// an arrow to a hidden row would point at nothing.
|
|
1181
|
+
// Which events an arrow DEPARTS from (something else lists them as a
|
|
1182
|
+
// predecessor). Feeds label spacing only, and membership is data-driven
|
|
1183
|
+
// rather than render-driven on purpose: a temporarily hidden dependent
|
|
1184
|
+
// still reserves the gap, so the label never jumps when its arrow scrolls
|
|
1185
|
+
// back into range. Stable across gestures - it only changes with events.
|
|
1186
|
+
const dependencySourceIds = useMemo(() => {
|
|
1187
|
+
if (!viewConfig.dependencyLines) return EMPTY_ID_SET
|
|
1188
|
+
let ids: Set<string> | null = null
|
|
1189
|
+
for (const event of allEvents) {
|
|
1190
|
+
if (!event.dependencies?.length) continue
|
|
1191
|
+
for (const id of event.dependencies) {
|
|
1192
|
+
if (!ids) ids = new Set()
|
|
1193
|
+
ids.add(id)
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
1196
|
+
return ids ?? EMPTY_ID_SET
|
|
1197
|
+
}, [allEvents, viewConfig.dependencyLines])
|
|
1198
|
+
|
|
1199
|
+
const dependencyEdges = useMemo(() => {
|
|
1200
|
+
const none = { edges: [] as DependencyEdge[], totalRem: 0 }
|
|
1201
|
+
if (!viewConfig.dependencyLines) return none
|
|
1202
|
+
const totalMin = (rangeEndMs - rangeStartMs) / 60000
|
|
1203
|
+
if (totalMin <= 0) return none
|
|
1204
|
+
type Pending = {
|
|
1205
|
+
id: string
|
|
1206
|
+
dependencies: string[]
|
|
1207
|
+
startX: number
|
|
1208
|
+
y: number
|
|
1209
|
+
}
|
|
1210
|
+
const anchors = new Map<string, { endX: number; y: number }>()
|
|
1211
|
+
const pending: Pending[] = []
|
|
1212
|
+
let topRem = 0
|
|
1213
|
+
for (const row of rows) {
|
|
1214
|
+
const bars = rowBars.get(row.resource.id)
|
|
1215
|
+
if (bars) {
|
|
1216
|
+
for (const segment of bars.segments) {
|
|
1217
|
+
const occ = segment.occurrence
|
|
1218
|
+
// a recurring instance has no single edge to anchor a plan on
|
|
1219
|
+
if (occ.isRecurring) continue
|
|
1220
|
+
const lane = segment.column ?? 0
|
|
1221
|
+
const y =
|
|
1222
|
+
topRem +
|
|
1223
|
+
laneTopRem(bars.laneOffsetRem, lane, laneHeightRem, laneGapRem) +
|
|
1224
|
+
laneHeightRem / 2
|
|
1225
|
+
const startX = ((segment.startMin ?? 0) / totalMin) * trackRemWidth
|
|
1226
|
+
const endX = ((segment.endMin ?? 0) / totalMin) * trackRemWidth
|
|
1227
|
+
anchors.set(occ.eventId, { endX, y })
|
|
1228
|
+
if (occ.event.dependencies?.length) {
|
|
1229
|
+
pending.push({
|
|
1230
|
+
id: occ.eventId,
|
|
1231
|
+
dependencies: occ.event.dependencies,
|
|
1232
|
+
startX,
|
|
1233
|
+
y,
|
|
1234
|
+
})
|
|
1235
|
+
}
|
|
1236
|
+
}
|
|
1237
|
+
}
|
|
1238
|
+
topRem += bars?.heightRem ?? minRowRem
|
|
1239
|
+
}
|
|
1240
|
+
if (pending.length === 0) return none
|
|
1241
|
+
const edges: DependencyEdge[] = []
|
|
1242
|
+
for (const item of pending) {
|
|
1243
|
+
for (const depId of item.dependencies) {
|
|
1244
|
+
if (depId === item.id) continue
|
|
1245
|
+
const from = anchors.get(depId)
|
|
1246
|
+
if (!from) continue
|
|
1247
|
+
edges.push({
|
|
1248
|
+
key: `${depId}->${item.id}`,
|
|
1249
|
+
fromId: depId,
|
|
1250
|
+
toId: item.id,
|
|
1251
|
+
fromX: from.endX,
|
|
1252
|
+
fromY: from.y,
|
|
1253
|
+
toX: item.startX,
|
|
1254
|
+
toY: item.y,
|
|
1255
|
+
})
|
|
1256
|
+
}
|
|
1257
|
+
}
|
|
1258
|
+
return { edges, totalRem: topRem }
|
|
1259
|
+
}, [
|
|
1260
|
+
rows,
|
|
1261
|
+
rowBars,
|
|
1262
|
+
rangeStartMs,
|
|
1263
|
+
rangeEndMs,
|
|
1264
|
+
trackRemWidth,
|
|
1265
|
+
laneHeightRem,
|
|
1266
|
+
laneGapRem,
|
|
1267
|
+
minRowRem,
|
|
1268
|
+
viewConfig.dependencyLines,
|
|
1269
|
+
])
|
|
1270
|
+
|
|
1271
|
+
const showCreateTask =
|
|
1272
|
+
viewConfig.displayCreateTaskHint &&
|
|
1273
|
+
!!settings.onCreateTask &&
|
|
1274
|
+
(settings.canCreateTask?.({ parentId: null }) ?? true)
|
|
1275
|
+
|
|
1276
|
+
// Responsive guard: the timeline must always keep a usable width, so on
|
|
1277
|
+
// narrow containers the tree pane yields down toward its minWidth. Measured
|
|
1278
|
+
// (not media-queried) because the gantt can live in any column. The -1
|
|
1279
|
+
// reserves the splitter hairline so the timeline truly keeps MIN width.
|
|
1280
|
+
const [containerWidth, setContainerWidth] = useState(0)
|
|
1281
|
+
const clampContainer = (width: number, container: number) => {
|
|
1282
|
+
if (container <= 0) return width
|
|
1283
|
+
const ceiling = container - minTimelineWidth - 1
|
|
1284
|
+
// The tree's own minWidth is a PREFERENCE, not a licence to squeeze the
|
|
1285
|
+
// timeline out of existence: cap it by what the container can actually
|
|
1286
|
+
// spare. Without this cap a consumer minWidth wider than the container
|
|
1287
|
+
// wins outright and the timeline collapses below minTimelineWidth with
|
|
1288
|
+
// the splitter already pinned, so the space cannot be dragged back.
|
|
1289
|
+
const floor = Math.min(treeConfig.minWidth, Math.max(ceiling, 0))
|
|
1290
|
+
return Math.max(Math.min(width, ceiling), Math.min(floor, container - 1))
|
|
1291
|
+
}
|
|
1292
|
+
const clampedTreeWidth = clampContainer(configuredTreeWidth, containerWidth)
|
|
1293
|
+
/** Live width while the splitter is dragging; render reads it so a
|
|
1294
|
+
mid-drag re-render can't snap the pane back to stale state. */
|
|
1295
|
+
const liveTreeWidthRef = useRef<number | null>(null)
|
|
1296
|
+
|
|
1297
|
+
// In-flight gestures must never outlive the view (leaked window listeners,
|
|
1298
|
+
// body overlays and the drag cursor), and must never keep running against
|
|
1299
|
+
// geometry they measured before it changed - a gesture snapshots the axis
|
|
1300
|
+
// and row rects once at activation, so any of these invalidates it.
|
|
1301
|
+
// Cancel-and-revert is the safe contract; all of this is a no-op in normal
|
|
1302
|
+
// flows (none of these values can change during an ordinary pointer drag).
|
|
1303
|
+
useGanttGestureTeardown()
|
|
1304
|
+
useEffect(() => {
|
|
1305
|
+
cancelActiveGanttGestures()
|
|
1306
|
+
}, [zoom, scale, rangeKey, clampedTreeWidth, rows.length])
|
|
1307
|
+
|
|
1308
|
+
// Leaf-row checkbox selection: uncontrolled unless selectedRows is passed
|
|
1309
|
+
const [internalSelected, setInternalSelected] = useState<string[]>([])
|
|
1310
|
+
const selectedRows = viewConfig.selectedRows ?? internalSelected
|
|
1311
|
+
const selectedSet = useMemo(() => new Set(selectedRows), [selectedRows])
|
|
1312
|
+
// Latest-value refs so the row handlers keep ONE identity across renders -
|
|
1313
|
+
// the row components are memoized and must not re-render per state change
|
|
1314
|
+
const viewConfigRef = useRef(viewConfig)
|
|
1315
|
+
viewConfigRef.current = viewConfig
|
|
1316
|
+
const selectedRowsRef = useRef(selectedRows)
|
|
1317
|
+
selectedRowsRef.current = selectedRows
|
|
1318
|
+
const toggleRowSelected = useCallback((id: string, checked: boolean) => {
|
|
1319
|
+
const current = selectedRowsRef.current
|
|
1320
|
+
const next = checked
|
|
1321
|
+
? [...current.filter((rowId) => rowId !== id), id]
|
|
1322
|
+
: current.filter((rowId) => rowId !== id)
|
|
1323
|
+
if (viewConfigRef.current.selectedRows === undefined) {
|
|
1324
|
+
setInternalSelected(next)
|
|
1325
|
+
}
|
|
1326
|
+
viewConfigRef.current.onSelectedRowsChange?.(next)
|
|
1327
|
+
}, [])
|
|
1328
|
+
|
|
1329
|
+
const bodyRef = useRef<HTMLDivElement | null>(null)
|
|
1330
|
+
const treePaneRef = useRef<HTMLDivElement | null>(null)
|
|
1331
|
+
const timelinePaneRef = useRef<HTMLDivElement | null>(null)
|
|
1332
|
+
const treeRowsRef = useRef<HTMLDivElement | null>(null)
|
|
1333
|
+
|
|
1334
|
+
// Track the body width so the tree pane can yield on narrow containers.
|
|
1335
|
+
// Layout effect, not effect: the first measure must flush BEFORE the first
|
|
1336
|
+
// paint so a clamped tree width never paints wide for a frame and then
|
|
1337
|
+
// snaps - the panes are laid out final from the very first displayed frame.
|
|
1338
|
+
useLayoutEffect(() => {
|
|
1339
|
+
const body = bodyRef.current
|
|
1340
|
+
if (!body) return
|
|
1341
|
+
const update = () => setContainerWidth(body.clientWidth)
|
|
1342
|
+
update()
|
|
1343
|
+
const observer = new ResizeObserver(update)
|
|
1344
|
+
observer.observe(body)
|
|
1345
|
+
return () => observer.disconnect()
|
|
1346
|
+
}, [])
|
|
1347
|
+
|
|
1348
|
+
const beginSplit = (e: React.PointerEvent) => {
|
|
1349
|
+
if (e.button !== 0) return
|
|
1350
|
+
e.preventDefault()
|
|
1351
|
+
const pointerId = e.pointerId
|
|
1352
|
+
const startX = e.clientX
|
|
1353
|
+
const startWidth = clampedTreeWidth
|
|
1354
|
+
const splitter = e.currentTarget as HTMLElement
|
|
1355
|
+
// in RTL the tree pane sits on the right: pointer deltas invert
|
|
1356
|
+
const dir = getComputedStyle(splitter).direction === "rtl" ? -1 : 1
|
|
1357
|
+
splitter.setAttribute("data-resizing", "")
|
|
1358
|
+
document.body.style.cursor = "col-resize"
|
|
1359
|
+
document.body.style.userSelect = "none"
|
|
1360
|
+
// Live width goes straight to the DOM: a React state write here would
|
|
1361
|
+
// re-render every row and bar per pointermove. State commits on release.
|
|
1362
|
+
// The container clamp applies live too - the timeline must not collapse
|
|
1363
|
+
// below its minimum mid-drag only to snap back on release.
|
|
1364
|
+
let liveWidth = startWidth
|
|
1365
|
+
const onMove = (ev: PointerEvent) => {
|
|
1366
|
+
if (ev.pointerId !== pointerId) return
|
|
1367
|
+
liveWidth = clampContainer(
|
|
1368
|
+
clampTree(startWidth + (ev.clientX - startX) * dir),
|
|
1369
|
+
bodyRef.current?.clientWidth ?? 0
|
|
1370
|
+
)
|
|
1371
|
+
liveTreeWidthRef.current = liveWidth
|
|
1372
|
+
if (treePaneRef.current) {
|
|
1373
|
+
treePaneRef.current.style.width = `${liveWidth}px`
|
|
1374
|
+
}
|
|
1375
|
+
}
|
|
1376
|
+
const finish = (ev?: PointerEvent) => {
|
|
1377
|
+
if (ev && ev.pointerId !== pointerId) return
|
|
1378
|
+
window.removeEventListener("pointermove", onMove)
|
|
1379
|
+
window.removeEventListener("pointerup", finish)
|
|
1380
|
+
window.removeEventListener("pointercancel", finish)
|
|
1381
|
+
splitter.removeAttribute("data-resizing")
|
|
1382
|
+
document.body.style.cursor = ""
|
|
1383
|
+
document.body.style.userSelect = ""
|
|
1384
|
+
liveTreeWidthRef.current = null
|
|
1385
|
+
if (liveWidth !== startWidth) {
|
|
1386
|
+
setTreeWidth(liveWidth)
|
|
1387
|
+
viewConfigRef.current.treePanel?.onWidthChange?.(liveWidth)
|
|
1388
|
+
}
|
|
1389
|
+
}
|
|
1390
|
+
window.addEventListener("pointermove", onMove)
|
|
1391
|
+
window.addEventListener("pointerup", finish)
|
|
1392
|
+
window.addEventListener("pointercancel", finish)
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1395
|
+
// Both panes scroll vertically; whichever moves drives the other.
|
|
1396
|
+
useEffect(() => {
|
|
1397
|
+
const treeViewport = getPaneViewport(treePaneRef.current)
|
|
1398
|
+
const timelineViewport = getPaneViewport(timelinePaneRef.current)
|
|
1399
|
+
if (!treeViewport || !timelineViewport) return
|
|
1400
|
+
const link = (source: HTMLElement, target: HTMLElement) => {
|
|
1401
|
+
// Mirror only when the source's own vertical position changed -
|
|
1402
|
+
// horizontal-only scroll events must not replay a stale scrollTop over
|
|
1403
|
+
// the other pane. Assign only on drift: the mirrored handler then
|
|
1404
|
+
// no-ops, so no loop.
|
|
1405
|
+
let lastTop = source.scrollTop
|
|
1406
|
+
const onScroll = () => {
|
|
1407
|
+
if (source.scrollTop === lastTop) return
|
|
1408
|
+
lastTop = source.scrollTop
|
|
1409
|
+
if (target.scrollTop !== source.scrollTop) {
|
|
1410
|
+
target.scrollTop = source.scrollTop
|
|
1411
|
+
}
|
|
1412
|
+
}
|
|
1413
|
+
source.addEventListener("scroll", onScroll)
|
|
1414
|
+
return () => source.removeEventListener("scroll", onScroll)
|
|
1415
|
+
}
|
|
1416
|
+
const unlinkTree = link(treeViewport, timelineViewport)
|
|
1417
|
+
const unlinkTimeline = link(timelineViewport, treeViewport)
|
|
1418
|
+
let unforward: (() => void) | null = null
|
|
1419
|
+
if (treeViewport.hasAttribute("data-gantt-native-scroll")) {
|
|
1420
|
+
// Native mode: the tree's vertical axis is overflow-hidden (its bar
|
|
1421
|
+
// would duplicate the timeline's), so vertical wheel intent forwards to
|
|
1422
|
+
// the timeline, which mirrors back through the link above. Horizontal
|
|
1423
|
+
// wheel intent stays native for the tree's own columns.
|
|
1424
|
+
const onWheel = (e: WheelEvent) => {
|
|
1425
|
+
// ctrl/cmd (and trackpad pinch, which sets ctrlKey) is the zoom
|
|
1426
|
+
// gesture; scrolling as well would move the rows out from under it
|
|
1427
|
+
if (e.ctrlKey || e.metaKey) return
|
|
1428
|
+
if (Math.abs(e.deltaY) <= Math.abs(e.deltaX)) return
|
|
1429
|
+
const dy = e.deltaMode === 1 ? e.deltaY * 16 : e.deltaY
|
|
1430
|
+
timelineViewport.scrollTop += dy
|
|
1431
|
+
e.preventDefault()
|
|
1432
|
+
}
|
|
1433
|
+
treeViewport.addEventListener("wheel", onWheel, { passive: false })
|
|
1434
|
+
unforward = () => treeViewport.removeEventListener("wheel", onWheel)
|
|
1435
|
+
} else {
|
|
1436
|
+
// Custom scrollbars: both panes are real vertical scrollers. The links
|
|
1437
|
+
// above mirror on the scroll event, which fires only AFTER the source
|
|
1438
|
+
// has already painted - so with compositor momentum (wheel/trackpad) the
|
|
1439
|
+
// active pane runs a frame ahead of the mirror and the two visibly drift
|
|
1440
|
+
// (the flicker). Fix: drive BOTH viewports from one wheel handler so they
|
|
1441
|
+
// move in the same frame, perfectly locked. Horizontal intent stays
|
|
1442
|
+
// native for each pane's own axis; the links still cover scrollbar drags,
|
|
1443
|
+
// keyboard, touch and programmatic scrolls; touch has no wheel events,
|
|
1444
|
+
// so flick-scrolling syncs through the (frame-lagged) link - accepted,
|
|
1445
|
+
// pointer drags are the gantt's primary touch interaction.
|
|
1446
|
+
const onWheel = (e: WheelEvent) => {
|
|
1447
|
+
// ctrl/cmd (and trackpad pinch, which sets ctrlKey) is the zoom
|
|
1448
|
+
// gesture; scrolling as well would move the rows out from under it
|
|
1449
|
+
if (e.ctrlKey || e.metaKey) return
|
|
1450
|
+
if (Math.abs(e.deltaY) <= Math.abs(e.deltaX)) return
|
|
1451
|
+
const max =
|
|
1452
|
+
timelineViewport.scrollHeight - timelineViewport.clientHeight
|
|
1453
|
+
if (max <= 0) return
|
|
1454
|
+
const unit =
|
|
1455
|
+
e.deltaMode === 1
|
|
1456
|
+
? 16
|
|
1457
|
+
: e.deltaMode === 2
|
|
1458
|
+
? timelineViewport.clientHeight
|
|
1459
|
+
: 1
|
|
1460
|
+
const next = Math.max(
|
|
1461
|
+
0,
|
|
1462
|
+
Math.min(max, timelineViewport.scrollTop + e.deltaY * unit)
|
|
1463
|
+
)
|
|
1464
|
+
e.preventDefault()
|
|
1465
|
+
timelineViewport.scrollTop = next
|
|
1466
|
+
treeViewport.scrollTop = next
|
|
1467
|
+
}
|
|
1468
|
+
treeViewport.addEventListener("wheel", onWheel, { passive: false })
|
|
1469
|
+
timelineViewport.addEventListener("wheel", onWheel, { passive: false })
|
|
1470
|
+
unforward = () => {
|
|
1471
|
+
treeViewport.removeEventListener("wheel", onWheel)
|
|
1472
|
+
timelineViewport.removeEventListener("wheel", onWheel)
|
|
1473
|
+
}
|
|
1474
|
+
}
|
|
1475
|
+
return () => {
|
|
1476
|
+
unlinkTree()
|
|
1477
|
+
unlinkTimeline()
|
|
1478
|
+
unforward?.()
|
|
1479
|
+
}
|
|
1480
|
+
}, [scale, viewConfig.scrollbars])
|
|
1481
|
+
|
|
1482
|
+
// Linked row hover: mirror data-hover onto the row's twin in the other pane
|
|
1483
|
+
useEffect(() => {
|
|
1484
|
+
const body = bodyRef.current
|
|
1485
|
+
if (!body) return
|
|
1486
|
+
let current: string | null = null
|
|
1487
|
+
const apply = (id: string | null) => {
|
|
1488
|
+
if (id === current) return
|
|
1489
|
+
if (current) {
|
|
1490
|
+
body
|
|
1491
|
+
.querySelectorAll(`[data-gantt-row-id="${CSS.escape(current)}"]`)
|
|
1492
|
+
.forEach((el) => el.removeAttribute("data-hover"))
|
|
1493
|
+
}
|
|
1494
|
+
if (id) {
|
|
1495
|
+
body
|
|
1496
|
+
.querySelectorAll(`[data-gantt-row-id="${CSS.escape(id)}"]`)
|
|
1497
|
+
.forEach((el) => el.setAttribute("data-hover", ""))
|
|
1498
|
+
}
|
|
1499
|
+
current = id
|
|
1500
|
+
}
|
|
1501
|
+
const onOver = (e: PointerEvent) => {
|
|
1502
|
+
const row = (e.target as HTMLElement | null)?.closest?.(
|
|
1503
|
+
"[data-gantt-row-id]"
|
|
1504
|
+
)
|
|
1505
|
+
apply(row?.getAttribute("data-gantt-row-id") ?? null)
|
|
1506
|
+
}
|
|
1507
|
+
const onLeave = () => apply(null)
|
|
1508
|
+
body.addEventListener("pointerover", onOver)
|
|
1509
|
+
body.addEventListener("pointerleave", onLeave)
|
|
1510
|
+
return () => {
|
|
1511
|
+
body.removeEventListener("pointerover", onOver)
|
|
1512
|
+
body.removeEventListener("pointerleave", onLeave)
|
|
1513
|
+
apply(null)
|
|
1514
|
+
}
|
|
1515
|
+
}, [])
|
|
1516
|
+
|
|
1517
|
+
// Auto-manage the horizontal position for the current anchor until the user
|
|
1518
|
+
// scrolls: pre-buffer one period per side (so infinite scroll never resizes
|
|
1519
|
+
// the scrollbar on the first gesture), then center the target instant.
|
|
1520
|
+
// Idempotent - re-running just re-centers the same instant - so React
|
|
1521
|
+
// StrictMode's double-invoke and range-growth re-renders are both safe.
|
|
1522
|
+
const anchorMs = useGanttSelector((state) => state.date.getTime())
|
|
1523
|
+
// flattened to a primitive so an inline `initialCenter={new Date(...)}`
|
|
1524
|
+
// cannot re-run the centring effect on every render
|
|
1525
|
+
const initialCenter =
|
|
1526
|
+
viewConfig.initialCenter instanceof Date
|
|
1527
|
+
? viewConfig.initialCenter.getTime()
|
|
1528
|
+
: viewConfig.initialCenter
|
|
1529
|
+
const manageRef = useRef({ key: "", buffered: false, userTook: false })
|
|
1530
|
+
useLayoutEffect(() => {
|
|
1531
|
+
const key = `${scale}:${anchorMs}:${viewConfig.scrollbars}`
|
|
1532
|
+
if (manageRef.current.key !== key) {
|
|
1533
|
+
// An anchor change from an extendRange window SLIDE continues the
|
|
1534
|
+
// user's own travel: the guard survives, or the pre-buffer branch
|
|
1535
|
+
// would re-extend and cascade further slides at the window cap.
|
|
1536
|
+
const slideContinuation =
|
|
1537
|
+
manageRef.current.userTook && instance.internals.didAnchorSlide()
|
|
1538
|
+
manageRef.current = slideContinuation
|
|
1539
|
+
? { key, buffered: manageRef.current.buffered, userTook: true }
|
|
1540
|
+
: { key, buffered: false, userTook: false }
|
|
1541
|
+
}
|
|
1542
|
+
if (manageRef.current.userTook) return
|
|
1543
|
+
// Deferred-mount wait: when the viewport is not measurable yet (hidden
|
|
1544
|
+
// tab, display:none ancestor), a ResizeObserver resumes positioning on
|
|
1545
|
+
// the exact frame it gains a size - RO callbacks run in the rendering
|
|
1546
|
+
// steps BEFORE that frame paints, so no uncentered frame is ever shown.
|
|
1547
|
+
// (An rAF retry here would paint the range start first and then snap.)
|
|
1548
|
+
let waiter: ResizeObserver | null = null
|
|
1549
|
+
const run = () => {
|
|
1550
|
+
waiter?.disconnect()
|
|
1551
|
+
waiter = null
|
|
1552
|
+
const viewport = getPaneViewport(timelinePaneRef.current)
|
|
1553
|
+
const axis = viewport?.querySelector<HTMLElement>("[data-gantt-axis]")
|
|
1554
|
+
if (!viewport || !axis) return
|
|
1555
|
+
if (viewport.clientWidth === 0) {
|
|
1556
|
+
waiter = new ResizeObserver(() => {
|
|
1557
|
+
if (viewport.clientWidth > 0) run()
|
|
1558
|
+
})
|
|
1559
|
+
waiter.observe(viewport)
|
|
1560
|
+
return
|
|
1561
|
+
}
|
|
1562
|
+
// pre-buffer once; the re-run after the range grows lands the center
|
|
1563
|
+
if (viewConfig.infiniteScroll && !manageRef.current.buffered) {
|
|
1564
|
+
manageRef.current.buffered = true
|
|
1565
|
+
extendLockRef.current = true
|
|
1566
|
+
instance.internals.extendRange("before")
|
|
1567
|
+
instance.internals.extendRange("after")
|
|
1568
|
+
return
|
|
1569
|
+
}
|
|
1570
|
+
extendLockRef.current = false
|
|
1571
|
+
if (viewport.scrollWidth <= viewport.clientWidth) return
|
|
1572
|
+
// read the clock at run time - the effect must not depend on a
|
|
1573
|
+
// reactive now that re-runs it (and the whole grid) every 30s.
|
|
1574
|
+
// Target now ONLY when the anchor period itself contains it: keying
|
|
1575
|
+
// on the whole (buffered) visible range would re-center prev/next
|
|
1576
|
+
// navigation right back onto today.
|
|
1577
|
+
const active = instance.getState().activeRange
|
|
1578
|
+
let target: number
|
|
1579
|
+
if (typeof initialCenter === "number") {
|
|
1580
|
+
target = initialCenter
|
|
1581
|
+
} else if (initialCenter === "anchor") {
|
|
1582
|
+
target = anchorMs
|
|
1583
|
+
} else {
|
|
1584
|
+
const nowMs = Date.now()
|
|
1585
|
+
target =
|
|
1586
|
+
nowMs >= active.start.getTime() && nowMs < active.end.getTime()
|
|
1587
|
+
? nowMs
|
|
1588
|
+
: anchorMs
|
|
1589
|
+
}
|
|
1590
|
+
const fraction = Math.min(
|
|
1591
|
+
Math.max((target - rangeStartMs) / (rangeEndMs - rangeStartMs), 0),
|
|
1592
|
+
1
|
|
1593
|
+
)
|
|
1594
|
+
setScrollStart(
|
|
1595
|
+
viewport,
|
|
1596
|
+
Math.max(0, fraction * viewport.scrollWidth - viewport.clientWidth / 2)
|
|
1597
|
+
)
|
|
1598
|
+
}
|
|
1599
|
+
run()
|
|
1600
|
+
return () => {
|
|
1601
|
+
waiter?.disconnect()
|
|
1602
|
+
}
|
|
1603
|
+
}, [
|
|
1604
|
+
scale,
|
|
1605
|
+
anchorMs,
|
|
1606
|
+
initialCenter,
|
|
1607
|
+
viewConfig.scrollbars,
|
|
1608
|
+
viewConfig.infiniteScroll,
|
|
1609
|
+
rangeKey,
|
|
1610
|
+
rangeStartMs,
|
|
1611
|
+
rangeEndMs,
|
|
1612
|
+
instance,
|
|
1613
|
+
])
|
|
1614
|
+
|
|
1615
|
+
// ----- infinite scroll: grow the range near an edge, keep the position -----
|
|
1616
|
+
// Restoration is anchored to a TIMESTAMP, not pixel deltas: it survives
|
|
1617
|
+
// growth, window slides, and zoom changes alike.
|
|
1618
|
+
const pendingRestoreRef = useRef<{
|
|
1619
|
+
ms: number
|
|
1620
|
+
align: "start" | "center"
|
|
1621
|
+
/**
|
|
1622
|
+
* Park the anchored instant this many pixels from the viewport's inline
|
|
1623
|
+
* start instead of at the edge or the middle. Wheel zoom needs it so the
|
|
1624
|
+
* instant under the cursor stays under the cursor.
|
|
1625
|
+
*/
|
|
1626
|
+
offsetPx?: number
|
|
1627
|
+
} | null>(null)
|
|
1628
|
+
const extendLockRef = useRef(false)
|
|
1629
|
+
const lastUserScrollRef = useRef(0)
|
|
1630
|
+
/** Fine-grained viewport-center instant, for controlled-zoom anchoring. */
|
|
1631
|
+
const fineCenterRef = useRef<number | null>(null)
|
|
1632
|
+
const lastZoomRef = useRef<number | null>(null)
|
|
1633
|
+
|
|
1634
|
+
// Only user gestures may extend the range - programmatic scrolls (chip
|
|
1635
|
+
// jumps, auto-center, zoom clamping) must never grow it.
|
|
1636
|
+
useEffect(() => {
|
|
1637
|
+
const pane = timelinePaneRef.current
|
|
1638
|
+
if (!pane) return
|
|
1639
|
+
const markIntent = () => {
|
|
1640
|
+
lastUserScrollRef.current = performance.now()
|
|
1641
|
+
}
|
|
1642
|
+
// zooming is not scroll intent - the re-seat it triggers must not be
|
|
1643
|
+
// mistaken for the user reaching an edge and asking to grow the range
|
|
1644
|
+
const markWheelIntent = (e: WheelEvent) => {
|
|
1645
|
+
if (e.ctrlKey || e.metaKey) return
|
|
1646
|
+
markIntent()
|
|
1647
|
+
}
|
|
1648
|
+
// pointerdown counts only where pressing can scroll: the scrollbars,
|
|
1649
|
+
// the pan header, or a native-scroll host - NOT bars, chips, or zoom
|
|
1650
|
+
const onPointerDown = (e: PointerEvent) => {
|
|
1651
|
+
const target = e.target as HTMLElement | null
|
|
1652
|
+
if (
|
|
1653
|
+
target?.closest(
|
|
1654
|
+
"[data-slot=scroll-area-scrollbar], [data-slot=gantt-timeline-header], [data-gantt-native-scroll]"
|
|
1655
|
+
)
|
|
1656
|
+
) {
|
|
1657
|
+
markIntent()
|
|
1658
|
+
}
|
|
1659
|
+
}
|
|
1660
|
+
pane.addEventListener("wheel", markWheelIntent, { passive: true })
|
|
1661
|
+
pane.addEventListener("pointerdown", onPointerDown)
|
|
1662
|
+
pane.addEventListener("touchstart", markIntent, { passive: true })
|
|
1663
|
+
pane.addEventListener("keydown", markIntent)
|
|
1664
|
+
return () => {
|
|
1665
|
+
pane.removeEventListener("wheel", markWheelIntent)
|
|
1666
|
+
pane.removeEventListener("pointerdown", onPointerDown)
|
|
1667
|
+
pane.removeEventListener("touchstart", markIntent)
|
|
1668
|
+
pane.removeEventListener("keydown", markIntent)
|
|
1669
|
+
}
|
|
1670
|
+
}, [])
|
|
1671
|
+
|
|
1672
|
+
useEffect(() => {
|
|
1673
|
+
if (!viewConfig.infiniteScroll) return
|
|
1674
|
+
const viewport = getPaneViewport(timelinePaneRef.current)
|
|
1675
|
+
if (!viewport) return
|
|
1676
|
+
const tryExtend = (direction: "before" | "after") => {
|
|
1677
|
+
// anchor the left edge as an instant, from the LIVE axis range
|
|
1678
|
+
const axis = viewport.querySelector<HTMLElement>("[data-gantt-axis]")
|
|
1679
|
+
const liveStart = Number(axis?.dataset.ganttRangeStart)
|
|
1680
|
+
const liveEnd = Number(axis?.dataset.ganttRangeEnd)
|
|
1681
|
+
if (!axis || Number.isNaN(liveStart) || Number.isNaN(liveEnd)) return
|
|
1682
|
+
extendLockRef.current = true
|
|
1683
|
+
manageRef.current.userTook = true
|
|
1684
|
+
pendingRestoreRef.current = {
|
|
1685
|
+
ms:
|
|
1686
|
+
liveStart +
|
|
1687
|
+
(getScrollStart(viewport) / viewport.scrollWidth) *
|
|
1688
|
+
(liveEnd - liveStart),
|
|
1689
|
+
align: "start",
|
|
1690
|
+
}
|
|
1691
|
+
if (!instance.internals.extendRange(direction)) {
|
|
1692
|
+
pendingRestoreRef.current = null
|
|
1693
|
+
extendLockRef.current = false
|
|
1694
|
+
}
|
|
1695
|
+
}
|
|
1696
|
+
// scrollLeft is signed by direction; all edge math runs on the
|
|
1697
|
+
// distance-from-inline-start so RTL panes behave identically
|
|
1698
|
+
const isRtl = getComputedStyle(viewport).direction === "rtl"
|
|
1699
|
+
const onScroll = () => {
|
|
1700
|
+
if (extendLockRef.current) return
|
|
1701
|
+
if (performance.now() - lastUserScrollRef.current > 1200) return
|
|
1702
|
+
// a track that fits the pane has no scroll gesture to extend from
|
|
1703
|
+
if (viewport.scrollWidth <= viewport.clientWidth + 8) return
|
|
1704
|
+
const fromStart = getScrollStart(viewport)
|
|
1705
|
+
const fromEnd = viewport.scrollWidth - fromStart - viewport.clientWidth
|
|
1706
|
+
const direction =
|
|
1707
|
+
fromStart < infiniteEdgePx
|
|
1708
|
+
? ("before" as const)
|
|
1709
|
+
: fromEnd < infiniteEdgePx
|
|
1710
|
+
? ("after" as const)
|
|
1711
|
+
: null
|
|
1712
|
+
if (!direction) return
|
|
1713
|
+
tryExtend(direction)
|
|
1714
|
+
}
|
|
1715
|
+
// parked exactly on an edge, further wheeling emits no scroll event -
|
|
1716
|
+
// the wheel itself is the growth gesture then
|
|
1717
|
+
const onWheel = (e: WheelEvent) => {
|
|
1718
|
+
// a zoom gesture must never grow the range: the track is rescaling
|
|
1719
|
+
// under the pointer, so an edge reading mid-gesture is meaningless
|
|
1720
|
+
if (e.ctrlKey || e.metaKey) return
|
|
1721
|
+
if (extendLockRef.current || e.deltaX === 0) return
|
|
1722
|
+
if (viewport.scrollWidth <= viewport.clientWidth + 8) return
|
|
1723
|
+
const towardStart = isRtl ? e.deltaX > 0 : e.deltaX < 0
|
|
1724
|
+
if (towardStart && getScrollStart(viewport) <= 0) {
|
|
1725
|
+
tryExtend("before")
|
|
1726
|
+
} else if (
|
|
1727
|
+
!towardStart &&
|
|
1728
|
+
getScrollStart(viewport) + viewport.clientWidth >=
|
|
1729
|
+
viewport.scrollWidth - 1
|
|
1730
|
+
) {
|
|
1731
|
+
tryExtend("after")
|
|
1732
|
+
}
|
|
1733
|
+
}
|
|
1734
|
+
viewport.addEventListener("scroll", onScroll)
|
|
1735
|
+
viewport.addEventListener("wheel", onWheel, { passive: true })
|
|
1736
|
+
return () => {
|
|
1737
|
+
viewport.removeEventListener("scroll", onScroll)
|
|
1738
|
+
viewport.removeEventListener("wheel", onWheel)
|
|
1739
|
+
}
|
|
1740
|
+
}, [
|
|
1741
|
+
instance,
|
|
1742
|
+
viewConfig.infiniteScroll,
|
|
1743
|
+
scale,
|
|
1744
|
+
viewConfig.scrollbars,
|
|
1745
|
+
infiniteEdgePx,
|
|
1746
|
+
])
|
|
1747
|
+
|
|
1748
|
+
// Report the visible-center instant so the nav title names what you are
|
|
1749
|
+
// looking at. Throttled to period boundaries (a coarse key) so scrolling
|
|
1750
|
+
// within a period never re-renders the grid.
|
|
1751
|
+
useEffect(() => {
|
|
1752
|
+
const viewport = getPaneViewport(timelinePaneRef.current)
|
|
1753
|
+
if (!viewport) return
|
|
1754
|
+
const keyFmt =
|
|
1755
|
+
scale === "day"
|
|
1756
|
+
? "yyyy-MM-dd"
|
|
1757
|
+
: scale === "week"
|
|
1758
|
+
? "RRRR-'W'II"
|
|
1759
|
+
: scale === "month"
|
|
1760
|
+
? "yyyy-MM"
|
|
1761
|
+
: scale === "quarter"
|
|
1762
|
+
? "yyyy-qqq"
|
|
1763
|
+
: "yyyy"
|
|
1764
|
+
let raf = 0
|
|
1765
|
+
let lastKey = ""
|
|
1766
|
+
const measure = () => {
|
|
1767
|
+
raf = 0
|
|
1768
|
+
const axis = viewport.querySelector<HTMLElement>("[data-gantt-axis]")
|
|
1769
|
+
const liveStart = Number(axis?.dataset.ganttRangeStart)
|
|
1770
|
+
const liveEnd = Number(axis?.dataset.ganttRangeEnd)
|
|
1771
|
+
if (!axis || Number.isNaN(liveStart) || Number.isNaN(liveEnd)) return
|
|
1772
|
+
const fraction =
|
|
1773
|
+
(getScrollStart(viewport) + viewport.clientWidth / 2) /
|
|
1774
|
+
Math.max(1, viewport.scrollWidth)
|
|
1775
|
+
const centerMs = liveStart + fraction * (liveEnd - liveStart)
|
|
1776
|
+
// fine center first (controlled-zoom anchor), then the coarse-keyed
|
|
1777
|
+
// store report that drives the nav title
|
|
1778
|
+
fineCenterRef.current = centerMs
|
|
1779
|
+
const center = new Date(centerMs)
|
|
1780
|
+
const key = format(toZoned(center, timeZone), keyFmt)
|
|
1781
|
+
if (key === lastKey) return
|
|
1782
|
+
lastKey = key
|
|
1783
|
+
instance.internals.setViewportCenter(center)
|
|
1784
|
+
}
|
|
1785
|
+
const schedule = () => {
|
|
1786
|
+
if (!raf) raf = requestAnimationFrame(measure)
|
|
1787
|
+
}
|
|
1788
|
+
viewport.addEventListener("scroll", schedule)
|
|
1789
|
+
schedule()
|
|
1790
|
+
return () => {
|
|
1791
|
+
viewport.removeEventListener("scroll", schedule)
|
|
1792
|
+
if (raf) cancelAnimationFrame(raf)
|
|
1793
|
+
}
|
|
1794
|
+
}, [instance, scale, timeZone, viewConfig.scrollbars, rangeKey])
|
|
1795
|
+
|
|
1796
|
+
// Re-seat the viewport on its anchored instant before paint. Runs for range
|
|
1797
|
+
// growth, window slides, and zoom changes; a slide moves the anchor date,
|
|
1798
|
+
// so pre-mark auto-centering as done for the new key.
|
|
1799
|
+
useLayoutEffect(() => {
|
|
1800
|
+
// Consumer-driven (controlled) zoom changes carry no anchorZoomCenter
|
|
1801
|
+
// call; anchor them to the last known viewport center so the view does
|
|
1802
|
+
// not drift. Built-in buttons set pendingRestore first and win.
|
|
1803
|
+
if (
|
|
1804
|
+
lastZoomRef.current !== null &&
|
|
1805
|
+
lastZoomRef.current !== zoom &&
|
|
1806
|
+
!pendingRestoreRef.current &&
|
|
1807
|
+
fineCenterRef.current !== null
|
|
1808
|
+
) {
|
|
1809
|
+
pendingRestoreRef.current = { ms: fineCenterRef.current, align: "center" }
|
|
1810
|
+
}
|
|
1811
|
+
lastZoomRef.current = zoom
|
|
1812
|
+
let raf: number | null = null
|
|
1813
|
+
let attempts = 0
|
|
1814
|
+
const seat = () => {
|
|
1815
|
+
raf = null
|
|
1816
|
+
const viewport = getPaneViewport(timelinePaneRef.current)
|
|
1817
|
+
if (viewport && pendingRestoreRef.current) {
|
|
1818
|
+
// Not laid out yet (0-width on first mount): centering with a 0 offset
|
|
1819
|
+
// parks the view a half-pane off. Defer until the pane is measured so
|
|
1820
|
+
// "center" lands the anchored instant in the middle on initial load.
|
|
1821
|
+
if (viewport.clientWidth === 0 && attempts++ < 20) {
|
|
1822
|
+
raf = requestAnimationFrame(seat)
|
|
1823
|
+
return
|
|
1824
|
+
}
|
|
1825
|
+
const { ms, align, offsetPx } = pendingRestoreRef.current
|
|
1826
|
+
pendingRestoreRef.current = null
|
|
1827
|
+
// clamp: a stale anchor (e.g. an ignored controlled-zoom proposal)
|
|
1828
|
+
// must never park the view outside the track
|
|
1829
|
+
const fraction = Math.min(
|
|
1830
|
+
Math.max((ms - rangeStartMs) / (rangeEndMs - rangeStartMs), 0),
|
|
1831
|
+
1
|
|
1832
|
+
)
|
|
1833
|
+
const offset =
|
|
1834
|
+
offsetPx ?? (align === "center" ? viewport.clientWidth / 2 : 0)
|
|
1835
|
+
setScrollStart(
|
|
1836
|
+
viewport,
|
|
1837
|
+
Math.max(0, fraction * viewport.scrollWidth - offset)
|
|
1838
|
+
)
|
|
1839
|
+
}
|
|
1840
|
+
extendLockRef.current = false
|
|
1841
|
+
}
|
|
1842
|
+
seat()
|
|
1843
|
+
return () => {
|
|
1844
|
+
if (raf !== null) cancelAnimationFrame(raf)
|
|
1845
|
+
}
|
|
1846
|
+
}, [
|
|
1847
|
+
rangeKey,
|
|
1848
|
+
zoom,
|
|
1849
|
+
rangeStartMs,
|
|
1850
|
+
rangeEndMs,
|
|
1851
|
+
scale,
|
|
1852
|
+
viewConfig.scrollbars,
|
|
1853
|
+
instance,
|
|
1854
|
+
])
|
|
1855
|
+
|
|
1856
|
+
/** Keep the view centered on the same instant across a zoom step. */
|
|
1857
|
+
const anchorZoomCenter = () => {
|
|
1858
|
+
const viewport = getPaneViewport(timelinePaneRef.current)
|
|
1859
|
+
const axis = viewport?.querySelector<HTMLElement>("[data-gantt-axis]")
|
|
1860
|
+
if (!viewport || !axis) return
|
|
1861
|
+
const liveStart = Number(axis.dataset.ganttRangeStart)
|
|
1862
|
+
const liveEnd = Number(axis.dataset.ganttRangeEnd)
|
|
1863
|
+
if (Number.isNaN(liveStart) || Number.isNaN(liveEnd)) return
|
|
1864
|
+
pendingRestoreRef.current = {
|
|
1865
|
+
ms:
|
|
1866
|
+
liveStart +
|
|
1867
|
+
((getScrollStart(viewport) + viewport.clientWidth / 2) /
|
|
1868
|
+
viewport.scrollWidth) *
|
|
1869
|
+
(liveEnd - liveStart),
|
|
1870
|
+
align: "center",
|
|
1871
|
+
}
|
|
1872
|
+
}
|
|
1873
|
+
|
|
1874
|
+
/**
|
|
1875
|
+
* Keep the instant under the POINTER pinned across a zoom step. The buttons
|
|
1876
|
+
* anchor the viewport center, but a wheel or pinch gesture points at
|
|
1877
|
+
* something - zooming away from it reads as the content sliding out from
|
|
1878
|
+
* under the cursor.
|
|
1879
|
+
*/
|
|
1880
|
+
const anchorZoomPointer = (clientX: number) => {
|
|
1881
|
+
const viewport = getPaneViewport(timelinePaneRef.current)
|
|
1882
|
+
const axis = viewport?.querySelector<HTMLElement>("[data-gantt-axis]")
|
|
1883
|
+
if (!viewport || !axis) return
|
|
1884
|
+
const liveStart = Number(axis.dataset.ganttRangeStart)
|
|
1885
|
+
const liveEnd = Number(axis.dataset.ganttRangeEnd)
|
|
1886
|
+
if (Number.isNaN(liveStart) || Number.isNaN(liveEnd)) return
|
|
1887
|
+
// trackPoint mirrors in RTL, where the range start is the right edge
|
|
1888
|
+
const offsetPx = trackPoint(viewport, clientX).offset
|
|
1889
|
+
pendingRestoreRef.current = {
|
|
1890
|
+
ms:
|
|
1891
|
+
liveStart +
|
|
1892
|
+
((getScrollStart(viewport) + offsetPx) / viewport.scrollWidth) *
|
|
1893
|
+
(liveEnd - liveStart),
|
|
1894
|
+
align: "start",
|
|
1895
|
+
offsetPx,
|
|
1896
|
+
}
|
|
1897
|
+
}
|
|
1898
|
+
|
|
1899
|
+
// ----- ctrl/cmd + wheel (and trackpad pinch) zooms the time range -----
|
|
1900
|
+
// The listener is manual and non-passive because it must preventDefault:
|
|
1901
|
+
// React's synthetic wheel handler cannot. It attaches once and reads the
|
|
1902
|
+
// live logic through a ref, so a zoom step never re-binds mid-gesture.
|
|
1903
|
+
const wheelZoomRef = useRef<((e: WheelEvent) => void) | null>(null)
|
|
1904
|
+
useEffect(() => {
|
|
1905
|
+
wheelZoomRef.current = (e: WheelEvent) => {
|
|
1906
|
+
if (!viewConfig.wheelZoom) return
|
|
1907
|
+
// Browsers deliver a trackpad pinch as wheel + ctrlKey on every
|
|
1908
|
+
// platform; metaKey is the Mac keyboard idiom the same gesture implies.
|
|
1909
|
+
if (!e.ctrlKey && !e.metaKey) return
|
|
1910
|
+
const lines = e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? 400 : 1
|
|
1911
|
+
// Continuous, not stepped: a pinch emits dozens of small deltas per
|
|
1912
|
+
// second, so the button's 0.25 step would slam to a limit instantly.
|
|
1913
|
+
// Exponential keeps each notch proportional at any zoom level.
|
|
1914
|
+
const next = clampZoom(zoom * Math.exp(-e.deltaY * lines * 0.002))
|
|
1915
|
+
// Already clamped: hand the gesture back so the browser's own page
|
|
1916
|
+
// zoom still works for anyone who relies on it.
|
|
1917
|
+
if (Math.abs(next - zoom) < 1e-4) return
|
|
1918
|
+
e.preventDefault()
|
|
1919
|
+
// Controlled zoom anchors via fineCenterRef when the parent adopts;
|
|
1920
|
+
// pre-setting an anchor would leak stale if the parent ignores it.
|
|
1921
|
+
if (viewConfig.zoom === undefined) anchorZoomPointer(e.clientX)
|
|
1922
|
+
setZoomValue(+next.toFixed(4))
|
|
1923
|
+
}
|
|
1924
|
+
})
|
|
1925
|
+
useEffect(() => {
|
|
1926
|
+
const viewport = getPaneViewport(timelinePaneRef.current)
|
|
1927
|
+
if (!viewport) return
|
|
1928
|
+
const onWheel = (e: WheelEvent) => wheelZoomRef.current?.(e)
|
|
1929
|
+
viewport.addEventListener("wheel", onWheel, { passive: false })
|
|
1930
|
+
return () => viewport.removeEventListener("wheel", onWheel)
|
|
1931
|
+
}, [viewConfig.scrollbars, scale])
|
|
1932
|
+
|
|
1933
|
+
// Drag-to-pan from the header (intent-based: activates after 4px)
|
|
1934
|
+
const beginHeaderPan = (e: React.PointerEvent) => {
|
|
1935
|
+
if (e.button !== 0) return
|
|
1936
|
+
const viewport = getPaneViewport(timelinePaneRef.current)
|
|
1937
|
+
if (!viewport) return
|
|
1938
|
+
const pointerId = e.pointerId
|
|
1939
|
+
const startX = e.clientX
|
|
1940
|
+
const startLeft = viewport.scrollLeft
|
|
1941
|
+
let active = false
|
|
1942
|
+
const onMove = (ev: PointerEvent) => {
|
|
1943
|
+
if (ev.pointerId !== pointerId) return
|
|
1944
|
+
const dx = ev.clientX - startX
|
|
1945
|
+
if (!active && Math.abs(dx) < 4) return
|
|
1946
|
+
if (!active) {
|
|
1947
|
+
markGestureEnd()
|
|
1948
|
+
setIsPanning(true)
|
|
1949
|
+
}
|
|
1950
|
+
active = true
|
|
1951
|
+
// panning is a user scroll: keep the infinite-scroll gate open
|
|
1952
|
+
lastUserScrollRef.current = performance.now()
|
|
1953
|
+
document.body.style.cursor = "grabbing"
|
|
1954
|
+
document.body.style.userSelect = "none"
|
|
1955
|
+
viewport.scrollLeft = startLeft - dx
|
|
1956
|
+
}
|
|
1957
|
+
const finish = (ev?: PointerEvent) => {
|
|
1958
|
+
if (ev && ev.pointerId !== pointerId) return
|
|
1959
|
+
window.removeEventListener("pointermove", onMove)
|
|
1960
|
+
window.removeEventListener("pointerup", finish)
|
|
1961
|
+
window.removeEventListener("pointercancel", finish)
|
|
1962
|
+
document.body.style.cursor = ""
|
|
1963
|
+
document.body.style.userSelect = ""
|
|
1964
|
+
if (active) setIsPanning(false)
|
|
1965
|
+
}
|
|
1966
|
+
window.addEventListener("pointermove", onMove)
|
|
1967
|
+
window.addEventListener("pointerup", finish)
|
|
1968
|
+
window.addEventListener("pointercancel", finish)
|
|
1969
|
+
}
|
|
1970
|
+
|
|
1971
|
+
// Panning suppresses the placement hints (scroll intent, not create intent)
|
|
1972
|
+
const [isPanning, setIsPanning] = useState(false)
|
|
1973
|
+
|
|
1974
|
+
// ----- tree-row drag reorder (mirrors the event engine: live validity,
|
|
1975
|
+
// destructive styling when invalid, Esc cancel, commit via callback) -----
|
|
1976
|
+
const [reorder, setReorder] = useState<TimelineReorderState | null>(null)
|
|
1977
|
+
const reorderEnabled = !!settings.onResourceReorder
|
|
1978
|
+
const settingsRef = useRef(settings)
|
|
1979
|
+
settingsRef.current = settings
|
|
1980
|
+
const rowsRef = useRef(rows)
|
|
1981
|
+
rowsRef.current = rows
|
|
1982
|
+
|
|
1983
|
+
const beginRowReorder = useCallback(
|
|
1984
|
+
(e: React.PointerEvent, dragRow: TimelineRow) => {
|
|
1985
|
+
const settings = settingsRef.current
|
|
1986
|
+
const rows = rowsRef.current
|
|
1987
|
+
if (e.button !== 0 || !settings.onResourceReorder) return
|
|
1988
|
+
e.preventDefault()
|
|
1989
|
+
e.stopPropagation()
|
|
1990
|
+
const container = treeRowsRef.current
|
|
1991
|
+
const pane = treePaneRef.current
|
|
1992
|
+
if (!container || !pane) return
|
|
1993
|
+
const rowEls = Array.from(
|
|
1994
|
+
container.querySelectorAll<HTMLElement>("[data-slot=gantt-row-group]")
|
|
1995
|
+
)
|
|
1996
|
+
document.body.style.cursor = "grabbing"
|
|
1997
|
+
document.body.style.userSelect = "none"
|
|
1998
|
+
let current: TimelineReorderState | null = null
|
|
1999
|
+
let lastBoundary = -1
|
|
2000
|
+
|
|
2001
|
+
// Carry overlay: clone the WHOLE row (name + detail columns), flat -
|
|
2002
|
+
// it reads as the row itself moving, not a separate card
|
|
2003
|
+
const pointerId = e.pointerId
|
|
2004
|
+
const rowEl = (e.currentTarget as HTMLElement).closest<HTMLElement>(
|
|
2005
|
+
"[data-slot=gantt-row-group]"
|
|
2006
|
+
)
|
|
2007
|
+
const overlay = document.createElement("div")
|
|
2008
|
+
overlay.setAttribute("data-slot", "gantt-drag-overlay")
|
|
2009
|
+
overlay.className =
|
|
2010
|
+
"bg-background pointer-events-none fixed overflow-hidden opacity-95"
|
|
2011
|
+
overlay.style.zIndex = "100"
|
|
2012
|
+
// body-appended, so it is outside the gantt root that owns the type
|
|
2013
|
+
// scale: without adopting the root's resolved metrics the carried row
|
|
2014
|
+
// renders at the document default and reads bigger than the row it left
|
|
2015
|
+
const ganttRoot = pane.closest<HTMLElement>("[data-slot=gantt]")
|
|
2016
|
+
if (ganttRoot) {
|
|
2017
|
+
const rootStyle = getComputedStyle(ganttRoot)
|
|
2018
|
+
overlay.style.fontSize = rootStyle.fontSize
|
|
2019
|
+
overlay.style.lineHeight = rootStyle.lineHeight
|
|
2020
|
+
overlay.style.fontFamily = rootStyle.fontFamily
|
|
2021
|
+
overlay.style.letterSpacing = rootStyle.letterSpacing
|
|
2022
|
+
overlay.style.direction = rootStyle.direction
|
|
2023
|
+
}
|
|
2024
|
+
if (rowEl) {
|
|
2025
|
+
const rowRect = rowEl.getBoundingClientRect()
|
|
2026
|
+
overlay.style.width = `${rowRect.width}px`
|
|
2027
|
+
overlay.style.height = `${rowRect.height}px`
|
|
2028
|
+
const clone = rowEl.cloneNode(true) as HTMLElement
|
|
2029
|
+
clone.removeAttribute("data-gantt-row-id")
|
|
2030
|
+
clone.classList.remove("border-b")
|
|
2031
|
+
clone.style.height = "100%"
|
|
2032
|
+
overlay.appendChild(clone)
|
|
2033
|
+
}
|
|
2034
|
+
document.body.appendChild(overlay)
|
|
2035
|
+
const grabRect = rowEl?.getBoundingClientRect()
|
|
2036
|
+
const grabDY = grabRect ? e.clientY - grabRect.top : 8
|
|
2037
|
+
// vertical-only carry, locked to the tree panel like a list row drag
|
|
2038
|
+
const lockedX = grabRect?.left ?? pane.getBoundingClientRect().left
|
|
2039
|
+
const paneRect = pane.getBoundingClientRect()
|
|
2040
|
+
const rowH = grabRect?.height ?? 40
|
|
2041
|
+
// Rows do not move during the gesture (the carry is a fixed overlay), so
|
|
2042
|
+
// rects are measured ONCE - per-move full-row rect scans forced a
|
|
2043
|
+
// synchronous reflow after every overlay style write.
|
|
2044
|
+
const rects = rowEls.map((el) => el.getBoundingClientRect())
|
|
2045
|
+
// [allblue-fork] 行窗口化后 rowEls/rects 只覆盖已渲染行(全量 rows 的一个
|
|
2046
|
+
// 切片),onMove 给出的 boundary 是切片内局部下标:propose 前按首行
|
|
2047
|
+
// row-id 解析窗口起点对齐全量下标——否则目标行整体偏移 startIdx,
|
|
2048
|
+
// 指示器画在窗口内、提交的却是偏移前的行(静默错误数据操作)。
|
|
2049
|
+
const windowStart =
|
|
2050
|
+
rowEls.length > 0
|
|
2051
|
+
? Math.max(
|
|
2052
|
+
0,
|
|
2053
|
+
rows.findIndex(
|
|
2054
|
+
(r) => r.resource.id === rowEls[0]?.dataset.ganttRowId
|
|
2055
|
+
)
|
|
2056
|
+
)
|
|
2057
|
+
: 0
|
|
2058
|
+
const place = (y: number) => {
|
|
2059
|
+
const top = Math.min(
|
|
2060
|
+
Math.max(y - grabDY, paneRect.top),
|
|
2061
|
+
paneRect.bottom - rowH
|
|
2062
|
+
)
|
|
2063
|
+
overlay.style.left = `${lockedX}px`
|
|
2064
|
+
overlay.style.top = `${top}px`
|
|
2065
|
+
}
|
|
2066
|
+
place(e.clientY)
|
|
2067
|
+
|
|
2068
|
+
const propose = (boundary: number): TimelineReorderState => {
|
|
2069
|
+
// [allblue-fork] 局部 boundary → 全量下标(未窗口化时 windowStart=0,行为不变)
|
|
2070
|
+
const absoluteBoundary = Math.min(windowStart + boundary, rows.length)
|
|
2071
|
+
const below = rows[absoluteBoundary]
|
|
2072
|
+
const parentId =
|
|
2073
|
+
below?.parentId ?? rows[rows.length - 1]?.parentId ?? null
|
|
2074
|
+
let index = 0
|
|
2075
|
+
for (let i = 0; i < absoluteBoundary; i++) {
|
|
2076
|
+
if (
|
|
2077
|
+
rows[i].parentId === parentId &&
|
|
2078
|
+
rows[i].resource.id !== dragRow.resource.id
|
|
2079
|
+
) {
|
|
2080
|
+
index++
|
|
2081
|
+
}
|
|
2082
|
+
}
|
|
2083
|
+
const next = reorderResources(
|
|
2084
|
+
settings.resources,
|
|
2085
|
+
dragRow.resource.id,
|
|
2086
|
+
parentId,
|
|
2087
|
+
index
|
|
2088
|
+
)
|
|
2089
|
+
const proposal: GanttResourceReorder | null = next
|
|
2090
|
+
? {
|
|
2091
|
+
resourceId: dragRow.resource.id,
|
|
2092
|
+
parentId,
|
|
2093
|
+
index,
|
|
2094
|
+
resources: next,
|
|
2095
|
+
}
|
|
2096
|
+
: null
|
|
2097
|
+
const valid =
|
|
2098
|
+
!!proposal && (settings.canReorderResource?.(proposal) ?? true)
|
|
2099
|
+
const top =
|
|
2100
|
+
boundary < rects.length
|
|
2101
|
+
? rects[boundary].top - paneRect.top
|
|
2102
|
+
: (rects[rects.length - 1]?.bottom ?? paneRect.top) - paneRect.top
|
|
2103
|
+
return { resourceId: dragRow.resource.id, top, valid, proposal }
|
|
2104
|
+
}
|
|
2105
|
+
|
|
2106
|
+
const onMove = (ev: PointerEvent) => {
|
|
2107
|
+
if (ev.pointerId !== pointerId) return
|
|
2108
|
+
place(ev.clientY)
|
|
2109
|
+
let boundary = rects.length
|
|
2110
|
+
for (let i = 0; i < rects.length; i++) {
|
|
2111
|
+
if (ev.clientY < rects[i].top + rects[i].height / 2) {
|
|
2112
|
+
boundary = i
|
|
2113
|
+
break
|
|
2114
|
+
}
|
|
2115
|
+
}
|
|
2116
|
+
// the immutable tree clone + validity check run only when the pointer
|
|
2117
|
+
// crosses into another slot, not per pointermove
|
|
2118
|
+
if (boundary === lastBoundary) return
|
|
2119
|
+
lastBoundary = boundary
|
|
2120
|
+
const nextState = propose(boundary)
|
|
2121
|
+
if (
|
|
2122
|
+
current &&
|
|
2123
|
+
current.top === nextState.top &&
|
|
2124
|
+
current.valid === nextState.valid
|
|
2125
|
+
) {
|
|
2126
|
+
return
|
|
2127
|
+
}
|
|
2128
|
+
current = nextState
|
|
2129
|
+
document.body.style.cursor = nextState.valid
|
|
2130
|
+
? "grabbing"
|
|
2131
|
+
: "not-allowed"
|
|
2132
|
+
setReorder(nextState)
|
|
2133
|
+
}
|
|
2134
|
+
const finish = (commit: boolean) => {
|
|
2135
|
+
window.removeEventListener("pointermove", onMove)
|
|
2136
|
+
window.removeEventListener("pointerup", onUp)
|
|
2137
|
+
window.removeEventListener("pointercancel", onCancelEvent)
|
|
2138
|
+
window.removeEventListener("keydown", onKey)
|
|
2139
|
+
overlay.remove()
|
|
2140
|
+
document.body.style.cursor = ""
|
|
2141
|
+
document.body.style.userSelect = ""
|
|
2142
|
+
if (commit && current?.proposal && !current.valid) {
|
|
2143
|
+
// released on a rejected position (e.g. a pinned row): let the
|
|
2144
|
+
// consumer explain it - the destructive indicator already showed live
|
|
2145
|
+
settings.onResourceReorderReject?.(current.proposal)
|
|
2146
|
+
} else if (commit && current?.valid && current.proposal) {
|
|
2147
|
+
settings.onResourceReorder?.(current.proposal)
|
|
2148
|
+
const announcer = pane
|
|
2149
|
+
.closest<HTMLElement>("[data-slot=gantt]")
|
|
2150
|
+
?.querySelector<HTMLElement>("[data-slot=gantt-announcer]")
|
|
2151
|
+
if (announcer) {
|
|
2152
|
+
announcer.textContent = `${dragRow.resource.title}: ${settings.i18n.labels.reorder}`
|
|
2153
|
+
}
|
|
2154
|
+
}
|
|
2155
|
+
setReorder(null)
|
|
2156
|
+
}
|
|
2157
|
+
const onUp = (ev: PointerEvent) => {
|
|
2158
|
+
if (ev.pointerId !== pointerId) return
|
|
2159
|
+
finish(true)
|
|
2160
|
+
}
|
|
2161
|
+
const onCancelEvent = (ev: PointerEvent) => {
|
|
2162
|
+
if (ev.pointerId !== pointerId) return
|
|
2163
|
+
finish(false)
|
|
2164
|
+
}
|
|
2165
|
+
const onKey = (ev: KeyboardEvent) => {
|
|
2166
|
+
if (ev.key === "Escape") finish(false)
|
|
2167
|
+
}
|
|
2168
|
+
window.addEventListener("pointermove", onMove)
|
|
2169
|
+
window.addEventListener("pointerup", onUp)
|
|
2170
|
+
window.addEventListener("pointercancel", onCancelEvent)
|
|
2171
|
+
window.addEventListener("keydown", onKey)
|
|
2172
|
+
},
|
|
2173
|
+
[]
|
|
2174
|
+
)
|
|
2175
|
+
|
|
2176
|
+
const collapsedIdsRef = useRef(collapsedIds)
|
|
2177
|
+
collapsedIdsRef.current = collapsedIds
|
|
2178
|
+
// Stable identity (reads through refs) so memoized rows survive re-renders
|
|
2179
|
+
const onToggleRow = useCallback((row: TimelineRow) => {
|
|
2180
|
+
const current = collapsedIdsRef.current
|
|
2181
|
+
const next = current.includes(row.resource.id)
|
|
2182
|
+
? current.filter((id) => id !== row.resource.id)
|
|
2183
|
+
: [...current, row.resource.id]
|
|
2184
|
+
if (viewConfigRef.current.collapsedGroups === undefined) {
|
|
2185
|
+
setInternalCollapsed(next)
|
|
2186
|
+
}
|
|
2187
|
+
viewConfigRef.current.onCollapsedGroupsChange?.(next)
|
|
2188
|
+
}, [])
|
|
2189
|
+
|
|
2190
|
+
const loading = useGanttSelector<unknown, boolean>((state) => state.loading)
|
|
2191
|
+
const customScrollbars = viewConfig.scrollbars !== "native"
|
|
2192
|
+
// [allblue-fork] row window virtualization — see docs/superpowers/specs/2026-10-18
|
|
2193
|
+
// 两 pane 垂直同步,时间轴 pane 是原生的纵向滚动源(native 模式下树 pane 的
|
|
2194
|
+
// 纵轴 overflow-hidden、滚轮转发到时间轴),由它驱动共享的行窗口。
|
|
2195
|
+
const rowWindow = useGanttRowWindow(
|
|
2196
|
+
rows,
|
|
2197
|
+
rowBars,
|
|
2198
|
+
timelinePaneRef,
|
|
2199
|
+
minRowRem
|
|
2200
|
+
)
|
|
2201
|
+
const gridLines = resolveTimelineLines(viewConfig.timelineLines)
|
|
2202
|
+
const showVerticalLines = gridLines.vertical !== null
|
|
2203
|
+
const offDayClassName =
|
|
2204
|
+
(typeof viewConfig.offDays === "object" && viewConfig.offDays.className) ||
|
|
2205
|
+
"bg-muted/40"
|
|
2206
|
+
// Body texture: default off-days carry a whisper-faint diagonal hatch over
|
|
2207
|
+
// a lighter wash (header cells stay flat). A custom offDays.className
|
|
2208
|
+
// replaces both surfaces verbatim.
|
|
2209
|
+
const offDayBodyClassName =
|
|
2210
|
+
(typeof viewConfig.offDays === "object" && viewConfig.offDays.className) ||
|
|
2211
|
+
"bg-muted/25 bg-[repeating-linear-gradient(135deg,transparent,transparent_5px,color-mix(in_oklab,var(--color-border)_35%,transparent)_5px,color-mix(in_oklab,var(--color-border)_35%,transparent)_6px)]"
|
|
2212
|
+
|
|
2213
|
+
// Header-only unit lines, and ONE mechanism for every vertical line in the
|
|
2214
|
+
// header: positioned spans at calc(fraction% - 1px). A background gradient
|
|
2215
|
+
// rasterizes stripe positions differently from element layout at fractional
|
|
2216
|
+
// unit widths, which shifted the group-row boundaries 1px off the unit
|
|
2217
|
+
// lines below them mid-track - identical span formulas snap identically.
|
|
2218
|
+
// The body stays bare - rows separate by whitespace, never vertical borders.
|
|
2219
|
+
const showUnitLines = !uniform || showVerticalLines
|
|
2220
|
+
|
|
2221
|
+
// ----- tree pane content -----
|
|
2222
|
+
// Header label offset = the row cell's ps-3 (0.75rem) left gutter + the
|
|
2223
|
+
// toggle/checkbox gutter (w-5 + me-1 = 1.5rem) + the reorder grip (0.875rem)
|
|
2224
|
+
// when present, so "Resources" lines up with the row titles below it.
|
|
2225
|
+
const namePaddingStart = reorderEnabled ? "3.125rem" : "2.25rem"
|
|
2226
|
+
const treeContent = (
|
|
2227
|
+
<div
|
|
2228
|
+
className={cn(
|
|
2229
|
+
"flex min-h-full w-max min-w-full flex-col",
|
|
2230
|
+
// clearance for the pinned horizontal scrollbar strip
|
|
2231
|
+
customScrollbars && "pb-2.5"
|
|
2232
|
+
)}
|
|
2233
|
+
>
|
|
2234
|
+
{/* Same 65px height as the two-row timeline header so the panes align.
|
|
2235
|
+
The head keeps its own bottom rule (under the Resources label), but
|
|
2236
|
+
the header/body boundary line is transparent so the first tree node
|
|
2237
|
+
has no rule directly above it - the 1px is kept only to preserve the
|
|
2238
|
+
65px height, matching the timeline. */}
|
|
2239
|
+
<div
|
|
2240
|
+
data-slot="gantt-tree-header"
|
|
2241
|
+
className="bg-background sticky top-0 z-30 box-content h-16 shrink-0 border-b border-b-transparent"
|
|
2242
|
+
>
|
|
2243
|
+
<div className="flex h-8 border-b">
|
|
2244
|
+
<div className="flex h-full min-w-0 flex-1">
|
|
2245
|
+
<div
|
|
2246
|
+
className="flex h-full shrink-0 items-center"
|
|
2247
|
+
style={{
|
|
2248
|
+
width: treeConfig.nameColumnWidth,
|
|
2249
|
+
paddingInlineStart: namePaddingStart,
|
|
2250
|
+
}}
|
|
2251
|
+
>
|
|
2252
|
+
<span className="text-muted-foreground truncate font-medium">
|
|
2253
|
+
{settings.i18n.labels.resources}
|
|
2254
|
+
</span>
|
|
2255
|
+
</div>
|
|
2256
|
+
{columns.map((column) => (
|
|
2257
|
+
<div
|
|
2258
|
+
key={column.id}
|
|
2259
|
+
data-slot="gantt-column-header"
|
|
2260
|
+
data-column={column.id}
|
|
2261
|
+
className={cn(
|
|
2262
|
+
"text-muted-foreground flex h-full shrink-0 items-center px-2 font-medium",
|
|
2263
|
+
column.align === "center" && "justify-center",
|
|
2264
|
+
column.align === "end" && "justify-end",
|
|
2265
|
+
column.className
|
|
2266
|
+
)}
|
|
2267
|
+
style={{ width: column.width ?? DEFAULT_COLUMN_WIDTH }}
|
|
2268
|
+
>
|
|
2269
|
+
<span className="truncate">{column.title ?? column.id}</span>
|
|
2270
|
+
</div>
|
|
2271
|
+
))}
|
|
2272
|
+
<div className="min-w-0 flex-1" />
|
|
2273
|
+
</div>
|
|
2274
|
+
{viewConfig.columnsMenu && (
|
|
2275
|
+
<div
|
|
2276
|
+
data-slot="gantt-columns-menu"
|
|
2277
|
+
// gradient lead-in: scrolled column headers dissolve into this
|
|
2278
|
+
// sticky control instead of hard-clipping against its background
|
|
2279
|
+
className="bg-background before:from-background sticky end-0 z-10 flex h-full shrink-0 items-center ps-1.5 pe-2.5 before:pointer-events-none before:absolute before:inset-y-0 before:-start-5 before:w-5 before:bg-linear-to-l before:to-transparent rtl:before:bg-linear-to-r"
|
|
2280
|
+
>
|
|
2281
|
+
{viewConfig.columnsMenu}
|
|
2282
|
+
</div>
|
|
2283
|
+
)}
|
|
2284
|
+
</div>
|
|
2285
|
+
</div>
|
|
2286
|
+
<div ref={treeRowsRef} className="flex flex-col">
|
|
2287
|
+
{/* [allblue-fork] row window virtualization — see docs/superpowers/specs/2026-10-18 */}
|
|
2288
|
+
{rowWindow.canVirtualize && (
|
|
2289
|
+
<div
|
|
2290
|
+
aria-hidden
|
|
2291
|
+
data-gantt-row-window-pad=""
|
|
2292
|
+
style={{ height: `${rowWindow.topPadRem}rem` }}
|
|
2293
|
+
/>
|
|
2294
|
+
)}
|
|
2295
|
+
{rows.slice(rowWindow.startIdx, rowWindow.endIdx).map((row) => (
|
|
2296
|
+
<GanttTreeRow
|
|
2297
|
+
key={row.resource.id}
|
|
2298
|
+
row={row}
|
|
2299
|
+
heightRem={rowBars.get(row.resource.id)?.heightRem ?? minRowRem}
|
|
2300
|
+
bandRem={rowBars.get(row.resource.id)?.bandRem ?? minRowRem}
|
|
2301
|
+
columns={columns}
|
|
2302
|
+
nameWidth={treeConfig.nameColumnWidth}
|
|
2303
|
+
dimmed={reorder?.resourceId === row.resource.id}
|
|
2304
|
+
selected={selectedSet.has(row.resource.id)}
|
|
2305
|
+
onSelectedChange={row.isGroup ? undefined : toggleRowSelected}
|
|
2306
|
+
onGripPointerDown={reorderEnabled ? beginRowReorder : undefined}
|
|
2307
|
+
onToggle={onToggleRow}
|
|
2308
|
+
/>
|
|
2309
|
+
))}
|
|
2310
|
+
{rowWindow.canVirtualize && (
|
|
2311
|
+
<div
|
|
2312
|
+
aria-hidden
|
|
2313
|
+
data-gantt-row-window-pad=""
|
|
2314
|
+
style={{ height: `${rowWindow.bottomPadRem}rem` }}
|
|
2315
|
+
/>
|
|
2316
|
+
)}
|
|
2317
|
+
{/* [/allblue-fork] row window virtualization */}
|
|
2318
|
+
{showCreateTask && (
|
|
2319
|
+
<button
|
|
2320
|
+
type="button"
|
|
2321
|
+
data-slot="gantt-create-task"
|
|
2322
|
+
// mirror the tree row's left structure so the + lands in the same
|
|
2323
|
+
// column as the row toggle chevrons, and the label lines up with
|
|
2324
|
+
// the task titles above
|
|
2325
|
+
className="text-muted-foreground hover:text-foreground hover:bg-muted/40 flex h-10 w-full shrink-0 items-center border-b ps-3 pe-3"
|
|
2326
|
+
onClick={() =>
|
|
2327
|
+
settings.onCreateTask?.({
|
|
2328
|
+
parentId: null,
|
|
2329
|
+
index: settings.resources.length,
|
|
2330
|
+
})
|
|
2331
|
+
}
|
|
2332
|
+
>
|
|
2333
|
+
<span className="flex h-full w-full items-center">
|
|
2334
|
+
{reorderEnabled && (
|
|
2335
|
+
<span aria-hidden className="w-3.5 shrink-0" />
|
|
2336
|
+
)}
|
|
2337
|
+
{/* group chevrons sit centered in a size-5 button that fills
|
|
2338
|
+
this w-5 gutter, so the + must center here too */}
|
|
2339
|
+
<span className="me-1 flex w-5 shrink-0 items-center justify-center">
|
|
2340
|
+
<PlusIcon className="size-3.5" aria-hidden="true" />
|
|
2341
|
+
</span>
|
|
2342
|
+
<span>{settings.i18n.labels.addTask}</span>
|
|
2343
|
+
</span>
|
|
2344
|
+
</button>
|
|
2345
|
+
)}
|
|
2346
|
+
</div>
|
|
2347
|
+
</div>
|
|
2348
|
+
)
|
|
2349
|
+
|
|
2350
|
+
// ----- timeline pane content -----
|
|
2351
|
+
const timelineContent = (
|
|
2352
|
+
<div
|
|
2353
|
+
className={cn(
|
|
2354
|
+
// grow (not just min-h-full) so the body reaches the bottom of the
|
|
2355
|
+
// pane: the columns then run the full height and the empty space
|
|
2356
|
+
// below the last row becomes pannable canvas instead of dead area
|
|
2357
|
+
"flex min-h-full w-max min-w-full grow flex-col",
|
|
2358
|
+
customScrollbars && "pb-2.5"
|
|
2359
|
+
)}
|
|
2360
|
+
>
|
|
2361
|
+
{/* Two-row grouped header; also the drag-to-pan surface */}
|
|
2362
|
+
<div
|
|
2363
|
+
data-slot="gantt-timeline-header"
|
|
2364
|
+
className="bg-background sticky top-0 z-30 shrink-0 border-b"
|
|
2365
|
+
style={{ minWidth: trackWidth }}
|
|
2366
|
+
onPointerDown={beginHeaderPan}
|
|
2367
|
+
>
|
|
2368
|
+
{/* group sectors; boundaries painted like the body lines */}
|
|
2369
|
+
<div className="relative h-8 border-b">
|
|
2370
|
+
<div className="flex h-full">
|
|
2371
|
+
{groups.map((group) => (
|
|
2372
|
+
<div
|
|
2373
|
+
key={group.key}
|
|
2374
|
+
data-slot="gantt-axis-group"
|
|
2375
|
+
// no `truncate` here: overflow-hidden would make this the
|
|
2376
|
+
// sticky label's scrollport and the stick would never fire
|
|
2377
|
+
className="text-muted-foreground flex min-w-0 items-center ps-3 pe-2"
|
|
2378
|
+
style={{ flex: `${group.span} 0 0px` }}
|
|
2379
|
+
>
|
|
2380
|
+
{/* Pure-CSS sticky: the label rides the leading edge for as
|
|
2381
|
+
long as its own band is on screen, then the next band pushes
|
|
2382
|
+
it out - so the day you are looking at always names itself.
|
|
2383
|
+
The browser composites this; a scroll listener would run JS
|
|
2384
|
+
on every frame to do worse. start-3 matches the cell's ps-3
|
|
2385
|
+
so the gutter is identical parked or pinned. */}
|
|
2386
|
+
<span
|
|
2387
|
+
data-slot="gantt-axis-group-label"
|
|
2388
|
+
className="sticky start-3 max-w-full truncate"
|
|
2389
|
+
>
|
|
2390
|
+
{group.label}
|
|
2391
|
+
</span>
|
|
2392
|
+
</div>
|
|
2393
|
+
))}
|
|
2394
|
+
</div>
|
|
2395
|
+
{/* same span formula as the unit lines below: equal fractions get
|
|
2396
|
+
equal layout rounding, so the two rows' lines never drift apart */}
|
|
2397
|
+
{groupBoundaries.map((fraction) => (
|
|
2398
|
+
<span
|
|
2399
|
+
key={fraction}
|
|
2400
|
+
aria-hidden
|
|
2401
|
+
className="bg-border absolute inset-y-0 w-px"
|
|
2402
|
+
style={{ insetInlineStart: `calc(${fraction * 100}% - 1px)` }}
|
|
2403
|
+
/>
|
|
2404
|
+
))}
|
|
2405
|
+
</div>
|
|
2406
|
+
{/* units, engine axis = this row */}
|
|
2407
|
+
<div
|
|
2408
|
+
data-gantt-axis=""
|
|
2409
|
+
data-gantt-range-start={rangeStartMs}
|
|
2410
|
+
data-gantt-range-end={rangeEndMs}
|
|
2411
|
+
data-gantt-snap={snapMin}
|
|
2412
|
+
className="relative h-8"
|
|
2413
|
+
>
|
|
2414
|
+
{/* chrome underlay: off-day washes under the label layer */}
|
|
2415
|
+
<div aria-hidden className="absolute inset-0">
|
|
2416
|
+
{unitFractions.map(
|
|
2417
|
+
({ unit, start, width }) =>
|
|
2418
|
+
unit.isOff && (
|
|
2419
|
+
<span
|
|
2420
|
+
key={unit.key}
|
|
2421
|
+
className={cn("absolute inset-y-0", offDayClassName)}
|
|
2422
|
+
style={{
|
|
2423
|
+
insetInlineStart: `${start * 100}%`,
|
|
2424
|
+
width: `${width * 100}%`,
|
|
2425
|
+
}}
|
|
2426
|
+
/>
|
|
2427
|
+
)
|
|
2428
|
+
)}
|
|
2429
|
+
</div>
|
|
2430
|
+
<div className="flex h-full">
|
|
2431
|
+
{units.map((unit) => (
|
|
2432
|
+
<div
|
|
2433
|
+
key={unit.key}
|
|
2434
|
+
data-today={unit.isToday || undefined}
|
|
2435
|
+
data-off={unit.isOff || undefined}
|
|
2436
|
+
className={cn(
|
|
2437
|
+
"flex min-w-0 items-center justify-center truncate px-1.5 text-center",
|
|
2438
|
+
"text-muted-foreground",
|
|
2439
|
+
unit.isToday && "text-primary font-medium"
|
|
2440
|
+
)}
|
|
2441
|
+
style={{ flex: `${unit.weight} 0 0px` }}
|
|
2442
|
+
>
|
|
2443
|
+
{/* today reads as a soft pill, not just tinted text */}
|
|
2444
|
+
{unit.isToday ? (
|
|
2445
|
+
<span className="bg-primary/10 truncate rounded-full px-1.5 py-px">
|
|
2446
|
+
{unit.label}
|
|
2447
|
+
</span>
|
|
2448
|
+
) : (
|
|
2449
|
+
unit.label
|
|
2450
|
+
)}
|
|
2451
|
+
</div>
|
|
2452
|
+
))}
|
|
2453
|
+
</div>
|
|
2454
|
+
{showUnitLines &&
|
|
2455
|
+
unitFractions.slice(1).map(({ unit, start }) => (
|
|
2456
|
+
<span
|
|
2457
|
+
key={unit.key}
|
|
2458
|
+
aria-hidden
|
|
2459
|
+
data-slot="gantt-grid-line"
|
|
2460
|
+
data-axis="vertical"
|
|
2461
|
+
className={cn(
|
|
2462
|
+
"absolute inset-y-0 w-px",
|
|
2463
|
+
// a dashed rule is a repeating gradient, not a border: the
|
|
2464
|
+
// line is a 1px span, and border-dashed on a zero-width box
|
|
2465
|
+
// paints nothing
|
|
2466
|
+
gridLines.vertical === "dashed"
|
|
2467
|
+
? "bg-[repeating-linear-gradient(to_bottom,var(--color-border)_0,var(--color-border)_3px,transparent_3px,transparent_6px)]"
|
|
2468
|
+
: "bg-border"
|
|
2469
|
+
)}
|
|
2470
|
+
style={{ insetInlineStart: `calc(${start * 100}% - 1px)` }}
|
|
2471
|
+
/>
|
|
2472
|
+
))}
|
|
2473
|
+
{viewConfig.nowIndicator && (
|
|
2474
|
+
<GanttNowDot rangeStartMs={rangeStartMs} rangeEndMs={rangeEndMs} />
|
|
2475
|
+
)}
|
|
2476
|
+
</div>
|
|
2477
|
+
</div>
|
|
2478
|
+
{/* Rows over a shared backdrop (off days, today, boundaries, now);
|
|
2479
|
+
grows so the columns run to the bottom of the pane. Pressing anywhere
|
|
2480
|
+
here that is not a bar or a hint tile begins a scroll pan - the whole
|
|
2481
|
+
panel is a draggable canvas, not just the rows. */}
|
|
2482
|
+
<div
|
|
2483
|
+
className="relative flex min-h-0 grow flex-col"
|
|
2484
|
+
onPointerDown={beginHeaderPan}
|
|
2485
|
+
>
|
|
2486
|
+
{/* off-day / today / now backdrop only; vertical gridlines are
|
|
2487
|
+
per-row and reveal on selection, not painted here */}
|
|
2488
|
+
<div
|
|
2489
|
+
aria-hidden
|
|
2490
|
+
data-slot="gantt-timeline-backdrop"
|
|
2491
|
+
className="pointer-events-none absolute inset-0"
|
|
2492
|
+
>
|
|
2493
|
+
{unitFractions.map(({ unit, start, width }) => (
|
|
2494
|
+
<span key={unit.key} className="contents">
|
|
2495
|
+
{unit.isOff && (
|
|
2496
|
+
<span
|
|
2497
|
+
data-off=""
|
|
2498
|
+
className={cn("absolute inset-y-0", offDayBodyClassName)}
|
|
2499
|
+
style={{
|
|
2500
|
+
insetInlineStart: `${start * 100}%`,
|
|
2501
|
+
width: `${width * 100}%`,
|
|
2502
|
+
}}
|
|
2503
|
+
/>
|
|
2504
|
+
)}
|
|
2505
|
+
{unit.isToday && scale !== "day" && (
|
|
2506
|
+
<span
|
|
2507
|
+
data-today=""
|
|
2508
|
+
className="bg-primary/5 absolute inset-y-0"
|
|
2509
|
+
style={{
|
|
2510
|
+
insetInlineStart: `${start * 100}%`,
|
|
2511
|
+
width: `${width * 100}%`,
|
|
2512
|
+
}}
|
|
2513
|
+
/>
|
|
2514
|
+
)}
|
|
2515
|
+
</span>
|
|
2516
|
+
))}
|
|
2517
|
+
{/* one layer for the whole body, not per row: the unit boundaries
|
|
2518
|
+
have to line up with the header's spans exactly, so both use the
|
|
2519
|
+
same fraction formula */}
|
|
2520
|
+
{gridLines.vertical !== null &&
|
|
2521
|
+
unitFractions
|
|
2522
|
+
.slice(1)
|
|
2523
|
+
.map(({ unit, start }) => (
|
|
2524
|
+
<span
|
|
2525
|
+
key={`grid-${unit.key}`}
|
|
2526
|
+
data-slot="gantt-grid-line"
|
|
2527
|
+
data-axis="vertical"
|
|
2528
|
+
className={cn(
|
|
2529
|
+
"absolute inset-y-0 w-px",
|
|
2530
|
+
gridLines.vertical === "dashed"
|
|
2531
|
+
? "bg-[repeating-linear-gradient(to_bottom,var(--color-border)_0,var(--color-border)_3px,transparent_3px,transparent_6px)]"
|
|
2532
|
+
: "bg-border"
|
|
2533
|
+
)}
|
|
2534
|
+
style={{ insetInlineStart: `calc(${start * 100}% - 1px)` }}
|
|
2535
|
+
/>
|
|
2536
|
+
))}
|
|
2537
|
+
{viewConfig.nowIndicator && (
|
|
2538
|
+
<GanttNowLine rangeStartMs={rangeStartMs} rangeEndMs={rangeEndMs} />
|
|
2539
|
+
)}
|
|
2540
|
+
</div>
|
|
2541
|
+
{/* Dependency arrows: after the backdrop, before the rows, so they
|
|
2542
|
+
run above the off-day wash but under every bar. Its own component
|
|
2543
|
+
because it follows drags per snap step, and that subscription must
|
|
2544
|
+
re-render a few paths, never this whole view. */}
|
|
2545
|
+
{dependencyEdges.edges.length > 0 && (
|
|
2546
|
+
<GanttDependencyLayer
|
|
2547
|
+
edges={dependencyEdges.edges}
|
|
2548
|
+
totalRem={dependencyEdges.totalRem}
|
|
2549
|
+
trackRemWidth={trackRemWidth}
|
|
2550
|
+
rangeStartMs={rangeStartMs}
|
|
2551
|
+
rangeEndMs={rangeEndMs}
|
|
2552
|
+
laneHeightRem={laneHeightRem}
|
|
2553
|
+
laneGapRem={laneGapRem}
|
|
2554
|
+
/>
|
|
2555
|
+
)}
|
|
2556
|
+
{/* [allblue-fork] row window virtualization — see docs/superpowers/specs/2026-10-18
|
|
2557
|
+
rowIndex 传绝对下标(窗口内局部 index + startIdx):首行 tooltip 定位依赖它 */}
|
|
2558
|
+
{rowWindow.canVirtualize && (
|
|
2559
|
+
<div
|
|
2560
|
+
aria-hidden
|
|
2561
|
+
data-gantt-row-window-pad=""
|
|
2562
|
+
style={{ height: `${rowWindow.topPadRem}rem` }}
|
|
2563
|
+
/>
|
|
2564
|
+
)}
|
|
2565
|
+
{rows
|
|
2566
|
+
.slice(rowWindow.startIdx, rowWindow.endIdx)
|
|
2567
|
+
.map((row, index) => (
|
|
2568
|
+
<GanttTimelineRow
|
|
2569
|
+
key={row.resource.id}
|
|
2570
|
+
row={row}
|
|
2571
|
+
rowIndex={rowWindow.startIdx + index}
|
|
2572
|
+
bars={rowBars.get(row.resource.id)}
|
|
2573
|
+
rangeStartMs={rangeStartMs}
|
|
2574
|
+
rangeEndMs={rangeEndMs}
|
|
2575
|
+
trackWidth={trackWidth}
|
|
2576
|
+
trackRemWidth={trackRemWidth}
|
|
2577
|
+
rowBorder={gridLines.horizontal}
|
|
2578
|
+
selected={selectedSet.has(row.resource.id)}
|
|
2579
|
+
resolveHintStop={resolveHintStop}
|
|
2580
|
+
isPanning={isPanning}
|
|
2581
|
+
laneHeightRem={laneHeightRem}
|
|
2582
|
+
laneGapRem={laneGapRem}
|
|
2583
|
+
minRowRem={minRowRem}
|
|
2584
|
+
ghostHeightRem={ghostHeightRem}
|
|
2585
|
+
dependencySourceIds={dependencySourceIds}
|
|
2586
|
+
/>
|
|
2587
|
+
))}
|
|
2588
|
+
{rowWindow.canVirtualize && (
|
|
2589
|
+
<div
|
|
2590
|
+
aria-hidden
|
|
2591
|
+
data-gantt-row-window-pad=""
|
|
2592
|
+
style={{ height: `${rowWindow.bottomPadRem}rem` }}
|
|
2593
|
+
/>
|
|
2594
|
+
)}
|
|
2595
|
+
{/* [/allblue-fork] row window virtualization */}
|
|
2596
|
+
{rows.length === 0 && viewConfig.renderNoResources && (
|
|
2597
|
+
<div
|
|
2598
|
+
data-slot="gantt-no-resources"
|
|
2599
|
+
className="text-muted-foreground flex grow items-center justify-center p-6 text-sm"
|
|
2600
|
+
>
|
|
2601
|
+
{viewConfig.renderNoResources()}
|
|
2602
|
+
</div>
|
|
2603
|
+
)}
|
|
2604
|
+
{showCreateTask && (
|
|
2605
|
+
<div
|
|
2606
|
+
aria-hidden
|
|
2607
|
+
data-slot="gantt-create-task-spacer"
|
|
2608
|
+
className="h-10 border-b"
|
|
2609
|
+
style={{ minWidth: trackWidth }}
|
|
2610
|
+
/>
|
|
2611
|
+
)}
|
|
2612
|
+
</div>
|
|
2613
|
+
</div>
|
|
2614
|
+
)
|
|
2615
|
+
|
|
2616
|
+
const horizontalScrollbar = (
|
|
2617
|
+
<ScrollBar
|
|
2618
|
+
orientation="horizontal"
|
|
2619
|
+
// taller strip with vertical padding so the thumb is not crowded.
|
|
2620
|
+
// end-0 runs the strip (bg + top border) to the pane's right edge instead
|
|
2621
|
+
// of stopping short by the corner width - the vertical scrollbar is inset
|
|
2622
|
+
// to end above this strip, so nothing collides in the bottom-right corner.
|
|
2623
|
+
className="bg-background border-t-border! end-0! z-40 h-4! rounded-none py-1"
|
|
2624
|
+
/>
|
|
2625
|
+
)
|
|
2626
|
+
|
|
2627
|
+
const defaultProps = {
|
|
2628
|
+
"data-slot": "gantt-view",
|
|
2629
|
+
"data-scale": scale,
|
|
2630
|
+
"aria-busy": loading || undefined,
|
|
2631
|
+
className: cn(
|
|
2632
|
+
"flex min-h-0 flex-1 flex-col overflow-hidden",
|
|
2633
|
+
viewConfig.classNames?.view,
|
|
2634
|
+
className
|
|
2635
|
+
),
|
|
2636
|
+
children: (
|
|
2637
|
+
<div ref={bodyRef} className="relative flex min-h-0 flex-1">
|
|
2638
|
+
{/* Tree pane */}
|
|
2639
|
+
<div
|
|
2640
|
+
ref={treePaneRef}
|
|
2641
|
+
data-slot="gantt-tree-pane"
|
|
2642
|
+
className="relative h-full shrink-0"
|
|
2643
|
+
style={{ width: liveTreeWidthRef.current ?? clampedTreeWidth }}
|
|
2644
|
+
>
|
|
2645
|
+
{customScrollbars ? (
|
|
2646
|
+
// keyed by scale: Base UI measures overflow once per mount, and a
|
|
2647
|
+
// scale switch changes content without resizing the viewport
|
|
2648
|
+
<ScrollArea
|
|
2649
|
+
key={scale}
|
|
2650
|
+
className="h-full [&>[data-orientation=vertical]]:hidden"
|
|
2651
|
+
>
|
|
2652
|
+
<ScrollAreaPrimitive.Content>
|
|
2653
|
+
{treeContent}
|
|
2654
|
+
</ScrollAreaPrimitive.Content>
|
|
2655
|
+
{horizontalScrollbar}
|
|
2656
|
+
</ScrollArea>
|
|
2657
|
+
) : (
|
|
2658
|
+
<div
|
|
2659
|
+
data-slot="scroll-area-viewport"
|
|
2660
|
+
data-gantt-native-scroll=""
|
|
2661
|
+
// vertical axis is DISPLAY-ONLY: the position is always
|
|
2662
|
+
// mirrored from the timeline, so no vertical scrollbar can
|
|
2663
|
+
// ever appear (overlay platforms included). Wheel deltas
|
|
2664
|
+
// forward to the timeline (see the sync effect); horizontal
|
|
2665
|
+
// column scrolling stays fully native.
|
|
2666
|
+
className="h-full overflow-x-auto overflow-y-hidden overscroll-contain"
|
|
2667
|
+
>
|
|
2668
|
+
{treeContent}
|
|
2669
|
+
</div>
|
|
2670
|
+
)}
|
|
2671
|
+
{/* Reserved horizontal-scrollbar rail: the tree usually has no
|
|
2672
|
+
horizontal overflow, so its real scrollbar never mounts and its
|
|
2673
|
+
bottom edge would sit higher than the timeline's pinned strip.
|
|
2674
|
+
This static rail fills that gutter (same 1rem height + top border)
|
|
2675
|
+
so the bottom strip reads as one continuous band across both
|
|
2676
|
+
panes; a real tree scrollbar (with columns) draws over it. */}
|
|
2677
|
+
{customScrollbars && (
|
|
2678
|
+
<div
|
|
2679
|
+
aria-hidden
|
|
2680
|
+
data-slot="gantt-tree-scrollbar-rail"
|
|
2681
|
+
className="bg-background border-t-border pointer-events-none absolute inset-x-0 bottom-0 h-4 border-t"
|
|
2682
|
+
/>
|
|
2683
|
+
)}
|
|
2684
|
+
{/* Reorder insertion indicator, pinned to the visible pane */}
|
|
2685
|
+
{reorder && (
|
|
2686
|
+
<div
|
|
2687
|
+
data-slot="gantt-reorder-indicator"
|
|
2688
|
+
data-invalid={!reorder.valid || undefined}
|
|
2689
|
+
className="pointer-events-none absolute inset-x-0 flex items-center"
|
|
2690
|
+
style={{ top: reorder.top - 4, zIndex: 110 }}
|
|
2691
|
+
>
|
|
2692
|
+
{/* caret head pointing along the insertion line; the whole
|
|
2693
|
+
indicator sits above the row carry overlay (z 100) */}
|
|
2694
|
+
<span
|
|
2695
|
+
className={cn(
|
|
2696
|
+
"ms-0.5 size-0 shrink-0 border-y-4 border-s-8 border-y-transparent",
|
|
2697
|
+
reorder.valid ? "border-s-primary" : "border-s-destructive"
|
|
2698
|
+
)}
|
|
2699
|
+
/>
|
|
2700
|
+
<span
|
|
2701
|
+
className={cn(
|
|
2702
|
+
"h-px min-w-0 flex-1",
|
|
2703
|
+
reorder.valid ? "bg-primary" : "bg-destructive"
|
|
2704
|
+
)}
|
|
2705
|
+
/>
|
|
2706
|
+
</div>
|
|
2707
|
+
)}
|
|
2708
|
+
</div>
|
|
2709
|
+
{/* Splitter */}
|
|
2710
|
+
{treeConfig.resizable ? (
|
|
2711
|
+
<div
|
|
2712
|
+
role="separator"
|
|
2713
|
+
aria-orientation="vertical"
|
|
2714
|
+
aria-label={settings.i18n.labels.resizePanel}
|
|
2715
|
+
aria-valuenow={Math.round(clampedTreeWidth)}
|
|
2716
|
+
// the EFFECTIVE floor, not the configured one: on a narrow
|
|
2717
|
+
// container the tree yields below its own minWidth to keep the
|
|
2718
|
+
// timeline usable, and valuenow must never fall outside the range
|
|
2719
|
+
aria-valuemin={Math.round(
|
|
2720
|
+
Math.min(treeConfig.minWidth, clampedTreeWidth)
|
|
2721
|
+
)}
|
|
2722
|
+
aria-valuemax={Math.round(
|
|
2723
|
+
Math.max(treeConfig.maxWidth, clampedTreeWidth)
|
|
2724
|
+
)}
|
|
2725
|
+
tabIndex={0}
|
|
2726
|
+
data-slot="gantt-splitter"
|
|
2727
|
+
className={cn(
|
|
2728
|
+
"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",
|
|
2729
|
+
"focus-visible:ring-ring/50 focus-visible:ring-2",
|
|
2730
|
+
"after:absolute after:inset-y-0 after:-start-1 after:-end-1"
|
|
2731
|
+
)}
|
|
2732
|
+
onPointerDown={beginSplit}
|
|
2733
|
+
onDoubleClick={() => {
|
|
2734
|
+
setTreeWidth(treeConfig.width)
|
|
2735
|
+
treeConfig.onWidthChange?.(treeConfig.width)
|
|
2736
|
+
}}
|
|
2737
|
+
onKeyDown={(e) => {
|
|
2738
|
+
if (e.key === "ArrowLeft" || e.key === "ArrowRight") {
|
|
2739
|
+
e.preventDefault()
|
|
2740
|
+
const dir =
|
|
2741
|
+
getComputedStyle(e.currentTarget).direction === "rtl" ? -1 : 1
|
|
2742
|
+
const delta = (e.key === "ArrowLeft" ? -16 : 16) * dir
|
|
2743
|
+
const next = clampTree(clampedTreeWidth + delta)
|
|
2744
|
+
setTreeWidth(next)
|
|
2745
|
+
treeConfig.onWidthChange?.(next)
|
|
2746
|
+
}
|
|
2747
|
+
}}
|
|
2748
|
+
>
|
|
2749
|
+
{/* grip pill: makes the hairline read as draggable on approach */}
|
|
2750
|
+
<span
|
|
2751
|
+
aria-hidden
|
|
2752
|
+
data-slot="gantt-splitter-grip"
|
|
2753
|
+
className="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"
|
|
2754
|
+
/>
|
|
2755
|
+
</div>
|
|
2756
|
+
) : (
|
|
2757
|
+
<div aria-hidden className="bg-border w-px shrink-0" />
|
|
2758
|
+
)}
|
|
2759
|
+
{/* Timeline pane */}
|
|
2760
|
+
<div
|
|
2761
|
+
ref={timelinePaneRef}
|
|
2762
|
+
data-slot="gantt-timeline-pane"
|
|
2763
|
+
className="relative h-full min-w-0 flex-1"
|
|
2764
|
+
>
|
|
2765
|
+
{viewConfig.zoomControl && (
|
|
2766
|
+
<div
|
|
2767
|
+
data-slot="gantt-zoom"
|
|
2768
|
+
/* --gantt-zoom-shift comes from the offscreen-chips measure
|
|
2769
|
+
pass: the control glides inward while a chip occupies its
|
|
2770
|
+
band, because the chips' position IS their meaning and this
|
|
2771
|
+
corner spot is merely a habit */
|
|
2772
|
+
className="bg-background absolute end-[calc(0.75rem+var(--gantt-zoom-shift,0px))] bottom-5 z-40 flex flex-col rounded-md border shadow-sm transition-[inset-inline-end] duration-200"
|
|
2773
|
+
>
|
|
2774
|
+
{/* aria-disabled instead of disabled: the not-allowed cursor
|
|
2775
|
+
must still show at the zoom limits */}
|
|
2776
|
+
<TooltipProvider delay={600} closeDelay={0} timeout={300}>
|
|
2777
|
+
<Tooltip>
|
|
2778
|
+
<TooltipTrigger
|
|
2779
|
+
render={
|
|
2780
|
+
<Button
|
|
2781
|
+
variant="ghost"
|
|
2782
|
+
size="icon-xs"
|
|
2783
|
+
aria-label={settings.i18n.labels.zoomIn}
|
|
2784
|
+
aria-disabled={!canZoomIn || undefined}
|
|
2785
|
+
className="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"
|
|
2786
|
+
onClick={() => {
|
|
2787
|
+
if (!canZoomIn) return
|
|
2788
|
+
// controlled zoom anchors via fineCenterRef when
|
|
2789
|
+
// the parent adopts; a pre-set anchor would leak
|
|
2790
|
+
// stale if the parent ignores the proposal
|
|
2791
|
+
if (viewConfig.zoom === undefined) anchorZoomCenter()
|
|
2792
|
+
setZoomValue(
|
|
2793
|
+
+(zoom + (zoomRange.step ?? 0.25)).toFixed(2)
|
|
2794
|
+
)
|
|
2795
|
+
}}
|
|
2796
|
+
/>
|
|
2797
|
+
}
|
|
2798
|
+
>
|
|
2799
|
+
<PlusIcon className="size-3" aria-hidden="true" />
|
|
2800
|
+
</TooltipTrigger>
|
|
2801
|
+
<TooltipContent side="left">
|
|
2802
|
+
{settings.i18n.labels.zoomIn}
|
|
2803
|
+
</TooltipContent>
|
|
2804
|
+
</Tooltip>
|
|
2805
|
+
<Tooltip>
|
|
2806
|
+
<TooltipTrigger
|
|
2807
|
+
render={
|
|
2808
|
+
<Button
|
|
2809
|
+
variant="ghost"
|
|
2810
|
+
size="icon-xs"
|
|
2811
|
+
aria-label={settings.i18n.labels.zoomOut}
|
|
2812
|
+
aria-disabled={!canZoomOut || undefined}
|
|
2813
|
+
className="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"
|
|
2814
|
+
onClick={() => {
|
|
2815
|
+
if (!canZoomOut) return
|
|
2816
|
+
if (viewConfig.zoom === undefined) anchorZoomCenter()
|
|
2817
|
+
setZoomValue(
|
|
2818
|
+
+(zoom - (zoomRange.step ?? 0.25)).toFixed(2)
|
|
2819
|
+
)
|
|
2820
|
+
}}
|
|
2821
|
+
/>
|
|
2822
|
+
}
|
|
2823
|
+
>
|
|
2824
|
+
<MinusIcon className="size-3" aria-hidden="true" />
|
|
2825
|
+
</TooltipTrigger>
|
|
2826
|
+
<TooltipContent side="left">
|
|
2827
|
+
{settings.i18n.labels.zoomOut}
|
|
2828
|
+
</TooltipContent>
|
|
2829
|
+
</Tooltip>
|
|
2830
|
+
</TooltipProvider>
|
|
2831
|
+
</div>
|
|
2832
|
+
)}
|
|
2833
|
+
{customScrollbars ? (
|
|
2834
|
+
// The vertical scrollbar is inset into the body lane: it starts
|
|
2835
|
+
// below the sticky 65px two-row header (otherwise its top slides
|
|
2836
|
+
// behind the header and the thumb is clipped) and stops above the
|
|
2837
|
+
// pinned 16px horizontal strip. `!` overrides Base UI's inline
|
|
2838
|
+
// top/bottom; h-auto lets top+bottom define the track height so
|
|
2839
|
+
// the thumb is measured against the visible lane, not the full pane.
|
|
2840
|
+
<ScrollArea
|
|
2841
|
+
key={scale}
|
|
2842
|
+
className="h-full [&>[data-orientation=vertical]]:top-[65px]! [&>[data-orientation=vertical]]:bottom-4! [&>[data-orientation=vertical]]:h-auto!"
|
|
2843
|
+
>
|
|
2844
|
+
{/* A FLEX column, not a percentage: the content's own
|
|
2845
|
+
min-h-full would resolve against this box's auto height and
|
|
2846
|
+
collapse to nothing, which is why the columns used to stop at
|
|
2847
|
+
the last row and leave the rest of the pane dead space. As a
|
|
2848
|
+
flex parent it can hand the leftover height down instead. */}
|
|
2849
|
+
<ScrollAreaPrimitive.Content className="flex min-h-full flex-col">
|
|
2850
|
+
{timelineContent}
|
|
2851
|
+
</ScrollAreaPrimitive.Content>
|
|
2852
|
+
{horizontalScrollbar}
|
|
2853
|
+
</ScrollArea>
|
|
2854
|
+
) : (
|
|
2855
|
+
<div
|
|
2856
|
+
data-slot="scroll-area-viewport"
|
|
2857
|
+
data-gantt-native-scroll=""
|
|
2858
|
+
className="h-full overflow-auto overscroll-contain"
|
|
2859
|
+
>
|
|
2860
|
+
{timelineContent}
|
|
2861
|
+
</div>
|
|
2862
|
+
)}
|
|
2863
|
+
{/* Reserved scrollbar rail - the twin of the tree rail. Keeps the
|
|
2864
|
+
bottom gutter present even when the real horizontal scrollbar is
|
|
2865
|
+
hidden (e.g. hover-reveal scrollbars at rest), so the strip reads
|
|
2866
|
+
as one continuous reserved band across both panes; the real
|
|
2867
|
+
scrollbar (z-40) draws over it when active. */}
|
|
2868
|
+
{customScrollbars && (
|
|
2869
|
+
<div
|
|
2870
|
+
aria-hidden
|
|
2871
|
+
data-slot="gantt-timeline-scrollbar-rail"
|
|
2872
|
+
className="bg-background border-t-border pointer-events-none absolute inset-x-0 bottom-0 h-4 border-t"
|
|
2873
|
+
/>
|
|
2874
|
+
)}
|
|
2875
|
+
{viewConfig.offscreenIndicators && (
|
|
2876
|
+
<GanttOffscreenChips
|
|
2877
|
+
paneRef={timelinePaneRef}
|
|
2878
|
+
occurrences={occurrences}
|
|
2879
|
+
locale={settings.locale}
|
|
2880
|
+
refreshKey={`${scale}:${rangeKey}:${zoom}:${rows.length}:${clampedTreeWidth}:${viewConfig.scrollbars}`}
|
|
2881
|
+
/>
|
|
2882
|
+
)}
|
|
2883
|
+
</div>
|
|
2884
|
+
{loading && (
|
|
2885
|
+
<div
|
|
2886
|
+
data-slot="gantt-loading"
|
|
2887
|
+
className="bg-background/60 absolute inset-0 z-50 flex items-center justify-center"
|
|
2888
|
+
>
|
|
2889
|
+
<span className="text-muted-foreground animate-pulse text-sm">
|
|
2890
|
+
{settings.i18n.labels.loading}
|
|
2891
|
+
</span>
|
|
2892
|
+
</div>
|
|
2893
|
+
)}
|
|
2894
|
+
{(viewConfig.renderDragPreview || viewConfig.renderResizeIndicator) && (
|
|
2895
|
+
<GanttCustomDragLayer />
|
|
2896
|
+
)}
|
|
2897
|
+
</div>
|
|
2898
|
+
),
|
|
2899
|
+
}
|
|
2900
|
+
|
|
2901
|
+
return useRender({
|
|
2902
|
+
defaultTagName: "div",
|
|
2903
|
+
render,
|
|
2904
|
+
props: mergeProps<"div">(defaultProps, props),
|
|
2905
|
+
})
|
|
2906
|
+
}
|
|
2907
|
+
|
|
2908
|
+
/**
|
|
2909
|
+
* The red now-line, self-ticking: only this component re-renders on the 30s
|
|
2910
|
+
* clock, never the grid around it. z-10 keeps it above row content but UNDER
|
|
2911
|
+
* the sticky header (z-30) - vertical scrolling slides it beneath, never over.
|
|
2912
|
+
*/
|
|
2913
|
+
function GanttNowLine({
|
|
2914
|
+
rangeStartMs,
|
|
2915
|
+
rangeEndMs,
|
|
2916
|
+
}: {
|
|
2917
|
+
rangeStartMs: number
|
|
2918
|
+
rangeEndMs: number
|
|
2919
|
+
}) {
|
|
2920
|
+
const now = useNow()
|
|
2921
|
+
const ms = now.getTime()
|
|
2922
|
+
if (ms < rangeStartMs || ms >= rangeEndMs) return null
|
|
2923
|
+
const fraction = (ms - rangeStartMs) / (rangeEndMs - rangeStartMs)
|
|
2924
|
+
return (
|
|
2925
|
+
<div
|
|
2926
|
+
data-slot="gantt-now-indicator"
|
|
2927
|
+
// comet tail: solid at the cap, dissolving toward the bottom -
|
|
2928
|
+
// present without ruling a hard line through every row
|
|
2929
|
+
className="from-destructive/80 via-destructive/45 to-destructive/15 absolute inset-y-0 z-10 w-px bg-linear-to-b"
|
|
2930
|
+
style={{ insetInlineStart: `${fraction * 100}%` }}
|
|
2931
|
+
/>
|
|
2932
|
+
)
|
|
2933
|
+
}
|
|
2934
|
+
|
|
2935
|
+
/**
|
|
2936
|
+
* The now-line's dot cap, pinned INSIDE the sticky header at the header/body
|
|
2937
|
+
* boundary: it stays put while the line scrolls beneath the header.
|
|
2938
|
+
*/
|
|
2939
|
+
function GanttNowDot({
|
|
2940
|
+
rangeStartMs,
|
|
2941
|
+
rangeEndMs,
|
|
2942
|
+
}: {
|
|
2943
|
+
rangeStartMs: number
|
|
2944
|
+
rangeEndMs: number
|
|
2945
|
+
}) {
|
|
2946
|
+
const now = useNow()
|
|
2947
|
+
const ms = now.getTime()
|
|
2948
|
+
if (ms < rangeStartMs || ms >= rangeEndMs) return null
|
|
2949
|
+
const fraction = (ms - rangeStartMs) / (rangeEndMs - rangeStartMs)
|
|
2950
|
+
return (
|
|
2951
|
+
<span
|
|
2952
|
+
aria-hidden
|
|
2953
|
+
data-slot="gantt-now-dot"
|
|
2954
|
+
className="bg-destructive absolute -bottom-0.75 z-10 size-1.5 -translate-x-1/2 rounded-full"
|
|
2955
|
+
style={{ insetInlineStart: `${fraction * 100}%` }}
|
|
2956
|
+
/>
|
|
2957
|
+
)
|
|
2958
|
+
}
|
|
2959
|
+
|
|
2960
|
+
/**
|
|
2961
|
+
* Consumer-owned drag/resize indicators (renderDragPreview /
|
|
2962
|
+
* renderResizeIndicator): content is React and re-renders per snap step from
|
|
2963
|
+
* drag state; the dnd engine adopts this wrapper and writes its
|
|
2964
|
+
* cursor-tracking transform imperatively, flipping visibility on the first
|
|
2965
|
+
* positioned frame so nothing flashes at the viewport origin.
|
|
2966
|
+
*/
|
|
2967
|
+
function GanttCustomDragLayer() {
|
|
2968
|
+
const viewConfig = useGanttViewConfig()
|
|
2969
|
+
const drag = useGanttSelector((state) => state.drag)
|
|
2970
|
+
if (!drag) return null
|
|
2971
|
+
const render =
|
|
2972
|
+
drag.kind === "move"
|
|
2973
|
+
? viewConfig.renderDragPreview
|
|
2974
|
+
: viewConfig.renderResizeIndicator
|
|
2975
|
+
if (!render) return null
|
|
2976
|
+
return (
|
|
2977
|
+
<div
|
|
2978
|
+
data-slot={
|
|
2979
|
+
drag.kind === "move" ? "gantt-drag-overlay" : "gantt-resize-indicator"
|
|
2980
|
+
}
|
|
2981
|
+
data-custom=""
|
|
2982
|
+
// physical left-0 anchor: positioned by the engine's translate3d from
|
|
2983
|
+
// raw clientX (physical); a logical start-0 would break in RTL
|
|
2984
|
+
className="pointer-events-none fixed top-0 left-0 z-100 will-change-transform"
|
|
2985
|
+
style={{ visibility: "hidden" }}
|
|
2986
|
+
>
|
|
2987
|
+
{render({
|
|
2988
|
+
occurrence: drag.occurrence,
|
|
2989
|
+
kind: drag.kind,
|
|
2990
|
+
start: drag.proposedStart,
|
|
2991
|
+
end: drag.proposedEnd,
|
|
2992
|
+
valid: drag.valid,
|
|
2993
|
+
// the plan rides along so a custom overlay can keep drawing it
|
|
2994
|
+
// mid-gesture (the resting ghost is hidden with the original bar)
|
|
2995
|
+
baseline: resolveEventBaseline(drag.occurrence.event),
|
|
2996
|
+
})}
|
|
2997
|
+
</div>
|
|
2998
|
+
)
|
|
2999
|
+
}
|
|
3000
|
+
|
|
3001
|
+
/**
|
|
3002
|
+
* The dependency-arrow overlay: one SVG whose user unit is 1rem at the
|
|
3003
|
+
* track's natural width (the viewBox mirrors it), stretched to w-full so its
|
|
3004
|
+
* mapping tracks the bars' percentage positions when the zoomed track is
|
|
3005
|
+
* narrower than the pane. While a gesture is in flight the dragged event's
|
|
3006
|
+
* own endpoints re-derive from the proposed range and its edges go dashed -
|
|
3007
|
+
* the arrows point at the drop indicator's landing, never at stale data.
|
|
3008
|
+
* A component of its own so the per-snap drag subscription re-renders a few
|
|
3009
|
+
* paths instead of the whole view.
|
|
3010
|
+
*/
|
|
3011
|
+
const GanttDependencyLayer = memo(function GanttDependencyLayer({
|
|
3012
|
+
edges,
|
|
3013
|
+
totalRem,
|
|
3014
|
+
trackRemWidth,
|
|
3015
|
+
rangeStartMs,
|
|
3016
|
+
rangeEndMs,
|
|
3017
|
+
laneHeightRem,
|
|
3018
|
+
laneGapRem,
|
|
3019
|
+
}: {
|
|
3020
|
+
edges: DependencyEdge[]
|
|
3021
|
+
totalRem: number
|
|
3022
|
+
trackRemWidth: number
|
|
3023
|
+
rangeStartMs: number
|
|
3024
|
+
rangeEndMs: number
|
|
3025
|
+
laneHeightRem: number
|
|
3026
|
+
laneGapRem: number
|
|
3027
|
+
}) {
|
|
3028
|
+
const viewConfig = useGanttViewConfig()
|
|
3029
|
+
// one snapshot per SNAP STEP (the engine already gates setDrag on those);
|
|
3030
|
+
// value-equal at rest, so resting renders never repeat
|
|
3031
|
+
const dragLink = useGanttSelector<
|
|
3032
|
+
unknown,
|
|
3033
|
+
{ eventId: string; startMs: number; endMs: number } | null
|
|
3034
|
+
>(
|
|
3035
|
+
(state) =>
|
|
3036
|
+
state.drag
|
|
3037
|
+
? {
|
|
3038
|
+
eventId: state.drag.occurrence.eventId,
|
|
3039
|
+
startMs: state.drag.proposedStart.getTime(),
|
|
3040
|
+
endMs: state.drag.proposedEnd.getTime(),
|
|
3041
|
+
}
|
|
3042
|
+
: null,
|
|
3043
|
+
{
|
|
3044
|
+
isEqual: (a, b) =>
|
|
3045
|
+
a === b ||
|
|
3046
|
+
(a !== null &&
|
|
3047
|
+
b !== null &&
|
|
3048
|
+
a.eventId === b.eventId &&
|
|
3049
|
+
a.startMs === b.startMs &&
|
|
3050
|
+
a.endMs === b.endMs),
|
|
3051
|
+
}
|
|
3052
|
+
)
|
|
3053
|
+
const totalMin = (rangeEndMs - rangeStartMs) / 60000
|
|
3054
|
+
// clamped exactly like segment minutes, so a live anchor can never leave
|
|
3055
|
+
// the coordinate space the resting anchors were computed in
|
|
3056
|
+
const liveX = (ms: number) =>
|
|
3057
|
+
(Math.min(Math.max((ms - rangeStartMs) / 60000, 0), totalMin) / totalMin) *
|
|
3058
|
+
trackRemWidth
|
|
3059
|
+
return (
|
|
3060
|
+
<svg
|
|
3061
|
+
aria-hidden
|
|
3062
|
+
data-slot="gantt-dependencies"
|
|
3063
|
+
// w-full, NOT the track's rem width: rows fill the pane when the
|
|
3064
|
+
// zoomed track is narrower (min-w-full), and bars position as a
|
|
3065
|
+
// percentage of that STRETCHED box - so the viewBox mapping must
|
|
3066
|
+
// stretch with them or every arrow drifts off its bar. The
|
|
3067
|
+
// non-uniform scale is neutralized on strokes below; arrowheads
|
|
3068
|
+
// lengthen slightly with the fill, which tracks the bars.
|
|
3069
|
+
className={cn(
|
|
3070
|
+
"text-muted-foreground/70 pointer-events-none absolute start-0 top-0 w-full overflow-visible rtl:-scale-x-100",
|
|
3071
|
+
viewConfig.classNames?.dependencies
|
|
3072
|
+
)}
|
|
3073
|
+
style={{ height: `${totalRem}rem` }}
|
|
3074
|
+
viewBox={`0 0 ${trackRemWidth} ${totalRem}`}
|
|
3075
|
+
preserveAspectRatio="none"
|
|
3076
|
+
>
|
|
3077
|
+
{edges.map((edge) => {
|
|
3078
|
+
// y never moves: a gesture stays on its own row and lane
|
|
3079
|
+
const dragged =
|
|
3080
|
+
dragLink !== null &&
|
|
3081
|
+
(edge.fromId === dragLink.eventId || edge.toId === dragLink.eventId)
|
|
3082
|
+
const fromX =
|
|
3083
|
+
dragged && edge.fromId === dragLink.eventId
|
|
3084
|
+
? liveX(dragLink.endMs)
|
|
3085
|
+
: edge.fromX
|
|
3086
|
+
const toX =
|
|
3087
|
+
dragged && edge.toId === dragLink.eventId
|
|
3088
|
+
? liveX(dragLink.startMs)
|
|
3089
|
+
: edge.toX
|
|
3090
|
+
const geometry = buildDependencyPath(
|
|
3091
|
+
{ x: fromX, y: edge.fromY },
|
|
3092
|
+
{ x: toX, y: edge.toY },
|
|
3093
|
+
{
|
|
3094
|
+
clearance: 0.625,
|
|
3095
|
+
// the loop route hops just past the lane edge
|
|
3096
|
+
laneStep: laneHeightRem / 2 + laneGapRem,
|
|
3097
|
+
arrowSize: 0.375,
|
|
3098
|
+
}
|
|
3099
|
+
)
|
|
3100
|
+
return (
|
|
3101
|
+
<g
|
|
3102
|
+
key={edge.key}
|
|
3103
|
+
data-slot="gantt-dependency"
|
|
3104
|
+
data-dragging={dragged || undefined}
|
|
3105
|
+
// in-flight edges stay SOLID - they still describe a real link -
|
|
3106
|
+
// and drop to a lower tone, arrowhead included, while the
|
|
3107
|
+
// endpoint is provisional
|
|
3108
|
+
className={dragged ? "opacity-50" : undefined}
|
|
3109
|
+
>
|
|
3110
|
+
<path
|
|
3111
|
+
d={geometry.line}
|
|
3112
|
+
fill="none"
|
|
3113
|
+
stroke="currentColor"
|
|
3114
|
+
// screen-space px via non-scaling-stroke, matching the
|
|
3115
|
+
// view's 1px gridlines; a viewBox-unit width would fatten
|
|
3116
|
+
// vertical runs whenever the track stretches
|
|
3117
|
+
strokeWidth={1.5}
|
|
3118
|
+
vectorEffect="non-scaling-stroke"
|
|
3119
|
+
/>
|
|
3120
|
+
<path d={geometry.arrow} fill="currentColor" />
|
|
3121
|
+
</g>
|
|
3122
|
+
)
|
|
3123
|
+
})}
|
|
3124
|
+
</svg>
|
|
3125
|
+
)
|
|
3126
|
+
})
|
|
3127
|
+
|
|
3128
|
+
/** Memoized: only rows whose props actually changed re-render. */
|
|
3129
|
+
const GanttTreeRow = memo(function GanttTreeRow({
|
|
3130
|
+
row,
|
|
3131
|
+
heightRem,
|
|
3132
|
+
bandRem,
|
|
3133
|
+
columns,
|
|
3134
|
+
nameWidth,
|
|
3135
|
+
dimmed,
|
|
3136
|
+
selected,
|
|
3137
|
+
onSelectedChange,
|
|
3138
|
+
onGripPointerDown,
|
|
3139
|
+
onToggle,
|
|
3140
|
+
}: {
|
|
3141
|
+
row: TimelineRow
|
|
3142
|
+
heightRem: number
|
|
3143
|
+
bandRem: number
|
|
3144
|
+
columns: GanttColumn[]
|
|
3145
|
+
nameWidth: number
|
|
3146
|
+
dimmed: boolean
|
|
3147
|
+
selected: boolean
|
|
3148
|
+
onSelectedChange?: (id: string, checked: boolean) => void
|
|
3149
|
+
onGripPointerDown?: (e: React.PointerEvent, row: TimelineRow) => void
|
|
3150
|
+
onToggle: (row: TimelineRow) => void
|
|
3151
|
+
}) {
|
|
3152
|
+
const settings = useGanttSettings()
|
|
3153
|
+
const viewConfig = useGanttViewConfig()
|
|
3154
|
+
const ctx = {
|
|
3155
|
+
resource: row.resource,
|
|
3156
|
+
depth: row.depth,
|
|
3157
|
+
isGroup: row.isGroup,
|
|
3158
|
+
collapsed: row.collapsed,
|
|
3159
|
+
}
|
|
3160
|
+
|
|
3161
|
+
// consumer-owned right-click menu, same contract as the bar menu
|
|
3162
|
+
const menu = viewConfig.renderResourceMenu?.(ctx)
|
|
3163
|
+
|
|
3164
|
+
// A node with several schedules grows its row; "start" keeps the label and
|
|
3165
|
+
// its columns on the FIRST schedule's baseline instead of floating them to
|
|
3166
|
+
// the middle of a tall row. Every cell's inner box is minRowHeight tall, so
|
|
3167
|
+
// a single-lane row renders identically either way.
|
|
3168
|
+
const alignStart = (viewConfig.rowAlign ?? DEFAULT_ROW_ALIGN) === "start"
|
|
3169
|
+
|
|
3170
|
+
const rowNode = (
|
|
3171
|
+
<div
|
|
3172
|
+
data-slot="gantt-row-group"
|
|
3173
|
+
data-gantt-row-id={row.resource.id}
|
|
3174
|
+
data-selected={selected || undefined}
|
|
3175
|
+
className={cn(
|
|
3176
|
+
"group/gantt-row data-hover:bg-muted/40 data-selected:bg-primary/5 data-selected:data-hover:bg-primary/5 flex border-b",
|
|
3177
|
+
dimmed && "opacity-50"
|
|
3178
|
+
)}
|
|
3179
|
+
style={{ height: `${heightRem}rem` }}
|
|
3180
|
+
onClick={
|
|
3181
|
+
settings.onResourceClick
|
|
3182
|
+
? (e: React.MouseEvent) => {
|
|
3183
|
+
// chrome clicks (chevron, checkbox, grip) are their own actions
|
|
3184
|
+
if ((e.target as HTMLElement).closest("button, [role=checkbox]"))
|
|
3185
|
+
return
|
|
3186
|
+
settings.onResourceClick?.(ctx, e)
|
|
3187
|
+
}
|
|
3188
|
+
: undefined
|
|
3189
|
+
}
|
|
3190
|
+
onDoubleClick={
|
|
3191
|
+
settings.onResourceDoubleClick
|
|
3192
|
+
? (e: React.MouseEvent) => {
|
|
3193
|
+
if ((e.target as HTMLElement).closest("button, [role=checkbox]"))
|
|
3194
|
+
return
|
|
3195
|
+
settings.onResourceDoubleClick?.(ctx, e)
|
|
3196
|
+
}
|
|
3197
|
+
: undefined
|
|
3198
|
+
}
|
|
3199
|
+
>
|
|
3200
|
+
<div className="flex h-full w-full min-w-0">
|
|
3201
|
+
{/* In-flow name cell: the whole tree row scrolls horizontally as one;
|
|
3202
|
+
row-level hover/selected tints show through the transparent cell */}
|
|
3203
|
+
<div
|
|
3204
|
+
data-slot="gantt-tree-cell"
|
|
3205
|
+
className={cn(
|
|
3206
|
+
// ps-3 keeps the row toggles off the left edge (kept in sync with
|
|
3207
|
+
// namePaddingStart above so headers stay aligned with row titles)
|
|
3208
|
+
"flex shrink-0 ps-3 pe-3",
|
|
3209
|
+
alignStart ? "items-start" : "items-center",
|
|
3210
|
+
row.isGroup && "font-medium"
|
|
3211
|
+
)}
|
|
3212
|
+
style={{ width: nameWidth }}
|
|
3213
|
+
>
|
|
3214
|
+
{/* exactly the band the first schedule occupies: same height, both
|
|
3215
|
+
top-anchored, so the label and that schedule share a centerline */}
|
|
3216
|
+
<div
|
|
3217
|
+
className="flex w-full min-w-0 items-center"
|
|
3218
|
+
style={{ height: `${bandRem}rem` }}
|
|
3219
|
+
>
|
|
3220
|
+
{onGripPointerDown && (
|
|
3221
|
+
<button
|
|
3222
|
+
type="button"
|
|
3223
|
+
data-slot="gantt-row-grip"
|
|
3224
|
+
aria-label={settings.i18n.labels.reorder}
|
|
3225
|
+
// pointer-coarse keeps the grip visible on touch (no hover there).
|
|
3226
|
+
// -ms-1.5/me-1.5 nudge the grip toward the row edge and open a
|
|
3227
|
+
// gap before the checkbox so it's easy to grab without catching
|
|
3228
|
+
// the checkbox; the equal start/end margins keep the grip's
|
|
3229
|
+
// footprint net-zero, so the title stays aligned with the header.
|
|
3230
|
+
className="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"
|
|
3231
|
+
onPointerDown={(e) => onGripPointerDown(e, row)}
|
|
3232
|
+
onClick={(e) => e.stopPropagation()}
|
|
3233
|
+
>
|
|
3234
|
+
<GripVerticalIcon className="size-3" aria-hidden="true" />
|
|
3235
|
+
</button>
|
|
3236
|
+
)}
|
|
3237
|
+
{/* per-level indent keeps sibling titles on one x */}
|
|
3238
|
+
<span
|
|
3239
|
+
aria-hidden
|
|
3240
|
+
className="shrink-0"
|
|
3241
|
+
style={{ width: `${row.depth * 0.875}rem` }}
|
|
3242
|
+
/>
|
|
3243
|
+
{/* fixed gutter: groups toggle here, leaves carry the checkbox -
|
|
3244
|
+
titles of one level share the same x either way */}
|
|
3245
|
+
<span className="me-1 flex w-5 shrink-0 items-center justify-start">
|
|
3246
|
+
{row.isGroup ? (
|
|
3247
|
+
<Button
|
|
3248
|
+
variant="ghost"
|
|
3249
|
+
size="icon-xs"
|
|
3250
|
+
aria-expanded={!row.collapsed}
|
|
3251
|
+
aria-label={row.resource.title}
|
|
3252
|
+
className={cn(
|
|
3253
|
+
"size-5! aria-expanded:bg-transparent!",
|
|
3254
|
+
row.collapsed
|
|
3255
|
+
? "text-foreground"
|
|
3256
|
+
: "text-muted-foreground aria-expanded:text-muted-foreground! hover:text-foreground!"
|
|
3257
|
+
)}
|
|
3258
|
+
onClick={() => onToggle(row)}
|
|
3259
|
+
>
|
|
3260
|
+
<ChevronRightIcon
|
|
3261
|
+
className={cn(
|
|
3262
|
+
"size-3.5 transition-transform",
|
|
3263
|
+
!row.collapsed && "rotate-90"
|
|
3264
|
+
)}
|
|
3265
|
+
aria-hidden="true"
|
|
3266
|
+
/>
|
|
3267
|
+
</Button>
|
|
3268
|
+
) : (
|
|
3269
|
+
viewConfig.rowCheckboxes &&
|
|
3270
|
+
onSelectedChange && (
|
|
3271
|
+
<Checkbox
|
|
3272
|
+
data-slot="gantt-row-checkbox"
|
|
3273
|
+
checked={selected}
|
|
3274
|
+
onCheckedChange={(checked: boolean) =>
|
|
3275
|
+
onSelectedChange(row.resource.id, checked)
|
|
3276
|
+
}
|
|
3277
|
+
aria-label={row.resource.title}
|
|
3278
|
+
className={cn(
|
|
3279
|
+
"size-3.5 opacity-0 transition-opacity group-hover/gantt-row:opacity-100 group-data-hover/gantt-row:opacity-100 focus-visible:opacity-100",
|
|
3280
|
+
selected && "opacity-100"
|
|
3281
|
+
)}
|
|
3282
|
+
/>
|
|
3283
|
+
)
|
|
3284
|
+
)}
|
|
3285
|
+
</span>
|
|
3286
|
+
{viewConfig.renderResourceLabel?.(ctx) ?? (
|
|
3287
|
+
<span className="truncate">{row.resource.title}</span>
|
|
3288
|
+
)}
|
|
3289
|
+
</div>
|
|
3290
|
+
</div>
|
|
3291
|
+
{columns.map((column) => (
|
|
3292
|
+
<div
|
|
3293
|
+
key={column.id}
|
|
3294
|
+
data-slot="gantt-tree-column-cell"
|
|
3295
|
+
data-column={column.id}
|
|
3296
|
+
className={cn(
|
|
3297
|
+
"flex shrink-0 px-2",
|
|
3298
|
+
alignStart ? "items-start" : "items-center",
|
|
3299
|
+
column.className
|
|
3300
|
+
)}
|
|
3301
|
+
style={{ width: column.width ?? DEFAULT_COLUMN_WIDTH }}
|
|
3302
|
+
>
|
|
3303
|
+
<div
|
|
3304
|
+
className={cn(
|
|
3305
|
+
"flex w-full min-w-0 items-center",
|
|
3306
|
+
column.align === "center" && "justify-center",
|
|
3307
|
+
column.align === "end" && "justify-end"
|
|
3308
|
+
)}
|
|
3309
|
+
style={{ height: `${bandRem}rem` }}
|
|
3310
|
+
>
|
|
3311
|
+
{column.render?.(ctx)}
|
|
3312
|
+
</div>
|
|
3313
|
+
</div>
|
|
3314
|
+
))}
|
|
3315
|
+
<div className="min-w-0 flex-1" />
|
|
3316
|
+
</div>
|
|
3317
|
+
</div>
|
|
3318
|
+
)
|
|
3319
|
+
|
|
3320
|
+
if (!menu) return rowNode
|
|
3321
|
+
return (
|
|
3322
|
+
<ContextMenu>
|
|
3323
|
+
<ContextMenuTrigger render={rowNode} />
|
|
3324
|
+
<ContextMenuContent data-slot="gantt-resource-menu" className="min-w-44">
|
|
3325
|
+
{menu}
|
|
3326
|
+
</ContextMenuContent>
|
|
3327
|
+
</ContextMenu>
|
|
3328
|
+
)
|
|
3329
|
+
})
|
|
3330
|
+
|
|
3331
|
+
const GanttTimelineRow = memo(function GanttTimelineRow({
|
|
3332
|
+
row,
|
|
3333
|
+
rowIndex,
|
|
3334
|
+
bars,
|
|
3335
|
+
rangeStartMs,
|
|
3336
|
+
rangeEndMs,
|
|
3337
|
+
trackWidth,
|
|
3338
|
+
trackRemWidth,
|
|
3339
|
+
rowBorder,
|
|
3340
|
+
selected,
|
|
3341
|
+
resolveHintStop,
|
|
3342
|
+
isPanning,
|
|
3343
|
+
laneHeightRem,
|
|
3344
|
+
laneGapRem,
|
|
3345
|
+
minRowRem,
|
|
3346
|
+
ghostHeightRem,
|
|
3347
|
+
dependencySourceIds,
|
|
3348
|
+
}: {
|
|
3349
|
+
row: TimelineRow
|
|
3350
|
+
rowIndex: number
|
|
3351
|
+
bars: TimelineRowBars | undefined
|
|
3352
|
+
rangeStartMs: number
|
|
3353
|
+
rangeEndMs: number
|
|
3354
|
+
trackWidth: string
|
|
3355
|
+
trackRemWidth: number
|
|
3356
|
+
rowBorder: "solid" | "dashed" | null
|
|
3357
|
+
selected: boolean
|
|
3358
|
+
resolveHintStop: (
|
|
3359
|
+
fraction: number
|
|
3360
|
+
) => { index: number; center: number; ms: number; endMs: number } | null
|
|
3361
|
+
isPanning: boolean
|
|
3362
|
+
laneHeightRem: number
|
|
3363
|
+
laneGapRem: number
|
|
3364
|
+
minRowRem: number
|
|
3365
|
+
ghostHeightRem: number
|
|
3366
|
+
dependencySourceIds: ReadonlySet<string>
|
|
3367
|
+
}) {
|
|
3368
|
+
const instance = useGantt()
|
|
3369
|
+
const settings = useGanttSettings()
|
|
3370
|
+
const viewConfig = useGanttViewConfig()
|
|
3371
|
+
const gestures = useGanttGestures()
|
|
3372
|
+
const segments = bars?.segments ?? []
|
|
3373
|
+
// parents aggregate their subtree; they take no direct scheduling gestures
|
|
3374
|
+
const schedulable = !row.isGroup || viewConfig.parentScheduling
|
|
3375
|
+
const heightRem = bars?.heightRem ?? minRowRem
|
|
3376
|
+
const laneCount = bars?.laneCount ?? 1
|
|
3377
|
+
const singleTrack =
|
|
3378
|
+
resolveScheduleMode(row.resource, viewConfig.scheduleMode) === "single"
|
|
3379
|
+
// shared with the tree pane so the two can never drift apart
|
|
3380
|
+
const laneOffsetRem = bars?.laneOffsetRem ?? (minRowRem - laneHeightRem) / 2
|
|
3381
|
+
|
|
3382
|
+
// Hover affordance over empty track space: the ghost tile snaps to the
|
|
3383
|
+
// unit under the cursor, so the (real) tooltip re-anchors per unit
|
|
3384
|
+
const [hintStop, setHintStop] = useState<{
|
|
3385
|
+
index: number
|
|
3386
|
+
center: number
|
|
3387
|
+
ms: number
|
|
3388
|
+
endMs: number
|
|
3389
|
+
} | null>(null)
|
|
3390
|
+
// Gated on an active hint: rows without one read a stable false, so a
|
|
3391
|
+
// gesture starting/ending anywhere doesn't re-render every row.
|
|
3392
|
+
const hintSuppressed = useGanttSelector<unknown, boolean>(
|
|
3393
|
+
(state) =>
|
|
3394
|
+
hintStop !== null && (state.drag !== null || state.slotDraft !== null)
|
|
3395
|
+
)
|
|
3396
|
+
const canSchedule = useGanttSelector<unknown, boolean>(
|
|
3397
|
+
(state) => state.interactions.selectSlot
|
|
3398
|
+
)
|
|
3399
|
+
// The affordance is offered ANYWHERE on a schedulable row - over bare track
|
|
3400
|
+
// and over existing bars alike - because a row can always take another
|
|
3401
|
+
// schedule on a free lane. The primitive deliberately owns NO opinion about
|
|
3402
|
+
// what may land where: that is `canSelectSlot`, the consumer's call. (It
|
|
3403
|
+
// used to withhold the hint on a one-track row that already held a
|
|
3404
|
+
// schedule, which also blocked adding a second NON-overlapping one.)
|
|
3405
|
+
// The lowest track FREE at the hovered time. Two jobs, kept separate on
|
|
3406
|
+
// purpose:
|
|
3407
|
+
// - VISIBILITY: `hintFreeLane < laneCount` is what proves an empty track
|
|
3408
|
+
// actually exists to draw on. It is computed regardless of mode, because
|
|
3409
|
+
// a "single" row whose only track is booked has nowhere free either, and
|
|
3410
|
+
// pinning the placement to 0 there would put the ring straight on the bar.
|
|
3411
|
+
// - PLACEMENT: "single" packs everything onto track 0, so that is where its
|
|
3412
|
+
// ring belongs; otherwise the ring sits on the track the schedule will
|
|
3413
|
+
// actually land on. Lane is a function of TIME only, so moving the pointer
|
|
3414
|
+
// down onto the ring never moves it away from you.
|
|
3415
|
+
const hintFreeLane = hintStop
|
|
3416
|
+
? lowestFreeLane(segments, hintStop.ms, hintStop.endMs)
|
|
3417
|
+
: 0
|
|
3418
|
+
const hintLane = bars?.scheduleMode === "single" ? 0 : hintFreeLane
|
|
3419
|
+
const hintTopRem = Math.min(
|
|
3420
|
+
laneTopRem(laneOffsetRem, hintLane, laneHeightRem, laneGapRem) +
|
|
3421
|
+
laneHeightRem / 2,
|
|
3422
|
+
Math.max(heightRem - laneHeightRem / 2, laneHeightRem / 2)
|
|
3423
|
+
)
|
|
3424
|
+
// Single source of truth for "the add affordance is live at this spot". The
|
|
3425
|
+
// ring renders on it AND the row takes its cursor from it, so the pointer can
|
|
3426
|
+
// never promise something the ring is not offering.
|
|
3427
|
+
const hintVisible =
|
|
3428
|
+
hintStop !== null &&
|
|
3429
|
+
hintFreeLane < laneCount &&
|
|
3430
|
+
!hintSuppressed &&
|
|
3431
|
+
!isPanning
|
|
3432
|
+
// name the gesture that is actually wired, not a generic one
|
|
3433
|
+
const hintLabel = viewConfig.dragCreate
|
|
3434
|
+
? settings.i18n.labels.scheduleHintDrag
|
|
3435
|
+
: settings.i18n.labels.scheduleHint
|
|
3436
|
+
const showHint =
|
|
3437
|
+
viewConfig.displayScheduleHint &&
|
|
3438
|
+
schedulable &&
|
|
3439
|
+
canSchedule &&
|
|
3440
|
+
!isPanning &&
|
|
3441
|
+
!!(settings.onSelectSlot || settings.onSlotClick)
|
|
3442
|
+
|
|
3443
|
+
const fractionOf = (ms: number) =>
|
|
3444
|
+
Math.min(Math.max((ms - rangeStartMs) / (rangeEndMs - rangeStartMs), 0), 1)
|
|
3445
|
+
|
|
3446
|
+
const dragTarget = useGanttSelector<unknown, "valid" | "invalid" | null>(
|
|
3447
|
+
(state) => {
|
|
3448
|
+
const drag = state.drag
|
|
3449
|
+
if (!drag || drag.proposedResourceId !== row.resource.id) return null
|
|
3450
|
+
return drag.valid ? "valid" : "invalid"
|
|
3451
|
+
}
|
|
3452
|
+
)
|
|
3453
|
+
const ghost = useGanttSelector<
|
|
3454
|
+
unknown,
|
|
3455
|
+
{
|
|
3456
|
+
from: number
|
|
3457
|
+
to: number
|
|
3458
|
+
color?: string
|
|
3459
|
+
valid: boolean
|
|
3460
|
+
title: string
|
|
3461
|
+
kind: string
|
|
3462
|
+
occurrenceKey: string
|
|
3463
|
+
milestone: boolean
|
|
3464
|
+
} | null
|
|
3465
|
+
>(
|
|
3466
|
+
(state) => {
|
|
3467
|
+
const drag = state.drag
|
|
3468
|
+
if (!drag || drag.proposedResourceId !== row.resource.id) return null
|
|
3469
|
+
return {
|
|
3470
|
+
from: fractionOf(drag.proposedStart.getTime()),
|
|
3471
|
+
to: fractionOf(drag.proposedEnd.getTime()),
|
|
3472
|
+
color: drag.occurrence.event.color,
|
|
3473
|
+
valid: drag.valid,
|
|
3474
|
+
title: drag.occurrence.event.title,
|
|
3475
|
+
kind: drag.kind,
|
|
3476
|
+
occurrenceKey: drag.occurrence.key,
|
|
3477
|
+
// real zero duration, not from==to: clamped fractions also coincide
|
|
3478
|
+
// for a bar dragged clear past a range edge
|
|
3479
|
+
milestone: drag.proposedEnd.getTime() === drag.proposedStart.getTime(),
|
|
3480
|
+
}
|
|
3481
|
+
},
|
|
3482
|
+
{
|
|
3483
|
+
isEqual: (a, b) =>
|
|
3484
|
+
a === b ||
|
|
3485
|
+
(a !== null &&
|
|
3486
|
+
b !== null &&
|
|
3487
|
+
a.from === b.from &&
|
|
3488
|
+
a.to === b.to &&
|
|
3489
|
+
a.valid === b.valid),
|
|
3490
|
+
}
|
|
3491
|
+
)
|
|
3492
|
+
const draft = useGanttSelector<
|
|
3493
|
+
unknown,
|
|
3494
|
+
{
|
|
3495
|
+
from: number
|
|
3496
|
+
to: number
|
|
3497
|
+
startMs: number
|
|
3498
|
+
endMs: number
|
|
3499
|
+
} | null
|
|
3500
|
+
>(
|
|
3501
|
+
(state) => {
|
|
3502
|
+
const slotDraft = state.slotDraft
|
|
3503
|
+
if (!slotDraft || slotDraft.resourceId !== row.resource.id) return null
|
|
3504
|
+
const startMs = slotDraft.start.getTime()
|
|
3505
|
+
const endMs = slotDraft.end.getTime()
|
|
3506
|
+
return {
|
|
3507
|
+
from: fractionOf(startMs),
|
|
3508
|
+
to: fractionOf(endMs),
|
|
3509
|
+
startMs,
|
|
3510
|
+
endMs,
|
|
3511
|
+
}
|
|
3512
|
+
},
|
|
3513
|
+
{
|
|
3514
|
+
// from/to as well as the instants: they are derived through fractionOf,
|
|
3515
|
+
// which closes over the range. An extendRange mid-gesture moves every
|
|
3516
|
+
// bar while an instants-only compare serves the cached (stale) fractions,
|
|
3517
|
+
// leaving the placeholder pinned to where the range used to be.
|
|
3518
|
+
isEqual: (a, b) =>
|
|
3519
|
+
a === b ||
|
|
3520
|
+
(a !== null &&
|
|
3521
|
+
b !== null &&
|
|
3522
|
+
a.startMs === b.startMs &&
|
|
3523
|
+
a.endMs === b.endMs &&
|
|
3524
|
+
a.from === b.from &&
|
|
3525
|
+
a.to === b.to),
|
|
3526
|
+
}
|
|
3527
|
+
)
|
|
3528
|
+
|
|
3529
|
+
/**
|
|
3530
|
+
* Where the schedule being painted will land - resolved in the shared layout
|
|
3531
|
+
* memo, which also RESERVES that track, so the row has already grown to hold
|
|
3532
|
+
* it. The clamp is only a backstop for the frame between a draft appearing
|
|
3533
|
+
* and the layout catching up.
|
|
3534
|
+
*/
|
|
3535
|
+
const draftLane = bars?.draftLane ?? 0
|
|
3536
|
+
const draftTopRem = Math.min(
|
|
3537
|
+
laneTopRem(laneOffsetRem, draftLane, laneHeightRem, laneGapRem),
|
|
3538
|
+
Math.max(heightRem - laneHeightRem, 0)
|
|
3539
|
+
)
|
|
3540
|
+
// the range you are painting, named while you paint it - a drag that shows
|
|
3541
|
+
// no times asks you to guess where you let go
|
|
3542
|
+
const draftLabel = draft
|
|
3543
|
+
? settings.i18n.functions.formatEventTime(
|
|
3544
|
+
toZoned(new Date(draft.startMs), settings.timeZone),
|
|
3545
|
+
toZoned(new Date(draft.endMs), settings.timeZone),
|
|
3546
|
+
false,
|
|
3547
|
+
settings.locale
|
|
3548
|
+
)
|
|
3549
|
+
: ""
|
|
3550
|
+
|
|
3551
|
+
/**
|
|
3552
|
+
* The row's create contract, in one place: onSlotClick if the consumer
|
|
3553
|
+
* wired it, else a ready-made slot draft. Both the hint tile and a plain
|
|
3554
|
+
* click on bare track go through this, so clicking anywhere placeable
|
|
3555
|
+
* behaves the same as clicking the tile.
|
|
3556
|
+
*/
|
|
3557
|
+
const createAt = (
|
|
3558
|
+
stop: { ms: number; endMs: number },
|
|
3559
|
+
e: React.MouseEvent
|
|
3560
|
+
) => {
|
|
3561
|
+
if (settings.onSlotClick) {
|
|
3562
|
+
settings.onSlotClick(
|
|
3563
|
+
{ date: new Date(stop.ms), allDay: false, resourceId: row.resource.id },
|
|
3564
|
+
e
|
|
3565
|
+
)
|
|
3566
|
+
} else {
|
|
3567
|
+
settings.onSelectSlot?.({
|
|
3568
|
+
start: new Date(stop.ms),
|
|
3569
|
+
end: new Date(Math.max(stop.endMs, stop.ms + 1)),
|
|
3570
|
+
allDay: false,
|
|
3571
|
+
resourceId: row.resource.id,
|
|
3572
|
+
})
|
|
3573
|
+
}
|
|
3574
|
+
}
|
|
3575
|
+
|
|
3576
|
+
// The drop indicator belongs on the lane the dragged schedule actually
|
|
3577
|
+
// occupies. The gesture has not committed, so the segment is still packed
|
|
3578
|
+
// under its pre-drag key - no second packing pass, just a lookup.
|
|
3579
|
+
const ghostLane = ghost
|
|
3580
|
+
? (segments.find(
|
|
3581
|
+
(segment) => segment.occurrence.key === ghost.occurrenceKey
|
|
3582
|
+
)?.column ?? 0)
|
|
3583
|
+
: 0
|
|
3584
|
+
const ghostLaneOffsetRem =
|
|
3585
|
+
laneTopRem(laneOffsetRem, ghostLane, laneHeightRem, laneGapRem) +
|
|
3586
|
+
(laneHeightRem - ghostHeightRem) / 2
|
|
3587
|
+
|
|
3588
|
+
return (
|
|
3589
|
+
<div
|
|
3590
|
+
data-gantt-row=""
|
|
3591
|
+
data-gantt-resource={row.resource.id}
|
|
3592
|
+
data-gantt-row-id={row.resource.id}
|
|
3593
|
+
data-gantt-row-static={!schedulable || undefined}
|
|
3594
|
+
data-gantt-bar-min={bars?.extent?.from}
|
|
3595
|
+
data-gantt-bar-max={bars?.extent?.to}
|
|
3596
|
+
data-gantt-bar-color={bars?.extent?.color}
|
|
3597
|
+
data-gantt-bar-label={bars?.extent?.label}
|
|
3598
|
+
data-gantt-bar-start-ms={bars?.extent?.startMs}
|
|
3599
|
+
data-drop-target={dragTarget ?? undefined}
|
|
3600
|
+
data-selected={selected || undefined}
|
|
3601
|
+
className={cn(
|
|
3602
|
+
"data-hover:bg-muted/30 data-selected:bg-primary/5 data-selected:data-hover:bg-primary/5 relative w-full min-w-0",
|
|
3603
|
+
// The ring is pointer-events-none, so a cursor set on IT can never be
|
|
3604
|
+
// reached - the row is the element actually under the pointer, so the
|
|
3605
|
+
// cursor belongs here. Gated on the same flag as the ring: wherever the
|
|
3606
|
+
// add affordance shows, the pointer says "add", and nowhere else.
|
|
3607
|
+
hintVisible && "cursor-crosshair",
|
|
3608
|
+
// horizontal separators mirror the tree node borders across panes.
|
|
3609
|
+
// No special case for the last row: the columns run the full height of
|
|
3610
|
+
// the pane, so the grid closes on the container edge on its own.
|
|
3611
|
+
rowBorder !== null && "border-b",
|
|
3612
|
+
rowBorder === "dashed" && "border-dashed",
|
|
3613
|
+
dragTarget === "valid" && "bg-muted/40",
|
|
3614
|
+
dragTarget === "invalid" && "bg-destructive/10"
|
|
3615
|
+
)}
|
|
3616
|
+
style={{
|
|
3617
|
+
height: `${heightRem}rem`,
|
|
3618
|
+
minWidth: trackWidth,
|
|
3619
|
+
}}
|
|
3620
|
+
onPointerDown={(e) => {
|
|
3621
|
+
// Opt-in drag-create owns presses on empty schedulable track (the
|
|
3622
|
+
// onSelectSlot contract); otherwise the press bubbles to the
|
|
3623
|
+
// container and pans the timeline.
|
|
3624
|
+
if (
|
|
3625
|
+
viewConfig.dragCreate &&
|
|
3626
|
+
e.button === 0 &&
|
|
3627
|
+
schedulable &&
|
|
3628
|
+
canSchedule &&
|
|
3629
|
+
// the bare track, or the hint tile that spawns under the cursor -
|
|
3630
|
+
// a sub-threshold press still ends as the tile's own click
|
|
3631
|
+
(e.target === e.currentTarget ||
|
|
3632
|
+
!!(e.target as HTMLElement).closest?.(
|
|
3633
|
+
"[data-slot=gantt-schedule-hint]"
|
|
3634
|
+
)) &&
|
|
3635
|
+
!!settings.onSelectSlot
|
|
3636
|
+
) {
|
|
3637
|
+
// validate BEFORE claiming the press: on a vetoed slot (e.g. the
|
|
3638
|
+
// one-schedule-per-task rule) the press falls through to the pan
|
|
3639
|
+
const stop = resolveHintStop(
|
|
3640
|
+
trackFraction(e.currentTarget, e.clientX)
|
|
3641
|
+
)
|
|
3642
|
+
const allowed =
|
|
3643
|
+
stop !== null &&
|
|
3644
|
+
(settings.canSelectSlot?.({
|
|
3645
|
+
start: new Date(stop.ms),
|
|
3646
|
+
end: new Date(Math.max(stop.endMs, stop.ms + 1)),
|
|
3647
|
+
allDay: false,
|
|
3648
|
+
resourceId: row.resource.id,
|
|
3649
|
+
}) ??
|
|
3650
|
+
true)
|
|
3651
|
+
if (!allowed) return
|
|
3652
|
+
e.stopPropagation()
|
|
3653
|
+
gestures.beginCreate(e)
|
|
3654
|
+
}
|
|
3655
|
+
}}
|
|
3656
|
+
onPointerMove={(e) => {
|
|
3657
|
+
// read interaction state imperatively - a subscription here would
|
|
3658
|
+
// re-render the row for every gesture anywhere on the grid
|
|
3659
|
+
const interacting =
|
|
3660
|
+
instance.getState().drag !== null ||
|
|
3661
|
+
instance.getState().slotDraft !== null
|
|
3662
|
+
if (!showHint || e.pointerType !== "mouse" || interacting) {
|
|
3663
|
+
if (hintStop) setHintStop(null)
|
|
3664
|
+
return
|
|
3665
|
+
}
|
|
3666
|
+
// EMPTY TRACK ONLY. A bar under the pointer means the pointer is not
|
|
3667
|
+
// on an empty slot, so the affordance goes away entirely rather than
|
|
3668
|
+
// hovering over booked time - and the bar is left completely alone,
|
|
3669
|
+
// hover and click both. (No "pointer is over the tile" bail: the ring
|
|
3670
|
+
// is pointer-events-none, so it is never the target; a bail on it would
|
|
3671
|
+
// freeze it on the spot, since it rides under the cursor.)
|
|
3672
|
+
if (e.target !== e.currentTarget) {
|
|
3673
|
+
if (hintStop) setHintStop(null)
|
|
3674
|
+
return
|
|
3675
|
+
}
|
|
3676
|
+
const { fraction, offset } = trackPoint(e.currentTarget, e.clientX)
|
|
3677
|
+
// The dot follows the pointer FREELY - it is a cursor, not a cell, so
|
|
3678
|
+
// it is never quantised to the interval grid. Its position is written
|
|
3679
|
+
// as a CSS custom property straight onto the row: the cursor moves
|
|
3680
|
+
// every frame and a state update per frame would re-render the row and
|
|
3681
|
+
// every bar in it. React state still owns WHICH slot is offered, and
|
|
3682
|
+
// that changes only once per interval.
|
|
3683
|
+
//
|
|
3684
|
+
// In pixels rather than a percentage, pre-snapped by trackPoint to the
|
|
3685
|
+
// viewport pixel grid: a fractional inset makes the browser antialias
|
|
3686
|
+
// the ring and the glyph across two device pixels, which reads as a
|
|
3687
|
+
// furry, smudged dot. -translate-x-1/2 of an even-sized box keeps it on
|
|
3688
|
+
// the grid, so the result is crisp.
|
|
3689
|
+
e.currentTarget.style.setProperty("--gantt-hint-x", `${offset}px`)
|
|
3690
|
+
const stop = resolveHintStop(fraction)
|
|
3691
|
+
// validate placement before offering it: a consumer canSelectSlot
|
|
3692
|
+
// veto (e.g. a locked span) hides the hint entirely
|
|
3693
|
+
const allowed =
|
|
3694
|
+
stop !== null &&
|
|
3695
|
+
(settings.canSelectSlot?.({
|
|
3696
|
+
start: new Date(stop.ms),
|
|
3697
|
+
end: new Date(Math.max(stop.endMs, stop.ms + 1)),
|
|
3698
|
+
allDay: false,
|
|
3699
|
+
resourceId: row.resource.id,
|
|
3700
|
+
}) ??
|
|
3701
|
+
true)
|
|
3702
|
+
const next = allowed ? stop : null
|
|
3703
|
+
// state changes only when the cursor crosses into another unit
|
|
3704
|
+
if (next?.index !== hintStop?.index) setHintStop(next)
|
|
3705
|
+
}}
|
|
3706
|
+
onPointerLeave={() => {
|
|
3707
|
+
if (hintStop) setHintStop(null)
|
|
3708
|
+
}}
|
|
3709
|
+
onClick={(e) => {
|
|
3710
|
+
// With dragCreate the press belongs to the create GESTURE, which only
|
|
3711
|
+
// activates past its movement threshold - so a click that never moved
|
|
3712
|
+
// committed nothing and the row felt dead. Treat it as a create at the
|
|
3713
|
+
// hovered slot, using the same validation the hint tile uses.
|
|
3714
|
+
if (!viewConfig.dragCreate || e.target !== e.currentTarget) return
|
|
3715
|
+
if (!schedulable || !canSchedule) return
|
|
3716
|
+
if (wasRecentDrag()) return
|
|
3717
|
+
if (!settings.onSlotClick && !settings.onSelectSlot) return
|
|
3718
|
+
const stop = resolveHintStop(trackFraction(e.currentTarget, e.clientX))
|
|
3719
|
+
if (!stop) return
|
|
3720
|
+
const allowed =
|
|
3721
|
+
settings.canSelectSlot?.({
|
|
3722
|
+
start: new Date(stop.ms),
|
|
3723
|
+
end: new Date(Math.max(stop.endMs, stop.ms + 1)),
|
|
3724
|
+
allDay: false,
|
|
3725
|
+
resourceId: row.resource.id,
|
|
3726
|
+
}) ?? true
|
|
3727
|
+
if (!allowed) return
|
|
3728
|
+
createAt(stop, e)
|
|
3729
|
+
setHintStop(null)
|
|
3730
|
+
}}
|
|
3731
|
+
>
|
|
3732
|
+
{/* row CONTENT (bars, labels, ghosts). Pointer-transparent so
|
|
3733
|
+
empty-track presses still hit the row itself.
|
|
3734
|
+
content-visibility lets the browser skip rendering this layer for
|
|
3735
|
+
rows scrolled out of view (large trees stay cheap on low-end
|
|
3736
|
+
devices). Safe here: the layer is absolute inset-0 (geometry comes
|
|
3737
|
+
from the row, never from content), its paint containment keeps it
|
|
3738
|
+
a stacking context, and nothing inside escapes the row box - the
|
|
3739
|
+
schedule hint deliberately lives OUTSIDE this layer. Browsers
|
|
3740
|
+
without support simply ignore it. */}
|
|
3741
|
+
<div
|
|
3742
|
+
className="pointer-events-none absolute inset-0"
|
|
3743
|
+
style={{ contentVisibility: "auto" }}
|
|
3744
|
+
>
|
|
3745
|
+
{/* The node's own planned window: a band behind ALL of its lanes,
|
|
3746
|
+
first in the layer so even the per-event ghosts paint over it. */}
|
|
3747
|
+
{bars?.rowBaseline &&
|
|
3748
|
+
(() => {
|
|
3749
|
+
const plan = bars.rowBaseline
|
|
3750
|
+
const from = fractionOf(rangeStartMs + plan.startMin * 60000)
|
|
3751
|
+
const to = fractionOf(rangeStartMs + plan.endMin * 60000)
|
|
3752
|
+
const bandRemHeight = laneBlockRem(
|
|
3753
|
+
laneCount,
|
|
3754
|
+
laneHeightRem,
|
|
3755
|
+
laneGapRem
|
|
3756
|
+
)
|
|
3757
|
+
const content = viewConfig.renderRowBaseline?.({
|
|
3758
|
+
resource: row.resource,
|
|
3759
|
+
start: plan.baseline.start,
|
|
3760
|
+
end: plan.baseline.end,
|
|
3761
|
+
milestone: plan.baseline.milestone,
|
|
3762
|
+
variance: plan.variance,
|
|
3763
|
+
})
|
|
3764
|
+
return (
|
|
3765
|
+
<div
|
|
3766
|
+
data-slot="gantt-row-baseline"
|
|
3767
|
+
data-milestone={plan.baseline.milestone || undefined}
|
|
3768
|
+
data-variance={plan.variance ?? undefined}
|
|
3769
|
+
aria-hidden
|
|
3770
|
+
className={cn(
|
|
3771
|
+
"absolute",
|
|
3772
|
+
plan.baseline.milestone
|
|
3773
|
+
? "flex items-center justify-center"
|
|
3774
|
+
: "px-px",
|
|
3775
|
+
viewConfig.classNames?.baseline
|
|
3776
|
+
)}
|
|
3777
|
+
style={
|
|
3778
|
+
{
|
|
3779
|
+
"--gantt-event-color":
|
|
3780
|
+
row.resource.color ?? "var(--color-primary)",
|
|
3781
|
+
insetInlineStart: plan.baseline.milestone
|
|
3782
|
+
? `clamp(0rem, ${from * 100}% - ${laneHeightRem / 2}rem, 100% - ${laneHeightRem}rem)`
|
|
3783
|
+
: `${from * 100}%`,
|
|
3784
|
+
width: plan.baseline.milestone
|
|
3785
|
+
? `${laneHeightRem}rem`
|
|
3786
|
+
: `${Math.max((to - from) * 100, 0.5)}%`,
|
|
3787
|
+
top: `${laneOffsetRem}rem`,
|
|
3788
|
+
height: `${bandRemHeight}rem`,
|
|
3789
|
+
} as CSSProperties
|
|
3790
|
+
}
|
|
3791
|
+
>
|
|
3792
|
+
{content ??
|
|
3793
|
+
(plan.baseline.milestone ? (
|
|
3794
|
+
// hollow diamond marking the node's planned instant
|
|
3795
|
+
<span className="size-2.5 rotate-45 rounded-[2px] border border-(--gantt-event-color)/60 bg-(--gantt-event-color)/15" />
|
|
3796
|
+
) : (
|
|
3797
|
+
// fainter than a per-event ghost: a whole-row context
|
|
3798
|
+
// band must never compete with the bars on it
|
|
3799
|
+
<span className="relative block h-full w-full rounded-sm border border-(--gantt-event-color)/25 bg-(--gantt-event-color)/6" />
|
|
3800
|
+
))}
|
|
3801
|
+
</div>
|
|
3802
|
+
)
|
|
3803
|
+
})()}
|
|
3804
|
+
{/* Baseline ghosts BEFORE the bars, with no z-index: DOM order keeps
|
|
3805
|
+
them painting under every bar (z 10+), and the layer's pointer
|
|
3806
|
+
transparency means they can never catch a press or a drop. */}
|
|
3807
|
+
{bars?.baselines.map((plan) => {
|
|
3808
|
+
const from = fractionOf(rangeStartMs + plan.startMin * 60000)
|
|
3809
|
+
const to = fractionOf(rangeStartMs + plan.endMin * 60000)
|
|
3810
|
+
const content = viewConfig.renderBaseline?.({
|
|
3811
|
+
event: plan.event,
|
|
3812
|
+
start: plan.baseline.start,
|
|
3813
|
+
end: plan.baseline.end,
|
|
3814
|
+
milestone: plan.baseline.milestone,
|
|
3815
|
+
variance: plan.variance,
|
|
3816
|
+
})
|
|
3817
|
+
return (
|
|
3818
|
+
<div
|
|
3819
|
+
key={`baseline-${plan.event.id}`}
|
|
3820
|
+
data-slot="gantt-baseline"
|
|
3821
|
+
data-milestone={plan.baseline.milestone || undefined}
|
|
3822
|
+
data-variance={plan.variance}
|
|
3823
|
+
aria-hidden
|
|
3824
|
+
// a milestone is a POINT: the wrapper centers on the instant
|
|
3825
|
+
// (flex centering, so RTL needs no translate gymnastics), while
|
|
3826
|
+
// a window mirrors the bar wrapper's px-px breathing room
|
|
3827
|
+
className={cn(
|
|
3828
|
+
"absolute",
|
|
3829
|
+
plan.baseline.milestone
|
|
3830
|
+
? "flex items-center justify-center"
|
|
3831
|
+
: "px-px",
|
|
3832
|
+
viewConfig.classNames?.baseline
|
|
3833
|
+
)}
|
|
3834
|
+
style={
|
|
3835
|
+
{
|
|
3836
|
+
"--gantt-event-color":
|
|
3837
|
+
plan.event.color ?? "var(--color-primary)",
|
|
3838
|
+
/* clamped into the row box: the layer's paint containment
|
|
3839
|
+
slices anything that overhangs it, which would cut an
|
|
3840
|
+
edge milestone's diamond in half - so it nudges inward
|
|
3841
|
+
instead, at most half a lane off its instant */
|
|
3842
|
+
insetInlineStart: plan.baseline.milestone
|
|
3843
|
+
? `clamp(0rem, ${from * 100}% - ${laneHeightRem / 2}rem, 100% - ${laneHeightRem}rem)`
|
|
3844
|
+
: `${from * 100}%`,
|
|
3845
|
+
width: plan.baseline.milestone
|
|
3846
|
+
? `${laneHeightRem}rem`
|
|
3847
|
+
: `${Math.max((to - from) * 100, 0.5)}%`,
|
|
3848
|
+
top: `${laneTopRem(laneOffsetRem, plan.lane, laneHeightRem, laneGapRem)}rem`,
|
|
3849
|
+
height: `${laneHeightRem}rem`,
|
|
3850
|
+
} as CSSProperties
|
|
3851
|
+
}
|
|
3852
|
+
>
|
|
3853
|
+
{content ??
|
|
3854
|
+
(plan.baseline.milestone ? (
|
|
3855
|
+
// hollow diamond marking the planned instant
|
|
3856
|
+
<span className="size-2 rotate-45 rounded-[2px] border border-(--gantt-event-color)/60 bg-(--gantt-event-color)/15" />
|
|
3857
|
+
) : (
|
|
3858
|
+
// block child (not inset-0) so the px-px padding holds and
|
|
3859
|
+
// the outline lands exactly on the bar's own box
|
|
3860
|
+
<span className="relative block h-full w-full rounded-sm border border-(--gantt-event-color)/40 bg-(--gantt-event-color)/10" />
|
|
3861
|
+
))}
|
|
3862
|
+
</div>
|
|
3863
|
+
)
|
|
3864
|
+
})}
|
|
3865
|
+
{segments.map((segment, segmentIndex) => {
|
|
3866
|
+
const from = fractionOf(
|
|
3867
|
+
rangeStartMs + (segment.startMin ?? 0) * 60000
|
|
3868
|
+
)
|
|
3869
|
+
const to = fractionOf(rangeStartMs + (segment.endMin ?? 0) * 60000)
|
|
3870
|
+
// Zero duration = an actual MILESTONE: it renders as a diamond
|
|
3871
|
+
// centered on its instant instead of a zero-width (invisible) bar.
|
|
3872
|
+
const milestone =
|
|
3873
|
+
segment.occurrence.end.getTime() ===
|
|
3874
|
+
segment.occurrence.start.getTime()
|
|
3875
|
+
if (to <= from && !milestone) return null
|
|
3876
|
+
const lane = segment.column ?? 0
|
|
3877
|
+
// Title placement: outside beside the bar when configured (or too
|
|
3878
|
+
// short in "auto"), flipped before the bar near the range end, and
|
|
3879
|
+
// back inside when the bar spans the whole view. A milestone has
|
|
3880
|
+
// no room inside its diamond, so its title is always outside.
|
|
3881
|
+
const barRemWidth = milestone
|
|
3882
|
+
? laneHeightRem
|
|
3883
|
+
: (to - from) * trackRemWidth
|
|
3884
|
+
const wantsOutside =
|
|
3885
|
+
milestone ||
|
|
3886
|
+
viewConfig.barLabel === "outside" ||
|
|
3887
|
+
(viewConfig.barLabel === "auto" &&
|
|
3888
|
+
barRemWidth <
|
|
3889
|
+
(viewConfig.metrics?.autoLabelMin ?? AUTO_LABEL_MIN_REM))
|
|
3890
|
+
const placement = !wantsOutside
|
|
3891
|
+
? "inside"
|
|
3892
|
+
: to <= 0.92
|
|
3893
|
+
? "after"
|
|
3894
|
+
: from >= 0.08
|
|
3895
|
+
? "before"
|
|
3896
|
+
: "inside"
|
|
3897
|
+
// the wrapper carries the drag kind itself (from the row's ghost
|
|
3898
|
+
// state, same notify as the bar's own attribute) so the hide rules
|
|
3899
|
+
// below use plain attribute selectors instead of :has(), which
|
|
3900
|
+
// older Firefox (<121) does not support
|
|
3901
|
+
const segDragKind =
|
|
3902
|
+
ghost && ghost.occurrenceKey === segment.occurrence.key
|
|
3903
|
+
? ghost.kind
|
|
3904
|
+
: undefined
|
|
3905
|
+
return (
|
|
3906
|
+
<div
|
|
3907
|
+
key={segment.occurrence.key}
|
|
3908
|
+
data-drag-kind={segDragKind}
|
|
3909
|
+
data-milestone={milestone || undefined}
|
|
3910
|
+
// lane position is headless state: a consumer can read it to
|
|
3911
|
+
// label the bar ("2 of 4") or drive its own manage UI
|
|
3912
|
+
data-lane={lane}
|
|
3913
|
+
data-lane-count={laneCount}
|
|
3914
|
+
// during a MOVE the whole thing (bar + its outside label) hides so
|
|
3915
|
+
// the smooth clone carries both; resize keeps the BAR as a faint
|
|
3916
|
+
// placeholder but its label yields to the ghost's outside label.
|
|
3917
|
+
// pointer-events-auto: the parent mask layer is pointer-
|
|
3918
|
+
// transparent so empty-track presses reach the row.
|
|
3919
|
+
// px-px keeps back-to-back bars off each other; the vertical
|
|
3920
|
+
// breathing room is the lane gap itself, not padding here, so
|
|
3921
|
+
// the bar is exactly laneHeight tall
|
|
3922
|
+
className="group/gantt-seg pointer-events-auto absolute px-px data-[drag-kind=move]:opacity-0"
|
|
3923
|
+
// insetInlineStart, not left: in RTL the axis mirrors and bars
|
|
3924
|
+
// must mirror with it (fractions measure from the range start)
|
|
3925
|
+
style={{
|
|
3926
|
+
/* a milestone wrapper is a lane-height square centered on the
|
|
3927
|
+
instant, clamped into the row box exactly like the planned
|
|
3928
|
+
milestone diamond */
|
|
3929
|
+
insetInlineStart: milestone
|
|
3930
|
+
? `clamp(0rem, ${from * 100}% - ${laneHeightRem / 2}rem, 100% - ${laneHeightRem}rem)`
|
|
3931
|
+
: `${from * 100}%`,
|
|
3932
|
+
width: milestone
|
|
3933
|
+
? `${laneHeightRem}rem`
|
|
3934
|
+
: `${Math.max((to - from) * 100, 0.5)}%`,
|
|
3935
|
+
top: `${laneTopRem(laneOffsetRem, lane, laneHeightRem, laneGapRem)}rem`,
|
|
3936
|
+
height: `${laneHeightRem}rem`,
|
|
3937
|
+
// one track means every lane is 0, so paint order (not the
|
|
3938
|
+
// lane) is what keeps overlapping bars individually reachable
|
|
3939
|
+
zIndex:
|
|
3940
|
+
segment.occurrence.event.zIndex ??
|
|
3941
|
+
10 + (singleTrack ? segmentIndex : lane),
|
|
3942
|
+
}}
|
|
3943
|
+
>
|
|
3944
|
+
<GanttBar
|
|
3945
|
+
segment={segment}
|
|
3946
|
+
labelOutside={placement !== "inside"}
|
|
3947
|
+
rowTitle={row.resource.title}
|
|
3948
|
+
className="h-full"
|
|
3949
|
+
/>
|
|
3950
|
+
{placement !== "inside" && (
|
|
3951
|
+
<span
|
|
3952
|
+
data-slot="gantt-bar-label"
|
|
3953
|
+
data-placement={placement}
|
|
3954
|
+
className={cn(
|
|
3955
|
+
"text-foreground pointer-events-none absolute top-1/2 z-10 max-w-60 -translate-y-1/2 truncate font-medium",
|
|
3956
|
+
// one label at a time: the resize ghost carries it while
|
|
3957
|
+
// this bar is the faded placeholder
|
|
3958
|
+
"group-data-[drag-kind^=resize]/gantt-seg:opacity-0",
|
|
3959
|
+
/* a departing dependency arrow drops its corner just past
|
|
3960
|
+
the bar's end, so exactly those labels reserve extra
|
|
3961
|
+
room; everything else keeps the tight default */
|
|
3962
|
+
placement === "after"
|
|
3963
|
+
? dependencySourceIds.has(segment.occurrence.eventId)
|
|
3964
|
+
? "start-full ms-4"
|
|
3965
|
+
: "start-full ms-2"
|
|
3966
|
+
: "end-full me-2"
|
|
3967
|
+
)}
|
|
3968
|
+
>
|
|
3969
|
+
{segment.occurrence.event.title}
|
|
3970
|
+
</span>
|
|
3971
|
+
)}
|
|
3972
|
+
</div>
|
|
3973
|
+
)
|
|
3974
|
+
})}
|
|
3975
|
+
{bars?.summary && segments.length === 0 && (
|
|
3976
|
+
<div
|
|
3977
|
+
data-slot="gantt-summary"
|
|
3978
|
+
aria-hidden
|
|
3979
|
+
className="pointer-events-none absolute top-1/2 -translate-y-1/2"
|
|
3980
|
+
style={{
|
|
3981
|
+
insetInlineStart: `${bars.summary.from * 100}%`,
|
|
3982
|
+
width: `${Math.max((bars.summary.to - bars.summary.from) * 100, 0.5)}%`,
|
|
3983
|
+
}}
|
|
3984
|
+
>
|
|
3985
|
+
{viewConfig.renderSummary ? (
|
|
3986
|
+
// consumer-owned rollup: the positioned envelope wrapper stays
|
|
3987
|
+
viewConfig.renderSummary({
|
|
3988
|
+
resource: row.resource,
|
|
3989
|
+
start: new Date(
|
|
3990
|
+
rangeStartMs + bars.summary.from * (rangeEndMs - rangeStartMs)
|
|
3991
|
+
),
|
|
3992
|
+
end: new Date(
|
|
3993
|
+
rangeStartMs + bars.summary.to * (rangeEndMs - rangeStartMs)
|
|
3994
|
+
),
|
|
3995
|
+
progress: bars.summary.progress,
|
|
3996
|
+
})
|
|
3997
|
+
) : (
|
|
3998
|
+
<>
|
|
3999
|
+
{/* envelope end caps: the classic PM rollup silhouette, muted */}
|
|
4000
|
+
<span
|
|
4001
|
+
aria-hidden
|
|
4002
|
+
className="bg-muted-foreground/50 absolute start-0 top-1/2 h-3 w-0.5 -translate-y-1/2 rounded-full"
|
|
4003
|
+
/>
|
|
4004
|
+
<span
|
|
4005
|
+
aria-hidden
|
|
4006
|
+
className="bg-muted-foreground/50 absolute end-0 top-1/2 h-3 w-0.5 -translate-y-1/2 rounded-full"
|
|
4007
|
+
/>
|
|
4008
|
+
<div className="bg-muted-foreground/20 relative h-1.5 overflow-hidden rounded-full">
|
|
4009
|
+
{bars.summary.progress !== null && (
|
|
4010
|
+
<div
|
|
4011
|
+
data-slot="gantt-summary-progress"
|
|
4012
|
+
className="bg-muted-foreground/50 absolute inset-y-0 start-0 rounded-full"
|
|
4013
|
+
style={{ width: `${bars.summary.progress}%` }}
|
|
4014
|
+
/>
|
|
4015
|
+
)}
|
|
4016
|
+
</div>
|
|
4017
|
+
{bars.summary.progress !== null && (
|
|
4018
|
+
<span className="text-muted-foreground absolute start-full top-1/2 ms-2 -translate-y-1/2 whitespace-nowrap">
|
|
4019
|
+
{bars.summary.progress}%
|
|
4020
|
+
</span>
|
|
4021
|
+
)}
|
|
4022
|
+
</>
|
|
4023
|
+
)}
|
|
4024
|
+
</div>
|
|
4025
|
+
)}
|
|
4026
|
+
{ghost && (
|
|
4027
|
+
<div
|
|
4028
|
+
data-slot="gantt-drag-ghost"
|
|
4029
|
+
data-kind={ghost.kind}
|
|
4030
|
+
data-milestone={ghost.milestone || undefined}
|
|
4031
|
+
data-drop-invalid={!ghost.valid || undefined}
|
|
4032
|
+
className={cn(
|
|
4033
|
+
// Slight dashed indicator, never a dramatic restyle. Move shows a
|
|
4034
|
+
// faint drop-target placeholder (the smooth cursor clone carries
|
|
4035
|
+
// the visual); resize shows the event at its new size in its own
|
|
4036
|
+
// color with just a dashed border.
|
|
4037
|
+
/* height via the class, not an inline style, so a consumer's
|
|
4038
|
+
own selector can still outrank it */
|
|
4039
|
+
"pointer-events-none absolute z-40 h-(--gantt-ghost-height) rounded-sm border border-dashed font-medium",
|
|
4040
|
+
!ghost.valid &&
|
|
4041
|
+
"border-destructive bg-destructive/10 text-destructive",
|
|
4042
|
+
ghost.valid &&
|
|
4043
|
+
ghost.kind === "move" &&
|
|
4044
|
+
"border-(--gantt-event-color)/50 bg-(--gantt-event-color)/8",
|
|
4045
|
+
ghost.valid &&
|
|
4046
|
+
ghost.kind !== "move" &&
|
|
4047
|
+
"text-foreground border-(--gantt-event-color)/70 bg-(--gantt-event-color)/22",
|
|
4048
|
+
/* a milestone's landing marker keeps the milestone's SHAPE: the
|
|
4049
|
+
wrapper sheds its own dashed box and centers a dashed diamond
|
|
4050
|
+
instead - a dashed rectangle read as a different object */
|
|
4051
|
+
ghost.milestone &&
|
|
4052
|
+
"flex items-center justify-center rounded-none border-0 bg-transparent"
|
|
4053
|
+
)}
|
|
4054
|
+
style={
|
|
4055
|
+
{
|
|
4056
|
+
/* a milestone drop lands as a lane-height square CENTERED on
|
|
4057
|
+
the instant (same clamp as the diamond); the generic 0.5%
|
|
4058
|
+
floor would anchor a sliver at the instant's left instead */
|
|
4059
|
+
insetInlineStart: ghost.milestone
|
|
4060
|
+
? `clamp(0rem, ${ghost.from * 100}% - ${laneHeightRem / 2}rem, 100% - ${laneHeightRem}rem)`
|
|
4061
|
+
: `${ghost.from * 100}%`,
|
|
4062
|
+
width: ghost.milestone
|
|
4063
|
+
? `${laneHeightRem}rem`
|
|
4064
|
+
: `${Math.max((ghost.to - ghost.from) * 100, 0.5)}%`,
|
|
4065
|
+
// sit on the dragged schedule's OWN lane. Centering the ghost
|
|
4066
|
+
// in the row put it on no lane at all once a node stacked, so
|
|
4067
|
+
// a 3-lane row showed the drop target floating in the middle.
|
|
4068
|
+
top: `${ghostLaneOffsetRem}rem`,
|
|
4069
|
+
/* metrics-driven, and published so a consumer can size
|
|
4070
|
+
against the SAME number instead of hardcoding 1.25 */
|
|
4071
|
+
"--gantt-ghost-height": `${ghostHeightRem}rem`,
|
|
4072
|
+
"--gantt-event-color": ghost.color ?? "var(--color-primary)",
|
|
4073
|
+
} as CSSProperties
|
|
4074
|
+
}
|
|
4075
|
+
>
|
|
4076
|
+
{ghost.milestone && (
|
|
4077
|
+
<span
|
|
4078
|
+
className={cn(
|
|
4079
|
+
"size-2.5 rotate-45 rounded-[2px] border border-dashed",
|
|
4080
|
+
ghost.valid
|
|
4081
|
+
? "border-(--gantt-event-color)/70 bg-(--gantt-event-color)/10"
|
|
4082
|
+
: "border-destructive bg-destructive/10"
|
|
4083
|
+
)}
|
|
4084
|
+
/>
|
|
4085
|
+
)}
|
|
4086
|
+
{ghost.kind !== "move" && (
|
|
4087
|
+
// label rides OUTSIDE after the bar, exactly like the resting
|
|
4088
|
+
// outside placement - never inside the schedule
|
|
4089
|
+
<span className="pointer-events-none absolute start-full top-1/2 ms-2 max-w-60 -translate-y-1/2 truncate whitespace-nowrap">
|
|
4090
|
+
{ghost.title}
|
|
4091
|
+
</span>
|
|
4092
|
+
)}
|
|
4093
|
+
</div>
|
|
4094
|
+
)}
|
|
4095
|
+
{draft && (
|
|
4096
|
+
<div
|
|
4097
|
+
data-slot="gantt-slot-draft"
|
|
4098
|
+
data-lane={draftLane}
|
|
4099
|
+
// OPAQUE. When the destination lane does not exist yet the row has
|
|
4100
|
+
// not grown, so the placeholder rides the bottom edge and overlaps
|
|
4101
|
+
// the bar it is going under - as a tint that read as a smudge over
|
|
4102
|
+
// the bar's own label. Punched out of the backdrop it reads as a
|
|
4103
|
+
// distinct object sliding underneath, which is what it is.
|
|
4104
|
+
className="border-primary bg-background pointer-events-none absolute z-40 overflow-hidden rounded-sm border border-dashed"
|
|
4105
|
+
style={{
|
|
4106
|
+
insetInlineStart: `${draft.from * 100}%`,
|
|
4107
|
+
width: `${Math.max((draft.to - draft.from) * 100, 0.5)}%`,
|
|
4108
|
+
// the destination lane, not the row's middle (which on a stacked
|
|
4109
|
+
// row is the GAP between two lanes), and the real lane height
|
|
4110
|
+
// rather than a hardcoded h-5 a consumer's metrics would break
|
|
4111
|
+
top: `${draftTopRem}rem`,
|
|
4112
|
+
height: `${laneHeightRem}rem`,
|
|
4113
|
+
}}
|
|
4114
|
+
>
|
|
4115
|
+
{/* the accent tint, over the opaque base rather than over whatever
|
|
4116
|
+
happens to be beneath the row */}
|
|
4117
|
+
<span aria-hidden className="bg-primary/15 absolute inset-0" />
|
|
4118
|
+
</div>
|
|
4119
|
+
)}
|
|
4120
|
+
</div>
|
|
4121
|
+
{/* OUTSIDE the content layer for the same reason as the hint bubble: the
|
|
4122
|
+
layer's paint containment would CLIP a chip that overhangs the row.
|
|
4123
|
+
Centred on the range being painted, so the times track the drag. */}
|
|
4124
|
+
{draft && draftLabel && (
|
|
4125
|
+
<div
|
|
4126
|
+
data-slot="gantt-slot-draft-label"
|
|
4127
|
+
className="pointer-events-none absolute z-40"
|
|
4128
|
+
style={{
|
|
4129
|
+
// anchored to the range's END and riding after it, exactly like the
|
|
4130
|
+
// drag ghost's own label. Centred above the placeholder it covered
|
|
4131
|
+
// the schedule on the track above - and the one thing a create
|
|
4132
|
+
// gesture must never hide is what is already booked.
|
|
4133
|
+
insetInlineStart: `${draft.to * 100}%`,
|
|
4134
|
+
top: `${draftTopRem}rem`,
|
|
4135
|
+
height: `${laneHeightRem}rem`,
|
|
4136
|
+
}}
|
|
4137
|
+
>
|
|
4138
|
+
<span className="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">
|
|
4139
|
+
{draftLabel}
|
|
4140
|
+
{/* Same arrow as every other bubble, one size down. A 45-degree
|
|
4141
|
+
square is symmetric, so size-2.5 spans ~14px whichever edge it
|
|
4142
|
+
straddles: a small nub under a ~120px-wide chip, but most of the
|
|
4143
|
+
short edge of a ~26px-TALL one. size-1.5 spans ~8.5px and
|
|
4144
|
+
protrudes ~4px, the usual tooltip-arrow proportion, so the side
|
|
4145
|
+
arrow reads the same weight as the ones above and below. */}
|
|
4146
|
+
<span
|
|
4147
|
+
aria-hidden
|
|
4148
|
+
className="bg-foreground absolute start-0 top-1/2 size-1.5 -translate-x-1/2 -translate-y-1/2 rotate-45 rounded-[1px]"
|
|
4149
|
+
/>
|
|
4150
|
+
</span>
|
|
4151
|
+
</div>
|
|
4152
|
+
)}
|
|
4153
|
+
{/* OUTSIDE the content layer: its paint containment creates a stacking
|
|
4154
|
+
context that would trap the bubble's z-index under neighboring rows.
|
|
4155
|
+
As a direct row child its z-30 stacks above every row in the pane. */}
|
|
4156
|
+
{/* `hintLane < laneCount` is the second half of "empty slots only": the
|
|
4157
|
+
pointer being on bare track can still mean the row's own padding above
|
|
4158
|
+
a column that is booked on every track. There is no free track to
|
|
4159
|
+
draw on there, so the clamp would park the ring on top of a bar -
|
|
4160
|
+
exactly what must never happen. Booking such an instant is a drag from
|
|
4161
|
+
a free point, not a click. */}
|
|
4162
|
+
{hintVisible && hintStop && (
|
|
4163
|
+
<div
|
|
4164
|
+
data-slot="gantt-schedule-hint"
|
|
4165
|
+
// rtl flips the translate sign: insetInlineStart anchors the box's
|
|
4166
|
+
// inline-start edge, which is the RIGHT edge under rtl, so centring
|
|
4167
|
+
// on the anchor shifts the opposite way there
|
|
4168
|
+
className="pointer-events-none absolute z-30 -translate-x-1/2 -translate-y-1/2 rtl:translate-x-1/2"
|
|
4169
|
+
style={{
|
|
4170
|
+
// the live cursor position, written without React (see onPointerMove)
|
|
4171
|
+
insetInlineStart: "var(--gantt-hint-x, 50%)",
|
|
4172
|
+
// first track, centred on it - z-30 on this wrapper keeps the dot
|
|
4173
|
+
// above every bar, so a busy span still offers the affordance
|
|
4174
|
+
top: `${hintTopRem}rem`,
|
|
4175
|
+
}}
|
|
4176
|
+
>
|
|
4177
|
+
{viewConfig.renderScheduleHint ? (
|
|
4178
|
+
// consumer-owned hint: the wrapper stays snapped + validated;
|
|
4179
|
+
// the content drives its own create flow
|
|
4180
|
+
viewConfig.renderScheduleHint({
|
|
4181
|
+
start: new Date(hintStop.ms),
|
|
4182
|
+
end: new Date(Math.max(hintStop.endMs, hintStop.ms + 1)),
|
|
4183
|
+
resource: row.resource,
|
|
4184
|
+
})
|
|
4185
|
+
) : (
|
|
4186
|
+
<>
|
|
4187
|
+
{/* the band IS the placement affordance: it covers the interval
|
|
4188
|
+
under the pointer, so what you see is the slot you get. Clicking
|
|
4189
|
+
books it; pressing and dragging paints a longer range. The bubble
|
|
4190
|
+
mirrors the tooltip theme, arrow included. */}
|
|
4191
|
+
{/* The dot IS the cursor: an open ring, so whatever it is standing
|
|
4192
|
+
on stays readable through it. The outer background ring is what
|
|
4193
|
+
keeps it visible on a dark bar - an outline, not a fill.
|
|
4194
|
+
The GLYPH is the native cursor rather than an icon: `cell` is the
|
|
4195
|
+
range-select cursor every spreadsheet uses, which is exactly the
|
|
4196
|
+
drag-a-range gesture, and `copy` carries a plus for the
|
|
4197
|
+
click-to-add case. Clicking books the interval it is standing in;
|
|
4198
|
+
pressing and dragging paints a range whose ends snap to the
|
|
4199
|
+
nearest boundary. */}
|
|
4200
|
+
<button
|
|
4201
|
+
type="button"
|
|
4202
|
+
data-slot="gantt-schedule-hint-tile"
|
|
4203
|
+
aria-label={hintLabel}
|
|
4204
|
+
// pointer-events-NONE: the ring rides under the cursor, so any
|
|
4205
|
+
// press it accepts is a press stolen from whatever it is
|
|
4206
|
+
// standing on - which is how it swallowed every click meant for
|
|
4207
|
+
// a bar. Passing pointers straight through makes the element
|
|
4208
|
+
// physically underneath the thing you click: a bar opens its
|
|
4209
|
+
// job, bare track creates (the row's own handlers, which the
|
|
4210
|
+
// ring merely previews). The button and its onClick stay for
|
|
4211
|
+
// KEYBOARD use - pointer-events never blocks Enter/Space.
|
|
4212
|
+
// No cursor class here: pointer-events-none makes it unreachable.
|
|
4213
|
+
// The row carries it (see `hintVisible`).
|
|
4214
|
+
className="border-primary ring-background/80 pointer-events-none block size-5 rounded-full border-2 bg-transparent ring-1"
|
|
4215
|
+
onPointerDown={(e) => {
|
|
4216
|
+
// with drag-create on the press belongs to the row's create
|
|
4217
|
+
// gesture; a sub-threshold press still ends as this click
|
|
4218
|
+
if (!viewConfig.dragCreate) e.stopPropagation()
|
|
4219
|
+
}}
|
|
4220
|
+
onClick={(e) => {
|
|
4221
|
+
e.stopPropagation()
|
|
4222
|
+
// a completed drag-create already committed via onSelectSlot
|
|
4223
|
+
if (wasRecentDrag()) return
|
|
4224
|
+
createAt(hintStop, e)
|
|
4225
|
+
setHintStop(null)
|
|
4226
|
+
}}
|
|
4227
|
+
/>
|
|
4228
|
+
<span
|
|
4229
|
+
data-slot="gantt-schedule-hint-bubble"
|
|
4230
|
+
className={cn(
|
|
4231
|
+
"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",
|
|
4232
|
+
rowIndex === 0 ? "top-full mt-2" : "bottom-full mb-2"
|
|
4233
|
+
)}
|
|
4234
|
+
>
|
|
4235
|
+
{hintLabel}
|
|
4236
|
+
<span
|
|
4237
|
+
aria-hidden
|
|
4238
|
+
className={cn(
|
|
4239
|
+
"bg-foreground absolute start-1/2 size-2.5 -translate-x-1/2 rotate-45 rounded-[2px]",
|
|
4240
|
+
rowIndex === 0 ? "-top-1" : "-bottom-1"
|
|
4241
|
+
)}
|
|
4242
|
+
/>
|
|
4243
|
+
</span>
|
|
4244
|
+
</>
|
|
4245
|
+
)}
|
|
4246
|
+
</div>
|
|
4247
|
+
)}
|
|
4248
|
+
</div>
|
|
4249
|
+
)
|
|
4250
|
+
})
|
|
4251
|
+
|
|
4252
|
+
interface OffscreenChip {
|
|
4253
|
+
id: string
|
|
4254
|
+
side: "start" | "end"
|
|
4255
|
+
top: number
|
|
4256
|
+
color?: string
|
|
4257
|
+
label: string
|
|
4258
|
+
/** First bar start, shown in the chip tooltip. */
|
|
4259
|
+
startMs: number | null
|
|
4260
|
+
/** scrollLeft that brings the bar back into view. */
|
|
4261
|
+
target: number
|
|
4262
|
+
}
|
|
4263
|
+
|
|
4264
|
+
function sameChips(a: OffscreenChip[], b: OffscreenChip[]): boolean {
|
|
4265
|
+
return (
|
|
4266
|
+
a.length === b.length &&
|
|
4267
|
+
a.every(
|
|
4268
|
+
(chip, i) =>
|
|
4269
|
+
chip.id === b[i].id &&
|
|
4270
|
+
chip.side === b[i].side &&
|
|
4271
|
+
chip.top === b[i].top &&
|
|
4272
|
+
chip.target === b[i].target
|
|
4273
|
+
)
|
|
4274
|
+
)
|
|
4275
|
+
}
|
|
4276
|
+
|
|
4277
|
+
/**
|
|
4278
|
+
* Edge chips for rows whose bars sit entirely outside the visible timeline;
|
|
4279
|
+
* clicking scrolls the bar back into view. Reads geometry straight from the
|
|
4280
|
+
* DOM (row data attributes), so scrolling never re-renders the grid.
|
|
4281
|
+
*/
|
|
4282
|
+
function GanttOffscreenChips({
|
|
4283
|
+
paneRef,
|
|
4284
|
+
occurrences,
|
|
4285
|
+
locale,
|
|
4286
|
+
refreshKey,
|
|
4287
|
+
}: {
|
|
4288
|
+
paneRef: RefObject<HTMLDivElement | null>
|
|
4289
|
+
occurrences: GanttOccurrence[]
|
|
4290
|
+
locale?: Locale
|
|
4291
|
+
refreshKey: string
|
|
4292
|
+
}) {
|
|
4293
|
+
const settings = useGanttSettings()
|
|
4294
|
+
const [chips, setChips] = useState<OffscreenChip[]>([])
|
|
4295
|
+
|
|
4296
|
+
useEffect(() => {
|
|
4297
|
+
const pane = paneRef.current
|
|
4298
|
+
const viewport = getPaneViewport(pane)
|
|
4299
|
+
if (!pane || !viewport) return
|
|
4300
|
+
let raf = 0
|
|
4301
|
+
const measure = () => {
|
|
4302
|
+
raf = 0
|
|
4303
|
+
const paneRect = pane.getBoundingClientRect()
|
|
4304
|
+
const header = viewport.querySelector<HTMLElement>(
|
|
4305
|
+
"[data-slot=gantt-timeline-header]"
|
|
4306
|
+
)
|
|
4307
|
+
const headerBottom = header
|
|
4308
|
+
? header.getBoundingClientRect().bottom - paneRect.top
|
|
4309
|
+
: 0
|
|
4310
|
+
const trackW = viewport.scrollWidth
|
|
4311
|
+
const visibleStart = getScrollStart(viewport)
|
|
4312
|
+
const visibleEnd = visibleStart + viewport.clientWidth
|
|
4313
|
+
// The floating zoom control shares the right edge. The chips stay in
|
|
4314
|
+
// their column - a shifted chip reads as misaligned, and its position
|
|
4315
|
+
// IS its meaning - so it is the ZOOM CONTROL that glides inward while
|
|
4316
|
+
// any end chip occupies its band (published as --gantt-zoom-shift,
|
|
4317
|
+
// written imperatively like the rest of this measure pass). The band
|
|
4318
|
+
// test uses the control's VERTICAL extent only, which a horizontal
|
|
4319
|
+
// dodge never changes, so the loop cannot oscillate.
|
|
4320
|
+
const zoomEl = pane.querySelector<HTMLElement>("[data-slot=gantt-zoom]")
|
|
4321
|
+
const zoom = zoomEl
|
|
4322
|
+
? {
|
|
4323
|
+
top: zoomEl.getBoundingClientRect().top - paneRect.top - 8,
|
|
4324
|
+
bottom: zoomEl.getBoundingClientRect().bottom - paneRect.top + 8,
|
|
4325
|
+
}
|
|
4326
|
+
: null
|
|
4327
|
+
let chipInZoomBand = false
|
|
4328
|
+
const next: OffscreenChip[] = []
|
|
4329
|
+
for (const rowEl of viewport.querySelectorAll<HTMLElement>(
|
|
4330
|
+
"[data-gantt-row]"
|
|
4331
|
+
)) {
|
|
4332
|
+
const from = parseFloat(rowEl.dataset.ganttBarMin ?? "")
|
|
4333
|
+
const to = parseFloat(rowEl.dataset.ganttBarMax ?? "")
|
|
4334
|
+
if (Number.isNaN(from) || Number.isNaN(to)) continue
|
|
4335
|
+
const rect = rowEl.getBoundingClientRect()
|
|
4336
|
+
const top = rect.top - paneRect.top + rect.height / 2
|
|
4337
|
+
if (top < headerBottom + 10 || top > paneRect.height - 16) continue
|
|
4338
|
+
const startPx = from * trackW
|
|
4339
|
+
const endPx = to * trackW
|
|
4340
|
+
const startMs = parseFloat(rowEl.dataset.ganttBarStartMs ?? "")
|
|
4341
|
+
const base = {
|
|
4342
|
+
id: rowEl.dataset.ganttRowId ?? "",
|
|
4343
|
+
top: Math.round(top),
|
|
4344
|
+
color: rowEl.dataset.ganttBarColor,
|
|
4345
|
+
label: rowEl.dataset.ganttBarLabel ?? "",
|
|
4346
|
+
startMs: Number.isNaN(startMs) ? null : startMs,
|
|
4347
|
+
}
|
|
4348
|
+
if (endPx <= visibleStart + 2) {
|
|
4349
|
+
next.push({
|
|
4350
|
+
...base,
|
|
4351
|
+
side: "start",
|
|
4352
|
+
target: startPx - 24,
|
|
4353
|
+
})
|
|
4354
|
+
} else if (startPx >= visibleEnd - 2) {
|
|
4355
|
+
if (zoom && top >= zoom.top && top <= zoom.bottom) {
|
|
4356
|
+
chipInZoomBand = true
|
|
4357
|
+
}
|
|
4358
|
+
next.push({
|
|
4359
|
+
...base,
|
|
4360
|
+
side: "end",
|
|
4361
|
+
target: endPx - viewport.clientWidth + 24,
|
|
4362
|
+
})
|
|
4363
|
+
}
|
|
4364
|
+
}
|
|
4365
|
+
// 28px clears the 20px chip column plus breathing room on both sides
|
|
4366
|
+
zoomEl?.style.setProperty(
|
|
4367
|
+
"--gantt-zoom-shift",
|
|
4368
|
+
chipInZoomBand ? "28px" : "0px"
|
|
4369
|
+
)
|
|
4370
|
+
setChips((prev) => (sameChips(prev, next) ? prev : next))
|
|
4371
|
+
}
|
|
4372
|
+
const schedule = () => {
|
|
4373
|
+
if (!raf) raf = requestAnimationFrame(measure)
|
|
4374
|
+
}
|
|
4375
|
+
viewport.addEventListener("scroll", schedule)
|
|
4376
|
+
const observer = new ResizeObserver(schedule)
|
|
4377
|
+
observer.observe(viewport)
|
|
4378
|
+
schedule()
|
|
4379
|
+
return () => {
|
|
4380
|
+
viewport.removeEventListener("scroll", schedule)
|
|
4381
|
+
observer.disconnect()
|
|
4382
|
+
if (raf) cancelAnimationFrame(raf)
|
|
4383
|
+
// the zoom control returns to its corner once no chip can collide
|
|
4384
|
+
pane
|
|
4385
|
+
.querySelector<HTMLElement>("[data-slot=gantt-zoom]")
|
|
4386
|
+
?.style.removeProperty("--gantt-zoom-shift")
|
|
4387
|
+
}
|
|
4388
|
+
}, [paneRef, occurrences, refreshKey])
|
|
4389
|
+
|
|
4390
|
+
if (chips.length === 0) return null
|
|
4391
|
+
|
|
4392
|
+
const scrollTo = (chip: OffscreenChip) => {
|
|
4393
|
+
const viewport = getPaneViewport(paneRef.current)
|
|
4394
|
+
if (!viewport) return
|
|
4395
|
+
const target = Math.max(0, chip.target)
|
|
4396
|
+
viewport.scrollTo({
|
|
4397
|
+
// chip targets are distances from the inline start; RTL signs them
|
|
4398
|
+
left: getComputedStyle(viewport).direction === "rtl" ? -target : target,
|
|
4399
|
+
behavior: "smooth",
|
|
4400
|
+
})
|
|
4401
|
+
// hand keyboard focus to the bar the chip promised (the chip unmounts)
|
|
4402
|
+
const bar = viewport.querySelector<HTMLElement>(
|
|
4403
|
+
`[data-gantt-row-id="${CSS.escape(chip.id)}"] [data-slot=gantt-bar]`
|
|
4404
|
+
)
|
|
4405
|
+
bar?.focus({ preventScroll: true })
|
|
4406
|
+
}
|
|
4407
|
+
|
|
4408
|
+
return (
|
|
4409
|
+
<div
|
|
4410
|
+
data-slot="gantt-offscreen-chips"
|
|
4411
|
+
className="pointer-events-none absolute inset-0 z-30 overflow-hidden"
|
|
4412
|
+
>
|
|
4413
|
+
<TooltipProvider delay={600} closeDelay={0} timeout={300}>
|
|
4414
|
+
{chips.map((chip) => (
|
|
4415
|
+
<Tooltip key={`${chip.id}-${chip.side}`}>
|
|
4416
|
+
<TooltipTrigger
|
|
4417
|
+
render={
|
|
4418
|
+
<button
|
|
4419
|
+
type="button"
|
|
4420
|
+
data-slot="gantt-offscreen-chip"
|
|
4421
|
+
data-side={chip.side}
|
|
4422
|
+
aria-label={settings.i18n.labels.jumpToBar(chip.label)}
|
|
4423
|
+
className="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"
|
|
4424
|
+
style={{
|
|
4425
|
+
top: chip.top,
|
|
4426
|
+
...(chip.side === "start"
|
|
4427
|
+
? { insetInlineStart: "0.5rem" }
|
|
4428
|
+
: { insetInlineEnd: "0.875rem" }),
|
|
4429
|
+
}}
|
|
4430
|
+
onClick={() => scrollTo(chip)}
|
|
4431
|
+
/>
|
|
4432
|
+
}
|
|
4433
|
+
>
|
|
4434
|
+
{chip.side === "start" ? (
|
|
4435
|
+
<ChevronLeftIcon className="size-3" aria-hidden="true" />
|
|
4436
|
+
) : (
|
|
4437
|
+
<ChevronRightIcon className="size-3" aria-hidden="true" />
|
|
4438
|
+
)}
|
|
4439
|
+
<span
|
|
4440
|
+
aria-hidden
|
|
4441
|
+
className="ring-background absolute -end-px -top-px size-1.5 rounded-full ring-1"
|
|
4442
|
+
style={{ background: chip.color ?? "var(--color-primary)" }}
|
|
4443
|
+
/>
|
|
4444
|
+
</TooltipTrigger>
|
|
4445
|
+
<TooltipContent side={chip.side === "start" ? "right" : "left"}>
|
|
4446
|
+
<div className="font-medium">{chip.label}</div>
|
|
4447
|
+
{chip.startMs !== null && (
|
|
4448
|
+
<div className="opacity-80">
|
|
4449
|
+
{/* zoned: the chip must name the same day the grid shows */}
|
|
4450
|
+
{format(
|
|
4451
|
+
toZoned(new Date(chip.startMs), settings.timeZone),
|
|
4452
|
+
"MMM d, yyyy",
|
|
4453
|
+
{ locale }
|
|
4454
|
+
)}
|
|
4455
|
+
</div>
|
|
4456
|
+
)}
|
|
4457
|
+
</TooltipContent>
|
|
4458
|
+
</Tooltip>
|
|
4459
|
+
))}
|
|
4460
|
+
</TooltipProvider>
|
|
4461
|
+
</div>
|
|
4462
|
+
)
|
|
4463
|
+
}
|
|
4464
|
+
|
|
4465
|
+
export { GanttView }
|
|
4466
|
+
export type { GanttViewProps }
|