explico 0.7.0 → 0.11.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/CHANGELOG.md +1409 -1
- package/README.md +60 -15
- package/dist/compiler/astro.d.ts +6 -1
- package/dist/compiler/cli.d.ts +54 -3
- package/dist/compiler/cli.js +80 -6
- package/dist/compiler/collections.d.ts +35 -2
- package/dist/compiler/collections.js +84 -8
- package/dist/compiler/course-config.d.ts +3 -194
- package/dist/compiler/course-config.js +78 -5
- package/dist/compiler/exports/standalone-html.d.ts +6 -1
- package/dist/compiler/knowledge-version.js +1 -1
- package/dist/compiler/lesson-derivation.d.ts +27 -10
- package/dist/compiler/lesson-derivation.js +23 -8
- package/dist/compiler/logging.d.ts +14 -1
- package/dist/compiler/logging.js +6 -2
- package/dist/compiler/manifest.d.ts +204 -31
- package/dist/compiler/manifest.js +270 -55
- package/dist/compiler/resolution-report.d.ts +6 -1
- package/dist/course-config-Dsnri9Tk.d.ts +464 -0
- package/dist/reference/base-accents.d.mts +26 -0
- package/dist/reference/base-accents.mjs +31 -0
- package/dist/reference/base-roman-labels.d.mts +2 -2
- package/dist/reference/base-roman-labels.mjs +2 -2
- package/dist/reference/consistency.d.ts +14 -2
- package/dist/reference/consistency.js +108 -4
- package/dist/reference/delimiter-size.d.ts +21 -11
- package/dist/reference/delimiter-size.js +15 -39
- package/dist/reference/diagrams-validate.d.ts +7 -2
- package/dist/reference/diagrams-validate.js +9 -1
- package/dist/reference/equations-validate.d.ts +8 -5
- package/dist/reference/equations-validate.js +1 -0
- package/dist/reference/equations.d.mts +69 -34
- package/dist/reference/equations.mjs +90 -44
- package/dist/reference/examples-validate.d.ts +31 -0
- package/dist/reference/examples-validate.js +48 -0
- package/dist/reference/figures-validate.d.ts +1 -1
- package/dist/reference/figures-validate.js +4 -1
- package/dist/reference/folds-validate.d.ts +33 -0
- package/dist/reference/folds-validate.js +32 -0
- package/dist/reference/gate-abbreviations.d.ts +3 -1
- package/dist/reference/gate-abbreviations.js +3 -2
- package/dist/reference/gate-course-rules.d.ts +32 -0
- package/dist/reference/gate-course-rules.js +85 -0
- package/dist/reference/gate-heading-length.d.ts +5 -2
- package/dist/reference/gate-heading-length.js +47 -23
- package/dist/reference/gate-heading-structure.d.ts +41 -0
- package/dist/reference/gate-heading-structure.js +113 -0
- package/dist/reference/gate-math-punctuation.d.ts +71 -0
- package/dist/reference/gate-math-punctuation.js +251 -0
- package/dist/reference/gate-math.d.ts +44 -6
- package/dist/reference/gate-math.js +63 -10
- package/dist/reference/gate-part-pages.d.ts +50 -0
- package/dist/reference/gate-part-pages.js +118 -0
- package/dist/reference/gate-prose-notation.d.ts +32 -11
- package/dist/reference/gate-prose-notation.js +30 -8
- package/dist/reference/katex-delimiters.d.mts +15 -0
- package/dist/reference/katex-delimiters.mjs +138 -0
- package/dist/reference/katex-options.mjs +4 -0
- package/dist/reference/keyed-float-validate.d.ts +27 -10
- package/dist/reference/keyed-float-validate.js +15 -1
- package/dist/reference/keyed-jsx-floats.d.mts +11 -5
- package/dist/reference/keyed-jsx-floats.mjs +73 -20
- package/dist/reference/knowledge-checks-validate.d.ts +1 -1
- package/dist/reference/knowledge-checks.d.mts +3 -1
- package/dist/reference/knowledge-checks.mjs +9 -4
- package/dist/reference/lab-math-scope.d.ts +1 -0
- package/dist/reference/lab-math-slot.d.ts +34 -0
- package/dist/reference/lab-math-slot.js +6 -0
- package/dist/reference/math-glyphs.d.mts +80 -4
- package/dist/reference/math-glyphs.mjs +588 -39
- package/dist/reference/page-notation.d.ts +6 -1
- package/dist/reference/raw-floats-validate.js +1 -1
- package/dist/reference/raw-floats.mjs +1 -1
- package/dist/reference/rehype-heading-anchors.d.mts +14 -0
- package/dist/reference/rehype-heading-anchors.mjs +116 -17
- package/dist/reference/rehype-table-header-labels.d.mts +6 -1
- package/dist/reference/rehype-table-header-labels.mjs +21 -3
- package/dist/reference/remark-asides.d.mts +3 -0
- package/dist/reference/remark-asides.mjs +180 -0
- package/dist/reference/remark-memo.d.mts +3 -1
- package/dist/reference/remark-memo.mjs +42 -11
- package/dist/reference/remark-notation.d.mts +60 -10
- package/dist/reference/remark-notation.mjs +387 -36
- package/dist/reference/render-lab-math.d.ts +4 -26
- package/dist/reference/render-lab-math.js +5 -5
- package/dist/reference/sections-validate.d.ts +27 -0
- package/dist/reference/sections-validate.js +41 -0
- package/dist/reference/sections.d.mts +97 -0
- package/dist/reference/sections.mjs +306 -0
- package/dist/reference/tables-validate.d.ts +1 -1
- package/dist/reference/types.d.ts +24 -1
- package/dist/reference/unit-words.d.ts +37 -0
- package/dist/reference/unit-words.js +170 -0
- package/dist/rules/catalog.d.ts +176 -0
- package/dist/rules/catalog.js +983 -0
- package/dist/rules/course-rules.d.ts +115 -0
- package/dist/rules/course-rules.js +292 -0
- package/dist/rules/course-rules.schema.json +138 -0
- package/dist/rules/in-effect.d.ts +53 -0
- package/dist/rules/in-effect.js +142 -0
- package/dist/rules/rules.schema.json +1072 -0
- package/dist/rules/settings.d.ts +3 -0
- package/dist/rules/settings.js +290 -0
- package/dist/rules/waivers.d.ts +53 -0
- package/dist/rules/waivers.js +132 -0
- package/dist/session/PreferenceStore.d.ts +5 -0
- package/dist/session/PreferenceStore.js +9 -0
- package/docs/VERSIONING.md +2 -2
- package/docs/authoring/README.md +232 -40
- package/docs/authoring/agents.md +55 -9
- package/docs/authoring/components.md +178 -22
- package/docs/authoring/migrating.md +1324 -2
- package/docs/authoring/notation.md +616 -344
- package/docs/authoring/prose.md +551 -0
- package/docs/authoring/retired.md +35 -0
- package/docs/authoring/structure-rules.md +422 -40
- package/docs/authoring/structure.md +445 -62
- package/docs/authoring/versioning.md +20 -28
- package/docs/authoring/visualizations.md +142 -20
- package/docs/katex-bracket-scaling.md +191 -0
- package/package.json +3 -2
- package/src/compiler/cli.ts +139 -6
- package/src/compiler/collections.ts +144 -10
- package/src/compiler/course-config.ts +273 -34
- package/src/compiler/knowledge-version.ts +1 -1
- package/src/compiler/lesson-derivation.ts +54 -15
- package/src/compiler/logging.ts +21 -4
- package/src/compiler/manifest.ts +573 -104
- package/src/components/BlockHeading.astro +113 -0
- package/src/components/CourseContents.astro +221 -0
- package/src/components/CurriculumMap.astro +5 -6
- package/src/components/FloatIndex.astro +200 -0
- package/src/components/HoverPanel.astro +431 -79
- package/src/components/PartBridge.astro +27 -0
- package/src/components/PartOverview.astro +401 -0
- package/src/components/PartScope.astro +27 -0
- package/src/components/abbreviation/AbbreviationLayer.astro +49 -51
- package/src/components/assessments/AssessmentRunner.tsx +8 -5
- package/src/components/assessments/AssessmentSet.astro +23 -26
- package/src/components/block-heading.ts +146 -0
- package/src/components/card-arrow.ts +243 -0
- package/src/components/card-drag.ts +631 -0
- package/src/components/card-jump.ts +45 -0
- package/src/components/card-manager.ts +1589 -0
- package/src/components/card-memory.ts +177 -0
- package/src/components/card-placement.ts +504 -0
- package/src/components/card-side.ts +336 -0
- package/src/components/card-travel.ts +197 -0
- package/src/components/citation/CitationLayer.astro +77 -76
- package/src/components/diagram/Diagram.astro +161 -316
- package/src/components/disclosure/course-disclosure.ts +24 -0
- package/src/components/disclosure/hover-preview.ts +122 -42
- package/src/components/disclosure/persisted-disclosure.ts +3 -3
- package/src/components/disclosure/print-as-shown.ts +252 -0
- package/src/components/examples/CompactExample.astro +3 -1
- package/src/components/examples/CompactExamples.astro +101 -94
- package/src/components/figure/Figure.astro +174 -274
- package/src/components/figure/FloatCaption.astro +87 -0
- package/src/components/figure/figure-state.ts +58 -0
- package/src/components/figure/figure-tools.ts +87 -0
- package/src/components/float-name.ts +101 -0
- package/src/components/fold/Fold.astro +125 -0
- package/src/components/fold/fold-id.ts +40 -0
- package/src/components/memo/Memo.astro +162 -231
- package/src/components/memo/ShortNote.astro +6 -6
- package/src/components/notation/NotationFormula.astro +5 -1
- package/src/components/notation/NotationGlossary.astro +24 -14
- package/src/components/notation/NotationLayer.astro +169 -162
- package/src/components/notation/NotationMath.astro +10 -1
- package/src/components/notation/render-notation-math.ts +111 -10
- package/src/components/reference/FloatRefLayer.astro +485 -423
- package/src/components/starlight/AboutPanel.astro +22 -6
- package/src/components/starlight/CollapsibleSections.astro +207 -93
- package/src/components/starlight/DiagramEnhancer.astro +15 -138
- package/src/components/starlight/HeadingAnchorEnhancer.astro +193 -1
- package/src/components/starlight/KeyedFloatEnhancer.astro +100 -9
- package/src/components/starlight/LayoutHeader.astro +298 -19
- package/src/components/starlight/LayoutPageSidebar.astro +99 -12
- package/src/components/starlight/LayoutPageTitle.astro +120 -0
- package/src/components/starlight/LayoutSidebar.astro +229 -11
- package/src/components/starlight/LessonExportActions.astro +53 -72
- package/src/components/starlight/LessonFooter.astro +26 -4
- package/src/components/starlight/section-parts.ts +15 -2
- package/src/components/starlight/section-summary.ts +181 -0
- package/src/components/table/Table.astro +95 -76
- package/src/components/table/TableOverflowExpander.astro +65 -28
- package/src/content-config.ts +40 -4
- package/src/reference/base-accents.mjs +31 -0
- package/src/reference/base-roman-labels.mjs +2 -2
- package/src/reference/consistency.ts +176 -5
- package/src/reference/delimiter-size.ts +40 -61
- package/src/reference/diagrams-validate.ts +17 -1
- package/src/reference/equations-validate.ts +10 -5
- package/src/reference/equations.mjs +90 -44
- package/src/reference/examples-validate.ts +97 -0
- package/src/reference/figures-validate.ts +6 -1
- package/src/reference/folds-validate.ts +65 -0
- package/src/reference/gate-abbreviations.ts +3 -2
- package/src/reference/gate-course-rules.ts +134 -0
- package/src/reference/gate-heading-length.ts +68 -37
- package/src/reference/gate-heading-structure.ts +193 -0
- package/src/reference/gate-math-punctuation.ts +381 -0
- package/src/reference/gate-math.ts +119 -12
- package/src/reference/gate-part-pages.ts +217 -0
- package/src/reference/gate-prose-notation.ts +74 -13
- package/src/reference/katex-delimiters.mjs +138 -0
- package/src/reference/katex-options.mjs +4 -0
- package/src/reference/keyed-float-validate.ts +45 -11
- package/src/reference/keyed-jsx-floats.mjs +73 -20
- package/src/reference/knowledge-checks-validate.ts +1 -1
- package/src/reference/knowledge-checks.mjs +9 -4
- package/src/reference/lab-math-slot.ts +36 -0
- package/src/reference/math-glyphs.mjs +588 -39
- package/src/reference/raw-floats-validate.ts +1 -1
- package/src/reference/raw-floats.mjs +1 -1
- package/src/reference/rehype-heading-anchors.mjs +116 -17
- package/src/reference/rehype-table-header-labels.mjs +21 -3
- package/src/reference/remark-asides.mjs +180 -0
- package/src/reference/remark-memo.mjs +42 -11
- package/src/reference/remark-notation.mjs +387 -36
- package/src/reference/render-lab-math.ts +17 -32
- package/src/reference/sections-validate.ts +81 -0
- package/src/reference/sections.mjs +306 -0
- package/src/reference/tables-validate.ts +2 -1
- package/src/reference/types.ts +24 -0
- package/src/reference/unit-words.ts +233 -0
- package/src/rules/catalog.ts +1298 -0
- package/src/rules/course-rules.ts +456 -0
- package/src/rules/in-effect.ts +235 -0
- package/src/rules/settings.ts +448 -0
- package/src/rules/waivers.ts +249 -0
- package/src/session/PreferenceStore.ts +15 -0
- package/src/styles/global.css +1471 -163
- package/src/styles/surfaces.css +182 -0
- package/src/components/hover-panel.ts +0 -339
- package/src/components/popup-stack.ts +0 -345
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The side column (rules S1–S10 of `docs/plans/hover-card-consistency.md`).
|
|
3
|
+
*
|
|
4
|
+
* A kept card can sit to the right of the article, level with its trigger's
|
|
5
|
+
* line, instead of next to the trigger. The room comes from sliding the
|
|
6
|
+
* article left — only while side cards are open, only as far as they need,
|
|
7
|
+
* and at most to the navigation's edge — never from covering the "On this
|
|
8
|
+
* page" rail. When even a fully left-aligned article leaves too little room,
|
|
9
|
+
* there is no side column (S7).
|
|
10
|
+
*
|
|
11
|
+
* The geometry is pure (`sideColumn`, `layoutColumn`) over numbers measured
|
|
12
|
+
* with the article at rest, so `tests/unit/card-side.test.ts` checks it in
|
|
13
|
+
* Node against the measured playground. The DOM half measures the page
|
|
14
|
+
* (`measureSide`), slides the article (`slideArticle`) and runs the "Keep
|
|
15
|
+
* the article left" toggle (S9). The card manager (`./card-manager.ts`)
|
|
16
|
+
* decides when any of it happens.
|
|
17
|
+
*/
|
|
18
|
+
import { createBrowserPreferenceStore } from '../session/PreferenceStore';
|
|
19
|
+
|
|
20
|
+
/** Rounding slack for comparing measured, fractional pixel positions. */
|
|
21
|
+
const EPSILON = 0.5;
|
|
22
|
+
|
|
23
|
+
const clamp = (value: number, low: number, high: number) =>
|
|
24
|
+
Math.max(low, Math.min(value, high));
|
|
25
|
+
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
// The geometry, pure.
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
/** What the side column needs from the page, measured with the article at rest (not slid). */
|
|
31
|
+
export interface SideMeasure {
|
|
32
|
+
/** The article's right text edge, at rest. */
|
|
33
|
+
readonly restRight: number;
|
|
34
|
+
/**
|
|
35
|
+
* How far the article can slide left: its start margin at rest. Slid that
|
|
36
|
+
* far, it starts at the navigation's edge plus the content padding.
|
|
37
|
+
*/
|
|
38
|
+
readonly maxSlide: number;
|
|
39
|
+
/** The rail's left edge, open or collapsed to its strip; the window's right edge without one. */
|
|
40
|
+
readonly railLeft: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface SideOptions {
|
|
44
|
+
/** S1: from the article's text edge to a side card, and from a side card to the rail. */
|
|
45
|
+
readonly gap: number;
|
|
46
|
+
/** S1: the narrowest and the widest a side card gets. */
|
|
47
|
+
readonly minWidth: number;
|
|
48
|
+
readonly maxWidth: number;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface SideColumn extends SideMeasure {
|
|
52
|
+
/** S1: the room between the fully left-aligned article and the rail, both gaps taken off. */
|
|
53
|
+
readonly room: number;
|
|
54
|
+
/** S1: a side card's width: all the room, up to the widest. */
|
|
55
|
+
readonly width: number;
|
|
56
|
+
/** S3: how far the article slides from rest to fit a side card: only as far as it needs. */
|
|
57
|
+
readonly slide: number;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** S1, S3, S7: the side column, or `null` when even a fully left-aligned article leaves too little room. */
|
|
61
|
+
export function sideColumn(measure: SideMeasure, options: SideOptions): SideColumn | null {
|
|
62
|
+
const maxSlide = Math.max(0, measure.maxSlide);
|
|
63
|
+
const room =
|
|
64
|
+
measure.railLeft - options.gap - (measure.restRight - maxSlide) - options.gap;
|
|
65
|
+
if (room < options.minWidth - EPSILON) return null;
|
|
66
|
+
const width = Math.min(options.maxWidth, room);
|
|
67
|
+
const slide = clamp(
|
|
68
|
+
measure.restRight + options.gap + width + options.gap - measure.railLeft,
|
|
69
|
+
0,
|
|
70
|
+
maxSlide,
|
|
71
|
+
);
|
|
72
|
+
return { ...measure, maxSlide, room, width, slide };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The side column's left edge with the article slid `slide` px left of rest. */
|
|
76
|
+
export const columnLeft = (measure: SideMeasure, slide: number, gap: number): number =>
|
|
77
|
+
measure.restRight - slide + gap;
|
|
78
|
+
|
|
79
|
+
/** The room between the article and the rail, both gaps taken off, with the article slid `slide` px. */
|
|
80
|
+
export const roomAt = (measure: SideMeasure, slide: number, gap: number): number =>
|
|
81
|
+
measure.railLeft - gap - columnLeft(measure, slide, gap);
|
|
82
|
+
|
|
83
|
+
/** A vertical stretch of the column, in page coordinates. */
|
|
84
|
+
export interface Span {
|
|
85
|
+
readonly top: number;
|
|
86
|
+
readonly bottom: number;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface ColumnEntry {
|
|
90
|
+
readonly key: string;
|
|
91
|
+
/** Level with its trigger's line (S2); `null` when the trigger has no box, and the card stays at `top`. */
|
|
92
|
+
readonly ideal: number | null;
|
|
93
|
+
/** Where the card is now. */
|
|
94
|
+
readonly top: number;
|
|
95
|
+
readonly height: number;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export interface ColumnLayoutOptions {
|
|
99
|
+
/** The card being pointed at: it gets its ideal spot before the others (S2). */
|
|
100
|
+
readonly first?: string | null;
|
|
101
|
+
/** The gap between two cards stacked in the column. */
|
|
102
|
+
readonly gap: number;
|
|
103
|
+
/** No card goes above this: the sticky header at the top of the page. */
|
|
104
|
+
readonly minTop: number;
|
|
105
|
+
/** Other cards in the column's way, which do not move. */
|
|
106
|
+
readonly obstacles?: readonly Span[];
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* S2: every side card level with its trigger's line. Overlaps resolve top
|
|
111
|
+
* down — the lower card moves down. The card being pointed at takes its
|
|
112
|
+
* ideal spot first; a card whose line is above it then moves up out of its
|
|
113
|
+
* way, the mirror of the rule, so the column keeps the order of the lines.
|
|
114
|
+
* Returns each card's top.
|
|
115
|
+
*/
|
|
116
|
+
export function layoutColumn(
|
|
117
|
+
entries: readonly ColumnEntry[],
|
|
118
|
+
options: ColumnLayoutOptions,
|
|
119
|
+
): Map<string, number> {
|
|
120
|
+
const { gap, minTop } = options;
|
|
121
|
+
const taken: Span[] = [...(options.obstacles ?? [])];
|
|
122
|
+
const tops = new Map<string, number>();
|
|
123
|
+
|
|
124
|
+
const clashes = (top: number, height: number, span: Span) =>
|
|
125
|
+
top < span.bottom + gap - EPSILON && top + height + gap > span.top + EPSILON;
|
|
126
|
+
/** The first top at or below `want` that keeps `gap` from everything taken. */
|
|
127
|
+
const fitDown = (want: number, height: number) => {
|
|
128
|
+
let top = want;
|
|
129
|
+
for (let moved = true; moved; ) {
|
|
130
|
+
moved = false;
|
|
131
|
+
for (const span of taken) {
|
|
132
|
+
if (clashes(top, height, span)) {
|
|
133
|
+
top = span.bottom + gap;
|
|
134
|
+
moved = true;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return top;
|
|
139
|
+
};
|
|
140
|
+
/** The first top at or above `want` that keeps `gap` from everything taken, if one is below `minTop`. */
|
|
141
|
+
const fitUp = (want: number, height: number) => {
|
|
142
|
+
let top = want;
|
|
143
|
+
for (let moved = true; moved && top >= minTop - EPSILON; ) {
|
|
144
|
+
moved = false;
|
|
145
|
+
for (const span of taken) {
|
|
146
|
+
if (clashes(top, height, span)) {
|
|
147
|
+
top = span.top - gap - height;
|
|
148
|
+
moved = true;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return top >= minTop - EPSILON ? top : null;
|
|
153
|
+
};
|
|
154
|
+
const put = (entry: ColumnEntry, top: number) => {
|
|
155
|
+
tops.set(entry.key, top);
|
|
156
|
+
taken.push({ top, bottom: top + entry.height });
|
|
157
|
+
};
|
|
158
|
+
const want = (entry: ColumnEntry) => Math.max(entry.ideal ?? entry.top, minTop);
|
|
159
|
+
|
|
160
|
+
// A card whose trigger has no box has no line to follow: it stays.
|
|
161
|
+
for (const entry of entries) if (entry.ideal === null) put(entry, entry.top);
|
|
162
|
+
const lined = entries.filter((entry) => entry.ideal !== null);
|
|
163
|
+
const first = lined.find((entry) => entry.key === options.first);
|
|
164
|
+
const rest = lined.filter((entry) => entry !== first).sort((a, b) => want(a) - want(b));
|
|
165
|
+
if (first) {
|
|
166
|
+
put(first, fitDown(want(first), first.height));
|
|
167
|
+
// Above the pointed card, nearest first: up out of its way, or, with no
|
|
168
|
+
// room left above, down past it.
|
|
169
|
+
for (const entry of rest.filter((e) => want(e) < want(first)).reverse()) {
|
|
170
|
+
put(entry, fitUp(want(entry), entry.height) ?? fitDown(want(entry), entry.height));
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
for (const entry of rest.filter((e) => !first || want(e) >= want(first))) {
|
|
174
|
+
put(entry, fitDown(want(entry), entry.height));
|
|
175
|
+
}
|
|
176
|
+
return tops;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// ---------------------------------------------------------------------------
|
|
180
|
+
// The DOM layer.
|
|
181
|
+
// ---------------------------------------------------------------------------
|
|
182
|
+
|
|
183
|
+
/** Set on `<html>` while the article is slid; `global.css` reads it with the start margin below. */
|
|
184
|
+
const SLID_ATTRIBUTE = 'data-article-slid';
|
|
185
|
+
/** The article's start margin while slid, which `global.css` hands to `--sl-content-margin-inline`. */
|
|
186
|
+
const START_PROPERTY = '--explico-article-start';
|
|
187
|
+
/** S9, remembered across pages and visits. */
|
|
188
|
+
const KEEP_LEFT_KEY = 'explico:layout:keep-article-left';
|
|
189
|
+
/** S7: below this there is never a side column, the same breakpoint Starlight drops the rail at. */
|
|
190
|
+
const SIDE_QUERY = '(min-width: 72rem)';
|
|
191
|
+
|
|
192
|
+
const root = () => document.documentElement;
|
|
193
|
+
|
|
194
|
+
/** The article's text column on this page: the main pane's, never a card's or an overlay's copy. */
|
|
195
|
+
const articleOf = () => document.querySelector<HTMLElement>('.main-pane .sl-markdown-content');
|
|
196
|
+
|
|
197
|
+
/** The boxes that move when the article slides: the title's and the article's containers. */
|
|
198
|
+
export const articleContainers = (): HTMLElement[] => [
|
|
199
|
+
...document.querySelectorAll<HTMLElement>('.main-pane .sl-container'),
|
|
200
|
+
];
|
|
201
|
+
|
|
202
|
+
/** The article's left text edge where it is now, or `null` when the page has none. */
|
|
203
|
+
export function articleLeft(): number | null {
|
|
204
|
+
const rect = articleOf()?.getBoundingClientRect();
|
|
205
|
+
return rect && rect.width > 0 ? rect.left : null;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** The top of the first line a trigger sits on, or `null` when it has no box. */
|
|
209
|
+
export function lineTopOf(trigger: HTMLElement): number | null {
|
|
210
|
+
if (!trigger.isConnected) return null;
|
|
211
|
+
const rect = trigger.getClientRects()[0];
|
|
212
|
+
return rect ? rect.top : null;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** The sticky header's bottom edge. */
|
|
216
|
+
export function headerBottom(): number {
|
|
217
|
+
return document.querySelector<HTMLElement>('.header')?.getBoundingClientRect().bottom ?? 0;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** A CSS length in `rem` (the constants' `18rem`, `23rem`) as pixels. */
|
|
221
|
+
export function remToPx(length: string): number {
|
|
222
|
+
const size = Number.parseFloat(getComputedStyle(root()).fontSize) || 16;
|
|
223
|
+
return Number.parseFloat(length) * (length.trim().endsWith('rem') ? size : 1);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* The rail's left edge. Collapsed, it is the strip's edge: the rail itself
|
|
228
|
+
* widens over the article while it peeks, which is no reason to move a card.
|
|
229
|
+
*/
|
|
230
|
+
export function railLeftEdge(): number {
|
|
231
|
+
const windowRight = root().clientWidth;
|
|
232
|
+
const rail = document.querySelector<HTMLElement>('.right-sidebar');
|
|
233
|
+
if (!rail || rail.getClientRects().length === 0) return windowRight;
|
|
234
|
+
if (root().hasAttribute('data-page-sidebar-hidden')) {
|
|
235
|
+
const strip = document
|
|
236
|
+
.querySelector<HTMLElement>('.right-sidebar-container')
|
|
237
|
+
?.getBoundingClientRect();
|
|
238
|
+
if (strip && strip.width > 0) return strip.left;
|
|
239
|
+
}
|
|
240
|
+
const rect = rail.getBoundingClientRect();
|
|
241
|
+
return rect.width > 0 ? rect.left : windowRight;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* S1, S3, S7: the article and the rail, measured with the article at rest.
|
|
246
|
+
* `null` when the layout can never have a side column: below 72rem, a
|
|
247
|
+
* right-to-left page, or a page without an article.
|
|
248
|
+
*/
|
|
249
|
+
export function measureSide(): SideMeasure | null {
|
|
250
|
+
if (!window.matchMedia(SIDE_QUERY).matches) return null;
|
|
251
|
+
if (getComputedStyle(root()).direction === 'rtl') return null;
|
|
252
|
+
const article = articleOf();
|
|
253
|
+
const container = article?.closest<HTMLElement>('.sl-container');
|
|
254
|
+
if (!article || !container) return null;
|
|
255
|
+
// Measure at rest: lift the slide for the length of two reads. Nothing is
|
|
256
|
+
// painted in between.
|
|
257
|
+
const slid = root().hasAttribute(SLID_ATTRIBUTE);
|
|
258
|
+
if (slid) root().removeAttribute(SLID_ATTRIBUTE);
|
|
259
|
+
const rect = article.getBoundingClientRect();
|
|
260
|
+
const margin = Number.parseFloat(getComputedStyle(container).marginInlineStart) || 0;
|
|
261
|
+
if (slid) root().setAttribute(SLID_ATTRIBUTE, '');
|
|
262
|
+
if (rect.width === 0) return null;
|
|
263
|
+
return { restRight: rect.right, maxSlide: Math.max(0, margin), railLeft: railLeftEdge() };
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* S3: slide the article `slide` px left of where it rests, through its start
|
|
268
|
+
* margin (`--sl-content-margin-inline` on `.main-pane`, in `global.css`);
|
|
269
|
+
* `.main-pane`'s width and the rail stay put. `0` gives the margin back to
|
|
270
|
+
* Starlight, which then follows resizes on its own again.
|
|
271
|
+
*/
|
|
272
|
+
export function slideArticle(slide: number, maxSlide: number): void {
|
|
273
|
+
if (slide < EPSILON) {
|
|
274
|
+
root().removeAttribute(SLID_ATTRIBUTE);
|
|
275
|
+
root().style.removeProperty(START_PROPERTY);
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
root().style.setProperty(START_PROPERTY, `${Math.max(0, maxSlide - slide)}px`);
|
|
279
|
+
root().setAttribute(SLID_ATTRIBUTE, '');
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// The one seam for per-viewer UI preferences; this module never touches
|
|
283
|
+
// browser storage itself.
|
|
284
|
+
const preferences = createBrowserPreferenceStore();
|
|
285
|
+
|
|
286
|
+
/** S9: whether the reader asked to keep the article left. Off by default. */
|
|
287
|
+
export const readKeepLeft = (): boolean => preferences.get(KEEP_LEFT_KEY) === 'true';
|
|
288
|
+
|
|
289
|
+
export const writeKeepLeft = (on: boolean): void => preferences.set(KEEP_LEFT_KEY, String(on));
|
|
290
|
+
|
|
291
|
+
export interface KeepLeftToggle {
|
|
292
|
+
/** Show the toggle at the top of the side column (`left`, in viewport x), or hide it. */
|
|
293
|
+
update(state: { readonly visible: boolean; readonly on: boolean; readonly left: number }): void;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* S9: "Keep the article left", a small toggle at the top of the side column,
|
|
298
|
+
* fixed just under the site header. It mounts right after the card layer,
|
|
299
|
+
* so it sits over the cards scrolling past and under the header.
|
|
300
|
+
*/
|
|
301
|
+
export function createKeepLeftToggle(
|
|
302
|
+
after: () => HTMLElement,
|
|
303
|
+
onToggle: (on: boolean) => void,
|
|
304
|
+
): KeepLeftToggle {
|
|
305
|
+
let button: HTMLButtonElement | null = null;
|
|
306
|
+
|
|
307
|
+
const build = () => {
|
|
308
|
+
const el = document.createElement('button');
|
|
309
|
+
el.type = 'button';
|
|
310
|
+
el.className = 'explico-keep-left';
|
|
311
|
+
el.dataset.keepArticleLeft = '';
|
|
312
|
+
el.title = 'Keep the article on the left after the last side card closes';
|
|
313
|
+
el.setAttribute('aria-pressed', 'false');
|
|
314
|
+
const knob = document.createElement('span');
|
|
315
|
+
knob.className = 'explico-keep-left-switch';
|
|
316
|
+
knob.setAttribute('aria-hidden', 'true');
|
|
317
|
+
const label = document.createElement('span');
|
|
318
|
+
label.textContent = 'Keep the article left';
|
|
319
|
+
el.append(knob, label);
|
|
320
|
+
el.addEventListener('click', () => onToggle(el.getAttribute('aria-pressed') !== 'true'));
|
|
321
|
+
return el;
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
return {
|
|
325
|
+
update({ visible, on, left }) {
|
|
326
|
+
if (!visible && !button) return;
|
|
327
|
+
if (!button?.isConnected) {
|
|
328
|
+
button = build();
|
|
329
|
+
after().after(button);
|
|
330
|
+
}
|
|
331
|
+
button.hidden = !visible;
|
|
332
|
+
button.setAttribute('aria-pressed', String(on));
|
|
333
|
+
if (visible) button.style.left = `${Math.round(left)}px`;
|
|
334
|
+
},
|
|
335
|
+
};
|
|
336
|
+
}
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The travel zone (rule C2 of `docs/plans/hover-card-consistency.md`).
|
|
3
|
+
*
|
|
4
|
+
* A card opens next to its trigger, but rarely touching it: a symbol inside
|
|
5
|
+
* a display equation gets its card below the whole equation. After the
|
|
6
|
+
* pointer leaves the trigger toward its card, the card stays open while the
|
|
7
|
+
* pointer is inside the triangle from the leave point to the card's near
|
|
8
|
+
* edge. Outside the triangle it closes after a short grace. Other triggers
|
|
9
|
+
* inside the triangle are held back, so a symbol grazed on the way does not
|
|
10
|
+
* take over; resting on one switches to it.
|
|
11
|
+
*
|
|
12
|
+
* The geometry is pure and works on plain boxes in viewport coordinates, so
|
|
13
|
+
* `tests/unit/card-travel.test.ts` can check it in Node. `startTrip` adds the
|
|
14
|
+
* timers. The card manager (`./card-manager.ts`) and the collapsed-content
|
|
15
|
+
* peeks (`./disclosure/hover-preview.ts`, `CollapsibleSections.astro`) share
|
|
16
|
+
* it; each measures its own card and trigger.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export interface Point {
|
|
20
|
+
readonly x: number;
|
|
21
|
+
readonly y: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** A box in viewport coordinates, as `getBoundingClientRect()` reports it. */
|
|
25
|
+
export interface Box {
|
|
26
|
+
readonly left: number;
|
|
27
|
+
readonly top: number;
|
|
28
|
+
readonly right: number;
|
|
29
|
+
readonly bottom: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The card's two corners on the side facing the trigger: the top corners if
|
|
34
|
+
* the card is below it, the bottom corners if above, else the facing
|
|
35
|
+
* vertical edge.
|
|
36
|
+
*/
|
|
37
|
+
export function nearEdge(card: Box, trigger: Box): [Point, Point] {
|
|
38
|
+
if (card.top >= trigger.bottom - 1) {
|
|
39
|
+
return [
|
|
40
|
+
{ x: card.left, y: card.top },
|
|
41
|
+
{ x: card.right, y: card.top },
|
|
42
|
+
];
|
|
43
|
+
}
|
|
44
|
+
if (card.bottom <= trigger.top + 1) {
|
|
45
|
+
return [
|
|
46
|
+
{ x: card.left, y: card.bottom },
|
|
47
|
+
{ x: card.right, y: card.bottom },
|
|
48
|
+
];
|
|
49
|
+
}
|
|
50
|
+
if (card.left >= trigger.right - 1) {
|
|
51
|
+
return [
|
|
52
|
+
{ x: card.left, y: card.top },
|
|
53
|
+
{ x: card.left, y: card.bottom },
|
|
54
|
+
];
|
|
55
|
+
}
|
|
56
|
+
return [
|
|
57
|
+
{ x: card.right, y: card.top },
|
|
58
|
+
{ x: card.right, y: card.bottom },
|
|
59
|
+
];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const cross = (p: Point, a: Point, b: Point) =>
|
|
63
|
+
(p.x - b.x) * (a.y - b.y) - (a.x - b.x) * (p.y - b.y);
|
|
64
|
+
|
|
65
|
+
/** Whether `p` lies inside triangle `abc` or on its edge. */
|
|
66
|
+
export function pointInTriangle(p: Point, a: Point, b: Point, c: Point): boolean {
|
|
67
|
+
const d1 = cross(p, a, b);
|
|
68
|
+
const d2 = cross(p, b, c);
|
|
69
|
+
const d3 = cross(p, c, a);
|
|
70
|
+
const negative = d1 < 0 || d2 < 0 || d3 < 0;
|
|
71
|
+
const positive = d1 > 0 || d2 > 0 || d3 > 0;
|
|
72
|
+
return !(negative && positive);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Whether `p` lies inside `box` or on its edge. */
|
|
76
|
+
export function pointInBox(box: Box, p: Point): boolean {
|
|
77
|
+
return p.x >= box.left && p.x <= box.right && p.y >= box.top && p.y <= box.bottom;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The triangle a trip keeps open: the leave point and the card's near edge. */
|
|
81
|
+
export function travelZone(apex: Point, card: Box, trigger: Box): [Point, Point, Point] {
|
|
82
|
+
const [near1, near2] = nearEdge(card, trigger);
|
|
83
|
+
return [apex, near1, near2];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface TripOptions {
|
|
87
|
+
/** How long the pointer may stay outside the zone before `onExit`. */
|
|
88
|
+
readonly graceMs: number;
|
|
89
|
+
/** How long the pointer must rest inside the zone before `onRest`. */
|
|
90
|
+
readonly restMs: number;
|
|
91
|
+
/** The longest one trip may last before `onExit`. */
|
|
92
|
+
readonly tripMaxMs: number;
|
|
93
|
+
/** The card, measured live; `null` once it is gone. */
|
|
94
|
+
cardBox(): Box | null;
|
|
95
|
+
/** The trigger the pointer left, measured live; `null` once it is gone. */
|
|
96
|
+
triggerBox(): Box | null;
|
|
97
|
+
/** The trip ran out: the pointer stayed outside the zone, or took too long. */
|
|
98
|
+
onExit(): void;
|
|
99
|
+
/** The pointer reached the card. */
|
|
100
|
+
onLand?(): void;
|
|
101
|
+
/** The pointer rested inside the zone, at `point`, for `restMs`. */
|
|
102
|
+
onRest?(point: Point): void;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export interface Trip {
|
|
106
|
+
/**
|
|
107
|
+
* Feed a pointer position. Ends the trip once it lands on the card. Coming
|
|
108
|
+
* back to the trigger is the caller's to notice, from the trigger's own
|
|
109
|
+
* `pointerover`: a KaTeX glyph's box overlaps its neighbours, so the point
|
|
110
|
+
* where the pointer left it often still lies inside that box.
|
|
111
|
+
*/
|
|
112
|
+
move(point: Point): void;
|
|
113
|
+
/** Whether `point` is inside the zone (a trigger there is held back). */
|
|
114
|
+
holds(point: Point): boolean;
|
|
115
|
+
/** Stop the timers without calling anything. */
|
|
116
|
+
end(): void;
|
|
117
|
+
readonly active: boolean;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Start a trip from `apex`, the point where the pointer left the trigger.
|
|
122
|
+
* Pointer moves are fed in by the caller (`move`), so one document-level
|
|
123
|
+
* `pointermove` listener can serve every trip.
|
|
124
|
+
*/
|
|
125
|
+
export function startTrip(apex: Point, options: TripOptions): Trip {
|
|
126
|
+
let active = true;
|
|
127
|
+
let exitTimer: ReturnType<typeof setTimeout> | null = null;
|
|
128
|
+
let restTimer: ReturnType<typeof setTimeout> | null = null;
|
|
129
|
+
|
|
130
|
+
const clear = () => {
|
|
131
|
+
if (exitTimer !== null) clearTimeout(exitTimer);
|
|
132
|
+
if (restTimer !== null) clearTimeout(restTimer);
|
|
133
|
+
clearTimeout(maxTimer);
|
|
134
|
+
exitTimer = null;
|
|
135
|
+
restTimer = null;
|
|
136
|
+
};
|
|
137
|
+
const end = () => {
|
|
138
|
+
if (!active) return;
|
|
139
|
+
active = false;
|
|
140
|
+
clear();
|
|
141
|
+
};
|
|
142
|
+
const exit = () => {
|
|
143
|
+
if (!active) return;
|
|
144
|
+
end();
|
|
145
|
+
options.onExit();
|
|
146
|
+
};
|
|
147
|
+
const maxTimer = setTimeout(exit, options.tripMaxMs);
|
|
148
|
+
|
|
149
|
+
const zone = () => {
|
|
150
|
+
const card = options.cardBox();
|
|
151
|
+
const trigger = options.triggerBox();
|
|
152
|
+
return card && trigger ? { card, trigger, corners: travelZone(apex, card, trigger) } : null;
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
const holds = (point: Point) => {
|
|
156
|
+
if (!active) return false;
|
|
157
|
+
const measured = zone();
|
|
158
|
+
return !!measured && pointInTriangle(point, ...measured.corners);
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
const move = (point: Point) => {
|
|
162
|
+
if (!active) return;
|
|
163
|
+
const measured = zone();
|
|
164
|
+
if (!measured) {
|
|
165
|
+
end();
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
if (pointInBox(measured.card, point)) {
|
|
169
|
+
end();
|
|
170
|
+
options.onLand?.();
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
if (restTimer !== null) clearTimeout(restTimer);
|
|
174
|
+
restTimer = null;
|
|
175
|
+
if (pointInTriangle(point, ...measured.corners)) {
|
|
176
|
+
if (exitTimer !== null) clearTimeout(exitTimer);
|
|
177
|
+
exitTimer = null;
|
|
178
|
+
restTimer = setTimeout(() => {
|
|
179
|
+
restTimer = null;
|
|
180
|
+
if (active) options.onRest?.(point);
|
|
181
|
+
}, options.restMs);
|
|
182
|
+
} else if (exitTimer === null) {
|
|
183
|
+
exitTimer = setTimeout(exit, options.graceMs);
|
|
184
|
+
}
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
move(apex);
|
|
188
|
+
|
|
189
|
+
return {
|
|
190
|
+
move,
|
|
191
|
+
holds,
|
|
192
|
+
end,
|
|
193
|
+
get active() {
|
|
194
|
+
return active;
|
|
195
|
+
},
|
|
196
|
+
};
|
|
197
|
+
}
|