@luckydye/calendar 1.3.0 → 1.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/calendar.js +1548 -1373
- package/package.json +2 -2
- package/src/CalDAVConfig.ts +50 -1
- package/src/CalendarInternal.ts +9 -4
- package/src/CalendarLayer.ts +28 -0
- package/src/CalendarView.ts +277 -1110
- package/src/GoogleCalendarSource.ts +25 -0
- package/src/IndexedDBStorage.ts +3 -0
- package/src/Keybinds.ts +3 -18
- package/src/StatusBar.ts +11 -0
- package/src/Theme.ts +4 -4
- package/src/TimeseriesJson.ts +114 -0
- package/src/app.ts +44 -4
- package/src/layers/EventsLayer.ts +958 -0
- package/src/layers/GridLayer.ts +296 -0
- package/src/layers/TimeseriesHeatmapLayer.ts +132 -0
- package/src/lib.ts +1 -0
package/src/CalendarView.ts
CHANGED
|
@@ -3,7 +3,6 @@ import {
|
|
|
3
3
|
type Attendee,
|
|
4
4
|
type CalendarEvent,
|
|
5
5
|
CalendarInternal,
|
|
6
|
-
type EventSegment,
|
|
7
6
|
type WeekInfo,
|
|
8
7
|
NOTIFICATION_PRESETS,
|
|
9
8
|
} from "./CalendarInternal.js";
|
|
@@ -19,12 +18,15 @@ import { serializeEventsToICal } from "./ICal.js";
|
|
|
19
18
|
import { queueStatus } from "./StatusMessage.js";
|
|
20
19
|
import type { StatusBarData } from "./StatusBar.js";
|
|
21
20
|
import { IndexedDBStorage } from "./IndexedDBStorage.js";
|
|
21
|
+
import { TIME_SCALE_DAY_HEIGHT, type CalendarLayer, type LayerContext } from "./CalendarLayer.js";
|
|
22
|
+
import { createGridLayer } from "./layers/GridLayer.js";
|
|
23
|
+
import { createEventsLayer, type EventRect, type EventsState } from "./layers/EventsLayer.js";
|
|
24
|
+
import { createTimeseriesHeatmapLayer } from "./layers/TimeseriesHeatmapLayer.js";
|
|
22
25
|
|
|
23
|
-
const MIN_DAY_HEIGHT =
|
|
26
|
+
const MIN_DAY_HEIGHT = 50;
|
|
24
27
|
const MAX_DAY_HEIGHT = 3000; // 1px per minute
|
|
25
28
|
const LEFT_GUTTER_WIDTH = 60;
|
|
26
29
|
const MINIMAP_WIDTH = 12;
|
|
27
|
-
const MIN_EVENT_HEIGHT = 20;
|
|
28
30
|
|
|
29
31
|
export class CalendarViewElement extends LitElement {
|
|
30
32
|
static styles = css`
|
|
@@ -854,6 +856,14 @@ export class CalendarViewElement extends LitElement {
|
|
|
854
856
|
minimapBufferCanvas: HTMLCanvasElement | null = null;
|
|
855
857
|
minimapBufferCtx: CanvasRenderingContext2D | null = null;
|
|
856
858
|
stripePatternCanvas: HTMLCanvasElement | null = null;
|
|
859
|
+
layers: CalendarLayer[] = [];
|
|
860
|
+
eventsLayer: ReturnType<typeof createEventsLayer> | null = null;
|
|
861
|
+
heatmapEvents: CalendarEvent[] = [];
|
|
862
|
+
heatmapRange: { start: Date; end: Date } | null = null;
|
|
863
|
+
heatmapQueryKey: string | null = null;
|
|
864
|
+
heatmapQueryToken = 0;
|
|
865
|
+
timeseriesSourceIds: Set<string> = new Set();
|
|
866
|
+
timeseriesSourceCacheKey: string | null = null;
|
|
857
867
|
scrollContainer: HTMLElement | null = null;
|
|
858
868
|
scrollContent: HTMLElement | null = null;
|
|
859
869
|
resizeObserver: ResizeObserver | null = null;
|
|
@@ -875,9 +885,12 @@ export class CalendarViewElement extends LitElement {
|
|
|
875
885
|
cursorPosition: { x: number; y: number } | null = null; // Mouse position in calendar coordinates
|
|
876
886
|
animationFrame: number | null = null;
|
|
877
887
|
isDraggingMinimap = false;
|
|
888
|
+
altKeyActive = false; // Tracks if alt/meta key is held (bypasses event interaction)
|
|
878
889
|
isFiltered = false; // Tracks if filter is currently active
|
|
879
890
|
timeUpdateInterval: ReturnType<typeof setInterval> | null = null;
|
|
880
891
|
isExtendingRange = false; // Prevents concurrent range extensions
|
|
892
|
+
boundaryCheckInterval: ReturnType<typeof setInterval> | null = null;
|
|
893
|
+
lastWheelTime = 0; // Timestamp of last wheel event, used to distinguish wheel vs scrollbar drag
|
|
881
894
|
isCreatingEvent = false;
|
|
882
895
|
eventCreationStart: { x: number; y: number } | null = null;
|
|
883
896
|
eventCreationEnd: { x: number; y: number } | null = null;
|
|
@@ -992,6 +1005,7 @@ export class CalendarViewElement extends LitElement {
|
|
|
992
1005
|
currentTheme: ThemeName = loadThemePreference();
|
|
993
1006
|
|
|
994
1007
|
activeCalendarColor: string | null = null;
|
|
1008
|
+
activeCalendarId: string | null = null;
|
|
995
1009
|
|
|
996
1010
|
get filter() {
|
|
997
1011
|
return this.internal.getFilter();
|
|
@@ -1302,6 +1316,8 @@ export class CalendarViewElement extends LitElement {
|
|
|
1302
1316
|
window.addEventListener("mousemove", this.onMouseMove);
|
|
1303
1317
|
window.addEventListener("mouseup", this.onMouseUp);
|
|
1304
1318
|
window.addEventListener("paste", this.onPaste);
|
|
1319
|
+
window.addEventListener("keydown", this.onKeyDown);
|
|
1320
|
+
window.addEventListener("keyup", this.onKeyUp);
|
|
1305
1321
|
}
|
|
1306
1322
|
|
|
1307
1323
|
disconnectedCallback() {
|
|
@@ -1310,6 +1326,8 @@ export class CalendarViewElement extends LitElement {
|
|
|
1310
1326
|
window.removeEventListener("mousemove", this.onMouseMove);
|
|
1311
1327
|
window.removeEventListener("mouseup", this.onMouseUp);
|
|
1312
1328
|
window.removeEventListener("paste", this.onPaste);
|
|
1329
|
+
window.removeEventListener("keydown", this.onKeyDown);
|
|
1330
|
+
window.removeEventListener("keyup", this.onKeyUp);
|
|
1313
1331
|
|
|
1314
1332
|
if (this.scrollContainer) {
|
|
1315
1333
|
this.scrollContainer.removeEventListener(
|
|
@@ -1326,6 +1344,9 @@ export class CalendarViewElement extends LitElement {
|
|
|
1326
1344
|
if (this.timeUpdateInterval) {
|
|
1327
1345
|
clearInterval(this.timeUpdateInterval);
|
|
1328
1346
|
}
|
|
1347
|
+
if (this.boundaryCheckInterval) {
|
|
1348
|
+
clearInterval(this.boundaryCheckInterval);
|
|
1349
|
+
}
|
|
1329
1350
|
}
|
|
1330
1351
|
|
|
1331
1352
|
onScrollContainerMouseLeave = (): void => {
|
|
@@ -1367,6 +1388,24 @@ export class CalendarViewElement extends LitElement {
|
|
|
1367
1388
|
this.minimapBufferCanvas = document.createElement("canvas");
|
|
1368
1389
|
this.minimapBufferCtx = this.minimapBufferCanvas.getContext("2d");
|
|
1369
1390
|
|
|
1391
|
+
const self = this;
|
|
1392
|
+
const eventsState: EventsState = {
|
|
1393
|
+
get events() { return self.events; },
|
|
1394
|
+
get hoveredEventId() { return self.hoveredEventId; },
|
|
1395
|
+
isEventSelected: (event) => self.internal.isEventSelected(event),
|
|
1396
|
+
shouldRenderEventWithStripes: (event) => self.shouldRenderEventWithStripes(event),
|
|
1397
|
+
getStripePatternCanvas: () => self.getStripePatternCanvas(),
|
|
1398
|
+
};
|
|
1399
|
+
const heatmapState = {
|
|
1400
|
+
get events() { return self.heatmapEvents; },
|
|
1401
|
+
};
|
|
1402
|
+
this.eventsLayer = createEventsLayer(eventsState);
|
|
1403
|
+
this.layers = [
|
|
1404
|
+
createGridLayer(),
|
|
1405
|
+
createTimeseriesHeatmapLayer(heatmapState),
|
|
1406
|
+
this.eventsLayer,
|
|
1407
|
+
];
|
|
1408
|
+
|
|
1370
1409
|
this.weeks = this.internal.generateWeeks();
|
|
1371
1410
|
|
|
1372
1411
|
// Restore zoom level from localStorage
|
|
@@ -1399,13 +1438,48 @@ export class CalendarViewElement extends LitElement {
|
|
|
1399
1438
|
this.requestUpdate();
|
|
1400
1439
|
}, 10000);
|
|
1401
1440
|
|
|
1441
|
+
// Extend range while stationary at the buffer boundary (no scroll events fire when idle)
|
|
1442
|
+
this.boundaryCheckInterval = setInterval(() => {
|
|
1443
|
+
this.checkAndExtendRange();
|
|
1444
|
+
}, 500);
|
|
1445
|
+
|
|
1402
1446
|
// Try to restore saved scroll position, otherwise scroll to today
|
|
1403
1447
|
this.loadScrollPosition();
|
|
1404
1448
|
|
|
1405
1449
|
let previousFilter = this.filter;
|
|
1406
1450
|
|
|
1407
1451
|
for await (const events of this.internal.events()) {
|
|
1408
|
-
|
|
1452
|
+
const timeseriesIds = this.loadTimeseriesSourceIds();
|
|
1453
|
+
let normalized = events;
|
|
1454
|
+
if (timeseriesIds.size > 0) {
|
|
1455
|
+
let needsClone = false;
|
|
1456
|
+
for (const event of events) {
|
|
1457
|
+
if (
|
|
1458
|
+
event.visualStyle !== "heatmap" &&
|
|
1459
|
+
event.sourceId &&
|
|
1460
|
+
timeseriesIds.has(event.sourceId)
|
|
1461
|
+
) {
|
|
1462
|
+
needsClone = true;
|
|
1463
|
+
break;
|
|
1464
|
+
}
|
|
1465
|
+
}
|
|
1466
|
+
if (needsClone) {
|
|
1467
|
+
normalized = events.map((event) => {
|
|
1468
|
+
if (
|
|
1469
|
+
event.visualStyle === "heatmap" ||
|
|
1470
|
+
!event.sourceId ||
|
|
1471
|
+
!timeseriesIds.has(event.sourceId)
|
|
1472
|
+
) {
|
|
1473
|
+
return event;
|
|
1474
|
+
}
|
|
1475
|
+
return { ...event, visualStyle: "heatmap" };
|
|
1476
|
+
});
|
|
1477
|
+
}
|
|
1478
|
+
}
|
|
1479
|
+
this.events = normalized;
|
|
1480
|
+
if (this.heatmapRange) {
|
|
1481
|
+
void this.refreshHeatmapEvents(true);
|
|
1482
|
+
}
|
|
1409
1483
|
|
|
1410
1484
|
this.renderCanvas();
|
|
1411
1485
|
|
|
@@ -1548,6 +1622,101 @@ export class CalendarViewElement extends LitElement {
|
|
|
1548
1622
|
}
|
|
1549
1623
|
}
|
|
1550
1624
|
|
|
1625
|
+
resolveStyles(): Record<string, string> {
|
|
1626
|
+
const cs = getComputedStyle(this);
|
|
1627
|
+
const props = [
|
|
1628
|
+
"--bg-today",
|
|
1629
|
+
"--accent-current-time",
|
|
1630
|
+
"--grid-color",
|
|
1631
|
+
"--grid-color-strong",
|
|
1632
|
+
"--grid-color-hover",
|
|
1633
|
+
"--text-muted",
|
|
1634
|
+
"--text-primary",
|
|
1635
|
+
"--text-inverse",
|
|
1636
|
+
"--bg-primary",
|
|
1637
|
+
"--bg-elevated",
|
|
1638
|
+
];
|
|
1639
|
+
const styles: Record<string, string> = {};
|
|
1640
|
+
for (const prop of props) {
|
|
1641
|
+
styles[prop] = cs.getPropertyValue(prop).trim();
|
|
1642
|
+
}
|
|
1643
|
+
return styles;
|
|
1644
|
+
}
|
|
1645
|
+
|
|
1646
|
+
private loadTimeseriesSourceIds(): Set<string> {
|
|
1647
|
+
const saved = localStorage.getItem("caldav-sources") || "";
|
|
1648
|
+
if (saved && saved === this.timeseriesSourceCacheKey) {
|
|
1649
|
+
return this.timeseriesSourceIds;
|
|
1650
|
+
}
|
|
1651
|
+
const ids = new Set<string>();
|
|
1652
|
+
if (saved) {
|
|
1653
|
+
try {
|
|
1654
|
+
const sources = JSON.parse(saved) as Array<{ id?: string; type?: string }>;
|
|
1655
|
+
for (const source of sources) {
|
|
1656
|
+
if (source?.type === "timeseries-json" && source.id) {
|
|
1657
|
+
ids.add(source.id);
|
|
1658
|
+
}
|
|
1659
|
+
}
|
|
1660
|
+
} catch {
|
|
1661
|
+
// Ignore malformed storage
|
|
1662
|
+
}
|
|
1663
|
+
}
|
|
1664
|
+
this.timeseriesSourceCacheKey = saved;
|
|
1665
|
+
this.timeseriesSourceIds = ids;
|
|
1666
|
+
return ids;
|
|
1667
|
+
}
|
|
1668
|
+
|
|
1669
|
+
private updateHeatmapRange(visibleWeeks: WeekInfo[]): void {
|
|
1670
|
+
if (visibleWeeks.length === 0) {
|
|
1671
|
+
this.heatmapRange = null;
|
|
1672
|
+
return;
|
|
1673
|
+
}
|
|
1674
|
+
const firstWeek = visibleWeeks[0]!;
|
|
1675
|
+
const lastWeek = visibleWeeks[visibleWeeks.length - 1]!;
|
|
1676
|
+
const startDay = firstWeek.days[0];
|
|
1677
|
+
const endDay = lastWeek.days[6];
|
|
1678
|
+
if (!startDay || !endDay) {
|
|
1679
|
+
this.heatmapRange = null;
|
|
1680
|
+
return;
|
|
1681
|
+
}
|
|
1682
|
+
const start = new Date(startDay);
|
|
1683
|
+
start.setHours(0, 0, 0, 0);
|
|
1684
|
+
const end = new Date(endDay);
|
|
1685
|
+
end.setHours(23, 59, 59, 999);
|
|
1686
|
+
this.heatmapRange = { start, end };
|
|
1687
|
+
}
|
|
1688
|
+
|
|
1689
|
+
private async refreshHeatmapEvents(force = false): Promise<void> {
|
|
1690
|
+
if (!this.heatmapRange) return;
|
|
1691
|
+
const { start, end } = this.heatmapRange;
|
|
1692
|
+
const timeseriesIds = this.loadTimeseriesSourceIds();
|
|
1693
|
+
const enabledKey = [...this.internal.enabledCalendars].join(",");
|
|
1694
|
+
const lockedKey = [...this.internal.lockedCalendars].join(",");
|
|
1695
|
+
const key = `${start.toISOString()}::${end.toISOString()}::${this.filter || ""}::${enabledKey}::${lockedKey}`;
|
|
1696
|
+
if (!force && key === this.heatmapQueryKey) return;
|
|
1697
|
+
this.heatmapQueryKey = key;
|
|
1698
|
+
|
|
1699
|
+
const token = ++this.heatmapQueryToken;
|
|
1700
|
+
try {
|
|
1701
|
+
let events: CalendarEvent[];
|
|
1702
|
+
if (this.internal.storage) {
|
|
1703
|
+
events = await this.internal.storage.queryEvents(start, end);
|
|
1704
|
+
} else {
|
|
1705
|
+
events = this.events;
|
|
1706
|
+
}
|
|
1707
|
+
if (token !== this.heatmapQueryToken) return;
|
|
1708
|
+
|
|
1709
|
+
const filtered = this.internal.filterEvents(events, this.filter);
|
|
1710
|
+
this.heatmapEvents = filtered.filter((event) => {
|
|
1711
|
+
if (event.visualStyle === "heatmap") return true;
|
|
1712
|
+
return event.sourceId ? timeseriesIds.has(event.sourceId) : false;
|
|
1713
|
+
});
|
|
1714
|
+
this.renderCanvas();
|
|
1715
|
+
} catch (error) {
|
|
1716
|
+
console.warn("Failed to refresh heatmap events:", error);
|
|
1717
|
+
}
|
|
1718
|
+
}
|
|
1719
|
+
|
|
1551
1720
|
renderCanvas(): void {
|
|
1552
1721
|
if (!this.ctx || !this.canvas || !this.scrollContainer) return;
|
|
1553
1722
|
|
|
@@ -1561,292 +1730,48 @@ export class CalendarViewElement extends LitElement {
|
|
|
1561
1730
|
const scrollTop = this.scrollTop;
|
|
1562
1731
|
const gridWidth = width - LEFT_GUTTER_WIDTH;
|
|
1563
1732
|
const dayWidth = gridWidth / this._columnsPerRow;
|
|
1564
|
-
const today = new Date();
|
|
1565
1733
|
|
|
1566
|
-
// Find visible weeks
|
|
1567
1734
|
const visibleWeeks = this.weeks.filter(
|
|
1568
1735
|
(w) =>
|
|
1569
1736
|
w.height > 0 &&
|
|
1570
1737
|
w.yOffset + w.height > scrollTop &&
|
|
1571
1738
|
w.yOffset < scrollTop + height,
|
|
1572
1739
|
);
|
|
1740
|
+
this.updateHeatmapRange(visibleWeeks);
|
|
1741
|
+
this.refreshHeatmapEvents();
|
|
1742
|
+
|
|
1743
|
+
const lc: LayerContext = {
|
|
1744
|
+
ctx,
|
|
1745
|
+
width,
|
|
1746
|
+
height,
|
|
1747
|
+
scrollTop,
|
|
1748
|
+
dayWidth,
|
|
1749
|
+
dayHeight: this.dayHeight,
|
|
1750
|
+
leftGutterWidth: LEFT_GUTTER_WIDTH,
|
|
1751
|
+
columnsPerRow: this._columnsPerRow,
|
|
1752
|
+
rowsPerWeek: this.rowsPerWeek,
|
|
1753
|
+
visibleWeeks,
|
|
1754
|
+
allWeeks: this.weeks,
|
|
1755
|
+
fontFamily,
|
|
1756
|
+
styles: this.resolveStyles(),
|
|
1757
|
+
getDayVisualPosition: (dayIndex) => this.getDayVisualPosition(dayIndex),
|
|
1758
|
+
filter: this.filter,
|
|
1759
|
+
};
|
|
1573
1760
|
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
);
|
|
1580
|
-
if (todayIndex >= 0) {
|
|
1581
|
-
const { row, col } = this.getDayVisualPosition(todayIndex);
|
|
1582
|
-
const x = LEFT_GUTTER_WIDTH + col * dayWidth;
|
|
1583
|
-
const dayY = week.yOffset + row * this.dayHeight - scrollTop;
|
|
1584
|
-
const bgToday =
|
|
1585
|
-
getComputedStyle(this).getPropertyValue("--bg-today").trim() ||
|
|
1586
|
-
"rgba(255, 255, 255, 0.05)";
|
|
1587
|
-
ctx.fillStyle = bgToday;
|
|
1588
|
-
ctx.fillRect(x, dayY, dayWidth, this.dayHeight);
|
|
1589
|
-
|
|
1590
|
-
if (showTimeScale) {
|
|
1591
|
-
// Draw current time indicator line (zoomed in)
|
|
1592
|
-
const now = new Date();
|
|
1593
|
-
const currentMinutes = now.getHours() * 60 + now.getMinutes();
|
|
1594
|
-
const timeY = dayY + (currentMinutes / 1440) * this.dayHeight;
|
|
1595
|
-
if (timeY >= 0 && timeY <= height) {
|
|
1596
|
-
const accentTime =
|
|
1597
|
-
getComputedStyle(this)
|
|
1598
|
-
.getPropertyValue("--accent-current-time")
|
|
1599
|
-
.trim() || "rgba(255, 0, 0, 0.8)";
|
|
1600
|
-
ctx.strokeStyle = accentTime;
|
|
1601
|
-
ctx.lineWidth = 1;
|
|
1602
|
-
ctx.beginPath();
|
|
1603
|
-
ctx.moveTo(x, timeY);
|
|
1604
|
-
ctx.lineTo(x + dayWidth, timeY);
|
|
1605
|
-
ctx.stroke();
|
|
1606
|
-
ctx.lineWidth = 1;
|
|
1607
|
-
}
|
|
1608
|
-
}
|
|
1609
|
-
}
|
|
1610
|
-
}
|
|
1611
|
-
|
|
1612
|
-
// Draw grid lines
|
|
1613
|
-
ctx.strokeStyle =
|
|
1614
|
-
getComputedStyle(this).getPropertyValue("--grid-color").trim() ||
|
|
1615
|
-
"rgba(255, 255, 255, 0.1)";
|
|
1616
|
-
ctx.lineWidth = 1;
|
|
1617
|
-
|
|
1618
|
-
// Vertical lines (day separators) - draw for each column
|
|
1619
|
-
for (let i = 1; i <= this._columnsPerRow; i++) {
|
|
1620
|
-
const x = LEFT_GUTTER_WIDTH + i * dayWidth;
|
|
1621
|
-
ctx.beginPath();
|
|
1622
|
-
ctx.moveTo(x, 0);
|
|
1623
|
-
ctx.lineTo(x, height);
|
|
1624
|
-
ctx.stroke();
|
|
1761
|
+
for (const layer of this.layers) {
|
|
1762
|
+
if (!layer.enabled) continue;
|
|
1763
|
+
ctx.save();
|
|
1764
|
+
layer.render(lc);
|
|
1765
|
+
ctx.restore();
|
|
1625
1766
|
}
|
|
1626
1767
|
|
|
1627
|
-
//
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
// Draw gap indicator if there are hidden weeks before this one
|
|
1634
|
-
if (this.filter && i > 0) {
|
|
1635
|
-
const prevWeek = visibleWeeks[i - 1];
|
|
1636
|
-
if (!prevWeek) continue;
|
|
1637
|
-
const prevWeekIndex = this.weeks.indexOf(prevWeek);
|
|
1638
|
-
const currentWeekIndex = this.weeks.indexOf(week);
|
|
1639
|
-
const hiddenWeeks = currentWeekIndex - prevWeekIndex - 1;
|
|
1640
|
-
|
|
1641
|
-
if (hiddenWeeks > 0) {
|
|
1642
|
-
// Draw gap indicator at the top of the current week
|
|
1643
|
-
const gapY = y;
|
|
1644
|
-
|
|
1645
|
-
// Draw dashed line
|
|
1646
|
-
const gridColorStrong =
|
|
1647
|
-
getComputedStyle(this)
|
|
1648
|
-
.getPropertyValue("--grid-color-strong")
|
|
1649
|
-
.trim() || "rgba(255, 255, 255, 0.3)";
|
|
1650
|
-
ctx.strokeStyle = gridColorStrong;
|
|
1651
|
-
ctx.lineWidth = 1;
|
|
1652
|
-
ctx.setLineDash([4, 4]);
|
|
1653
|
-
ctx.beginPath();
|
|
1654
|
-
ctx.moveTo(LEFT_GUTTER_WIDTH, gapY);
|
|
1655
|
-
ctx.lineTo(width, gapY);
|
|
1656
|
-
ctx.stroke();
|
|
1657
|
-
ctx.setLineDash([]);
|
|
1658
|
-
|
|
1659
|
-
// Draw ellipsis in the center
|
|
1660
|
-
const textMuted =
|
|
1661
|
-
getComputedStyle(this).getPropertyValue("--text-muted").trim() ||
|
|
1662
|
-
"rgba(255, 255, 255, 0.4)";
|
|
1663
|
-
ctx.fillStyle = textMuted;
|
|
1664
|
-
ctx.textAlign = "center";
|
|
1665
|
-
const ellipsisText = `⋯ ${hiddenWeeks} week${
|
|
1666
|
-
hiddenWeeks > 1 ? "s" : ""
|
|
1667
|
-
}`;
|
|
1668
|
-
|
|
1669
|
-
// Draw background pill for the ellipsis
|
|
1670
|
-
const textWidth = ctx.measureText(ellipsisText).width;
|
|
1671
|
-
const pillPadding = 8;
|
|
1672
|
-
const pillX =
|
|
1673
|
-
(LEFT_GUTTER_WIDTH + width) / 2 - textWidth / 2 - pillPadding;
|
|
1674
|
-
const pillY = gapY - 8;
|
|
1675
|
-
const pillWidth = textWidth + pillPadding * 2;
|
|
1676
|
-
const pillHeight = 16;
|
|
1677
|
-
|
|
1678
|
-
const bgPrimary =
|
|
1679
|
-
getComputedStyle(this).getPropertyValue("--bg-primary").trim() ||
|
|
1680
|
-
"rgba(30, 30, 30, 0.9)";
|
|
1681
|
-
ctx.fillStyle = bgPrimary;
|
|
1682
|
-
ctx.beginPath();
|
|
1683
|
-
ctx.roundRect(pillX, pillY, pillWidth, pillHeight, 8);
|
|
1684
|
-
ctx.fill();
|
|
1685
|
-
|
|
1686
|
-
ctx.fillStyle = textMuted;
|
|
1687
|
-
ctx.fillText(ellipsisText, (LEFT_GUTTER_WIDTH + width) / 2, gapY + 4);
|
|
1688
|
-
}
|
|
1689
|
-
}
|
|
1690
|
-
|
|
1691
|
-
// Week separator line
|
|
1692
|
-
ctx.strokeStyle =
|
|
1693
|
-
getComputedStyle(this).getPropertyValue("--grid-color").trim() ||
|
|
1694
|
-
"rgba(255, 255, 255, 0.1)";
|
|
1695
|
-
ctx.beginPath();
|
|
1696
|
-
ctx.moveTo(LEFT_GUTTER_WIDTH, y);
|
|
1697
|
-
ctx.lineTo(width, y);
|
|
1698
|
-
ctx.stroke();
|
|
1699
|
-
|
|
1700
|
-
// Draw horizontal lines between visual rows within this week
|
|
1701
|
-
for (let row = 1; row < this.rowsPerWeek; row++) {
|
|
1702
|
-
const rowY = y + row * this.dayHeight;
|
|
1703
|
-
if (rowY >= 0 && rowY <= height) {
|
|
1704
|
-
ctx.beginPath();
|
|
1705
|
-
ctx.moveTo(LEFT_GUTTER_WIDTH, rowY);
|
|
1706
|
-
ctx.lineTo(width, rowY);
|
|
1707
|
-
ctx.stroke();
|
|
1708
|
-
}
|
|
1709
|
-
}
|
|
1710
|
-
|
|
1711
|
-
// Left gutter: week number and time scale
|
|
1712
|
-
const hourLabelOpacity = Math.max(
|
|
1713
|
-
0,
|
|
1714
|
-
Math.min(1, (this.dayHeight - 300) / 300),
|
|
1715
|
-
);
|
|
1716
|
-
|
|
1717
|
-
ctx.font = `500 11px ${fontFamily}`;
|
|
1718
|
-
ctx.textBaseline = "bottom";
|
|
1719
|
-
ctx.textAlign = "right";
|
|
1720
|
-
|
|
1721
|
-
// Draw hourly lines and labels for each visual row
|
|
1722
|
-
const gridColor =
|
|
1723
|
-
getComputedStyle(this).getPropertyValue("--grid-color").trim() ||
|
|
1724
|
-
"rgba(255, 255, 255, 0.1)";
|
|
1725
|
-
const textMuted =
|
|
1726
|
-
getComputedStyle(this).getPropertyValue("--text-muted").trim() ||
|
|
1727
|
-
"rgba(255, 255, 255, 0.4)";
|
|
1728
|
-
ctx.strokeStyle = gridColor.replace(
|
|
1729
|
-
/[\d.]+\)$/,
|
|
1730
|
-
`${0.05 * hourLabelOpacity})`,
|
|
1731
|
-
);
|
|
1732
|
-
|
|
1733
|
-
// Calculate opacity for hour labels based on zoom level
|
|
1734
|
-
// Fade in as we zoom in from 200px to 400px
|
|
1735
|
-
ctx.fillStyle = textMuted.replace(
|
|
1736
|
-
/[\d.]+\)$/,
|
|
1737
|
-
`${0.4 * hourLabelOpacity})`,
|
|
1738
|
-
);
|
|
1739
|
-
|
|
1740
|
-
for (let row = 0; row < this.rowsPerWeek; row++) {
|
|
1741
|
-
const rowY = y + row * this.dayHeight;
|
|
1742
|
-
for (let hour = 0; hour < 24; hour++) {
|
|
1743
|
-
const hourY = rowY + (hour / 24) * this.dayHeight;
|
|
1744
|
-
if (hourY >= 0 && hourY <= height) {
|
|
1745
|
-
// Hour line
|
|
1746
|
-
ctx.beginPath();
|
|
1747
|
-
ctx.moveTo(LEFT_GUTTER_WIDTH, hourY);
|
|
1748
|
-
ctx.lineTo(width, hourY);
|
|
1749
|
-
ctx.stroke();
|
|
1750
|
-
|
|
1751
|
-
// Hour label (only draw if opacity is significant)
|
|
1752
|
-
if (hourLabelOpacity > 0.1) {
|
|
1753
|
-
const label = `${hour.toString().padStart(2, "0")}:00`;
|
|
1754
|
-
ctx.fillText(label, 48, hourY + 4);
|
|
1755
|
-
}
|
|
1756
|
-
}
|
|
1757
|
-
}
|
|
1758
|
-
}
|
|
1759
|
-
|
|
1760
|
-
// Draw current time indicator in left gutter (only when timescale is visible and today is in this week)
|
|
1761
|
-
if (hourLabelOpacity > 0.1) {
|
|
1762
|
-
const today = new Date();
|
|
1763
|
-
const todayIndex = week.days.findIndex((d) =>
|
|
1764
|
-
CalendarInternal.isSameDay(d, today),
|
|
1765
|
-
);
|
|
1766
|
-
|
|
1767
|
-
if (todayIndex >= 0) {
|
|
1768
|
-
const { row } = this.getDayVisualPosition(todayIndex);
|
|
1769
|
-
const currentMinutes = today.getHours() * 60 + today.getMinutes();
|
|
1770
|
-
const timeY =
|
|
1771
|
-
y + row * this.dayHeight + (currentMinutes / 1440) * this.dayHeight;
|
|
1772
|
-
|
|
1773
|
-
if (timeY >= 0 && timeY <= height) {
|
|
1774
|
-
const hours = today.getHours().toString().padStart(2, "0");
|
|
1775
|
-
const minutes = today.getMinutes().toString().padStart(2, "0");
|
|
1776
|
-
const timeText = `${hours}:${minutes}`;
|
|
1777
|
-
|
|
1778
|
-
ctx.save();
|
|
1779
|
-
ctx.textAlign = "right";
|
|
1780
|
-
ctx.textBaseline = "middle";
|
|
1781
|
-
|
|
1782
|
-
const textWidth = ctx.measureText(timeText).width;
|
|
1783
|
-
const bgPaddingX = 6;
|
|
1784
|
-
const bgPaddingY = 3;
|
|
1785
|
-
const textX = 48;
|
|
1786
|
-
const textY = timeY;
|
|
1787
|
-
|
|
1788
|
-
// Draw background pill
|
|
1789
|
-
const bgElevated =
|
|
1790
|
-
getComputedStyle(this).getPropertyValue("--bg-elevated").trim() ||
|
|
1791
|
-
"rgba(0, 0, 0, 0.7)";
|
|
1792
|
-
ctx.fillStyle = bgElevated;
|
|
1793
|
-
ctx.beginPath();
|
|
1794
|
-
ctx.roundRect(
|
|
1795
|
-
textX - textWidth - bgPaddingX,
|
|
1796
|
-
textY - 8,
|
|
1797
|
-
textWidth + bgPaddingX * 2,
|
|
1798
|
-
16,
|
|
1799
|
-
4,
|
|
1800
|
-
);
|
|
1801
|
-
ctx.fill();
|
|
1802
|
-
|
|
1803
|
-
// Draw current time text in white
|
|
1804
|
-
ctx.fillStyle =
|
|
1805
|
-
getComputedStyle(this)
|
|
1806
|
-
.getPropertyValue("--text-primary")
|
|
1807
|
-
.trim() || "rgba(255, 255, 255, 1)";
|
|
1808
|
-
ctx.fillText(timeText, textX, textY);
|
|
1809
|
-
ctx.restore();
|
|
1810
|
-
}
|
|
1811
|
-
}
|
|
1812
|
-
}
|
|
1813
|
-
|
|
1814
|
-
// Draw week number - sticky within visible portion of week
|
|
1815
|
-
// Fade out week numbers as we zoom in from 150px to 200px
|
|
1816
|
-
const weekNumberOpacity = Math.max(
|
|
1817
|
-
0,
|
|
1818
|
-
Math.min(1, 1 - (this.dayHeight - 300) / 50),
|
|
1819
|
-
);
|
|
1820
|
-
const textMutedForWeek =
|
|
1821
|
-
getComputedStyle(this).getPropertyValue("--text-muted").trim() ||
|
|
1822
|
-
"rgba(255, 255, 255, 0.4)";
|
|
1823
|
-
ctx.fillStyle = textMutedForWeek.replace(
|
|
1824
|
-
/[\d.]+\)$/,
|
|
1825
|
-
`${0.4 * weekNumberOpacity})`,
|
|
1826
|
-
);
|
|
1827
|
-
ctx.textAlign = "center";
|
|
1828
|
-
const label = `W${week.weekNumber}`;
|
|
1829
|
-
const weekTop = y;
|
|
1830
|
-
const weekBottom = y + week.height;
|
|
1831
|
-
// Clamp label to be visible: at least 14px from top of viewport,
|
|
1832
|
-
// but within the week's bounds
|
|
1833
|
-
const labelY = Math.max(
|
|
1834
|
-
14,
|
|
1835
|
-
Math.min(weekTop + week.height / 2 + 4, weekBottom - 4),
|
|
1836
|
-
);
|
|
1837
|
-
// Only draw if the label position is within the week's visible area and viewport and opacity is significant
|
|
1838
|
-
if (
|
|
1839
|
-
labelY >= Math.max(0, weekTop + 4) &&
|
|
1840
|
-
labelY <= Math.min(height, weekBottom) &&
|
|
1841
|
-
weekNumberOpacity > 0.1
|
|
1842
|
-
) {
|
|
1843
|
-
ctx.fillText(label, 30, labelY);
|
|
1844
|
-
}
|
|
1768
|
+
// Copy event rects from events layer for hit-testing
|
|
1769
|
+
if (this.eventsLayer) {
|
|
1770
|
+
this.eventRects = this.eventsLayer.eventRects;
|
|
1771
|
+
} else {
|
|
1772
|
+
this.eventRects = [];
|
|
1845
1773
|
}
|
|
1846
1774
|
|
|
1847
|
-
// Render events on canvas
|
|
1848
|
-
this.renderEventsOnCanvas(ctx, scrollTop, height, dayWidth, visibleWeeks);
|
|
1849
|
-
|
|
1850
1775
|
this.renderDateLabels();
|
|
1851
1776
|
|
|
1852
1777
|
// Draw sticky weekday labels at top of viewport
|
|
@@ -1862,808 +1787,17 @@ export class CalendarViewElement extends LitElement {
|
|
|
1862
1787
|
this.renderMinimap();
|
|
1863
1788
|
}
|
|
1864
1789
|
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
visibleWeeks: WeekInfo[],
|
|
1871
|
-
): void {
|
|
1872
|
-
const events = this.events;
|
|
1873
|
-
const fontFamily = getComputedStyle(this).fontFamily;
|
|
1874
|
-
const viewportBottom = scrollTop + height;
|
|
1875
|
-
const showTimeScale = this.dayHeight >= 300;
|
|
1876
|
-
|
|
1877
|
-
// Clear event rects for new render
|
|
1878
|
-
this.eventRects = [];
|
|
1879
|
-
|
|
1880
|
-
if (visibleWeeks.length === 0) return;
|
|
1881
|
-
|
|
1882
|
-
// Compute visible date range
|
|
1883
|
-
const firstVisibleWeek = visibleWeeks[0]!;
|
|
1884
|
-
const lastVisibleWeek = visibleWeeks[visibleWeeks.length - 1]!;
|
|
1885
|
-
const firstVisibleDay = firstVisibleWeek.days[0]!;
|
|
1886
|
-
const lastVisibleDay = lastVisibleWeek.days[6]!;
|
|
1887
|
-
const visibleStartTime = firstVisibleDay.getTime();
|
|
1888
|
-
const visibleEndTime = lastVisibleDay.getTime() + 86400000 - 1;
|
|
1889
|
-
|
|
1890
|
-
// Track occupied row slots per day-column for stacking
|
|
1891
|
-
const dayOccupiedRows = new Map<string, Set<number>>();
|
|
1892
|
-
const eventRowIndex = new Map<string, number>();
|
|
1893
|
-
|
|
1894
|
-
const segments: EventSegment[] = [];
|
|
1895
|
-
|
|
1896
|
-
// Build segments - separate all-day and timed events
|
|
1897
|
-
const allDaySegments: EventSegment[] = [];
|
|
1898
|
-
const timedSegments: EventSegment[] = [];
|
|
1899
|
-
|
|
1900
|
-
for (const event of events) {
|
|
1901
|
-
const eventStartTime = event.start.getTime();
|
|
1902
|
-
const eventEndTime = event.end.getTime();
|
|
1903
|
-
|
|
1904
|
-
if (eventEndTime < visibleStartTime || eventStartTime > visibleEndTime)
|
|
1905
|
-
continue;
|
|
1906
|
-
|
|
1907
|
-
// Only iterate through visible weeks
|
|
1908
|
-
const eventWeeks: { weekIndex: number; week: WeekInfo }[] = [];
|
|
1909
|
-
|
|
1910
|
-
for (const week of visibleWeeks) {
|
|
1911
|
-
const weekStartDay = week.days[0];
|
|
1912
|
-
const weekEndDay = week.days[6];
|
|
1913
|
-
if (!weekStartDay || !weekEndDay) continue;
|
|
1914
|
-
|
|
1915
|
-
const weekStart = weekStartDay.getTime();
|
|
1916
|
-
const weekEnd = weekEndDay.getTime() + 86399999;
|
|
1917
|
-
|
|
1918
|
-
if (eventEndTime >= weekStart && eventStartTime <= weekEnd) {
|
|
1919
|
-
const weekIndex = this.weeks.indexOf(week);
|
|
1920
|
-
eventWeeks.push({ weekIndex, week });
|
|
1921
|
-
}
|
|
1922
|
-
}
|
|
1923
|
-
|
|
1924
|
-
const totalWeeks = eventWeeks.length;
|
|
1925
|
-
const isAllDay = event.isAllDay === true;
|
|
1926
|
-
|
|
1927
|
-
for (let i = 0; i < eventWeeks.length; i++) {
|
|
1928
|
-
const { weekIndex, week } = eventWeeks[i]!;
|
|
1929
|
-
|
|
1930
|
-
const isStart = i === 0;
|
|
1931
|
-
const isEnd = i === eventWeeks.length - 1;
|
|
1932
|
-
|
|
1933
|
-
let startDayIndex = 0;
|
|
1934
|
-
let endDayIndex = 6;
|
|
1935
|
-
|
|
1936
|
-
if (isStart) {
|
|
1937
|
-
startDayIndex = getDayIndexInWeek(week, event.start);
|
|
1938
|
-
}
|
|
1939
|
-
if (isEnd) {
|
|
1940
|
-
// For all-day events, the end date is exclusive (e.g., Monday event ends Tuesday 00:00)
|
|
1941
|
-
// Subtract 1ms to get the actual last day
|
|
1942
|
-
const effectiveEnd = isAllDay
|
|
1943
|
-
? new Date(event.end.getTime() - 1)
|
|
1944
|
-
: event.end;
|
|
1945
|
-
endDayIndex = getDayIndexInWeek(week, effectiveEnd);
|
|
1946
|
-
}
|
|
1947
|
-
|
|
1948
|
-
const segment = {
|
|
1949
|
-
event,
|
|
1950
|
-
weekIndex,
|
|
1951
|
-
week,
|
|
1952
|
-
startDayIndex,
|
|
1953
|
-
endDayIndex,
|
|
1954
|
-
isStart,
|
|
1955
|
-
isEnd,
|
|
1956
|
-
totalWeeks,
|
|
1957
|
-
};
|
|
1958
|
-
|
|
1959
|
-
if (isAllDay) {
|
|
1960
|
-
allDaySegments.push(segment);
|
|
1961
|
-
} else {
|
|
1962
|
-
timedSegments.push(segment);
|
|
1963
|
-
}
|
|
1964
|
-
}
|
|
1965
|
-
}
|
|
1966
|
-
|
|
1967
|
-
// In timescale mode, split multi-day timed segments into per-day sub-segments
|
|
1968
|
-
// so each day renders its own block with correct time positions (like renderVirtualEvent).
|
|
1969
|
-
const renderedTimedSegments = showTimeScale
|
|
1970
|
-
? timedSegments.flatMap((seg) => {
|
|
1971
|
-
if (seg.startDayIndex === seg.endDayIndex) return [seg];
|
|
1972
|
-
const result = [];
|
|
1973
|
-
for (let d = seg.startDayIndex; d <= seg.endDayIndex; d++) {
|
|
1974
|
-
result.push({
|
|
1975
|
-
...seg,
|
|
1976
|
-
startDayIndex: d,
|
|
1977
|
-
endDayIndex: d,
|
|
1978
|
-
isStart: d === seg.startDayIndex && seg.isStart,
|
|
1979
|
-
isEnd: d === seg.endDayIndex && seg.isEnd,
|
|
1980
|
-
totalWeeks: 1,
|
|
1981
|
-
});
|
|
1982
|
-
}
|
|
1983
|
-
return result;
|
|
1984
|
-
})
|
|
1985
|
-
: timedSegments;
|
|
1986
|
-
|
|
1987
|
-
// Combine segments with all-day events first (so they get top rows)
|
|
1988
|
-
segments.push(...allDaySegments, ...renderedTimedSegments);
|
|
1989
|
-
|
|
1990
|
-
// Calculate columns for overlapping events in time scale mode
|
|
1991
|
-
interface EventLayout {
|
|
1992
|
-
segment: EventSegment;
|
|
1993
|
-
yStart: number;
|
|
1994
|
-
yEnd: number;
|
|
1995
|
-
column: number;
|
|
1996
|
-
totalColumns: number;
|
|
1997
|
-
}
|
|
1998
|
-
const eventLayouts: EventLayout[] = [];
|
|
1999
|
-
|
|
2000
|
-
// Group segments by week and day for overlap detection
|
|
2001
|
-
const segmentsByWeekDay = new Map<string, EventSegment[]>();
|
|
2002
|
-
for (const segment of segments) {
|
|
2003
|
-
const allDay = segment.event.isAllDay === true;
|
|
2004
|
-
|
|
2005
|
-
// Only do column layout for time-scaled non-all-day events
|
|
2006
|
-
if (showTimeScale && !allDay) {
|
|
2007
|
-
// For multi-day events in time scale, each day gets its own column calculation
|
|
2008
|
-
for (
|
|
2009
|
-
let dayIdx = segment.startDayIndex;
|
|
2010
|
-
dayIdx <= segment.endDayIndex;
|
|
2011
|
-
dayIdx++
|
|
2012
|
-
) {
|
|
2013
|
-
const key = `${segment.weekIndex}-${dayIdx}`;
|
|
2014
|
-
if (!segmentsByWeekDay.has(key)) {
|
|
2015
|
-
segmentsByWeekDay.set(key, []);
|
|
2016
|
-
}
|
|
2017
|
-
segmentsByWeekDay.get(key)!.push(segment);
|
|
2018
|
-
}
|
|
2019
|
-
}
|
|
2020
|
-
}
|
|
2021
|
-
|
|
2022
|
-
// Calculate column assignments for each day's overlapping events
|
|
2023
|
-
const segmentColumns = new Map<
|
|
2024
|
-
string,
|
|
2025
|
-
{ column: number; totalColumns: number }
|
|
2026
|
-
>();
|
|
2027
|
-
|
|
2028
|
-
for (const [dayKey, daySegments] of segmentsByWeekDay) {
|
|
2029
|
-
const [weekIndexStr, dayIndexStr] = dayKey.split("-");
|
|
2030
|
-
const dayIndex = parseInt(dayIndexStr!);
|
|
2031
|
-
const weekIndex = parseInt(weekIndexStr!);
|
|
2032
|
-
|
|
2033
|
-
// Calculate time ranges for each segment in this day
|
|
2034
|
-
interface SegmentTime {
|
|
2035
|
-
segment: EventSegment;
|
|
2036
|
-
startMinutes: number;
|
|
2037
|
-
endMinutes: number;
|
|
2038
|
-
}
|
|
2039
|
-
const segmentTimes: SegmentTime[] = [];
|
|
2040
|
-
|
|
2041
|
-
for (const seg of daySegments) {
|
|
2042
|
-
const week = this.weeks[weekIndex];
|
|
2043
|
-
if (!week) continue;
|
|
2044
|
-
|
|
2045
|
-
const dayStartTime = new Date(week.days[dayIndex]!).setHours(
|
|
2046
|
-
0,
|
|
2047
|
-
0,
|
|
2048
|
-
0,
|
|
2049
|
-
0,
|
|
2050
|
-
);
|
|
2051
|
-
const dayEndTime = new Date(week.days[dayIndex]!).setHours(
|
|
2052
|
-
23,
|
|
2053
|
-
59,
|
|
2054
|
-
59,
|
|
2055
|
-
999,
|
|
2056
|
-
);
|
|
2057
|
-
const eventStartTime = seg.event.start.getTime();
|
|
2058
|
-
const eventEndTime = seg.event.end.getTime() - 60000; // Subtract 1 minute for rendering
|
|
2059
|
-
|
|
2060
|
-
const effectiveStartTime = Math.max(eventStartTime, dayStartTime);
|
|
2061
|
-
const effectiveEndTime = Math.min(eventEndTime, dayEndTime);
|
|
2062
|
-
|
|
2063
|
-
const effectiveStart = new Date(effectiveStartTime);
|
|
2064
|
-
const effectiveEnd = new Date(effectiveEndTime);
|
|
2065
|
-
|
|
2066
|
-
const startMinutes =
|
|
2067
|
-
effectiveStart.getHours() * 60 + effectiveStart.getMinutes();
|
|
2068
|
-
const endMinutes =
|
|
2069
|
-
effectiveEnd.getHours() * 60 + effectiveEnd.getMinutes();
|
|
2070
|
-
|
|
2071
|
-
segmentTimes.push({ segment: seg, startMinutes, endMinutes });
|
|
2072
|
-
}
|
|
2073
|
-
|
|
2074
|
-
// Sort by start time, then by duration (longer first)
|
|
2075
|
-
segmentTimes.sort((a, b) => {
|
|
2076
|
-
if (a.startMinutes !== b.startMinutes)
|
|
2077
|
-
return a.startMinutes - b.startMinutes;
|
|
2078
|
-
return b.endMinutes - b.startMinutes - (a.endMinutes - a.startMinutes);
|
|
2079
|
-
});
|
|
2080
|
-
|
|
2081
|
-
// Assign columns using a greedy algorithm
|
|
2082
|
-
const columns: { endMinutes: number }[] = [];
|
|
2083
|
-
|
|
2084
|
-
for (const st of segmentTimes) {
|
|
2085
|
-
// Find the first column where this event fits
|
|
2086
|
-
let columnIndex = 0;
|
|
2087
|
-
for (; columnIndex < columns.length; columnIndex++) {
|
|
2088
|
-
if (columns[columnIndex]!.endMinutes <= st.startMinutes) {
|
|
2089
|
-
// This column is free
|
|
2090
|
-
break;
|
|
2091
|
-
}
|
|
2092
|
-
}
|
|
2093
|
-
|
|
2094
|
-
// If no existing column works, create a new one
|
|
2095
|
-
if (columnIndex === columns.length) {
|
|
2096
|
-
columns.push({ endMinutes: st.endMinutes });
|
|
2097
|
-
} else {
|
|
2098
|
-
columns[columnIndex]!.endMinutes = st.endMinutes;
|
|
2099
|
-
}
|
|
2100
|
-
|
|
2101
|
-
const segmentKey = `${st.segment.weekIndex}-${st.segment.event.id}-${dayIndex}`;
|
|
2102
|
-
segmentColumns.set(segmentKey, {
|
|
2103
|
-
column: columnIndex,
|
|
2104
|
-
totalColumns: columns.length,
|
|
2105
|
-
});
|
|
2106
|
-
}
|
|
2107
|
-
|
|
2108
|
-
// Update total columns for all segments in this day
|
|
2109
|
-
for (const st of segmentTimes) {
|
|
2110
|
-
const segmentKey = `${st.segment.weekIndex}-${st.segment.event.id}-${dayIndex}`;
|
|
2111
|
-
const layout = segmentColumns.get(segmentKey);
|
|
2112
|
-
if (layout) {
|
|
2113
|
-
layout.totalColumns = columns.length;
|
|
2114
|
-
}
|
|
2115
|
-
}
|
|
2116
|
-
}
|
|
2117
|
-
|
|
2118
|
-
// Render segments
|
|
2119
|
-
for (const segment of segments) {
|
|
2120
|
-
const {
|
|
2121
|
-
event,
|
|
2122
|
-
week,
|
|
2123
|
-
weekIndex,
|
|
2124
|
-
startDayIndex,
|
|
2125
|
-
endDayIndex,
|
|
2126
|
-
isStart,
|
|
2127
|
-
isEnd,
|
|
2128
|
-
totalWeeks,
|
|
2129
|
-
} = segment;
|
|
2130
|
-
const weekYOffset = week.yOffset;
|
|
2131
|
-
|
|
2132
|
-
const allDay = event.isAllDay === true;
|
|
2133
|
-
|
|
2134
|
-
// Get visual position for the start day
|
|
2135
|
-
const startVisualPos = this.getDayVisualPosition(startDayIndex);
|
|
2136
|
-
const endVisualPos = this.getDayVisualPosition(endDayIndex);
|
|
2137
|
-
|
|
2138
|
-
// Skip segments that span multiple visual rows for now (would need to be split)
|
|
2139
|
-
// For simple case, render each day on its visual row
|
|
2140
|
-
if (startVisualPos.row !== endVisualPos.row) {
|
|
2141
|
-
// Event spans visual rows - for now, only render the start portion
|
|
2142
|
-
// TODO: Split into multiple segments across visual rows
|
|
2143
|
-
}
|
|
2144
|
-
|
|
2145
|
-
let yStart: number;
|
|
2146
|
-
let yEnd: number;
|
|
2147
|
-
|
|
2148
|
-
if (showTimeScale && !allDay) {
|
|
2149
|
-
const dayStartTime = new Date(week.days[startDayIndex]!).setHours(
|
|
2150
|
-
0,
|
|
2151
|
-
0,
|
|
2152
|
-
0,
|
|
2153
|
-
0,
|
|
2154
|
-
);
|
|
2155
|
-
const dayEndTime = new Date(week.days[endDayIndex]!).setHours(
|
|
2156
|
-
23,
|
|
2157
|
-
59,
|
|
2158
|
-
59,
|
|
2159
|
-
999,
|
|
2160
|
-
);
|
|
2161
|
-
const eventStartTime = event.start.getTime();
|
|
2162
|
-
const eventEndTime = event.end.getTime() - 60000; // Subtract 1 minute for rendering
|
|
2163
|
-
|
|
2164
|
-
const effectiveStartTime = Math.max(eventStartTime, dayStartTime);
|
|
2165
|
-
const effectiveEndTime = Math.min(eventEndTime, dayEndTime);
|
|
2166
|
-
|
|
2167
|
-
const effectiveStart = new Date(effectiveStartTime);
|
|
2168
|
-
const effectiveEnd = new Date(effectiveEndTime);
|
|
2169
|
-
|
|
2170
|
-
const startMinutes =
|
|
2171
|
-
effectiveStart.getHours() * 60 + effectiveStart.getMinutes();
|
|
2172
|
-
const endMinutes =
|
|
2173
|
-
effectiveEnd.getHours() * 60 + effectiveEnd.getMinutes();
|
|
2174
|
-
|
|
2175
|
-
// Position within the visual row
|
|
2176
|
-
const visualRowY = weekYOffset + startVisualPos.row * this.dayHeight;
|
|
2177
|
-
yStart = visualRowY + (startMinutes / 1440) * this.dayHeight;
|
|
2178
|
-
yEnd = visualRowY + (endMinutes / 1440) * this.dayHeight;
|
|
2179
|
-
} else {
|
|
2180
|
-
const eventKey = `${weekIndex}-${event.id}`;
|
|
2181
|
-
let rowIndex = eventRowIndex.get(eventKey);
|
|
2182
|
-
|
|
2183
|
-
if (rowIndex === undefined) {
|
|
2184
|
-
rowIndex = 0;
|
|
2185
|
-
while (true) {
|
|
2186
|
-
let rowFree = true;
|
|
2187
|
-
for (let d = startDayIndex; d <= endDayIndex; d++) {
|
|
2188
|
-
const dayKey = `${weekIndex}-${d}`;
|
|
2189
|
-
const occupied = dayOccupiedRows.get(dayKey);
|
|
2190
|
-
if (occupied?.has(rowIndex)) {
|
|
2191
|
-
rowFree = false;
|
|
2192
|
-
break;
|
|
2193
|
-
}
|
|
2194
|
-
}
|
|
2195
|
-
if (rowFree) break;
|
|
2196
|
-
rowIndex++;
|
|
2197
|
-
}
|
|
2198
|
-
eventRowIndex.set(eventKey, rowIndex);
|
|
2199
|
-
}
|
|
2200
|
-
|
|
2201
|
-
for (let d = startDayIndex; d <= endDayIndex; d++) {
|
|
2202
|
-
const dayKey = `${weekIndex}-${d}`;
|
|
2203
|
-
let occupied = dayOccupiedRows.get(dayKey);
|
|
2204
|
-
if (!occupied) {
|
|
2205
|
-
occupied = new Set();
|
|
2206
|
-
dayOccupiedRows.set(dayKey, occupied);
|
|
2207
|
-
}
|
|
2208
|
-
occupied.add(rowIndex);
|
|
2209
|
-
}
|
|
2210
|
-
|
|
2211
|
-
const maxEventsInRow = Math.floor(
|
|
2212
|
-
(this.dayHeight - 4) / (MIN_EVENT_HEIGHT + 2),
|
|
2213
|
-
);
|
|
2214
|
-
if (rowIndex >= maxEventsInRow) continue;
|
|
2215
|
-
|
|
2216
|
-
// Position within the visual row
|
|
2217
|
-
const visualRowY = weekYOffset + startVisualPos.row * this.dayHeight;
|
|
2218
|
-
yStart = visualRowY + 4 + rowIndex * (MIN_EVENT_HEIGHT + 2);
|
|
2219
|
-
yEnd = yStart + MIN_EVENT_HEIGHT;
|
|
2220
|
-
}
|
|
2221
|
-
|
|
2222
|
-
const eventHeight = Math.max(
|
|
2223
|
-
showTimeScale ? 4 : MIN_EVENT_HEIGHT,
|
|
2224
|
-
yEnd - yStart,
|
|
2225
|
-
);
|
|
2226
|
-
|
|
2227
|
-
// Calculate horizontal position with column layout for time-scaled events
|
|
2228
|
-
let x: number;
|
|
2229
|
-
let spanWidth: number;
|
|
2230
|
-
|
|
2231
|
-
const segmentKey = `${weekIndex}-${event.id}-${startDayIndex}`;
|
|
2232
|
-
const columnLayout = segmentColumns.get(segmentKey);
|
|
2233
|
-
|
|
2234
|
-
if (
|
|
2235
|
-
showTimeScale &&
|
|
2236
|
-
!allDay &&
|
|
2237
|
-
columnLayout &&
|
|
2238
|
-
columnLayout.totalColumns > 1
|
|
2239
|
-
) {
|
|
2240
|
-
// This event has parallel events - divide the day width
|
|
2241
|
-
const columnWidth = dayWidth / columnLayout.totalColumns;
|
|
2242
|
-
x =
|
|
2243
|
-
LEFT_GUTTER_WIDTH +
|
|
2244
|
-
startVisualPos.col * dayWidth +
|
|
2245
|
-
columnLayout.column * columnWidth;
|
|
2246
|
-
spanWidth = columnWidth;
|
|
2247
|
-
} else {
|
|
2248
|
-
// Normal layout - use visual column position
|
|
2249
|
-
// For multi-day events on same visual row, calculate span
|
|
2250
|
-
const colSpan = endVisualPos.col - startVisualPos.col + 1;
|
|
2251
|
-
x = LEFT_GUTTER_WIDTH + startVisualPos.col * dayWidth;
|
|
2252
|
-
spanWidth = colSpan * dayWidth;
|
|
2253
|
-
}
|
|
2254
|
-
|
|
2255
|
-
// Convert to viewport coordinates
|
|
2256
|
-
const viewportY = yStart - scrollTop;
|
|
2257
|
-
|
|
2258
|
-
// Skip if completely out of view
|
|
2259
|
-
if (viewportY + eventHeight < 0 || viewportY > height) continue;
|
|
2260
|
-
|
|
2261
|
-
const eventColor = event.color || "#888888";
|
|
2262
|
-
const isSelected = this.internal.isEventSelected(event);
|
|
2263
|
-
const isHovered = this.hoveredEventId === event.id;
|
|
2264
|
-
|
|
2265
|
-
// Apple Calendar style dimensions
|
|
2266
|
-
const padding = 2;
|
|
2267
|
-
const eventWidth = spanWidth - padding * 2;
|
|
2268
|
-
const leftBorderWidth = 3;
|
|
2269
|
-
const contentPadding = 6;
|
|
2270
|
-
|
|
2271
|
-
// Determine border radius based on span position
|
|
2272
|
-
let radiusTopLeft = 4;
|
|
2273
|
-
let radiusTopRight = 4;
|
|
2274
|
-
let radiusBottomLeft = 4;
|
|
2275
|
-
let radiusBottomRight = 4;
|
|
2276
|
-
|
|
2277
|
-
if (totalWeeks > 1) {
|
|
2278
|
-
if (isStart && !isEnd) {
|
|
2279
|
-
radiusTopRight = 0;
|
|
2280
|
-
radiusBottomRight = 0;
|
|
2281
|
-
} else if (!isStart && isEnd) {
|
|
2282
|
-
radiusTopLeft = 0;
|
|
2283
|
-
radiusBottomLeft = 0;
|
|
2284
|
-
} else if (!isStart && !isEnd) {
|
|
2285
|
-
radiusTopLeft = 0;
|
|
2286
|
-
radiusTopRight = 0;
|
|
2287
|
-
radiusBottomLeft = 0;
|
|
2288
|
-
radiusBottomRight = 0;
|
|
2289
|
-
}
|
|
2290
|
-
}
|
|
2291
|
-
|
|
2292
|
-
ctx.save();
|
|
2293
|
-
|
|
2294
|
-
// Apply transparency for read-only events
|
|
2295
|
-
if (event.readOnly) {
|
|
2296
|
-
ctx.globalAlpha = 0.5;
|
|
2297
|
-
}
|
|
2298
|
-
|
|
2299
|
-
// Draw background - solid color if selected, subtle if not
|
|
2300
|
-
const hsl = rgbToHsl(hexToRgb(eventColor));
|
|
2301
|
-
const textPrimary =
|
|
2302
|
-
getComputedStyle(this).getPropertyValue("--text-primary").trim() ||
|
|
2303
|
-
"rgba(255, 255, 255, 0.9)";
|
|
2304
|
-
const textInverse =
|
|
2305
|
-
getComputedStyle(this).getPropertyValue("--text-inverse").trim() ||
|
|
2306
|
-
"rgb(0, 0, 0)";
|
|
2307
|
-
// Increase saturation to 75% for more vibrant colors, allow higher lightness for brighter backgrounds
|
|
2308
|
-
const backgroundColor = isSelected
|
|
2309
|
-
? eventColor
|
|
2310
|
-
: `hsla(${hsl[0]}, ${Math.min(hsl[1] * 0.9, 90)}%, ${Math.min(
|
|
2311
|
-
hsl[2] + 15,
|
|
2312
|
-
40,
|
|
2313
|
-
)}%, 0.45)`;
|
|
2314
|
-
const borderColor = isSelected
|
|
2315
|
-
? eventColor
|
|
2316
|
-
: `hsla(${hsl[0]}, ${Math.min(hsl[1] * 0.9, 90)}%, ${Math.min(
|
|
2317
|
-
hsl[2] + 10,
|
|
2318
|
-
70,
|
|
2319
|
-
)}%, 1)`;
|
|
2320
|
-
|
|
2321
|
-
// Check if event should have stripes (not accepted)
|
|
2322
|
-
const useStripes = this.shouldRenderEventWithStripes(event);
|
|
2323
|
-
|
|
2324
|
-
ctx.beginPath();
|
|
2325
|
-
ctx.roundRect(x + padding, viewportY, eventWidth, eventHeight, [
|
|
2326
|
-
radiusTopLeft,
|
|
2327
|
-
radiusTopRight,
|
|
2328
|
-
radiusBottomRight,
|
|
2329
|
-
radiusBottomLeft,
|
|
2330
|
-
]);
|
|
2331
|
-
|
|
2332
|
-
// Fill with solid background first
|
|
2333
|
-
ctx.fillStyle = backgroundColor;
|
|
2334
|
-
ctx.fill();
|
|
2335
|
-
|
|
2336
|
-
// Add stripe pattern overlay if needed
|
|
2337
|
-
if (useStripes) {
|
|
2338
|
-
const patternCanvas = this.getStripePatternCanvas();
|
|
2339
|
-
if (patternCanvas) {
|
|
2340
|
-
ctx.save();
|
|
2341
|
-
|
|
2342
|
-
// Clip to the event rectangle
|
|
2343
|
-
ctx.beginPath();
|
|
2344
|
-
ctx.roundRect(x + padding, viewportY, eventWidth, eventHeight, [
|
|
2345
|
-
radiusTopLeft,
|
|
2346
|
-
radiusTopRight,
|
|
2347
|
-
radiusBottomRight,
|
|
2348
|
-
radiusBottomLeft,
|
|
2349
|
-
]);
|
|
2350
|
-
ctx.clip();
|
|
2351
|
-
|
|
2352
|
-
// Pattern size is 16px (8 * scale of 2)
|
|
2353
|
-
const patternSize = 12;
|
|
2354
|
-
|
|
2355
|
-
// Calculate starting position to align with content coordinates
|
|
2356
|
-
// Start from a position that makes the pattern appear fixed in content space
|
|
2357
|
-
const startX = Math.floor((x + padding) / patternSize) * patternSize;
|
|
2358
|
-
const startY =
|
|
2359
|
-
Math.floor(yStart / patternSize) * patternSize - scrollTop;
|
|
2360
|
-
|
|
2361
|
-
// Tile the pattern across the clipped area
|
|
2362
|
-
for (
|
|
2363
|
-
let py = startY;
|
|
2364
|
-
py < viewportY + eventHeight;
|
|
2365
|
-
py += patternSize
|
|
2366
|
-
) {
|
|
2367
|
-
for (
|
|
2368
|
-
let px = startX;
|
|
2369
|
-
px < x + padding + eventWidth;
|
|
2370
|
-
px += patternSize
|
|
2371
|
-
) {
|
|
2372
|
-
ctx.drawImage(patternCanvas, px, py, patternSize, patternSize);
|
|
2373
|
-
}
|
|
2374
|
-
}
|
|
2375
|
-
|
|
2376
|
-
ctx.restore();
|
|
2377
|
-
}
|
|
2378
|
-
}
|
|
2379
|
-
|
|
2380
|
-
// Draw colored left border (Apple Calendar style)
|
|
2381
|
-
if (!isSelected) {
|
|
2382
|
-
ctx.fillStyle = borderColor;
|
|
2383
|
-
ctx.beginPath();
|
|
2384
|
-
ctx.roundRect(
|
|
2385
|
-
x + padding + 2,
|
|
2386
|
-
viewportY + 2,
|
|
2387
|
-
leftBorderWidth,
|
|
2388
|
-
eventHeight - 4,
|
|
2389
|
-
[radiusTopLeft, radiusTopLeft, radiusBottomLeft, radiusBottomLeft],
|
|
2390
|
-
);
|
|
2391
|
-
ctx.fill();
|
|
2392
|
-
}
|
|
2393
|
-
|
|
2394
|
-
// Draw selection border - visible outline for selected events
|
|
2395
|
-
if (isSelected) {
|
|
2396
|
-
ctx.strokeStyle = textInverse;
|
|
2397
|
-
ctx.lineWidth = 1;
|
|
2398
|
-
ctx.beginPath();
|
|
2399
|
-
ctx.roundRect(x + padding, viewportY, eventWidth, eventHeight, [
|
|
2400
|
-
radiusTopLeft,
|
|
2401
|
-
radiusTopRight,
|
|
2402
|
-
radiusBottomRight,
|
|
2403
|
-
radiusBottomLeft,
|
|
2404
|
-
]);
|
|
2405
|
-
ctx.stroke();
|
|
2406
|
-
}
|
|
2407
|
-
|
|
2408
|
-
// Draw hover outline
|
|
2409
|
-
if (isHovered && !isSelected) {
|
|
2410
|
-
ctx.strokeStyle =
|
|
2411
|
-
getComputedStyle(this)
|
|
2412
|
-
.getPropertyValue("--grid-color-hover")
|
|
2413
|
-
.trim() || "rgba(255, 255, 255, 0.2)";
|
|
2414
|
-
ctx.lineWidth = 1;
|
|
2415
|
-
ctx.beginPath();
|
|
2416
|
-
ctx.roundRect(x + padding, viewportY, eventWidth, eventHeight, [
|
|
2417
|
-
radiusTopLeft,
|
|
2418
|
-
radiusTopRight,
|
|
2419
|
-
radiusBottomRight,
|
|
2420
|
-
radiusBottomLeft,
|
|
2421
|
-
]);
|
|
2422
|
-
ctx.stroke();
|
|
2423
|
-
}
|
|
2424
|
-
|
|
2425
|
-
// Draw text if this is the start segment and there's enough space
|
|
2426
|
-
if (isStart && eventHeight >= 16) {
|
|
2427
|
-
// Use inverse text color for selected events (for contrast against solid color)
|
|
2428
|
-
// Use event color or primary text color for unselected events
|
|
2429
|
-
const textColor = isSelected
|
|
2430
|
-
? textInverse || "white"
|
|
2431
|
-
: textPrimary || eventColor;
|
|
2432
|
-
ctx.fillStyle = textColor;
|
|
2433
|
-
ctx.font = `11px ${fontFamily}`;
|
|
2434
|
-
ctx.textAlign = "left";
|
|
2435
|
-
ctx.textBaseline = "top";
|
|
2436
|
-
|
|
2437
|
-
const textX = x + padding + leftBorderWidth + contentPadding + 1;
|
|
2438
|
-
const textY = viewportY + 6;
|
|
2439
|
-
let maxTextWidth = eventWidth - leftBorderWidth - contentPadding - 4;
|
|
2440
|
-
|
|
2441
|
-
// Clip text to event bounds
|
|
2442
|
-
ctx.save();
|
|
2443
|
-
ctx.beginPath();
|
|
2444
|
-
ctx.rect(
|
|
2445
|
-
x + padding + leftBorderWidth,
|
|
2446
|
-
viewportY,
|
|
2447
|
-
eventWidth - leftBorderWidth,
|
|
2448
|
-
eventHeight,
|
|
2449
|
-
);
|
|
2450
|
-
ctx.clip();
|
|
2451
|
-
|
|
2452
|
-
// Draw recurring icon if event has rrule
|
|
2453
|
-
if (event.rrule) {
|
|
2454
|
-
ctx.font = `11px ${fontFamily}`;
|
|
2455
|
-
const recurIcon = "⟳";
|
|
2456
|
-
const iconWidth = ctx.measureText(recurIcon).width;
|
|
2457
|
-
ctx.fillText(recurIcon, textX, textY);
|
|
2458
|
-
maxTextWidth -= iconWidth + 4;
|
|
2459
|
-
}
|
|
2460
|
-
|
|
2461
|
-
// Draw title with ellipsis if needed
|
|
2462
|
-
const titleStartX =
|
|
2463
|
-
textX + (event.rrule ? ctx.measureText("⟳").width + 4 : 0);
|
|
2464
|
-
let displayTitle = event.title;
|
|
2465
|
-
ctx.font = `11px ${fontFamily}`;
|
|
2466
|
-
const titleWidth = ctx.measureText(displayTitle).width;
|
|
2467
|
-
|
|
2468
|
-
if (titleWidth > maxTextWidth) {
|
|
2469
|
-
const ellipsis = "…";
|
|
2470
|
-
const ellipsisWidth = ctx.measureText(ellipsis).width;
|
|
2471
|
-
|
|
2472
|
-
// Binary search for the right length
|
|
2473
|
-
let left = 0;
|
|
2474
|
-
let right = displayTitle.length;
|
|
2475
|
-
let bestFit = 0;
|
|
2476
|
-
|
|
2477
|
-
while (left <= right) {
|
|
2478
|
-
const mid = Math.floor((left + right) / 2);
|
|
2479
|
-
const testText = displayTitle.substring(0, mid);
|
|
2480
|
-
const testWidth = ctx.measureText(testText).width + ellipsisWidth;
|
|
2481
|
-
|
|
2482
|
-
if (testWidth <= maxTextWidth) {
|
|
2483
|
-
bestFit = mid;
|
|
2484
|
-
left = mid + 1;
|
|
2485
|
-
} else {
|
|
2486
|
-
right = mid - 1;
|
|
2487
|
-
}
|
|
2488
|
-
}
|
|
2489
|
-
|
|
2490
|
-
displayTitle = displayTitle.substring(0, bestFit) + ellipsis;
|
|
2491
|
-
}
|
|
2492
|
-
|
|
2493
|
-
ctx.fillText(displayTitle, titleStartX, textY);
|
|
2494
|
-
|
|
2495
|
-
// Draw time if there's enough space (at least 32px for title + time)
|
|
2496
|
-
if (eventHeight >= 32) {
|
|
2497
|
-
const formatTime = (date: Date) => {
|
|
2498
|
-
const hours = date.getHours();
|
|
2499
|
-
const minutes = date.getMinutes();
|
|
2500
|
-
const ampm = hours >= 12 ? "PM" : "AM";
|
|
2501
|
-
const displayHours = hours % 12 || 12;
|
|
2502
|
-
return `${displayHours}:${minutes
|
|
2503
|
-
.toString()
|
|
2504
|
-
.padStart(2, "0")} ${ampm}`;
|
|
2505
|
-
};
|
|
2506
|
-
|
|
2507
|
-
const startTime = formatTime(event.start);
|
|
2508
|
-
const endTime = formatTime(event.end);
|
|
2509
|
-
const timeText = `${startTime} – ${endTime}`;
|
|
2510
|
-
|
|
2511
|
-
// Use inverse text color for selected events, primary text or event color for unselected
|
|
2512
|
-
const timeTextColor = isSelected
|
|
2513
|
-
? textInverse || "white"
|
|
2514
|
-
: textPrimary || eventColor;
|
|
2515
|
-
ctx.fillStyle = timeTextColor;
|
|
2516
|
-
ctx.font = `10px ${fontFamily}`;
|
|
2517
|
-
|
|
2518
|
-
// Draw time with ellipsis if needed
|
|
2519
|
-
let displayTime = timeText;
|
|
2520
|
-
let timeWidth = ctx.measureText(displayTime).width;
|
|
2521
|
-
|
|
2522
|
-
if (timeWidth > maxTextWidth) {
|
|
2523
|
-
const ellipsis = "…";
|
|
2524
|
-
const ellipsisWidth = ctx.measureText(ellipsis).width;
|
|
2525
|
-
|
|
2526
|
-
let left = 0;
|
|
2527
|
-
let right = displayTime.length;
|
|
2528
|
-
let bestFit = 0;
|
|
2529
|
-
|
|
2530
|
-
while (left <= right) {
|
|
2531
|
-
const mid = Math.floor((left + right) / 2);
|
|
2532
|
-
const testText = displayTime.substring(0, mid);
|
|
2533
|
-
const testWidth = ctx.measureText(testText).width + ellipsisWidth;
|
|
2534
|
-
|
|
2535
|
-
if (testWidth <= maxTextWidth) {
|
|
2536
|
-
bestFit = mid;
|
|
2537
|
-
left = mid + 1;
|
|
2538
|
-
} else {
|
|
2539
|
-
right = mid - 1;
|
|
2540
|
-
}
|
|
2541
|
-
}
|
|
2542
|
-
|
|
2543
|
-
displayTime = displayTime.substring(0, bestFit) + ellipsis;
|
|
2544
|
-
}
|
|
2545
|
-
|
|
2546
|
-
ctx.fillText(displayTime, textX, textY + 14);
|
|
2547
|
-
}
|
|
2548
|
-
|
|
2549
|
-
ctx.restore();
|
|
2550
|
-
}
|
|
2551
|
-
|
|
2552
|
-
ctx.restore();
|
|
2553
|
-
|
|
2554
|
-
// Store rect for hit testing (in content coordinates)
|
|
2555
|
-
this.eventRects.push({
|
|
2556
|
-
event,
|
|
2557
|
-
x: x + padding,
|
|
2558
|
-
y: yStart,
|
|
2559
|
-
width: eventWidth,
|
|
2560
|
-
height: eventHeight,
|
|
2561
|
-
});
|
|
2562
|
-
}
|
|
2563
|
-
|
|
2564
|
-
// Render month labels
|
|
2565
|
-
const monthNames = [
|
|
2566
|
-
"January",
|
|
2567
|
-
"February",
|
|
2568
|
-
"March",
|
|
2569
|
-
"April",
|
|
2570
|
-
"May",
|
|
2571
|
-
"June",
|
|
2572
|
-
"July",
|
|
2573
|
-
"August",
|
|
2574
|
-
"September",
|
|
2575
|
-
"October",
|
|
2576
|
-
"November",
|
|
2577
|
-
"December",
|
|
2578
|
-
];
|
|
2579
|
-
|
|
2580
|
-
const monthBoundaries: {
|
|
2581
|
-
monthKey: string;
|
|
2582
|
-
monthName: string;
|
|
2583
|
-
year: number;
|
|
2584
|
-
yOffset: number;
|
|
2585
|
-
}[] = [];
|
|
2586
|
-
const seenMonths = new Set<string>();
|
|
2587
|
-
|
|
2588
|
-
for (const week of visibleWeeks) {
|
|
2589
|
-
const firstDay = week.days[0];
|
|
2590
|
-
if (!firstDay) continue;
|
|
2591
|
-
|
|
2592
|
-
const monthIndex = firstDay.getMonth();
|
|
2593
|
-
const year = firstDay.getFullYear();
|
|
2594
|
-
const monthKey = `${monthIndex}-${year}`;
|
|
2595
|
-
|
|
2596
|
-
if (!seenMonths.has(monthKey)) {
|
|
2597
|
-
seenMonths.add(monthKey);
|
|
2598
|
-
const monthName = monthNames[monthIndex];
|
|
2599
|
-
if (monthName) {
|
|
2600
|
-
monthBoundaries.push({
|
|
2601
|
-
monthKey,
|
|
2602
|
-
monthName,
|
|
2603
|
-
year,
|
|
2604
|
-
yOffset: week.yOffset,
|
|
2605
|
-
});
|
|
2606
|
-
}
|
|
2607
|
-
}
|
|
2608
|
-
}
|
|
2609
|
-
|
|
2610
|
-
for (let i = 0; i < monthBoundaries.length; i++) {
|
|
2611
|
-
const month = monthBoundaries[i]!;
|
|
2612
|
-
const nextMonth = monthBoundaries[i + 1];
|
|
2613
|
-
const labelY = month.yOffset;
|
|
2614
|
-
const nextMonthY = nextMonth ? nextMonth.yOffset : this.totalHeight;
|
|
2615
|
-
|
|
2616
|
-
if (nextMonthY < scrollTop) continue;
|
|
2617
|
-
if (labelY > viewportBottom) break;
|
|
2618
|
-
|
|
2619
|
-
const padding = [12, 0, 0, 12];
|
|
2620
|
-
|
|
2621
|
-
const stickyTop = Math.max(0, scrollTop - labelY);
|
|
2622
|
-
const maxStickyTop = nextMonthY - labelY - 24;
|
|
2623
|
-
const clampedStickyTop = Math.min(stickyTop, maxStickyTop);
|
|
2624
|
-
const labelTopMargin = 32;
|
|
2625
|
-
const finalTop = labelY + clampedStickyTop - scrollTop + labelTopMargin;
|
|
2626
|
-
|
|
2627
|
-
ctx.save();
|
|
2628
|
-
ctx.font = `bold 18px ${fontFamily}`;
|
|
2629
|
-
ctx.textAlign = "left";
|
|
2630
|
-
ctx.textBaseline = "top";
|
|
2631
|
-
|
|
2632
|
-
const labelText = `${month.monthName} ${month.year}`;
|
|
2633
|
-
const textWidth = ctx.measureText(labelText).width;
|
|
2634
|
-
const leftMargin = 8;
|
|
2635
|
-
const textX = 64 + padding[3] + leftMargin;
|
|
2636
|
-
const textY = finalTop + padding[0];
|
|
2637
|
-
|
|
2638
|
-
// Draw background
|
|
2639
|
-
const bgPaddingLeft = 8;
|
|
2640
|
-
const bgPaddingRight = 8;
|
|
2641
|
-
const bgElevated =
|
|
2642
|
-
getComputedStyle(this).getPropertyValue("--bg-elevated").trim() ||
|
|
2643
|
-
"rgba(0, 0, 0, 0.7)";
|
|
2644
|
-
ctx.fillStyle = bgElevated;
|
|
2645
|
-
ctx.beginPath();
|
|
2646
|
-
ctx.roundRect(
|
|
2647
|
-
textX - bgPaddingLeft,
|
|
2648
|
-
textY - 4,
|
|
2649
|
-
textWidth + bgPaddingLeft + bgPaddingRight,
|
|
2650
|
-
26,
|
|
2651
|
-
6,
|
|
2652
|
-
);
|
|
2653
|
-
ctx.fill();
|
|
2654
|
-
|
|
2655
|
-
// Draw text
|
|
2656
|
-
const textPrimary =
|
|
2657
|
-
getComputedStyle(this).getPropertyValue("--text-primary").trim() ||
|
|
2658
|
-
"rgba(255, 255, 255, 0.95)";
|
|
2659
|
-
ctx.fillStyle = textPrimary;
|
|
2660
|
-
ctx.fillText(labelText, textX, textY);
|
|
2661
|
-
ctx.restore();
|
|
2662
|
-
}
|
|
1790
|
+
toggleLayer(name: string): void {
|
|
1791
|
+
const layer = this.layers.find((l) => l.name === name);
|
|
1792
|
+
if (!layer) throw new Error(`Layer "${name}" not found`);
|
|
1793
|
+
layer.enabled = !layer.enabled;
|
|
1794
|
+
this.renderCanvas();
|
|
2663
1795
|
}
|
|
2664
1796
|
|
|
1797
|
+
|
|
2665
1798
|
onWheel = (e: WheelEvent): void => {
|
|
2666
1799
|
if (this.hasAttribute("scroll-lock")) return;
|
|
1800
|
+
this.lastWheelTime = Date.now();
|
|
2667
1801
|
|
|
2668
1802
|
if (this.scrollAnimationFrame) {
|
|
2669
1803
|
this.cancelScrollAnimation();
|
|
@@ -3051,9 +2185,10 @@ export class CalendarViewElement extends LitElement {
|
|
|
3051
2185
|
snappedEnd.setTime(snappedStart.getTime() + 15 * 60 * 1000);
|
|
3052
2186
|
}
|
|
3053
2187
|
|
|
2188
|
+
const isZoomedOut = this.dayHeight < TIME_SCALE_DAY_HEIGHT;
|
|
3054
2189
|
this.dispatchEvent(
|
|
3055
2190
|
new CustomEvent("create-event", {
|
|
3056
|
-
detail: { start: snappedStart, end: snappedEnd },
|
|
2191
|
+
detail: { start: snappedStart, end: snappedEnd, isAllDay: isZoomedOut },
|
|
3057
2192
|
bubbles: true,
|
|
3058
2193
|
}),
|
|
3059
2194
|
);
|
|
@@ -3241,16 +2376,16 @@ export class CalendarViewElement extends LitElement {
|
|
|
3241
2376
|
// Update cursor position for status bar
|
|
3242
2377
|
this.cursorPosition = { x, y };
|
|
3243
2378
|
|
|
3244
|
-
// Check for resize handles first
|
|
3245
|
-
const resizeHandle = this.getResizeHandle(x, y);
|
|
2379
|
+
// Check for resize handles first — suppressed when alt key is active
|
|
2380
|
+
const resizeHandle = !e.altKey ? this.getResizeHandle(x, y) : null;
|
|
3246
2381
|
if (resizeHandle && !this.isResizingEvent && !this.isDraggingEvent) {
|
|
3247
2382
|
this.scrollContainer.style.cursor = "ns-resize";
|
|
3248
2383
|
} else if (!this.isResizingEvent && !this.isDraggingEvent) {
|
|
3249
2384
|
this.scrollContainer.style.cursor = "";
|
|
3250
2385
|
}
|
|
3251
2386
|
|
|
3252
|
-
// Check for event hover
|
|
3253
|
-
const hoveredEvent = this.getEventAtPosition(x, y);
|
|
2387
|
+
// Check for event hover — suppressed when alt key is active
|
|
2388
|
+
const hoveredEvent = !e.altKey ? this.getEventAtPosition(x, y) : null;
|
|
3254
2389
|
const newHoveredId = hoveredEvent ? hoveredEvent.id : null;
|
|
3255
2390
|
|
|
3256
2391
|
if (newHoveredId !== this.hoveredEventId) {
|
|
@@ -3408,6 +2543,31 @@ export class CalendarViewElement extends LitElement {
|
|
|
3408
2543
|
}
|
|
3409
2544
|
};
|
|
3410
2545
|
|
|
2546
|
+
onKeyDown = (e: KeyboardEvent): void => {
|
|
2547
|
+
const focused = e.composedPath()[0] as HTMLElement;
|
|
2548
|
+
if (focused?.tagName === "INPUT" || focused?.tagName === "TEXTAREA" || focused?.isContentEditable) return;
|
|
2549
|
+
if (e.altKey && !this.altKeyActive) {
|
|
2550
|
+
this.altKeyActive = true;
|
|
2551
|
+
this.dispatchEvent(new CustomEvent("meta-key-change", { bubbles: true }));
|
|
2552
|
+
if (this.scrollContainer) {
|
|
2553
|
+
this.scrollContainer.style.cursor = "";
|
|
2554
|
+
}
|
|
2555
|
+
if (this.hoveredEventId !== null) {
|
|
2556
|
+
this.hoveredEventId = null;
|
|
2557
|
+
this.renderCanvas();
|
|
2558
|
+
}
|
|
2559
|
+
}
|
|
2560
|
+
};
|
|
2561
|
+
|
|
2562
|
+
onKeyUp = (e: KeyboardEvent): void => {
|
|
2563
|
+
const focused = e.composedPath()[0] as HTMLElement;
|
|
2564
|
+
if (focused?.tagName === "INPUT" || focused?.tagName === "TEXTAREA" || focused?.isContentEditable) return;
|
|
2565
|
+
if (!e.altKey && this.altKeyActive) {
|
|
2566
|
+
this.altKeyActive = false;
|
|
2567
|
+
this.dispatchEvent(new CustomEvent("meta-key-change", { bubbles: true }));
|
|
2568
|
+
}
|
|
2569
|
+
};
|
|
2570
|
+
|
|
3411
2571
|
onPaste = async (e: ClipboardEvent): Promise<void> => {
|
|
3412
2572
|
const files = e.clipboardData?.files;
|
|
3413
2573
|
if (files && files.length > 0) {
|
|
@@ -3653,13 +2813,13 @@ export class CalendarViewElement extends LitElement {
|
|
|
3653
2813
|
*
|
|
3654
2814
|
* Selection box is 2D with different behavior based on zoom level:
|
|
3655
2815
|
*
|
|
3656
|
-
* ZOOMED OUT (dayHeight <
|
|
2816
|
+
* ZOOMED OUT (dayHeight < TIME_SCALE_DAY_HEIGHT):
|
|
3657
2817
|
* - X axis: days of the week (horizontal)
|
|
3658
2818
|
* - Y axis: multiple weeks stacked (vertical)
|
|
3659
2819
|
* - Creates time ranges per WEEK for selected day range
|
|
3660
2820
|
* - Example: Wed-Thu across 3 weeks = 3 ranges (one per week)
|
|
3661
2821
|
*
|
|
3662
|
-
* ZOOMED IN (dayHeight >=
|
|
2822
|
+
* ZOOMED IN (dayHeight >= TIME_SCALE_DAY_HEIGHT, time scale visible):
|
|
3663
2823
|
* - X axis: days of the week (horizontal)
|
|
3664
2824
|
* - Y axis: time within days (vertical, with weeks)
|
|
3665
2825
|
* - Creates time ranges per DAY for selected time range
|
|
@@ -3705,7 +2865,7 @@ export class CalendarViewElement extends LitElement {
|
|
|
3705
2865
|
if (intersectingWeeks.length === 0) return;
|
|
3706
2866
|
|
|
3707
2867
|
const timeRanges: Array<{ start: Date; end: Date }> = [];
|
|
3708
|
-
const isZoomedIn = this.dayHeight >=
|
|
2868
|
+
const isZoomedIn = this.dayHeight >= TIME_SCALE_DAY_HEIGHT;
|
|
3709
2869
|
|
|
3710
2870
|
if (isZoomedIn) {
|
|
3711
2871
|
// ZOOMED IN: Create time ranges per DAY (same time across multiple weeks)
|
|
@@ -3794,6 +2954,10 @@ export class CalendarViewElement extends LitElement {
|
|
|
3794
2954
|
for (const event of this.events) {
|
|
3795
2955
|
// Skip all-day events
|
|
3796
2956
|
if (event.isAllDay) continue;
|
|
2957
|
+
if (event.visualStyle === "heatmap") continue;
|
|
2958
|
+
|
|
2959
|
+
// Only select events from the active calendar
|
|
2960
|
+
if (this.activeCalendarId && event.calendarId !== this.activeCalendarId && event.sourceId !== this.activeCalendarId) continue;
|
|
3797
2961
|
|
|
3798
2962
|
const eventStartTime = event.start.getTime();
|
|
3799
2963
|
const eventEndTime = event.end.getTime();
|
|
@@ -3885,9 +3049,17 @@ export class CalendarViewElement extends LitElement {
|
|
|
3885
3049
|
const scrollTop = this.scrollTop;
|
|
3886
3050
|
const scrollBottom = scrollTop + this.viewportHeight;
|
|
3887
3051
|
|
|
3052
|
+
const nearTop = scrollTop < bufferHeight;
|
|
3053
|
+
const nearBottom = scrollBottom > this.totalHeight - bufferHeight;
|
|
3054
|
+
if (!nearTop && !nearBottom) return;
|
|
3055
|
+
|
|
3888
3056
|
// Check if near top (extending to past)
|
|
3889
|
-
if (
|
|
3057
|
+
if (nearTop) {
|
|
3890
3058
|
this.isExtendingRange = true;
|
|
3059
|
+
// Capture before extension: only compensate for wheel/touch scroll.
|
|
3060
|
+
// For scrollbar drags, skip compensation so the thumb stays where the user
|
|
3061
|
+
// put it and they see the newly loaded historical content — no treadmill.
|
|
3062
|
+
const isWheelScroll = Date.now() - this.lastWheelTime < 200;
|
|
3891
3063
|
const newWeeks = this.internal.extendRange("past");
|
|
3892
3064
|
|
|
3893
3065
|
// Prepend new weeks
|
|
@@ -3906,11 +3078,13 @@ export class CalendarViewElement extends LitElement {
|
|
|
3906
3078
|
// Must update DOM first so scroll container has correct height
|
|
3907
3079
|
this.requestUpdate();
|
|
3908
3080
|
|
|
3909
|
-
// Adjust scroll position to maintain visual position (after DOM update)
|
|
3910
3081
|
requestAnimationFrame(() => {
|
|
3911
|
-
|
|
3912
|
-
|
|
3913
|
-
this.
|
|
3082
|
+
if (isWheelScroll) {
|
|
3083
|
+
// Maintain visual position so content doesn't jump during wheel scroll
|
|
3084
|
+
this._scrollTop += addedHeight;
|
|
3085
|
+
if (this.scrollContainer) {
|
|
3086
|
+
this.scrollContainer.scrollTop = this._scrollTop;
|
|
3087
|
+
}
|
|
3914
3088
|
}
|
|
3915
3089
|
|
|
3916
3090
|
this.renderCanvas();
|
|
@@ -3921,7 +3095,7 @@ export class CalendarViewElement extends LitElement {
|
|
|
3921
3095
|
}
|
|
3922
3096
|
|
|
3923
3097
|
// Check if near bottom (extending to future)
|
|
3924
|
-
if (
|
|
3098
|
+
if (nearBottom) {
|
|
3925
3099
|
this.isExtendingRange = true;
|
|
3926
3100
|
const newWeeks = this.internal.extendRange("future");
|
|
3927
3101
|
|
|
@@ -4194,6 +3368,7 @@ export class CalendarViewElement extends LitElement {
|
|
|
4194
3368
|
fill: accent.replace("rgb", "rgba").replace(")", ", 0.3)"),
|
|
4195
3369
|
stroke: accent.replace("rgb", "rgba").replace(")", ", 0.8)"),
|
|
4196
3370
|
text: "white",
|
|
3371
|
+
dashed: false,
|
|
4197
3372
|
},
|
|
4198
3373
|
useStartEdge,
|
|
4199
3374
|
);
|
|
@@ -4206,6 +3381,7 @@ export class CalendarViewElement extends LitElement {
|
|
|
4206
3381
|
fill: `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, 0.5)`,
|
|
4207
3382
|
stroke: `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, 0.5)`,
|
|
4208
3383
|
text: "rgba(255, 255, 255, 0.5)",
|
|
3384
|
+
dashed: false,
|
|
4209
3385
|
},
|
|
4210
3386
|
useStartEdge,
|
|
4211
3387
|
);
|
|
@@ -4250,8 +3426,13 @@ export class CalendarViewElement extends LitElement {
|
|
|
4250
3426
|
later = startDate < endDate ? endDate : startDate;
|
|
4251
3427
|
}
|
|
4252
3428
|
|
|
4253
|
-
|
|
4254
|
-
|
|
3429
|
+
if (this.dayHeight < TIME_SCALE_DAY_HEIGHT) {
|
|
3430
|
+
earlier.setHours(0, 0, 0, 0);
|
|
3431
|
+
later.setHours(23, 59, 59, 999);
|
|
3432
|
+
} else {
|
|
3433
|
+
earlier.setMinutes(Math.round(earlier.getMinutes() / 15) * 15, 0, 0);
|
|
3434
|
+
later.setMinutes(Math.round(later.getMinutes() / 15) * 15, 0, 0);
|
|
3435
|
+
}
|
|
4255
3436
|
|
|
4256
3437
|
// Use active calendar color if available, otherwise fall back to accent-primary
|
|
4257
3438
|
let fill: string;
|
|
@@ -4279,7 +3460,7 @@ export class CalendarViewElement extends LitElement {
|
|
|
4279
3460
|
renderVirtualEvent(
|
|
4280
3461
|
start: Date,
|
|
4281
3462
|
end: Date,
|
|
4282
|
-
color: { fill: string; stroke: string; text: string },
|
|
3463
|
+
color: { fill: string; stroke: string; text: string; dashed?: boolean },
|
|
4283
3464
|
useStartEdge = true,
|
|
4284
3465
|
): void {
|
|
4285
3466
|
if (!this.overlayCanvas || !this.overlayCtx || !this.scrollContainer)
|
|
@@ -4329,7 +3510,7 @@ export class CalendarViewElement extends LitElement {
|
|
|
4329
3510
|
ctx.fill();
|
|
4330
3511
|
ctx.strokeStyle = color.stroke;
|
|
4331
3512
|
ctx.lineWidth = 1;
|
|
4332
|
-
ctx.setLineDash([6, 3]);
|
|
3513
|
+
if (color.dashed !== false) ctx.setLineDash([6, 3]);
|
|
4333
3514
|
ctx.stroke();
|
|
4334
3515
|
ctx.setLineDash([]);
|
|
4335
3516
|
};
|
|
@@ -4813,7 +3994,7 @@ export class CalendarViewElement extends LitElement {
|
|
|
4813
3994
|
let hour = 0;
|
|
4814
3995
|
let minute = 0;
|
|
4815
3996
|
|
|
4816
|
-
if (this.dayHeight >=
|
|
3997
|
+
if (this.dayHeight >= TIME_SCALE_DAY_HEIGHT) {
|
|
4817
3998
|
const offsetInRow = y - (week.yOffset + rowInWeek * this.dayHeight);
|
|
4818
3999
|
const minutes = Math.floor((offsetInRow / this.dayHeight) * 24 * 60);
|
|
4819
4000
|
hour = Math.floor(minutes / 60);
|
|
@@ -4897,6 +4078,7 @@ export class CalendarViewElement extends LitElement {
|
|
|
4897
4078
|
formattedCursorDate: cursorTime
|
|
4898
4079
|
? this.formatCompactDate(cursorTime.date)
|
|
4899
4080
|
: "",
|
|
4081
|
+
altKeyActive: this.altKeyActive,
|
|
4900
4082
|
};
|
|
4901
4083
|
}
|
|
4902
4084
|
|
|
@@ -5635,18 +4817,3 @@ export class CalendarViewElement extends LitElement {
|
|
|
5635
4817
|
`;
|
|
5636
4818
|
}
|
|
5637
4819
|
}
|
|
5638
|
-
|
|
5639
|
-
// Helper to get day index (0-6) within a week for a given date
|
|
5640
|
-
function getDayIndexInWeek(week: WeekInfo, date: Date) {
|
|
5641
|
-
const dateStart = new Date(date).setHours(0, 0, 0, 0);
|
|
5642
|
-
for (let i = 0; i < 7; i++) {
|
|
5643
|
-
const weekDay = week.days[i];
|
|
5644
|
-
if (weekDay && new Date(weekDay).setHours(0, 0, 0, 0) === dateStart) {
|
|
5645
|
-
return i;
|
|
5646
|
-
}
|
|
5647
|
-
}
|
|
5648
|
-
// Date is before or after this week
|
|
5649
|
-
const weekStart = new Date(week.days[0]!).setHours(0, 0, 0, 0);
|
|
5650
|
-
if (dateStart < weekStart) return 0;
|
|
5651
|
-
return 6;
|
|
5652
|
-
}
|