react-bracket-ui
Version:
A modern, feature-rich React component library for displaying single-elimination tournament brackets with drag-drop, zoom/pan, and error validation
1,017 lines (1,012 loc) • 35.3 kB
JavaScript
// src/components/Bracket.tsx
import React3, { useState as useState3, useMemo, useCallback, useRef as useRef2, useEffect as useEffect2 } from "react";
import { TransformWrapper, TransformComponent } from "react-zoom-pan-pinch";
// src/utils/bracketValidation.ts
function validateBracket(matches) {
const errors = [];
if (!matches || matches.length === 0) {
return errors;
}
const matchIds = /* @__PURE__ */ new Set();
matches.forEach((match) => {
if (matchIds.has(match.id)) {
errors.push({
matchId: match.id,
message: "Duplicate match ID detected",
type: "error"
});
}
matchIds.add(match.id);
});
matches.forEach((match) => {
if (match.nextMatchId && !matchIds.has(match.nextMatchId)) {
errors.push({
matchId: match.id,
message: `Invalid nextMatchId: ${match.nextMatchId}`,
type: "error"
});
}
});
matches.forEach((match) => {
if (match.participant1 && !match.participant1.id) {
errors.push({
matchId: match.id,
message: "Participant 1 missing ID",
type: "warning"
});
}
if (match.participant2 && !match.participant2.id) {
errors.push({
matchId: match.id,
message: "Participant 2 missing ID",
type: "warning"
});
}
});
matches.forEach((match) => {
if (match.winner) {
const participant1Id = match.participant1?.id;
const participant2Id = match.participant2?.id;
if (match.winner !== participant1Id && match.winner !== participant2Id) {
errors.push({
matchId: match.id,
message: "Winner ID does not match any participant",
type: "error"
});
}
}
});
return errors;
}
function hasMatchError(matchId, errors) {
return errors.some((error) => error.matchId === matchId);
}
function getMatchErrorMessage(matchId, errors) {
const matchErrors = errors.filter((error) => error.matchId === matchId);
return matchErrors.map((e) => e.message).join(", ");
}
// src/components/MatchComponent.tsx
import { memo, useState } from "react";
import { jsx, jsxs } from "react/jsx-runtime";
var MatchComponent = ({
match,
hasError,
errorMessage,
enableDragDrop,
onDragStart,
onDragEnd,
onDragOver,
onDrop,
colors,
matchWidth,
matchHeight,
forwardedRef,
onScheduleChange,
schedule
}) => {
const isParticipant1Winner = match.winner === match.participant1?.id;
const isParticipant2Winner = match.winner === match.participant2?.id;
const matchStyles = {
border: hasError ? `2px solid ${colors.error}` : `1px solid #ccc`,
borderRadius: "6px",
padding: "8px",
backgroundColor: hasError ? "#ffebee" : "white",
display: "flex",
flexDirection: "column",
minWidth: `${matchWidth}px`,
minHeight: `${matchHeight}px`,
height: `${matchHeight}px`,
boxShadow: hasError ? `0 0 0 3px ${colors.error}33, 0 0 12px ${colors.error}` : "0 1px 3px rgba(0,0,0,0.1)",
transition: "all 0.2s ease",
position: "relative",
overflow: "visible"
// Đảm bảo tooltip không bị cắt
};
const participantStyles = (participant, isWinner, slot) => ({
padding: "8px 12px",
borderBottom: slot === "participant1" ? "1px solid #eee" : "none",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
fontWeight: isWinner ? "bold" : "normal",
backgroundColor: isWinner ? colors.winner : "transparent",
cursor: enableDragDrop && participant ? "grab" : "default",
transition: "background-color 0.2s ease",
borderRadius: "4px",
minHeight: "32px"
});
const renderParticipant = (participant, isWinner, slot) => {
if (!participant) {
const style = participantStyles(null, false, slot);
console.log(`[MatchComponent] Placeholder for match ${match.id} slot ${slot}:`, style);
return /* @__PURE__ */ jsx(
"div",
{
className: enableDragDrop ? "draggable-participant" : void 0,
style: {
...style,
fontStyle: "italic",
color: "#999",
cursor: enableDragDrop ? "pointer" : "default",
backgroundColor: enableDragDrop ? "#f5f5f5" : "transparent"
},
onDragOver: enableDragDrop ? onDragOver : void 0,
onDrop: enableDragDrop ? (e) => {
e.preventDefault();
onDrop(match.id, slot);
} : void 0,
children: "TBD"
}
);
}
return /* @__PURE__ */ jsxs(
"div",
{
className: enableDragDrop ? "draggable-participant" : void 0,
draggable: enableDragDrop,
onDragStart: enableDragDrop ? (e) => {
e.dataTransfer.effectAllowed = "move";
onDragStart(match.id, participant.id, slot);
} : void 0,
onDragEnd: enableDragDrop && onDragEnd ? onDragEnd : void 0,
onDragOver: enableDragDrop ? onDragOver : void 0,
onDrop: enableDragDrop ? (e) => {
e.preventDefault();
onDrop(match.id, slot);
} : void 0,
style: participantStyles(participant, isWinner, slot),
onMouseEnter: (e) => {
if (enableDragDrop && participant) {
e.currentTarget.style.backgroundColor = isWinner ? colors.winner : "#f0f0f0";
}
},
onMouseLeave: (e) => {
if (enableDragDrop && participant) {
e.currentTarget.style.backgroundColor = isWinner ? colors.winner : "transparent";
}
},
children: [
/* @__PURE__ */ jsx("span", { style: { flex: 1 }, children: participant.name }),
typeof participant.score === "number" && /* @__PURE__ */ jsx("span", { style: {
fontWeight: "bold",
marginLeft: "8px",
fontSize: "14px",
color: colors.primary
}, children: participant.score })
]
}
);
};
console.log(`[MatchComponent] Render match ${match.id} style:`, matchStyles, {
participant1: match.participant1,
participant2: match.participant2
});
let formattedSchedule = "";
if (schedule) {
let dateObj = null;
if (typeof schedule === "string") {
const parsed = Date.parse(schedule);
if (!isNaN(parsed)) dateObj = new Date(parsed);
} else if (schedule instanceof Date) {
dateObj = schedule;
}
if (dateObj) {
const pad = (n) => n < 10 ? "0" + n : n;
const h = pad(dateObj.getHours());
const m = pad(dateObj.getMinutes());
const d = pad(dateObj.getDate());
const mo = pad(dateObj.getMonth() + 1);
const y = dateObj.getFullYear();
formattedSchedule = `${h}:${m}, ${d}/${mo}/${y}`;
} else if (typeof schedule === "string") {
formattedSchedule = schedule;
}
}
const [hovered, setHovered] = useState(false);
return /* @__PURE__ */ jsxs(
"div",
{
className: (enableDragDrop ? "match-container " : "") + "relative",
style: { ...matchStyles, zIndex: hasError && hovered ? 1050 : void 0 },
ref: forwardedRef,
onMouseEnter: () => setHovered(true),
onMouseLeave: () => setHovered(false),
children: [
/* @__PURE__ */ jsxs("div", { style: {
fontSize: "11px",
color: hasError ? colors.error : "#666",
fontWeight: hasError ? "bold" : "normal",
display: "flex",
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
position: "relative",
gap: 8
}, children: [
/* @__PURE__ */ jsxs("span", { style: { whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }, children: [
"Match ",
match.matchNumber || match.id
] }),
/* @__PURE__ */ jsx("div", { style: { display: "flex", alignItems: "center", gap: 4, flexShrink: 0 }, children: /* @__PURE__ */ jsx(
"input",
{
type: "datetime-local",
value: (() => {
if (!schedule) return "";
let d = null;
if (typeof schedule === "string") {
const parsed = Date.parse(schedule);
if (!isNaN(parsed)) d = new Date(parsed);
} else if (schedule instanceof Date) {
d = schedule;
}
if (!d) return "";
const pad = (n) => n < 10 ? "0" + n : n;
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
})(),
onChange: (e) => {
if (typeof match.id !== "undefined" && typeof e.target.value === "string" && e.target.value && typeof onScheduleChange === "function") {
onScheduleChange(match.id, e.target.value);
}
},
style: {
fontSize: 12,
color: "#888",
fontWeight: 400,
border: "1px solid #ccc",
borderRadius: 4,
padding: "2px 4px",
background: "#fafbfc",
minWidth: 120,
maxWidth: 150
},
title: "Ch\u1EC9nh s\u1EEDa l\u1ECBch thi \u0111\u1EA5u"
}
) })
] }),
renderParticipant(match.participant1, isParticipant1Winner, "participant1"),
renderParticipant(match.participant2, isParticipant2Winner, "participant2"),
hasError && hovered && errorMessage && /* @__PURE__ */ jsx(
"div",
{
className: "absolute left-1/2 -translate-x-1/2 top-full mt-2 z-[1051] bg-red-600 text-white px-3 py-2 rounded shadow-lg text-sm",
style: {
pointerEvents: "none",
boxShadow: "0 0 0 2px #d32f2f, 0 4px 16px #d32f2faa",
background: "#d32f2f",
minWidth: 0,
maxWidth: matchWidth - 16,
wordBreak: "break-word",
whiteSpace: "pre-line",
textAlign: "left"
},
children: errorMessage
}
)
]
}
);
};
var MatchComponent_default = memo(MatchComponent);
// src/components/BracketRound.tsx
import { useRef, useEffect } from "react";
import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
var BracketRound = ({
round,
matches,
totalRounds,
renderMatch,
matchHeight,
gap,
roundName,
showRoundNames,
primaryColor,
matchWidth,
onMatchPositionsChange,
zoomScale = 1
}) => {
const matchRefs = useRef(/* @__PURE__ */ new Map());
const containerRef = useRef(null);
useEffect(() => {
if (!onMatchPositionsChange || !containerRef.current) return;
const positions = [];
const containerTop = containerRef.current.getBoundingClientRect().top;
matches.forEach((match) => {
const element = matchRefs.current.get(match.id);
if (element) {
const rect = element.getBoundingClientRect();
console.log(`[BracketRound] round ${round} match ${match.id} measured top:`, (rect.top - containerTop) / zoomScale, "height:", rect.height / zoomScale);
positions.push({
matchId: match.id,
top: (rect.top - containerTop) / zoomScale,
height: rect.height / zoomScale
});
}
});
onMatchPositionsChange(positions);
}, [matches, onMatchPositionsChange, zoomScale]);
const matchSpacing = round === 1 ? gap : (Math.pow(2, round - 1) - 1) * matchHeight + Math.pow(2, round - 1) * gap;
console.log(`[BracketRound] round ${round} matchSpacing:`, matchSpacing, "fixed matchHeight:", matchHeight, "gap:", gap);
const topPadding = round === 1 ? 0 : (matchHeight + gap) * (Math.pow(2, round - 1) - 1) / 2;
console.log(`[BracketRound] round ${round} topPadding:`, topPadding);
return /* @__PURE__ */ jsxs2("div", { style: {
display: "flex",
flexDirection: "column",
minWidth: `${matchWidth + 20}px`
}, children: [
showRoundNames && roundName && /* @__PURE__ */ jsx2("h4", { style: {
margin: "0 0 12px 0",
color: primaryColor,
fontSize: "14px",
fontWeight: "600",
textAlign: "center",
height: "30px"
}, children: roundName }),
/* @__PURE__ */ jsx2(
"div",
{
ref: containerRef,
style: {
display: "flex",
flexDirection: "column",
paddingTop: `${topPadding}px`
},
children: matches.map((match, index) => /* @__PURE__ */ jsx2(
"div",
{
ref: (el) => {
if (el) {
matchRefs.current.set(match.id, el);
} else {
matchRefs.current.delete(match.id);
}
},
style: {
marginBottom: index < matches.length - 1 ? `${matchSpacing}px` : "0",
visibility: match.isVirtual ? "hidden" : "visible"
},
children: renderMatch(match, index)
},
match.id
))
}
)
] });
};
var BracketRound_default = BracketRound;
// src/components/BracketConnectors.tsx
import { jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
var BracketConnectors = ({
leftRoundMatches,
rightRoundMatches,
leftRound,
rightRound,
matchHeight,
gap,
color,
showRoundNames,
matchPositions,
zoomScale = 1
}) => {
const connectorWidth = 50;
const getActualMatchHeight = (matches) => {
if (matches.length === 0) return matchHeight;
let totalHeight2 = 0;
let count = 0;
matches.forEach((match) => {
const pos = matchPositions.get(match.id);
if (pos && pos.height > 0) {
totalHeight2 += pos.height;
count++;
console.log(`[BracketConnectors] getActualMatchHeight match ${match.id} height:`, pos.height);
}
});
return count > 0 ? totalHeight2 / count : matchHeight;
};
const actualLeftMatchHeight = getActualMatchHeight(leftRoundMatches);
const actualRightMatchHeight = getActualMatchHeight(rightRoundMatches);
const leftMatchSpacing = leftRound === 1 ? gap : (Math.pow(2, leftRound - 1) - 1) * actualLeftMatchHeight + Math.pow(2, leftRound - 1) * gap;
console.log(`[BracketConnectors] leftRound ${leftRound} leftMatchSpacing:`, leftMatchSpacing, "actualLeftMatchHeight:", actualLeftMatchHeight, "gap:", gap);
const leftTopPadding = leftRound === 1 ? 0 : (actualLeftMatchHeight + gap) * (Math.pow(2, leftRound - 1) - 1) / 2;
console.log(`[BracketConnectors] leftRound ${leftRound} leftTopPadding:`, leftTopPadding);
const rightMatchSpacing = rightRound === 1 ? gap : (Math.pow(2, rightRound - 1) - 1) * actualRightMatchHeight + Math.pow(2, rightRound - 1) * gap;
console.log(`[BracketConnectors] rightRound ${rightRound} rightMatchSpacing:`, rightMatchSpacing, "actualRightMatchHeight:", actualRightMatchHeight, "gap:", gap);
const rightTopPadding = rightRound === 1 ? 0 : (actualRightMatchHeight + gap) * (Math.pow(2, rightRound - 1) - 1) / 2;
console.log(`[BracketConnectors] rightRound ${rightRound} rightTopPadding:`, rightTopPadding);
const leftTotalHeight = leftTopPadding + leftRoundMatches.length * actualLeftMatchHeight + (leftRoundMatches.length - 1) * leftMatchSpacing;
const rightTotalHeight = rightTopPadding + rightRoundMatches.length * actualRightMatchHeight + (rightRoundMatches.length - 1) * rightMatchSpacing;
const totalHeight = Math.max(leftTotalHeight, rightTotalHeight);
const connectors = [];
rightRoundMatches.forEach((rightMatch, rightIndex) => {
let leftMatches = leftRoundMatches.filter(
(m) => m.nextMatchId === rightMatch.id
);
if (leftRound === 1) {
leftMatches = leftMatches.filter((m) => !m.isVirtual);
}
if (leftMatches.length === 0) return;
const rightPos = matchPositions.get(rightMatch.id);
if (!rightPos) return;
const rightY = rightPos.top + rightPos.height / 2;
if (leftMatches.length === 1) {
const leftMatch = leftMatches[0];
const leftPos = matchPositions.get(leftMatch.id);
if (!leftPos) return;
const leftY = leftPos.top + leftPos.height / 2;
const meetX = connectorWidth / 2;
connectors.push(
/* @__PURE__ */ jsx3(
"line",
{
x1: "0",
y1: leftY,
x2: meetX,
y2: leftY,
stroke: color,
strokeWidth: "2"
},
`connector-h-left-${leftMatch.id}`
)
);
connectors.push(
/* @__PURE__ */ jsx3(
"line",
{
x1: meetX,
y1: leftY,
x2: meetX,
y2: rightY,
stroke: color,
strokeWidth: "2"
},
`connector-v-${leftMatch.id}-${rightMatch.id}`
)
);
connectors.push(
/* @__PURE__ */ jsx3(
"line",
{
x1: meetX,
y1: rightY,
x2: connectorWidth,
y2: rightY,
stroke: color,
strokeWidth: "2"
},
`connector-h-right-${rightMatch.id}`
)
);
} else {
const leftYPositions = [];
leftMatches.forEach((leftMatch) => {
const leftPos = matchPositions.get(leftMatch.id);
if (!leftPos) return;
const leftY = leftPos.top + leftPos.height / 2;
leftYPositions.push(leftY);
});
if (leftYPositions.length === 0) return;
const meetX = connectorWidth / 2;
const minLeftY = Math.min(...leftYPositions);
const maxLeftY = Math.max(...leftYPositions);
leftMatches.forEach((leftMatch) => {
const leftPos = matchPositions.get(leftMatch.id);
if (!leftPos) return;
const leftY = leftPos.top + leftPos.height / 2;
connectors.push(
/* @__PURE__ */ jsx3(
"line",
{
x1: "0",
y1: leftY,
x2: meetX,
y2: leftY,
stroke: color,
strokeWidth: "2"
},
`connector-h-${leftMatch.id}`
)
);
});
connectors.push(
/* @__PURE__ */ jsx3(
"line",
{
x1: meetX,
y1: minLeftY,
x2: meetX,
y2: maxLeftY,
stroke: color,
strokeWidth: "2"
},
`connector-v-${rightMatch.id}`
)
);
connectors.push(
/* @__PURE__ */ jsx3(
"line",
{
x1: meetX,
y1: rightY,
x2: connectorWidth,
y2: rightY,
stroke: color,
strokeWidth: "2"
},
`connector-r-${rightMatch.id}`
)
);
}
});
return /* @__PURE__ */ jsxs3("div", { style: {
width: `${connectorWidth}px`,
display: "flex",
flexDirection: "column",
position: "relative"
}, children: [
showRoundNames && /* @__PURE__ */ jsx3("div", { style: { height: "42px" } }),
/* @__PURE__ */ jsx3(
"svg",
{
width: connectorWidth,
height: totalHeight,
style: {
overflow: "visible"
},
children: connectors
}
)
] });
};
var BracketConnectors_default = BracketConnectors;
// src/components/Bracket.tsx
import { Fragment, jsx as jsx4, jsxs as jsxs4 } from "react/jsx-runtime";
var DEFAULT_COLORS = {
primary: "#1976d2",
secondary: "#424242",
background: "#f9f9f9",
error: "#d32f2f",
warning: "#ff9800",
winner: "#e8f5e9"
};
function Bracket({
matches,
className,
style,
enableDragDrop = false,
onBracketChange,
enableZoomPan = false,
minZoom = 0.5,
maxZoom = 3,
initialZoom = 1,
errors: externalErrors,
onErrorClick,
showRoundNames = true,
roundNames,
matchWidth = 220,
matchHeight = 100,
gap = 20,
colors: customColors,
onScheduleChange,
schedules = {}
}) {
const [internalMatches, setInternalMatches] = useState3(matches);
const [isDragging, setIsDragging] = useState3(false);
const [matchPositions, setMatchPositions] = useState3(/* @__PURE__ */ new Map());
const [currentScale, setCurrentScale] = useState3(initialZoom);
const dragDataRef = useRef2(null);
const [fixedMatchHeight, setFixedMatchHeight] = useState3(matchHeight);
const round1MatchRef = useRef2(null);
const bracketContentRef = useRef2(null);
const [viewportSize, setViewportSize] = useState3({ width: 0, height: 0 });
const [contentSize, setContentSize] = useState3({ width: 0, height: 0 });
const [limitPan, setLimitPan] = useState3(true);
const colors = useMemo(() => ({
...DEFAULT_COLORS,
...customColors
}), [customColors]);
const allErrors = useMemo(() => {
const validationErrors = validateBracket(internalMatches);
return externalErrors ? [...validationErrors, ...externalErrors] : validationErrors;
}, [internalMatches, externalErrors]);
React3.useEffect(() => {
setInternalMatches(matches);
}, [matches]);
const groupedMatches = useMemo(() => {
return internalMatches.reduce((acc, match) => {
if (!acc[match.round]) {
acc[match.round] = [];
}
acc[match.round].push(match);
return acc;
}, {});
}, [internalMatches]);
const rounds = useMemo(
() => Object.keys(groupedMatches).map(Number).sort((a, b) => a - b),
[groupedMatches]
);
const handleDragStart = useCallback((matchId, participantId, slot) => {
setIsDragging(true);
dragDataRef.current = {
fromMatchId: matchId,
participantId,
participantSlot: slot
};
}, []);
const handleDragEnd = useCallback(() => {
setIsDragging(false);
dragDataRef.current = null;
}, []);
const handleDragOver = useCallback((e) => {
e.preventDefault();
e.dataTransfer.dropEffect = "move";
}, []);
const handleDrop = useCallback((toMatchId, toSlot) => {
if (!dragDataRef.current || !enableDragDrop) return;
const { fromMatchId, participantId, participantSlot } = dragDataRef.current;
if (fromMatchId === toMatchId && participantSlot === toSlot) {
dragDataRef.current = null;
setIsDragging(false);
return;
}
const sourceMatch = internalMatches.find((m) => m.id === fromMatchId);
const targetMatch = internalMatches.find((m) => m.id === toMatchId);
if (!sourceMatch || !targetMatch) {
dragDataRef.current = null;
setIsDragging(false);
return;
}
const sourceParticipant = sourceMatch[participantSlot];
const targetSlot = toSlot;
const targetParticipant = targetMatch[targetSlot];
const updatedMatches = internalMatches.map((match) => {
if (match.id === fromMatchId) {
return {
...match,
[participantSlot]: targetParticipant || null
};
} else if (match.id === toMatchId) {
return {
...match,
[targetSlot]: sourceParticipant || null
};
} else {
return match;
}
});
setInternalMatches(updatedMatches);
if (onBracketChange) {
const dragDropResult = {
fromMatchId,
toMatchId,
participantId,
participantSlot: toSlot
};
const event = {
type: "drag-drop",
matches: updatedMatches,
dragDropResult
};
onBracketChange(event);
}
dragDataRef.current = null;
setIsDragging(false);
}, [internalMatches, enableDragDrop, onBracketChange]);
const renderMatch = useCallback((match, round, matchIndex) => {
const hasError = hasMatchError(match.id, allErrors);
const errorMessage = getMatchErrorMessage(match.id, allErrors);
const ref = round === 1 && matchIndex === 0 ? round1MatchRef : void 0;
const schedule = schedules && match.id in schedules ? schedules[match.id] : void 0;
return /* @__PURE__ */ jsx4(
MatchComponent_default,
{
match,
hasError,
errorMessage,
enableDragDrop,
onDragStart: handleDragStart,
onDragEnd: handleDragEnd,
onDragOver: handleDragOver,
onDrop: handleDrop,
colors,
matchWidth,
matchHeight: fixedMatchHeight,
forwardedRef: ref,
onScheduleChange,
schedule
},
match.id
);
}, [allErrors, enableDragDrop, handleDragStart, handleDragEnd, handleDragOver, handleDrop, colors, matchWidth, fixedMatchHeight, schedules]);
useEffect2(() => {
if (round1MatchRef.current && (!enableZoomPan || currentScale === 1)) {
const height = round1MatchRef.current.getBoundingClientRect().height;
if (height > 0 && Math.abs(height - fixedMatchHeight) > 1) {
setFixedMatchHeight(height);
console.log("[Bracket] Measured fixedMatchHeight from round 1 (scale=1):", height);
}
}
}, [internalMatches, enableZoomPan, currentScale]);
const getRoundName = useCallback((round) => {
if (roundNames && roundNames[round]) {
return roundNames[round];
}
const totalRounds = rounds.length;
const roundsFromEnd = totalRounds - round + 1;
if (roundsFromEnd === 1) return "Final";
if (roundsFromEnd === 2) return "Semi-Final";
if (roundsFromEnd === 3) return "Quarter-Final";
return `Round ${round}`;
}, [rounds, roundNames]);
const handleMatchPositionsChange = useCallback((positions) => {
setMatchPositions((prev) => {
const newMap = new Map(prev);
positions.forEach(({ matchId, top, height }) => {
newMap.set(matchId, { top, height });
});
return newMap;
});
}, []);
const defaultStyles = {
border: "1px solid #ddd",
borderRadius: "8px",
padding: "16px",
fontFamily: "Arial, sans-serif",
backgroundColor: colors.background,
height: "600px",
overflow: "hidden"
};
useEffect2(() => {
if (!enableZoomPan) return;
const handleResize = () => {
if (bracketContentRef.current) {
const contentRect = bracketContentRef.current.getBoundingClientRect();
setContentSize({ width: contentRect.width, height: contentRect.height });
}
const parent = bracketContentRef.current?.parentElement;
if (parent) {
const viewportRect = parent.getBoundingClientRect();
setViewportSize({ width: viewportRect.width, height: viewportRect.height });
}
};
handleResize();
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}, [enableZoomPan, fixedMatchHeight, internalMatches]);
useEffect2(() => {
if (!enableZoomPan) return;
if (contentSize.width > viewportSize.width || contentSize.height > viewportSize.height) {
setLimitPan(false);
} else {
setLimitPan(true);
}
}, [contentSize, viewportSize, enableZoomPan]);
const bracketContent = /* @__PURE__ */ jsxs4("div", { ref: enableZoomPan ? bracketContentRef : void 0, style: { padding: enableZoomPan ? "20px" : "0", position: "relative" }, children: [
/* @__PURE__ */ jsx4("h3", { style: { margin: "0 0 16px 0", color: colors.secondary }, children: "Tournament Bracket" }),
rounds.length === 0 ? /* @__PURE__ */ jsx4("div", { style: {
textAlign: "center",
color: "#999",
fontStyle: "italic",
padding: "40px 0"
}, children: "No matches available" }) : /* @__PURE__ */ jsx4("div", { style: {
display: "flex",
alignItems: "flex-start",
gap: "0px",
minWidth: "min-content"
}, children: rounds.map((round, index) => /* @__PURE__ */ jsxs4(React3.Fragment, { children: [
/* @__PURE__ */ jsx4(
BracketRound_default,
{
round,
matches: groupedMatches[round],
totalRounds: rounds.length,
renderMatch: (match, matchIdx) => renderMatch(match, round, matchIdx),
matchHeight: fixedMatchHeight,
matchWidth,
gap,
roundName: getRoundName(round),
showRoundNames,
primaryColor: colors.primary,
onMatchPositionsChange: handleMatchPositionsChange,
zoomScale: enableZoomPan ? currentScale : 1
}
),
index < rounds.length - 1 && /* @__PURE__ */ jsx4(
BracketConnectors_default,
{
leftRoundMatches: groupedMatches[round],
rightRoundMatches: groupedMatches[rounds[index + 1]],
leftRound: round,
rightRound: rounds[index + 1],
matchHeight: fixedMatchHeight,
gap,
color: colors.secondary,
showRoundNames,
matchPositions,
zoomScale: enableZoomPan ? currentScale : 1
}
)
] }, round)) })
] });
return /* @__PURE__ */ jsxs4(
"div",
{
className,
style: { ...defaultStyles, ...style, position: "relative", overflow: "hidden" },
children: [
enableZoomPan && /* @__PURE__ */ jsx4("div", { style: {
position: "absolute",
bottom: "24px",
right: "24px",
zIndex: 100,
display: "flex",
gap: "8px",
backgroundColor: "white",
padding: "8px",
borderRadius: "6px",
boxShadow: "0 2px 8px rgba(0,0,0,0.15)"
} }),
enableZoomPan ? /* @__PURE__ */ jsx4(
TransformWrapper,
{
initialScale: initialZoom,
minScale: 0.25,
maxScale: 2,
wheel: { disabled: true },
doubleClick: { disabled: true },
panning: {
disabled: isDragging,
excluded: enableDragDrop ? ["draggable-participant", "match-container"] : [],
velocityDisabled: false
},
limitToBounds: false,
centerOnInit: true,
onTransformed: (ref, state) => {
setCurrentScale(state.scale);
},
children: ({ zoomIn, zoomOut, resetTransform, setTransform }) => {
const zoomToLevel = (level) => {
setTransform(0, 0, level, 300, "easeOut");
};
const zoomLevels = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2];
const currentLevelIndex = zoomLevels.findIndex((level) => Math.abs(level - currentScale) < 0.01);
const canZoomIn = currentLevelIndex < zoomLevels.length - 1;
const canZoomOut = currentLevelIndex > 0;
const handleZoomIn = () => {
if (canZoomIn) {
zoomToLevel(zoomLevels[currentLevelIndex + 1]);
}
};
const handleZoomOut = () => {
if (canZoomOut) {
zoomToLevel(zoomLevels[currentLevelIndex - 1]);
}
};
return /* @__PURE__ */ jsxs4(Fragment, { children: [
/* @__PURE__ */ jsxs4("div", { style: {
position: "absolute",
bottom: "24px",
right: "24px",
zIndex: 100,
display: "flex",
gap: "8px",
backgroundColor: "white",
padding: "8px",
borderRadius: "6px",
boxShadow: "0 2px 8px rgba(0,0,0,0.15)"
}, children: [
/* @__PURE__ */ jsx4(
"button",
{
onClick: handleZoomOut,
disabled: !canZoomOut,
style: {
padding: "6px 12px",
border: "1px solid #ddd",
borderRadius: "4px",
backgroundColor: "white",
cursor: canZoomOut ? "pointer" : "not-allowed",
fontSize: "16px",
opacity: canZoomOut ? 1 : 0.5
},
title: "Zoom Out",
children: "\u2212"
}
),
/* @__PURE__ */ jsxs4("span", { style: {
padding: "6px 12px",
fontSize: "14px",
fontWeight: "bold",
display: "flex",
alignItems: "center",
minWidth: "60px",
justifyContent: "center"
}, children: [
Math.round(currentScale * 100),
"%"
] }),
/* @__PURE__ */ jsx4(
"button",
{
onClick: handleZoomIn,
disabled: !canZoomIn,
style: {
padding: "6px 12px",
border: "1px solid #ddd",
borderRadius: "4px",
backgroundColor: "white",
cursor: canZoomIn ? "pointer" : "not-allowed",
fontSize: "16px",
opacity: canZoomIn ? 1 : 0.5
},
title: "Zoom In",
children: "+"
}
),
/* @__PURE__ */ jsx4(
"button",
{
onClick: () => zoomToLevel(1),
style: {
padding: "6px 12px",
border: "1px solid #ddd",
borderRadius: "4px",
backgroundColor: "white",
cursor: "pointer",
fontSize: "12px"
},
title: "Reset to 100%",
children: "\u21BA"
}
),
/* @__PURE__ */ jsx4(
"button",
{
onClick: () => setTransform(0, 0, currentScale, 300, "easeOut"),
style: {
padding: "6px 12px",
border: "1px solid #1976d2",
borderRadius: "4px",
backgroundColor: "#1976d2",
color: "white",
cursor: "pointer",
fontSize: "12px",
fontWeight: 600
},
title: "Center Bracket",
children: "Center"
}
)
] }),
/* @__PURE__ */ jsx4(
TransformComponent,
{
wrapperStyle: {
width: "100%",
height: "100%"
},
contentStyle: {
width: "100%",
height: "100%"
},
children: bracketContent
}
)
] });
}
}
) : /* @__PURE__ */ jsx4("div", { style: {
width: "100%",
height: "100%",
overflow: "auto"
}, children: bracketContent })
]
}
);
}
export {
Bracket,
getMatchErrorMessage,
hasMatchError,
validateBracket
};
//# sourceMappingURL=index.mjs.map