Right click (long press on touch devices) on the timeline to fire a context-menu event.
Attach an event handler to the context-menu event by using the timeline.on() function.
See Also
Right click (long press on touch devices) on the timeline to invoke a context-menu event.
Attach an event handler to the context-menu event by using the onTimelineContextMenu function.
See Also
import { createTimeline } from "kronograph";
import data from "./data";
const timeline = createTimeline("my-timeline");
const contextmenuElement = document.getElementById("my-contextmenu");
const timelineElement = document.getElementById("my-timeline");
let entitiesFocused;
let currentData;
function filter(items, predicate) {
return Object.entries(items).reduce((result, [key, val]) => {
if (predicate(val, key, items)) {
result[key] = val;
}
return result;
}, {});
}
function setTimelineData(newData) {
currentData = newData;
timeline.set(newData);
}
const contextmenuOptions = {
background: [
{
id: "zoomToFit",
text: () => "Zoom to Fit",
handler: () => timeline.fit(),
},
{
id: "resetTimeline",
text: () => "Reset Timeline",
handler: () => {
setTimelineData(data);
timeline.options({ scales: { dateOrder: "mdy" } });
timeline.focus([]);
timeline.markers([]);
timeline.pin(timeline.getPinnedEntityIds(), false);
timeline.fit();
},
},
],
entity: [
{
id: "entityRowPin",
text: (id) =>
timeline.getPinnedEntityIds().includes(id) ? "Unpin Entity Row" : "Pin Entity Row",
handler: (id) => timeline.pin(id, !timeline.getPinnedEntityIds().includes(id)),
},
{
id: "entityRowFocus",
text: (id) => (entitiesFocused.includes(id) ? "Unfocus Entity Row" : "Focus Entity Row"),
handler: (id) => {
timeline.focus(
entitiesFocused.includes(id)
? entitiesFocused.filter((entityId) => entityId !== id)
: [...entitiesFocused, id]
);
},
},
{
id: "removeEntityRow",
text: () => "Remove Entity Row",
handler: (id) => removeEntity(id),
},
],
event: [
{
id: "markEvent",
text: () => "Mark Event",
handler: (id) => {
timeline.markers([...timeline.markers(), { label: id, time: timeline.getEvent(id).time }]);
},
},
{
id: "focusEnds",
text: (id) => (eventEndsFocused(id) ? "Unfocus Ends" : "Focus Ends"),
handler: (id) => {
const eventEntityIds = timeline.getEvent(id).entityIds;
timeline.focus(
eventEndsFocused(id)
? entitiesFocused.filter((entityId) => !eventEntityIds.includes(entityId))
: [...entitiesFocused, ...eventEntityIds]
);
},
},
],
marker: [
{
id: "unMarkEvent",
text: () => "Unmark Event",
handler: (id) => {
const markerId = id.split("_marker")[1];
timeline.markers(timeline.markers().filter((_m, index) => index !== +markerId));
},
},
],
scale: [
{
id: "dmyFormat",
text: () => "Use 'dmy' Format",
handler: () =>
timeline.options({
scales: {
dateOrder: "dmy",
},
}),
},
{
id: "mdyFormat",
text: () => "Use 'mdy' Format",
handler: () =>
timeline.options({
scales: {
dateOrder: "mdy",
},
}),
},
],
};
const removeEntity = (id) => {
const newEntities = filter(currentData.entities, (entity, entityId) => entityId !== id);
setTimelineData({ entities: newEntities, events: currentData.events });
};
const eventEndsFocused = (id) =>
timeline.getEvent(id).entityIds.every((entityId) => entitiesFocused.includes(entityId));
const createContextmenuMarkup = (menuType, id) => {
const listOptions = contextmenuOptions[menuType]
.map(
(entry) => `
<li class='story__contextmenu__option' id=${entry.id} oncontextmenu='return false;'>
${entry.text(id)}
</li>`
)
.join("");
return `
<ul class='story__contextmenu__options'>
${listOptions}
</ul>
`;
};
const displayContextmenu = ({ id, targetType, x, y }) => {
// Don't create a contextmenu when clicking on the pin or focus items.
if (["pin", "focus", "eventSummary"].includes(targetType)) {
hideContextmenu();
return;
}
const timelineBounds = timelineElement.getBoundingClientRect();
const targetTypeSelected = id && id.includes("_marker") ? "marker" : targetType || "background";
if (targetTypeSelected === "entity" || targetTypeSelected === "event") {
entitiesFocused = timeline.focus();
}
// Style and create the HTML menu
contextmenuElement.style.display = "block";
contextmenuElement.style.left = `${timelineBounds.left + x + 10}px`;
contextmenuElement.style.top = `${timelineBounds.top + y}px`;
contextmenuElement.innerHTML = createContextmenuMarkup(targetTypeSelected, id);
// Attach click handler to the menu list items
contextmenuOptions[targetTypeSelected].forEach((option) => {
document.getElementById(option.id).addEventListener("click", () => {
option.handler(id);
hideContextmenu();
});
});
};
const hideContextmenu = () => {
contextmenuElement.style.display = "none";
};
timeline.on("context-menu", displayContextmenu);
timeline.on("range", hideContextmenu);
timeline.on("click", hideContextmenu);
setTimelineData(data);
timeline.fit(); export default {
entities: {
"smith-johnathan-151": {
label: "John Smith",
},
"west-josephine-126": {
label: "Josephine West",
},
"roberts-nathaniel-023": {
label: "Nathan Roberts",
},
"baxter-eleanor-004": {
label: "Ella Baxter",
},
},
events: {
"Email 1": {
entityIds: ["smith-johnathan-151", "roberts-nathaniel-023"],
time: new Date(2025, 7, 14, 8, 49),
},
"Email 2": {
entityIds: ["smith-johnathan-151", "baxter-eleanor-004"],
time: new Date(2025, 7, 14, 8, 56),
},
"Email 3": {
entityIds: ["smith-johnathan-151", "west-josephine-126"],
time: new Date(2025, 7, 14, 9, 2),
},
"Email 4": {
entityIds: ["smith-johnathan-151", "west-josephine-126"],
time: new Date(2025, 7, 14, 9, 27),
},
"Email 5": {
entityIds: ["baxter-eleanor-004", "roberts-nathaniel-023"],
time: new Date(2025, 7, 14, 10, 20),
},
"Email 6": {
entityIds: ["roberts-nathaniel-023", "smith-johnathan-151"],
time: new Date(2025, 7, 14, 10, 30),
},
"Email 7": {
entityIds: ["west-josephine-126", "smith-johnathan-151"],
time: new Date(2025, 7, 14, 10, 40),
},
"Email 8": {
entityIds: ["smith-johnathan-151", "west-josephine-126"],
time: new Date(2025, 7, 14, 10, 51),
},
"Email 9": {
entityIds: ["west-josephine-126", "smith-johnathan-151"],
time: new Date(2025, 7, 14, 12, 5),
},
"Email 10": {
entityIds: ["smith-johnathan-151", "west-josephine-126"],
time: new Date(2025, 7, 14, 14, 37),
},
"Email 11": {
entityIds: ["baxter-eleanor-004", "west-josephine-126"],
time: new Date(2025, 7, 14, 16, 7),
},
"Email 12": {
entityIds: ["west-josephine-126", "roberts-nathaniel-023"],
time: new Date(2025, 7, 14, 17, 42),
},
},
}; <!doctype html>
<html>
<head>
<link rel="stylesheet" href="@ci/theme/kg/css/examples.css" />
<link rel="stylesheet" href="./style.css" />
</head>
<body>
<div class="story">
<div class="story__timeline" id="my-timeline"></div>
<div id="my-contextmenu" class="story__contextmenu"></div>
</div>
<script type="module" src="./code.js"></script>
</body>
</html> @import url("https://fonts.googleapis.com/css?family=Muli:200,400,500,700&display=swap");
body {
font-family: "Muli", "Helvetica Neue", Helvetica, sans-serif;
font-weight: 400;
margin: 0;
}
.story {
background-color: hsl(210, 18%, 22%); /* Charcoal */
color: hsl(214, 15%, 91%);
display: flex;
flex-direction: column;
height: 100vh;
}
.story__timeline {
flex: 2;
min-height: 0px; /* Allow the DOM to resize the timeline */
}
.story__contextmenu {
background-color: hsl(209, 15%, 28%);
box-shadow: 0 4px 5px 3px rgba(0, 0, 0, 0.2);
border-radius: 0.1975rem;
color: hsl(216, 33%, 97%);
display: none;
max-height: 300px;
max-width: 300px;
position: absolute;
}
.story__contextmenu__options {
list-style: none;
margin: none;
padding: 10px 0px;
margin-block-start: 0em;
margin-block-end: 0em;
}
.story__contextmenu__option {
color: hsl(214, 15%, 91%);
font-weight: 500;
font-size: 0.8em;
padding: 0px 10px;
cursor: pointer;
}
.story__contextmenu__option:hover {
background: hsl(25, 90%, 63%);
} import React, { useState, useRef } from "react";
import { createRoot } from "react-dom/client";
import Timeline from "kronograph/react/Timeline";
import data from "./data";
function filter(items, predicate) {
return Object.entries(items).reduce((result, [key, val]) => {
if (predicate(val, key, items)) {
result[key] = val;
}
return result;
}, {});
}
function find(items, predicate) {
const result = Object.entries(items).find(([itemId, item]) => predicate(item, itemId));
return result ? result[1] : undefined;
}
export const Demo = () => {
const storyContainer = useRef(null);
const timelineRef = useRef(null);
const [timelineData, setTimelineData] = useState(data);
const [options, setOptions] = useState({});
const [focus, setFocus] = useState([]);
const [markers, setMarkers] = useState([]);
const [pin, setPin] = useState([]);
const [contextmenu, setContextmenu] = useState(null);
const contextmenuOptions = {
background: [
{
id: "zoomToFit",
text: () => "Zoom to Fit",
handler: () => timelineRef.current.fit(),
},
{
id: "resetTimeline",
text: () => "Reset Timeline",
handler: () => {
setTimelineData({
entities: data.entities,
events: data.events,
});
setOptions({ scales: { dateOrder: "mdy" } });
setFocus([]);
setMarkers([]);
setPin([]);
timelineRef.current.fit();
},
},
],
entity: [
{
id: "entityRowPin",
text: (id) => (pin.includes(id) ? "Unpin Entity Row" : "Pin Entity Row"),
handler: (id) => {
setPin(pin.includes(id) ? pin.filter((entityId) => entityId !== id) : [...pin, id]);
},
},
{
id: "entityRowFocus",
text: (id) => (focus.includes(id) ? "Unfocus Entity Row" : "Focus Entity Row"),
handler: (id) =>
setFocus(
focus.includes(id) ? focus.filter((entityId) => entityId !== id) : [...focus, id]
),
},
{
id: "removeEntityRow",
text: () => "Remove Entity Row",
handler: (id) => removeEntity(id),
},
],
event: [
{
id: "markEvent",
text: () => "Mark Event",
handler: (id) => {
const newMarkers = [
...markers,
{
label: id,
time: find(timelineData.events, (event, eventId) => eventId === id).time,
},
];
const sortedMarkers = newMarkers.sort((markerA, markerB) => markerA.time - markerB.time);
setMarkers(sortedMarkers);
},
},
{
id: "focusEnds",
text: (id) => (eventEndsFocused(id) ? "Unfocus Ends" : "Focus Ends"),
handler: (id) => {
const eventEntityIds = find(
timelineData.events,
(event, eventId) => eventId === id
).entityIds;
setFocus(
eventEndsFocused(id)
? focus.filter((entityId) => !eventEntityIds.includes(entityId))
: [...focus, ...eventEntityIds]
);
},
},
],
marker: [
{
id: "unMarkEvent",
text: () => "Unmark Event",
handler: (id) => {
const markerId = id.split("_marker")[1];
const newMarkers = markers
.filter((_m, index) => index !== +markerId)
.sort((a, b) => a.time - b.time);
setMarkers(newMarkers);
},
},
],
scale: [
{
id: "dmyFormat",
text: () => "Use 'dmy' Format",
handler: () => {
setOptions({
scales: {
dateOrder: "dmy",
},
});
},
},
{
id: "mdyFormat",
text: () => "Use 'mdy' Format",
handler: () =>
setOptions({
scales: {
dateOrder: "mdy",
},
}),
},
],
};
const eventEndsFocused = (id) => {
const ev = find(data.events, (event, eventId) => eventId === id);
return ev.entityIds.every((entityId) => focus.includes(entityId));
};
const removeEntity = (id) => {
const newEntities = filter(timelineData.entities, (entity, entityId) => entityId !== id);
setTimelineData({ entities: newEntities, events: timelineData.events });
};
const createMenuMarkup = (menuType, id) => (
<ul className="story__contextmenu__options">
{contextmenuOptions[menuType].map((entry) => (
<li
key={entry.id}
className="story__contextmenu__option"
onContextMenu={() => false}
onClick={() => {
entry.handler(id);
setContextmenu(null);
}}
>
{entry.text(id)}
</li>
))}
</ul>
);
const contextmenuHandler = (event) => {
// Don't create a contextmenu when clicking on the pin or focus items.
if (["pin", "focus", "eventSummary"].includes(event.targetType)) {
setContextmenu(null);
return;
}
const bounds = storyContainer.current.getBoundingClientRect();
const targetTypeSelected =
event.id && event.id.includes("_marker") ? "marker" : event.targetType || "background";
setContextmenu({
left: `${bounds.left + event.x + 10}px`,
top: `${bounds.top + event.y}px`,
menuOptions: createMenuMarkup(targetTypeSelected, event.id),
});
};
const onChangeHandler = (change) => {
if (change.range) {
setContextmenu(null);
}
if (change.focus) {
setFocus(change.focus);
}
if (change.pin) {
setPin(change.pin);
}
};
const onClickHandler = () => {
setContextmenu(null);
};
return (
<div className="story" ref={storyContainer}>
<Timeline
entities={timelineData.entities}
events={timelineData.events}
options={options}
focus={focus}
markers={markers}
pin={pin}
onTimelineContextMenu={contextmenuHandler}
onTimelineChange={onChangeHandler}
onTimelineClick={onClickHandler}
ref={timelineRef}
/>
{contextmenu (
<div
id="my-contextmenu"
className="story__contextmenu"
style={{
display: "block",
left: contextmenu.left,
top: contextmenu.top,
}}
>
<div>{contextmenu.menuOptions}</div>
</div>
) : null}
</div>
);
};
const root = createRoot(document.getElementById("my-timeline"));
root.render(<Demo />); export default {
entities: {
"smith-johnathan-151": {
label: "John Smith",
},
"west-josephine-126": {
label: "Josephine West",
},
"roberts-nathaniel-023": {
label: "Nathan Roberts",
},
"baxter-eleanor-004": {
label: "Ella Baxter",
},
},
events: {
"Email 1": {
entityIds: ["smith-johnathan-151", "roberts-nathaniel-023"],
time: new Date(2025, 7, 14, 8, 49),
},
"Email 2": {
entityIds: ["smith-johnathan-151", "baxter-eleanor-004"],
time: new Date(2025, 7, 14, 8, 56),
},
"Email 3": {
entityIds: ["smith-johnathan-151", "west-josephine-126"],
time: new Date(2025, 7, 14, 9, 2),
},
"Email 4": {
entityIds: ["smith-johnathan-151", "west-josephine-126"],
time: new Date(2025, 7, 14, 9, 27),
},
"Email 5": {
entityIds: ["baxter-eleanor-004", "roberts-nathaniel-023"],
time: new Date(2025, 7, 14, 10, 20),
},
"Email 6": {
entityIds: ["roberts-nathaniel-023", "smith-johnathan-151"],
time: new Date(2025, 7, 14, 10, 30),
},
"Email 7": {
entityIds: ["west-josephine-126", "smith-johnathan-151"],
time: new Date(2025, 7, 14, 10, 40),
},
"Email 8": {
entityIds: ["smith-johnathan-151", "west-josephine-126"],
time: new Date(2025, 7, 14, 10, 51),
},
"Email 9": {
entityIds: ["west-josephine-126", "smith-johnathan-151"],
time: new Date(2025, 7, 14, 12, 5),
},
"Email 10": {
entityIds: ["smith-johnathan-151", "west-josephine-126"],
time: new Date(2025, 7, 14, 14, 37),
},
"Email 11": {
entityIds: ["baxter-eleanor-004", "west-josephine-126"],
time: new Date(2025, 7, 14, 16, 7),
},
"Email 12": {
entityIds: ["west-josephine-126", "roberts-nathaniel-023"],
time: new Date(2025, 7, 14, 17, 42),
},
},
}; <!doctype html>
<html>
<head>
<link rel="stylesheet" href="@ci/theme/kg/css/examples.css" />
<link rel="stylesheet" href="./style.css" />
</head>
<body>
<div id="my-timeline" style="height: 100vh"></div>
<script type="module" src="./code.jsx"></script>
</body>
</html> @import url("https://fonts.googleapis.com/css?family=Muli:200,400,500,700&display=swap");
body {
font-family: "Muli", "Helvetica Neue", Helvetica, sans-serif;
font-weight: 400;
margin: 0;
}
.story {
background-color: hsl(210, 18%, 22%); /* Charcoal */
color: hsl(214, 15%, 91%);
display: flex;
flex-direction: column;
height: 100vh;
}
.story__timeline {
flex: 2;
min-height: 0px; /* Allow the DOM to resize the timeline */
}
.story__contextmenu {
background-color: hsl(209, 15%, 28%);
box-shadow: 0 4px 5px 3px rgba(0, 0, 0, 0.2);
border-radius: 0.1975rem;
color: hsl(216, 33%, 97%);
display: none;
max-height: 300px;
max-width: 300px;
position: absolute;
}
.story__contextmenu__options {
list-style: none;
margin: none;
padding: 10px 0px;
margin-block-start: 0em;
margin-block-end: 0em;
}
.story__contextmenu__option {
color: hsl(214, 15%, 91%);
font-weight: 500;
font-size: 0.8em;
padding: 0px 10px;
cursor: pointer;
}
.story__contextmenu__option:hover {
background: hsl(25, 90%, 63%);
}