diff --git a/packages/react-notion-x/src/styles.css b/packages/react-notion-x/src/styles.css index b6f9ec81..bad5735c 100644 --- a/packages/react-notion-x/src/styles.css +++ b/packages/react-notion-x/src/styles.css @@ -1813,6 +1813,466 @@ svg.notion-page-icon { height: 180px; } +.notion-calendar-collection { + width: 100%; + max-width: 100%; + align-self: center; + box-sizing: border-box; + overflow: visible; +} + +.notion-calendar-view { + position: relative; + padding-left: 0; + transition: padding 200ms ease-out; + max-width: 100%; + width: 100%; + overflow-x: auto; + overflow-y: visible; + box-sizing: border-box; +} + +.notion-calendar-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 0 16px 0; + margin-bottom: 0; + border-bottom: none; +} + +.notion-calendar-header-title { + font-size: 20px; + font-weight: 600; + color: var(--fg-color); + line-height: 1.5; +} + +.notion-calendar-header-controls { + display: flex; + align-items: center; + gap: 16px; +} + +.notion-calendar-header-nav { + display: flex; + align-items: center; + gap: 4px; +} + +.notion-calendar-nav-button { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border: 1px solid rgba(55, 53, 47, 0.16); + background: var(--bg-color); + border-radius: 3px; + cursor: pointer; + font-size: 16px; + color: var(--fg-color); + transition: background 100ms ease-in, border-color 100ms ease-in; + user-select: none; + padding: 0; +} + +.notion-calendar-nav-button:hover { + background: rgba(55, 53, 47, 0.08); + border-color: rgba(55, 53, 47, 0.2); +} + +.notion-calendar-nav-button:active { + background: rgba(55, 53, 47, 0.12); +} + +.notion-calendar-nav-today-button { + display: flex; + align-items: center; + justify-content: center; + min-width: 48px; + height: 28px; + border: 1px solid rgba(55, 53, 47, 0.16); + background: var(--bg-color); + border-radius: 3px; + cursor: pointer; + font-size: 14px; + color: var(--fg-color); + transition: background 100ms ease-in, border-color 100ms ease-in; + user-select: none; + padding: 0 8px; +} + +.notion-calendar-nav-today-button:hover { + background: rgba(55, 53, 47, 0.08); + border-color: rgba(55, 53, 47, 0.2); +} + +.notion-calendar-nav-today-button:active { + background: rgba(55, 53, 47, 0.12); +} + +.notion-calendar-grid { + display: flex; + flex-direction: column; + width: 100%; + min-width: 0; + overflow: visible; + box-sizing: border-box; +} + +.notion-calendar-weekdays { + display: grid; + grid-template-columns: repeat(7, minmax(0, 1fr)); + gap: 0; + margin-bottom: 0; + min-width: 0; + box-sizing: border-box; + border-top: 1px solid var(--fg-color-1); + border-left: 1px solid var(--fg-color-1); +} + +.notion-calendar-weekday { + display: flex; + align-items: center; + justify-content: center; + padding: 8px; + font-size: 12px; + font-weight: 600; + color: var(--fg-color-3); + text-transform: uppercase; + border-right: 1px solid var(--fg-color-1); + border-bottom: 1px solid var(--fg-color-1); + background: var(--bg-color); +} + +.notion-calendar-days { + display: flex; + flex-direction: column; + gap: 0; + border-top: 1px solid var(--fg-color-1); + border-left: 1px solid var(--fg-color-1); + position: relative; + overflow: visible; + min-width: 0; + box-sizing: border-box; +} + +.notion-calendar-weeks { + display: flex; + flex-direction: column; + gap: 0; + overflow: visible; +} + +.notion-calendar-week-row { + position: relative; + display: grid; + grid-template-columns: repeat(7, minmax(0, 1fr)); + gap: 0; + border-top: 1px solid var(--fg-color-1); + border-left: 1px solid var(--fg-color-1); + overflow: visible; + min-width: 0; + box-sizing: border-box; + isolation: isolate; +} + +.notion-calendar-day { + border-right: 1px solid var(--fg-color-1); + border-bottom: 1px solid var(--fg-color-1); + padding: 6px 4px; + background: var(--bg-color); + position: relative; + overflow: visible; + z-index: 0; + display: flex; + flex-direction: column; + min-width: 0; + box-sizing: border-box; + height: 100%; + clip-path: none; +} + +.notion-calendar-day::-webkit-scrollbar { + width: 4px; +} + +.notion-calendar-day::-webkit-scrollbar-track { + background: transparent; +} + +.notion-calendar-day::-webkit-scrollbar-thumb { + background-color: var(--fg-color-1); + border-radius: 2px; +} + +.notion-calendar-day::-webkit-scrollbar-thumb:hover { + background-color: var(--fg-color-2); +} + +.notion-calendar-day-has-multiday { + overflow: visible; + z-index: 1; + isolation: isolate; +} + +.notion-calendar-day-other-month { + background: var(--bg-color); + color: var(--fg-color-3); +} + +.notion-calendar-day-today .notion-calendar-day-number { + color: #fff; + font-weight: 600; + background: #ff0000; + border-radius: 50%; + width: 32px; + height: 32px; + display: inline-flex; + align-items: center; + justify-content: center; + margin-bottom: 4px; + padding: 0; + font-size: 14px; +} + +.notion-calendar-day-number { + font-size: 13px; + font-weight: 500; + color: var(--fg-color); + margin-bottom: 2px; + padding: 2px 4px; + display: inline-block; + line-height: 1.2; +} + +.notion-calendar-day-events { + display: flex; + flex-direction: column; + gap: 1px; + overflow: visible; + position: relative; + flex: 1; + min-height: 0; + padding-top: 0; +} + +.notion-calendar-event-card { + position: absolute; + display: flex; + flex-direction: column; + padding: 4px 6px; + margin: 0; + background: #ffffff; + border-radius: 3px; + border: 1px solid rgba(55, 53, 47, 0.16); + cursor: pointer; + transition: background 100ms ease-in, border-color 100ms ease-in, + box-shadow 100ms ease-in; + text-decoration: none; + color: inherit; + overflow: visible; + gap: 2px; + box-sizing: border-box; + white-space: normal; + word-wrap: break-word; + min-height: fit-content; + height: auto; + width: 100%; + max-width: none; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.03); + font-size: 12px; + line-height: 1.3; + z-index: 10; + will-change: transform; +} + +.notion-calendar-event-card .notion-collection-card-body { + padding: 2px 4px; +} + +.notion-calendar-event-card .notion-collection-card-property { + padding: 2px 0; + font-size: 11px; + line-height: 1.2; +} + +.notion-calendar-event-card .notion-collection-card-property:first-child { + font-size: 12px; + font-weight: 500; + padding: 1px 0; +} + +.notion-calendar-event-card:hover { + background: rgba(55, 53, 47, 0.03); + border-color: rgba(55, 53, 47, 0.2); + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05); +} + +.notion-calendar-event-card-wrapper { + position: absolute; + z-index: 10; + overflow: visible; + width: 100%; + height: auto; +} + +.notion-calendar-event-card-wrapper[data-continuation='true'] + .notion-calendar-event-card { + border-top-left-radius: 0; + border-bottom-left-radius: 0; + border-left: none; + padding-left: 8px; +} + +.notion-calendar-event-card-wrapper[data-continues-next-week='true'] + .notion-calendar-event-card { + border-top-right-radius: 0; + border-bottom-right-radius: 0; + padding-right: 8px; +} + +.notion-calendar-event-clip-left { + position: absolute; + left: -2px; + top: 50%; + transform: translateY(-50%); + width: 0; + height: 0; + border-top: 6px solid transparent; + border-bottom: 6px solid transparent; + border-left: 6px solid rgba(55, 53, 47, 0.16); + z-index: 2; +} + +.notion-calendar-event-clip-right { + position: absolute; + right: -2px; + top: 50%; + transform: translateY(-50%); + width: 0; + height: 0; + border-top: 6px solid transparent; + border-bottom: 6px solid transparent; + border-right: 6px solid rgba(55, 53, 47, 0.16); + z-index: 2; +} + +.notion-calendar-event-resume-right { + position: absolute; + right: -2px; + top: 50%; + transform: translateY(-50%); + width: 0; + height: 0; + border-top: 6px solid transparent; + border-bottom: 6px solid transparent; + border-right: 6px solid #2383e2; + z-index: 2; +} + +.notion-calendar-event-card-wrapper[data-continuation='true'] { + pointer-events: none; +} + +@media (max-width: 768px) { + .notion-calendar-header { + flex-direction: column; + align-items: flex-start; + gap: 12px; + padding: 12px 0; + } + + .notion-calendar-header-title { + font-size: 20px; + } + + .notion-calendar-day { + --multiday-card-height: 40px; + --multiday-card-gap: 5px; + --multiday-start-offset: 24px; + min-height: calc( + 100px + var(--multiday-layers, 0) * + (var(--multiday-card-height) + var(--multiday-card-gap)) + ); + padding: 4px; + } + + .notion-calendar-day-number { + font-size: 12px; + } + + .notion-calendar-weekday { + font-size: 11px; + padding: 6px 4px; + } + + .notion-calendar-event-card { + padding: 6px 8px; + font-size: 12px; + } +} + +@media (max-width: 480px) { + .notion-calendar-header-title { + font-size: 18px; + } + + .notion-calendar-day { + --multiday-card-height: 36px; + --multiday-card-gap: 4px; + --multiday-start-offset: 20px; + min-height: calc( + 80px + var(--multiday-layers, 0) * + (var(--multiday-card-height) + var(--multiday-card-gap)) + ); + padding: 3px; + } + + .notion-calendar-day-number { + font-size: 11px; + margin-bottom: 2px; + } + + .notion-calendar-day-today .notion-calendar-day-number { + width: 28px; + height: 28px; + font-size: 12px; + } + + .notion-calendar-weekday { + font-size: 10px; + padding: 4px 2px; + } + + .notion-calendar-event-card { + padding: 4px 6px; + gap: 4px; + } + + .notion-calendar-event-title { + font-size: 11px; + } + + .notion-calendar-event-time { + font-size: 10px; + } + + .notion-calendar-nav-button { + width: 24px; + height: 24px; + font-size: 14px; + } + + .notion-calendar-nav-today-button { + min-width: 40px; + height: 24px; + font-size: 12px; + padding: 0 6px; + } +} + .notion-collection-page-properties { width: 100%; display: flex; @@ -2668,9 +3128,6 @@ svg.notion-page-icon { margin-bottom: 1em; } -.notion-collection-group > summary { -} - .notion-collection-group > summary > div { transform: scale(0.85); transform-origin: 0% 50%; @@ -3079,6 +3536,7 @@ svg.notion-page-icon { color: var(--fg-color); display: -webkit-box; -webkit-line-clamp: 2; + line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; margin: 0; diff --git a/packages/react-notion-x/src/third-party/collection-view-calendar.tsx b/packages/react-notion-x/src/third-party/collection-view-calendar.tsx new file mode 100644 index 00000000..144267c3 --- /dev/null +++ b/packages/react-notion-x/src/third-party/collection-view-calendar.tsx @@ -0,0 +1,223 @@ +import { + addMonths, + eachDayOfInterval, + endOfMonth, + format, + startOfMonth, + startOfWeek, + subMonths +} from 'date-fns' +import { type PageBlock } from 'notion-types' +import { getDateValue } from 'notion-utils' +import React from 'react' + +import { useNotionContext } from '../context' +import { type CollectionViewProps } from '../types' +import { CalendarWeekRow } from './collection-week-row' + +const defaultBlockIds: string[] = [] + +export function CollectionViewCalendar({ + collection, + collectionView, + collectionData +}: CollectionViewProps) { + const { recordMap } = useNotionContext() + const [currentMonth, setCurrentMonth] = React.useState(new Date()) + + const blockIds = + (collectionData.collection_group_results?.blockIds ?? + collectionData.blockIds) || + defaultBlockIds + + const datePropertyId = React.useMemo(() => { + const calendarBy = (collectionView as any)?.query2?.calendar_by + if (calendarBy) { + const schema = collection.schema + const propertySchema = schema[calendarBy] + if (propertySchema && propertySchema.type === 'date') { + return calendarBy + } + } + + const schema = collection.schema + for (const [propertyId, propertySchema] of Object.entries(schema)) { + if (propertySchema.type === 'date') { + return propertyId + } + } + return null + }, [collection.schema, collectionView]) + + const eventsByDate = React.useMemo(() => { + const eventsMap = new Map< + string, + Array<{ block: PageBlock; dateRange: { start: Date; end: Date } | null }> + >() + + if (!datePropertyId) { + return eventsMap + } + + for (const blockId of blockIds) { + const block = recordMap.block[blockId]?.value as PageBlock + if (!block) continue + + const dateProperty = + block.properties?.[datePropertyId as keyof typeof block.properties] + if (!dateProperty) continue + + const dateValue = getDateValue(dateProperty as any[]) + if (!dateValue) continue + + let startDate: Date + let endDate: Date | null = null + let dateRange: { start: Date; end: Date } | null = null + + if (dateValue.type === 'date' || dateValue.type === 'datetime') { + startDate = new Date(dateValue.start_date) + endDate = null + } else if ( + dateValue.type === 'daterange' || + dateValue.type === 'datetimerange' + ) { + startDate = new Date(dateValue.start_date) + endDate = dateValue.end_date ? new Date(dateValue.end_date) : null + if (endDate) { + dateRange = { start: startDate, end: endDate } + } + } else { + continue + } + + if (dateRange && endDate) { + const allDays = eachDayOfInterval({ + start: startDate, + end: endDate + }) + for (const day of allDays) { + const dateKey = format(day, 'yyyy-MM-dd') + if (!eventsMap.has(dateKey)) { + eventsMap.set(dateKey, []) + } + eventsMap.get(dateKey)!.push({ block, dateRange }) + } + } else { + const dateKey = format(startDate, 'yyyy-MM-dd') + if (!eventsMap.has(dateKey)) { + eventsMap.set(dateKey, []) + } + eventsMap.get(dateKey)!.push({ block, dateRange: null }) + } + } + + return eventsMap + }, [blockIds, datePropertyId, recordMap]) + + const calendarDays = React.useMemo(() => { + const weekStartsOn = 1 as 0 | 1 | 2 | 3 | 4 | 5 | 6 + const monthStart = startOfMonth(currentMonth) + const monthEnd = endOfMonth(currentMonth) + const calendarStart = startOfWeek(monthStart, { weekStartsOn }) + const calendarEnd = startOfWeek(monthEnd, { weekStartsOn }) + const endDate = new Date(calendarEnd) + endDate.setDate(endDate.getDate() + 6) + + return eachDayOfInterval({ + start: calendarStart, + end: endDate + }) + }, [currentMonth]) + + const goToPreviousMonth = () => { + setCurrentMonth(subMonths(currentMonth, 1)) + } + + const goToNextMonth = () => { + setCurrentMonth(addMonths(currentMonth, 1)) + } + + const goToToday = () => { + setCurrentMonth(new Date()) + } + + const today = new Date() + + const weeks = React.useMemo(() => { + const weekArray: Date[][] = [] + for (let i = 0; i < calendarDays.length; i += 7) { + weekArray.push(calendarDays.slice(i, i + 7)) + } + return weekArray + }, [calendarDays]) + + return ( +
+
+
+
+ {format(currentMonth, 'MMMM yyyy')} +
+
+
+ + + +
+
+
+
+
+ {Array.from({ length: 7 }, (_, i) => { + const dayIndex = (i + 1) % 7 + const date = new Date(2024, 0, 7 + dayIndex) + const dayName = format(date, 'EEEEEE') + return ( +
+ {dayName} +
+ ) + })} +
+
+ {weeks.map((weekDays, weekIndex) => ( + + ))} +
+
+
+
+ ) +} diff --git a/packages/react-notion-x/src/third-party/collection-view.tsx b/packages/react-notion-x/src/third-party/collection-view.tsx index 378550dc..44173a70 100644 --- a/packages/react-notion-x/src/third-party/collection-view.tsx +++ b/packages/react-notion-x/src/third-party/collection-view.tsx @@ -2,6 +2,7 @@ import React from 'react' import { type CollectionViewProps } from '../types' import { CollectionViewBoard } from './collection-view-board' +import { CollectionViewCalendar } from './collection-view-calendar' import { CollectionViewGallery } from './collection-view-gallery' import { CollectionViewList } from './collection-view-list' import { CollectionViewTable } from './collection-view-table' @@ -22,6 +23,9 @@ export function CollectionViewImpl(props: CollectionViewProps) { case 'board': return + case 'calendar': + return + default: console.warn('unsupported collection view', collectionView) return null diff --git a/packages/react-notion-x/src/third-party/collection-week-row.tsx b/packages/react-notion-x/src/third-party/collection-week-row.tsx new file mode 100644 index 00000000..5b1b73e4 --- /dev/null +++ b/packages/react-notion-x/src/third-party/collection-week-row.tsx @@ -0,0 +1,703 @@ +import { format, getDay, isSameDay, isSameMonth } from 'date-fns' +import { type PageBlock } from 'notion-types' +import React from 'react' + +import { cs } from '../utils' +import { CollectionCard } from './collection-card' + +const CARD_GAP = 8 +const CARD_HORIZONTAL_PADDING = 4 +const DAY_BORDER_WIDTH = 1 + +export function CalendarWeekRow({ + weekIndex, + weekDays, + calendarDays, + eventsByDate, + collection, + collectionView, + datePropertyId, + currentMonth, + today +}: { + weekIndex: number + weekDays: Date[] + calendarDays: Date[] + eventsByDate: Map< + string, + Array<{ block: PageBlock; dateRange: { start: Date; end: Date } | null }> + > + collection: any + collectionView: any + datePropertyId: string | null + currentMonth: Date + today: Date +}) { + const weekRef = React.useRef(null) + const cardRefs = React.useRef>(new Map()) + const [weekHeight, setWeekHeight] = React.useState(140) + const [dayWidths, setDayWidths] = React.useState([]) + const [cardHeights, setCardHeights] = React.useState>( + new Map() + ) + + const propertiesSource = + collectionView.format?.calendar_properties || + collectionView.format?.list_properties || + [] + + const visibleProperties = propertiesSource.filter( + (p: any) => + p.visible === true && + p.property !== 'title' && + (datePropertyId ? p.property !== datePropertyId : true) + ) + + const calendarCover = (collectionView.format as any)?.calendar_cover || { + type: 'none' + } + + const calendarCoverSize = + (collectionView.format as any)?.calendar_cover_size || 'small' + const calendarCoverAspect = + (collectionView.format as any)?.calendar_cover_aspect || 'cover' + + const MULTIDAY_CARD_MIN_HEIGHT = 44 + const CARD_TOP_OFFSET = 48 + + const { eventsWithAdjustedWidth: allEventsWithWidth, layersPerDay } = + React.useMemo(() => { + const events: Array<{ + block: PageBlock + dateRange: { start: Date; end: Date } | null + startIndex: number + endIndex: number + displayStartIndex: number + displayEndIndex: number + displayStartDayIndex: number + displayEndDayIndex: number + layerIndex: number + isStartDay: boolean + isEndDay: boolean + startDateKey: string + endDateKey: string + dateKey: string + globalIndex: number + dayIndexInWeek: number + }> = [] + + const allEvents: Array<{ + block: PageBlock + dateRange: { start: Date; end: Date } | null + startIndex: number + endIndex: number + displayStartIndex: number + displayEndIndex: number + displayStartDayIndex: number + displayEndDayIndex: number + isStartDay: boolean + isEndDay: boolean + startDateKey: string + endDateKey: string + dateKey: string + globalIndex: number + dayIndexInWeek: number + }> = [] + + for (const [dayIndexInWeek, day] of weekDays.entries()) { + const globalIndex = weekIndex * 7 + dayIndexInWeek + const dateKey = format(day, 'yyyy-MM-dd') + const dayEvents = eventsByDate.get(dateKey) || [] + + for (const event of dayEvents) { + const { block, dateRange } = event + + const startDate = dateRange ? dateRange.start : new Date(day) + const endDate = dateRange ? dateRange.end : new Date(day) + const startDateKey = format(startDate, 'yyyy-MM-dd') + const endDateKey = format(endDate, 'yyyy-MM-dd') + + const isInRange = dateKey >= startDateKey && dateKey <= endDateKey + if (!isInRange) continue + + const startIndex = calendarDays.findIndex((d) => + isSameDay(d, startDate) + ) + const endIndex = calendarDays.findIndex((d) => isSameDay(d, endDate)) + if (startIndex === -1 || endIndex === -1) continue + + const isStartDay = dateKey === startDateKey + + const currentWeekIndex = Math.floor(globalIndex / 7) + const weekStartIndex = currentWeekIndex * 7 + const weekEndIndex = weekStartIndex + 6 + + const isWeekStart = globalIndex === weekStartIndex + const shouldRenderCard = + isStartDay || + (isWeekStart && + startIndex < weekStartIndex && + globalIndex <= endIndex) + + if (!shouldRenderCard) continue + + let displayStartIndex: number + let displayEndIndex: number + + if (isStartDay) { + displayStartIndex = startIndex + displayEndIndex = Math.min(endIndex, weekEndIndex) + } else { + displayStartIndex = Math.max(startIndex, weekStartIndex) + displayEndIndex = Math.min(endIndex, weekEndIndex) + } + + const spanDays = displayEndIndex - displayStartIndex + 1 + if (spanDays <= 0) continue + + const displayStartDayIndex = displayStartIndex - weekStartIndex + const isEndDay = dateKey === endDateKey + const displayEndDayIndex = displayEndIndex - weekStartIndex + + allEvents.push({ + block, + dateRange: dateRange || null, + startIndex, + endIndex, + displayStartIndex, + displayEndIndex, + displayStartDayIndex, + displayEndDayIndex, + isStartDay, + isEndDay, + startDateKey, + endDateKey, + dateKey, + globalIndex, + dayIndexInWeek + }) + } + } + + const sortedEvents = [...allEvents].toSorted((a, b) => { + const durationA = a.displayEndIndex - a.displayStartIndex + const durationB = b.displayEndIndex - b.displayStartIndex + if (durationA !== durationB) { + return durationB - durationA + } + if (a.displayStartIndex !== b.displayStartIndex) { + return a.displayStartIndex - b.displayStartIndex + } + return a.displayEndIndex - b.displayEndIndex + }) + + const dayLayerAssignments: Array> = + Array.from( + { length: 7 }, + () => [] as Array<(typeof allEvents)[0] | null> + ) + const layersPerDay = Array.from({ length: 7 }).fill(0) + + for (const event of sortedEvents) { + const spanStart = Math.max(0, event.displayStartDayIndex) + const spanEnd = Math.min(6, event.displayEndDayIndex) + let layerIndex = 0 + + while (true) { + const canUseLayer = (() => { + for (let dayIndex = spanStart; dayIndex <= spanEnd; dayIndex++) { + const dayLayers = dayLayerAssignments[dayIndex] + if (dayLayers && dayLayers[layerIndex]) { + return false + } + } + return true + })() + + if (canUseLayer) { + for (let dayIndex = spanStart; dayIndex <= spanEnd; dayIndex++) { + const dayLayers = dayLayerAssignments[dayIndex] + if (dayLayers) { + dayLayers[layerIndex] = event + layersPerDay[dayIndex] = Math.max( + layersPerDay[dayIndex], + layerIndex + 1 + ) + } + } + break + } + + layerIndex += 1 + } + + events.push({ + ...event, + layerIndex + }) + } + + const eventsWithAdjustedWidth: Array< + (typeof events)[0] & { + width: number + left: number + leftPadding: number + rightPadding: number + continuesIntoNextWeek: boolean + continuesFromPreviousWeek: boolean + } + > = [] + + for (const event of events) { + let totalWidth = 0 + const displayStartDate = calendarDays[event.displayStartIndex] + const displayEndDate = calendarDays[event.displayEndIndex] + const startDayOfWeek = displayStartDate + ? getDay(displayStartDate) + : null + const endDayOfWeek = displayEndDate ? getDay(displayEndDate) : null + + const continuesFromPreviousWeek = + !event.isStartDay && startDayOfWeek === 1 + const continuesIntoNextWeek = !event.isEndDay && endDayOfWeek === 0 + + let leftPadding = CARD_HORIZONTAL_PADDING + let rightPadding = CARD_HORIZONTAL_PADDING + + if (continuesFromPreviousWeek) { + leftPadding = 0 + } + + if (continuesIntoNextWeek) { + rightPadding = 0 + } + + if (dayWidths.length === 7) { + const spanDays = event.displayEndIndex - event.displayStartIndex + 1 + for (let i = 0; i < spanDays; i++) { + const dayIndex = event.displayStartDayIndex + i + if (dayIndex >= 0 && dayIndex < 7) { + totalWidth += dayWidths[dayIndex] || 0 + } + } + totalWidth -= leftPadding + rightPadding + } else { + const spanDays = event.displayEndIndex - event.displayStartIndex + 1 + totalWidth = spanDays * (100 / 7) - (leftPadding + rightPadding) + } + + let startLeft = leftPadding + if (dayWidths.length === 7) { + for (let i = 0; i < event.displayStartDayIndex; i++) { + startLeft += dayWidths[i] || 0 + } + } else { + startLeft = event.displayStartDayIndex * (100 / 7) + } + + eventsWithAdjustedWidth.push({ + ...event, + width: totalWidth, + left: startLeft, + leftPadding, + rightPadding, + continuesIntoNextWeek, + continuesFromPreviousWeek + }) + } + + return { eventsWithAdjustedWidth, layersPerDay } + }, [weekDays, weekIndex, eventsByDate, calendarDays, dayWidths]) + + const getEventCardKey = React.useCallback( + (event: { + block: PageBlock + globalIndex: number + displayStartIndex: number + layerIndex: number + }) => { + return `event-${event.block.id}-${event.globalIndex}-${event.displayStartIndex}-${event.layerIndex}` + }, + [] + ) + + const layerHeights = React.useMemo(() => { + const heights = new Map() + + for (const event of allEventsWithWidth) { + const cardKey = getEventCardKey(event) + const cardHeight = cardHeights.get(cardKey) || MULTIDAY_CARD_MIN_HEIGHT + const prevHeight = heights.get(event.layerIndex) || 0 + + if (cardHeight > prevHeight) { + heights.set(event.layerIndex, cardHeight) + } + } + + return heights + }, [allEventsWithWidth, cardHeights, getEventCardKey]) + + const layerOffsets = React.useMemo(() => { + const offsets = new Map() + let cumulativeOffset = CARD_TOP_OFFSET + + const sortedLayers = Array.from(layerHeights.keys()).toSorted( + (a, b) => a - b + ) + + for (const layerIndex of sortedLayers) { + offsets.set(layerIndex, cumulativeOffset) + const height = layerHeights.get(layerIndex) || MULTIDAY_CARD_MIN_HEIGHT + cumulativeOffset += height + CARD_GAP + } + + return offsets + }, [layerHeights]) + + React.useEffect(() => { + const measureCardHeights = () => { + const heights = new Map() + + for (const [key, cardElement] of cardRefs.current.entries()) { + if (cardElement) { + const collectionCard = cardElement.querySelector( + '.notion-calendar-event-card' + ) as HTMLElement + + if (collectionCard) { + const rect = collectionCard.getBoundingClientRect() + if (rect.height > 0) { + heights.set(key, rect.height) + } + } else { + const rect = cardElement.getBoundingClientRect() + if (rect.height > 0) { + heights.set(key, rect.height) + } + } + } + } + + if (heights.size > 0) { + setCardHeights((prevHeights) => { + let hasChanges = false + for (const [key, height] of heights.entries()) { + if (prevHeights.get(key) !== height) { + hasChanges = true + } + } + if (!hasChanges && heights.size === prevHeights.size) { + return prevHeights + } + return new Map(heights) + }) + } + } + + const timeoutId1 = setTimeout(() => { + measureCardHeights() + }, 0) + + const timeoutId2 = setTimeout(() => { + measureCardHeights() + }, 50) + + const timeoutId3 = setTimeout(() => { + measureCardHeights() + }, 100) + + const resizeObserver = new ResizeObserver(() => { + measureCardHeights() + }) + + for (const cardElement of cardRefs.current) { + if (cardElement && cardElement instanceof Element) { + resizeObserver.observe(cardElement) + + const collectionCard = cardElement.querySelector( + '.notion-calendar-event-card' + ) as HTMLElement + if (collectionCard && collectionCard instanceof Element) { + resizeObserver.observe(collectionCard) + } + } + } + + return () => { + clearTimeout(timeoutId1) + clearTimeout(timeoutId2) + clearTimeout(timeoutId3) + resizeObserver.disconnect() + } + }, [allEventsWithWidth]) + + React.useEffect(() => { + if (!weekRef.current) return + + const measureDayWidths = () => { + const weekElement = weekRef.current + if (!weekElement) return + + const dayElements = weekElement.querySelectorAll('.notion-calendar-day') + const widths: number[] = [] + + for (const dayElement of dayElements) { + const rect = dayElement.getBoundingClientRect() + widths.push(rect.width) + } + + if (widths.length === 7) { + setDayWidths(widths) + } + } + + const timeoutId = setTimeout(() => { + measureDayWidths() + }, 0) + + const resizeObserver = new ResizeObserver(() => { + measureDayWidths() + }) + + if (weekRef.current) resizeObserver.observe(weekRef.current) + + return () => { + clearTimeout(timeoutId) + resizeObserver.disconnect() + } + }, [weekDays]) + + React.useEffect(() => { + if (!weekRef.current) return + + const measureWeekHeight = () => { + const weekElement = weekRef.current + if (!weekElement) return + + const allCards = weekElement.querySelectorAll( + '.notion-calendar-event-card-wrapper' + ) + + if (allCards.length === 0) { + setWeekHeight(140) + return + } + + let maxBottom = 0 + + for (const card of allCards) { + const cardElement = card as HTMLElement + const weekRect = weekElement.getBoundingClientRect() + const wrapperRect = cardElement.getBoundingClientRect() + const relativeTop = wrapperRect.top - weekRect.top + + const collectionCard = cardElement.querySelector( + '.notion-calendar-event-card' + ) as HTMLElement + + let cardHeight = wrapperRect.height + if (collectionCard) { + const cardRect = collectionCard.getBoundingClientRect() + cardHeight = cardRect.height + } + + const cardBottom = relativeTop + cardHeight + + if (cardBottom > maxBottom) { + maxBottom = cardBottom + } + } + + const calculatedHeight = Math.max(140, maxBottom + 8) + setWeekHeight(calculatedHeight) + } + + const timeoutId = setTimeout(() => { + measureWeekHeight() + }, 0) + + const resizeObserver = new ResizeObserver(() => { + measureWeekHeight() + + const heights = new Map() + for (const [key, cardElement] of cardRefs.current.entries()) { + if (cardElement) { + const collectionCard = cardElement.querySelector( + '.notion-calendar-event-card' + ) as HTMLElement + + if (collectionCard) { + const rect = collectionCard.getBoundingClientRect() + if (rect.height > 0) { + heights.set(key, rect.height) + } + } else { + const rect = cardElement.getBoundingClientRect() + if (rect.height > 0) { + heights.set(key, rect.height) + } + } + } + } + if (heights.size > 0) { + setCardHeights(heights) + } + }) + + const allCards = weekRef.current.querySelectorAll( + '.notion-calendar-event-card-wrapper' + ) + for (const card of allCards) { + const cardElement = card as HTMLElement + if (cardElement && cardElement instanceof Element) { + resizeObserver.observe(cardElement) + + const collectionCard = cardElement.querySelector( + '.notion-calendar-event-card' + ) as HTMLElement + if (collectionCard && collectionCard instanceof Element) { + resizeObserver.observe(collectionCard) + } + } + } + + if (weekRef.current) { + resizeObserver.observe(weekRef.current) + } + + return () => { + clearTimeout(timeoutId) + resizeObserver.disconnect() + } + }, [weekDays, eventsByDate]) + + return ( +
+ {weekDays.map((day, dayIndexInWeek) => { + const globalIndex = weekIndex * 7 + dayIndexInWeek + const isCurrentMonth = isSameMonth(day, currentMonth) + const isToday = isSameDay(day, today) + const layerCountForDay = layersPerDay[dayIndexInWeek] || 0 + + return ( +
+
{format(day, 'd')}
+
+
+ ) + })} + + {allEventsWithWidth.map((event) => { + const displayStartDate = calendarDays[event.displayStartIndex] + const displayEndDate = calendarDays[event.displayEndIndex] + if (!displayStartDate || !displayEndDate) return null + + const nextDay = new Date(displayEndDate) + nextDay.setDate(nextDay.getDate() + 1) + + const spanDays = event.displayEndIndex - event.displayStartIndex + 1 + const widthValue = + dayWidths.length === 7 + ? `${event.width}px` + : `calc(${spanDays} * (100% / 7) + ${ + (spanDays - 1) * DAY_BORDER_WIDTH + }px - ${(event.leftPadding + event.rightPadding).toFixed(2)}px)` + + const leftValue = + dayWidths.length === 7 + ? `${event.left}px` + : `calc(${event.displayStartDayIndex} * ((100% / 7) + ${ + DAY_BORDER_WIDTH + }px) + ${event.leftPadding.toFixed(2)}px)` + + const topOffset = layerOffsets.get(event.layerIndex) + + const cardKey = getEventCardKey(event) + + const eventCardStyle = { + width: widthValue, + position: 'absolute' as const, + left: leftValue, + top: `${topOffset}px`, + zIndex: 100 + event.layerIndex, + pointerEvents: 'auto' as const, + height: 'auto', + minHeight: `${MULTIDAY_CARD_MIN_HEIGHT}px`, + boxSizing: 'border-box' as const + } + + return ( +
{ + if (el) { + cardRefs.current.set(cardKey, el) + } else { + cardRefs.current.delete(cardKey) + } + }} + className={cs('notion-calendar-event-card-wrapper')} + style={eventCardStyle} + data-layer-index={event.layerIndex} + data-continuation={!event.isStartDay ? 'true' : undefined} + data-start-day={event.isStartDay ? 'true' : undefined} + data-end-day={event.isEndDay ? 'true' : undefined} + data-continues-next-week={ + event.continuesIntoNextWeek ? 'true' : undefined + } + data-continues-from-prev-week={ + event.continuesFromPreviousWeek ? 'true' : undefined + } + > + +
+ ) + })} +
+ ) +}