Skip to main content
Back to examples
Popular features/Custom task editor
Loading live demo…from dhtmlxcode.com
Popular features

Custom task editor

Replace the default lightbox with a custom editor that opens on double click and follows the host product's own look and layout.

// custom_task_editor/31_custom_task_editor.js
/* -- the team: fuels both the owner combo-box and the grid avatar chip ------- */
let OWNERS = [
	{ id: 1, name: "Priya Shah",   role: "Product Lead",          color: "#2563eb" },
	{ id: 2, name: "Marcus Webb",  role: "SEO & Analytics",       color: "#7c3aed" },
	{ id: 3, name: "Elena Torres", role: "Content Strategist",    color: "#059669" },
	{ id: 4, name: "Noah Kim",     role: "Front-end Engineer",    color: "#d97706" },
	{ id: 5, name: "Grace Liu",    role: "Design Lead",           color: "#db2777" }
];
let OWNERS_BY_ID = {};
OWNERS.forEach(function (owner) { OWNERS_BY_ID[owner.id] = owner; });

function normalizeOwnerIds(value) {
	if (Array.isArray(value)) {
		return value.map(function (id) { return Number(id); }).filter(function (id) {
			return !!OWNERS_BY_ID[id];
		});
	}
	if (value == null || value === "") return [];
	let id = Number(value);
	return OWNERS_BY_ID[id] ? [id] : [];
}

let PRIORITY_LABELS = { high: "High", medium: "Medium", low: "Low" };

gantt.plugins({
	auto_scheduling: true
});

gantt.config.auto_scheduling = {
	enabled: true,
	apply_constraints: false,
	gap_behavior: "compress",
};

gantt.config.row_height   = 36;
gantt.config.bar_height   = 24;
gantt.config.scale_height = 52;
gantt.config.work_time    = true;
gantt.config.fit_tasks    = true;
gantt.config.min_column_width = 34;

function mondayOnOrAfter(date) {
	date = gantt.date.day_start(new Date(date));
	while (date.getDay() !== 1) date = gantt.date.add(date, 1, "day");
	return date;
}
let ANCHOR = mondayOnOrAfter(new Date());
function addDays(n) { return gantt.date.add(ANCHOR, n, "day"); }

gantt.config.scales = [
	{ unit: "month", step: 1, format: "%F %Y" },
	{ unit: "day",   step: 1, format: "%j %D" }
];

gantt.templates.scale_cell_class = function (date) {
	return gantt.isWorkTime(date) ? "" : "weekend";
};
gantt.templates.timeline_cell_class = function (task, date) {
	return gantt.isWorkTime({ date, task }) ? "" : "weekend";
};

/* Leaf-task bars pick up a color from their priority. */
gantt.templates.task_class = function (start, end, task) {
	let isLeaf = !gantt.isSummaryTask(task) && task.type !== gantt.config.types.milestone;
	if (isLeaf) return "task_priority_" + (task.priority || "medium");
	return "";
};

/* -- grid ------------------------------------------------------------------ */
gantt.config.columns = [
	{ name: "text",       label: "Task",  tree: true, width: 220, resize: true },
	{ name: "start_date", label: "Start", align: "center", width: 100, resize: true,
		template: function (task) {
			return task.type === gantt.config.types.project ? "" : gantt.templates.date_grid(task.start_date, task);
		}
	},
	{ name: "end_date",   label: "End",   align: "center", width: 100, resize: true,
		template: function (task) {
			return task.type === gantt.config.types.project ? "" : gantt.templates.date_grid(task.end_date, task);
		}
	},
	{ name: "owner",      label: "Owners", width: 170, resize: true,
		template: function (task) {
			let ownerIds = normalizeOwnerIds(task.owner);
			if (!ownerIds.length) return "";

			const avatars = ownerIds.map(function (ownerId) {
				let owner = OWNERS_BY_ID[ownerId];
				return "<span class='dhx_chip' style='background:" + owner.color +
					"' title='" + DHX.ui.escape(owner.name) + "'>" +
					DHX.ui.initials(owner.name) + "</span>";
			}).join("")
			const length = `<span class='owner_name'>${ownerIds.length} assigned</span>`;
			return `<span class='owner_cell'>${avatars} ${length}</span>`
		} },
	{ name: "priority",   label: "Priority", align: "center", width: 96, resize: true,
		template: function (task) {
			if (task.type === gantt.config.types.project || !task.priority) return "";
			return `<span class='priority_cell'><span class='dhx_dot dhx_dot--${task.priority}'></span>${PRIORITY_LABELS[task.priority]}</span>`;
		} },
	{ name: "add", width: 40, resize: true }
];

/* -- fully custom task editor ----------------------------------------------- */
let editorState = {
	taskId: null,
	ownerIds: [],
	priority: "medium",
	wasNew: false
};

let editorBackdrop = document.getElementById("task_editor_backdrop");
let editor = document.getElementById("task_editor");
let editorTitle = document.getElementById("task_editor_title");
let editorType = document.getElementById("task_editor_type");
let editorName = document.getElementById("task_editor_name");
let editorProgress = document.getElementById("task_editor_progress");
let editorProgressValue = document.getElementById("task_editor_progress_value");
let editorStartField = document.getElementById("task_editor_start_field");
let editorEndField = document.getElementById("task_editor_end_field");
let editorStart = document.getElementById("task_editor_start");
let editorEnd = document.getElementById("task_editor_end");
let ownerCombo = document.getElementById("task_editor_owner_combo");
let ownerChips = document.getElementById("task_editor_owner_chips");
let ownerInput = document.getElementById("task_editor_owner_input");
let ownerMenu = document.getElementById("task_editor_owner_menu");
let ownerToggle = document.getElementById("task_editor_owner_toggle");
let predecessorList = document.getElementById("task_editor_pred_list");
let autoSchedulingToggle = document.getElementById("auto_scheduling_toggle");
let runAutoSchedulingButton = document.getElementById("run_auto_scheduling");

let editorDateFormat = gantt.date.date_to_str("%Y-%m-%d");
let noticeDateFormat = gantt.date.date_to_str("%d %M %Y");
let editorAutoScheduleContext = null;

const linkLabels = {
	"0": "Finish-to-start",
	"1": "Start-to-start",
	"2": "Finish-to-finish",
	"3": "Start-to-finish"
};


function formatNoticeDateRange(startDate, endDate) {
	const startText = noticeDateFormat(startDate);
	return endDate ? startText + " – " + noticeDateFormat(endDate) : startText;
}

function autoScheduleLinkTypeLabel(linkType) {
	return linkLabels[String(linkType)] || "dependency";
}

function autoScheduleLagLabel(lag) {
	const numericLag = Number(lag) || 0;
	if (!numericLag) return "no lag";

	const absoluteLag = Math.abs(numericLag);
	return absoluteLag + " " + (absoluteLag === 1 ? "day" : "days") + (numericLag > 0 ? " lag" : " lead");
}

function showEditorAutoScheduleNotice(task, context) {
	const requestedDates = formatNoticeDateRange(context.requestedStartDate, context.requestedEndDate);
	const scheduledEndDate = context.requestedEndDate ? task.end_date : null;
	const scheduledDates = formatNoticeDateRange(task.start_date, scheduledEndDate);

	let reason = "to satisfy its predecessor dependencies.";
	if (context.link && context.predecessor) {
		const linkType = autoScheduleLinkTypeLabel(context.link.type);
		const predecessorName = DHX.ui.escape(context.predecessor.text);
		const lag = autoScheduleLagLabel(context.link.lag);
		reason = "because of the " + linkType + " dependency on <b>" + predecessorName + "</b> (" + lag + ").";
	}

	gantt.message({
		text: "<b>" + DHX.ui.escape(task.text) + "</b> was moved by auto scheduling from " +
			requestedDates + " to " + scheduledDates + " " + reason
	});
}

gantt.attachEvent("onAfterTaskAutoSchedule", function (task, startDate, link, predecessor) {
	if (!editorAutoScheduleContext || !editorAutoScheduleContext.waitingForFinalSchedule) return;
	if (String(task.id) !== editorAutoScheduleContext.taskId) return;

	editorAutoScheduleContext.link = link;
	editorAutoScheduleContext.predecessor = predecessor;
});

gantt.attachEvent("onAfterAutoSchedule", function (rootTaskId, updatedTaskIds) {
	const context = editorAutoScheduleContext;
	if (!context || !context.waitingForFinalSchedule) return;

	context.waitingForFinalSchedule = false;

	const editedTaskWasUpdated = Array.isArray(updatedTaskIds) && updatedTaskIds.some(function (taskId) {
		return String(taskId) === context.taskId;
	});

	if (!editedTaskWasUpdated || !gantt.isTaskExists(context.taskId)) {
		editorAutoScheduleContext = null;
		return;
	}

	const task = gantt.getTask(context.taskId);
	const startDateChanged = context.requestedStartDate &&
		task.start_date.valueOf() !== context.requestedStartDate.valueOf();
	const endDateChanged = context.requestedEndDate &&
		task.end_date.valueOf() !== context.requestedEndDate.valueOf();

	if (startDateChanged || endDateChanged) {
		showEditorAutoScheduleNotice(task, context);
	}

	editorAutoScheduleContext = null;
});

function setAutoScheduling(enabled) {
	gantt.config.auto_scheduling.enabled = enabled;
	DHX.toggle.setWrap(autoSchedulingToggle, enabled);
	if (enabled){
		gantt.autoSchedule();
	}
}

function runAutoScheduling() {
	editorAutoScheduleContext = null;
	gantt.autoSchedule();
}


function parseEditorDate(value) {
	if (!value) return null;
	let parts = value.split("-");
	return new Date(Number(parts[0]), Number(parts[1]) - 1, Number(parts[2]));
}

function taskTypeLabel(task) {
	if (task.type === gantt.config.types.project) return "Project editor";
	if (task.type === gantt.config.types.milestone) return "Milestone editor";
	return "Task editor";
}

function setEditorTab(name) {
	editor.querySelectorAll("[data-editor-tab]").forEach(function (tab) {
		tab.classList.toggle("is_active", tab.getAttribute("data-editor-tab") === name);
	});
	editor.querySelectorAll("[data-editor-page]").forEach(function (page) {
		page.classList.toggle("is_active", page.getAttribute("data-editor-page") === name);
	});
}

function getOwnerById(id) {
	return OWNERS_BY_ID[Number(id)] || null;
}

function hasSelectedOwner(id) {
	return editorState.ownerIds.some(function (ownerId) {
		return String(ownerId) === String(id);
	});
}

function renderSelectedOwners() {
	ownerChips.innerHTML = editorState.ownerIds.map(function (ownerId) {
		let owner = getOwnerById(ownerId);
		if (!owner) return "";

		return "<span class='task_editor_combo__chip'>" +
			"<span class='task_editor_combo__chip_name'>" + DHX.ui.escape(owner.name) + "</span>" +
			"<button type='button' class='task_editor_combo__chip_remove' data-owner-id='" +
				owner.id + "' aria-label='Remove " + DHX.ui.escape(owner.name) + "'>&times;</button>" +
		"</span>";
	}).join("");

	ownerChips.querySelectorAll(".task_editor_combo__chip_remove").forEach(function (button) {
		button.addEventListener("mousedown", function (event) {
			event.preventDefault();
			event.stopPropagation();
			toggleOwner(button.getAttribute("data-owner-id"), false);
			ownerInput.focus();
		});
	});
}

function renderOwnerMenu(query) {
	let normalized = (query || "").trim().toLowerCase();
	let matches = OWNERS.filter(function (owner) {
		return !normalized ||
			owner.name.toLowerCase().indexOf(normalized) !== -1 ||
			owner.role.toLowerCase().indexOf(normalized) !== -1;
	});

	if (!matches.length) {
		ownerMenu.innerHTML = "<span class='task_editor_combo__empty'>No matching owners</span>";
		return;
	}

	ownerMenu.innerHTML = matches.map(function (owner, index) {
		let selected = hasSelectedOwner(owner.id);

		return "<div class='task_editor_combo__option" +
			(index === 0 ? " is_active" : "") +
			(selected ? " is_selected" : "") +
			"' data-owner-id='" + owner.id + "'>" +
			"<span class='task_editor_combo__avatar' style='background:" + owner.color + "'>" +
				DHX.ui.initials(owner.name) +
			"</span>" +
			"<span><span class='task_editor_combo__name'>" + DHX.ui.escape(owner.name) + "</span>" +
			"<span class='task_editor_combo__role'>" + DHX.ui.escape(owner.role) + "</span></span>" +
			"<span class='task_editor_combo__check'>" + (selected ? "✓" : "") + "</span>" +
		"</div>";
	}).join("");

	ownerMenu.querySelectorAll(".task_editor_combo__option").forEach(function (option) {
		option.addEventListener("mousedown", function (event) {
			event.preventDefault();
			toggleOwner(option.getAttribute("data-owner-id"));
		});
	});
}

function openOwnerMenu() {
	renderOwnerMenu(ownerInput.value);
	ownerCombo.classList.add("is_open");
	ownerInput.setAttribute("aria-expanded", "true");
}

function closeOwnerMenu() {
	ownerCombo.classList.remove("is_open");
	ownerInput.setAttribute("aria-expanded", "false");
}

function toggleOwner(id, keepOpen) {
	let ownerId = Number(id);
	let selected = hasSelectedOwner(ownerId);

	if (selected) {
		editorState.ownerIds = editorState.ownerIds.filter(function (currentId) {
			return String(currentId) !== String(ownerId);
		});
	} else {
		editorState.ownerIds.push(ownerId);
	}

	ownerInput.value = "";
	renderSelectedOwners();
	renderOwnerMenu("");

	if (keepOpen !== false) {
		ownerCombo.classList.add("is_open");
		ownerInput.setAttribute("aria-expanded", "true");
		ownerInput.focus();
	} else {
		closeOwnerMenu();
	}
}

function setPriority(value) {
	editorState.priority = value || "medium";
	document.querySelectorAll("#task_editor_priority [data-priority]").forEach(function (button) {
		button.classList.toggle("is_active", button.getAttribute("data-priority") === editorState.priority);
	});
}

function incomingLinks(taskId) {
	return gantt.getLinks().filter(function (link) {
		return String(link.target) === String(taskId);
	});
}

function isDescendantOf(candidateId, ancestorId) {
	let currentId = candidateId;
	while (gantt.isTaskExists(currentId)) {
		let current = gantt.getTask(currentId);
		if (String(current.parent) === String(ancestorId)) return true;
		if (
			current.parent == null ||
			current.parent === gantt.config.root_id ||
			current.parent === 0 ||
			String(current.parent) === String(currentId)
		) {
			break;
		}
		currentId = current.parent;
	}
	return false;
}

function predecessorOptions(selectedId) {
	let options = ["<option value=''>Choose a task...</option>"];

	gantt.eachTask(function (task) {
		if (String(task.id) === String(editorState.taskId)) return;
		if (isDescendantOf(task.id, editorState.taskId)) return;

		options.push(
			"<option value='" + DHX.ui.escape(String(task.id)) + "'" +
			(String(task.id) === String(selectedId) ? " selected" : "") + ">" +
			DHX.ui.escape(task.text) +
			"</option>"
		);
	});

	return options.join("");
}

function linkTypeOptions(selectedType) {
	const options = [];
	for (const prop in linkLabels){
		options.push(
			`<option value='${prop}'${(prop === String(selectedType) ? " selected" : "")}>${linkLabels[prop]}</option>`
		)
	}
	return options.join("");

}

function addPredecessorRow(link) {
	let row = document.createElement("div");
	row.className = "task_editor__pred_row";
	row.setAttribute("data-link-id", link && link.id != null ? link.id : "");

	row.innerHTML =
		"<select class='task_editor__select task_editor__pred_source'>" +
			predecessorOptions(link ? link.source : "") +
		"</select>" +
		"<select class='task_editor__select task_editor__pred_type'>" +
			linkTypeOptions(link ? link.type : "0") +
		"</select>" +
		"<input class='task_editor__number task_editor__pred_lag' type='number' step='1' value='" +
			DHX.ui.escape(String(link && link.lag != null ? link.lag : 0)) + "'>" +
		"<button type='button' class='task_editor__pred_remove' title='Remove predecessor'>" +
			"<svg viewBox='0 0 24 24'><polyline points='4 7 20 7'/><path d='M9 7V4h6v3'/><path d='M6 7l1 13h10l1-13'/></svg>" +
		"</button>";

	row.querySelector(".task_editor__pred_remove").addEventListener("click", function () {
		row.parentNode.removeChild(row);
		updatePredecessorEmptyState();
	});

	predecessorList.appendChild(row);
	updatePredecessorEmptyState();
}

function updatePredecessorEmptyState() {
	let empty = predecessorList.querySelector(".task_editor__pred_empty");
	let rows = predecessorList.querySelectorAll(".task_editor__pred_row");

	if (rows.length && empty) {
		empty.parentNode.removeChild(empty);
	} else if (!rows.length && !empty) {
		predecessorList.innerHTML = "<div class='task_editor__pred_empty'>No predecessors yet. Add one to define how this task depends on another task.</div>";
	}
}

function renderPredecessors(taskId) {
	predecessorList.innerHTML = "";
	let links = incomingLinks(taskId);

	if (!links.length) {
		updatePredecessorEmptyState();
		return;
	}

	links.forEach(addPredecessorRow);
}

function positionEditorInitially() {
	let width = Math.min(540, window.innerWidth - 36);
	let height = Math.min(570, window.innerHeight - 36);

	editor.style.width = width + "px";
	editor.style.height = height + "px";
	editor.style.left = Math.max(6, (window.innerWidth - width) / 2) + "px";
	editor.style.top = Math.max(6, (window.innerHeight - height) / 2) + "px";
}

function openTaskEditor(taskId) {
	if (!gantt.isTaskExists(taskId)) return;

	let task = gantt.getTask(taskId);
	editorState.taskId = task.id;
	editorState.ownerIds = normalizeOwnerIds(task.owner);
	editorState.wasNew = !!task.$new;

	editorTitle.textContent = task.text || "Untitled task";
	editorType.textContent = taskTypeLabel(task);
	editorName.value = task.text || "";

	ownerInput.value = "";
	renderSelectedOwners();
	renderOwnerMenu("");

	setPriority(task.priority || "medium");

	let isProject = task.type === gantt.config.types.project;
	let isMilestone = task.type === gantt.config.types.milestone;

	if (isMilestone) {
		editorProgress.parentNode.parentNode.style.display = "none";
	}
	else {
		let progress = Math.round((task.progress || 0) * 100);
		editorProgress.value = progress;
		editorProgressValue.textContent = progress + "%";
		editorProgress.parentNode.parentNode.style.display = "";
	}

	editorStartField.style.display = isProject ? "none" : "";
	editorEndField.style.display = (isProject || isMilestone) ? "none" : "";

	if (!isProject) {
		editorStart.value = editorDateFormat(task.start_date);
		if (!isMilestone) {
			editorEnd.value = editorDateFormat(task.end_date);
			editorEnd.min = editorStart.value;
		}
	}

	renderPredecessors(task.id);
	setEditorTab("details");
	positionEditorInitially();
	editorBackdrop.classList.add("is_open");

	setTimeout(function () {
		editorName.focus();
		editorName.select();
	}, 0);
}

function closeTaskEditor(options) {
	options = options || {};

	if (editorState.wasNew && !options.keepNewTask && editorState.taskId != null && gantt.isTaskExists(editorState.taskId)) {
		gantt.deleteTask(editorState.taskId);
	}

	closeOwnerMenu();
	editorBackdrop.classList.remove("is_open");
	editorState.taskId = null;
	editorState.wasNew = false;
}

function collectPredecessors() {
	let result = [];

	predecessorList.querySelectorAll(".task_editor__pred_row").forEach(function (row) {
		let source = row.querySelector(".task_editor__pred_source").value;
		if (!source) return;

		result.push({
			id: row.getAttribute("data-link-id") || null,
			source: source,
			target: editorState.taskId,
			type: row.querySelector(".task_editor__pred_type").value,
			lag: Number(row.querySelector(".task_editor__pred_lag").value) || 0
		});
	});

	return result;
}

function validateEditor(task, predecessors) {
	let name = editorName.value.trim();
	if (!name) {
		setEditorTab("details");
		editorName.focus();
		gantt.message({ type: "error", text: "Give the task a name before saving." });
		return false;
	}

	if (task.type !== gantt.config.types.project) {
		let start = parseEditorDate(editorStart.value);
		if (!start) {
			setEditorTab("details");
			editorStart.focus();
			gantt.message({ type: "error", text: "Choose a start date." });
			return false;
		}

		if (task.type !== gantt.config.types.milestone) {
			let end = parseEditorDate(editorEnd.value);
			if (!end || end.valueOf() <= start.valueOf()) {
				setEditorTab("details");
				editorEnd.focus();
				gantt.message({ type: "error", text: "End date must be after the start date." });
				return false;
			}
		}
	}

	let seenSources = {};
	for (let i = 0; i < predecessors.length; i++) {
		let key = String(predecessors[i].source);
		if (seenSources[key]) {
			setEditorTab("predecessors");
			gantt.message({ type: "error", text: "The same predecessor cannot be added twice." });
			return false;
		}
		seenSources[key] = true;
	}

	return true;
}

function savePredecessors(taskId, rows) {
	let existing = incomingLinks(taskId);
	let retained = {};

	rows.forEach(function (row) {
		if (row.id && gantt.isLinkExists(row.id)) {
			let link = gantt.getLink(row.id);
			link.source = row.source;
			link.target = taskId;
			link.type = row.type;
			link.lag = row.lag;
			gantt.updateLink(link.id);
			retained[String(link.id)] = true;
		} else {
			let newId = gantt.addLink({
				source: row.source,
				target: taskId,
				type: row.type,
				lag: row.lag,
			});
			retained[String(newId)] = true;
		}
	});

	existing.forEach(function (link) {
		if (!retained[String(link.id)] && gantt.isLinkExists(link.id)) {
			gantt.deleteLink(link.id);
		}
	});
}

function saveTaskEditor() {
	if (editorState.taskId == null || !gantt.isTaskExists(editorState.taskId)) return;

	let task = gantt.getTask(editorState.taskId);
	let predecessorRows = collectPredecessors();

	if (!validateEditor(task, predecessorRows)) return;

	const taskValues = {
		text: editorName.value.trim(),
		owner: editorState.ownerIds.slice(),
		priority: editorState.priority,
		progress: Number(editorProgress.value) / 100,
		startDate: task.type !== gantt.config.types.project ? parseEditorDate(editorStart.value) : null,
		endDate: task.type !== gantt.config.types.project && task.type !== gantt.config.types.milestone
			? parseEditorDate(editorEnd.value)
			: null
	};

	gantt.batchUpdate(function () {
		/* Apply the final predecessor set before updating the task itself. */
		savePredecessors(task.id, predecessorRows);

		/* Auto scheduling may have changed the task while links were updated. */
		task = gantt.getTask(task.id);
		task.text = taskValues.text;
		task.owner = taskValues.owner;
		task.priority = taskValues.priority;

		if (task.type !== gantt.config.types.milestone) {
			task.progress = taskValues.progress;
		}

		if (task.type !== gantt.config.types.project) {
			task.start_date = taskValues.startDate;

			if (task.type !== gantt.config.types.milestone) {
				task.end_date = taskValues.endDate;
				task.duration = gantt.calculateDuration({
					start_date: taskValues.startDate,
					end_date: taskValues.endDate,
					task: task
				});
			}
		}

		task.$new = false;

		/*
		* Create the context only for the final task update. Auto-scheduling
		* cycles caused by predecessor changes above must not show the notice.
		*/
		if (gantt.config.auto_scheduling.enabled && task.type !== gantt.config.types.project) {
			editorAutoScheduleContext = {
				taskId: String(task.id),
				requestedStartDate: taskValues.startDate ? new Date(taskValues.startDate) : null,
				requestedEndDate: taskValues.endDate ? new Date(taskValues.endDate) : null,
				waitingForFinalSchedule: true,
				link: null,
				predecessor: null
			};
		} else {
			editorAutoScheduleContext = null;
		}

		gantt.updateTask(task.id);
	});

	editorState.wasNew = false;
	closeTaskEditor({ keepNewTask: true });
}

function deleteEditedTask() {
	if (editorState.taskId == null || !gantt.isTaskExists(editorState.taskId)) return;

	let task = gantt.getTask(editorState.taskId);
	if (!window.confirm('Delete "' + task.text + '"?')) return;

	let taskId = task.id;
	editorState.wasNew = false;
	closeTaskEditor({ keepNewTask: true });
	gantt.deleteTask(taskId);
}

/* Redirect every built-in lightbox request to the custom editor. */
gantt.attachEvent("onBeforeLightbox", function (taskId) {
	openTaskEditor(taskId);
	return false;
});

/* Tabs and footer actions. */
editor.querySelectorAll("[data-editor-tab]").forEach(function (tab) {
	tab.addEventListener("click", function () {
		setEditorTab(tab.getAttribute("data-editor-tab"));
	});
});

document.getElementById("task_editor_close").addEventListener("click", function () {
	closeTaskEditor();
});
document.getElementById("task_editor_cancel").addEventListener("click", function () {
	closeTaskEditor();
});
document.getElementById("task_editor_save").addEventListener("click", saveTaskEditor);
document.getElementById("task_editor_delete").addEventListener("click", deleteEditedTask);
document.getElementById("task_editor_add_pred").addEventListener("click", function () {
	let empty = predecessorList.querySelector(".task_editor__pred_empty");
	if (empty) empty.parentNode.removeChild(empty);
	addPredecessorRow(null);
});

autoSchedulingToggle.addEventListener("click", function () {
	setAutoScheduling(!gantt.config.auto_scheduling.enabled);
});
runAutoSchedulingButton.addEventListener("click", runAutoScheduling);
setAutoScheduling(gantt.config.auto_scheduling.enabled);

/* Details controls. */
editorName.addEventListener("input", function () {
	editorTitle.textContent = editorName.value.trim() || "Untitled task";
});

document.querySelectorAll("#task_editor_priority [data-priority]").forEach(function (button) {
	button.addEventListener("click", function () {
		setPriority(button.getAttribute("data-priority"));
	});
});

editorProgress.addEventListener("input", function () {
	editorProgressValue.textContent = editorProgress.value + "%";
});

editorStart.addEventListener("change", function () {
	editorEnd.min = editorStart.value;

	const currentTask = editorState.taskId != null && gantt.isTaskExists(editorState.taskId)
		? gantt.getTask(editorState.taskId)
		: null;

	if (!currentTask || currentTask.type === gantt.config.types.milestone) return;

	const startDate = parseEditorDate(editorStart.value);
	if (!startDate) return;

	const duration = Math.max(Number(currentTask.duration) || 1, 1);

	editorEnd.value = editorDateFormat(gantt.calculateEndDate({
		start_date: startDate,
		duration: duration,
		task: currentTask
	}));
});

/* Owner combo-box interactions. */
ownerInput.addEventListener("focus", function () {
	openOwnerMenu();
});

ownerInput.addEventListener("input", function () {
	renderOwnerMenu(ownerInput.value);
	ownerCombo.classList.add("is_open");
	ownerInput.setAttribute("aria-expanded", "true");
});

ownerToggle.addEventListener("mousedown", function (event) {
	event.preventDefault();

	if (ownerCombo.classList.contains("is_open")) {
		closeOwnerMenu();
	} else {
		ownerInput.value = "";
		ownerInput.focus();
		openOwnerMenu();
	}
});

ownerInput.addEventListener("keydown", function (event) {
	let options = ownerMenu.querySelectorAll(".task_editor_combo__option");
	let active = ownerMenu.querySelector(".task_editor_combo__option.is_active");
	let index = Array.prototype.indexOf.call(options, active);

	if (event.key === "ArrowDown" || event.key === "ArrowUp") {
		event.preventDefault();

		if (!ownerCombo.classList.contains("is_open")) {
			openOwnerMenu();
		}

		options = ownerMenu.querySelectorAll(".task_editor_combo__option");
		if (!options.length) return;

		index = event.key === "ArrowDown"
			? Math.min(index + 1, options.length - 1)
			: Math.max(index <= 0 ? 0 : index - 1, 0);

		options.forEach(function (option) {
			option.classList.remove("is_active");
		});
		options[index].classList.add("is_active");
		options[index].scrollIntoView({ block: "nearest" });
	} else if (event.key === "Enter" && active) {
		event.preventDefault();
		toggleOwner(active.getAttribute("data-owner-id"));
	} else if (event.key === "Backspace" && !ownerInput.value && editorState.ownerIds.length) {
		editorState.ownerIds.pop();
		renderSelectedOwners();
		renderOwnerMenu("");
	} else if (event.key === "Escape") {
		event.stopPropagation();
		closeOwnerMenu();
	}
});

ownerInput.addEventListener("blur", function () {
	setTimeout(function () {
		if (!ownerCombo.contains(document.activeElement)) {
			ownerInput.value = "";
			closeOwnerMenu();
		}
	}, 0);
});

/* Close only when the backdrop itself is pressed. */
editorBackdrop.addEventListener("mousedown", function (event) {
	if (event.target === editorBackdrop) {
		closeTaskEditor();
	}
});

document.addEventListener("keydown", function (event) {
	if (!editorBackdrop.classList.contains("is_open")) return;
	if (event.key === "Escape" && !ownerCombo.classList.contains("is_open")) {
		closeTaskEditor();
	}
});

/* Drag from any non-interactive part of the editor. */
let dragState = null;

editor.addEventListener("pointerdown", function (event) {
	if (event.button !== 0) return;
	if (event.target.closest(
		"input, select, textarea, button, label, .task_editor_combo__option, " +
		".task_editor__pred_row, .task_editor__resize"
	)) {
		return;
	}

	let rect = editor.getBoundingClientRect();
	dragState = {
		pointerId: event.pointerId,
		startX: event.clientX,
		startY: event.clientY,
		left: rect.left,
		top: rect.top
	};

	editor.classList.add("is_dragging");
	editor.setPointerCapture(event.pointerId);
	event.preventDefault();
});

editor.addEventListener("pointermove", function (event) {
	if (!dragState || dragState.pointerId !== event.pointerId) return;

	let left = dragState.left + event.clientX - dragState.startX;
	let top = dragState.top + event.clientY - dragState.startY;
	let rect = editor.getBoundingClientRect();

	editor.style.left = Math.max(0, Math.min(left, window.innerWidth - rect.width)) + "px";
	editor.style.top = Math.max(0, Math.min(top, window.innerHeight - rect.height)) + "px";
});

function stopEditorDrag(event) {
	if (!dragState || dragState.pointerId !== event.pointerId) return;
	dragState = null;
	editor.classList.remove("is_dragging");
	if (editor.hasPointerCapture(event.pointerId)) {
		editor.releasePointerCapture(event.pointerId);
	}
}

editor.addEventListener("pointerup", stopEditorDrag);
editor.addEventListener("pointercancel", stopEditorDrag);

/* Resize from the lower-right corner. */
let resizeState = null;
let resizeHandle = document.getElementById("task_editor_resize");

resizeHandle.addEventListener("pointerdown", function (event) {
	if (event.button !== 0) return;

	let rect = editor.getBoundingClientRect();
	resizeState = {
		pointerId: event.pointerId,
		startX: event.clientX,
		startY: event.clientY,
		width: rect.width,
		height: rect.height
	};

	editor.classList.add("is_resizing");
	resizeHandle.setPointerCapture(event.pointerId);
	event.preventDefault();
	event.stopPropagation();
});

resizeHandle.addEventListener("pointermove", function (event) {
	if (!resizeState || resizeState.pointerId !== event.pointerId) return;

	let rect = editor.getBoundingClientRect();
	let minWidth = Math.min(540, window.innerWidth - rect.left);
	let minHeight = Math.min(470, window.innerHeight - rect.top);
	let width = Math.max(minWidth, Math.min(
		resizeState.width + event.clientX - resizeState.startX,
		window.innerWidth - rect.left
	));
	let height = Math.max(minHeight, Math.min(
		resizeState.height + event.clientY - resizeState.startY,
		window.innerHeight - rect.top
	));

	editor.style.width = width + "px";
	editor.style.height = height + "px";
});

function stopEditorResize(event) {
	if (!resizeState || resizeState.pointerId !== event.pointerId) return;
	resizeState = null;
	editor.classList.remove("is_resizing");
	if (resizeHandle.hasPointerCapture(event.pointerId)) {
		resizeHandle.releasePointerCapture(event.pointerId);
	}
}

resizeHandle.addEventListener("pointerup", stopEditorResize);
resizeHandle.addEventListener("pointercancel", stopEditorResize);


/* -- data: marketing-site redesign, two phases, five-person team ------------ */
let data = {
	tasks: [
		{ id: 1, text: "Discovery & Design", type: "project", open: true, progress: 0.55 },

		{ id: 11, text: "Stakeholder interviews",       parent: 1, start_date: addDays(0),  duration: 3, owner: 1, priority: "high",   progress: 1 },
		{ id: 12, text: "Competitor & analytics audit", parent: 1, start_date: addDays(0),  duration: 3, owner: 2, priority: "medium", progress: 1 },
		{ id: 13, text: "Sitemap & content strategy",   parent: 1, start_date: addDays(3),  duration: 4, owner: 3, priority: "high",   progress: 0.8 },
		{ id: 14, text: "Wireframes",                   parent: 1, start_date: addDays(7),  duration: 5, owner: 5, priority: "high",   progress: 0.6 },
		{ id: 15, text: "Visual design system",         parent: 1, start_date: addDays(7),  duration: 6, owner: 5, priority: "medium", progress: 0.4 },
		{ id: 16, text: "Design review & sign-off",     parent: 1, start_date: addDays(13), duration: 2, owner: 1, priority: "high",   progress: 0.1 },
		{ id: 17, text: "Design phase complete",        parent: 1, start_date: addDays(15), type: "milestone" },

		{ id: 2, text: "Build & Launch", type: "project", open: true, progress: 0.05 },

		{ id: 21, text: "Component library build",      parent: 2, start_date: addDays(15), duration: 6, owner: 4, priority: "high",   progress: 0 },
		{ id: 22, text: "Homepage build",                parent: 2, start_date: addDays(21), duration: 4, owner: 4, priority: "high",   progress: 0 },
		{ id: 23, text: "Product pages build",           parent: 2, start_date: addDays(21), duration: 5, owner: 2, priority: "medium", progress: 0 },
		{ id: 24, text: "Blog & resources build",        parent: 2, start_date: addDays(21), duration: 4, owner: 3, priority: "low",    progress: 0 },
		{ id: 25, text: "CMS content migration",         parent: 2, start_date: addDays(21), duration: 5, owner: 3, priority: "medium", progress: 0 },
		{ id: 26, text: "SEO & metadata setup",          parent: 2, start_date: addDays(26), duration: 3, owner: 2, priority: "medium", progress: 0 },
		{ id: 27, text: "Analytics & tracking setup",    parent: 2, start_date: addDays(26), duration: 2, owner: 4, priority: "low",    progress: 0 },
		{ id: 28, text: "Cross-browser QA",              parent: 2, start_date: addDays(29), duration: 4, owner: 1, priority: "high",   progress: 0 },
		{ id: 29, text: "Performance & accessibility audit", parent: 2, start_date: addDays(29), duration: 3, owner: 5, priority: "medium", progress: 0 },
		{ id: 30, text: "Legal & compliance review",     parent: 2, start_date: addDays(26), duration: 2, owner: 1, priority: "low",    progress: 0 },
		{ id: 31, text: "Stakeholder UAT",               parent: 2, start_date: addDays(33), duration: 3, owner: 1, priority: "high",   progress: 0 },
		{ id: 32, text: "Launch prep & DNS cutover",     parent: 2, start_date: addDays(36), duration: 2, owner: 4, priority: "high",   progress: 0 },
		{ id: 33, text: "Site launch",                   parent: 2, start_date: addDays(38), type: "milestone" },
		{ id: 34, text: "Post-launch monitoring",        parent: 2, start_date: addDays(38), duration: 3, owner: 2, priority: "low",    progress: 0 }
	],
	links: [
		{ id: 1,  source: 11, target: 13, type: "0" },
		{ id: 2,  source: 13, target: 14, type: "0" },
		{ id: 3,  source: 13, target: 15, type: "0" },
		{ id: 4,  source: 14, target: 16, type: "0" },
		{ id: 5,  source: 15, target: 16, type: "0" },
		{ id: 6,  source: 16, target: 17, type: "0" },
		{ id: 7,  source: 16, target: 21, type: "0" },
		{ id: 8,  source: 21, target: 22, type: "0" },
		{ id: 9,  source: 21, target: 23, type: "0" },
		{ id: 10, source: 21, target: 24, type: "0" },
		{ id: 11, source: 13, target: 25, type: "0" },
		{ id: 12, source: 22, target: 26, type: "0" },
		{ id: 13, source: 23, target: 26, type: "0" },
		{ id: 14, source: 22, target: 27, type: "0" },
		{ id: 15, source: 22, target: 28, type: "0" },
		{ id: 16, source: 23, target: 28, type: "0" },
		{ id: 17, source: 24, target: 28, type: "0" },
		{ id: 18, source: 22, target: 29, type: "0" },
		{ id: 19, source: 23, target: 29, type: "0" },
		{ id: 20, source: 25, target: 30, type: "0" },
		{ id: 21, source: 28, target: 31, type: "0" },
		{ id: 22, source: 29, target: 31, type: "0" },
		{ id: 23, source: 30, target: 31, type: "0" },
		{ id: 24, source: 31, target: 32, type: "0" },
		{ id: 25, source: 32, target: 33, type: "0" },
		{ id: 26, source: 33, target: 34, type: "0" }
	]
};

/* The button opens the lightbox for "Homepage build": it carries every
   field the demo showcases (owner, priority, partial progress, dates). */
function openSampleEditor() {
	openTaskEditor(22);
}

document.getElementById("open_sample_editor").addEventListener("click", openSampleEditor);

gantt.init("gantt_here");
gantt.parse(data);
<!DOCTYPE html>
<html lang="en">
<head>
	<meta http-equiv="Content-type" content="text/html; charset=utf-8">
	<title>A task editor that matches your product: dhtmlxGantt</title>
	<script src="../../codebase/dhtmlxgantt.js?v=10.0.0"></script>
	<link rel="stylesheet" href="../../codebase/dhtmlxgantt.css?v=10.0.0">
	<link rel="stylesheet" href="../common/demo-controls/dhx_controls.css">
	<script src="../common/demo-controls/dhx_controls.js"></script>
	<link rel="preconnect" href="https://fonts.googleapis.com">
	<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
	<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap">
	<link rel="stylesheet" href="custom_task_editor/31_custom_task_editor.css">
</head>
<body>

<div class="dhx_header">
	<span class="hint" style="flex:1 1 0">Double-click any bar to open the editor</span>
	<button class="dhx_btn dhx_btn--primary" id="open_sample_editor">Open editor for a sample task</button>
	<div class="dhx_sep"></div>
	<label class="dhx_toggle" id="auto_scheduling_toggle">
		<span>Auto scheduling</span><span class="dhx_toggle__track"></span>
	</label>
	<button type="button" class="dhx_btn dhx_btn--surface" id="run_auto_scheduling">Recalculate all tasks</button>
	<div class="dhx_spacer"></div>
</div>

<div id="gantt_here"></div>

<div class="task_editor_backdrop" id="task_editor_backdrop">
	<section class="task_editor" id="task_editor" role="dialog" aria-modal="true" aria-labelledby="task_editor_title">
		<header class="task_editor__header">
			<div class="task_editor__title_wrap">
				<span class="task_editor__eyebrow" id="task_editor_type">Task editor</span>
				<span class="task_editor__title" id="task_editor_title">Task</span>
			</div>
			<button type="button" class="task_editor__close" id="task_editor_close" aria-label="Close">
				<svg viewBox="0 0 24 24"><line x1="6" y1="6" x2="18" y2="18"/><line x1="18" y1="6" x2="6" y2="18"/></svg>
			</button>
		</header>

		<nav class="task_editor__tabs" aria-label="Task editor pages">
			<button type="button" class="task_editor__tab is_active" data-editor-tab="details">Details</button>
			<button type="button" class="task_editor__tab" data-editor-tab="predecessors">Predecessors</button>
		</nav>

		<div class="task_editor__body">
			<div class="task_editor__page is_active" data-editor-page="details">
				<div class="task_editor__grid">
					<label class="task_editor__field task_editor__field--wide">
						<span class="task_editor__label">Task name</span>
						<input type="text" class="task_editor__input" id="task_editor_name" autocomplete="off">
					</label>

					<div class="task_editor__field task_editor__field--wide">
						<span class="task_editor__label">Owner</span>
						<div class="task_editor_combo" id="task_editor_owner_combo">
							<div class="task_editor_combo__control">
								<div class="task_editor_combo__chips" id="task_editor_owner_chips"></div>
								<input type="text" class="task_editor_combo__input" id="task_editor_owner_input"
									placeholder="Search people..." autocomplete="off" role="combobox"
									aria-autocomplete="list" aria-expanded="false">
								<button type="button" class="task_editor_combo__toggle" id="task_editor_owner_toggle" aria-label="Show owners">
									<svg viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
								</button>
							</div>
							<div class="task_editor_combo__menu" id="task_editor_owner_menu" role="listbox"></div>
						</div>
					</div>

					<div class="task_editor__field task_editor__field--wide">
						<span class="task_editor__label">Priority</span>
						<div class="task_editor__priority" id="task_editor_priority">
							<button type="button" class="task_editor__priority_btn" data-priority="high">
								<span class="task_editor__dot task_editor__dot--high"></span>High
							</button>
							<button type="button" class="task_editor__priority_btn" data-priority="medium">
								<span class="task_editor__dot task_editor__dot--medium"></span>Medium
							</button>
							<button type="button" class="task_editor__priority_btn" data-priority="low">
								<span class="task_editor__dot task_editor__dot--low"></span>Low
							</button>
						</div>
					</div>

					<div class="task_editor__field task_editor__field--wide">
						<span class="task_editor__label">Progress</span>
						<div class="task_editor__progress">
							<input type="range" class="task_editor__range" id="task_editor_progress" min="0" max="100" step="5">
							<span class="task_editor__progress_value" id="task_editor_progress_value">0%</span>
						</div>
					</div>

					<label class="task_editor__field" id="task_editor_start_field">
						<span class="task_editor__label">Start date</span>
						<input type="date" class="task_editor__input" id="task_editor_start">
					</label>

					<label class="task_editor__field" id="task_editor_end_field">
						<span class="task_editor__label">End date</span>
						<input type="date" class="task_editor__input" id="task_editor_end">
					</label>
				</div>
			</div>

			<div class="task_editor__page" data-editor-page="predecessors">
				<div class="task_editor__pred_head">
					<span>Predecessor</span>
					<span>Link type</span>
					<span>Lag</span>
					<span></span>
				</div>
				<div class="task_editor__pred_list" id="task_editor_pred_list"></div>
				<button type="button" class="task_editor__add_pred" id="task_editor_add_pred">+ Add predecessor</button>
			</div>
		</div>

		<footer class="task_editor__footer">
			<button type="button" class="task_editor__button task_editor__button--delete" id="task_editor_delete">Delete</button>
			<span class="task_editor__footer_spacer"></span>
			<button type="button" class="task_editor__button" id="task_editor_cancel">Cancel</button>
			<button type="button" class="task_editor__button task_editor__button--save" id="task_editor_save">Save</button>
		</footer>

		<span class="task_editor__resize" id="task_editor_resize" aria-hidden="true"></span>
	</section>
</div>


<script src="custom_task_editor/31_custom_task_editor.js"></script>
</body>
</html>