@nomideusz/svelte-calendar 0.16.1 → 0.20.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -2
- package/dist/adapters/composite.js +7 -2
- package/dist/adapters/recurring.d.ts +16 -0
- package/dist/adapters/recurring.js +21 -5
- package/dist/calendar/Calendar.svelte +13 -1
- package/dist/calendar/Calendar.svelte.d.ts +7 -0
- package/dist/engine/view-state.svelte.d.ts +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.js +1 -1
- package/dist/text-fit.d.ts +30 -0
- package/dist/text-fit.js +62 -7
- package/dist/views/planner/PlannerScroll.svelte +1220 -0
- package/dist/views/planner/PlannerScroll.svelte.d.ts +28 -0
- package/dist/views/planner/PlannerWeek.svelte +98 -13
- package/dist/views/planner/PlannerWeek.svelte.d.ts +7 -0
- package/dist/views/shared/chip-fit.svelte.d.ts +27 -0
- package/dist/views/shared/chip-fit.svelte.js +0 -0
- package/package.json +6 -6
- package/widget/widget.js +13816 -6235
package/README.md
CHANGED
|
@@ -33,10 +33,13 @@ That's it — 6 views (Day/Week × Planner, Agenda, Mobile), auto-coloring, drag
|
|
|
33
33
|
|
|
34
34
|
## Views
|
|
35
35
|
|
|
36
|
-
Switch between **Planner** (time grid), **
|
|
36
|
+
Switch between **Planner** (time grid), **Scroll** (weeks stacked in one
|
|
37
|
+
vertical scroller — drag to move, drop onto a day, scrolls under a drag),
|
|
38
|
+
**Agenda** (list) and the **Month** grid:
|
|
37
39
|
|
|
38
40
|
```svelte
|
|
39
41
|
<Calendar {adapter} view="week-planner" /> <!-- default -->
|
|
42
|
+
<Calendar {adapter} view="week-scroll" />
|
|
40
43
|
<Calendar {adapter} view="day-planner" />
|
|
41
44
|
<Calendar {adapter} view="week-agenda" />
|
|
42
45
|
<Calendar {adapter} view="day-agenda" />
|
|
@@ -760,7 +763,9 @@ Small helpers used by the built-in views, exported for custom rendering:
|
|
|
760
763
|
| `segmentForDay(ev, dayMs)` | The slice of a multi-day event that falls on one day |
|
|
761
764
|
| `createClock()` | Reactive clock (`tick`, `today`) driving now-lines and relative labels |
|
|
762
765
|
| `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. |
|
|
766
|
+
| `fitLabel([long, short])` / `pickFit` / `fits` / `textHeight` / `textWidth` | 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. |
|
|
767
|
+
| `createRecurringAdapter(schedule, { movable })` | Projects a repeating schedule. Occurrences are read-only unless `movable` is set — the adapter stores nothing, so a host that opts in must handle `oneventmove` itself: add the date to the rule's `excludeDates` and keep the moved occurrence wherever its one-off events live. |
|
|
768
|
+
| `fitParts(parts, budget)` | Which parts of a dense chip fit, along either axis. Each part is `{ key, text, font }` to measure or `{ key, size }` to state its cost, plus `priority` (`0` never drops, higher goes first) and `extra` for a gap or icon. Anchors always show — they are what earns the ellipsis — and the first part that does not fit ends it, so a chip never keeps a later detail after dropping an earlier one. A budget of `0` (server-rendered, not yet measured) leaves the anchors. |
|
|
764
769
|
|
|
765
770
|
## Embeddable Widget
|
|
766
771
|
|
|
@@ -41,8 +41,13 @@ export function createCompositeAdapter(adapters, options = {}) {
|
|
|
41
41
|
try {
|
|
42
42
|
return await adapter.updateEvent(id, patch);
|
|
43
43
|
}
|
|
44
|
-
catch {
|
|
45
|
-
//
|
|
44
|
+
catch (e) {
|
|
45
|
+
// A refusal is an answer: the adapter that owns this event
|
|
46
|
+
// cannot write it, and asking the others would turn that
|
|
47
|
+
// into a meaningless "not found" the host cannot act on.
|
|
48
|
+
if (e instanceof Error && e.message.includes('read-only'))
|
|
49
|
+
throw e;
|
|
50
|
+
// Otherwise: not this adapter's event, try the next.
|
|
46
51
|
}
|
|
47
52
|
}
|
|
48
53
|
throw new Error(`Event not found in any adapter: ${id}`);
|
|
@@ -43,6 +43,12 @@ export interface RecurringEvent {
|
|
|
43
43
|
* No events are generated after this date.
|
|
44
44
|
*/
|
|
45
45
|
until?: string;
|
|
46
|
+
/**
|
|
47
|
+
* Dates this rule skips, `"YYYY-MM-DD"`. The occurrence is simply not
|
|
48
|
+
* projected — this is how a host takes one occurrence out of its rule
|
|
49
|
+
* (moved, cancelled, or replaced by a one-off it stores itself).
|
|
50
|
+
*/
|
|
51
|
+
excludeDates?: string[];
|
|
46
52
|
/**
|
|
47
53
|
* Maximum number of occurrences, counted from `startDate`.
|
|
48
54
|
* Alternative to `until` — if both are set the stricter bound wins.
|
|
@@ -74,6 +80,16 @@ export interface RecurringAdapterOptions {
|
|
|
74
80
|
* Defaults to the built-in vivid palette.
|
|
75
81
|
*/
|
|
76
82
|
palette?: string[];
|
|
83
|
+
/**
|
|
84
|
+
* Let projected occurrences be dragged (default: `false`).
|
|
85
|
+
*
|
|
86
|
+
* Off, every occurrence carries `data.readOnly` and the views refuse to
|
|
87
|
+
* move it — correct, because this adapter projects and cannot store the
|
|
88
|
+
* result. Turn it on when YOU can: handle `oneventmove`, add that date to
|
|
89
|
+
* the rule's `excludeDates`, and keep the moved occurrence wherever your
|
|
90
|
+
* one-off events live. A read-only occurrence can always be clicked.
|
|
91
|
+
*/
|
|
92
|
+
movable?: boolean;
|
|
77
93
|
}
|
|
78
94
|
/**
|
|
79
95
|
* Create a CalendarAdapter that projects recurring events onto concrete
|
|
@@ -70,7 +70,7 @@ function createConcreteEvent(rec, date) {
|
|
|
70
70
|
tags: rec.tags,
|
|
71
71
|
location: rec.location,
|
|
72
72
|
resourceId: rec.resourceId,
|
|
73
|
-
data: { ...rec.data, recurringId: rec.id
|
|
73
|
+
data: { ...rec.data, recurringId: rec.id },
|
|
74
74
|
};
|
|
75
75
|
}
|
|
76
76
|
// ── Count → until resolution ────────────────────────────
|
|
@@ -232,7 +232,7 @@ const PALETTE = VIVID_PALETTE;
|
|
|
232
232
|
* Read-only by default — create/update/delete throw.
|
|
233
233
|
*/
|
|
234
234
|
export function createRecurringAdapter(schedule, options = {}) {
|
|
235
|
-
const { mondayStart = true, palette } = options;
|
|
235
|
+
const { mondayStart = true, palette, movable = false } = options;
|
|
236
236
|
const colors = palette?.length ? palette : PALETTE;
|
|
237
237
|
// Auto-color: assign from palette by category/title
|
|
238
238
|
const colorAssignments = new Map();
|
|
@@ -282,14 +282,30 @@ export function createRecurringAdapter(schedule, options = {}) {
|
|
|
282
282
|
break;
|
|
283
283
|
}
|
|
284
284
|
}
|
|
285
|
-
|
|
285
|
+
// Dates a host has taken out of their rule (see `excludeDates`).
|
|
286
|
+
const skipped = new Set();
|
|
287
|
+
for (const rec of schedule) {
|
|
288
|
+
for (const d of rec.excludeDates ?? [])
|
|
289
|
+
skipped.add(`${rec.id}--${d.replace(/-/g, '')}`);
|
|
290
|
+
}
|
|
291
|
+
const kept = skipped.size ? events.filter((e) => !skipped.has(e.id)) : events;
|
|
292
|
+
return movable ? kept : kept.map((e) => ({ ...e, data: { ...e.data, readOnly: true } }));
|
|
286
293
|
};
|
|
287
294
|
return {
|
|
288
295
|
fetchEventsSync,
|
|
289
296
|
async fetchEvents(range) {
|
|
290
297
|
return fetchEventsSync(range);
|
|
291
298
|
},
|
|
292
|
-
//
|
|
293
|
-
//
|
|
299
|
+
// This adapter projects; it stores nothing. It still answers for the
|
|
300
|
+
// occurrences it owns, so a composite can tell "not my event" apart
|
|
301
|
+
// from "mine, and it cannot be written" — the second is what lets the
|
|
302
|
+
// HOST take the move (exclude the date, keep the result itself).
|
|
303
|
+
async updateEvent(id) {
|
|
304
|
+
const ruleId = id.split('--')[0];
|
|
305
|
+
if (!schedule.some((rec) => rec.id === ruleId)) {
|
|
306
|
+
throw new Error(`Event not found: ${id}`);
|
|
307
|
+
}
|
|
308
|
+
throw new Error(`read-only: ${id} is a projected occurrence. Add its date to the rule's excludeDates and store the change yourself.`);
|
|
309
|
+
},
|
|
294
310
|
};
|
|
295
311
|
}
|
|
@@ -27,6 +27,7 @@ import { getLabels, fmtWeekRange } from "../core/locale.js";
|
|
|
27
27
|
import { auto } from "../theme/presets.js";
|
|
28
28
|
import { probeHostTheme, observeHostTheme } from "../theme/auto.js";
|
|
29
29
|
import Planner from "../views/planner/Planner.svelte";
|
|
30
|
+
import PlannerScroll from "../views/planner/PlannerScroll.svelte";
|
|
30
31
|
import Agenda from "../views/agenda/Agenda.svelte";
|
|
31
32
|
import Mobile from "../views/mobile/Mobile.svelte";
|
|
32
33
|
import MonthGrid from "../views/month/MonthGrid.svelte";
|
|
@@ -47,6 +48,12 @@ const DEFAULT_VIEWS = [
|
|
|
47
48
|
mode: "week",
|
|
48
49
|
component: Planner
|
|
49
50
|
},
|
|
51
|
+
{
|
|
52
|
+
id: "week-scroll",
|
|
53
|
+
label: "Scroll",
|
|
54
|
+
mode: "week",
|
|
55
|
+
component: PlannerScroll
|
|
56
|
+
},
|
|
50
57
|
{
|
|
51
58
|
id: "day-agenda",
|
|
52
59
|
label: "Agenda",
|
|
@@ -78,7 +85,7 @@ const DEFAULT_VIEWS = [
|
|
|
78
85
|
component: MonthGrid
|
|
79
86
|
}
|
|
80
87
|
];
|
|
81
|
-
let { adapter, views = DEFAULT_VIEWS, view: activeViewId, theme = auto, autoTheme, mondayStart = true, height: heightProp = 600, borderRadius = 12, dir, locale, labels: labelsProp, readOnly = false, visibleHours, initialDate, snapInterval = 15, minColumnWidth = 110, showModePills = true, showNavigation = true, equalDays = false, showDates = true, hideDays, currentDate, blockedSlots, days, minDuration, maxDuration, disabledDates, compact = false, columns = false, mobile: mobileProp = "auto", event: eventSnippet, empty: emptySnippet, dayHeader: dayHeaderSnippet, header: headerSnippet, navigation: navigationSnippet, oneventclick, oneventcreate, oneventmove, onviewchange, ondatechange, oneventhover, ondayclick, onerror, timezone } = $props();
|
|
88
|
+
let { adapter, views = DEFAULT_VIEWS, view: activeViewId, theme = auto, autoTheme, mondayStart = true, height: heightProp = 600, borderRadius = 12, dir, locale, labels: labelsProp, readOnly = false, visibleHours, initialDate, snapInterval = 15, minColumnWidth = 110, showModePills = true, showNavigation = true, equalDays = false, showDates = true, hideDays, currentDate, blockedSlots, days, minDuration, maxDuration, disabledDates, compact = false, columns = false, mobile: mobileProp = "auto", event: eventSnippet, empty: emptySnippet, dayHeader: dayHeaderSnippet, header: headerSnippet, navigation: navigationSnippet, oneventclick, oneventcreate, onexternaldrop, oneventmove, onviewchange, ondatechange, oneventhover, ondayclick, onerror, timezone } = $props();
|
|
82
89
|
// In readOnly mode, suppress mutation callbacks. With a timezone, the
|
|
83
90
|
// drag plane is zoned wall-clock — hosts always receive real instants.
|
|
84
91
|
const unzone = (d) => timezone ? fromZonedTime(d, timezone) : d;
|
|
@@ -86,6 +93,10 @@ const effectiveCreate = $derived(readOnly || !oneventcreate ? undefined : (range
|
|
|
86
93
|
start: unzone(range.start),
|
|
87
94
|
end: unzone(range.end)
|
|
88
95
|
}));
|
|
96
|
+
const effectiveExternalDrop = $derived(readOnly || !onexternaldrop ? undefined : (info) => onexternaldrop({
|
|
97
|
+
start: unzone(info.start),
|
|
98
|
+
dataTransfer: info.dataTransfer
|
|
99
|
+
}));
|
|
89
100
|
const effectiveMove = $derived(readOnly || !oneventmove ? undefined : (ev, start, end) => oneventmove(ev, unzone(start), unzone(end)));
|
|
90
101
|
// Clicking an event selects it (highlight via selectedEventId) and then
|
|
91
102
|
// notifies the host — selection used to be created but never driven.
|
|
@@ -701,6 +712,7 @@ const navCtx = $derived({
|
|
|
701
712
|
focusDate={viewState.focusDate}
|
|
702
713
|
oneventclick={handleEventClick}
|
|
703
714
|
oneventcreate={effectiveCreate}
|
|
715
|
+
onexternaldrop={effectiveExternalDrop}
|
|
704
716
|
readOnly={readOnly}
|
|
705
717
|
visibleHours={visibleHours}
|
|
706
718
|
selectedEventId={selection.selectedId}
|
|
@@ -129,6 +129,13 @@ interface Props {
|
|
|
129
129
|
start: Date;
|
|
130
130
|
end: Date;
|
|
131
131
|
}) => void;
|
|
132
|
+
/** An HTML5 drag from outside the calendar dropped on the planner grid
|
|
133
|
+
* (a class chip, a template): the pointer's time, snapped, as a real
|
|
134
|
+
* instant — plus the drag's dataTransfer for whatever the source put in. */
|
|
135
|
+
onexternaldrop?: (info: {
|
|
136
|
+
start: Date;
|
|
137
|
+
dataTransfer: DataTransfer;
|
|
138
|
+
}) => void;
|
|
132
139
|
oneventmove?: (event: TimelineEvent, newStart: Date, newEnd: Date) => void;
|
|
133
140
|
onviewchange?: (viewId: CalendarViewId) => void;
|
|
134
141
|
/** Called when the focused date changes (navigation, drag-scroll, etc.) */
|
|
@@ -3,7 +3,7 @@ export type { DateRange };
|
|
|
3
3
|
/**
|
|
4
4
|
* Built-in view IDs. Custom view IDs are also supported — see CalendarViewId.
|
|
5
5
|
*/
|
|
6
|
-
export type BuiltInViewId = 'day-planner' | 'day-agenda' | 'day-mobile' | 'week-planner' | 'week-agenda' | 'week-mobile' | 'month-grid';
|
|
6
|
+
export type BuiltInViewId = 'day-planner' | 'day-agenda' | 'day-mobile' | 'week-planner' | 'week-scroll' | 'week-agenda' | 'week-mobile' | 'month-grid';
|
|
7
7
|
/**
|
|
8
8
|
* Any view identifier. Use built-in strings like 'day-planner' or your own
|
|
9
9
|
* custom IDs like 'day-kanban', 'week-resource', etc.
|
package/dist/index.d.ts
CHANGED
|
@@ -15,4 +15,5 @@ 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';
|
|
18
|
+
export { fits, lineCount, textHeight, textWidth, pickFit, fontOf, fitLabel, fitParts, breakLines, typeset } from './text-fit.js';
|
|
19
|
+
export type { ChipPart } from './text-fit.js';
|
package/dist/index.js
CHANGED
|
@@ -18,4 +18,4 @@ export { wrapAdapterWithTimezone } from './core/timezone.js';
|
|
|
18
18
|
// ─── Headless API ───────────────────────────────────────
|
|
19
19
|
export { createCalendar, createAgenda, createRangeAgenda } from './headless/index.js';
|
|
20
20
|
// ─── Text fitting (pretext) ─────────────────────────────
|
|
21
|
-
export { fits, lineCount, textHeight, pickFit, fontOf, fitLabel, breakLines, typeset } from './text-fit.js';
|
|
21
|
+
export { fits, lineCount, textHeight, textWidth, pickFit, fontOf, fitLabel, fitParts, breakLines, typeset } from './text-fit.js';
|
package/dist/text-fit.d.ts
CHANGED
|
@@ -4,8 +4,38 @@ export declare function lineCount(text: string, font: string, width: number, opt
|
|
|
4
4
|
export declare function fits(text: string, font: string, width: number, lines?: number): boolean;
|
|
5
5
|
/** Height the text will take at `width`, before it is in the DOM. */
|
|
6
6
|
export declare function textHeight(text: string, font: string, width: number, lineHeight: number): number;
|
|
7
|
+
/** Natural (unwrapped) width of `text` in `font`, px. */
|
|
8
|
+
export declare function textWidth(text: string, font: string): number;
|
|
7
9
|
/** Longest candidate (in given order) that fits in `lines`; the last one if none does. */
|
|
8
10
|
export declare function pickFit(candidates: readonly string[], font: string, width: number, lines?: number): string;
|
|
11
|
+
/** One part of a chip. Give `text` + `font` to measure it, or `size` to state its cost. */
|
|
12
|
+
export interface ChipPart {
|
|
13
|
+
/** Names this part in the result. */
|
|
14
|
+
key: string;
|
|
15
|
+
/** The text exactly as it will render. Empty or absent ⇒ the part never shows. */
|
|
16
|
+
text?: string;
|
|
17
|
+
/** Canvas font string for `text` — `fontOf(el)`. */
|
|
18
|
+
font?: string;
|
|
19
|
+
/** A stated cost instead of a measured one: a line's height, an icon's width. */
|
|
20
|
+
size?: number;
|
|
21
|
+
/** Dropped before lower numbers; `0` is an anchor and never drops. Default 1. */
|
|
22
|
+
priority?: number;
|
|
23
|
+
/** What this part costs beside its own size: a gap, a dot, a separator. */
|
|
24
|
+
extra?: number;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Which parts of a chip fit in `budget` px along one axis — width for a row
|
|
28
|
+
* of parts, height for a stack of them.
|
|
29
|
+
*
|
|
30
|
+
* Anchors (`priority: 0`) always show; they are what earns an ellipsis when
|
|
31
|
+
* even they overflow. The rest are added in priority order and the first one
|
|
32
|
+
* that does not fit ends it: a chip reads in one direction, so keeping a later
|
|
33
|
+
* part after dropping an earlier one reads as arbitrary.
|
|
34
|
+
*
|
|
35
|
+
* A budget of 0 — server-rendered, or before the first measurement — leaves
|
|
36
|
+
* the anchors, so the first paint is the important part and nothing else.
|
|
37
|
+
*/
|
|
38
|
+
export declare function fitParts(parts: readonly ChipPart[], budget: number, measure?: (text: string, font: string) => number): Record<string, boolean>;
|
|
9
39
|
/** The element's computed font as a canvas font string. */
|
|
10
40
|
export declare function fontOf(el: Element): string;
|
|
11
41
|
/**
|
package/dist/text-fit.js
CHANGED
|
@@ -13,10 +13,52 @@ export function fits(text, font, width, lines = 1) {
|
|
|
13
13
|
export function textHeight(text, font, width, lineHeight) {
|
|
14
14
|
return layout(prepare(text, font), width, lineHeight).height;
|
|
15
15
|
}
|
|
16
|
+
/** Natural (unwrapped) width of `text` in `font`, px. */
|
|
17
|
+
export function textWidth(text, font) {
|
|
18
|
+
return measureNaturalWidth(prepareWithSegments(text, font));
|
|
19
|
+
}
|
|
16
20
|
/** Longest candidate (in given order) that fits in `lines`; the last one if none does. */
|
|
17
21
|
export function pickFit(candidates, font, width, lines = 1) {
|
|
18
22
|
return candidates.find((c) => fits(c, font, width, lines)) ?? candidates.at(-1) ?? '';
|
|
19
23
|
}
|
|
24
|
+
/**
|
|
25
|
+
* Which parts of a chip fit in `budget` px along one axis — width for a row
|
|
26
|
+
* of parts, height for a stack of them.
|
|
27
|
+
*
|
|
28
|
+
* Anchors (`priority: 0`) always show; they are what earns an ellipsis when
|
|
29
|
+
* even they overflow. The rest are added in priority order and the first one
|
|
30
|
+
* that does not fit ends it: a chip reads in one direction, so keeping a later
|
|
31
|
+
* part after dropping an earlier one reads as arbitrary.
|
|
32
|
+
*
|
|
33
|
+
* A budget of 0 — server-rendered, or before the first measurement — leaves
|
|
34
|
+
* the anchors, so the first paint is the important part and nothing else.
|
|
35
|
+
*/
|
|
36
|
+
export function fitParts(parts, budget, measure = textWidth) {
|
|
37
|
+
const shown = {};
|
|
38
|
+
for (const p of parts)
|
|
39
|
+
shown[p.key] = false;
|
|
40
|
+
const present = parts.filter((p) => p.size !== undefined || !!p.text);
|
|
41
|
+
const anchors = present.filter((p) => (p.priority ?? 1) === 0);
|
|
42
|
+
for (const p of anchors)
|
|
43
|
+
shown[p.key] = true;
|
|
44
|
+
if (!(budget > 0))
|
|
45
|
+
return shown;
|
|
46
|
+
const cost = (p) => (p.size ?? measure(p.text, p.font ?? '')) + (p.extra ?? 0);
|
|
47
|
+
let used = 0;
|
|
48
|
+
for (const p of anchors)
|
|
49
|
+
used += cost(p);
|
|
50
|
+
const optional = present
|
|
51
|
+
.filter((p) => (p.priority ?? 1) !== 0)
|
|
52
|
+
.sort((a, b) => (a.priority ?? 1) - (b.priority ?? 1));
|
|
53
|
+
for (const p of optional) {
|
|
54
|
+
const c = cost(p);
|
|
55
|
+
if (used + c > budget)
|
|
56
|
+
break;
|
|
57
|
+
used += c;
|
|
58
|
+
shown[p.key] = true;
|
|
59
|
+
}
|
|
60
|
+
return shown;
|
|
61
|
+
}
|
|
20
62
|
/** The element's computed font as a canvas font string. */
|
|
21
63
|
export function fontOf(el) {
|
|
22
64
|
const s = getComputedStyle(el);
|
|
@@ -141,23 +183,36 @@ export function typeset(text) {
|
|
|
141
183
|
return;
|
|
142
184
|
const font = fontOf(el);
|
|
143
185
|
let width = 0;
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
186
|
+
// A line span never wraps: canvas and layout disagree by a pixel now and
|
|
187
|
+
// then, and a line that wrapped inside its span — the browser hyphenating
|
|
188
|
+
// the last word with its own dictionary, "małżeń-" / "stwo" — read as a
|
|
189
|
+
// hole in the paragraph. Set as nowrap, a misfit is plain overflow, which
|
|
190
|
+
// is measurable: re-break a touch narrower, and only then hand the text
|
|
191
|
+
// back to the browser.
|
|
192
|
+
const set = (w) => {
|
|
193
|
+
const lines = breakLines(text, font, w);
|
|
148
194
|
el.replaceChildren(...lines.map((l, i) => {
|
|
149
195
|
const s = document.createElement('span');
|
|
150
196
|
s.style.display = 'block';
|
|
197
|
+
s.style.whiteSpace = 'nowrap';
|
|
198
|
+
s.style.hyphens = 'manual';
|
|
151
199
|
s.style.textAlign = 'justify';
|
|
152
200
|
s.style.textAlignLast = i === lines.length - 1 ? 'auto' : 'justify';
|
|
153
201
|
s.textContent = l;
|
|
154
202
|
return s;
|
|
155
203
|
}));
|
|
156
204
|
for (const s of el.children)
|
|
157
|
-
if (s.scrollWidth > s.clientWidth + 1)
|
|
158
|
-
|
|
205
|
+
if (s.scrollWidth > s.clientWidth + 1)
|
|
206
|
+
return false;
|
|
207
|
+
return true;
|
|
208
|
+
};
|
|
209
|
+
const apply = () => {
|
|
210
|
+
if (!width)
|
|
211
|
+
return;
|
|
212
|
+
for (const w of [width, width - 2, width * 0.985, width * 0.97])
|
|
213
|
+
if (set(w))
|
|
159
214
|
return;
|
|
160
|
-
|
|
215
|
+
el.textContent = text;
|
|
161
216
|
};
|
|
162
217
|
const ro = new ResizeObserver(([e]) => {
|
|
163
218
|
if (e.contentRect.width === width)
|