@taskctrl/canvas-timeline 0.8.0 → 0.10.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.
@@ -19,3 +19,18 @@ export declare function resolveCanResize(item: Item, globalCanResize: false | 'l
19
19
  * falling through to the global value.
20
20
  */
21
21
  export declare function resolveCanMove(item: Item, globalCanMove: boolean): boolean;
22
+ /**
23
+ * Decide which interaction (if any) a pointer-down at `edge` should start for
24
+ * `item`, given the timeline-wide `canResize`/`canMove` props. Returns `null`
25
+ * when nothing is permitted — the caller then starts no interaction (no ghost)
26
+ * and shows no affordance (default cursor). Centralising this keeps the cursor
27
+ * and the interaction decision in lockstep.
28
+ *
29
+ * An item explicitly resize-locked per-item (`item.canResize === false`) has
30
+ * inert edges: grabbing an edge returns `null` rather than falling through to a
31
+ * move, so a user trying to resize a locked item sees no UI. This is scoped to
32
+ * the *explicit* per-item override — an item inheriting a global `canResize:
33
+ * false` (field undefined) keeps the move-only behaviour where the whole item,
34
+ * edges included, is draggable.
35
+ */
36
+ export declare function resolveInteractionMode(item: Item, edge: 'left' | 'right' | 'body', globalCanResize: false | 'left' | 'right' | 'both', globalCanMove: boolean): 'move' | 'resize-left' | 'resize-right' | null;
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export type { Group, Item, ItemBounds, ItemState, DrawHelpers, CanvasItemRenderer, CanvasGroupItemRenderer, DayStyle, RowStyle, Dependency, TimeRangeHighlight, TimelineTheme, MarkerConfig, CanvasTimelineProps, CanvasTimelineRef, CaptureOptions, } from './types';
1
+ export type { Group, Item, ItemBounds, ItemState, DrawHelpers, CanvasItemRenderer, CanvasGroupItemRenderer, DayStyle, RowStyle, Dependency, TimeRangeHighlight, TimelineTheme, MarkerConfig, InteractionInfo, DragSnapFn, DragSnapResult, DragSnapAnchor, CanvasTimelineProps, CanvasTimelineRef, CaptureOptions, } from './types';
2
2
  export { DEFAULT_THEME } from './types';
3
3
  export { CanvasTimeline } from './CanvasTimeline';
4
4
  export { HierarchyEngine } from './core/HierarchyEngine';
@@ -1,4 +1,4 @@
1
- import type { Item } from '../types';
1
+ import type { Item, DragSnapFn } from '../types';
2
2
  export type InteractionMode = 'move' | 'resize-left' | 'resize-right';
3
3
  export interface InteractionState {
4
4
  item: Item;
@@ -15,7 +15,7 @@ export declare class InteractionHandler {
15
15
  private state;
16
16
  private dragSnap;
17
17
  private activated;
18
- constructor(dragSnap: number);
18
+ constructor(dragSnap: number | DragSnapFn | undefined);
19
19
  startInteraction(item: Item, mode: InteractionMode, x: number, y: number): void;
20
20
  update(x: number, y: number): void;
21
21
  setCurrentGroup(groupId: string | number): void;
@@ -0,0 +1,27 @@
1
+ import type { Item, InteractionInfo, DragSnapFn } from '../types';
2
+ import type { ViewState } from '../core/ViewState';
3
+ import type { IntervalTree } from '../core/IntervalTree';
4
+ import type { HierarchyEngine } from '../core/HierarchyEngine';
5
+ import type { InteractionState } from './InteractionHandler';
6
+ /** The subset of props the snap helpers need. */
7
+ export interface SnapDeps {
8
+ canvasWidth: number;
9
+ dragSnap?: number | DragSnapFn;
10
+ timezone?: string;
11
+ intervalTree: IntervalTree<Item>;
12
+ hierarchyEngine: HierarchyEngine;
13
+ }
14
+ /** Snapped new start time for a move, matching the pointer-up commit path:
15
+ * item-edge snap (start edge first, then end edge) with grid snap as fallback. */
16
+ export declare function snappedMoveStart(state: InteractionState, vs: ViewState, p: SnapDeps): number;
17
+ /** Snapped edge time for a resize, matching the pointer-up commit path: item-edge
18
+ * snap with grid-snap fallback, then hierarchy resize constraints. */
19
+ export declare function snappedResizeEdge(state: InteractionState, vs: ViewState, p: SnapDeps): {
20
+ edge: 'left' | 'right';
21
+ time: number;
22
+ };
23
+ /** Build the public InteractionInfo payload for the current drag/resize state. */
24
+ export declare function buildInteractionInfo(state: InteractionState, vs: ViewState, p: SnapDeps, pointer: {
25
+ x: number;
26
+ y: number;
27
+ }): InteractionInfo;
@@ -0,0 +1,41 @@
1
+ import type { Item, DragSnapFn, DragSnapResult } from '../types';
2
+ /** Reset the once-per-session invalid-snap warning. Test-only. */
3
+ export declare function _resetSnapWarning(): void;
4
+ /**
5
+ * Resolve a `dragSnap` prop to a concrete snap spec for the given item/edge.
6
+ *
7
+ * - `undefined` → `1` (no snap).
8
+ * - `number` → returned as-is (preserves legacy behavior, including `0`).
9
+ * - `DragSnapFn` → invoked with `(item, edge)`. `edge` is `'left' | 'right'`
10
+ * during a resize and `undefined` during a move. It may return either a bare
11
+ * interval (ms) — tz-aware default anchoring (see {@link gridSnap}) — or a
12
+ * `{ interval, anchor }` spec to control phase explicitly. An invalid return
13
+ * (`interval` that is `0`, `NaN`, or negative, or a non-finite `anchor`) falls
14
+ * back to `1` (no snap) and warns once per session.
15
+ *
16
+ * Hot path: called on every pointer-move frame. Returns the consumer's object
17
+ * by reference — no allocation or copying here.
18
+ */
19
+ export declare function resolveDragSnap(dragSnap: number | DragSnapFn | undefined, item: Item, edge?: 'left' | 'right'): DragSnapResult;
20
+ /**
21
+ * Snap `time` (epoch ms) to a snap grid.
22
+ *
23
+ * When `snap` is a `{ interval, anchor }` spec, the grid is `anchor + N *
24
+ * interval` and the `timezone` is ignored — the consumer owns the phase (see
25
+ * {@link DragSnapAnchor} for the DST caveat). This branch is pure arithmetic
26
+ * (no dayjs), so it is the cheapest path.
27
+ *
28
+ * When `snap` is a bare `number` interval:
29
+ * - For day-or-larger intervals with a `timezone`, the grid is anchored to
30
+ * tz-local midnight rather than the UTC epoch, so the snap lands on the same
31
+ * day boundaries the grid/headers render (which are also tz-local). Day
32
+ * boundaries in a tz with a non-zero UTC offset are not multiples of
33
+ * 86_400_000 since the epoch, so a naive `Math.round(time / interval) *
34
+ * interval` drifts by the offset. The snap is computed in local-calendar-day
35
+ * space, so it stays correct across DST transitions (where a local day is 23
36
+ * or 25 hours long).
37
+ * - Sub-day intervals (1h, 15min, …) stay UTC-anchored: hours and quarter-hours
38
+ * line up between UTC and any whole- or fractional-hour offset, so no
39
+ * translation is needed.
40
+ */
41
+ export declare function gridSnap(time: number, snap: DragSnapResult, timezone?: string): number;
@@ -11,5 +11,5 @@ export declare function collectItemEdges(items: SnapItem[], excludeItemId: numbe
11
11
  * Find the best snap target from a list of edge X positions.
12
12
  * Returns the snapped X position if an edge is within threshold, or null to fall back to time-grid snap.
13
13
  */
14
- export declare function findSnapTarget(currentX: number, edgeXPositions: number[], thresholdPx: number, _pixelsPerMs: number, _dragSnap: number): number | null;
14
+ export declare function findSnapTarget(currentX: number, edgeXPositions: number[], thresholdPx: number, _pixelsPerMs: number, _dragSnap: import('../types').DragSnapResult): number | null;
15
15
  export {};
package/dist/types.d.ts CHANGED
@@ -107,6 +107,60 @@ export interface MarkerConfig {
107
107
  width: number;
108
108
  label?: string;
109
109
  }
110
+ /** Live state of an in-progress drag/resize, emitted via `onInteractionUpdate`.
111
+ * All times are the snapped values that will commit on drop (item-edge snap +
112
+ * grid snap, plus hierarchy resize constraints) — i.e. where the item will
113
+ * actually land, matching the snap guide. The consumer-supplied
114
+ * `moveResizeValidator` is NOT applied here (it may run per frame). */
115
+ export interface InteractionInfo {
116
+ itemId: number;
117
+ mode: 'move' | 'resize-left' | 'resize-right';
118
+ /** Set for resize interactions; which edge is being dragged. */
119
+ edge?: 'left' | 'right';
120
+ /** The headline time to display: the new start for `move`/`resize-left`, the
121
+ * new end for `resize-right`. */
122
+ time: number;
123
+ /** The item's snapped start time during this interaction. */
124
+ startTime: number;
125
+ /** The item's snapped end time during this interaction. */
126
+ endTime: number;
127
+ /** Pointer position in viewport coordinates (clientX/clientY), for
128
+ * positioning a `position: fixed` tooltip without further math. */
129
+ pointerX: number;
130
+ pointerY: number;
131
+ }
132
+ /** Anchored snap grid: snap to `anchor + N * interval`. Returning this from a
133
+ * {@link DragSnapFn} overrides the default tz-local-midnight anchoring — the
134
+ * consumer is fully in charge of phase. */
135
+ export interface DragSnapAnchor {
136
+ /** Snap interval in ms. Must be finite and `> 0`, else snapping is disabled
137
+ * (falls back to no snap, one `console.warn` per session). */
138
+ interval: number;
139
+ /** Absolute epoch ms. The snap grid is built as
140
+ * `anchor + N * interval`. Because `interval` is exact ms, the grid does
141
+ * NOT track wall-clock hour across DST — at the spring-forward boundary the
142
+ * snapped position will drift one hour from local time and stay drifted
143
+ * until the next anchor reset. Consumers that need wall-clock-stable
144
+ * snapping should re-pick an anchor on the DST side they care about.
145
+ * The timeline's `timezone` prop is ignored when an anchor is supplied. */
146
+ anchor: number;
147
+ }
148
+ /** What a {@link DragSnapFn} may return: a bare interval (ms) — tz-aware
149
+ * default anchoring — or an explicit `{ interval, anchor }` grid. */
150
+ export type DragSnapResult = number | DragSnapAnchor;
151
+ /**
152
+ * Per-drag snap resolver. Called on every pointer-move frame during a
153
+ * drag/resize, so keep it cheap — the library does not memoize the result.
154
+ *
155
+ * @param item The item being dragged/resized.
156
+ * @param edge `'left' | 'right'` during a resize; `undefined` during a move.
157
+ * @returns Either a snap interval in ms (varies only the *interval*; phase stays
158
+ * grid-aligned to multiples of it — tz-local midnight for day-or-larger
159
+ * intervals when `timezone` is set), or a {@link DragSnapAnchor} `{ interval,
160
+ * anchor }` to also control phase (tz is ignored in that case). A `0`, `NaN`,
161
+ * or negative `interval` (or non-finite `anchor`) falls back to no snap.
162
+ */
163
+ export type DragSnapFn = (item: Item, edge?: 'left' | 'right') => DragSnapResult;
110
164
  export interface CanvasTimelineProps {
111
165
  groups: Group[];
112
166
  items: Item[];
@@ -122,7 +176,10 @@ export interface CanvasTimelineProps {
122
176
  canMove: boolean;
123
177
  canResize: false | 'left' | 'right' | 'both';
124
178
  canChangeGroup: boolean;
125
- dragSnap: number;
179
+ /** Snap interval for drag/resize, in ms. A `number` snaps everything to that
180
+ * interval; a {@link DragSnapFn} resolves the interval per item/edge. When
181
+ * omitted, no snapping is applied. */
182
+ dragSnap?: number | DragSnapFn;
126
183
  minZoom: number;
127
184
  maxZoom: number;
128
185
  theme?: Partial<TimelineTheme>;
@@ -148,6 +205,11 @@ export interface CanvasTimelineProps {
148
205
  /** Validate and optionally constrain move/resize. Return the (possibly modified) time. */
149
206
  moveResizeValidator?: (action: 'move' | 'resize', itemId: number, time: number, edge?: 'left' | 'right') => number;
150
207
  onItemHover?: (itemId: number | null, e: PointerEvent) => void;
208
+ /** Fires once per animation frame during an active drag/resize, then once with
209
+ * `null` when it ends. Intended for a live "current time" tooltip. This is a
210
+ * hot path: do NOT call setState here — mutate a ref'd DOM node's text/transform
211
+ * directly so neither the library nor your app re-renders per frame. */
212
+ onInteractionUpdate?: (info: InteractionInfo | null) => void;
151
213
  onCanvasDoubleClick?: (groupId: number, time: number) => void;
152
214
  onCanvasContextMenu?: (groupId: number, time: number, e: PointerEvent) => void;
153
215
  onTimeChange?: (start: number, end: number) => void;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@taskctrl/canvas-timeline",
3
- "version": "0.8.0",
3
+ "version": "0.10.0",
4
4
  "description": "High-performance canvas-based timeline component for React",
5
5
  "scripts": {
6
6
  "build": "rimraf ./dist && tsc && vite build",