@nomideusz/svelte-calendar 0.15.2 → 0.16.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.
package/README.md CHANGED
@@ -759,6 +759,8 @@ Small helpers used by the built-in views, exported for custom rendering:
759
759
  | `isAllDay(ev)` / `isMultiDay(ev)` | Event classification |
760
760
  | `segmentForDay(ev, dayMs)` | The slice of a multi-day event that falls on one day |
761
761
  | `createClock()` | Reactive clock (`tick`, `today`) driving now-lines and relative labels |
762
+ | `typeset(text)` / `breakLines(text, font, width)` | Knuth-Plass paragraph breaking over the text's own spaces and soft hyphens, measured with pretext. `<p {@attach typeset(text)}>` sets justified block-span lines, re-done on resize; if the browser disagrees with the measure the plain text goes back. Progressive — SSR text stays. |
763
+ | `fitLabel([long, short])` / `pickFit` / `fits` / `textHeight` | Text fitting via [pretext](https://github.com/chenglou/pretext) — measure before render, no layout thrash. `<span class="eb-title" {@attach fitLabel([ev.title, ev.short])}>` keeps the longest label that fits. Browser-only. |
762
764
 
763
765
  ## Embeddable Widget
764
766
 
@@ -8,6 +8,7 @@ export type { RecurringEvent, RecurringAdapterOptions } from './recurring.js';
8
8
  export { createMappedAdapter } from './mapped.js';
9
9
  export type { FieldMapping, MappedAdapterOptions, MutationHandler } from './mapped.js';
10
10
  export { createCompositeAdapter } from './composite.js';
11
+ export { withInitialEvents } from './seeded.js';
11
12
  export type { CompositeAdapterOptions } from './composite.js';
12
13
  export { createJmapAdapter } from './jmap.js';
13
14
  export type { JmapClient, JmapCalendarAdapterOptions } from './jmap.js';
@@ -3,4 +3,5 @@ export { createRestAdapter } from './rest.js';
3
3
  export { createRecurringAdapter } from './recurring.js';
4
4
  export { createMappedAdapter } from './mapped.js';
5
5
  export { createCompositeAdapter } from './composite.js';
6
+ export { withInitialEvents } from './seeded.js';
6
7
  export { createJmapAdapter } from './jmap.js';
@@ -26,9 +26,11 @@ export function createMemoryAdapter(initial = [], options) {
26
26
  function overlaps(ev, range) {
27
27
  return ev.start < range.end && ev.end > range.start;
28
28
  }
29
+ const fetchEventsSync = (range) => events.filter((ev) => overlaps(ev, range)).map(withColor);
29
30
  return {
31
+ fetchEventsSync,
30
32
  async fetchEvents(range) {
31
- return events.filter((ev) => overlaps(ev, range)).map(withColor);
33
+ return fetchEventsSync(range);
32
34
  },
33
35
  async createEvent(data) {
34
36
  const ev = { ...data, id: uid() };
@@ -252,38 +252,42 @@ export function createRecurringAdapter(schedule, options = {}) {
252
252
  const key = rec.category ?? rec.title;
253
253
  return colorAssignments.get(key);
254
254
  }
255
+ const fetchEventsSync = (range) => {
256
+ const events = [];
257
+ for (const rec of schedule) {
258
+ const colored = { ...rec, color: resolveColor(rec) };
259
+ const freq = rec.frequency ?? 'weekly';
260
+ // Parse bounds
261
+ const sd = rec.startDate ? parseDate(rec.startDate) : undefined;
262
+ const untilDate = rec.until ? parseDate(rec.until) : undefined;
263
+ const countUntil = sd
264
+ ? computeUntilFromCount(rec, sd, mondayStart)
265
+ : undefined;
266
+ // Effective until = tighter of the two bounds
267
+ let effectiveUntil = untilDate;
268
+ if (countUntil) {
269
+ effectiveUntil = effectiveUntil
270
+ ? countUntil < effectiveUntil ? countUntil : effectiveUntil
271
+ : countUntil;
272
+ }
273
+ switch (freq) {
274
+ case 'daily':
275
+ projectDaily(colored, range, sd, effectiveUntil, events);
276
+ break;
277
+ case 'weekly':
278
+ projectWeekly(colored, range, sd, effectiveUntil, mondayStart, events);
279
+ break;
280
+ case 'monthly':
281
+ projectMonthly(colored, range, sd, effectiveUntil, events);
282
+ break;
283
+ }
284
+ }
285
+ return events;
286
+ };
255
287
  return {
288
+ fetchEventsSync,
256
289
  async fetchEvents(range) {
257
- const events = [];
258
- for (const rec of schedule) {
259
- const colored = { ...rec, color: resolveColor(rec) };
260
- const freq = rec.frequency ?? 'weekly';
261
- // Parse bounds
262
- const sd = rec.startDate ? parseDate(rec.startDate) : undefined;
263
- const untilDate = rec.until ? parseDate(rec.until) : undefined;
264
- const countUntil = sd
265
- ? computeUntilFromCount(rec, sd, mondayStart)
266
- : undefined;
267
- // Effective until = tighter of the two bounds
268
- let effectiveUntil = untilDate;
269
- if (countUntil) {
270
- effectiveUntil = effectiveUntil
271
- ? countUntil < effectiveUntil ? countUntil : effectiveUntil
272
- : countUntil;
273
- }
274
- switch (freq) {
275
- case 'daily':
276
- projectDaily(colored, range, sd, effectiveUntil, events);
277
- break;
278
- case 'weekly':
279
- projectWeekly(colored, range, sd, effectiveUntil, mondayStart, events);
280
- break;
281
- case 'monthly':
282
- projectMonthly(colored, range, sd, effectiveUntil, events);
283
- break;
284
- }
285
- }
286
- return events;
290
+ return fetchEventsSync(range);
287
291
  },
288
292
  // Read-only adapter: CRUD methods intentionally omitted.
289
293
  // Use createMemoryAdapter or createRestAdapter for mutations.
@@ -0,0 +1,9 @@
1
+ import type { CalendarAdapter } from './types.js';
2
+ import type { TimelineEvent } from '../core/types.js';
3
+ /**
4
+ * An adapter that answers its first load synchronously with events the
5
+ * server already had — so the server render carries the rows and the client
6
+ * hydrates the same ones — and every later load, including a refresh(), goes
7
+ * to the real adapter. The seed is meant for the range the page opens on.
8
+ */
9
+ export declare function withInitialEvents(adapter: CalendarAdapter, events: TimelineEvent[]): CalendarAdapter;
@@ -0,0 +1,17 @@
1
+ /**
2
+ * An adapter that answers its first load synchronously with events the
3
+ * server already had — so the server render carries the rows and the client
4
+ * hydrates the same ones — and every later load, including a refresh(), goes
5
+ * to the real adapter. The seed is meant for the range the page opens on.
6
+ */
7
+ export function withInitialEvents(adapter, events) {
8
+ let seed = events;
9
+ return {
10
+ ...adapter,
11
+ fetchEventsSync(range) {
12
+ const s = seed;
13
+ seed = undefined;
14
+ return s ?? adapter.fetchEventsSync?.(range);
15
+ },
16
+ };
17
+ }
@@ -14,6 +14,11 @@ export interface DateRange {
14
14
  export interface CalendarAdapter {
15
15
  /** Fetch events that overlap the given date range */
16
16
  fetchEvents(range: DateRange): Promise<TimelineEvent[]>;
17
+ /** Same answer, synchronously — for adapters whose data is already in
18
+ * memory. The store prefers it, so a server render carries the events
19
+ * and the client hydrates the same rows instead of a loading state.
20
+ * Return undefined to send this call down the async path. */
21
+ fetchEventsSync?(range: DateRange): TimelineEvent[] | undefined;
17
22
  /** Create a new event, return it with a server-assigned ID */
18
23
  createEvent?(event: Omit<TimelineEvent, 'id'>): Promise<TimelineEvent>;
19
24
  /** Update an event, return the full updated event */
@@ -16,6 +16,7 @@
16
16
  * // store.load() — fetch from adapter for a range
17
17
  */
18
18
  import { SvelteMap } from 'svelte/reactivity';
19
+ import { untrack } from 'svelte';
19
20
  import { sod, DAY_MS } from '../core/time.js';
20
21
  /**
21
22
  * Create a reactive event store backed by a CalendarAdapter.
@@ -45,6 +46,18 @@ export function createEventStore(adapter) {
45
46
  function upsertEvent(ev) {
46
47
  eventMap.set(ev.id, ev);
47
48
  }
49
+ // Merge: upsert fetched, don't blow away events outside this range.
50
+ // Inside the range the adapter is authoritative — drop what it no
51
+ // longer returns, or deleted/moved events linger until remount.
52
+ function merge(fetched, range) {
53
+ const keep = new Set(fetched.map((ev) => ev.id));
54
+ for (const ev of [...eventMap.values()]) {
55
+ if (!keep.has(ev.id) && overlaps(ev, range.start, range.end))
56
+ removeEvent(ev.id);
57
+ }
58
+ for (const ev of fetched)
59
+ upsertEvent(ev);
60
+ }
48
61
  // ── Public API ──
49
62
  return {
50
63
  get events() {
@@ -58,23 +71,24 @@ export function createEventStore(adapter) {
58
71
  },
59
72
  async load(range) {
60
73
  const seq = ++loadSeq;
74
+ const adapter = getAdapter();
75
+ // In-memory adapters answer at once: no loading state, and a server
76
+ // render already holds the events.
77
+ const sync = adapter.fetchEventsSync?.(range);
78
+ if (sync) {
79
+ error = null;
80
+ // Callers load from an effect; reading the map here would make that
81
+ // effect depend on what it writes.
82
+ untrack(() => merge(sync, range));
83
+ return;
84
+ }
61
85
  loading = true;
62
86
  error = null;
63
87
  try {
64
- const fetched = await getAdapter().fetchEvents(range);
88
+ const fetched = await adapter.fetchEvents(range);
65
89
  if (seq !== loadSeq)
66
90
  return; // superseded by a newer load
67
- // Merge: upsert fetched, don't blow away events outside this range.
68
- // Inside the range the adapter is authoritative — drop what it no
69
- // longer returns, or deleted/moved events linger until remount.
70
- const keep = new Set(fetched.map((ev) => ev.id));
71
- for (const ev of [...eventMap.values()]) {
72
- if (!keep.has(ev.id) && overlaps(ev, range.start, range.end))
73
- removeEvent(ev.id);
74
- }
75
- for (const ev of fetched) {
76
- upsertEvent(ev);
77
- }
91
+ merge(fetched, range);
78
92
  }
79
93
  catch (e) {
80
94
  error = e instanceof Error ? e.message : String(e);
@@ -52,6 +52,8 @@ export interface HeadlessRangeAgenda {
52
52
  goToday(): void;
53
53
  /** Set the window start to a specific date */
54
54
  setDate(date: Date): void;
55
+ /** Load the current window again — after a seeded first render, or a write elsewhere */
56
+ refresh(): void;
55
57
  /** Format a Date to locale time string (e.g. "14:30") */
56
58
  fmtTime(date: Date): string;
57
59
  /** Format event duration (e.g. "1h 30m") */
@@ -103,6 +103,9 @@ export function createRangeAgenda(options) {
103
103
  setDate(date) {
104
104
  startMs = sod(date.getTime());
105
105
  },
106
+ refresh() {
107
+ void store.load({ start: new Date(startMs), end: new Date(endMs) });
108
+ },
106
109
  fmtTime: (d) => _fmtTime(d, locale),
107
110
  fmtDuration: (ev) => fmtDuration(ev.start, ev.end),
108
111
  fmtRange: (ev) => `${_fmtTime(ev.start, locale)} – ${_fmtTime(ev.end, locale)}`,
package/dist/index.d.ts CHANGED
@@ -5,7 +5,7 @@ export { default as MonthGrid } from './views/month/MonthGrid.svelte';
5
5
  export type { CalendarView } from './calendar/index.js';
6
6
  export { createEventStore, createViewState, createSelection, createDragState, } from './engine/index.js';
7
7
  export type { EventStore, ViewState, ViewStateOptions, CalendarViewId, BuiltInViewId, ViewMode, Selection as CalendarSelection, DragState, DragMode, DragPayload, } from './engine/index.js';
8
- export { createMemoryAdapter, createRestAdapter, createRecurringAdapter, createMappedAdapter, createCompositeAdapter, createJmapAdapter } from './adapters/index.js';
8
+ export { createMemoryAdapter, createRestAdapter, createRecurringAdapter, createMappedAdapter, createCompositeAdapter, createJmapAdapter, withInitialEvents } from './adapters/index.js';
9
9
  export type { CalendarAdapter, WritableCalendarAdapter, DateRange, MemoryAdapterOptions, RestAdapterOptions, RecurringEvent, RecurringAdapterOptions, FieldMapping, MappedAdapterOptions, MutationHandler, CompositeAdapterOptions, JmapClient, JmapCalendarAdapterOptions, } from './adapters/index.js';
10
10
  export { createClock, startOfWeek, fmtH, fmtTime, fmtDuration, weekdayShort, weekdayLong, monthShort, monthLong, dateShort, dateWithWeekday, fmtDay, fmtWeekRange, setDefaultLocale, getDefaultLocale, is24HourLocale, defaultLabels, setLabels, resetLabels, getLabels, toZonedTime, fromZonedTime, nowInZone, formatInTimeZone, generatePalette, extractAccent, VIVID_PALETTE, isMultiDay, isAllDay, segmentForDay, } from './core/index.js';
11
11
  export type { Clock, TimelineEvent, BlockedSlot, DaySegment, CalendarLabels, EventStatus, } from './core/index.js';
@@ -15,3 +15,4 @@ export { wrapAdapterWithTimezone } from './core/timezone.js';
15
15
  export type { PresetName, AutoThemeOptions } from './theme/index.js';
16
16
  export { createCalendar, createAgenda, createRangeAgenda } from './headless/index.js';
17
17
  export type { HeadlessCalendarOptions, HeadlessCalendar, HeadlessDay, HeadlessWeek, TodayQueue, HeaderContext, NavigationContext, AgendaOptions, HeadlessAgenda, RangeAgendaOptions, RangeAgendaDay, HeadlessRangeAgenda, } from './headless/index.js';
18
+ export { fits, lineCount, textHeight, pickFit, fontOf, fitLabel, breakLines, typeset } from './text-fit.js';
package/dist/index.js CHANGED
@@ -8,7 +8,7 @@ export { default as MonthGrid } from './views/month/MonthGrid.svelte';
8
8
  // ─── Engine (reactive state) ────────────────────────────
9
9
  export { createEventStore, createViewState, createSelection, createDragState, } from './engine/index.js';
10
10
  // ─── Adapters ───────────────────────────────────────────
11
- export { createMemoryAdapter, createRestAdapter, createRecurringAdapter, createMappedAdapter, createCompositeAdapter, createJmapAdapter } from './adapters/index.js';
11
+ export { createMemoryAdapter, createRestAdapter, createRecurringAdapter, createMappedAdapter, createCompositeAdapter, createJmapAdapter, withInitialEvents } from './adapters/index.js';
12
12
  // ─── Core: clock, time, locale, types ───────────────────
13
13
  export { createClock, startOfWeek, fmtH, fmtTime, fmtDuration, weekdayShort, weekdayLong, monthShort, monthLong, dateShort, dateWithWeekday, fmtDay, fmtWeekRange, setDefaultLocale, getDefaultLocale, is24HourLocale, defaultLabels, setLabels, resetLabels, getLabels, toZonedTime, fromZonedTime, nowInZone, formatInTimeZone, generatePalette, extractAccent, VIVID_PALETTE, isMultiDay, isAllDay, segmentForDay, } from './core/index.js';
14
14
  // ─── Themes ─────────────────────────────────────────────
@@ -17,3 +17,5 @@ export { probeHostTheme, observeHostTheme } from './theme/index.js';
17
17
  export { wrapAdapterWithTimezone } from './core/timezone.js';
18
18
  // ─── Headless API ───────────────────────────────────────
19
19
  export { createCalendar, createAgenda, createRangeAgenda } from './headless/index.js';
20
+ // ─── Text fitting (pretext) ─────────────────────────────
21
+ export { fits, lineCount, textHeight, pickFit, fontOf, fitLabel, breakLines, typeset } from './text-fit.js';
@@ -0,0 +1,26 @@
1
+ import { type PrepareOptions } from '@chenglou/pretext';
2
+ import type { Attachment } from 'svelte/attachments';
3
+ export declare function lineCount(text: string, font: string, width: number, options?: PrepareOptions): number;
4
+ export declare function fits(text: string, font: string, width: number, lines?: number): boolean;
5
+ /** Height the text will take at `width`, before it is in the DOM. */
6
+ export declare function textHeight(text: string, font: string, width: number, lineHeight: number): number;
7
+ /** Longest candidate (in given order) that fits in `lines`; the last one if none does. */
8
+ export declare function pickFit(candidates: readonly string[], font: string, width: number, lines?: number): string;
9
+ /** The element's computed font as a canvas font string. */
10
+ export declare function fontOf(el: Element): string;
11
+ /**
12
+ * `{@attach fitLabel([title, short, initials])}` — keeps the element's text at
13
+ * the longest candidate that fits its content box. The element needs a width
14
+ * that does not come from its own text (block, or a flex item with min-width: 0).
15
+ */
16
+ export declare function fitLabel(candidates: readonly string[], lines?: number): Attachment<HTMLElement>;
17
+ export declare function breakLines(text: string, font: string, width: number): string[];
18
+ /**
19
+ * `<p {@attach typeset(text)}>` — sets the paragraph as justified Knuth-Plass
20
+ * lines, one block span per line, re-done on resize and after webfonts load.
21
+ * Progressive: the server-rendered text is what crawlers and no-JS get; if the
22
+ * browser's own measure disagrees with ours (a line overflows), the plain
23
+ * text goes back and the browser breaks it. Pass the text, not the DOM — the
24
+ * span rebuild replaces the framework's text node.
25
+ */
26
+ export declare function typeset(text: string): Attachment<HTMLElement>;
@@ -0,0 +1,170 @@
1
+ // Text measurement without layout thrash — a thin wrapper over @chenglou/pretext.
2
+ // Browser-only (Canvas 2D + Intl.Segmenter): call from effects, attachments or
3
+ // event handlers, never during SSR. `font` is a canvas font string, e.g.
4
+ // "500 12px Inter" — name a real family; bare `system-ui` measures wrong on macOS.
5
+ import { prepare, layout, prepareWithSegments, measureNaturalWidth } from '@chenglou/pretext';
6
+ export function lineCount(text, font, width, options) {
7
+ return layout(prepare(text, font, options), width, 1).lineCount;
8
+ }
9
+ export function fits(text, font, width, lines = 1) {
10
+ return lineCount(text, font, width) <= lines;
11
+ }
12
+ /** Height the text will take at `width`, before it is in the DOM. */
13
+ export function textHeight(text, font, width, lineHeight) {
14
+ return layout(prepare(text, font), width, lineHeight).height;
15
+ }
16
+ /** Longest candidate (in given order) that fits in `lines`; the last one if none does. */
17
+ export function pickFit(candidates, font, width, lines = 1) {
18
+ return candidates.find((c) => fits(c, font, width, lines)) ?? candidates.at(-1) ?? '';
19
+ }
20
+ /** The element's computed font as a canvas font string. */
21
+ export function fontOf(el) {
22
+ const s = getComputedStyle(el);
23
+ return `${s.fontStyle} ${s.fontWeight} ${s.fontSize} ${s.fontFamily}`;
24
+ }
25
+ /**
26
+ * `{@attach fitLabel([title, short, initials])}` — keeps the element's text at
27
+ * the longest candidate that fits its content box. The element needs a width
28
+ * that does not come from its own text (block, or a flex item with min-width: 0).
29
+ */
30
+ export function fitLabel(candidates, lines = 1) {
31
+ return (el) => {
32
+ const font = fontOf(el);
33
+ let width = 0;
34
+ const apply = () => { el.textContent = pickFit(candidates, font, width, lines); };
35
+ const ro = new ResizeObserver(([entry]) => { width = entry.contentRect.width; apply(); });
36
+ ro.observe(el);
37
+ document.fonts?.ready.then(apply); // webfont swap changes widths without a resize
38
+ return () => ro.disconnect();
39
+ };
40
+ }
41
+ // ─── Paragraph breaking (Knuth-Plass, simplified) ────────────────────────
42
+ // The browser breaks greedily: fill a line, break, repeat. This scores every
43
+ // feasible set of breaks by how much each line would have to stretch and
44
+ // keeps the cheapest — the lines come out even instead of one loose line
45
+ // paying for the next. Break opportunities are the text's own: spaces and
46
+ // soft hyphens (U+00AD, typeset server-side). No-break spaces stay glued.
47
+ // Scoring is TeX's: badness = 100·(slack / stretch)³ where each interword
48
+ // space may stretch by half its width, capped at 10000; a line costs
49
+ // (10 + badness)² plus 50² for ending inside a word, plus 10000 when the line
50
+ // above did too. ponytail: no fitness classes, no shrink; O(n·k).
51
+ const SHY = '­';
52
+ export function breakLines(text, font, width) {
53
+ const measure = (s) => measureNaturalWidth(prepareWithSegments(s, font));
54
+ const boxes = [];
55
+ const glue = []; // glue[i] sits between boxes[i] and boxes[i + 1]
56
+ const words = text.split(' ').filter(Boolean);
57
+ words.forEach((w, wi) => {
58
+ const frags = w.split(SHY);
59
+ frags.forEach((f, fi) => {
60
+ boxes.push(f);
61
+ if (fi < frags.length - 1)
62
+ glue.push('shy');
63
+ });
64
+ if (wi < words.length - 1)
65
+ glue.push('space');
66
+ });
67
+ const n = boxes.length;
68
+ if (n === 0)
69
+ return [];
70
+ const bw = boxes.map(measure);
71
+ const space = measure('a a') - measure('aa');
72
+ const hyphen = measure('-');
73
+ const badness = (slackPx, spaces) => {
74
+ const r = slackPx / (Math.max(spaces, 1) * space * 0.5);
75
+ return Math.min(10000, 100 * r * r * r);
76
+ };
77
+ // best[h][k]: cheapest way to set boxes[0..k) with a break after, h = 1 when
78
+ // that last line ended inside a word; prev[h][k]: [start of that line, its h].
79
+ const best = [new Array(n + 1).fill(Infinity), new Array(n + 1).fill(Infinity)];
80
+ const prev = [new Array(n + 1), new Array(n + 1)];
81
+ best[0][0] = 0;
82
+ for (let i = 0; i < n; i++) {
83
+ for (const ph of [0, 1]) {
84
+ if (best[ph][i] === Infinity)
85
+ continue;
86
+ let w = 0;
87
+ let spaces = 0;
88
+ for (let j = i; j < n; j++) {
89
+ w += bw[j];
90
+ const last = j === n - 1;
91
+ const h = !last && glue[j] === 'shy' ? 1 : 0;
92
+ const lineW = h ? w + hyphen : w;
93
+ if (lineW > width) {
94
+ if (w > width)
95
+ break;
96
+ continue; // only the hyphen overflows — try the next box
97
+ }
98
+ const b = last ? 0 : badness(width - lineW, spaces);
99
+ const cost = last ? 0 : (10 + b) ** 2 + (h ? 50 ** 2 : 0) + (h && ph ? 10000 : 0);
100
+ if (best[ph][i] + cost < best[h][j + 1]) {
101
+ best[h][j + 1] = best[ph][i] + cost;
102
+ prev[h][j + 1] = [i, ph];
103
+ }
104
+ if (!last && glue[j] === 'space') {
105
+ w += space;
106
+ spaces++;
107
+ }
108
+ }
109
+ }
110
+ }
111
+ let h = best[0][n] <= best[1][n] ? 0 : 1;
112
+ if (best[h][n] === Infinity)
113
+ return [text]; // a box wider than the column: let the browser cope
114
+ const lines = [];
115
+ for (let k = n; k > 0;) {
116
+ const [i, ph] = prev[h][k];
117
+ let line = '';
118
+ for (let b = i; b < k; b++)
119
+ line += boxes[b] + (b < k - 1 && glue[b] === 'space' ? ' ' : '');
120
+ if (h)
121
+ line += '-';
122
+ lines.unshift(line);
123
+ k = i;
124
+ h = ph;
125
+ }
126
+ return lines;
127
+ }
128
+ /**
129
+ * `<p {@attach typeset(text)}>` — sets the paragraph as justified Knuth-Plass
130
+ * lines, one block span per line, re-done on resize and after webfonts load.
131
+ * Progressive: the server-rendered text is what crawlers and no-JS get; if the
132
+ * browser's own measure disagrees with ours (a line overflows), the plain
133
+ * text goes back and the browser breaks it. Pass the text, not the DOM — the
134
+ * span rebuild replaces the framework's text node.
135
+ */
136
+ export function typeset(text) {
137
+ return (el) => {
138
+ if (getComputedStyle(el).whiteSpace !== 'normal')
139
+ return;
140
+ const font = fontOf(el);
141
+ let width = 0;
142
+ const apply = () => {
143
+ if (!width)
144
+ return;
145
+ const lines = breakLines(text, font, width);
146
+ el.replaceChildren(...lines.map((l, i) => {
147
+ const s = document.createElement('span');
148
+ s.style.display = 'block';
149
+ s.style.textAlign = 'justify';
150
+ s.style.textAlignLast = i === lines.length - 1 ? 'auto' : 'justify';
151
+ s.textContent = l;
152
+ return s;
153
+ }));
154
+ for (const s of el.children)
155
+ if (s.scrollWidth > s.clientWidth + 1) {
156
+ el.textContent = text;
157
+ return;
158
+ }
159
+ };
160
+ const ro = new ResizeObserver(([e]) => {
161
+ if (e.contentRect.width === width)
162
+ return;
163
+ width = e.contentRect.width;
164
+ apply();
165
+ });
166
+ ro.observe(el);
167
+ document.fonts?.ready.then(apply);
168
+ return () => ro.disconnect();
169
+ };
170
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nomideusz/svelte-calendar",
3
- "version": "0.15.2",
3
+ "version": "0.16.0",
4
4
  "description": "A themeable Svelte 5 calendar with Day and Week views — Planner and Agenda.",
5
5
  "type": "module",
6
6
  "sideEffects": [
@@ -61,6 +61,7 @@
61
61
  "vitest": "^4.1.10"
62
62
  },
63
63
  "dependencies": {
64
+ "@chenglou/pretext": "^0.0.9",
64
65
  "date-fns": "^4.4.0",
65
66
  "date-fns-tz": "^3.2.0"
66
67
  },
@@ -0,0 +1 @@
1
+ export {};
package/widget/widget.js CHANGED
@@ -5684,6 +5684,11 @@ createHTML: (html) => {
5684
5684
  function upsertEvent(ev) {
5685
5685
  eventMap.set(ev.id, ev);
5686
5686
  }
5687
+ function merge(fetched, range) {
5688
+ const keep = new Set(fetched.map((ev) => ev.id));
5689
+ for (const ev of [...eventMap.values()]) if (!keep.has(ev.id) && overlaps(ev, range.start, range.end)) removeEvent(ev.id);
5690
+ for (const ev of fetched) upsertEvent(ev);
5691
+ }
5687
5692
  return {
5688
5693
  get events() {
5689
5694
  return get(eventArray);
@@ -5696,14 +5701,19 @@ createHTML: (html) => {
5696
5701
  },
5697
5702
  async load(range) {
5698
5703
  const seq = ++loadSeq;
5704
+ const adapter = getAdapter();
5705
+ const sync = adapter.fetchEventsSync?.(range);
5706
+ if (sync) {
5707
+ set(error, null);
5708
+ untrack(() => merge(sync, range));
5709
+ return;
5710
+ }
5699
5711
  set(loading, true);
5700
5712
  set(error, null);
5701
5713
  try {
5702
- const fetched = await getAdapter().fetchEvents(range);
5714
+ const fetched = await adapter.fetchEvents(range);
5703
5715
  if (seq !== loadSeq) return;
5704
- const keep = new Set(fetched.map((ev) => ev.id));
5705
- for (const ev of [...eventMap.values()]) if (!keep.has(ev.id) && overlaps(ev, range.start, range.end)) removeEvent(ev.id);
5706
- for (const ev of fetched) upsertEvent(ev);
5716
+ merge(fetched, range);
5707
5717
  } catch (e) {
5708
5718
  set(error, e instanceof Error ? e.message : String(e), true);
5709
5719
  } finally {
@@ -12493,9 +12503,11 @@ createHTML: (html) => {
12493
12503
  function overlaps(ev, range) {
12494
12504
  return ev.start < range.end && ev.end > range.start;
12495
12505
  }
12506
+ const fetchEventsSync = (range) => events.filter((ev) => overlaps(ev, range)).map(withColor);
12496
12507
  return {
12508
+ fetchEventsSync,
12497
12509
  async fetchEvents(range) {
12498
- return events.filter((ev) => overlaps(ev, range)).map(withColor);
12510
+ return fetchEventsSync(range);
12499
12511
  },
12500
12512
  async createEvent(data) {
12501
12513
  const ev = {