@luckydye/calendar 1.3.2 → 1.4.0

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.
@@ -1,33 +1,47 @@
1
- import { css, html, LitElement, render } from "lit";
1
+ import { LitElement, css, html, render } from "lit";
2
2
  import {
3
3
  type Attendee,
4
4
  type CalendarEvent,
5
5
  CalendarInternal,
6
- type WeekInfo,
7
6
  NOTIFICATION_PRESETS,
7
+ type WeekInfo,
8
8
  } from "./CalendarInternal.js";
9
+ import {
10
+ type CalendarLayer,
11
+ type LayerContext,
12
+ TIME_SCALE_DAY_HEIGHT,
13
+ } from "./CalendarLayer.js";
9
14
  import { hexToRgb, rgbToHsl } from "./Color.js";
15
+ import { sanitizeEventDescription } from "./DescriptionSanitizer.js";
16
+ import { serializeEventsToICal } from "./ICal.js";
17
+ import { IndexedDBStorage } from "./IndexedDBStorage.js";
18
+ import type { StatusBarData } from "./StatusBar.js";
19
+ import { queueStatus } from "./StatusMessage.js";
10
20
  import {
11
21
  type ThemeName,
12
22
  applyTheme,
13
- saveThemePreference,
14
- loadThemePreference,
15
23
  availableThemes,
24
+ loadThemePreference,
25
+ saveThemePreference,
16
26
  } from "./Theme.js";
17
- import { serializeEventsToICal } from "./ICal.js";
18
- import { queueStatus } from "./StatusMessage.js";
19
- import type { StatusBarData } from "./StatusBar.js";
20
- import { IndexedDBStorage } from "./IndexedDBStorage.js";
21
- import { sanitizeEventDescription } from "./DescriptionSanitizer.js";
22
- import { TIME_SCALE_DAY_HEIGHT, type CalendarLayer, type LayerContext } from "./CalendarLayer.js";
27
+ import {
28
+ type EventRect,
29
+ type EventsState,
30
+ type PreviewEventData,
31
+ createEventsLayer,
32
+ } from "./layers/EventsLayer.js";
23
33
  import { createGridLayer } from "./layers/GridLayer.js";
24
- import { createEventsLayer, type EventRect, type EventsState } from "./layers/EventsLayer.js";
25
34
  import { createTimeseriesHeatmapLayer } from "./layers/TimeseriesHeatmapLayer.js";
26
35
 
27
36
  const MIN_DAY_HEIGHT = 50;
28
37
  const MAX_DAY_HEIGHT = 3000; // 1px per minute
29
38
  const LEFT_GUTTER_WIDTH = 60;
30
39
  const MINIMAP_WIDTH = 12;
40
+ const MIN_DAY_COLUMN_WIDTH = 80;
41
+ const MAX_DAY_COLUMN_WIDTH = 320;
42
+ const DEFAULT_DAY_COLUMN_WIDTH = 120;
43
+ const TIME_SCALE_EVENT_THRESHOLD = 400;
44
+ const FILTER_CONTEXT_WEEKS = 8;
31
45
 
32
46
  export class CalendarViewElement extends LitElement {
33
47
  static styles = css`
@@ -65,7 +79,7 @@ export class CalendarViewElement extends LitElement {
65
79
  bottom: 0;
66
80
  left: 0;
67
81
  right: 0;
68
- z-index: 100;
82
+ z-index: 200;
69
83
  }
70
84
 
71
85
  .toolbar::before {
@@ -160,6 +174,12 @@ export class CalendarViewElement extends LitElement {
160
174
  gap: 8px;
161
175
  }
162
176
 
177
+ .toolbar-slider-label {
178
+ font-size: 12px;
179
+ color: var(--text-muted, rgba(255, 255, 255, 0.6));
180
+ user-select: none;
181
+ }
182
+
163
183
  .toolbar-zoom-slider {
164
184
  width: 100px;
165
185
  height: 4px;
@@ -231,10 +251,11 @@ export class CalendarViewElement extends LitElement {
231
251
  position: absolute;
232
252
  inset: 0;
233
253
  overflow-y: overlay;
234
- overflow-x: hidden;
254
+ overflow-x: auto;
235
255
  z-index: 1;
236
256
  cursor: default;
237
257
  overflow-anchor: none;
258
+ touch-action: pan-x pan-y;
238
259
  }
239
260
 
240
261
  :host([scroll-lock]) .scroll-container {
@@ -742,6 +763,17 @@ export class CalendarViewElement extends LitElement {
742
763
  return this._dayHeight;
743
764
  }
744
765
 
766
+ _minDayColumnWidth = DEFAULT_DAY_COLUMN_WIDTH;
767
+ set minDayColumnWidth(value) {
768
+ this._minDayColumnWidth = value;
769
+ this.saveMinDayColumnWidth();
770
+ this.renderCanvas();
771
+ this.requestUpdate();
772
+ }
773
+ get minDayColumnWidth() {
774
+ return this._minDayColumnWidth;
775
+ }
776
+
745
777
  _scrollTop = 0;
746
778
  set scrollTop(value) {
747
779
  this._scrollTop = value;
@@ -749,7 +781,7 @@ export class CalendarViewElement extends LitElement {
749
781
  if (!this.scrollContainer || !this.scrollContent) return;
750
782
 
751
783
  if (this.scrollContainer.scrollHeight < value) {
752
- this.scrollContent.style.minHeight = value + window.innerHeight + "px";
784
+ this.scrollContent.style.minHeight = `${value + window.innerHeight}px`;
753
785
  }
754
786
 
755
787
  this.scrollContainer.scrollTop = value;
@@ -797,10 +829,13 @@ export class CalendarViewElement extends LitElement {
797
829
 
798
830
  const progress = Math.min((currentTime - startTime) / duration, 1);
799
831
 
800
- this._dayHeight = startDayHeight + (toDayHeight - startDayHeight) * easeOutExpo(progress);
832
+ this._dayHeight =
833
+ startDayHeight +
834
+ (toDayHeight - startDayHeight) * easeOutExpo(progress);
801
835
  this.updateWeekOffsets();
802
836
 
803
- const currentFrac = startFrac + (targetFrac - startFrac) * easeOutCubic(progress);
837
+ const currentFrac =
838
+ startFrac + (targetFrac - startFrac) * easeOutCubic(progress);
804
839
  this._scrollTop = Math.max(0, currentFrac * this.totalHeight);
805
840
 
806
841
  if (this.scrollContainer) {
@@ -808,8 +843,9 @@ export class CalendarViewElement extends LitElement {
808
843
  this.scrollContent &&
809
844
  this.scrollContainer.scrollHeight < this._scrollTop
810
845
  ) {
811
- this.scrollContent.style.minHeight =
812
- this._scrollTop + window.innerHeight + "px";
846
+ this.scrollContent.style.minHeight = `${
847
+ this._scrollTop + window.innerHeight
848
+ }px`;
813
849
  }
814
850
  this.scrollContainer.scrollTop = this._scrollTop;
815
851
  }
@@ -852,6 +888,17 @@ export class CalendarViewElement extends LitElement {
852
888
  this.renderCanvas();
853
889
  }
854
890
 
891
+ syncScrollDomToState(): void {
892
+ if (!this.scrollContainer || !this.scrollContent) return;
893
+
894
+ if (this.scrollContainer.scrollHeight < this._scrollTop) {
895
+ this.scrollContent.style.minHeight = `${
896
+ this._scrollTop + window.innerHeight
897
+ }px`;
898
+ }
899
+ this.scrollContainer.scrollTop = this._scrollTop;
900
+ }
901
+
855
902
  viewportHeight = 0;
856
903
 
857
904
  currentTime = new Date();
@@ -904,8 +951,13 @@ export class CalendarViewElement extends LitElement {
904
951
  timeUpdateInterval: ReturnType<typeof setInterval> | null = null;
905
952
  isExtendingRange = false; // Prevents concurrent range extensions
906
953
  boundaryCheckInterval: ReturnType<typeof setInterval> | null = null;
954
+ wheelZoomSyncTimeout: ReturnType<typeof setTimeout> | null = null;
907
955
  lastWheelTime = 0; // Timestamp of last wheel event, used to distinguish wheel vs scrollbar drag
908
956
  isCreatingEvent = false;
957
+ isPinchZooming = false;
958
+ pinchStartDistance = 0;
959
+ pinchStartHeight = MIN_DAY_HEIGHT;
960
+ pinchStartOriginY = 0;
909
961
  eventCreationStart: { x: number; y: number } | null = null;
910
962
  eventCreationEnd: { x: number; y: number } | null = null;
911
963
  eventCreationShiftPressed = false;
@@ -926,28 +978,49 @@ export class CalendarViewElement extends LitElement {
926
978
  resizingOriginalEnd: Date | null = null;
927
979
  isResizingEvent = false;
928
980
 
929
- _columnsPerRow = 7;
930
- set columnsPerRow(value: number) {
931
- const clamped = Math.max(1, Math.min(7, Math.floor(value)));
932
- if (this._columnsPerRow !== clamped) {
933
- this._columnsPerRow = clamped;
934
- this.updateWeekOffsets();
935
- this.renderCanvas();
936
- this.requestUpdate();
937
- }
938
- }
939
981
  get columnsPerRow(): number {
940
- return this._columnsPerRow;
982
+ return 7;
941
983
  }
942
984
 
943
985
  get rowsPerWeek(): number {
944
- return Math.ceil(7 / this._columnsPerRow);
986
+ return 1;
945
987
  }
946
988
 
947
989
  getDayVisualPosition(dayIndex: number): { row: number; col: number } {
948
- const row = Math.floor(dayIndex / this._columnsPerRow);
949
- const col = dayIndex % this._columnsPerRow;
950
- return { row, col };
990
+ return { row: 0, col: dayIndex };
991
+ }
992
+
993
+ get scrollLeft(): number {
994
+ return this.scrollContainer?.scrollLeft ?? 0;
995
+ }
996
+
997
+ getViewportGridWidth(): number {
998
+ if (!this.scrollContainer) return this.columnsPerRow * this.minDayColumnWidth;
999
+ return Math.max(
1000
+ 0,
1001
+ this.scrollContainer.clientWidth - LEFT_GUTTER_WIDTH - MINIMAP_WIDTH,
1002
+ );
1003
+ }
1004
+
1005
+ getGridWidth(): number {
1006
+ return Math.max(
1007
+ this.getViewportGridWidth(),
1008
+ this.columnsPerRow * this.minDayColumnWidth,
1009
+ );
1010
+ }
1011
+
1012
+ getDayWidth(): number {
1013
+ return this.getGridWidth() / this.columnsPerRow;
1014
+ }
1015
+
1016
+ getContentWidth(): number {
1017
+ return LEFT_GUTTER_WIDTH + this.getGridWidth();
1018
+ }
1019
+
1020
+ getContentXFromClientX(clientX: number): number {
1021
+ if (!this.scrollContainer) return clientX;
1022
+ const rect = this.scrollContainer.getBoundingClientRect();
1023
+ return clientX - rect.left + this.scrollLeft;
951
1024
  }
952
1025
 
953
1026
  getVisualPositionFromCoords(
@@ -957,9 +1030,9 @@ export class CalendarViewElement extends LitElement {
957
1030
  ): { dayIndex: number; timeFraction: number; weekYOffset: number } | null {
958
1031
  if (!this.scrollContainer) return null;
959
1032
 
960
- const dayWidth = gridWidth / this._columnsPerRow;
1033
+ const dayWidth = gridWidth / this.columnsPerRow;
961
1034
  const col = Math.floor((x - LEFT_GUTTER_WIDTH) / dayWidth);
962
- if (col < 0 || col >= this._columnsPerRow) return null;
1035
+ if (col < 0 || col >= this.columnsPerRow) return null;
963
1036
 
964
1037
  const week = this.weeks.find(
965
1038
  (w) => w.height > 0 && y >= w.yOffset && y < w.yOffset + w.height,
@@ -968,7 +1041,7 @@ export class CalendarViewElement extends LitElement {
968
1041
 
969
1042
  const rowHeight = this.dayHeight;
970
1043
  const row = Math.floor((y - week.yOffset) / rowHeight);
971
- const dayIndex = row * this._columnsPerRow + col;
1044
+ const dayIndex = row * this.columnsPerRow + col;
972
1045
  if (dayIndex < 0 || dayIndex > 6) return null;
973
1046
 
974
1047
  const timeFraction = ((y - week.yOffset) % rowHeight) / rowHeight;
@@ -1056,7 +1129,7 @@ export class CalendarViewElement extends LitElement {
1056
1129
  if (saved) {
1057
1130
  return Math.max(
1058
1131
  MIN_DAY_HEIGHT,
1059
- Math.min(MAX_DAY_HEIGHT, parseFloat(saved)),
1132
+ Math.min(MAX_DAY_HEIGHT, Number.parseFloat(saved)),
1060
1133
  );
1061
1134
  }
1062
1135
 
@@ -1067,6 +1140,25 @@ export class CalendarViewElement extends LitElement {
1067
1140
  localStorage.setItem("calendar-dayHeight", this.dayHeight.toString());
1068
1141
  }
1069
1142
 
1143
+ loadMinDayColumnWidth(): number {
1144
+ const saved = localStorage.getItem("calendar-minDayColumnWidth");
1145
+ if (saved) {
1146
+ return Math.max(
1147
+ MIN_DAY_COLUMN_WIDTH,
1148
+ Math.min(MAX_DAY_COLUMN_WIDTH, Number.parseFloat(saved)),
1149
+ );
1150
+ }
1151
+
1152
+ return DEFAULT_DAY_COLUMN_WIDTH;
1153
+ }
1154
+
1155
+ saveMinDayColumnWidth(): void {
1156
+ localStorage.setItem(
1157
+ "calendar-minDayColumnWidth",
1158
+ this.minDayColumnWidth.toString(),
1159
+ );
1160
+ }
1161
+
1070
1162
  saveScrollPosition(): void {
1071
1163
  // Save the date at the center of the viewport instead of pixel offset
1072
1164
  const centerY = this.scrollTop + this.viewportHeight / 2;
@@ -1085,6 +1177,90 @@ export class CalendarViewElement extends LitElement {
1085
1177
  }
1086
1178
  }
1087
1179
 
1180
+ renderMonthLabels(
1181
+ ctx: CanvasRenderingContext2D,
1182
+ width: number,
1183
+ height: number,
1184
+ fontFamily: string,
1185
+ ): void {
1186
+ const monthNames = [
1187
+ "January",
1188
+ "February",
1189
+ "March",
1190
+ "April",
1191
+ "May",
1192
+ "June",
1193
+ "July",
1194
+ "August",
1195
+ "September",
1196
+ "October",
1197
+ "November",
1198
+ "December",
1199
+ ];
1200
+ const monthBoundaries: Array<{
1201
+ monthName: string;
1202
+ year: number;
1203
+ yOffset: number;
1204
+ }> = [];
1205
+ let previousMonthKey: string | null = null;
1206
+
1207
+ for (const week of this.weeks) {
1208
+ if (week.height === 0) continue;
1209
+ if (week.yOffset + week.height < this.scrollTop) continue;
1210
+ if (week.yOffset > this.scrollTop + this.viewportHeight) break;
1211
+
1212
+ for (const day of week.days) {
1213
+ if (!day) continue;
1214
+ const monthIndex = day.getMonth();
1215
+ const year = day.getFullYear();
1216
+ const monthKey = `${monthIndex}-${year}`;
1217
+ if (monthKey === previousMonthKey) continue;
1218
+
1219
+ previousMonthKey = monthKey;
1220
+ const monthName = monthNames[monthIndex];
1221
+ if (!monthName) continue;
1222
+ monthBoundaries.push({ monthName, year, yOffset: week.yOffset });
1223
+ }
1224
+ }
1225
+
1226
+ const bgPrimary =
1227
+ getComputedStyle(this).getPropertyValue("--bg-primary").trim() ||
1228
+ "rgba(30, 30, 30, 1)";
1229
+ const monthTextPrimary =
1230
+ getComputedStyle(this).getPropertyValue("--text-primary").trim() ||
1231
+ "rgba(255, 255, 255, 0.95)";
1232
+
1233
+ for (let i = 0; i < monthBoundaries.length; i++) {
1234
+ const month = monthBoundaries[i];
1235
+ if (!month) continue;
1236
+ const nextMonth = monthBoundaries[i + 1];
1237
+ const nextMonthY = nextMonth
1238
+ ? nextMonth.yOffset
1239
+ : Number.POSITIVE_INFINITY;
1240
+
1241
+ if (nextMonthY < this.scrollTop) continue;
1242
+ if (month.yOffset > this.scrollTop + this.viewportHeight) break;
1243
+
1244
+ const stickyTop = Math.max(0, this.scrollTop - month.yOffset);
1245
+ const maxStickyTop = nextMonthY - month.yOffset - 24;
1246
+ const clampedStickyTop = Math.min(stickyTop, maxStickyTop);
1247
+ const finalTop = month.yOffset + clampedStickyTop - this.scrollTop;
1248
+ const isSticky = clampedStickyTop > 0;
1249
+
1250
+ ctx.save();
1251
+ ctx.globalAlpha = isSticky ? 1 : 0.5;
1252
+ ctx.fillStyle = bgPrimary;
1253
+ ctx.fillRect(0, finalTop, width, 40);
1254
+ ctx.globalAlpha = 1;
1255
+ ctx.font = `bold 18px ${fontFamily}`;
1256
+ ctx.textAlign = "left";
1257
+ ctx.textBaseline = "top";
1258
+ ctx.fillStyle = monthTextPrimary;
1259
+ ctx.fillText(`${month.monthName} ${month.year}`, 20, finalTop + 10);
1260
+ ctx.restore();
1261
+ }
1262
+ }
1263
+
1088
1264
  loadScrollPosition(): void {
1089
1265
  const saved = localStorage.getItem("calendar-scrollDate");
1090
1266
  if (saved) {
@@ -1118,9 +1294,8 @@ export class CalendarViewElement extends LitElement {
1118
1294
  CalendarInternal.isSameDay(d, now),
1119
1295
  );
1120
1296
  if (todayIndex >= 0) {
1121
- const row = Math.floor(todayIndex / this._columnsPerRow);
1122
1297
  const timeFraction = (now.getHours() + now.getMinutes() / 60) / 24;
1123
- offsetInWeek = (row + timeFraction) / this.rowsPerWeek;
1298
+ offsetInWeek = timeFraction;
1124
1299
  }
1125
1300
  }
1126
1301
 
@@ -1347,8 +1522,17 @@ export class CalendarViewElement extends LitElement {
1347
1522
  window.removeEventListener("paste", this.onPaste);
1348
1523
  window.removeEventListener("keydown", this.onKeyDown);
1349
1524
  window.removeEventListener("keyup", this.onKeyUp);
1525
+ if (this.wheelZoomSyncTimeout) {
1526
+ clearTimeout(this.wheelZoomSyncTimeout);
1527
+ this.wheelZoomSyncTimeout = null;
1528
+ }
1350
1529
 
1351
1530
  if (this.scrollContainer) {
1531
+ this.scrollContainer.removeEventListener("scroll", this.onScroll);
1532
+ this.scrollContainer.removeEventListener("touchstart", this.onTouchStart);
1533
+ this.scrollContainer.removeEventListener("touchmove", this.onTouchMove);
1534
+ this.scrollContainer.removeEventListener("touchend", this.onTouchEnd);
1535
+ this.scrollContainer.removeEventListener("touchcancel", this.onTouchEnd);
1352
1536
  this.scrollContainer.removeEventListener(
1353
1537
  "mouseleave",
1354
1538
  this.onScrollContainerMouseLeave,
@@ -1401,7 +1585,9 @@ export class CalendarViewElement extends LitElement {
1401
1585
  }
1402
1586
 
1403
1587
  private requestDescriptionSummary(event: CalendarEvent): void {
1404
- const description = sanitizeEventDescription(event.description ?? "").trim();
1588
+ const description = sanitizeEventDescription(
1589
+ event.description ?? "",
1590
+ ).trim();
1405
1591
  if (description.length <= 200) return;
1406
1592
 
1407
1593
  const targetKey = this.getDescriptionSummaryTargetKey(event);
@@ -1452,11 +1638,12 @@ export class CalendarViewElement extends LitElement {
1452
1638
  if (key !== this.descriptionSummaryTargetKey) return;
1453
1639
  this.descriptionSummaryLoading = false;
1454
1640
  this.descriptionSummaryError = message;
1455
- if (!this.descriptionSummaryText && this.selectedEventForDetail?.description) {
1456
- this.descriptionSummaryText = this.selectedEventForDetail.description.replaceAll(
1457
- "\\n",
1458
- "\n",
1459
- );
1641
+ if (
1642
+ !this.descriptionSummaryText &&
1643
+ this.selectedEventForDetail?.description
1644
+ ) {
1645
+ this.descriptionSummaryText =
1646
+ this.selectedEventForDetail.description.replaceAll("\\n", "\n");
1460
1647
  }
1461
1648
  this.requestUpdate();
1462
1649
  }
@@ -1472,18 +1659,44 @@ export class CalendarViewElement extends LitElement {
1472
1659
  repositionEventDetailOverlay(): void {
1473
1660
  if (!this.selectedEventForDetail || !this.selectedEventRect) return;
1474
1661
 
1475
- const overlay = this.renderRoot.querySelector<HTMLElement>(".event-detail-overlay");
1662
+ const overlay = this.renderRoot.querySelector<HTMLElement>(
1663
+ ".event-detail-overlay",
1664
+ );
1476
1665
  if (!overlay) return;
1666
+ const currentRect = this.eventRects.find(
1667
+ (rect) => rect.event === this.selectedEventForDetail,
1668
+ );
1669
+ if (currentRect) {
1670
+ this.selectedEventRect = {
1671
+ x: currentRect.x,
1672
+ y: currentRect.y,
1673
+ width: currentRect.width,
1674
+ height: currentRect.height,
1675
+ };
1676
+ }
1477
1677
 
1678
+ const actualWidth = overlay.offsetWidth;
1478
1679
  const actualHeight = overlay.offsetHeight;
1479
1680
  const GAP = 8;
1681
+ const containerWidth = this.scrollContainer?.clientWidth || 800;
1480
1682
  const containerHeight = this.scrollContainer?.clientHeight || 600;
1481
1683
 
1684
+ const rightPosition =
1685
+ this.selectedEventRect.x + this.selectedEventRect.width + GAP;
1686
+ const leftPosition = this.selectedEventRect.x - actualWidth - GAP;
1687
+ const preferredLeft =
1688
+ rightPosition + actualWidth <= containerWidth
1689
+ ? rightPosition
1690
+ : leftPosition;
1691
+ const minLeft = LEFT_GUTTER_WIDTH + GAP;
1692
+ const maxLeft = containerWidth - actualWidth - GAP;
1693
+ const left = Math.max(minLeft, Math.min(maxLeft, preferredLeft));
1482
1694
  const rawTop = this.selectedEventRect.y - this.scrollTop;
1483
1695
  const minTop = GAP;
1484
1696
  const maxTop = containerHeight - actualHeight - GAP;
1485
1697
  const top = Math.max(minTop, Math.min(maxTop, rawTop));
1486
1698
 
1699
+ overlay.style.left = `${left}px`;
1487
1700
  overlay.style.top = `${top}px`;
1488
1701
  }
1489
1702
 
@@ -1500,14 +1713,24 @@ export class CalendarViewElement extends LitElement {
1500
1713
 
1501
1714
  const self = this;
1502
1715
  const eventsState: EventsState = {
1503
- get events() { return self.events; },
1504
- get hoveredEventId() { return self.hoveredEventId; },
1716
+ get events() {
1717
+ return self.events;
1718
+ },
1719
+ get previewEvents() {
1720
+ return self.getPreviewEvents();
1721
+ },
1722
+ get hoveredEventId() {
1723
+ return self.hoveredEventId;
1724
+ },
1505
1725
  isEventSelected: (event) => self.internal.isEventSelected(event),
1506
- shouldRenderEventWithStripes: (event) => self.shouldRenderEventWithStripes(event),
1726
+ shouldRenderEventWithStripes: (event) =>
1727
+ self.shouldRenderEventWithStripes(event),
1507
1728
  getStripePatternCanvas: () => self.getStripePatternCanvas(),
1508
1729
  };
1509
1730
  const heatmapState = {
1510
- get events() { return self.heatmapEvents; },
1731
+ get events() {
1732
+ return self.heatmapEvents;
1733
+ },
1511
1734
  };
1512
1735
  this.eventsLayer = createEventsLayer(eventsState);
1513
1736
  this.layers = [
@@ -1520,14 +1743,30 @@ export class CalendarViewElement extends LitElement {
1520
1743
 
1521
1744
  // Restore zoom level from localStorage
1522
1745
  const savedDayHeight = this.loadDayHeight();
1523
- if (savedDayHeight !== 80) {
1746
+ if (savedDayHeight !== MIN_DAY_HEIGHT) {
1524
1747
  this.dayHeight = savedDayHeight;
1525
1748
  }
1749
+ const savedMinDayColumnWidth = this.loadMinDayColumnWidth();
1750
+ if (savedMinDayColumnWidth !== DEFAULT_DAY_COLUMN_WIDTH) {
1751
+ this.minDayColumnWidth = savedMinDayColumnWidth;
1752
+ }
1526
1753
 
1527
1754
  if (this.scrollContainer) {
1528
1755
  this.scrollContainer.addEventListener("scroll", this.onScroll, {
1529
1756
  passive: false,
1530
1757
  });
1758
+ this.scrollContainer.addEventListener("touchstart", this.onTouchStart, {
1759
+ passive: false,
1760
+ });
1761
+ this.scrollContainer.addEventListener("touchmove", this.onTouchMove, {
1762
+ passive: false,
1763
+ });
1764
+ this.scrollContainer.addEventListener("touchend", this.onTouchEnd, {
1765
+ passive: false,
1766
+ });
1767
+ this.scrollContainer.addEventListener("touchcancel", this.onTouchEnd, {
1768
+ passive: false,
1769
+ });
1531
1770
  this.scrollContainer.addEventListener(
1532
1771
  "mouseleave",
1533
1772
  this.onScrollContainerMouseLeave,
@@ -1639,30 +1878,27 @@ export class CalendarViewElement extends LitElement {
1639
1878
 
1640
1879
  updateWeekOffsets(): void {
1641
1880
  let y = 0;
1642
- const weekHeight = this.dayHeight * this.rowsPerWeek;
1881
+ const weekHeight = this.dayHeight;
1643
1882
 
1644
1883
  if (this.filter) {
1645
- const filteredEvents = this.events;
1646
-
1647
- // Pre-compute event date ranges once (avoiding repeated startOfDayTime/endOfDayTime calls)
1648
- const eventRanges = filteredEvents.map((e) => ({
1649
- start: CalendarInternal.startOfDayTime(e.start),
1650
- end: CalendarInternal.endOfDayTime(e.end),
1651
- }));
1884
+ const eventRanges = this.getFilteredEventRanges(this.events);
1885
+ this.ensureFilteredContextWeeks(eventRanges);
1886
+
1887
+ const visibleWeekIndexes = new Set<number>();
1888
+ for (const index of this.getMatchingWeekIndexes(eventRanges)) {
1889
+ for (
1890
+ let visibleIndex = Math.max(0, index - FILTER_CONTEXT_WEEKS);
1891
+ visibleIndex <=
1892
+ Math.min(this.weeks.length - 1, index + FILTER_CONTEXT_WEEKS);
1893
+ visibleIndex++
1894
+ ) {
1895
+ visibleWeekIndexes.add(visibleIndex);
1896
+ }
1897
+ }
1652
1898
 
1653
- for (const week of this.weeks) {
1899
+ for (const [index, week] of this.weeks.entries()) {
1654
1900
  week.yOffset = y;
1655
-
1656
- // Check if any day in this week overlaps any event range
1657
- const weekStartTime = week.days[0]?.getTime() ?? 0;
1658
- const weekEndTime = week.days[6]?.getTime() ?? 0;
1659
-
1660
- // Quick check: skip if week is entirely outside all event ranges
1661
- const hasEvents = eventRanges.some(
1662
- (range) => range.end >= weekStartTime && range.start <= weekEndTime,
1663
- );
1664
-
1665
- week.height = hasEvents ? weekHeight : 0;
1901
+ week.height = visibleWeekIndexes.has(index) ? weekHeight : 0;
1666
1902
  y += week.height;
1667
1903
  }
1668
1904
  } else {
@@ -1676,6 +1912,77 @@ export class CalendarViewElement extends LitElement {
1676
1912
  this.totalHeight = y;
1677
1913
  }
1678
1914
 
1915
+ private getFilteredEventRanges(
1916
+ events: CalendarEvent[],
1917
+ ): Array<{ start: number; end: number }> {
1918
+ return events
1919
+ .map((event) => ({
1920
+ start: CalendarInternal.startOfDayTime(event.start),
1921
+ end: CalendarInternal.endOfDayTime(event.end),
1922
+ }))
1923
+ .sort((a, b) => a.start - b.start);
1924
+ }
1925
+
1926
+ private getMatchingWeekIndexes(
1927
+ eventRanges: Array<{ start: number; end: number }>,
1928
+ ): number[] {
1929
+ const matchingIndexes: number[] = [];
1930
+
1931
+ for (const [index, week] of this.weeks.entries()) {
1932
+ const weekStartTime = CalendarInternal.startOfDayTime(
1933
+ week.days[0] ?? new Date(0),
1934
+ );
1935
+ const weekEndTime = CalendarInternal.endOfDayTime(
1936
+ week.days[6] ?? new Date(0),
1937
+ );
1938
+ const hasEvents = eventRanges.some(
1939
+ (range) => range.end >= weekStartTime && range.start <= weekEndTime,
1940
+ );
1941
+ if (hasEvents) {
1942
+ matchingIndexes.push(index);
1943
+ }
1944
+ }
1945
+
1946
+ return matchingIndexes;
1947
+ }
1948
+
1949
+ private ensureFilteredContextWeeks(
1950
+ eventRanges: Array<{ start: number; end: number }>,
1951
+ ): void {
1952
+ if (eventRanges.length === 0) return;
1953
+
1954
+ let matchingWeekIndexes = this.getMatchingWeekIndexes(eventRanges);
1955
+ if (matchingWeekIndexes.length === 0) {
1956
+ this.weeks = this.internal.resetRangeAroundDate(
1957
+ new Date(eventRanges[0]?.start),
1958
+ );
1959
+ matchingWeekIndexes = this.getMatchingWeekIndexes(eventRanges);
1960
+ }
1961
+
1962
+ while (
1963
+ matchingWeekIndexes.length > 0 &&
1964
+ matchingWeekIndexes[0]! < FILTER_CONTEXT_WEEKS
1965
+ ) {
1966
+ const newWeeks = this.internal.extendRange("past");
1967
+ if (newWeeks.length === 0) break;
1968
+ this.weeks = [...newWeeks, ...this.weeks];
1969
+ matchingWeekIndexes = this.getMatchingWeekIndexes(eventRanges);
1970
+ }
1971
+
1972
+ while (
1973
+ matchingWeekIndexes.length > 0 &&
1974
+ this.weeks.length -
1975
+ 1 -
1976
+ matchingWeekIndexes[matchingWeekIndexes.length - 1]! <
1977
+ FILTER_CONTEXT_WEEKS
1978
+ ) {
1979
+ const newWeeks = this.internal.extendRange("future");
1980
+ if (newWeeks.length === 0) break;
1981
+ this.weeks = [...this.weeks, ...newWeeks];
1982
+ matchingWeekIndexes = this.getMatchingWeekIndexes(eventRanges);
1983
+ }
1984
+ }
1985
+
1679
1986
  handleResize(): void {
1680
1987
  if (!this.canvas || !this.scrollContainer) return;
1681
1988
 
@@ -1684,8 +1991,11 @@ export class CalendarViewElement extends LitElement {
1684
1991
 
1685
1992
  this.rect = rect;
1686
1993
 
1687
- this.canvas.width = rect.width * dpr;
1994
+ const canvasWidth = LEFT_GUTTER_WIDTH + this.getViewportGridWidth();
1995
+ this.canvas.width = canvasWidth * dpr;
1688
1996
  this.canvas.height = rect.height * dpr;
1997
+ this.canvas.style.width = `${canvasWidth}px`;
1998
+ this.canvas.style.height = `${rect.height}px`;
1689
1999
  this.viewportHeight = rect.height;
1690
2000
 
1691
2001
  // Reset and rescale context (scale is reset when canvas dimensions change)
@@ -1696,7 +2006,7 @@ export class CalendarViewElement extends LitElement {
1696
2006
 
1697
2007
  // Resize and configure overlay canvas
1698
2008
  if (this.overlayCanvas) {
1699
- const overlayWidth = rect.width - LEFT_GUTTER_WIDTH - MINIMAP_WIDTH;
2009
+ const overlayWidth = this.getViewportGridWidth();
1700
2010
  this.overlayCanvas.width = overlayWidth * dpr;
1701
2011
  this.overlayCanvas.height = rect.height * dpr;
1702
2012
  this.overlayCanvas.style.width = `${overlayWidth}px`;
@@ -1708,30 +2018,9 @@ export class CalendarViewElement extends LitElement {
1708
2018
  }
1709
2019
  }
1710
2020
 
1711
- this.updateColumnsForViewport();
1712
2021
  this.renderCanvas();
1713
2022
  }
1714
2023
 
1715
- updateColumnsForViewport(): void {
1716
- if (!this.scrollContainer) return;
1717
-
1718
- const rect = this.scrollContainer.getBoundingClientRect();
1719
- const gridWidth = rect.width - LEFT_GUTTER_WIDTH;
1720
-
1721
- // Determine optimal columns based on available width
1722
- // Minimum 60px per day column for usability
1723
- const minDayWidth = 120;
1724
- const optimalColumns = Math.max(
1725
- 1,
1726
- Math.min(7, Math.floor(gridWidth / minDayWidth)),
1727
- );
1728
-
1729
- if (this._columnsPerRow !== optimalColumns) {
1730
- this._columnsPerRow = optimalColumns;
1731
- this.updateWeekOffsets();
1732
- }
1733
- }
1734
-
1735
2024
  resolveStyles(): Record<string, string> {
1736
2025
  const cs = getComputedStyle(this);
1737
2026
  const props = [
@@ -1761,7 +2050,10 @@ export class CalendarViewElement extends LitElement {
1761
2050
  const ids = new Set<string>();
1762
2051
  if (saved) {
1763
2052
  try {
1764
- const sources = JSON.parse(saved) as Array<{ id?: string; type?: string }>;
2053
+ const sources = JSON.parse(saved) as Array<{
2054
+ id?: string;
2055
+ type?: string;
2056
+ }>;
1765
2057
  for (const source of sources) {
1766
2058
  if (source?.type === "timeseries-json" && source.id) {
1767
2059
  ids.add(source.id);
@@ -1802,7 +2094,9 @@ export class CalendarViewElement extends LitElement {
1802
2094
  const timeseriesIds = this.loadTimeseriesSourceIds();
1803
2095
  const enabledKey = [...this.internal.enabledCalendars].join(",");
1804
2096
  const lockedKey = [...this.internal.lockedCalendars].join(",");
1805
- const key = `${start.toISOString()}::${end.toISOString()}::${this.filter || ""}::${enabledKey}::${lockedKey}`;
2097
+ const key = `${start.toISOString()}::${end.toISOString()}::${
2098
+ this.filter || ""
2099
+ }::${enabledKey}::${lockedKey}`;
1806
2100
  if (!force && key === this.heatmapQueryKey) return;
1807
2101
  this.heatmapQueryKey = key;
1808
2102
 
@@ -1838,8 +2132,7 @@ export class CalendarViewElement extends LitElement {
1838
2132
  ctx.clearRect(0, 0, width, height);
1839
2133
 
1840
2134
  const scrollTop = this.scrollTop;
1841
- const gridWidth = width - LEFT_GUTTER_WIDTH;
1842
- const dayWidth = gridWidth / this._columnsPerRow;
2135
+ const dayWidth = this.getDayWidth();
1843
2136
 
1844
2137
  const visibleWeeks = this.weeks.filter(
1845
2138
  (w) =>
@@ -1855,10 +2148,11 @@ export class CalendarViewElement extends LitElement {
1855
2148
  width,
1856
2149
  height,
1857
2150
  scrollTop,
2151
+ scrollLeft: this.scrollLeft,
1858
2152
  dayWidth,
1859
2153
  dayHeight: this.dayHeight,
1860
2154
  leftGutterWidth: LEFT_GUTTER_WIDTH,
1861
- columnsPerRow: this._columnsPerRow,
2155
+ columnsPerRow: this.columnsPerRow,
1862
2156
  rowsPerWeek: this.rowsPerWeek,
1863
2157
  visibleWeeks,
1864
2158
  allWeeks: this.weeks,
@@ -1875,6 +2169,14 @@ export class CalendarViewElement extends LitElement {
1875
2169
  ctx.restore();
1876
2170
  }
1877
2171
 
2172
+ this.renderTimeScaleGutter(
2173
+ ctx,
2174
+ visibleWeeks,
2175
+ height,
2176
+ fontFamily,
2177
+ lc.styles,
2178
+ );
2179
+
1878
2180
  // Copy event rects from events layer for hit-testing
1879
2181
  if (this.eventsLayer) {
1880
2182
  this.eventRects = this.eventsLayer.eventRects;
@@ -1887,14 +2189,102 @@ export class CalendarViewElement extends LitElement {
1887
2189
  // Draw sticky weekday labels at top of viewport
1888
2190
  this.renderWeekdayLabels(ctx, dayWidth, visibleWeeks, scrollTop, height);
1889
2191
 
1890
- if (this.isCreatingEvent) {
1891
- this.renderEventCreationPreview();
1892
- }
1893
- if (this.isDraggingEvent) {
1894
- this.renderEventMovePreview();
2192
+ this.renderMinimap();
2193
+ }
2194
+
2195
+ renderTimeScaleGutter(
2196
+ ctx: CanvasRenderingContext2D,
2197
+ visibleWeeks: WeekInfo[],
2198
+ height: number,
2199
+ fontFamily: string,
2200
+ styles: Record<string, string>,
2201
+ ): void {
2202
+ if (visibleWeeks.length === 0) return;
2203
+
2204
+ const hourLabelOpacity = Math.max(
2205
+ 0,
2206
+ Math.min(1, (this.dayHeight - TIME_SCALE_DAY_HEIGHT) / 300),
2207
+ );
2208
+ if (hourLabelOpacity <= 0.1) return;
2209
+
2210
+ const textMuted = styles["--text-muted"] || "rgba(255, 255, 255, 0.4)";
2211
+ const textPrimary = styles["--text-primary"] || "rgba(255, 255, 255, 1)";
2212
+ const bgPrimary = styles["--bg-primary"] || "rgba(30, 30, 30, 0.9)";
2213
+ const bgElevated = styles["--bg-elevated"] || "rgba(0, 0, 0, 0.7)";
2214
+ const today = new Date();
2215
+
2216
+ ctx.save();
2217
+ ctx.font = `500 11px ${fontFamily}`;
2218
+ ctx.textBaseline = "bottom";
2219
+ ctx.textAlign = "right";
2220
+ ctx.fillStyle = textMuted.replace(
2221
+ /[\d.]+\)$/,
2222
+ `${0.4 * hourLabelOpacity})`,
2223
+ );
2224
+
2225
+ for (const week of visibleWeeks) {
2226
+ const y = week.yOffset - this.scrollTop;
2227
+ const visibleWeekTop = Math.max(0, y);
2228
+ const visibleWeekBottom = Math.min(height, y + week.height);
2229
+
2230
+ if (visibleWeekBottom > visibleWeekTop) {
2231
+ ctx.save();
2232
+ ctx.globalAlpha = 0.7 * hourLabelOpacity;
2233
+ ctx.fillStyle = bgPrimary;
2234
+ ctx.fillRect(
2235
+ 0,
2236
+ visibleWeekTop,
2237
+ LEFT_GUTTER_WIDTH - 1,
2238
+ visibleWeekBottom - visibleWeekTop,
2239
+ );
2240
+ ctx.restore();
2241
+ }
2242
+
2243
+ for (let hour = 0; hour < 24; hour++) {
2244
+ const hourY = y + (hour / 24) * this.dayHeight;
2245
+ if (hourY < 0 || hourY > height) continue;
2246
+ const label = `${hour.toString().padStart(2, "0")}:00`;
2247
+ ctx.fillText(label, 48, hourY + 4);
2248
+ }
2249
+
2250
+ const todayIndex = week.days.findIndex((d) =>
2251
+ CalendarInternal.isSameDay(d, today),
2252
+ );
2253
+ if (todayIndex < 0) continue;
2254
+
2255
+ const currentMinutes = today.getHours() * 60 + today.getMinutes();
2256
+ const timeY = y + (currentMinutes / 1440) * this.dayHeight;
2257
+ if (timeY < 0 || timeY > height) continue;
2258
+
2259
+ const hours = today.getHours().toString().padStart(2, "0");
2260
+ const minutes = today.getMinutes().toString().padStart(2, "0");
2261
+ const timeText = `${hours}:${minutes}`;
2262
+ const textWidth = ctx.measureText(timeText).width;
2263
+ const bgPaddingX = 6;
2264
+ const textX = 48;
2265
+
2266
+ ctx.fillStyle = bgElevated;
2267
+ ctx.beginPath();
2268
+ ctx.roundRect(
2269
+ textX - textWidth - bgPaddingX,
2270
+ timeY - 8,
2271
+ textWidth + bgPaddingX * 2,
2272
+ 16,
2273
+ 4,
2274
+ );
2275
+ ctx.fill();
2276
+
2277
+ ctx.fillStyle = textPrimary;
2278
+ ctx.textBaseline = "middle";
2279
+ ctx.fillText(timeText, textX, timeY);
2280
+ ctx.textBaseline = "bottom";
2281
+ ctx.fillStyle = textMuted.replace(
2282
+ /[\d.]+\)$/,
2283
+ `${0.4 * hourLabelOpacity})`,
2284
+ );
1895
2285
  }
1896
2286
 
1897
- this.renderMinimap();
2287
+ ctx.restore();
1898
2288
  }
1899
2289
 
1900
2290
  toggleLayer(name: string): void {
@@ -1904,7 +2294,6 @@ export class CalendarViewElement extends LitElement {
1904
2294
  this.renderCanvas();
1905
2295
  }
1906
2296
 
1907
-
1908
2297
  onWheel = (e: WheelEvent): void => {
1909
2298
  if (this.hasAttribute("scroll-lock")) return;
1910
2299
  this.lastWheelTime = Date.now();
@@ -1914,7 +2303,9 @@ export class CalendarViewElement extends LitElement {
1914
2303
  }
1915
2304
 
1916
2305
  const isMac = navigator.platform.toUpperCase().indexOf("MAC") >= 0;
1917
- const isZoomKey = isMac ? e.metaKey : e.ctrlKey;
2306
+ const isPinchZoomGesture = e.ctrlKey;
2307
+ const isShortcutZoom = isMac ? e.metaKey : e.ctrlKey;
2308
+ const isZoomGesture = isPinchZoomGesture || isShortcutZoom;
1918
2309
 
1919
2310
  if (this.isFiltered && this.historyIndex === 0) {
1920
2311
  this.replaceFilterInHistory();
@@ -1922,7 +2313,7 @@ export class CalendarViewElement extends LitElement {
1922
2313
  this.debouncedSaveToHistory();
1923
2314
  }
1924
2315
 
1925
- if (!isZoomKey || !this.scrollContainer) {
2316
+ if (!isZoomGesture || !this.scrollContainer) {
1926
2317
  this.updateMousePosition();
1927
2318
  return;
1928
2319
  }
@@ -1942,8 +2333,95 @@ export class CalendarViewElement extends LitElement {
1942
2333
  const newOriginY = this.zoomOriginY * scaleRatio;
1943
2334
  const newScrollTop = newOriginY - this.zoomViewportY;
1944
2335
 
1945
- this.setView(newHeight, newScrollTop);
2336
+ // Avoid DOM scroll sync during wheel zoom to prevent scroll/zoom race flicker.
2337
+ this.setView(newHeight, newScrollTop, false);
1946
2338
  this.zoomOriginY = newOriginY;
2339
+ if (this.wheelZoomSyncTimeout) {
2340
+ clearTimeout(this.wheelZoomSyncTimeout);
2341
+ }
2342
+ this.wheelZoomSyncTimeout = setTimeout(() => {
2343
+ this.syncScrollDomToState();
2344
+ this.wheelZoomSyncTimeout = null;
2345
+ }, 80);
2346
+ };
2347
+
2348
+ getTouchDistance(touches: TouchList): number {
2349
+ if (touches.length < 2) return 0;
2350
+ const first = touches[0];
2351
+ const second = touches[1];
2352
+ if (!first || !second) return 0;
2353
+ return Math.hypot(
2354
+ second.clientX - first.clientX,
2355
+ second.clientY - first.clientY,
2356
+ );
2357
+ }
2358
+
2359
+ getTouchCenterY(touches: TouchList): number {
2360
+ if (touches.length < 2) return 0;
2361
+ const first = touches[0];
2362
+ const second = touches[1];
2363
+ if (!first || !second || !this.scrollContainer) return 0;
2364
+ const rect = this.scrollContainer.getBoundingClientRect();
2365
+ return (first.clientY + second.clientY) / 2 - rect.top;
2366
+ }
2367
+
2368
+ onTouchStart = (e: TouchEvent): void => {
2369
+ if (this.hasAttribute("scroll-lock") || !this.scrollContainer) return;
2370
+ if (e.touches.length < 2) return;
2371
+
2372
+ if (this.scrollAnimationFrame) {
2373
+ this.cancelScrollAnimation();
2374
+ }
2375
+
2376
+ const distance = this.getTouchDistance(e.touches);
2377
+ if (distance <= 0) return;
2378
+
2379
+ e.preventDefault();
2380
+ this.isPinchZooming = true;
2381
+ this.pinchStartDistance = distance;
2382
+ this.pinchStartHeight = this.dayHeight;
2383
+ this.zoomViewportY = this.getTouchCenterY(e.touches);
2384
+ this.pinchStartOriginY = this.zoomViewportY + this.scrollTop;
2385
+ this.zoomOriginY = this.pinchStartOriginY;
2386
+
2387
+ if (this.isFiltered && this.historyIndex === 0) {
2388
+ this.replaceFilterInHistory();
2389
+ } else {
2390
+ this.debouncedSaveToHistory();
2391
+ }
2392
+ };
2393
+
2394
+ onTouchMove = (e: TouchEvent): void => {
2395
+ if (!this.isPinchZooming || !this.scrollContainer) return;
2396
+ if (e.touches.length < 2 || this.pinchStartDistance <= 0) return;
2397
+
2398
+ e.preventDefault();
2399
+ const distance = this.getTouchDistance(e.touches);
2400
+ if (distance <= 0) return;
2401
+
2402
+ this.zoomViewportY = this.getTouchCenterY(e.touches);
2403
+ const scaleRatio = distance / this.pinchStartDistance;
2404
+ const newHeight = Math.max(
2405
+ MIN_DAY_HEIGHT,
2406
+ Math.min(MAX_DAY_HEIGHT, this.pinchStartHeight * scaleRatio),
2407
+ );
2408
+ const zoomRatio = newHeight / this.pinchStartHeight;
2409
+ const newOriginY = this.pinchStartOriginY * zoomRatio;
2410
+ const newScrollTop = newOriginY - this.zoomViewportY;
2411
+
2412
+ this.setView(newHeight, newScrollTop, false, false);
2413
+ this.zoomOriginY = newOriginY;
2414
+ };
2415
+
2416
+ onTouchEnd = (e: TouchEvent): void => {
2417
+ if (e.touches.length >= 2) {
2418
+ this.onTouchStart(e);
2419
+ return;
2420
+ }
2421
+
2422
+ this.isPinchZooming = false;
2423
+ this.pinchStartDistance = 0;
2424
+ this.syncScrollDomToState();
1947
2425
  };
1948
2426
 
1949
2427
  lastPointerY = 0;
@@ -2080,7 +2558,7 @@ export class CalendarViewElement extends LitElement {
2080
2558
  this.scrollContainer
2081
2559
  ) {
2082
2560
  const rect = this.scrollContainer.getBoundingClientRect();
2083
- const currentX = e.clientX - rect.left;
2561
+ const currentX = this.getContentXFromClientX(e.clientX);
2084
2562
  const currentY = e.clientY - rect.top + this.scrollTop;
2085
2563
 
2086
2564
  // Start selection
@@ -2102,7 +2580,7 @@ export class CalendarViewElement extends LitElement {
2102
2580
  this.selection = {
2103
2581
  startX: this.selectionStartX,
2104
2582
  startY: this.selectionStartY,
2105
- endX: e.clientX - rect.left,
2583
+ endX: this.getContentXFromClientX(e.clientX),
2106
2584
  endY: e.clientY - rect.top + this.scrollTop,
2107
2585
  };
2108
2586
  this.requestUpdate();
@@ -2111,7 +2589,7 @@ export class CalendarViewElement extends LitElement {
2111
2589
  // Event creation drag
2112
2590
  if (this.eventCreationStart && this.scrollContainer) {
2113
2591
  const rect = this.scrollContainer.getBoundingClientRect();
2114
- const currentX = e.clientX - rect.left;
2592
+ const currentX = this.getContentXFromClientX(e.clientX);
2115
2593
  const currentY = e.clientY - rect.top + this.scrollTop;
2116
2594
  this.isCreatingEvent = true;
2117
2595
 
@@ -2153,7 +2631,6 @@ export class CalendarViewElement extends LitElement {
2153
2631
  this.eventCreationEnd = { x: currentX, y: currentY };
2154
2632
  this.eventCreationPreviousShiftPressed = e.shiftKey;
2155
2633
  this.renderDateLabels();
2156
- this.renderEventCreationPreview();
2157
2634
  }
2158
2635
 
2159
2636
  // Event move drag - now handled by HTML5 drag and drop
@@ -2165,7 +2642,7 @@ export class CalendarViewElement extends LitElement {
2165
2642
  // Event resize drag
2166
2643
  if (this.resizingEvent && this.resizingEdge && this.scrollContainer) {
2167
2644
  const rect = this.scrollContainer.getBoundingClientRect();
2168
- const currentX = e.clientX - rect.left;
2645
+ const currentX = this.getContentXFromClientX(e.clientX);
2169
2646
  const currentY = e.clientY - rect.top + this.scrollTop;
2170
2647
  this.isResizingEvent = true;
2171
2648
 
@@ -2219,14 +2696,7 @@ export class CalendarViewElement extends LitElement {
2219
2696
  }
2220
2697
  if (this.isDraggingZoom) {
2221
2698
  this.isDraggingZoom = false;
2222
- // Sync scroll position with DOM now that drag is complete
2223
- if (this.scrollContainer && this.scrollContent) {
2224
- if (this.scrollContainer.scrollHeight < this._scrollTop) {
2225
- this.scrollContent.style.minHeight =
2226
- this._scrollTop + window.innerHeight + "px";
2227
- }
2228
- this.scrollContainer.scrollTop = this._scrollTop;
2229
- }
2699
+ this.syncScrollDomToState();
2230
2700
  }
2231
2701
  if (this.isDraggingMinimap) {
2232
2702
  this.isDraggingMinimap = false;
@@ -2298,7 +2768,11 @@ export class CalendarViewElement extends LitElement {
2298
2768
  const isZoomedOut = this.dayHeight < TIME_SCALE_DAY_HEIGHT;
2299
2769
  this.dispatchEvent(
2300
2770
  new CustomEvent("create-event", {
2301
- detail: { start: snappedStart, end: snappedEnd, isAllDay: isZoomedOut },
2771
+ detail: {
2772
+ start: snappedStart,
2773
+ end: snappedEnd,
2774
+ isAllDay: isZoomedOut,
2775
+ },
2302
2776
  bubbles: true,
2303
2777
  }),
2304
2778
  );
@@ -2486,14 +2960,15 @@ export class CalendarViewElement extends LitElement {
2486
2960
  if (!this.scrollContainer || this.isDraggingZoom) return;
2487
2961
 
2488
2962
  const rect = this.scrollContainer.getBoundingClientRect();
2489
- const x = e.clientX - rect.left;
2963
+ const viewportX = e.clientX - rect.left;
2964
+ const contentX = this.getContentXFromClientX(e.clientX);
2490
2965
  const y = e.clientY - rect.top + this.scrollTop;
2491
2966
 
2492
2967
  // Update cursor position for status bar
2493
- this.cursorPosition = { x, y };
2968
+ this.cursorPosition = { x: contentX, y };
2494
2969
 
2495
2970
  // Check for resize handles first — suppressed when alt key is active
2496
- const resizeHandle = !e.altKey ? this.getResizeHandle(x, y) : null;
2971
+ const resizeHandle = !e.altKey ? this.getResizeHandle(viewportX, y) : null;
2497
2972
  if (resizeHandle && !this.isResizingEvent && !this.isDraggingEvent) {
2498
2973
  this.scrollContainer.style.cursor =
2499
2974
  this.dayHeight >= TIME_SCALE_DAY_HEIGHT ? "ns-resize" : "ew-resize";
@@ -2502,7 +2977,9 @@ export class CalendarViewElement extends LitElement {
2502
2977
  }
2503
2978
 
2504
2979
  // Check for event hover — suppressed when alt key is active
2505
- const hoveredEvent = !e.altKey ? this.getEventAtPosition(x, y) : null;
2980
+ const hoveredEvent = !e.altKey
2981
+ ? this.getEventAtPosition(viewportX, y)
2982
+ : null;
2506
2983
  const newHoveredId = hoveredEvent ? hoveredEvent.id : null;
2507
2984
 
2508
2985
  if (newHoveredId !== this.hoveredEventId) {
@@ -2512,7 +2989,7 @@ export class CalendarViewElement extends LitElement {
2512
2989
 
2513
2990
  this.requestUpdate();
2514
2991
 
2515
- if (x < LEFT_GUTTER_WIDTH) {
2992
+ if (viewportX < LEFT_GUTTER_WIDTH) {
2516
2993
  this.scrollContainer.classList.add("zoom-cursor");
2517
2994
  } else {
2518
2995
  this.scrollContainer.classList.remove("zoom-cursor");
@@ -2557,7 +3034,7 @@ export class CalendarViewElement extends LitElement {
2557
3034
  // Update visual preview
2558
3035
  if (this.scrollContainer) {
2559
3036
  const rect = this.scrollContainer.getBoundingClientRect();
2560
- const x = e.clientX - rect.left;
3037
+ const x = this.getContentXFromClientX(e.clientX);
2561
3038
  const y = e.clientY - rect.top + this.scrollTop;
2562
3039
  this.movingEventEnd = { x, y };
2563
3040
  this.renderCanvas(); // Re-render canvas with preview
@@ -2601,7 +3078,7 @@ export class CalendarViewElement extends LitElement {
2601
3078
  ) {
2602
3079
  // Handle internal event drop
2603
3080
  const rect = this.scrollContainer.getBoundingClientRect();
2604
- const dropX = e.clientX - rect.left;
3081
+ const dropX = this.getContentXFromClientX(e.clientX);
2605
3082
  const dropY = e.clientY - rect.top + this.scrollTop;
2606
3083
 
2607
3084
  const originDate = this.convertPositionToDateTime(
@@ -2661,7 +3138,12 @@ export class CalendarViewElement extends LitElement {
2661
3138
 
2662
3139
  onKeyDown = (e: KeyboardEvent): void => {
2663
3140
  const focused = e.composedPath()[0] as HTMLElement;
2664
- if (focused?.tagName === "INPUT" || focused?.tagName === "TEXTAREA" || focused?.isContentEditable) return;
3141
+ if (
3142
+ focused?.tagName === "INPUT" ||
3143
+ focused?.tagName === "TEXTAREA" ||
3144
+ focused?.isContentEditable
3145
+ )
3146
+ return;
2665
3147
  if (e.altKey && !this.altKeyActive) {
2666
3148
  this.altKeyActive = true;
2667
3149
  this.dispatchEvent(new CustomEvent("meta-key-change", { bubbles: true }));
@@ -2677,7 +3159,12 @@ export class CalendarViewElement extends LitElement {
2677
3159
 
2678
3160
  onKeyUp = (e: KeyboardEvent): void => {
2679
3161
  const focused = e.composedPath()[0] as HTMLElement;
2680
- if (focused?.tagName === "INPUT" || focused?.tagName === "TEXTAREA" || focused?.isContentEditable) return;
3162
+ if (
3163
+ focused?.tagName === "INPUT" ||
3164
+ focused?.tagName === "TEXTAREA" ||
3165
+ focused?.isContentEditable
3166
+ )
3167
+ return;
2681
3168
  if (!e.altKey && this.altKeyActive) {
2682
3169
  this.altKeyActive = false;
2683
3170
  this.dispatchEvent(new CustomEvent("meta-key-change", { bubbles: true }));
@@ -2857,17 +3344,18 @@ export class CalendarViewElement extends LitElement {
2857
3344
  }
2858
3345
 
2859
3346
  const rect = this.scrollContainer.getBoundingClientRect();
2860
- const x = e.clientX - rect.left;
3347
+ const viewportX = e.clientX - rect.left;
3348
+ const x = this.getContentXFromClientX(e.clientX);
2861
3349
  const y = e.clientY - rect.top + this.scrollTop;
2862
3350
 
2863
3351
  // Check if clicking on zoom handle area (left gutter)
2864
- if (x < LEFT_GUTTER_WIDTH) {
3352
+ if (viewportX < LEFT_GUTTER_WIDTH) {
2865
3353
  this.onZoomHandleMouseDown(e);
2866
3354
  return;
2867
3355
  }
2868
3356
 
2869
3357
  // Check if clicking on a resize handle first
2870
- const resizeHandle = !e.altKey ? this.getResizeHandle(x, y) : null;
3358
+ const resizeHandle = !e.altKey ? this.getResizeHandle(viewportX, y) : null;
2871
3359
  if (resizeHandle) {
2872
3360
  this.resizingEvent = resizeHandle.event;
2873
3361
  this.resizingEdge = resizeHandle.edge;
@@ -2881,7 +3369,9 @@ export class CalendarViewElement extends LitElement {
2881
3369
 
2882
3370
  // Check if clicking on an event — defer click vs drag to mouseup
2883
3371
  // Skip event interaction if Alt/Option is held (create new event instead)
2884
- const clickedEvent = !e.altKey ? this.getEventAtPosition(x, y) : null;
3372
+ const clickedEvent = !e.altKey
3373
+ ? this.getEventAtPosition(viewportX, y)
3374
+ : null;
2885
3375
  if (clickedEvent) {
2886
3376
  // Clear selection when starting to drag/move an event
2887
3377
  const hadSelection = this.internal.getSelectedEvents().length > 0;
@@ -2973,22 +3463,20 @@ export class CalendarViewElement extends LitElement {
2973
3463
  const minY = Math.min(this.selection.startY, this.selection.endY);
2974
3464
  const maxY = Math.max(this.selection.startY, this.selection.endY);
2975
3465
 
2976
- const rect = this.scrollContainer.getBoundingClientRect();
2977
- const gridWidth = rect.width - LEFT_GUTTER_WIDTH;
2978
- const dayWidth = gridWidth / this._columnsPerRow;
3466
+ const dayWidth = this.getDayWidth();
2979
3467
 
2980
3468
  // Find column indices from X coordinates
2981
3469
  const startCol = Math.max(
2982
3470
  0,
2983
3471
  Math.min(
2984
- this._columnsPerRow - 1,
3472
+ this.columnsPerRow - 1,
2985
3473
  Math.floor((minX - LEFT_GUTTER_WIDTH) / dayWidth),
2986
3474
  ),
2987
3475
  );
2988
3476
  const endCol = Math.max(
2989
3477
  0,
2990
3478
  Math.min(
2991
- this._columnsPerRow - 1,
3479
+ this.columnsPerRow - 1,
2992
3480
  Math.floor((maxX - LEFT_GUTTER_WIDTH) / dayWidth),
2993
3481
  ),
2994
3482
  );
@@ -3093,7 +3581,12 @@ export class CalendarViewElement extends LitElement {
3093
3581
  if (event.visualStyle === "heatmap") continue;
3094
3582
 
3095
3583
  // Only select events from the active calendar
3096
- if (this.activeCalendarId && event.calendarId !== this.activeCalendarId && event.sourceId !== this.activeCalendarId) continue;
3584
+ if (
3585
+ this.activeCalendarId &&
3586
+ event.calendarId !== this.activeCalendarId &&
3587
+ event.sourceId !== this.activeCalendarId
3588
+ )
3589
+ continue;
3097
3590
 
3098
3591
  const eventStartTime = event.start.getTime();
3099
3592
  const eventEndTime = event.end.getTime();
@@ -3301,7 +3794,7 @@ export class CalendarViewElement extends LitElement {
3301
3794
 
3302
3795
  onZoomSliderChange = (e: Event): void => {
3303
3796
  const slider = e.target as HTMLInputElement;
3304
- const newHeight = parseInt(slider.value, 10);
3797
+ const newHeight = Number.parseInt(slider.value, 10);
3305
3798
  const oldHeight = this.dayHeight;
3306
3799
 
3307
3800
  if (!this.scrollContainer) {
@@ -3321,6 +3814,38 @@ export class CalendarViewElement extends LitElement {
3321
3814
  this.setView(newHeight, newScrollTop);
3322
3815
  };
3323
3816
 
3817
+ onDayColumnWidthSliderChange = (e: Event): void => {
3818
+ const slider = e.target as HTMLInputElement;
3819
+ const newWidth = Number.parseInt(slider.value, 10);
3820
+
3821
+ if (!this.scrollContainer) {
3822
+ this.minDayColumnWidth = newWidth;
3823
+ return;
3824
+ }
3825
+
3826
+ const oldGridWidth = this.getGridWidth();
3827
+ const viewportWidth = this.scrollContainer.clientWidth;
3828
+ const viewportCenterX = viewportWidth / 2;
3829
+ const contentCenterX = this.scrollLeft + viewportCenterX;
3830
+
3831
+ this.minDayColumnWidth = newWidth;
3832
+
3833
+ const newGridWidth = this.getGridWidth();
3834
+ const scaleRatio = newGridWidth / oldGridWidth;
3835
+ const newContentCenterX = contentCenterX * scaleRatio;
3836
+ const maxScrollLeft = Math.max(
3837
+ 0,
3838
+ LEFT_GUTTER_WIDTH + newGridWidth - viewportWidth,
3839
+ );
3840
+ const newScrollLeft = Math.max(
3841
+ 0,
3842
+ Math.min(maxScrollLeft, newContentCenterX - viewportCenterX),
3843
+ );
3844
+
3845
+ this.scrollContainer.scrollLeft = newScrollLeft;
3846
+ this.renderCanvas();
3847
+ };
3848
+
3324
3849
  async onEventClick(event: CalendarEvent, e: MouseEvent): Promise<void> {
3325
3850
  const isCmdOrCtrl = e.metaKey || e.ctrlKey;
3326
3851
 
@@ -3468,296 +3993,135 @@ export class CalendarViewElement extends LitElement {
3468
3993
  this.renderDateLabels();
3469
3994
  }
3470
3995
 
3471
- renderEventMovePreview(): void {
3472
- if (!this.movingEvent || !this.movingEventOrigin || !this.movingEventEnd)
3473
- return;
3474
-
3475
- const originDate = this.convertPositionToDateTime(
3476
- this.movingEventOrigin.x,
3477
- this.movingEventOrigin.y,
3478
- );
3479
- const currentDate = this.convertPositionToDateTime(
3480
- this.movingEventEnd.x,
3481
- this.movingEventEnd.y,
3482
- );
3483
- if (!originDate || !currentDate) return;
3996
+ getPreviewEvents(): PreviewEventData[] {
3997
+ const previews: PreviewEventData[] = [];
3484
3998
 
3485
- const deltaMs = currentDate.getTime() - originDate.getTime();
3486
- const snap15 = (d: Date) => {
3487
- d.setMinutes(Math.round(d.getMinutes() / 15) * 15, 0, 0);
3488
- return d;
3489
- };
3490
- const newStart = snap15(
3491
- new Date(this.movingEvent.start.getTime() + deltaMs),
3492
- );
3493
- const newEnd = snap15(new Date(this.movingEvent.end.getTime() + deltaMs));
3494
-
3495
- // The grab point (originDate) relative to the event's midpoint tells us
3496
- // which half the user grabbed — this is stable for the whole drag.
3497
- const eventMidMs = (this.movingEvent.start.getTime() + this.movingEvent.end.getTime()) / 2;
3498
- const useStartEdge = originDate.getTime() <= eventMidMs;
3499
-
3500
- if (this.movingEventDuplicateMode) {
3501
- const accent =
3502
- getComputedStyle(this).getPropertyValue("--accent-primary").trim() ||
3503
- "rgb(100, 150, 255)";
3504
- this.renderVirtualEvent(
3505
- newStart,
3506
- newEnd,
3507
- {
3508
- fill: accent.replace("rgb", "rgba").replace(")", ", 0.3)"),
3509
- stroke: accent.replace("rgb", "rgba").replace(")", ", 0.8)"),
3510
- text: "white",
3511
- dashed: false,
3512
- },
3513
- useStartEdge,
3514
- );
3515
- } else {
3516
- const rgb = hexToRgb(this.movingEvent.color || "#888888");
3517
- this.renderVirtualEvent(
3518
- newStart,
3519
- newEnd,
3520
- {
3521
- fill: `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, 0.5)`,
3522
- stroke: `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, 0.5)`,
3523
- text: "rgba(255, 255, 255, 0.5)",
3524
- dashed: false,
3525
- },
3526
- useStartEdge,
3999
+ if (this.movingEvent && this.movingEventOrigin && this.movingEventEnd) {
4000
+ const originDate = this.convertPositionToDateTime(
4001
+ this.movingEventOrigin.x,
4002
+ this.movingEventOrigin.y,
3527
4003
  );
3528
- }
3529
- }
3530
-
3531
- renderEventCreationPreview(): void {
3532
- if (!this.eventCreationStart || !this.eventCreationEnd) return;
3533
-
3534
- const startDate = this.convertPositionToDateTime(
3535
- this.eventCreationStart.x,
3536
- this.eventCreationStart.y,
3537
- );
3538
- const endDate = this.convertPositionToDateTime(
3539
- this.eventCreationEnd.x,
3540
- this.eventCreationEnd.y,
3541
- );
3542
- if (!startDate || !endDate) return;
3543
-
3544
- let earlier: Date;
3545
- let later: Date;
3546
-
3547
- if (
3548
- this.eventCreationShiftPressed &&
3549
- this.eventCreationInitialDuration !== null
3550
- ) {
3551
- // Shift pressed: move event with fixed duration to current mouse position
3552
4004
  const currentDate = this.convertPositionToDateTime(
3553
- this.eventCreationEnd.x,
3554
- this.eventCreationEnd.y,
4005
+ this.movingEventEnd.x,
4006
+ this.movingEventEnd.y,
3555
4007
  );
3556
- if (!currentDate) return;
3557
-
3558
- // Position the event so it ends at the current mouse position
3559
- later = new Date(currentDate);
3560
- earlier = new Date(
3561
- currentDate.getTime() - this.eventCreationInitialDuration,
3562
- );
3563
- } else {
3564
- // Normal behavior: stretch from start to end
3565
- earlier = startDate < endDate ? startDate : endDate;
3566
- later = startDate < endDate ? endDate : startDate;
3567
- }
4008
+ if (originDate && currentDate) {
4009
+ const deltaMs = currentDate.getTime() - originDate.getTime();
4010
+ const snap15 = (d: Date) => {
4011
+ d.setMinutes(Math.round(d.getMinutes() / 15) * 15, 0, 0);
4012
+ return d;
4013
+ };
4014
+ const start = snap15(
4015
+ new Date(this.movingEvent.start.getTime() + deltaMs),
4016
+ );
4017
+ const end = snap15(new Date(this.movingEvent.end.getTime() + deltaMs));
4018
+ if (this.dayHeight < TIME_SCALE_EVENT_THRESHOLD) {
4019
+ start.setHours(0, 0, 0, 0);
4020
+ end.setHours(23, 59, 59, 999);
4021
+ }
3568
4022
 
3569
- if (this.dayHeight < TIME_SCALE_DAY_HEIGHT) {
3570
- earlier.setHours(0, 0, 0, 0);
3571
- later.setHours(23, 59, 59, 999);
3572
- } else {
3573
- earlier.setMinutes(Math.round(earlier.getMinutes() / 15) * 15, 0, 0);
3574
- later.setMinutes(Math.round(later.getMinutes() / 15) * 15, 0, 0);
4023
+ if (this.movingEventDuplicateMode) {
4024
+ const accent =
4025
+ getComputedStyle(this)
4026
+ .getPropertyValue("--accent-primary")
4027
+ .trim() || "rgb(100, 150, 255)";
4028
+ previews.push({
4029
+ id: `move-preview-${this.movingEvent.id}`,
4030
+ start,
4031
+ end,
4032
+ color: {
4033
+ fill: accent.replace("rgb", "rgba").replace(")", ", 0.3)"),
4034
+ stroke: accent.replace("rgb", "rgba").replace(")", ", 0.8)"),
4035
+ text: "white",
4036
+ dashed: false,
4037
+ },
4038
+ });
4039
+ } else {
4040
+ const rgb = hexToRgb(this.movingEvent.color || "#888888");
4041
+ previews.push({
4042
+ id: `move-preview-${this.movingEvent.id}`,
4043
+ start,
4044
+ end,
4045
+ excludeEventId: this.movingEvent.id,
4046
+ color: {
4047
+ fill: `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, 0.5)`,
4048
+ stroke: `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, 0.5)`,
4049
+ text: "rgba(255, 255, 255, 0.5)",
4050
+ dashed: false,
4051
+ },
4052
+ });
4053
+ }
4054
+ }
3575
4055
  }
3576
4056
 
3577
- // Use active calendar color if available, otherwise fall back to accent-primary
3578
- let fill: string;
3579
- let stroke: string;
3580
- if (this.activeCalendarColor) {
3581
- const rgb = hexToRgb(this.activeCalendarColor);
3582
- fill = `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, 0.3)`;
3583
- stroke = `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, 0.8)`;
3584
- } else {
3585
- const accent =
3586
- getComputedStyle(this).getPropertyValue("--accent-primary").trim() ||
3587
- "rgb(100, 150, 255)";
3588
- fill = accent.replace("rgb", "rgba").replace(")", ", 0.3)");
3589
- stroke = accent.replace("rgb", "rgba").replace(")", ", 0.8)");
3590
- }
3591
- // In shift mode the cursor tracks the end; otherwise it tracks whichever
3592
- // edge is away from the fixed anchor (eventCreationStart).
3593
- const useStartEdge = this.eventCreationShiftPressed
3594
- ? false
3595
- : this.eventCreationEnd.y <= this.eventCreationStart.y;
3596
-
3597
- this.renderVirtualEvent(earlier, later, { fill, stroke, text: "white" }, useStartEdge);
3598
- }
3599
-
3600
- renderVirtualEvent(
3601
- start: Date,
3602
- end: Date,
3603
- color: { fill: string; stroke: string; text: string; dashed?: boolean },
3604
- useStartEdge = true,
3605
- ): void {
3606
- if (!this.overlayCanvas || !this.overlayCtx || !this.scrollContainer)
3607
- return;
3608
-
3609
- const ctx = this.overlayCtx;
3610
- const fontFamily = getComputedStyle(this).fontFamily;
3611
- const scrollRect = this.scrollContainer.getBoundingClientRect();
3612
- const gridWidth = scrollRect.width - LEFT_GUTTER_WIDTH - MINIMAP_WIDTH;
3613
- const dayWidth = gridWidth / this._columnsPerRow;
3614
-
3615
- const getTimeY = (day: Date, hours: number, minutes: number) => {
3616
- const week = this.weeks.find((w) =>
3617
- w.days.some((d) => d.toDateString() === day.toDateString()),
4057
+ if (this.eventCreationStart && this.eventCreationEnd) {
4058
+ const startDate = this.convertPositionToDateTime(
4059
+ this.eventCreationStart.x,
4060
+ this.eventCreationStart.y,
3618
4061
  );
3619
- if (!week) return null;
3620
- const dayIndex = week.days.findIndex(
3621
- (d) => d.toDateString() === day.toDateString(),
3622
- );
3623
- if (dayIndex < 0) return null;
3624
- const { row } = this.getDayVisualPosition(dayIndex);
3625
- const totalMinutes = hours * 60 + minutes;
3626
- const rowY = week.yOffset + row * this.dayHeight;
3627
- return rowY + (totalMinutes / 1440) * this.dayHeight - this.scrollTop;
3628
- };
3629
-
3630
- const getDayColumnX = (day: Date) => {
3631
- const week = this.weeks.find((w) =>
3632
- w.days.some((d) => d.toDateString() === day.toDateString()),
3633
- );
3634
- if (!week) return null;
3635
- const dayIndex = week.days.findIndex(
3636
- (d) => d.toDateString() === day.toDateString(),
4062
+ const endDate = this.convertPositionToDateTime(
4063
+ this.eventCreationEnd.x,
4064
+ this.eventCreationEnd.y,
3637
4065
  );
3638
- if (dayIndex < 0) return null;
3639
- const { col } = this.getDayVisualPosition(dayIndex);
3640
- return col * dayWidth;
3641
- };
3642
-
3643
- const drawBlock = (colX: number, top: number, bottom: number) => {
3644
- const bx = colX + 2;
3645
- const bw = dayWidth - 4;
3646
- const bh = Math.max(4, bottom - top);
3647
- ctx.fillStyle = color.fill;
3648
- ctx.beginPath();
3649
- ctx.roundRect(bx, top, bw, bh, 4);
3650
- ctx.fill();
3651
- ctx.strokeStyle = color.stroke;
3652
- ctx.lineWidth = 1;
3653
- if (color.dashed !== false) ctx.setLineDash([6, 3]);
3654
- ctx.stroke();
3655
- ctx.setLineDash([]);
3656
- };
3657
-
3658
- const sameDay = start.toDateString() === end.toDateString();
4066
+ if (startDate && endDate) {
4067
+ let earlier: Date;
4068
+ let later: Date;
3659
4069
 
3660
- if (sameDay) {
3661
- const colX = getDayColumnX(start);
3662
- const top = getTimeY(start, start.getHours(), start.getMinutes());
3663
- const bottom = getTimeY(end, end.getHours(), end.getMinutes());
3664
- if (colX != null && top != null && bottom != null)
3665
- drawBlock(colX, top, bottom);
3666
- } else {
3667
- const current = new Date(start);
3668
- current.setHours(0, 0, 0, 0);
3669
- const endDay = new Date(end);
3670
- endDay.setHours(23, 59, 59, 999);
3671
- while (current <= endDay) {
3672
- const colX = getDayColumnX(current);
3673
- if (colX != null) {
3674
- const isFirst = current.toDateString() === start.toDateString();
3675
- const isLast = current.toDateString() === end.toDateString();
3676
- let top: number | null;
3677
- let bottom: number | null;
3678
- if (isFirst) {
3679
- top = getTimeY(current, start.getHours(), start.getMinutes());
3680
- bottom = getTimeY(current, 23, 59);
3681
- } else if (isLast) {
3682
- top = getTimeY(current, 0, 0);
3683
- bottom = getTimeY(current, end.getHours(), end.getMinutes());
4070
+ if (
4071
+ this.eventCreationShiftPressed &&
4072
+ this.eventCreationInitialDuration !== null
4073
+ ) {
4074
+ const currentDate = this.convertPositionToDateTime(
4075
+ this.eventCreationEnd.x,
4076
+ this.eventCreationEnd.y,
4077
+ );
4078
+ if (currentDate) {
4079
+ later = new Date(currentDate);
4080
+ earlier = new Date(
4081
+ currentDate.getTime() - this.eventCreationInitialDuration,
4082
+ );
3684
4083
  } else {
3685
- top = getTimeY(current, 0, 0);
3686
- bottom = getTimeY(current, 23, 59);
4084
+ earlier = startDate < endDate ? startDate : endDate;
4085
+ later = startDate < endDate ? endDate : startDate;
3687
4086
  }
3688
- if (top != null && bottom != null) drawBlock(colX, top, bottom);
4087
+ } else {
4088
+ earlier = startDate < endDate ? startDate : endDate;
4089
+ later = startDate < endDate ? endDate : startDate;
3689
4090
  }
3690
- current.setDate(current.getDate() + 1);
3691
- }
3692
- }
3693
-
3694
- // Time label
3695
- const fmtTime = (d: Date) =>
3696
- `${d.getHours().toString().padStart(2, "0")}:${d
3697
- .getMinutes()
3698
- .toString()
3699
- .padStart(2, "0")}`;
3700
- const fmtDate = (d: Date) => `${d.getDate()}.${d.getMonth() + 1}`;
3701
- const label = sameDay
3702
- ? `${fmtTime(start)} – ${fmtTime(end)}`
3703
- : `${fmtDate(start)} ${fmtTime(start)} – ${fmtDate(end)} ${fmtTime(end)}`;
3704
4091
 
3705
- const durationMs = Math.abs(end.getTime() - start.getTime());
3706
- const durationMinutes = durationMs / (1000 * 60);
3707
- const isShortEvent = durationMinutes < 15;
3708
-
3709
- const firstColX = getDayColumnX(start);
3710
- const lastColX = getDayColumnX(end);
3711
- const startY = getTimeY(start, start.getHours(), start.getMinutes());
3712
- const endY = getTimeY(end, end.getHours(), end.getMinutes());
3713
-
3714
- if (
3715
- firstColX != null &&
3716
- lastColX != null &&
3717
- startY != null &&
3718
- endY != null
3719
- ) {
3720
- ctx.font = `600 11px ${fontFamily}`;
3721
- ctx.fillStyle = color.text;
3722
- ctx.textAlign = "left";
4092
+ if (this.dayHeight < TIME_SCALE_EVENT_THRESHOLD) {
4093
+ earlier.setHours(0, 0, 0, 0);
4094
+ later.setHours(23, 59, 59, 999);
4095
+ } else {
4096
+ earlier.setMinutes(Math.round(earlier.getMinutes() / 15) * 15, 0, 0);
4097
+ later.setMinutes(Math.round(later.getMinutes() / 15) * 15, 0, 0);
4098
+ }
3723
4099
 
3724
- // Use appropriate column based on which edge is closer
3725
- const labelColX = useStartEdge ? firstColX : lastColX;
3726
-
3727
- if (isShortEvent) {
3728
- // For short events, display label below the event on the edge closest to cursor
3729
- const labelY = useStartEdge ? startY : endY;
3730
- const labelBgY = labelY + 6;
3731
- const labelTextY = labelY + 10;
3732
-
3733
- // Draw background pill
3734
- const textWidth = ctx.measureText(label).width;
3735
- const bgPaddingX = 6;
3736
- const bgElevated =
3737
- getComputedStyle(this).getPropertyValue("--bg-elevated").trim() ||
3738
- "rgba(0, 0, 0, 0.8)";
3739
- ctx.fillStyle = bgElevated;
3740
- ctx.beginPath();
3741
- ctx.roundRect(
3742
- labelColX + 4,
3743
- labelBgY,
3744
- textWidth + bgPaddingX * 2,
3745
- 16,
3746
- 4,
3747
- );
3748
- ctx.fill();
4100
+ let fill: string;
4101
+ let stroke: string;
4102
+ if (this.activeCalendarColor) {
4103
+ const rgb = hexToRgb(this.activeCalendarColor);
4104
+ fill = `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, 0.3)`;
4105
+ stroke = `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, 0.8)`;
4106
+ } else {
4107
+ const accent =
4108
+ getComputedStyle(this)
4109
+ .getPropertyValue("--accent-primary")
4110
+ .trim() || "rgb(100, 150, 255)";
4111
+ fill = accent.replace("rgb", "rgba").replace(")", ", 0.3)");
4112
+ stroke = accent.replace("rgb", "rgba").replace(")", ", 0.8)");
4113
+ }
3749
4114
 
3750
- // Draw text
3751
- ctx.fillStyle = "white";
3752
- ctx.textBaseline = "top";
3753
- ctx.fillText(label, labelColX + 4 + bgPaddingX, labelTextY);
3754
- } else {
3755
- // For longer events, display label inside the event on the edge closest to cursor
3756
- ctx.textBaseline = "top";
3757
- const labelY = useStartEdge ? startY + 4 : endY - 18;
3758
- ctx.fillText(label, labelColX + 8, labelY);
4115
+ previews.push({
4116
+ id: "creation-preview",
4117
+ start: earlier,
4118
+ end: later,
4119
+ color: { fill, stroke, text: "white", dashed: true },
4120
+ });
3759
4121
  }
3760
4122
  }
4123
+
4124
+ return previews;
3761
4125
  }
3762
4126
 
3763
4127
  renderDateLabels(): void {
@@ -3770,11 +4134,12 @@ export class CalendarViewElement extends LitElement {
3770
4134
 
3771
4135
  ctx.clearRect(0, 0, width, height);
3772
4136
 
3773
- const dayWidth = width / this._columnsPerRow;
4137
+ const dayWidth = this.getDayWidth();
3774
4138
  const scrollTop = this.scrollTop;
4139
+ const scrollLeft = this.scrollLeft;
3775
4140
  const fontFamily = getComputedStyle(this).fontFamily;
3776
4141
 
3777
- ctx.font = `600 10px ${fontFamily}`;
4142
+ ctx.font = `600 12px ${fontFamily}`;
3778
4143
  const textSecondary =
3779
4144
  getComputedStyle(this).getPropertyValue("--text-secondary").trim() ||
3780
4145
  "rgba(255, 255, 255, 0.6)";
@@ -3792,7 +4157,7 @@ export class CalendarViewElement extends LitElement {
3792
4157
  if (!day) continue;
3793
4158
 
3794
4159
  const { row, col } = this.getDayVisualPosition(dayIndex);
3795
- const x = col * dayWidth;
4160
+ const x = col * dayWidth - scrollLeft;
3796
4161
  const dayTop = week.yOffset + row * this.dayHeight - scrollTop;
3797
4162
  const dayBottom = dayTop + this.dayHeight;
3798
4163
 
@@ -3831,6 +4196,8 @@ export class CalendarViewElement extends LitElement {
3831
4196
  }
3832
4197
  }
3833
4198
  }
4199
+
4200
+ this.renderMonthLabels(ctx, width, height, fontFamily);
3834
4201
  }
3835
4202
 
3836
4203
  renderWeekdayLabels(
@@ -3851,57 +4218,41 @@ export class CalendarViewElement extends LitElement {
3851
4218
  getComputedStyle(this).getPropertyValue("--bg-primary").trim() ||
3852
4219
  "rgba(30, 30, 30, 0.9)";
3853
4220
 
3854
- ctx.font = `500 12px ${fontFamily}`;
4221
+ ctx.font = `600 12px ${fontFamily}`;
3855
4222
  ctx.textAlign = "center";
3856
4223
  ctx.textBaseline = "top";
3857
4224
 
3858
4225
  const labelHeight = 16;
3859
- const labelY = 12; // Below month label
4226
+ const labelY = 48; // Below sticky month label
3860
4227
 
3861
- // Find the first visible visual row
3862
- const firstWeek = visibleWeeks[0];
3863
- if (!firstWeek) return;
4228
+ const stickyY = labelY;
3864
4229
 
3865
- // Determine which visual rows are visible
3866
- for (let row = 0; row < this.rowsPerWeek; row++) {
3867
- const rowTop = firstWeek.yOffset + row * this.dayHeight - scrollTop;
3868
- const rowBottom = rowTop + this.dayHeight;
4230
+ for (let dayIndex = 0; dayIndex < 7; dayIndex++) {
4231
+ const dayName = weekdayNames[dayIndex];
4232
+ if (!dayName) continue;
3869
4233
 
3870
- // Check if this visual row is visible
3871
- if (rowBottom < 0 || rowTop > height) continue;
4234
+ const x =
4235
+ LEFT_GUTTER_WIDTH +
4236
+ dayIndex * dayWidth +
4237
+ dayWidth / 2 -
4238
+ this.scrollLeft;
3872
4239
 
3873
- // Calculate sticky Y position - stays at top but doesn't go past row bottom
3874
- const stickyY = Math.min(labelY, rowBottom - labelHeight - 2);
3875
- if (stickyY < 0) continue;
3876
-
3877
- // Draw weekday labels for each column in this visual row
3878
- for (let col = 0; col < this._columnsPerRow; col++) {
3879
- const dayIndex = row * this._columnsPerRow + col;
3880
- if (dayIndex >= 7) continue;
3881
- const dayName = weekdayNames[dayIndex];
3882
- if (!dayName) continue;
3883
-
3884
- const x = LEFT_GUTTER_WIDTH + col * dayWidth + dayWidth / 2;
3885
-
3886
- // Draw background pill
3887
- const textWidth = ctx.measureText(dayName).width;
3888
- const bgPaddingX = 6;
3889
- const bgPaddingY = 2;
3890
- ctx.fillStyle = bgPrimary;
3891
- ctx.beginPath();
3892
- ctx.roundRect(
3893
- x - textWidth / 2 - bgPaddingX,
3894
- stickyY,
3895
- textWidth + bgPaddingX * 2,
3896
- labelHeight,
3897
- 4,
3898
- );
3899
- ctx.fill();
4240
+ const textWidth = ctx.measureText(dayName).width;
4241
+ const bgPaddingX = 6;
4242
+ const bgPaddingY = 2;
4243
+ ctx.fillStyle = bgPrimary;
4244
+ ctx.beginPath();
4245
+ ctx.roundRect(
4246
+ x - textWidth / 2 - bgPaddingX,
4247
+ stickyY,
4248
+ textWidth + bgPaddingX * 2,
4249
+ labelHeight,
4250
+ 4,
4251
+ );
4252
+ ctx.fill();
3900
4253
 
3901
- // Draw text
3902
- ctx.fillStyle = textMuted;
3903
- ctx.fillText(dayName, x, stickyY + bgPaddingY + 1);
3904
- }
4254
+ ctx.fillStyle = textMuted;
4255
+ ctx.fillText(dayName, x, stickyY + bgPaddingY + 1);
3905
4256
  }
3906
4257
  }
3907
4258
 
@@ -4099,9 +4450,7 @@ export class CalendarViewElement extends LitElement {
4099
4450
  ): Date | null {
4100
4451
  if (!this.scrollContainer) return null;
4101
4452
 
4102
- const rect = this.scrollContainer.getBoundingClientRect();
4103
- const gridWidth = rect.width - LEFT_GUTTER_WIDTH;
4104
- const dayWidth = gridWidth / this._columnsPerRow;
4453
+ const dayWidth = this.getDayWidth();
4105
4454
 
4106
4455
  // Check if X is in the calendar grid area
4107
4456
  if (x < LEFT_GUTTER_WIDTH) return null;
@@ -4109,7 +4458,7 @@ export class CalendarViewElement extends LitElement {
4109
4458
  const col = Math.max(
4110
4459
  0,
4111
4460
  Math.min(
4112
- this._columnsPerRow - 1,
4461
+ this.columnsPerRow - 1,
4113
4462
  Math.floor((x - LEFT_GUTTER_WIDTH) / dayWidth),
4114
4463
  ),
4115
4464
  );
@@ -4123,7 +4472,7 @@ export class CalendarViewElement extends LitElement {
4123
4472
 
4124
4473
  // Calculate which visual row within the week
4125
4474
  const rowInWeek = Math.floor((y - week.yOffset) / this.dayHeight);
4126
- const dayIndex = rowInWeek * this._columnsPerRow + col;
4475
+ const dayIndex = rowInWeek * this.columnsPerRow + col;
4127
4476
 
4128
4477
  if (dayIndex < 0 || dayIndex > 6) return null;
4129
4478
 
@@ -4153,9 +4502,7 @@ export class CalendarViewElement extends LitElement {
4153
4502
  convertDateTimeToPosition(date: Date): { x: number; y: number } | null {
4154
4503
  if (!this.scrollContainer) return null;
4155
4504
 
4156
- const rect = this.scrollContainer.getBoundingClientRect();
4157
- const gridWidth = rect.width - LEFT_GUTTER_WIDTH;
4158
- const dayWidth = gridWidth / this._columnsPerRow;
4505
+ const dayWidth = this.getDayWidth();
4159
4506
 
4160
4507
  // Find the week that contains this date
4161
4508
  const dateStr = date.toDateString();
@@ -4416,17 +4763,20 @@ export class CalendarViewElement extends LitElement {
4416
4763
  ? this.descriptionSummaryText
4417
4764
  : "";
4418
4765
  const isSummaryLoading =
4419
- useDescriptionSummary && hasSummaryStateForCurrentEvent && this.descriptionSummaryLoading;
4766
+ useDescriptionSummary &&
4767
+ hasSummaryStateForCurrentEvent &&
4768
+ this.descriptionSummaryLoading;
4420
4769
  const shouldRenderSummary =
4421
4770
  useDescriptionSummary && hasSummaryStateForCurrentEvent;
4422
4771
  const descriptionToRender = shouldRenderSummary
4423
- ? streamedSummaryText || (isSummaryLoading ? "Generating summary..." : eventDescriptionText)
4772
+ ? streamedSummaryText ||
4773
+ (isSummaryLoading ? "Generating summary..." : eventDescriptionText)
4424
4774
  : eventDescriptionText;
4425
4775
  const summarizeButtonLabel = isSummaryLoading
4426
4776
  ? "Summarizing..."
4427
4777
  : shouldRenderSummary
4428
- ? "Summarize again"
4429
- : "Summarize";
4778
+ ? "Summarize again"
4779
+ : "Summarize";
4430
4780
 
4431
4781
  const formatDate = (date: Date) => {
4432
4782
  return new Intl.DateTimeFormat(this.locale, {
@@ -4573,7 +4923,9 @@ export class CalendarViewElement extends LitElement {
4573
4923
 
4574
4924
  <div class="event-detail-body">
4575
4925
  ${(() => {
4576
- const teamsMatch = eventDescription?.match(/https:\/\/teams\.microsoft\.com\/[^\s<>"]+/);
4926
+ const teamsMatch = eventDescription?.match(
4927
+ /https:\/\/teams\.microsoft\.com\/[^\s<>"]+/,
4928
+ );
4577
4929
  const teamsUrl = teamsMatch ? teamsMatch[0] : null;
4578
4930
  if (!event.location && !teamsUrl) return null;
4579
4931
  return html`
@@ -4581,7 +4933,11 @@ export class CalendarViewElement extends LitElement {
4581
4933
  <div class="event-detail-label">Location</div>
4582
4934
  <div class="event-detail-value">
4583
4935
  ${event.location ? html`<div>${event.location}</div>` : null}
4584
- ${teamsUrl ? html`<a href="${teamsUrl}" class="event-detail-link" target="_blank" rel="noopener">Join Microsoft Teams Meeting</a>` : null}
4936
+ ${
4937
+ teamsUrl
4938
+ ? html`<a href="${teamsUrl}" class="event-detail-link" target="_blank" rel="noopener">Join Microsoft Teams Meeting</a>`
4939
+ : null
4940
+ }
4585
4941
  </div>
4586
4942
  </div>
4587
4943
  `;
@@ -4677,7 +5033,7 @@ export class CalendarViewElement extends LitElement {
4677
5033
 
4678
5034
  ${
4679
5035
  useDescriptionSummary
4680
- ? html`
5036
+ ? html`
4681
5037
  <div class="event-detail-section">
4682
5038
  <div class="event-detail-label">Description</div>
4683
5039
  <div class="event-detail-description ${
@@ -4688,21 +5044,24 @@ export class CalendarViewElement extends LitElement {
4688
5044
  <pre class="event-detail-value">${descriptionToRender}</pre>
4689
5045
  </div>
4690
5046
  ${
4691
- shouldRenderSummary && this.descriptionSummaryError
4692
- ? html`<div class="event-detail-value" style="opacity: 0.7;">Summary unavailable: ${this.descriptionSummaryError}. Showing original description.</div>`
4693
- : null
4694
- }
5047
+ shouldRenderSummary && this.descriptionSummaryError
5048
+ ? html`<div class="event-detail-value" style="opacity: 0.7;">Summary unavailable: ${this.descriptionSummaryError}. Showing original description.</div>`
5049
+ : null
5050
+ }
4695
5051
  <div class="description-actions">
4696
5052
  ${
4697
5053
  !shouldRenderSummary
4698
5054
  ? html`<button
4699
5055
  class="description-see-more"
4700
5056
  @click=${() => {
4701
- this.isDescriptionExpanded = !this.isDescriptionExpanded;
5057
+ this.isDescriptionExpanded =
5058
+ !this.isDescriptionExpanded;
4702
5059
  this.requestUpdate();
4703
5060
  }}
4704
5061
  >
4705
- ${this.isDescriptionExpanded ? "Show less" : "Show more"}
5062
+ ${
5063
+ this.isDescriptionExpanded ? "Show less" : "Show more"
5064
+ }
4706
5065
  </button>`
4707
5066
  : null
4708
5067
  }
@@ -4719,36 +5078,36 @@ export class CalendarViewElement extends LitElement {
4719
5078
  : event.readOnly ||
4720
5079
  (event.organizer != null &&
4721
5080
  !this.currentUserEmails.has(event.organizer.email))
4722
- ? eventDescription
5081
+ ? eventDescription
4723
5082
  ? html`
4724
5083
  <div class="event-detail-section">
4725
5084
  <div class="event-detail-label">Description</div>
4726
5085
  <div class="event-detail-description ${
4727
- this.isDescriptionExpanded ? "expanded" : ""
4728
- }">
5086
+ this.isDescriptionExpanded ? "expanded" : ""
5087
+ }">
4729
5088
  <pre class="event-detail-value">${descriptionToRender}</pre>
4730
5089
  </div>
4731
5090
  ${
4732
- eventDescription.length > 300 ||
4733
- eventDescription.split("\n").length > 8
4734
- ? html`
5091
+ eventDescription.length > 300 ||
5092
+ eventDescription.split("\n").length > 8
5093
+ ? html`
4735
5094
  <button
4736
5095
  class="description-see-more"
4737
5096
  @click=${() => {
4738
- this.isDescriptionExpanded =
4739
- !this.isDescriptionExpanded;
4740
- this.requestUpdate();
4741
- }}
5097
+ this.isDescriptionExpanded =
5098
+ !this.isDescriptionExpanded;
5099
+ this.requestUpdate();
5100
+ }}
4742
5101
  >
4743
5102
  ${this.isDescriptionExpanded ? "See less" : "See more"}
4744
5103
  </button>
4745
5104
  `
4746
- : null
4747
- }
5105
+ : null
5106
+ }
4748
5107
  </div>
4749
5108
  `
4750
5109
  : null
4751
- : html`
5110
+ : html`
4752
5111
  <div class="event-detail-section">
4753
5112
  <div class="event-detail-label">Description</div>
4754
5113
  <textarea
@@ -4872,7 +5231,9 @@ export class CalendarViewElement extends LitElement {
4872
5231
  @mousedown=${this.onCanvasMouseDown}
4873
5232
  @mousemove=${this.onScrollContainerMouseMove}
4874
5233
  >
4875
- <div class="scroll-content" style="height: ${this.totalHeight}px;">
5234
+ <div class="scroll-content" style="height: ${
5235
+ this.totalHeight
5236
+ }px; width: ${this.getContentWidth()}px;">
4876
5237
  </div>
4877
5238
  ${this.renderSelection()}
4878
5239
  </div>
@@ -4886,27 +5247,42 @@ export class CalendarViewElement extends LitElement {
4886
5247
  <div class="toolbar">
4887
5248
  <div class="toolbar-left">
4888
5249
  <button class="toolbar-button" title="Month" @click=${
4889
- this.scrollToMonth
4890
- }>
5250
+ this.scrollToMonth
5251
+ }>
4891
5252
  Month
4892
5253
  </button>
4893
5254
  <button class="toolbar-button" title="Today" @click=${
4894
- this.scrollToToday
4895
- }>
5255
+ this.scrollToToday
5256
+ }>
4896
5257
  Today
4897
5258
  </button>
4898
5259
  <button ?disabled=${
4899
- this.historyStack.length === 0 ||
4900
- this.historyIndex >= this.historyStack.length - 1
4901
- } class="toolbar-button" title="Back" @click=${this.goBack}>
5260
+ this.historyStack.length === 0 ||
5261
+ this.historyIndex >= this.historyStack.length - 1
5262
+ } class="toolbar-button" title="Back" @click=${this.goBack}>
4902
5263
 
4903
5264
  </button>
4904
5265
  <button ?disabled=${
4905
- this.historyStack.length === 0 || this.historyIndex === 0
4906
- } class="toolbar-button" title="Forward" @click=${this.goForward}>
5266
+ this.historyStack.length === 0 || this.historyIndex === 0
5267
+ } class="toolbar-button" title="Forward" @click=${
5268
+ this.goForward
5269
+ }>
4907
5270
 
4908
5271
  </button>
4909
5272
  <div class="toolbar-zoom">
5273
+ <span class="toolbar-slider-label">Width</span>
5274
+ <input
5275
+ type="range"
5276
+ class="toolbar-zoom-slider"
5277
+ min="${MIN_DAY_COLUMN_WIDTH}"
5278
+ max="${MAX_DAY_COLUMN_WIDTH}"
5279
+ .value=${this.minDayColumnWidth}
5280
+ @input=${this.onDayColumnWidthSliderChange}
5281
+ title="Adjust minimum day column width"
5282
+ />
5283
+ </div>
5284
+ <div class="toolbar-zoom">
5285
+ <span class="toolbar-slider-label">Zoom</span>
4910
5286
  <input
4911
5287
  type="range"
4912
5288
  class="toolbar-zoom-slider"
@@ -4927,9 +5303,9 @@ export class CalendarViewElement extends LitElement {
4927
5303
  title="Select theme"
4928
5304
  >
4929
5305
  ${availableThemes.map(
4930
- (theme) =>
4931
- html`<option value="${theme.name}">${theme.label}</option>`,
4932
- )}
5306
+ (theme) =>
5307
+ html`<option value="${theme.name}">${theme.label}</option>`,
5308
+ )}
4933
5309
  </select>-->
4934
5310
  <div class="toolbar-search">
4935
5311
  <span class="toolbar-search-icon">🔍</span>