Hey everybody, following up on my own post with what we found after extensive research and development.
To answer the original question: the sister object structure is the only option to work with in Captivate Classic’s HTML5 output. We were not able to find a way to force a shared parent wrapper, and groups in Captivate did not help. Our solution was to embrace the structure and manipulate both elements separately at the same time, applying the same CSS transform to each on every movement.
One important discovery: if you publish with Scalable HTML Content enabled, pointer coordinates (clientX, clientY) are in screen pixels while CSS transforms operate in CSS pixels. At 100% scale these match, but at smaller scales they diverge — causing dragged elements to offset increasingly the further they are moved. We built a getScaleFactor() function to correct for this automatically.
For anyone else looking to build a custom drag and drop interaction in Captivate Classic using JavaScript, we are sharing our basic working script below. It handles dragging, dropping, validation, incorrect feedback, and works in an activity with multiple drag and drops (and other scripts) for multi-slide functionality.
Hope this saves someone else some time!
// Custom Drag and Drop Script for Adobe Captivate Classic
//
// CAPTIVATE OBJECT STRUCTURE
// Captivate splits each interactive element into two sister objects with no shared parent:
// - The clickable element: identified by the name you give the object in Captivate (e.g. "termA")
// - The visible element (wrapper): automatically named with "re-" prefix and "c" suffix (e.g. "re-termAc")
// Both must be manipulated separately but in sync to achieve smooth drag and drop behavior.
//
// SCALING NOTE
// If you publish with "Scalable HTML Content" enabled, pointer coordinates (clientX, clientY)
// are in screen pixels while CSS transforms operate in CSS pixels. At 100% scale these match,
// but at smaller scales they diverge causing offset issues on mobile and small screens.
// getScaleFactor() and getScaledDelta() correct for this automatically.
class CaptivateSource {
constructor(sourceId, targetId, answerId) {
this.sourceId = sourceId; // ID of the clickable element in Captivate
this.targetId = targetId; // ID of the target element in Captivate
this.answerId = answerId; // ID of the correct target for validation
this.isDragging = false;
this.pointerStartpointX = 0;
this.pointerStartpointY = 0;
this.lastStoredPosition = { x: 0, y: 0 };
this.isLocked = false;
this.displayStyle = "inline-block";
}
// The visible wrapper element — named "re-" + sourceId + "c" by Captivate
visibleElement() {
return document.getElementById("re-" + this.sourceId + "c");
}
// The clickable element — named by you in Captivate
clickableElement() {
return document.getElementById(this.sourceId);
}
// The target element
targetElement() {
return document.getElementById(this.targetId);
}
// Moves the source to an absolute screen position.
// Divides by scale factor to convert screen pixels to CSS pixels,
// correcting for Captivate's scalable HTML content setting.
moveTo(screenX, screenY) {
const visibleElement = this.visibleElement();
const currentTransform = new DOMMatrix(getComputedStyle(visibleElement).transform);
const rect = visibleElement.getBoundingClientRect();
const scale = getScaleFactor();
const originX = (rect.left / scale) - currentTransform.m41;
const originY = (rect.top / scale) - currentTransform.m42;
const translateX = (screenX / scale) - originX;
const translateY = (screenY / scale) - originY;
visibleElement.style.transform = `translate(${translateX}px, ${translateY}px)`;
this.clickableElement().style.transform = `translate(${translateX}px, ${translateY}px)`;
}
// Moves the source by a delta from its last stored position.
// Used during dragging. getScaledDelta() corrects for scaling before this is called.
moveBy(deltaX, deltaY) {
var x = this.lastStoredPosition.x + deltaX;
var y = this.lastStoredPosition.y + deltaY;
this.visibleElement().style.transform = "translate(" + x + "px, " + y + "px)";
this.clickableElement().style.transform = "translate(" + x + "px, " + y + "px)";
}
// Snaps the source to the center of the target element on correct drop
snapToTarget(targetElement) {
var visibleRect = this.visibleElement().getBoundingClientRect();
const targetRect = targetElement.getBoundingClientRect();
var deltaX = targetRect.width / 2 - visibleRect.width / 2;
var deltaY = targetRect.height / 2 - visibleRect.height / 2;
this.moveTo(targetRect.left + deltaX, targetRect.top + deltaY);
}
// Resets the source back to its original position
reset() {
if (this.visibleElement()) {
this.visibleElement().style.transform = "translate(0px, 0px)";
this.clickableElement().style.transform = "translate(0px, 0px)";
this.visibleElement().style.zIndex = "1000";
this.clickableElement().style.zIndex = "1000";
}
if (this.clickableElement()) {
this.enableClicks();
}
this.lastStoredPosition = { x: 0, y: 0 };
}
disableClicks() {
if (this.clickableElement()) this.clickableElement().style.pointerEvents = "none";
}
enableClicks() {
if (this.clickableElement()) this.clickableElement().style.pointerEvents = "auto";
}
disableTransitionDuringDrag() {
if (this.visibleElement()) this.visibleElement().style.transition = "none";
}
enableTransition() {
if (this.visibleElement()) this.visibleElement().style.transition = "transform 0.4s ease";
}
initStyles() {
if (this.visibleElement() && this.clickableElement()) {
this.visibleElement().style.display = this.displayStyle;
this.clickableElement().style.cursor = "pointer";
this.clickableElement().style.touchAction = "none";
this.enableTransition();
this.visibleElement().style.zIndex = "1000";
this.clickableElement().style.zIndex = "1000";
}
}
addEventListener(eventType, handler) {
this.clickableElement().addEventListener(eventType, handler);
}
clickOnSource(event) {
return this.clickableElement() === event.target;
}
clickOnTarget(event) {
return this.targetElement() === event.target;
}
// Returns true if this slide's elements are currently visible in the DOM.
// In Captivate, document-level event listeners persist across slides.
// This guard prevents listeners from one slide interfering with another.
isSlideActive() {
var el = this.clickableElement();
return el !== null && el.offsetParent !== null;
}
}
// -----------------------------------------------
// EDIT THIS SECTION FOR EACH NEW DND SLIDE
// Replace termA/termB with your Captivate object IDs.
// Targets follow the same naming but with "source" swapped for "target".
// Update the RESET_BUTTON_ID to have a unique name on each DND activity in your file.
// -----------------------------------------------
var termA = new CaptivateSource("termA", "TargetA", "TargetA");
var termB = new CaptivateSource("termB", "TargetB", "TargetB");
var activeSource = null;
const CAPTIVATE_SOURCES = [termA, termB];
const RESET_BUTTON_ID = "ResetBtn";
// -----------------------------------------------
// INCORRECT FEEDBACK MESSAGE STYLING
// -----------------------------------------------
const INCORRECT_FEEDBACK_MESSAGE_STYLING = {
color: "#787878",
fontFamily: "'Century Gothic', Arial, sans-serif",
duration: 2000,
text: "Try Again!",
fontWeight: 400,
pointerEvents: "none",
zIndex: 9999,
whiteSpace: "nowrap",
textAlign: "center"
};
// -----------------------------------------------
// TARGET HOVER STATE
// -----------------------------------------------
function updateTargetCursors() {
var style = activeSource ? "pointer" : "default";
CAPTIVATE_SOURCES.forEach(function (source) {
var target = source.targetElement();
if (target) target.style.cursor = style;
});
}
// -----------------------------------------------
// RESET BUTTON
// -----------------------------------------------
function resetInteraction() {
CAPTIVATE_SOURCES.forEach(function (source) { source.reset(); });
if (activeSource) {
activeSource = null;
}
updateTargetCursors();
var existing = document.querySelector(".incorrect-feedback");
if (existing) existing.remove();
}
// -----------------------------------------------
// INCORRECT FEEDBACK MESSAGE
// -----------------------------------------------
function showIncorrectFeedback(targetElement, message, duration = 2000) {
const rect = targetElement.getBoundingClientRect();
const incorrectFeedback = document.createElement("div");
incorrectFeedback.className = "incorrect-feedback";
incorrectFeedback.innerText = message;
Object.assign(incorrectFeedback.style, {
position: "fixed",
left: rect.left + rect.width / 2 + "px",
top: rect.top + rect.height / 2 + "px",
transform: "translate(-50%, -50%)",
fontSize: (15 * getScaleFactor()) + "px",
...INCORRECT_FEEDBACK_MESSAGE_STYLING,
});
document.body.appendChild(incorrectFeedback);
setTimeout(() => { if (incorrectFeedback) incorrectFeedback.remove(); }, duration);
}
// -----------------------------------------------
// VALIDATION
// -----------------------------------------------
function validateAnswer(targetElement) {
if (activeSource && activeSource.answerId === targetElement.id) {
var correctDrop = activeSource;
activeSource = null;
updateTargetCursors();
correctDrop.isLocked = true;
correctDrop.disableClicks();
} else if (activeSource) {
var incorrectDrop = activeSource;
activeSource = null;
updateTargetCursors();
// Wait for the drop animation to finish before bouncing back
setTimeout(function () {
if (incorrectDrop) incorrectDrop.reset();
setTimeout(function () {
showIncorrectFeedback(targetElement, INCORRECT_FEEDBACK_MESSAGE_STYLING.text);
}, 100);
}, 500);
}
}
function handleDrop(targetElement) {
activeSource.snapToTarget(targetElement);
validateAnswer(targetElement);
}
// -----------------------------------------------
// CLICK OUTSIDE TO DESELECT
// -----------------------------------------------
function isOnSourceOrTarget(event) {
return CAPTIVATE_SOURCES.some(function (source) {
return source.clickOnSource(event) || source.clickOnTarget(event);
});
}
// -----------------------------------------------
// SOURCE EVENT LISTENERS
// -----------------------------------------------
CAPTIVATE_SOURCES.forEach(function (source) {
source.initStyles();
source.addEventListener("pointerdown", function (element) {
element.stopPropagation();
if (source.isLocked) return;
element.preventDefault();
activeSource = source;
source.isDragging = true;
source.wasDragged = false;
source.pointerStartpointX = element.clientX;
source.pointerStartpointY = element.clientY;
source.disableTransitionDuringDrag();
source.trackPointerDuringDrag(element.pointerId);
});
});
// -----------------------------------------------
// DOCUMENT-LEVEL EVENT LISTENERS
//
// getScaleFactor() returns the ratio of the slide's rendered width to its
// authored width (1024px). Used to convert between screen and CSS pixels
// when Captivate's scalable HTML content setting is enabled.
//
// getScaledDelta() divides a raw pointer delta by the scale factor so that
// drag distances are correct regardless of how the content is scaled.
//
// isSlideActive() prevents listeners from one DND slide interfering with
// another when multiple DND slides exist in the same Captivate module.
// -----------------------------------------------
function getScaleFactor() {
var slide = document.getElementById("div_Slide");
if (!slide) return 1;
return slide.getBoundingClientRect().width / 1024;
}
function getScaledDelta(rawDelta) {
var slide = document.getElementById("div_Slide");
if (!slide) return rawDelta;
return rawDelta / (slide.getBoundingClientRect().width / 1024);
}
document.addEventListener("pointermove", function (element) {
if (!CAPTIVATE_SOURCES[0].isSlideActive()) return;
if (!activeSource || !activeSource.isDragging) return;
activeSource.wasDragged = true;
var deltaX = getScaledDelta(element.clientX - activeSource.pointerStartpointX);
var deltaY = getScaledDelta(element.clientY - activeSource.pointerStartpointY);
activeSource.moveBy(deltaX, deltaY);
});
document.addEventListener("pointerup", function (event) {
if (!CAPTIVATE_SOURCES[0].isSlideActive()) return;
if (activeSource && activeSource.isDragging) {
var deltaX = getScaledDelta(event.clientX - activeSource.pointerStartpointX);
var deltaY = getScaledDelta(event.clientY - activeSource.pointerStartpointY);
activeSource.lastStoredPosition.x += deltaX;
activeSource.lastStoredPosition.y += deltaY;
activeSource.isDragging = false;
activeSource.enableTransition();
activeSource.clickableElement().style.pointerEvents = "none";
var elementUnderPointer = document.elementFromPoint(event.clientX, event.clientY);
activeSource.clickableElement().style.pointerEvents = "auto";
var matchedTarget = null;
CAPTIVATE_SOURCES.forEach(function (potentialSource) {
var target = potentialSource.targetElement();
if (target && target.contains(elementUnderPointer)) matchedTarget = target;
});
var droppedSource = activeSource;
if (matchedTarget) {
handleDrop(matchedTarget);
} else {
activeSource.reset();
activeSource = null;
}
if (!droppedSource.wasDragged) droppedSource.enableTransition();
activeSource = null;
return;
}
if (!isOnSourceOrTarget(event) && activeSource) {
activeSource = null;
updateTargetCursors();
}
});
// -----------------------------------------------
// RESET BUTTON EVENT LISTENER
// -----------------------------------------------
var resetBtn = document.getElementById(RESET_BUTTON_ID);
if (resetBtn) {
resetBtn.addEventListener("pointerup", function () {
resetInteraction();
CAPTIVATE_SOURCES.forEach(function (source) { source.isLocked = false; });
});
}