explico 0.8.0 → 0.11.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/CHANGELOG.md +1257 -1
- package/README.md +60 -15
- package/dist/compiler/astro.d.ts +6 -1
- package/dist/compiler/cli.d.ts +46 -2
- package/dist/compiler/cli.js +70 -4
- package/dist/compiler/collections.d.ts +35 -2
- package/dist/compiler/collections.js +68 -5
- 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 +4 -1
- package/dist/compiler/logging.js +4 -1
- 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 +3 -2
- 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 +60 -31
- package/dist/reference/equations.mjs +83 -43
- 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 +1 -0
- 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 +45 -21
- 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 +22 -6
- package/dist/reference/gate-math.js +42 -8
- 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 +21 -11
- package/dist/reference/gate-prose-notation.js +25 -7
- package/dist/reference/katex-delimiters.mjs +20 -7
- 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 +572 -37
- 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 +4 -4
- 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 +17 -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 +5 -3
- 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 +1152 -2
- package/docs/authoring/notation.md +576 -322
- 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/package.json +2 -2
- package/src/compiler/cli.ts +116 -6
- package/src/compiler/collections.ts +111 -7
- 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 +8 -2
- 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 +3 -4
- package/src/components/notation/NotationLayer.astro +169 -162
- package/src/components/notation/NotationMath.astro +10 -1
- package/src/components/reference/FloatRefLayer.astro +485 -423
- package/src/components/starlight/AboutPanel.astro +5 -4
- 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 +3 -2
- package/src/reference/diagrams-validate.ts +17 -1
- package/src/reference/equations-validate.ts +10 -5
- package/src/reference/equations.mjs +83 -43
- 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 +1 -0
- package/src/reference/gate-course-rules.ts +134 -0
- package/src/reference/gate-heading-length.ts +66 -35
- package/src/reference/gate-heading-structure.ts +193 -0
- package/src/reference/gate-math-punctuation.ts +381 -0
- package/src/reference/gate-math.ts +69 -10
- package/src/reference/gate-part-pages.ts +217 -0
- package/src/reference/gate-prose-notation.ts +59 -12
- package/src/reference/katex-delimiters.mjs +20 -7
- 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 +572 -37
- 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 +16 -31
- 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 +17 -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 +1467 -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,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Remembering a page's open explanation cards. A reader who leaves a lesson
|
|
3
|
+
* with cards pinned, docked or moved finds them where they were on coming
|
|
4
|
+
* back to it: through a link, Back or a reload, in another tab, or after the
|
|
5
|
+
* browser restarts. Closing the last card forgets the page.
|
|
6
|
+
*
|
|
7
|
+
* The card manager (`./card-manager.ts`) saves the kept cards as the page is
|
|
8
|
+
* left (`pagehide`) and restores them as each layer registers. A card is
|
|
9
|
+
* named by its layer and item, and by which occurrence of the item its
|
|
10
|
+
* trigger is, counted in document order within the page, or within its
|
|
11
|
+
* parent card for a card opened from inside another. Its place is saved as
|
|
12
|
+
* it is kept: its anchor next to the trigger, a moved card's spot, a docked
|
|
13
|
+
* card's height against its line.
|
|
14
|
+
*
|
|
15
|
+
* The stored value is plain JSON, checked field by field on the way back
|
|
16
|
+
* (`decodeCards`), so a stale or malformed entry is dropped, never trusted.
|
|
17
|
+
* `decodeCards` and `restorable` are pure, so `tests/unit/card-memory.test.ts`
|
|
18
|
+
* checks them in Node.
|
|
19
|
+
*/
|
|
20
|
+
import { createBrowserPreferenceStore } from '../session/PreferenceStore';
|
|
21
|
+
import type { MovedSpot } from './card-drag';
|
|
22
|
+
import type { CardAnchor } from './card-placement';
|
|
23
|
+
|
|
24
|
+
/** A remembered card's mode: a hovered one only when a kept child held it open (C9). */
|
|
25
|
+
export type RememberedMode = 'hover' | 'pinned' | 'side' | 'moved';
|
|
26
|
+
|
|
27
|
+
export interface RememberedCard {
|
|
28
|
+
/** The card's layer (`notation`, `citation`, …) and item; its key is `layer:item`. */
|
|
29
|
+
readonly layer: string;
|
|
30
|
+
readonly item: string;
|
|
31
|
+
/** Which occurrence of the item its trigger was, in document order within its scope. */
|
|
32
|
+
readonly occurrence: number;
|
|
33
|
+
/** The card it was opened from, restored first; `null` for a card opened from the page. */
|
|
34
|
+
readonly parentKey: string | null;
|
|
35
|
+
readonly mode: RememberedMode;
|
|
36
|
+
/** P1: where it sits next to its trigger (a pinned card's place, every card's home). */
|
|
37
|
+
readonly anchor: CardAnchor | null;
|
|
38
|
+
/** D2: a moved card's spot against the article column and its trigger's line. */
|
|
39
|
+
readonly moved: MovedSpot | null;
|
|
40
|
+
/** S2, D3: a docked card's top against its trigger's line. */
|
|
41
|
+
readonly sideDy: number | null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface RememberedPage {
|
|
45
|
+
/** In the order they were first opened, so Escape closes them in the same order (C10). */
|
|
46
|
+
readonly cards: readonly RememberedCard[];
|
|
47
|
+
/** S2: the docked card pointed at last. */
|
|
48
|
+
readonly pointed: string | null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Bumped whenever the stored shape changes; an entry of another version is dropped. */
|
|
52
|
+
const VERSION = 1;
|
|
53
|
+
|
|
54
|
+
export const EMPTY_PAGE: RememberedPage = { cards: [], pointed: null };
|
|
55
|
+
|
|
56
|
+
export const keyOf = (card: Pick<RememberedCard, 'layer' | 'item'>): string =>
|
|
57
|
+
`${card.layer}:${card.item}`;
|
|
58
|
+
|
|
59
|
+
export function encodeCards(page: RememberedPage): string {
|
|
60
|
+
return JSON.stringify({ v: VERSION, cards: page.cards, pointed: page.pointed });
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
64
|
+
typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
65
|
+
|
|
66
|
+
const isNumber = (value: unknown): value is number =>
|
|
67
|
+
typeof value === 'number' && Number.isFinite(value);
|
|
68
|
+
|
|
69
|
+
const MODES: readonly string[] = ['hover', 'pinned', 'side', 'moved'];
|
|
70
|
+
|
|
71
|
+
function decodeAnchor(value: unknown): CardAnchor | null {
|
|
72
|
+
if (!isRecord(value)) return null;
|
|
73
|
+
const { side, dx, dy, column } = value;
|
|
74
|
+
if ((side !== 'below' && side !== 'above') || !isNumber(dx) || !isNumber(dy)) return null;
|
|
75
|
+
return column === true ? { side, dx, dy, column: true } : { side, dx, dy };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function decodeMoved(value: unknown): MovedSpot | null {
|
|
79
|
+
if (!isRecord(value) || !isNumber(value.x) || !isNumber(value.y)) return null;
|
|
80
|
+
return { x: value.x, y: value.y };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function decodeCard(value: unknown): RememberedCard | null {
|
|
84
|
+
if (!isRecord(value)) return null;
|
|
85
|
+
const { layer, item, occurrence, parentKey, mode } = value;
|
|
86
|
+
if (typeof layer !== 'string' || layer === '' || typeof item !== 'string' || item === '') {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
if (!isNumber(occurrence) || occurrence < 0 || !Number.isInteger(occurrence)) return null;
|
|
90
|
+
if (parentKey !== null && typeof parentKey !== 'string') return null;
|
|
91
|
+
if (typeof mode !== 'string' || !MODES.includes(mode)) return null;
|
|
92
|
+
const moved = decodeMoved(value.moved);
|
|
93
|
+
// A moved card without its spot has nowhere to go.
|
|
94
|
+
if (mode === 'moved' && !moved) return null;
|
|
95
|
+
return {
|
|
96
|
+
layer,
|
|
97
|
+
item,
|
|
98
|
+
occurrence,
|
|
99
|
+
parentKey,
|
|
100
|
+
mode: mode as RememberedMode,
|
|
101
|
+
anchor: decodeAnchor(value.anchor),
|
|
102
|
+
moved: mode === 'moved' ? moved : null,
|
|
103
|
+
sideDy: mode === 'side' && isNumber(value.sideDy) ? value.sideDy : null,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* The page a stored value remembers, or an empty one for anything else: no
|
|
109
|
+
* value, other JSON, another version. Malformed cards are dropped one by
|
|
110
|
+
* one, and so is a card whose parent is not remembered before it.
|
|
111
|
+
*/
|
|
112
|
+
export function decodeCards(text: string | null): RememberedPage {
|
|
113
|
+
if (!text) return EMPTY_PAGE;
|
|
114
|
+
let value: unknown;
|
|
115
|
+
try {
|
|
116
|
+
value = JSON.parse(text);
|
|
117
|
+
} catch {
|
|
118
|
+
return EMPTY_PAGE;
|
|
119
|
+
}
|
|
120
|
+
if (!isRecord(value) || value.v !== VERSION || !Array.isArray(value.cards)) return EMPTY_PAGE;
|
|
121
|
+
const cards: RememberedCard[] = [];
|
|
122
|
+
const seen = new Set<string>();
|
|
123
|
+
for (const entry of value.cards) {
|
|
124
|
+
const card = decodeCard(entry);
|
|
125
|
+
if (!card || seen.has(keyOf(card))) continue;
|
|
126
|
+
if (card.parentKey !== null && !seen.has(card.parentKey)) continue;
|
|
127
|
+
seen.add(keyOf(card));
|
|
128
|
+
cards.push(card);
|
|
129
|
+
}
|
|
130
|
+
const pointed =
|
|
131
|
+
typeof value.pointed === 'string' && seen.has(value.pointed) ? value.pointed : null;
|
|
132
|
+
return { cards, pointed };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* The remembered cards that can be restored now, in their order: their layer
|
|
137
|
+
* has registered, they are not open already, and the card they were opened
|
|
138
|
+
* from, if any, is open.
|
|
139
|
+
*/
|
|
140
|
+
export function restorable(
|
|
141
|
+
cards: readonly RememberedCard[],
|
|
142
|
+
layers: ReadonlySet<string>,
|
|
143
|
+
open: ReadonlySet<string>,
|
|
144
|
+
): RememberedCard[] {
|
|
145
|
+
return cards.filter(
|
|
146
|
+
(card) =>
|
|
147
|
+
layers.has(card.layer) &&
|
|
148
|
+
!open.has(keyOf(card)) &&
|
|
149
|
+
(card.parentKey === null || open.has(card.parentKey)),
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ---------------------------------------------------------------------------
|
|
154
|
+
// The DOM layer.
|
|
155
|
+
// ---------------------------------------------------------------------------
|
|
156
|
+
|
|
157
|
+
// The one seam for browser storage; this module never touches it itself.
|
|
158
|
+
// Local storage, so the cards outlive the tab and a browser restart; tabs
|
|
159
|
+
// open on the same page share one entry, and the last one left wins.
|
|
160
|
+
const store = createBrowserPreferenceStore();
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* One entry per page, kept only while the page has cards to bring back. The
|
|
164
|
+
* path includes a course's base, so courses on one origin never share one.
|
|
165
|
+
*/
|
|
166
|
+
const storageKey = () => `explico:cards:${window.location.pathname}`;
|
|
167
|
+
|
|
168
|
+
/** The cards this page had open when the reader last left it. */
|
|
169
|
+
export const readRememberedCards = (): RememberedPage => decodeCards(store.get(storageKey()));
|
|
170
|
+
|
|
171
|
+
/** Remember this page's cards; with none, the page is forgotten. */
|
|
172
|
+
export function writeRememberedCards(page: RememberedPage): void {
|
|
173
|
+
const key = storageKey();
|
|
174
|
+
if (page.cards.length > 0) store.set(key, encodeCards(page));
|
|
175
|
+
else if (store.remove) store.remove(key);
|
|
176
|
+
else store.set(key, '');
|
|
177
|
+
}
|
|
@@ -0,0 +1,504 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where an explanation card goes (rules P1–P6 of
|
|
3
|
+
* `docs/plans/hover-card-consistency.md`).
|
|
4
|
+
*
|
|
5
|
+
* P1. Cards live in one body-level layer and are positioned in document
|
|
6
|
+
* coordinates, so they scroll with the page natively: no scroll listener
|
|
7
|
+
* moves them, and a pinned card leaves with its trigger. They are placed
|
|
8
|
+
* once, when they open, and only re-anchored to their trigger when the
|
|
9
|
+
* layout changes (`watchCardLayout`) or when a scroll container around the
|
|
10
|
+
* trigger scrolls (`scrollContainersOf`).
|
|
11
|
+
*
|
|
12
|
+
* P2. A card takes the first free spot in a fixed order, judged against the
|
|
13
|
+
* window below the sticky header at open time and against every other open
|
|
14
|
+
* card (P6). `placeCard` is that decision as a pure function over plain
|
|
15
|
+
* boxes, so `tests/unit/card-placement.test.ts` runs it in Node; the rest of
|
|
16
|
+
* this module measures the page and applies the answer.
|
|
17
|
+
*/
|
|
18
|
+
import type { Box } from './card-travel';
|
|
19
|
+
|
|
20
|
+
export type CardSide = 'below' | 'above';
|
|
21
|
+
export type PlacementStep = 1 | 2 | 3 | 4 | 5 | 6;
|
|
22
|
+
|
|
23
|
+
/** A spot in the side column, for P2 step 5: its left edge, and level with the trigger's line. */
|
|
24
|
+
export interface SideSpot {
|
|
25
|
+
readonly left: number;
|
|
26
|
+
readonly top: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface PlacementInput {
|
|
30
|
+
/** The part of the window a card must fit: below the sticky header, inset by the window edge. */
|
|
31
|
+
readonly band: Box;
|
|
32
|
+
/** The article column's horizontal extent, or `null` when the page has none. */
|
|
33
|
+
readonly column: { readonly left: number; readonly right: number } | null;
|
|
34
|
+
/** The safe region R: the enclosing display equation, else the trigger. */
|
|
35
|
+
readonly region: Box;
|
|
36
|
+
readonly trigger: Box;
|
|
37
|
+
/** The card's natural size. */
|
|
38
|
+
readonly width: number;
|
|
39
|
+
readonly height: number;
|
|
40
|
+
/** Every other open card, oldest first. */
|
|
41
|
+
readonly others: readonly Box[];
|
|
42
|
+
/** The gap between a card and R, and between cards placed side by side. */
|
|
43
|
+
readonly gap: number;
|
|
44
|
+
/** P2 step 6: how far a card placed over the card in its way is shifted. */
|
|
45
|
+
readonly overlapOffset: number;
|
|
46
|
+
/** The shortest a card may be squeezed to fit a side; it scrolls inside. */
|
|
47
|
+
readonly minHeight: number;
|
|
48
|
+
/**
|
|
49
|
+
* P2 step 5: the side column at the trigger's line, for a card of this
|
|
50
|
+
* size, or `null` when the window has no side column with room for it
|
|
51
|
+
* (S1, S7). Absent, step 5 is skipped. The spot is free or not like any
|
|
52
|
+
* other: inside the band (moved up or down into it when it sticks out)
|
|
53
|
+
* and clear of every other card.
|
|
54
|
+
*/
|
|
55
|
+
readonly sideColumn?: ((width: number, height: number) => SideSpot | null) | null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface Placement {
|
|
59
|
+
readonly left: number;
|
|
60
|
+
readonly top: number;
|
|
61
|
+
/** The card's height: its natural height, or less when it was squeezed to fit a side. */
|
|
62
|
+
readonly height: number;
|
|
63
|
+
readonly side: CardSide;
|
|
64
|
+
readonly step: PlacementStep;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Rounding slack for comparing measured, fractional pixel positions. */
|
|
68
|
+
const EPSILON = 0.5;
|
|
69
|
+
|
|
70
|
+
const clamp = (value: number, low: number, high: number) =>
|
|
71
|
+
Math.max(low, Math.min(value, high));
|
|
72
|
+
|
|
73
|
+
const boxAt = (left: number, top: number, width: number, height: number): Box => ({
|
|
74
|
+
left,
|
|
75
|
+
top,
|
|
76
|
+
right: left + width,
|
|
77
|
+
bottom: top + height,
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
/** Whether two boxes share any area; boxes that only touch do not overlap. */
|
|
81
|
+
export function overlaps(a: Box, b: Box): boolean {
|
|
82
|
+
return (
|
|
83
|
+
a.left < b.right - EPSILON &&
|
|
84
|
+
a.right > b.left + EPSILON &&
|
|
85
|
+
a.top < b.bottom - EPSILON &&
|
|
86
|
+
a.bottom > b.top + EPSILON
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The horizontal range a card may use: the article column, or the whole
|
|
92
|
+
* band when the column is narrower than the card.
|
|
93
|
+
*/
|
|
94
|
+
export function horizontalRange(
|
|
95
|
+
band: Box,
|
|
96
|
+
column: PlacementInput['column'],
|
|
97
|
+
width: number,
|
|
98
|
+
): { low: number; high: number } {
|
|
99
|
+
if (column) {
|
|
100
|
+
const low = Math.max(band.left, column.left);
|
|
101
|
+
const high = Math.min(band.right, column.right);
|
|
102
|
+
if (high - low >= width - EPSILON) return { low, high };
|
|
103
|
+
}
|
|
104
|
+
return { low: band.left, high: band.right };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** P2: the first free spot, in order. Never the top of the window (B11). */
|
|
108
|
+
export function placeCard(input: PlacementInput): Placement {
|
|
109
|
+
const { band, region, trigger, width, gap, others } = input;
|
|
110
|
+
|
|
111
|
+
const spaceBelow = band.bottom - (region.bottom + gap);
|
|
112
|
+
const spaceAbove = region.top - gap - band.top;
|
|
113
|
+
const space = (side: CardSide) => (side === 'below' ? spaceBelow : spaceAbove);
|
|
114
|
+
const fits = (side: CardSide) => input.height <= space(side) + EPSILON;
|
|
115
|
+
|
|
116
|
+
// 1: below if the card fits there, else the roomier side.
|
|
117
|
+
const preferred: CardSide =
|
|
118
|
+
fits('below') || spaceBelow >= spaceAbove ? 'below' : 'above';
|
|
119
|
+
const other: CardSide = preferred === 'below' ? 'above' : 'below';
|
|
120
|
+
// A card taller than its preferred side is squeezed to it and scrolls
|
|
121
|
+
// inside, down to `minHeight`; below that it keeps its height and overflows.
|
|
122
|
+
const squeezed = Math.max(
|
|
123
|
+
Math.min(input.height, space(preferred)),
|
|
124
|
+
Math.min(input.height, input.minHeight),
|
|
125
|
+
);
|
|
126
|
+
const height = fits(preferred) ? input.height : squeezed;
|
|
127
|
+
|
|
128
|
+
const { low, high } = horizontalRange(band, input.column, width);
|
|
129
|
+
const centered = clamp(
|
|
130
|
+
(trigger.left + trigger.right) / 2 - width / 2,
|
|
131
|
+
low,
|
|
132
|
+
Math.max(low, high - width),
|
|
133
|
+
);
|
|
134
|
+
const topFor = (side: CardSide, h: number) =>
|
|
135
|
+
side === 'below' ? region.bottom + gap : region.top - gap - h;
|
|
136
|
+
|
|
137
|
+
const fitsBand = (box: Box) =>
|
|
138
|
+
box.top >= band.top - EPSILON &&
|
|
139
|
+
box.bottom <= band.bottom + EPSILON &&
|
|
140
|
+
// A card wider than the whole band cannot fit it; its horizontal
|
|
141
|
+
// position is already as good as it gets.
|
|
142
|
+
(width > band.right - band.left ||
|
|
143
|
+
(box.left >= band.left - EPSILON && box.right <= band.right + EPSILON));
|
|
144
|
+
const free = (box: Box) => fitsBand(box) && !others.some((o) => overlaps(o, box));
|
|
145
|
+
|
|
146
|
+
/** Steps 2 and 3: the free spot on `side` nearest to the centered one. */
|
|
147
|
+
const slide = (side: CardSide, h: number): Box | null => {
|
|
148
|
+
const top = topFor(side, h);
|
|
149
|
+
if (!fitsBand(boxAt(centered, top, width, h))) return null;
|
|
150
|
+
const inRow = others.filter((o) => o.top < top + h - EPSILON && o.bottom > top + EPSILON);
|
|
151
|
+
const candidates = new Set<number>([centered, low, high - width]);
|
|
152
|
+
for (const o of inRow) {
|
|
153
|
+
candidates.add(o.right + gap);
|
|
154
|
+
candidates.add(o.left - gap - width);
|
|
155
|
+
}
|
|
156
|
+
let best: Box | null = null;
|
|
157
|
+
for (const left of candidates) {
|
|
158
|
+
if (left < low - EPSILON || left + width > high + EPSILON) continue;
|
|
159
|
+
const box = boxAt(left, top, width, h);
|
|
160
|
+
if (!free(box)) continue;
|
|
161
|
+
if (!best || Math.abs(left - centered) < Math.abs(best.left - centered)) best = box;
|
|
162
|
+
}
|
|
163
|
+
return best;
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
/** Step 4: pushed past the cards in the way, away from R. */
|
|
167
|
+
const stack = (start: Box, side: CardSide): Box | null => {
|
|
168
|
+
let box = start;
|
|
169
|
+
const h = start.bottom - start.top;
|
|
170
|
+
for (let attempt = 0; attempt <= others.length; attempt++) {
|
|
171
|
+
const hits = others.filter((o) => overlaps(o, box));
|
|
172
|
+
if (hits.length === 0) return fitsBand(box) ? box : null;
|
|
173
|
+
const top =
|
|
174
|
+
side === 'below'
|
|
175
|
+
? Math.max(...hits.map((o) => o.bottom)) + gap
|
|
176
|
+
: Math.min(...hits.map((o) => o.top)) - gap - h;
|
|
177
|
+
box = boxAt(box.left, top, width, h);
|
|
178
|
+
if (!fitsBand(box)) return null;
|
|
179
|
+
}
|
|
180
|
+
return null;
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
/** Step 6: over the newest card in the way, shifted down and right. */
|
|
184
|
+
const cover = (start: Box): Box => {
|
|
185
|
+
const blocker = [...others].reverse().find((o) => overlaps(o, start));
|
|
186
|
+
if (!blocker) return start;
|
|
187
|
+
let left = blocker.left + input.overlapOffset;
|
|
188
|
+
let top = blocker.top + input.overlapOffset;
|
|
189
|
+
// Cascade past a card already sitting at that offset.
|
|
190
|
+
for (let attempt = 0; attempt < others.length; attempt++) {
|
|
191
|
+
const taken = others.some(
|
|
192
|
+
(o) => Math.abs(o.left - left) < 1 && Math.abs(o.top - top) < 1,
|
|
193
|
+
);
|
|
194
|
+
if (!taken) break;
|
|
195
|
+
left += input.overlapOffset;
|
|
196
|
+
top += input.overlapOffset;
|
|
197
|
+
}
|
|
198
|
+
left = clamp(left, band.left, Math.max(band.left, band.right - width));
|
|
199
|
+
return boxAt(left, top, width, start.bottom - start.top);
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
const result = (box: Box, side: CardSide, step: PlacementStep): Placement => ({
|
|
203
|
+
left: box.left,
|
|
204
|
+
top: box.top,
|
|
205
|
+
height: box.bottom - box.top,
|
|
206
|
+
side,
|
|
207
|
+
step,
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
const first = boxAt(centered, topFor(preferred, height), width, height);
|
|
211
|
+
if (free(first)) return result(first, preferred, 1);
|
|
212
|
+
|
|
213
|
+
const slid = slide(preferred, height);
|
|
214
|
+
if (slid) return result(slid, preferred, 2);
|
|
215
|
+
|
|
216
|
+
if (fits(other)) {
|
|
217
|
+
const across = boxAt(centered, topFor(other, input.height), width, input.height);
|
|
218
|
+
if (free(across)) return result(across, other, 3);
|
|
219
|
+
const acrossSlid = slide(other, input.height);
|
|
220
|
+
if (acrossSlid) return result(acrossSlid, other, 3);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const stacked = stack(first, preferred);
|
|
224
|
+
if (stacked) return result(stacked, preferred, 4);
|
|
225
|
+
|
|
226
|
+
const spot = input.sideColumn?.(width, height);
|
|
227
|
+
if (spot) {
|
|
228
|
+
const top = clamp(spot.top, band.top, Math.max(band.top, band.bottom - height));
|
|
229
|
+
const beside = boxAt(spot.left, top, width, height);
|
|
230
|
+
if (free(beside)) return result(beside, preferred, 5);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return result(cover(first), preferred, 6);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// ---------------------------------------------------------------------------
|
|
237
|
+
// The DOM layer.
|
|
238
|
+
// ---------------------------------------------------------------------------
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* How a placed card follows its trigger when the layout changes: the same
|
|
242
|
+
* horizontal offset from the trigger, and the same distance from the side of
|
|
243
|
+
* the safe region it was placed on.
|
|
244
|
+
*/
|
|
245
|
+
export interface CardAnchor {
|
|
246
|
+
readonly side: CardSide;
|
|
247
|
+
/** The card's left minus the trigger's left. */
|
|
248
|
+
readonly dx: number;
|
|
249
|
+
/**
|
|
250
|
+
* Below: the card's top minus R's bottom. Above: the card's bottom minus
|
|
251
|
+
* R's top. In the side column: the card's top minus the top of the
|
|
252
|
+
* trigger's first line.
|
|
253
|
+
*/
|
|
254
|
+
readonly dy: number;
|
|
255
|
+
/**
|
|
256
|
+
* Placed in the side column (P2 step 5): it follows the column's edge and
|
|
257
|
+
* its trigger's line, not the trigger's left, and the card manager moves
|
|
258
|
+
* it (`reanchorElement` does not).
|
|
259
|
+
*/
|
|
260
|
+
readonly column?: boolean;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
export interface PlaceOptions {
|
|
264
|
+
readonly gap: number;
|
|
265
|
+
readonly edge: number;
|
|
266
|
+
readonly overlapOffset: number;
|
|
267
|
+
readonly minHeight: number;
|
|
268
|
+
readonly sideColumn?: PlacementInput['sideColumn'];
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** The safe region R a card must not cover: the enclosing display equation, else the trigger. */
|
|
272
|
+
export const regionOf = (trigger: HTMLElement): HTMLElement =>
|
|
273
|
+
trigger.closest<HTMLElement>('.katex-display') ?? trigger;
|
|
274
|
+
|
|
275
|
+
/** The window below the sticky header, inset by `edge`, in viewport coordinates. */
|
|
276
|
+
export function cardBand(edge: number): Box {
|
|
277
|
+
const header = document.querySelector<HTMLElement>('.header');
|
|
278
|
+
const headerBottom = header ? header.getBoundingClientRect().bottom : 0;
|
|
279
|
+
const root = document.documentElement;
|
|
280
|
+
return {
|
|
281
|
+
left: edge,
|
|
282
|
+
top: Math.max(edge, headerBottom + edge),
|
|
283
|
+
right: root.clientWidth - edge,
|
|
284
|
+
bottom: root.clientHeight - edge,
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* The article column a trigger's card is kept in. A trigger inside a card
|
|
290
|
+
* or an expanded diagram (whose `.sl-markdown-content` stand-in has no box
|
|
291
|
+
* of its own) uses the page's main article.
|
|
292
|
+
*/
|
|
293
|
+
export function articleColumn(
|
|
294
|
+
trigger: HTMLElement,
|
|
295
|
+
): { left: number; right: number } | null {
|
|
296
|
+
const candidates = [
|
|
297
|
+
trigger.closest<HTMLElement>('.sl-markdown-content'),
|
|
298
|
+
document.querySelector<HTMLElement>('.main-pane .sl-markdown-content'),
|
|
299
|
+
document.querySelector<HTMLElement>('.sl-markdown-content'),
|
|
300
|
+
];
|
|
301
|
+
for (const article of candidates) {
|
|
302
|
+
const rect = article?.getBoundingClientRect();
|
|
303
|
+
if (rect && rect.width > 0) return { left: rect.left, right: rect.right };
|
|
304
|
+
}
|
|
305
|
+
return null;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
let layer: HTMLElement | null = null;
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* The one body-level layer every card mounts in (P1). It sits at the
|
|
312
|
+
* document origin and spans the viewport's width, so a card's `left`/`top`
|
|
313
|
+
* inside it are document coordinates and the page's own scrolling carries
|
|
314
|
+
* it. It takes the navbar's stacking level but comes before `.page` in the
|
|
315
|
+
* document, so cards slide under the header (one level up) and under both
|
|
316
|
+
* side panels (the same level, later in the document; `global.css`) as the
|
|
317
|
+
* page scrolls, yet stay above the expanded tables and diagrams one level
|
|
318
|
+
* below.
|
|
319
|
+
* Cards must never mount inside the article: an ancestor's stacking context
|
|
320
|
+
* once let the Starlight sidebar swallow a card's clicks.
|
|
321
|
+
*/
|
|
322
|
+
export function cardLayer(variables: Readonly<Record<string, string>> = {}): HTMLElement {
|
|
323
|
+
if (layer?.isConnected) return layer;
|
|
324
|
+
layer = document.createElement('div');
|
|
325
|
+
layer.className = 'explico-card-layer';
|
|
326
|
+
layer.dataset.cardLayer = '';
|
|
327
|
+
layer.style.cssText =
|
|
328
|
+
'position:absolute;left:0;top:0;width:100%;height:0;z-index:var(--sl-z-index-navbar,10)';
|
|
329
|
+
for (const [name, value] of Object.entries(variables)) {
|
|
330
|
+
layer.style.setProperty(name, value);
|
|
331
|
+
}
|
|
332
|
+
const page = document.body.querySelector(':scope > .page');
|
|
333
|
+
if (page) page.before(layer);
|
|
334
|
+
else document.body.append(layer);
|
|
335
|
+
return layer;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** Move `el`, mounted in the card layer, so its viewport position is (`left`, `top`). */
|
|
339
|
+
export function moveTo(el: HTMLElement, left: number, top: number): void {
|
|
340
|
+
const origin = (el.parentElement ?? document.body).getBoundingClientRect();
|
|
341
|
+
el.style.left = `${Math.round(left - origin.left)}px`;
|
|
342
|
+
el.style.top = `${Math.round(top - origin.top)}px`;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Place `el`, already mounted in the card layer, next to `trigger` and clear
|
|
347
|
+
* of `others` (P2, P6). Returns how it follows the trigger from now on.
|
|
348
|
+
*/
|
|
349
|
+
export function placeElement(
|
|
350
|
+
el: HTMLElement,
|
|
351
|
+
trigger: HTMLElement,
|
|
352
|
+
others: readonly HTMLElement[],
|
|
353
|
+
options: PlaceOptions,
|
|
354
|
+
): CardAnchor {
|
|
355
|
+
const band = cardBand(options.edge);
|
|
356
|
+
const bandHeight = Math.max(band.bottom - band.top, 0);
|
|
357
|
+
// Measure with all the room the window offers, then keep whatever part of
|
|
358
|
+
// it the chosen spot allows.
|
|
359
|
+
el.style.maxHeight = `${bandHeight}px`;
|
|
360
|
+
moveTo(el, band.left, band.top);
|
|
361
|
+
const size = el.getBoundingClientRect();
|
|
362
|
+
const region = regionOf(trigger).getBoundingClientRect();
|
|
363
|
+
const triggerBox = trigger.getBoundingClientRect();
|
|
364
|
+
|
|
365
|
+
const placement = placeCard({
|
|
366
|
+
band,
|
|
367
|
+
column: articleColumn(trigger),
|
|
368
|
+
region,
|
|
369
|
+
trigger: triggerBox,
|
|
370
|
+
width: size.width,
|
|
371
|
+
height: size.height,
|
|
372
|
+
others: others.map((other) => other.getBoundingClientRect()),
|
|
373
|
+
gap: options.gap,
|
|
374
|
+
overlapOffset: options.overlapOffset,
|
|
375
|
+
minHeight: options.minHeight,
|
|
376
|
+
sideColumn: options.sideColumn ?? null,
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
const capped =
|
|
380
|
+
placement.height < size.height - EPSILON || size.height >= bandHeight - EPSILON;
|
|
381
|
+
el.style.maxHeight = capped ? `${placement.height}px` : '';
|
|
382
|
+
moveTo(el, placement.left, placement.top);
|
|
383
|
+
|
|
384
|
+
if (placement.step === 5) {
|
|
385
|
+
const line = trigger.getClientRects()[0]?.top ?? triggerBox.top;
|
|
386
|
+
return {
|
|
387
|
+
side: placement.side,
|
|
388
|
+
dx: placement.left - triggerBox.left,
|
|
389
|
+
dy: placement.top - line,
|
|
390
|
+
column: true,
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
return {
|
|
394
|
+
side: placement.side,
|
|
395
|
+
dx: placement.left - triggerBox.left,
|
|
396
|
+
dy:
|
|
397
|
+
placement.side === 'below'
|
|
398
|
+
? placement.top - region.bottom
|
|
399
|
+
: placement.top + placement.height - region.top,
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Where `el` sits at its anchor next to `trigger`, in the viewport: its
|
|
405
|
+
* top-left corner, or `null` when the trigger has no box (inside a closed
|
|
406
|
+
* disclosure). Not for an anchor in the side column.
|
|
407
|
+
*/
|
|
408
|
+
export function anchoredSpot(
|
|
409
|
+
el: HTMLElement,
|
|
410
|
+
trigger: HTMLElement,
|
|
411
|
+
anchor: CardAnchor,
|
|
412
|
+
edge: number,
|
|
413
|
+
): { x: number; y: number } | null {
|
|
414
|
+
if (!trigger.isConnected || trigger.getClientRects().length === 0) return null;
|
|
415
|
+
const region = regionOf(trigger).getBoundingClientRect();
|
|
416
|
+
const triggerBox = trigger.getBoundingClientRect();
|
|
417
|
+
const { width, height } = el.getBoundingClientRect();
|
|
418
|
+
const band = cardBand(edge);
|
|
419
|
+
const { low, high } = horizontalRange(band, articleColumn(trigger), width);
|
|
420
|
+
return {
|
|
421
|
+
x: clamp(triggerBox.left + anchor.dx, low, Math.max(low, high - width)),
|
|
422
|
+
y: anchor.side === 'below' ? region.bottom + anchor.dy : region.top + anchor.dy - height,
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* Put `el` back at its anchor after the layout moved its trigger (P1). A
|
|
428
|
+
* trigger with no box (inside a closed disclosure) leaves the card where it
|
|
429
|
+
* is. Returns whether the trigger could be measured.
|
|
430
|
+
*/
|
|
431
|
+
export function reanchorElement(
|
|
432
|
+
el: HTMLElement,
|
|
433
|
+
trigger: HTMLElement,
|
|
434
|
+
anchor: CardAnchor,
|
|
435
|
+
edge: number,
|
|
436
|
+
): boolean {
|
|
437
|
+
const spot = anchoredSpot(el, trigger, anchor, edge);
|
|
438
|
+
if (!spot) return false;
|
|
439
|
+
moveTo(el, spot.x, spot.y);
|
|
440
|
+
return true;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* The elements between `trigger` and the page that scroll on their own — a
|
|
445
|
+
* wide display equation, a wide table, a card's own body — so a card can
|
|
446
|
+
* follow its trigger when one of them scrolls (P1).
|
|
447
|
+
*/
|
|
448
|
+
export function scrollContainersOf(trigger: HTMLElement): HTMLElement[] {
|
|
449
|
+
const found: HTMLElement[] = [];
|
|
450
|
+
for (
|
|
451
|
+
let node = trigger.parentElement;
|
|
452
|
+
node && node !== document.body && node !== document.documentElement;
|
|
453
|
+
node = node.parentElement
|
|
454
|
+
) {
|
|
455
|
+
const style = getComputedStyle(node);
|
|
456
|
+
if (/auto|scroll/.test(`${style.overflowX} ${style.overflowY}`)) found.push(node);
|
|
457
|
+
}
|
|
458
|
+
return found;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* Call `onChange` (at most once a frame) whenever the layout may have moved a
|
|
463
|
+
* trigger: a resize, a font load, a disclosure opening or closing, a panel
|
|
464
|
+
* toggling, or anything else that changes the size of the page or the
|
|
465
|
+
* article column. Returns `schedule`, for any other event that may move a
|
|
466
|
+
* trigger (a scroll container scrolling), and `observe`, which adds one more
|
|
467
|
+
* element to watch (an open card, whose own size change moves a card placed
|
|
468
|
+
* above its trigger).
|
|
469
|
+
*/
|
|
470
|
+
export function watchCardLayout(onChange: () => void): {
|
|
471
|
+
schedule(): void;
|
|
472
|
+
observe(el: Element): void;
|
|
473
|
+
unobserve(el: Element): void;
|
|
474
|
+
} {
|
|
475
|
+
let frame = 0;
|
|
476
|
+
const schedule = () => {
|
|
477
|
+
if (frame) return;
|
|
478
|
+
frame = requestAnimationFrame(() => {
|
|
479
|
+
frame = 0;
|
|
480
|
+
onChange();
|
|
481
|
+
});
|
|
482
|
+
};
|
|
483
|
+
|
|
484
|
+
window.addEventListener('resize', schedule, { passive: true });
|
|
485
|
+
document.fonts?.addEventListener?.('loadingdone', schedule);
|
|
486
|
+
// `toggle` does not bubble; capture sees every `<details>`.
|
|
487
|
+
document.addEventListener('toggle', schedule, true);
|
|
488
|
+
new MutationObserver(schedule).observe(document.documentElement, {
|
|
489
|
+
attributes: true,
|
|
490
|
+
attributeFilter: ['data-navigation-hidden', 'data-page-sidebar-hidden'],
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
const resizes = new ResizeObserver(schedule);
|
|
494
|
+
resizes.observe(document.body);
|
|
495
|
+
for (const el of document.querySelectorAll('.main-pane, .sl-markdown-content')) {
|
|
496
|
+
resizes.observe(el);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
return {
|
|
500
|
+
schedule,
|
|
501
|
+
observe: (el) => resizes.observe(el),
|
|
502
|
+
unobserve: (el) => resizes.unobserve(el),
|
|
503
|
+
};
|
|
504
|
+
}
|