explico 0.2.0 → 0.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.
- package/CHANGELOG.md +173 -0
- package/README.md +67 -7
- package/dist/compiler/astro.d.ts +30 -0
- package/dist/compiler/astro.js +79 -0
- package/dist/compiler/cli.d.ts +16 -0
- package/dist/compiler/cli.js +43 -9
- package/dist/compiler/collections.js +4 -1
- package/dist/compiler/course-config.d.ts +80 -4
- package/dist/compiler/course-config.js +12 -0
- package/dist/compiler/knowledge-version.d.ts +58 -0
- package/dist/compiler/knowledge-version.js +80 -0
- package/dist/compiler/lesson-derivation.d.ts +1 -1
- package/dist/compiler/lesson-derivation.js +2 -2
- package/dist/compiler/manifest.d.ts +98 -2
- package/dist/compiler/manifest.js +153 -24
- package/dist/compiler/resolution-report.d.ts +4 -0
- package/dist/reference/base-operators.d.mts +38 -0
- package/dist/reference/base-operators.mjs +103 -0
- package/dist/reference/consistency.d.ts +7 -2
- package/dist/reference/consistency.js +51 -2
- package/dist/reference/diagram-layout.d.ts +63 -0
- package/dist/reference/diagram-layout.js +122 -0
- package/dist/reference/equations-validate.d.ts +10 -0
- package/dist/reference/equations-validate.js +1 -1
- package/dist/reference/equations.d.mts +80 -13
- package/dist/reference/equations.mjs +109 -19
- package/dist/reference/figures-validate.d.ts +21 -0
- package/dist/reference/figures-validate.js +13 -0
- package/dist/reference/keyed-float-validate.d.ts +73 -0
- package/dist/reference/keyed-float-validate.js +110 -0
- package/dist/reference/keyed-jsx-floats.d.mts +64 -0
- package/dist/reference/keyed-jsx-floats.mjs +191 -0
- package/dist/reference/math-glyphs.d.mts +18 -0
- package/dist/reference/math-glyphs.mjs +92 -0
- package/dist/reference/remark-notation.d.mts +61 -0
- package/dist/reference/remark-notation.mjs +344 -10
- package/dist/reference/tables-validate.d.ts +21 -0
- package/dist/reference/tables-validate.js +13 -0
- package/dist/reference/units-vocab.js +19 -1
- package/docs/VERSIONING.md +66 -0
- package/docs/authoring/README.md +100 -0
- package/docs/authoring/agents.md +63 -0
- package/docs/authoring/migrating.md +169 -0
- package/docs/authoring/notation.md +297 -0
- package/docs/authoring/structure.md +139 -0
- package/docs/authoring/versioning.md +116 -0
- package/docs/authoring/visualizations.md +195 -0
- package/package.json +20 -5
- package/src/compiler/astro.ts +127 -0
- package/src/compiler/cli.ts +52 -7
- package/src/compiler/collections.ts +12 -3
- package/src/compiler/course-config.ts +93 -3
- package/src/compiler/knowledge-version.ts +157 -0
- package/src/compiler/lesson-derivation.ts +2 -2
- package/src/compiler/manifest.ts +320 -26
- package/src/components/CurriculumMap.astro +27 -0
- package/src/components/diagram/Diagram.astro +317 -0
- package/src/components/examples/CompactExamples.astro +1 -1
- package/src/components/figure/Figure.astro +104 -0
- package/src/components/notation/NotationLayer.astro +11 -0
- package/src/components/notation/render-notation-math.ts +13 -2
- package/src/components/reference/FloatRefLayer.astro +671 -0
- package/src/components/starlight/AboutPanel.astro +255 -0
- package/src/components/starlight/HeadingAnchorEnhancer.astro +3 -3
- package/src/components/starlight/KeyedFloatEnhancer.astro +137 -0
- package/src/components/starlight/LayoutSidebar.astro +62 -1
- package/src/components/starlight/LayoutSocialIcons.astro +12 -0
- package/src/components/starlight/LessonFooter.astro +15 -2
- package/src/components/table/Table.astro +105 -0
- package/src/reference/base-operators.mjs +103 -0
- package/src/reference/consistency.ts +83 -2
- package/src/reference/diagram-layout.ts +246 -0
- package/src/reference/equations-validate.ts +8 -1
- package/src/reference/equations.mjs +109 -19
- package/src/reference/figures-validate.ts +40 -0
- package/src/reference/keyed-float-validate.ts +210 -0
- package/src/reference/keyed-jsx-floats.mjs +191 -0
- package/src/reference/math-glyphs.mjs +92 -0
- package/src/reference/remark-notation.mjs +344 -10
- package/src/reference/tables-validate.ts +40 -0
- package/src/reference/units-vocab.ts +18 -0
- package/src/styles/global.css +244 -7
- package/src/components/starlight/EquationEnhancer.astro +0 -87
|
@@ -0,0 +1,671 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* Progressive enhancement for `[[eq-key]]` / `[[tab-key]]` / `[[fig-key]]`
|
|
4
|
+
* references in prose (F4, extended F-tables-figures) — one hover/pin/jump
|
|
5
|
+
* engine shared by all three keyed floats. This generalizes the original
|
|
6
|
+
* equation-only `EquationRefLayer`; the positioning, mutual-exclusion, and
|
|
7
|
+
* pin/jump/Escape mechanics are unchanged, parameterized over `KIND_CONFIG`
|
|
8
|
+
* below so a hover on any of the three trigger classes is handled the same
|
|
9
|
+
* way.
|
|
10
|
+
*
|
|
11
|
+
* The no-JS baseline already works for all three: `a.equation-ref` /
|
|
12
|
+
* `a.table-ref` / `a.figure-ref` are real links to `#eq-`/`#tab-`/`#fig-`
|
|
13
|
+
* anchors, and `KeyedFloatEnhancer.astro` + `global.css`'s `:target` rules
|
|
14
|
+
* handle the landing. This layer adds a *preview*, so pointing at a
|
|
15
|
+
* reference in the prose doesn't require leaving the sentence to see what
|
|
16
|
+
* it points to:
|
|
17
|
+
*
|
|
18
|
+
* - if the referenced float is on the current page and already fully on
|
|
19
|
+
* screen, it is highlighted in place instead of opening a card;
|
|
20
|
+
* - otherwise a small card floats near the reference showing its number,
|
|
21
|
+
* caption (table/figure), content, and a "Jump to …" link.
|
|
22
|
+
*
|
|
23
|
+
* What the card's content shows differs by kind:
|
|
24
|
+
* - equation: the KaTeX formula, cloned from the target (unchanged from the
|
|
25
|
+
* original `EquationRefLayer` behavior).
|
|
26
|
+
* - table: the rendered `<table>`, cloned from the target.
|
|
27
|
+
* - figure: cloned only when the `<Figure>` was authored `previewable`
|
|
28
|
+
* (the default) — an interactive `client:*` island set
|
|
29
|
+
* `previewable={false}` shows its caption and number only, since a
|
|
30
|
+
* cloned copy would be an inert, non-hydrated, id-colliding duplicate,
|
|
31
|
+
* not a working preview.
|
|
32
|
+
*
|
|
33
|
+
* Cross-page references (`[[lesson#eq-key]]` etc.) get the same card: their
|
|
34
|
+
* `href` is a real path to another lesson page, so `showCrossPage` fetches
|
|
35
|
+
* and parses that page once (cached), pulls the `#eq-…`/`#tab-…`/`#fig-…`
|
|
36
|
+
* node out of it, and fills the card exactly as above (cloning across
|
|
37
|
+
* documents via `importNode`). The card always opens — there is nothing in
|
|
38
|
+
* this document to highlight in place — and clicking the reference navigates
|
|
39
|
+
* to the other page, where `KeyedFloatEnhancer.astro` handles the landing.
|
|
40
|
+
*
|
|
41
|
+
* This is a single-panel engine, not the notation layer's stack
|
|
42
|
+
* (`popup-stack.ts`): only one card is ever open at a time — now shared
|
|
43
|
+
* across all three kinds, not just equations — which is also why it's
|
|
44
|
+
* hand-rolled here rather than built on `createHoverPanel`.
|
|
45
|
+
* `claimActiveSurface`/`releaseActiveSurface` are still reused so an open
|
|
46
|
+
* float card and an open citation or notation popup remain mutually
|
|
47
|
+
* exclusive.
|
|
48
|
+
*/
|
|
49
|
+
import HoverPanel from '../HoverPanel.astro';
|
|
50
|
+
---
|
|
51
|
+
|
|
52
|
+
<HoverPanel id="float-ref-panel" label="Reference preview" data-float-ref-panel>
|
|
53
|
+
<div class="float-ref-panel-body">
|
|
54
|
+
<div class="float-ref-panel-main">
|
|
55
|
+
<div class="float-ref-panel-content" data-panel-content></div>
|
|
56
|
+
<a class="float-ref-panel-number" data-panel-number></a>
|
|
57
|
+
</div>
|
|
58
|
+
<p class="float-ref-panel-caption" data-panel-caption></p>
|
|
59
|
+
<a class="float-ref-panel-jump" data-panel-jump
|
|
60
|
+
>Jump to <span data-panel-jump-label></span
|
|
61
|
+
><span aria-hidden="true"> ↗</span></a
|
|
62
|
+
>
|
|
63
|
+
</div>
|
|
64
|
+
</HoverPanel>
|
|
65
|
+
|
|
66
|
+
<script>
|
|
67
|
+
import { claimActiveSurface, releaseActiveSurface } from '../hover-panel';
|
|
68
|
+
|
|
69
|
+
const GROUP = 'float-ref';
|
|
70
|
+
|
|
71
|
+
interface KindConfig {
|
|
72
|
+
readonly triggerClass: string;
|
|
73
|
+
readonly hrefPrefix: string;
|
|
74
|
+
readonly numberClass: string;
|
|
75
|
+
readonly numberDataKey: string;
|
|
76
|
+
readonly highlightClass: string;
|
|
77
|
+
readonly jumpLabel: string;
|
|
78
|
+
/** Only the equation number badge fades until hovered/visited (F4) — a
|
|
79
|
+
* table/figure number is always visible in its caption, so pinning one
|
|
80
|
+
* has nothing to reveal. */
|
|
81
|
+
readonly revealOnPin: boolean;
|
|
82
|
+
fillContent(target: HTMLElement, contentEl: HTMLElement): void;
|
|
83
|
+
captionFor(target: HTMLElement): string;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// `importNode`, not `cloneNode`: `target` may come from a *different*
|
|
87
|
+
// document (a cross-page reference's target, parsed from a fetched page —
|
|
88
|
+
// see `fetchDoc`), and only `importNode` adopts it into this document.
|
|
89
|
+
function cloneEquationFormula(target: HTMLElement): HTMLElement {
|
|
90
|
+
const clone = document.importNode(target, true);
|
|
91
|
+
clone.removeAttribute('id');
|
|
92
|
+
clone.removeAttribute('data-eq-number');
|
|
93
|
+
clone.removeAttribute('data-eq-key');
|
|
94
|
+
clone.removeAttribute('data-eq-auto');
|
|
95
|
+
clone.classList.remove('keyed-equation', 'eq-ref-highlight');
|
|
96
|
+
clone.querySelector('.keyed-equation__number')?.remove();
|
|
97
|
+
return clone;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function cloneBody(target: HTMLElement, bodyClass: string): HTMLElement | null {
|
|
101
|
+
const body = target.querySelector<HTMLElement>(`.${bodyClass}`);
|
|
102
|
+
return body ? document.importNode(body, true) : null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const KINDS: Record<'equation' | 'table' | 'figure', KindConfig> = {
|
|
106
|
+
equation: {
|
|
107
|
+
triggerClass: 'equation-ref',
|
|
108
|
+
hrefPrefix: '#eq-',
|
|
109
|
+
numberClass: 'keyed-equation__number',
|
|
110
|
+
numberDataKey: 'eqNumber',
|
|
111
|
+
highlightClass: 'eq-ref-highlight',
|
|
112
|
+
jumpLabel: 'equation',
|
|
113
|
+
revealOnPin: true,
|
|
114
|
+
fillContent(target, contentEl) {
|
|
115
|
+
contentEl.replaceChildren(cloneEquationFormula(target));
|
|
116
|
+
},
|
|
117
|
+
captionFor() {
|
|
118
|
+
return '';
|
|
119
|
+
},
|
|
120
|
+
},
|
|
121
|
+
table: {
|
|
122
|
+
triggerClass: 'table-ref',
|
|
123
|
+
hrefPrefix: '#tab-',
|
|
124
|
+
numberClass: 'keyed-table__number',
|
|
125
|
+
numberDataKey: 'tabNumber',
|
|
126
|
+
highlightClass: 'tab-ref-highlight',
|
|
127
|
+
jumpLabel: 'table',
|
|
128
|
+
revealOnPin: false,
|
|
129
|
+
fillContent(target, contentEl) {
|
|
130
|
+
const clone = cloneBody(target, 'keyed-table__body');
|
|
131
|
+
contentEl.replaceChildren(...(clone ? [clone] : []));
|
|
132
|
+
},
|
|
133
|
+
captionFor(target) {
|
|
134
|
+
return (
|
|
135
|
+
target.querySelector('.keyed-table__caption-text')?.textContent ?? ''
|
|
136
|
+
);
|
|
137
|
+
},
|
|
138
|
+
},
|
|
139
|
+
figure: {
|
|
140
|
+
triggerClass: 'figure-ref',
|
|
141
|
+
hrefPrefix: '#fig-',
|
|
142
|
+
numberClass: 'keyed-figure__number',
|
|
143
|
+
numberDataKey: 'figNumber',
|
|
144
|
+
highlightClass: 'fig-ref-highlight',
|
|
145
|
+
jumpLabel: 'figure',
|
|
146
|
+
revealOnPin: false,
|
|
147
|
+
fillContent(target, contentEl) {
|
|
148
|
+
const previewable = target.dataset.figPreviewable !== 'false';
|
|
149
|
+
const clone = previewable ? cloneBody(target, 'keyed-figure__body') : null;
|
|
150
|
+
contentEl.replaceChildren(...(clone ? [clone] : []));
|
|
151
|
+
},
|
|
152
|
+
captionFor(target) {
|
|
153
|
+
return (
|
|
154
|
+
target.querySelector('.keyed-figure__caption-text')?.textContent ?? ''
|
|
155
|
+
);
|
|
156
|
+
},
|
|
157
|
+
},
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
const TRIGGER_SELECTOR = Object.values(KINDS)
|
|
161
|
+
.map((kind) => `a.${kind.triggerClass}`)
|
|
162
|
+
.join(', ');
|
|
163
|
+
|
|
164
|
+
function closestElement(node: EventTarget | null, selector: string) {
|
|
165
|
+
return node instanceof Element ? node.closest<HTMLElement>(selector) : null;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function kindOf(trigger: HTMLElement): KindConfig | undefined {
|
|
169
|
+
return Object.values(KINDS).find((kind) =>
|
|
170
|
+
trigger.classList.contains(kind.triggerClass),
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* A `[[lesson#eq-key]]` reference: its `href` is a real path to another page
|
|
176
|
+
* (`/motion/constant-acceleration/#eq-velocity`), not the bare `#eq-…`
|
|
177
|
+
* fragment a same-page reference carries. `remark-notation` also stamps a
|
|
178
|
+
* `data-<prefix>-lesson` attribute, so either signal identifies one.
|
|
179
|
+
*/
|
|
180
|
+
function crossPageHref(
|
|
181
|
+
trigger: HTMLElement,
|
|
182
|
+
kind: KindConfig,
|
|
183
|
+
): { page: string; id: string } | null {
|
|
184
|
+
const href = trigger.getAttribute('href') ?? '';
|
|
185
|
+
if (href.startsWith('#')) return null;
|
|
186
|
+
const hash = href.indexOf('#');
|
|
187
|
+
if (hash === -1) return null;
|
|
188
|
+
const id = href.slice(hash + 1);
|
|
189
|
+
if (!id.startsWith(kind.hrefPrefix.slice(1))) return null;
|
|
190
|
+
return { page: href.slice(0, hash), id };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Parsed target pages, keyed by path. A cross-page preview fetches the
|
|
195
|
+
* referenced lesson once and reuses it for every later hover on this page.
|
|
196
|
+
* A failed fetch drops its entry so a later hover can retry.
|
|
197
|
+
*/
|
|
198
|
+
const pageCache = new Map<string, Promise<Document>>();
|
|
199
|
+
|
|
200
|
+
function fetchDoc(page: string): Promise<Document> {
|
|
201
|
+
let pending = pageCache.get(page);
|
|
202
|
+
if (!pending) {
|
|
203
|
+
pending = fetch(page, { credentials: 'same-origin' })
|
|
204
|
+
.then((response) => {
|
|
205
|
+
if (!response.ok) throw new Error(`${response.status} for ${page}`);
|
|
206
|
+
return response.text();
|
|
207
|
+
})
|
|
208
|
+
.then((html) => new DOMParser().parseFromString(html, 'text/html'))
|
|
209
|
+
.catch((error) => {
|
|
210
|
+
pageCache.delete(page);
|
|
211
|
+
throw error;
|
|
212
|
+
});
|
|
213
|
+
pageCache.set(page, pending);
|
|
214
|
+
}
|
|
215
|
+
return pending;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* A cloned equation preview needs KaTeX's stylesheet to render. Every page
|
|
220
|
+
* that *authors* math already loads it; this only matters for a page whose
|
|
221
|
+
* sole math is a cross-page reference, so copy the `<link>` from the fetched
|
|
222
|
+
* document when this one has none.
|
|
223
|
+
*/
|
|
224
|
+
function ensureKatexStylesheet(sourceDoc: Document): void {
|
|
225
|
+
const selector = 'link[rel="stylesheet"][href*="katex"]';
|
|
226
|
+
if (document.querySelector(selector)) return;
|
|
227
|
+
const link = sourceDoc.querySelector<HTMLLinkElement>(selector);
|
|
228
|
+
if (link) document.head.appendChild(document.importNode(link, true));
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function setup() {
|
|
232
|
+
const panel = document.querySelector<HTMLElement>('[data-float-ref-panel]')!;
|
|
233
|
+
if (!panel || panel.dataset.floatRefReady === 'true') return;
|
|
234
|
+
|
|
235
|
+
const refs = Array.from(
|
|
236
|
+
document.querySelectorAll<HTMLAnchorElement>(
|
|
237
|
+
Object.values(KINDS)
|
|
238
|
+
.map((kind) => `.sl-markdown-content a.${kind.triggerClass}`)
|
|
239
|
+
.join(', '),
|
|
240
|
+
),
|
|
241
|
+
).filter((a) => {
|
|
242
|
+
const kind = kindOf(a);
|
|
243
|
+
if (!kind) return false;
|
|
244
|
+
// Same-page (`#eq-…`) or a resolvable cross-page (`/lesson/#eq-…`) ref.
|
|
245
|
+
return (
|
|
246
|
+
(a.getAttribute('href') ?? '').startsWith(kind.hrefPrefix) ||
|
|
247
|
+
crossPageHref(a, kind) !== null
|
|
248
|
+
);
|
|
249
|
+
});
|
|
250
|
+
if (refs.length === 0) return;
|
|
251
|
+
panel.dataset.floatRefReady = 'true';
|
|
252
|
+
|
|
253
|
+
const refSet = new Set<HTMLElement>(refs);
|
|
254
|
+
const numberEl = panel.querySelector<HTMLAnchorElement>('[data-panel-number]')!;
|
|
255
|
+
const contentEl = panel.querySelector<HTMLElement>('[data-panel-content]')!;
|
|
256
|
+
const captionEl = panel.querySelector<HTMLElement>('[data-panel-caption]')!;
|
|
257
|
+
const jumpEl = panel.querySelector<HTMLAnchorElement>('[data-panel-jump]')!;
|
|
258
|
+
const jumpLabelEl = panel.querySelector<HTMLElement>('[data-panel-jump-label]')!;
|
|
259
|
+
const closeButton = panel.querySelector<HTMLElement>('[data-hover-panel-close]');
|
|
260
|
+
|
|
261
|
+
let activeTrigger: HTMLElement | null = null;
|
|
262
|
+
let activeTarget: HTMLElement | null = null;
|
|
263
|
+
let activeKind: KindConfig | null = null;
|
|
264
|
+
let mode: 'highlight' | 'panel' | null = null;
|
|
265
|
+
let pinned = false;
|
|
266
|
+
let leaveTimer: number | null = null;
|
|
267
|
+
|
|
268
|
+
const targetFor = (trigger: HTMLElement, kind: KindConfig) => {
|
|
269
|
+
const href = trigger.getAttribute('href') ?? '';
|
|
270
|
+
return href.startsWith(kind.hrefPrefix)
|
|
271
|
+
? document.getElementById(href.slice(1))
|
|
272
|
+
: null;
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
const isFullyVisible = (el: HTMLElement) => {
|
|
276
|
+
const rect = el.getBoundingClientRect();
|
|
277
|
+
const headerBottom =
|
|
278
|
+
document
|
|
279
|
+
.querySelector<HTMLElement>('.header')
|
|
280
|
+
?.getBoundingClientRect().bottom ?? 0;
|
|
281
|
+
const top = Math.max(0, headerBottom);
|
|
282
|
+
return (
|
|
283
|
+
rect.top >= top &&
|
|
284
|
+
rect.left >= 0 &&
|
|
285
|
+
rect.bottom <= window.innerHeight &&
|
|
286
|
+
rect.right <= window.innerWidth
|
|
287
|
+
);
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
const reveal = (target: HTMLElement | null, kind: KindConfig) => {
|
|
291
|
+
if (!target || !kind.revealOnPin) return;
|
|
292
|
+
target.querySelector(`.${kind.numberClass}`)?.classList.add('eq-number-revealed');
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
function position(trigger: HTMLElement) {
|
|
296
|
+
const edge = 12;
|
|
297
|
+
const gap = 10;
|
|
298
|
+
const displayEquation = trigger.closest<HTMLElement>('.katex-display');
|
|
299
|
+
const safeRegion = displayEquation ?? trigger;
|
|
300
|
+
const regionRect = safeRegion.getBoundingClientRect();
|
|
301
|
+
const anchorRect = trigger.getBoundingClientRect();
|
|
302
|
+
|
|
303
|
+
const headerBottom =
|
|
304
|
+
document
|
|
305
|
+
.querySelector<HTMLElement>('.header')
|
|
306
|
+
?.getBoundingClientRect().bottom ?? 0;
|
|
307
|
+
const verticalStart = Math.max(edge, headerBottom + edge);
|
|
308
|
+
const verticalEnd = window.innerHeight - edge;
|
|
309
|
+
const spaceAbove = regionRect.top - gap - verticalStart;
|
|
310
|
+
const spaceBelow = verticalEnd - regionRect.bottom - gap;
|
|
311
|
+
|
|
312
|
+
panel.style.maxHeight = `${Math.max(spaceAbove, spaceBelow, 0)}px`;
|
|
313
|
+
let rect = panel.getBoundingClientRect();
|
|
314
|
+
const placeBelow = spaceBelow >= rect.height || spaceBelow >= spaceAbove;
|
|
315
|
+
panel.style.maxHeight = `${Math.max(placeBelow ? spaceBelow : spaceAbove, 0)}px`;
|
|
316
|
+
rect = panel.getBoundingClientRect();
|
|
317
|
+
|
|
318
|
+
let top = placeBelow
|
|
319
|
+
? regionRect.bottom + gap
|
|
320
|
+
: regionRect.top - gap - rect.height;
|
|
321
|
+
let left = anchorRect.left + anchorRect.width / 2 - rect.width / 2;
|
|
322
|
+
|
|
323
|
+
const article = document.querySelector<HTMLElement>('.sl-markdown-content');
|
|
324
|
+
const articleRect = article?.getBoundingClientRect();
|
|
325
|
+
const horizontalStart = Math.max(edge, articleRect?.left ?? edge);
|
|
326
|
+
const horizontalEnd = Math.min(
|
|
327
|
+
window.innerWidth - edge,
|
|
328
|
+
articleRect?.right ?? window.innerWidth - edge,
|
|
329
|
+
);
|
|
330
|
+
|
|
331
|
+
top = Math.max(verticalStart, Math.min(top, verticalEnd - rect.height));
|
|
332
|
+
left =
|
|
333
|
+
horizontalEnd - horizontalStart >= rect.width
|
|
334
|
+
? Math.max(horizontalStart, Math.min(left, horizontalEnd - rect.width))
|
|
335
|
+
: Math.max(edge, Math.min(left, window.innerWidth - rect.width - edge));
|
|
336
|
+
|
|
337
|
+
panel.style.top = `${Math.round(top)}px`;
|
|
338
|
+
panel.style.left = `${Math.round(left)}px`;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function fillPanel(trigger: HTMLElement, target: HTMLElement, kind: KindConfig) {
|
|
342
|
+
const number = (target.dataset[kind.numberDataKey] ?? '') as string;
|
|
343
|
+
const label = kind.jumpLabel;
|
|
344
|
+
const href = trigger.getAttribute('href') ?? '';
|
|
345
|
+
// The number sits to the right of the content and is itself the jump link
|
|
346
|
+
// — for an equation just "(2.1)", for a table/figure the labelled form.
|
|
347
|
+
numberEl.textContent =
|
|
348
|
+
kind.jumpLabel === 'equation'
|
|
349
|
+
? `(${number})`
|
|
350
|
+
: `${label.charAt(0).toUpperCase()}${label.slice(1)} ${number}`;
|
|
351
|
+
numberEl.href = href;
|
|
352
|
+
numberEl.title = `Jump to the ${label}`;
|
|
353
|
+
kind.fillContent(target, contentEl);
|
|
354
|
+
captionEl.textContent = kind.captionFor(target);
|
|
355
|
+
captionEl.hidden = captionEl.textContent.length === 0;
|
|
356
|
+
jumpLabelEl.textContent = label;
|
|
357
|
+
jumpEl.href = href;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function clearAll() {
|
|
361
|
+
if (activeTarget && activeKind) {
|
|
362
|
+
activeTarget.classList.remove(activeKind.highlightClass);
|
|
363
|
+
}
|
|
364
|
+
if (mode === 'panel') {
|
|
365
|
+
panel.hidden = true;
|
|
366
|
+
releaseActiveSurface(GROUP);
|
|
367
|
+
}
|
|
368
|
+
activeTrigger = null;
|
|
369
|
+
activeTarget = null;
|
|
370
|
+
activeKind = null;
|
|
371
|
+
mode = null;
|
|
372
|
+
pinned = false;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Pin the open card and hand the exclusivity group back: a pinned card is a
|
|
377
|
+
* little workspace the reader dwells in, so hovering a symbol *inside* it
|
|
378
|
+
* must be free to open its notation popup without the surface arbiter
|
|
379
|
+
* treating that as "attention moved elsewhere" and closing the card. The
|
|
380
|
+
* card still closes on its "×", on an outside click, or on Escape.
|
|
381
|
+
*/
|
|
382
|
+
function pinPanel(target: HTMLElement | null, kind: KindConfig) {
|
|
383
|
+
pinned = true;
|
|
384
|
+
reveal(target, kind);
|
|
385
|
+
releaseActiveSurface(GROUP);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function show(trigger: HTMLElement, pin: boolean) {
|
|
389
|
+
const kind = kindOf(trigger);
|
|
390
|
+
if (!kind) return;
|
|
391
|
+
|
|
392
|
+
const crossPage = crossPageHref(trigger, kind);
|
|
393
|
+
if (crossPage) {
|
|
394
|
+
void showCrossPage(trigger, kind, crossPage);
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
const target = targetFor(trigger, kind);
|
|
399
|
+
if (!target) return;
|
|
400
|
+
|
|
401
|
+
if (pinned && trigger !== activeTrigger) return;
|
|
402
|
+
|
|
403
|
+
if (trigger === activeTrigger) {
|
|
404
|
+
if (pin) pinPanel(activeTarget, kind);
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
clearAll();
|
|
409
|
+
activeTrigger = trigger;
|
|
410
|
+
activeTarget = target;
|
|
411
|
+
activeKind = kind;
|
|
412
|
+
|
|
413
|
+
if (isFullyVisible(target)) {
|
|
414
|
+
// Never reached with `pin: true` — the click handler only calls
|
|
415
|
+
// `show(trigger, true)` once it has already checked the float is
|
|
416
|
+
// off screen; an on-screen reference is left to navigate natively.
|
|
417
|
+
mode = 'highlight';
|
|
418
|
+
target.classList.add(kind.highlightClass);
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
mode = 'panel';
|
|
423
|
+
fillPanel(trigger, target, kind);
|
|
424
|
+
claimActiveSurface(GROUP, clearAll);
|
|
425
|
+
panel.style.visibility = 'hidden';
|
|
426
|
+
panel.hidden = false;
|
|
427
|
+
position(trigger);
|
|
428
|
+
panel.style.visibility = 'visible';
|
|
429
|
+
if (pin) pinPanel(target, kind);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* The cross-page counterpart of `show`: the target float lives on another
|
|
434
|
+
* lesson page, so there is nothing in this document to highlight in place.
|
|
435
|
+
* Fetch and parse that page once (`fetchDoc` caches it), pull the
|
|
436
|
+
* `#eq-…`/`#tab-…`/`#fig-…` node out of it, and fill the same card — the
|
|
437
|
+
* formula, table body, or (for a `previewable` figure) figure body, cloned
|
|
438
|
+
* across documents by `fillPanel`'s `importNode`-based helpers. A slow
|
|
439
|
+
* fetch that resolves after the pointer has moved on is discarded.
|
|
440
|
+
*/
|
|
441
|
+
async function showCrossPage(
|
|
442
|
+
trigger: HTMLElement,
|
|
443
|
+
kind: KindConfig,
|
|
444
|
+
ref: { page: string; id: string },
|
|
445
|
+
) {
|
|
446
|
+
if (pinned && trigger !== activeTrigger) return;
|
|
447
|
+
if (trigger === activeTrigger) return;
|
|
448
|
+
|
|
449
|
+
clearAll();
|
|
450
|
+
activeTrigger = trigger;
|
|
451
|
+
activeKind = kind;
|
|
452
|
+
|
|
453
|
+
let sourceDoc: Document;
|
|
454
|
+
try {
|
|
455
|
+
sourceDoc = await fetchDoc(ref.page);
|
|
456
|
+
} catch {
|
|
457
|
+
if (activeTrigger === trigger) clearAll();
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
if (activeTrigger !== trigger) return;
|
|
461
|
+
|
|
462
|
+
const target = sourceDoc.getElementById(ref.id);
|
|
463
|
+
if (!target) {
|
|
464
|
+
clearAll();
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
activeTarget = target; // a detached node owned by `sourceDoc`
|
|
469
|
+
mode = 'panel';
|
|
470
|
+
ensureKatexStylesheet(sourceDoc);
|
|
471
|
+
fillPanel(trigger, target, kind);
|
|
472
|
+
claimActiveSurface(GROUP, clearAll);
|
|
473
|
+
panel.style.visibility = 'hidden';
|
|
474
|
+
panel.hidden = false;
|
|
475
|
+
position(trigger);
|
|
476
|
+
panel.style.visibility = 'visible';
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
function scheduleLeave() {
|
|
480
|
+
if (pinned) return;
|
|
481
|
+
if (leaveTimer !== null) window.cancelAnimationFrame(leaveTimer);
|
|
482
|
+
leaveTimer = window.requestAnimationFrame(() => {
|
|
483
|
+
leaveTimer = null;
|
|
484
|
+
if (pinned || !activeTrigger) return;
|
|
485
|
+
const stillActive =
|
|
486
|
+
activeTrigger.matches(':hover') ||
|
|
487
|
+
activeTrigger === document.activeElement ||
|
|
488
|
+
panel.matches(':hover') ||
|
|
489
|
+
panel.contains(document.activeElement);
|
|
490
|
+
if (!stillActive) clearAll();
|
|
491
|
+
});
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
document.addEventListener('pointerover', (event) => {
|
|
495
|
+
if (event.pointerType === 'touch') return;
|
|
496
|
+
const trigger = closestElement(event.target, TRIGGER_SELECTOR);
|
|
497
|
+
if (!trigger || !refSet.has(trigger)) return;
|
|
498
|
+
if (trigger === closestElement(event.relatedTarget, TRIGGER_SELECTOR)) return;
|
|
499
|
+
show(trigger, false);
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
document.addEventListener('pointerout', (event) => {
|
|
503
|
+
if (event.pointerType === 'touch') return;
|
|
504
|
+
const trigger = closestElement(event.target, TRIGGER_SELECTOR);
|
|
505
|
+
if (!trigger || !refSet.has(trigger)) return;
|
|
506
|
+
if (trigger === closestElement(event.relatedTarget, TRIGGER_SELECTOR)) return;
|
|
507
|
+
scheduleLeave();
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
document.addEventListener('focusin', (event) => {
|
|
511
|
+
const trigger = closestElement(event.target, TRIGGER_SELECTOR);
|
|
512
|
+
if (trigger && refSet.has(trigger)) show(trigger, false);
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
document.addEventListener('focusout', (event) => {
|
|
516
|
+
const trigger = closestElement(event.target, TRIGGER_SELECTOR);
|
|
517
|
+
if (trigger && refSet.has(trigger)) scheduleLeave();
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
panel.addEventListener('pointerleave', scheduleLeave);
|
|
521
|
+
|
|
522
|
+
document.addEventListener('click', (event) => {
|
|
523
|
+
if (closeButton && closestElement(event.target, '[data-hover-panel-close]')) {
|
|
524
|
+
event.preventDefault();
|
|
525
|
+
clearAll();
|
|
526
|
+
return;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
const trigger = closestElement(event.target, TRIGGER_SELECTOR);
|
|
530
|
+
if (trigger && refSet.has(trigger)) {
|
|
531
|
+
const kind = kindOf(trigger);
|
|
532
|
+
if (!kind) return;
|
|
533
|
+
|
|
534
|
+
const isCrossPage = crossPageHref(trigger, kind) !== null;
|
|
535
|
+
|
|
536
|
+
// A card that is already open for this trigger — same-page *or*
|
|
537
|
+
// cross-page — pins in place instead of navigating. The only way to
|
|
538
|
+
// the float itself is now the card's own number/"Jump to …" link.
|
|
539
|
+
if (mode === 'panel' && activeTrigger === trigger) {
|
|
540
|
+
event.preventDefault();
|
|
541
|
+
pinPanel(isCrossPage ? null : targetFor(trigger, kind), kind);
|
|
542
|
+
return;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
if (isCrossPage) {
|
|
546
|
+
// No card open yet (keyboard, or a click that beat the hover): open
|
|
547
|
+
// it — pinned — rather than leave the page.
|
|
548
|
+
event.preventDefault();
|
|
549
|
+
const ref = crossPageHref(trigger, kind)!;
|
|
550
|
+
void showCrossPage(trigger, kind, ref).then(() => {
|
|
551
|
+
if (activeTrigger === trigger && mode === 'panel') {
|
|
552
|
+
pinPanel(null, kind);
|
|
553
|
+
}
|
|
554
|
+
});
|
|
555
|
+
return;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
const target = targetFor(trigger, kind);
|
|
559
|
+
if (target && !isFullyVisible(target)) {
|
|
560
|
+
event.preventDefault();
|
|
561
|
+
show(trigger, true);
|
|
562
|
+
}
|
|
563
|
+
// Already fully visible: let the native anchor navigation through —
|
|
564
|
+
// `KeyedFloatEnhancer.astro` picks up the `hashchange` it produces.
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
if (closestElement(event.target, '[data-panel-jump], [data-panel-number]')) {
|
|
569
|
+
// Real navigation to the float is the point of these links; just drop
|
|
570
|
+
// our own state so a stray pointerout doesn't fight it.
|
|
571
|
+
clearAll();
|
|
572
|
+
return;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
if (mode === 'panel' && event.target instanceof Node) {
|
|
576
|
+
// An outside click closes the card — but a click landing in a notation
|
|
577
|
+
// popup that a card symbol opened is "inside" as far as the reader is
|
|
578
|
+
// concerned.
|
|
579
|
+
if (
|
|
580
|
+
!panel.contains(event.target) &&
|
|
581
|
+
!closestElement(event.target, '.notation-popup')
|
|
582
|
+
) {
|
|
583
|
+
clearAll();
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
document.addEventListener('keydown', (event) => {
|
|
589
|
+
if (event.key === 'Escape' && mode) clearAll();
|
|
590
|
+
});
|
|
591
|
+
|
|
592
|
+
window.addEventListener(
|
|
593
|
+
'resize',
|
|
594
|
+
() => {
|
|
595
|
+
if (mode === 'panel' && activeTrigger) position(activeTrigger);
|
|
596
|
+
},
|
|
597
|
+
{ passive: true },
|
|
598
|
+
);
|
|
599
|
+
window.addEventListener(
|
|
600
|
+
'scroll',
|
|
601
|
+
() => {
|
|
602
|
+
if (mode === 'panel' && activeTrigger) position(activeTrigger);
|
|
603
|
+
},
|
|
604
|
+
{ passive: true, capture: true },
|
|
605
|
+
);
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
setup();
|
|
609
|
+
document.addEventListener('astro:page-load', setup);
|
|
610
|
+
</script>
|
|
611
|
+
|
|
612
|
+
<style>
|
|
613
|
+
@layer starlight.components {
|
|
614
|
+
.float-ref-panel-body {
|
|
615
|
+
display: grid;
|
|
616
|
+
gap: 0.6rem;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
.float-ref-panel-main {
|
|
620
|
+
display: flex;
|
|
621
|
+
align-items: baseline;
|
|
622
|
+
justify-content: space-between;
|
|
623
|
+
gap: 0.75rem;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
.float-ref-panel-number {
|
|
627
|
+
flex: none;
|
|
628
|
+
align-self: flex-start;
|
|
629
|
+
margin: 0;
|
|
630
|
+
color: var(--sl-color-gray-3);
|
|
631
|
+
font-weight: 600;
|
|
632
|
+
font-variant-numeric: tabular-nums;
|
|
633
|
+
text-decoration: none;
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
.float-ref-panel-number:hover,
|
|
637
|
+
.float-ref-panel-number:focus-visible {
|
|
638
|
+
color: var(--sl-color-text-accent);
|
|
639
|
+
text-decoration: underline;
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
.float-ref-panel-content {
|
|
643
|
+
min-width: 0;
|
|
644
|
+
overflow-x: auto;
|
|
645
|
+
color: var(--sl-color-text);
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
.float-ref-panel-content:empty {
|
|
649
|
+
display: none;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
.float-ref-panel-content :global(.katex-display) {
|
|
653
|
+
margin-block: 0;
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
.float-ref-panel-content :global(table) {
|
|
657
|
+
margin: 0;
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
.float-ref-panel-caption {
|
|
661
|
+
margin: 0;
|
|
662
|
+
color: var(--sl-color-gray-3);
|
|
663
|
+
font-size: var(--sl-text-xs);
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
.float-ref-panel-jump {
|
|
667
|
+
justify-self: start;
|
|
668
|
+
font-size: var(--sl-text-xs);
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
</style>
|