{"version":3,"file":"useTimelineKeyframeDrag.mjs","names":[],"sources":["../../../src/hooks/keyframes/useTimelineKeyframeDrag.ts"],"sourcesContent":["import { TimelineEditGesture } from '#react/hooks/editing/timelineEditGesture';\nimport { timelineCommandFail, timelineCommandOk } from '@techsquidtv/canvas-timeline-core';\nimport type {\n  TimelineCommandResult,\n  TimelineInteractionGeometry,\n  TimelineKeyframeRect,\n  TimelineKeyframeEditCommand,\n  TimelineReadonly,\n  TimelineKeyframe,\n  Clip,\n  TimelineRegisteredKeyframePropertyDefinition,\n} from '@techsquidtv/canvas-timeline-core';\nimport { useTimelineEngine } from '#react/hooks/core/useTimelineEngine';\nimport {\n  addRational,\n  fromSeconds,\n  subRational,\n  toSeconds,\n  resolveTimecodeFrameRate,\n} from '@techsquidtv/canvas-timeline-utils';\nimport type { RationalTime, TimecodeFrameRate } from '@techsquidtv/canvas-timeline-utils';\nimport { useCallback, useEffect, useMemo, useRef, useState } from 'react';\n/** Pointer data needed to begin a keyframe drag. */\nexport interface TimelineKeyframeDragStartInput {\n  /** Clip owning the keyframe. */\n  clipId: string;\n  /** Keyframe being dragged. */\n  keyframeId: string;\n  /** Pointer client X captured at drag start. */\n  clientX: number;\n  /** Pointer Y in timeline viewport coordinates, including the ruler area. */\n  viewportY: number;\n  /** Optional keyframe rect from the initiating hit test. */\n  keyframeRect?: TimelineKeyframeRect;\n}\n\n/** Pointer data needed to update a keyframe drag. */\nexport interface TimelineKeyframeDragMoveInput {\n  /** Constrain movement to one axis. */\n  axis?: 'time' | 'value';\n  /** Reduce movement to one tenth for precision edits. */\n  fine?: boolean;\n  /** Temporarily bypass snapping. */\n  snap?: boolean;\n  /** Current pointer client X. */\n  clientX: number;\n  /** Current pointer Y in timeline viewport coordinates, including the ruler area. */\n  viewportY: number;\n}\n\n/**\n * Options accepted by `useTimelineKeyframeDrag`.\n *\n * @remarks\n *\n * Geometry must match the renderer or keyframe interaction layer so horizontal\n * pointer movement maps to timeline time and vertical movement maps to property\n * value consistently. The hook edits values in preview mode while dragging and\n * settles history when the drag ends.\n *\n * @see {@link useTimelineKeyframes}\n * @see {@link https://canvastimeline.com/docs/keyframes | Keyframes}\n */\nexport interface UseTimelineKeyframeDragOptions extends TimelineInteractionGeometry {\n  /** Frame grid; defaults to the engine frame rate, or 30 fps when unset. */\n  frameRate?: TimecodeFrameRate;\n  /** Keyframe affordance size in CSS pixels. Defaults to engine geometry. */\n  keyframeSize?: number;\n  /** Vertical padding used when mapping property values into a clip row. Defaults to engine geometry. */\n  keyframeValuePadding?: number;\n}\n\n/** Result returned by `useTimelineKeyframeDrag`. */\nexport interface UseTimelineKeyframeDragResult {\n  /** Whether a keyframe drag is currently active. */\n  dragging: boolean;\n  /** Starts a keyframe drag. */\n  startKeyframeDrag: (input: TimelineKeyframeDragStartInput) => TimelineCommandResult;\n  /** Updates the active keyframe drag preview. */\n  moveKeyframeDrag: (\n    input: TimelineKeyframeDragMoveInput\n  ) => TimelineCommandResult<TimelineKeyframeDragUpdate>;\n  /** Ends the active keyframe drag and settles history. */\n  endKeyframeDrag: () => TimelineCommandResult;\n  /** Cancels the drag and restores the committed state without an undo entry. */\n  cancelKeyframeDrag: () => TimelineCommandResult;\n}\n\n/** Successful keyframe drag update payload. */\nexport interface TimelineKeyframeDragUpdate {\n  /** Clip owning the keyframe. */\n  clipId: string;\n  /** Keyframe being dragged. */\n  keyframeId: string;\n  /** Updated timeline time. */\n  time: RationalTime;\n  /** Updated property value. */\n  value: number;\n}\n\ninterface ActiveKeyframeDrag {\n  gesture: TimelineEditGesture;\n  zoomScale: number;\n  clipId: string;\n  keyframeId: string;\n  startClientX: number;\n  startY: number;\n  time: RationalTime;\n  valueHeight: number;\n  entries: {\n    clipId: string;\n    keyframeId: string;\n    key: TimelineReadonly<TimelineKeyframe>;\n    clip: TimelineReadonly<Clip>;\n    normalized: number;\n    definition: TimelineRegisteredKeyframePropertyDefinition;\n  }[];\n}\n\n/**\n * Headless keyframe drag behavior shared by canvas and custom timeline UIs.\n *\n * @remarks\n *\n * Use this hook when building custom DOM or canvas hit targets for keyframe\n * points. The hook owns drag lifecycle, preview updates, value mapping, and\n * settle behavior. It intentionally does not render handles; pair it with\n * {@link useTimelineKeyframeGeometry} for keyframe geometry.\n *\n * @param options - Drag geometry aligned with the renderer and hit-test layer.\n * @returns Keyframe drag state and pointer command helpers.\n *\n * @example\n * ```tsx\n * import { useTimelineKeyframeDrag } from '@techsquidtv/canvas-timeline-react';\n *\n * export function KeyframeHandle({ clipId, keyframeId }: { clipId: string; keyframeId: string }) {\n *   const drag = useTimelineKeyframeDrag();\n *\n *   return (\n *     <button\n *       type=\"button\"\n *       aria-pressed={drag.dragging}\n *       onPointerDown={(event) =>\n *         drag.startKeyframeDrag({\n *           clipId,\n *           keyframeId,\n *           clientX: event.clientX,\n *           viewportY: event.nativeEvent.offsetY,\n *         })\n *       }\n *     >\n *       Move keyframe\n *     </button>\n *   );\n * }\n * ```\n *\n * @see {@link useTimelineKeyframes}\n * @see {@link https://canvastimeline.com/demos/keyframe-opacity | Keyframe opacity demo}\n */\nexport function useTimelineKeyframeDrag(\n  options: UseTimelineKeyframeDragOptions = {}\n): UseTimelineKeyframeDragResult {\n  const engine = useTimelineEngine();\n  const activeDragRef = useRef<ActiveKeyframeDrag | null>(null);\n  const [dragging, setDragging] = useState(false);\n  useEffect(\n    () => () => {\n      if (activeDragRef.current) {\n        activeDragRef.current.gesture.cancel();\n        activeDragRef.current = null;\n      }\n    },\n    [engine]\n  );\n\n  const startKeyframeDrag = useCallback(\n    (input: TimelineKeyframeDragStartInput): TimelineCommandResult => {\n      if (activeDragRef.current) {\n        return timelineCommandFail('unsupported', 'A keyframe drag is already active.');\n      }\n      const found = engine.geometry.getClip(input.clipId);\n      const key = found?.clip.keyframes?.find((candidate) => candidate.id === input.keyframeId);\n      const rect = engine.geometry.getClipRect(input.clipId, options);\n      if (!found || !key || !rect) {\n        return timelineCommandFail('not-found');\n      }\n      if (found.track.locked) {\n        return timelineCommandFail('locked');\n      }\n      if (![input.clientX, input.viewportY].every(Number.isFinite)) {\n        return timelineCommandFail('invalid-input');\n      }\n      const references = key.selected\n        ? engine.keyframes.getSelectedKeyframes()\n        : [{ clipId: input.clipId, keyframeId: input.keyframeId }];\n      const entries = references.flatMap((ref) => {\n        const owner = engine.geometry.getClip(ref.clipId);\n        const candidate = owner?.clip.keyframes?.find((item) => item.id === ref.keyframeId);\n        const definition = candidate\n          ? engine.getKeyframePropertyDefinition(candidate.property)\n          : null;\n        return owner && candidate && definition\n          ? [\n              {\n                ...ref,\n                key: candidate,\n                clip: owner.clip,\n                definition,\n                normalized: definition.normalizeValue(candidate.value),\n              },\n            ]\n          : [];\n      });\n      engine.cancelEdit();\n      activeDragRef.current = {\n        gesture: new TimelineEditGesture(engine),\n        zoomScale: engine.zoomScale,\n        clipId: input.clipId,\n        keyframeId: input.keyframeId,\n        startClientX: input.clientX,\n        startY: input.viewportY,\n        time: key.time,\n        entries,\n        valueHeight: Math.max(1, rect.height - 2 * (options.keyframeValuePadding ?? 7)),\n      };\n      setDragging(true);\n      return timelineCommandOk();\n    },\n    [engine, options]\n  );\n\n  const moveKeyframeDrag = useCallback(\n    (input: TimelineKeyframeDragMoveInput): TimelineCommandResult<TimelineKeyframeDragUpdate> => {\n      const active = activeDragRef.current;\n      if (!active) {\n        return timelineCommandFail('unsupported');\n      }\n      if (![input.clientX, input.viewportY].every(Number.isFinite)) {\n        return timelineCommandFail('invalid-input');\n      }\n      if (!active.gesture.isCurrent()) {\n        return timelineCommandFail('unsupported', 'The keyframe preview was replaced.');\n      }\n      const sensitivity = input.fine ? 0.1 : 1;\n      let seconds =\n        input.axis === 'value'\n          ? 0\n          : ((input.clientX - active.startClientX) / active.zoomScale) * sensitivity;\n      let valueDelta =\n        input.axis === 'time'\n          ? 0\n          : ((active.startY - input.viewportY) / active.valueHeight) * sensitivity;\n      if (input.axis !== 'value' && input.snap !== false && engine.getState().snapEnabled) {\n        const target = toSeconds(active.time) + seconds;\n        const frameRate = options.frameRate ?? engine.frameRate ?? 30;\n        const fps = resolveTimecodeFrameRate(frameRate);\n        let snapped = Math.round(target * fps) / fps;\n        let distance = Math.abs(snapped - target) * engine.zoomScale;\n        const selected = new Set(\n          active.entries.map((entry) => JSON.stringify([entry.clipId, entry.keyframeId]))\n        );\n        const candidates = [\n          engine.getState().playheadTime,\n          ...(engine.getState().markers ?? []).map((marker) => marker.time),\n        ];\n        for (const track of engine.getState().tracks) {\n          for (const clip of track.clips) {\n            candidates.push(clip.timelineStart, clip.timelineEnd);\n            for (const key of clip.keyframes ?? []) {\n              if (!selected.has(JSON.stringify([clip.id, key.id]))) {\n                candidates.push(key.time);\n              }\n            }\n          }\n        }\n        for (const time of candidates) {\n          const pixels = Math.abs(toSeconds(time) - target) * engine.zoomScale;\n          if (pixels <= engine.getState().snapThresholdPixels && pixels <= distance) {\n            snapped = toSeconds(time);\n            distance = pixels;\n          }\n        }\n        seconds = snapped - toSeconds(active.time);\n      }\n      for (const entry of active.entries) {\n        seconds = Math.max(\n          toSeconds(subRational(entry.clip.timelineStart, entry.key.time)),\n          Math.min(toSeconds(subRational(entry.clip.timelineEnd, entry.key.time)), seconds)\n        );\n        valueDelta = Math.max(-entry.normalized, Math.min(1 - entry.normalized, valueDelta));\n      }\n      const command: TimelineKeyframeEditCommand = {\n        type: 'keyframes',\n        edits: active.entries.map((entry) => ({\n          type: 'update',\n          clipId: entry.clipId,\n          keyframeId: entry.keyframeId,\n          time: addRational(entry.key.time, fromSeconds(seconds)),\n          value: entry.definition.denormalizeValue(entry.normalized + valueDelta),\n        })),\n      };\n      const preview = active.gesture.publish(command);\n      if (!preview.valid) {\n        return timelineCommandFail(\n          preview.reason === 'locked' ? 'locked' : 'invalid-input',\n          preview.message\n        );\n      }\n      const key = preview.changedClips\n        .find((clip) => clip.id === active.clipId)\n        ?.keyframes?.find((candidate) => candidate.id === active.keyframeId);\n      if (!key) {\n        return timelineCommandFail('not-found');\n      }\n      return timelineCommandOk({\n        clipId: active.clipId,\n        keyframeId: active.keyframeId,\n        time: key.time,\n        value: key.value,\n      });\n    },\n    [engine, options.frameRate]\n  );\n\n  const finish = useCallback((commit: boolean): TimelineCommandResult => {\n    const active = activeDragRef.current;\n    if (!active) {\n      return timelineCommandFail('unsupported');\n    }\n    activeDragRef.current = null;\n    setDragging(false);\n    if (commit) {\n      return active.gesture.commit();\n    }\n    active.gesture.cancel();\n    return timelineCommandOk();\n  }, []);\n  const endKeyframeDrag = useCallback(() => finish(true), [finish]);\n  const cancelKeyframeDrag = useCallback(() => finish(false), [finish]);\n  return useMemo(\n    () => ({ dragging, startKeyframeDrag, moveKeyframeDrag, endKeyframeDrag, cancelKeyframeDrag }),\n    [dragging, startKeyframeDrag, moveKeyframeDrag, endKeyframeDrag, cancelKeyframeDrag]\n  );\n}\n"],"mappings":"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"}