explico 0.11.1 → 0.13.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 +1292 -1
- package/README.md +91 -13
- package/dist/compiler/astro.d.ts +11 -4
- package/dist/compiler/astro.js +48 -15
- package/dist/compiler/cli.d.ts +27 -12
- package/dist/compiler/cli.js +96 -25
- package/dist/compiler/collections.d.ts +33 -2
- package/dist/compiler/collections.js +91 -19
- package/dist/compiler/content-schema.d.ts +31 -0
- package/dist/compiler/content-schema.js +82 -0
- package/dist/compiler/content-schemas.d.ts +422 -0
- package/dist/compiler/content-schemas.js +229 -0
- package/dist/compiler/course-config.d.ts +1 -1
- package/dist/compiler/course-config.js +30 -2
- package/dist/compiler/exports/combined-html.d.ts +97 -0
- package/dist/compiler/exports/combined-html.js +492 -0
- package/dist/compiler/exports/download-index.d.ts +9 -0
- package/dist/compiler/exports/download-index.js +33 -0
- package/dist/compiler/exports/export-chapters.d.ts +98 -0
- package/dist/compiler/exports/export-chapters.js +164 -0
- package/dist/compiler/exports/export-pages.d.ts +48 -0
- package/dist/compiler/exports/export-pages.js +22 -0
- package/dist/compiler/exports/html-tree.d.ts +22 -0
- package/dist/compiler/exports/html-tree.js +47 -0
- package/dist/compiler/exports/latex/capture.d.ts +106 -0
- package/dist/compiler/exports/latex/capture.js +435 -0
- package/dist/compiler/exports/latex/compile.d.ts +73 -0
- package/dist/compiler/exports/latex/compile.js +115 -0
- package/dist/compiler/exports/latex/document.d.ts +102 -0
- package/dist/compiler/exports/latex/document.js +349 -0
- package/dist/compiler/exports/latex/explico.cls +415 -0
- package/dist/compiler/exports/latex/numbering.d.ts +18 -0
- package/dist/compiler/exports/latex/numbering.js +57 -0
- package/dist/compiler/exports/latex/serialize.d.ts +112 -0
- package/dist/compiler/exports/latex/serialize.js +606 -0
- package/dist/compiler/exports/latex-export.d.ts +44 -0
- package/dist/compiler/exports/latex-export.js +169 -0
- package/dist/compiler/exports/markdown/serialize.d.ts +51 -0
- package/dist/compiler/exports/markdown/serialize.js +527 -0
- package/dist/compiler/exports/markdown-export.d.ts +39 -0
- package/dist/compiler/exports/markdown-export.js +240 -0
- package/dist/compiler/exports/standalone-html.d.ts +15 -5
- package/dist/compiler/exports/standalone-html.js +23 -51
- package/dist/compiler/exports/static-tree.d.ts +253 -0
- package/dist/compiler/exports/static-tree.js +625 -0
- package/dist/compiler/exports/zip.d.ts +16 -0
- package/dist/compiler/exports/zip.js +72 -0
- package/dist/compiler/lesson-derivation.d.ts +8 -2
- package/dist/compiler/lesson-derivation.js +6 -7
- package/dist/compiler/manifest.d.ts +115 -8
- package/dist/compiler/manifest.js +221 -39
- package/dist/compiler/render-diagnostics.d.ts +65 -0
- package/dist/compiler/render-diagnostics.js +56 -0
- package/dist/compiler/resolution-report.d.ts +3 -1
- package/dist/{course-config-Dsnri9Tk.d.ts → course-config-rRtME5UV.d.ts} +56 -13
- package/dist/curriculum/validation.d.ts +9 -3
- package/dist/curriculum/validation.js +35 -17
- package/dist/reference/base-abbreviations.d.mts +2 -2
- package/dist/reference/base-abbreviations.mjs +2 -2
- package/dist/reference/base-operators.d.mts +8 -2
- package/dist/reference/base-operators.mjs +8 -10
- package/dist/reference/base-roman-labels.d.mts +4 -3
- package/dist/reference/base-roman-labels.mjs +4 -3
- package/dist/reference/citation-format.d.mts +35 -0
- package/dist/reference/citation-format.mjs +74 -0
- package/dist/reference/consistency.d.ts +5 -9
- package/dist/reference/consistency.js +34 -38
- package/dist/reference/curriculum-alignment.js +3 -24
- package/dist/reference/derived-entries.d.ts +45 -0
- package/dist/reference/derived-entries.js +50 -0
- package/dist/reference/derived-notation.d.mts +125 -0
- package/dist/reference/derived-notation.mjs +121 -0
- package/dist/reference/diagram-fit.d.ts +45 -0
- package/dist/reference/diagram-fit.js +54 -0
- package/dist/reference/diagram-layout.d.ts +59 -10
- package/dist/reference/diagram-layout.js +85 -24
- package/dist/reference/diagrams-validate.d.ts +1 -1
- package/dist/reference/equations-validate.d.ts +1 -1
- package/dist/reference/equations-validate.js +26 -1
- package/dist/reference/equations.d.mts +27 -0
- package/dist/reference/equations.mjs +120 -22
- package/dist/reference/examples-validate.d.ts +1 -1
- package/dist/reference/figure-fit.d.ts +70 -0
- package/dist/reference/figure-fit.js +60 -0
- package/dist/reference/figure-pictures.d.ts +27 -0
- package/dist/reference/figure-pictures.js +49 -0
- package/dist/reference/figure-symbols.d.ts +32 -0
- package/dist/reference/figure-symbols.js +55 -0
- package/dist/reference/figures-validate.d.ts +1 -1
- package/dist/reference/gate-abbreviations.d.ts +1 -1
- package/dist/reference/gate-abbreviations.js +1 -1
- package/dist/reference/gate-course-rules.d.ts +4 -3
- package/dist/reference/gate-course-rules.js +9 -4
- package/dist/reference/gate-heading-length.d.ts +2 -0
- package/dist/reference/gate-heading-length.js +7 -5
- package/dist/reference/gate-heading-structure.js +26 -0
- package/dist/reference/gate-indices.d.ts +35 -0
- package/dist/reference/gate-indices.js +299 -0
- package/dist/reference/gate-math.d.ts +32 -2
- package/dist/reference/gate-math.js +68 -12
- package/dist/reference/gate-mdx-expression.d.ts +17 -0
- package/dist/reference/gate-mdx-expression.js +29 -0
- package/dist/reference/gate-non-chapter-pages.d.ts +39 -0
- package/dist/reference/gate-non-chapter-pages.js +156 -0
- package/dist/reference/gate-part-pages.d.ts +4 -2
- package/dist/reference/gate-part-pages.js +2 -1
- package/dist/reference/gate-prop-text.d.ts +28 -0
- package/dist/reference/gate-prop-text.js +106 -0
- package/dist/reference/gate-prose-notation.d.ts +3 -1
- package/dist/reference/gate-prose-notation.js +4 -2
- package/dist/reference/gate-retired-keys.d.ts +35 -0
- package/dist/reference/gate-retired-keys.js +58 -0
- package/dist/reference/gloss.d.ts +2 -0
- package/dist/reference/katex-measure.d.ts +17 -0
- package/dist/reference/katex-measure.js +223 -0
- package/dist/reference/keyed-float-validate.js +12 -0
- package/dist/reference/keyed-jsx-floats.d.mts +2 -2
- package/dist/reference/keyed-jsx-floats.mjs +11 -14
- package/dist/reference/lab-katex-validate.d.ts +43 -0
- package/dist/reference/lab-katex-validate.js +96 -0
- package/dist/reference/lab-math-scope.d.ts +1 -0
- package/dist/reference/lab-math-scope.js +13 -2
- package/dist/reference/lint-ignore.js +1 -1
- package/dist/reference/math-glyphs.d.mts +94 -2
- package/dist/reference/math-glyphs.mjs +851 -46
- package/dist/reference/mixed-content.d.ts +8 -3
- package/dist/reference/mixed-content.js +4 -2
- package/dist/reference/page-notation.d.ts +39 -7
- package/dist/reference/page-notation.js +62 -10
- package/dist/reference/raw-floats.mjs +37 -13
- package/dist/reference/references.d.ts +11 -1
- package/dist/reference/references.js +6 -0
- package/dist/reference/registry.js +115 -11
- package/dist/reference/rehype-fail-katex-errors.d.mts +9 -0
- package/dist/reference/rehype-fail-katex-errors.mjs +19 -0
- package/dist/reference/rehype-heading-anchors.mjs +11 -1
- package/dist/reference/remark-citation.mjs +0 -0
- package/dist/reference/remark-notation.mjs +275 -23
- package/dist/reference/render-lab-math.d.ts +13 -2
- package/dist/reference/render-lab-math.js +18 -6
- package/dist/reference/sections-validate.d.ts +28 -3
- package/dist/reference/sections-validate.js +31 -4
- package/dist/reference/sections.d.mts +24 -6
- package/dist/reference/sections.mjs +92 -17
- package/dist/reference/tables-validate.d.ts +1 -1
- package/dist/reference/tables-validate.js +24 -1
- package/dist/reference/text-measure.d.ts +10 -0
- package/dist/reference/text-measure.js +41 -0
- package/dist/reference/text-widths.d.ts +10 -0
- package/dist/reference/text-widths.js +1238 -0
- package/dist/reference/types.d.ts +64 -8
- package/dist/rules/catalog.js +223 -32
- package/dist/rules/in-effect.d.ts +1 -1
- package/dist/rules/rules.schema.json +100 -27
- package/dist/rules/settings.d.ts +1 -1
- package/dist/rules/settings.js +7 -0
- package/dist/session/PreferenceStore.d.ts +18 -1
- package/dist/session/PreferenceStore.js +29 -1
- package/docs/authoring/README.md +33 -21
- package/docs/authoring/agents.md +38 -7
- package/docs/authoring/components.md +115 -10
- package/docs/authoring/migrating.md +878 -0
- package/docs/authoring/notation.md +285 -30
- package/docs/authoring/retired.md +1 -0
- package/docs/authoring/structure-rules.md +84 -45
- package/docs/authoring/structure.md +67 -2
- package/docs/authoring/visualizations.md +167 -25
- package/package.json +12 -7
- package/src/compiler/astro.ts +72 -21
- package/src/compiler/cli.ts +143 -35
- package/src/compiler/collections.ts +158 -27
- package/src/compiler/content-schema.ts +108 -0
- package/src/compiler/content-schemas.ts +391 -0
- package/src/compiler/course-config.ts +86 -15
- package/src/compiler/exports/combined-html.ts +669 -0
- package/src/compiler/exports/download-index.ts +36 -0
- package/src/compiler/exports/export-chapters.ts +233 -0
- package/src/compiler/exports/export-pages.ts +44 -0
- package/src/compiler/exports/html-tree.ts +62 -0
- package/src/compiler/exports/latex/capture.ts +651 -0
- package/src/compiler/exports/latex/compile.ts +168 -0
- package/src/compiler/exports/latex/document.ts +479 -0
- package/src/compiler/exports/latex/explico.cls +415 -0
- package/src/compiler/exports/latex/numbering.ts +74 -0
- package/src/compiler/exports/latex/serialize.ts +787 -0
- package/src/compiler/exports/latex/texlive-packages.txt +76 -0
- package/src/compiler/exports/latex-export.ts +255 -0
- package/src/compiler/exports/markdown/serialize.ts +632 -0
- package/src/compiler/exports/markdown-export.ts +329 -0
- package/src/compiler/exports/standalone-html.ts +39 -70
- package/src/compiler/exports/static-tree.ts +944 -0
- package/src/compiler/exports/zip.ts +88 -0
- package/src/compiler/lesson-derivation.ts +13 -8
- package/src/compiler/manifest.ts +387 -40
- package/src/compiler/render-diagnostics.ts +111 -0
- package/src/components/CourseContents.astro +6 -0
- package/src/components/HoverPanel.astro +204 -6
- package/src/components/PartOverview.astro +18 -9
- package/src/components/abbreviation/AbbreviationGlossary.astro +6 -2
- package/src/components/abbreviation/AbbreviationLayer.astro +21 -12
- package/src/components/assessments/AssessmentRunner.tsx +7 -1
- package/src/components/assessments/AssessmentSet.astro +14 -1
- package/src/components/block-heading.ts +38 -0
- package/src/components/card-arrow.ts +18 -4
- package/src/components/card-carry.ts +406 -0
- package/src/components/card-drag.ts +1 -30
- package/src/components/card-fetch.ts +87 -0
- package/src/components/card-manager.ts +1222 -38
- package/src/components/card-memory.ts +5 -1
- package/src/components/card-placement.ts +211 -13
- package/src/components/card-side.ts +281 -31
- package/src/components/citation/CitationLayer.astro +45 -40
- package/src/components/diagram/Diagram.astro +123 -44
- package/src/components/disclosure/print-as-shown.ts +42 -6
- package/src/components/figure/Figure.astro +17 -1
- package/src/components/figure/FloatCaption.astro +4 -17
- package/src/components/figure/figure-state.ts +26 -17
- package/src/components/float-toolbar.ts +274 -0
- package/src/components/memo/Memo.astro +21 -10
- package/src/components/memo/ShortNote.astro +4 -0
- package/src/components/notation/NotationGlossary.astro +22 -17
- package/src/components/notation/NotationLayer.astro +300 -79
- package/src/components/notation/NotationMath.astro +8 -1
- package/src/components/notation/render-notation-math.ts +51 -5
- package/src/components/readable-text.ts +25 -0
- package/src/components/reference/FloatRefLayer.astro +162 -89
- package/src/components/starlight/HeadingAnchorEnhancer.astro +13 -8
- package/src/components/starlight/KeyedFloatEnhancer.astro +28 -9
- package/src/components/starlight/LayoutPageSidebar.astro +17 -3
- package/src/components/starlight/LayoutSocialIcons.astro +79 -29
- package/src/components/starlight/LessonExportActions.astro +384 -310
- package/src/components/starlight/LessonFooter.astro +66 -8
- package/src/components/starlight/PrintController.astro +28 -0
- package/src/components/starlight/TextSizeControl.astro +137 -0
- package/src/components/starlight/print-controller.ts +380 -0
- package/src/components/starlight/section-summary.ts +13 -5
- package/src/components/table/Table.astro +14 -22
- package/src/components/table/table-truncation.ts +15 -0
- package/src/content-config.ts +20 -312
- package/src/curriculum/validation.ts +72 -24
- package/src/reference/base-abbreviations.mjs +2 -2
- package/src/reference/base-operators.mjs +8 -10
- package/src/reference/base-roman-labels.mjs +4 -3
- package/src/reference/citation-format.mjs +74 -0
- package/src/reference/consistency.ts +47 -61
- package/src/reference/curriculum-alignment.ts +4 -41
- package/src/reference/derived-entries.ts +68 -0
- package/src/reference/derived-notation.mjs +121 -0
- package/src/reference/diagram-fit.ts +104 -0
- package/src/reference/diagram-layout.ts +182 -51
- package/src/reference/diagrams-validate.ts +2 -1
- package/src/reference/equations-validate.ts +41 -2
- package/src/reference/equations.mjs +120 -22
- package/src/reference/examples-validate.ts +1 -0
- package/src/reference/figure-fit.ts +139 -0
- package/src/reference/figure-pictures.ts +96 -0
- package/src/reference/figure-symbols.ts +117 -0
- package/src/reference/figures-validate.ts +2 -1
- package/src/reference/gate-abbreviations.ts +1 -1
- package/src/reference/gate-course-rules.ts +22 -7
- package/src/reference/gate-heading-length.ts +25 -7
- package/src/reference/gate-heading-structure.ts +40 -1
- package/src/reference/gate-indices.ts +470 -0
- package/src/reference/gate-math.ts +102 -13
- package/src/reference/gate-mdx-expression.ts +45 -0
- package/src/reference/gate-non-chapter-pages.ts +241 -0
- package/src/reference/gate-part-pages.ts +1 -1
- package/src/reference/gate-prop-text.ts +145 -0
- package/src/reference/gate-prose-notation.ts +19 -5
- package/src/reference/gate-retired-keys.ts +101 -0
- package/src/reference/gloss.ts +2 -0
- package/src/reference/katex-measure.ts +384 -0
- package/src/reference/keyed-float-validate.ts +22 -0
- package/src/reference/keyed-jsx-floats.mjs +11 -14
- package/src/reference/lab-katex-validate.ts +156 -0
- package/src/reference/lab-math-scope.ts +15 -1
- package/src/reference/lint-ignore.ts +4 -1
- package/src/reference/math-glyphs.mjs +851 -46
- package/src/reference/mixed-content.ts +11 -2
- package/src/reference/page-notation.ts +114 -14
- package/src/reference/raw-floats.mjs +37 -13
- package/src/reference/references.ts +14 -0
- package/src/reference/registry.ts +170 -9
- package/src/reference/rehype-fail-katex-errors.mjs +19 -0
- package/src/reference/rehype-heading-anchors.mjs +11 -1
- package/src/reference/remark-citation.mjs +0 -0
- package/src/reference/remark-notation.mjs +275 -23
- package/src/reference/render-lab-math.ts +40 -5
- package/src/reference/sections-validate.ts +67 -3
- package/src/reference/sections.mjs +92 -17
- package/src/reference/tables-validate.ts +40 -2
- package/src/reference/text-measure.ts +63 -0
- package/src/reference/text-widths.ts +133 -0
- package/src/reference/types.ts +75 -8
- package/src/rules/catalog.ts +241 -31
- package/src/rules/settings.ts +10 -0
- package/src/session/PreferenceStore.ts +53 -0
- package/src/styles/global.css +322 -83
|
@@ -25,7 +25,12 @@
|
|
|
25
25
|
* - a card's grip drags it anywhere between the sidebars, or into the side
|
|
26
26
|
* column, and its arrow keys move it (`./card-drag.ts`);
|
|
27
27
|
* - the kept cards are remembered when the page is left and come back when
|
|
28
|
-
* the reader does, even after a browser restart (`./card-memory.ts`)
|
|
28
|
+
* the reader does, even after a browser restart (`./card-memory.ts`);
|
|
29
|
+
* - with "Carry cards" on, a kept card stays on screen, held at the window's
|
|
30
|
+
* top or bottom edge once its place scrolls past it (`heldAt`,
|
|
31
|
+
* `./card-placement.ts`, rules K2–K4), and comes along to every page of
|
|
32
|
+
* the course: attached to its trigger again on its own page, rebuilt from
|
|
33
|
+
* that page anywhere else (`./card-carry.ts`, K5).
|
|
29
34
|
*
|
|
30
35
|
* The registry's rules are pure functions over plain `CardState` records
|
|
31
36
|
* (`escapeTarget`, `chainOf`, `closedByOpening`, …) so `tests/unit/` can check
|
|
@@ -39,7 +44,6 @@ import {
|
|
|
39
44
|
movedAt,
|
|
40
45
|
movedSpot,
|
|
41
46
|
nudgeSpot,
|
|
42
|
-
sidebarsBetween,
|
|
43
47
|
type CardDrag,
|
|
44
48
|
type DragHost,
|
|
45
49
|
type MovedSpot,
|
|
@@ -51,38 +55,78 @@ import {
|
|
|
51
55
|
writeRememberedCards,
|
|
52
56
|
type RememberedCard,
|
|
53
57
|
} from './card-memory';
|
|
58
|
+
import {
|
|
59
|
+
capturePicture,
|
|
60
|
+
carriedKeyOf,
|
|
61
|
+
decodeCarried,
|
|
62
|
+
encodeCarried,
|
|
63
|
+
forgetCarried,
|
|
64
|
+
openedAtText,
|
|
65
|
+
readCardConfig,
|
|
66
|
+
readCarried,
|
|
67
|
+
readCarry,
|
|
68
|
+
restorePicture,
|
|
69
|
+
sectionPlace,
|
|
70
|
+
stashOpenedAt,
|
|
71
|
+
takeOpenedAt,
|
|
72
|
+
writeCarried,
|
|
73
|
+
writeCarry,
|
|
74
|
+
type CarriedCard,
|
|
75
|
+
type CarriedSet,
|
|
76
|
+
type OpenedAt,
|
|
77
|
+
} from './card-carry';
|
|
78
|
+
import { isThisPage, pageTitleOf } from './card-fetch';
|
|
79
|
+
import { wireJumpArrow } from './card-jump';
|
|
80
|
+
import { readableText } from './readable-text';
|
|
81
|
+
import { animatedScrollIntoView } from './scroll-animation';
|
|
54
82
|
import {
|
|
55
83
|
anchoredSpot,
|
|
56
84
|
articleColumn,
|
|
57
85
|
cardLayer,
|
|
86
|
+
heldAt,
|
|
58
87
|
moveTo,
|
|
59
88
|
placeElement,
|
|
60
89
|
reanchorElement,
|
|
61
90
|
scrollContainersOf,
|
|
62
91
|
watchCardLayout,
|
|
63
92
|
type CardAnchor,
|
|
93
|
+
type Held,
|
|
94
|
+
type HoldCard,
|
|
95
|
+
type HoldEdge,
|
|
64
96
|
type SideSpot,
|
|
65
97
|
} from './card-placement';
|
|
66
98
|
import {
|
|
67
99
|
articleContainers,
|
|
68
100
|
articleLeft,
|
|
101
|
+
closedRoom,
|
|
69
102
|
columnLeft,
|
|
70
|
-
|
|
103
|
+
createSideControls,
|
|
71
104
|
headerBottom,
|
|
72
105
|
layoutColumn,
|
|
73
106
|
lineTopOf,
|
|
74
107
|
measureSide,
|
|
108
|
+
offeredWidths,
|
|
109
|
+
railLeftEdge,
|
|
75
110
|
readKeepLeft,
|
|
111
|
+
readSideWidth,
|
|
112
|
+
readSideWidthOffered,
|
|
76
113
|
remToPx,
|
|
77
114
|
roomAt,
|
|
78
115
|
sideColumn,
|
|
116
|
+
sideWidthPixels,
|
|
117
|
+
sidebarsBetween,
|
|
79
118
|
slideArticle,
|
|
119
|
+
SIDE_WIDTH_OPTIONS,
|
|
80
120
|
writeKeepLeft,
|
|
121
|
+
writeSideWidth,
|
|
122
|
+
writeSideWidthOffered,
|
|
81
123
|
type ColumnEntry,
|
|
82
|
-
type KeepLeftToggle,
|
|
83
124
|
type SideColumn,
|
|
125
|
+
type SideControls,
|
|
126
|
+
type SideControlsState,
|
|
84
127
|
type SideMeasure,
|
|
85
128
|
type SideOptions,
|
|
129
|
+
type SideWidthPreset,
|
|
86
130
|
} from './card-side';
|
|
87
131
|
import { startTrip, type Box, type Point, type Trip } from './card-travel';
|
|
88
132
|
|
|
@@ -111,6 +155,8 @@ export const CARD_CONSTANTS = {
|
|
|
111
155
|
travelTripMaxMs: 2500,
|
|
112
156
|
/** C8: how long a kept card flashes (ms). */
|
|
113
157
|
flashMs: 500,
|
|
158
|
+
/** K7: how long the trigger an "Opened at" link lands on stays lit (ms). */
|
|
159
|
+
originFlashMs: 1200,
|
|
114
160
|
/** S1: the side column's gaps to the article and to the rail (px). */
|
|
115
161
|
sideGap: 16,
|
|
116
162
|
/** S1: the side card's narrowest and widest. */
|
|
@@ -261,6 +307,34 @@ export interface CardSource {
|
|
|
261
307
|
render(item: string, trigger: HTMLElement): HTMLElement | null | Promise<HTMLElement | null>;
|
|
262
308
|
/** After any of this layer's cards opens, closes or changes mode: the open items. */
|
|
263
309
|
onChange?(open: ReadonlyMap<string, CardMode>): void;
|
|
310
|
+
/**
|
|
311
|
+
* The item's own element on this page, which a card may hang from as from
|
|
312
|
+
* a trigger (`keepCardFor`): a float's number, for its toolbar's Pin and
|
|
313
|
+
* Dock. It is no trigger: hovering or clicking it opens nothing.
|
|
314
|
+
*/
|
|
315
|
+
ownAnchor?(item: string): HTMLElement | null;
|
|
316
|
+
/**
|
|
317
|
+
* K5: the card for `item`, which a trigger on `page` opened, built for
|
|
318
|
+
* another page from `page`, fetched (`./card-fetch.ts`). Links in it go to
|
|
319
|
+
* `page`. Without it, the layer's cards do not travel.
|
|
320
|
+
*/
|
|
321
|
+
build?(item: string, page: string): HTMLElement | null | Promise<HTMLElement | null>;
|
|
322
|
+
/**
|
|
323
|
+
* K5: `item`'s name on another page, when its own name means something only
|
|
324
|
+
* on `page`: a citation's number, a reference to `page`'s own equation. The
|
|
325
|
+
* card's key there is `layer:awayItem`. Default: `item` itself.
|
|
326
|
+
*/
|
|
327
|
+
awayItem?(item: string, page: string): string;
|
|
328
|
+
/**
|
|
329
|
+
* D4, K6: the glyph a trigger shows, comparable across pages (a notation
|
|
330
|
+
* entry's LaTeX), or `null`. A card from another page for a symbol of
|
|
331
|
+
* that page's own (`data-card-scope="local"`, its glyph in
|
|
332
|
+
* `data-card-glyph`) points at a trigger with its glyph with a dashed
|
|
333
|
+
* arrow: the same letters, defined elsewhere.
|
|
334
|
+
*/
|
|
335
|
+
glyphOf?(trigger: HTMLElement): string | null;
|
|
336
|
+
/** D4: every trigger on the page outside the cards whose glyph is `glyph`. */
|
|
337
|
+
lookalikes?(glyph: string): HTMLElement[];
|
|
264
338
|
}
|
|
265
339
|
|
|
266
340
|
let shellSerial = 0;
|
|
@@ -283,6 +357,16 @@ export function cloneCardShell(template: HTMLTemplateElement): HTMLElement | nul
|
|
|
283
357
|
// The manager.
|
|
284
358
|
// ---------------------------------------------------------------------------
|
|
285
359
|
|
|
360
|
+
/** K5: a card carried here from another page, where its trigger is. */
|
|
361
|
+
interface Away {
|
|
362
|
+
/** The carried card as the set names it: its page, its item there, its occurrence, its place there. */
|
|
363
|
+
readonly saved: CarriedCard;
|
|
364
|
+
/** K2: the edge it is held at here. */
|
|
365
|
+
edge: HoldEdge;
|
|
366
|
+
/** Its left against the article column's (px). */
|
|
367
|
+
dx: number;
|
|
368
|
+
}
|
|
369
|
+
|
|
286
370
|
interface CardRecord extends CardState {
|
|
287
371
|
readonly key: string;
|
|
288
372
|
readonly item: string;
|
|
@@ -301,6 +385,21 @@ interface CardRecord extends CardState {
|
|
|
301
385
|
dropAt: Point | null;
|
|
302
386
|
/** S2, D3: a docked card's top minus its trigger's line top; a drop sets its own. */
|
|
303
387
|
sideDy: number;
|
|
388
|
+
/** S11: a docked card's own width, from its edge; `null` takes the column's. */
|
|
389
|
+
width: number | null;
|
|
390
|
+
/** K2: where the card is held at the window's edge, while it is. */
|
|
391
|
+
held: Held | null;
|
|
392
|
+
/** K2: the place the layout gave it with the page, kept while it is held. */
|
|
393
|
+
natural: { readonly left: string; readonly top: string; readonly maxHeight: string } | null;
|
|
394
|
+
/** K3: its height whole and its header row's, measured while it shows whole. */
|
|
395
|
+
fullHeight: number;
|
|
396
|
+
headerHeight: number;
|
|
397
|
+
/** K3: the lowest-ranked held card collapses first: its order, renewed when the reader expands it. */
|
|
398
|
+
rank: number;
|
|
399
|
+
/** K5: carried here from another page, with no trigger on this one; `null` for a card attached here. */
|
|
400
|
+
away: Away | null;
|
|
401
|
+
/** K7: where the card was opened, once it is kept. */
|
|
402
|
+
openedAt: OpenedAt | null;
|
|
304
403
|
graceTimer: ReturnType<typeof setTimeout> | null;
|
|
305
404
|
flashTimer: ReturnType<typeof setTimeout> | null;
|
|
306
405
|
unwatch: () => void;
|
|
@@ -356,7 +455,10 @@ let slid = 0;
|
|
|
356
455
|
let pointedKey: string | null = null;
|
|
357
456
|
/** S9: "Keep the article left", as the reader last set it. */
|
|
358
457
|
let keepLeft = false;
|
|
359
|
-
let
|
|
458
|
+
let sideControls: SideControls | null = null;
|
|
459
|
+
/** S11: the width menu's choice, and whether the menu is offered yet. */
|
|
460
|
+
let sideWidth: SideWidthPreset = 'default';
|
|
461
|
+
let sideWidthOffered = false;
|
|
360
462
|
/** S5: the one arrow, from a kept card to an occurrence of its item. */
|
|
361
463
|
let arrow: ArrowOverlay | null = null;
|
|
362
464
|
/** S4: the animations moving cards and the article now. */
|
|
@@ -369,6 +471,28 @@ let dragged: {
|
|
|
369
471
|
readonly measure: SideMeasure | null;
|
|
370
472
|
readonly column: SideColumn | null;
|
|
371
473
|
} | null = null;
|
|
474
|
+
/** K1: "Carry cards", as the reader last set it. */
|
|
475
|
+
let carry = false;
|
|
476
|
+
/** S9, S11: the side column the controls row sits at the top of, while it is in use. */
|
|
477
|
+
let columnInUse: { readonly left: number; readonly width: SideControlsState['width'] } | null = null;
|
|
478
|
+
/** K2: whether the one scroll listener that holds cards is on. */
|
|
479
|
+
let holding = false;
|
|
480
|
+
/** K5: no carrying in a standalone export, which has no other pages to fetch. */
|
|
481
|
+
let standalone = false;
|
|
482
|
+
/** K5: the course's base path, which names its carried set. */
|
|
483
|
+
let base = '/';
|
|
484
|
+
/** K5: carried cards from other pages, waiting for their layer, then for their build. */
|
|
485
|
+
let awayQueue: CarriedCard[] = [];
|
|
486
|
+
const awayBuilding = new Set<CarriedCard>();
|
|
487
|
+
/** K5: every carried card from another page this page restored, to find a child's parent. */
|
|
488
|
+
let awayEntries: CarriedCard[] = [];
|
|
489
|
+
/** C10, K3: the order a restored card had, so cards arriving late keep their place. */
|
|
490
|
+
const restoreOrder = new Map<RememberedCard, number>();
|
|
491
|
+
/** K5: the set this page last stored, to tell when another page changed it (Back, Forward). */
|
|
492
|
+
let lastStored: string | null = null;
|
|
493
|
+
let saveTimer: ReturnType<typeof setTimeout> | null = null;
|
|
494
|
+
/** K5: no saving while the cards are put back from a stored set. */
|
|
495
|
+
let quiet = false;
|
|
372
496
|
/** Cards this page had open when the reader last left it, not restored yet. */
|
|
373
497
|
let remembered: RememberedCard[] = [];
|
|
374
498
|
let rememberedPointed: string | null = null;
|
|
@@ -431,6 +555,7 @@ function wanted(card: CardRecord): boolean {
|
|
|
431
555
|
}
|
|
432
556
|
|
|
433
557
|
function notify(source: CardSource): void {
|
|
558
|
+
scheduleSave();
|
|
434
559
|
if (!source.onChange) return;
|
|
435
560
|
const open = new Map<string, CardMode>();
|
|
436
561
|
for (const card of ordered()) if (card.source === source) open.set(card.item, card.mode);
|
|
@@ -439,6 +564,8 @@ function notify(source: CardSource): void {
|
|
|
439
564
|
|
|
440
565
|
function syncMode(card: CardRecord): void {
|
|
441
566
|
card.el.dataset.cardMode = card.mode;
|
|
567
|
+
showOpenedAt(card);
|
|
568
|
+
syncMark(card);
|
|
442
569
|
for (const pin of card.el.querySelectorAll<HTMLElement>('[data-card-pin]')) {
|
|
443
570
|
pin.setAttribute('aria-pressed', String(card.mode === 'pinned'));
|
|
444
571
|
}
|
|
@@ -463,7 +590,10 @@ function setMode(card: CardRecord, mode: CardMode): void {
|
|
|
463
590
|
card.moved = null;
|
|
464
591
|
card.dropAt = null;
|
|
465
592
|
}
|
|
466
|
-
if (mode !== 'side')
|
|
593
|
+
if (mode !== 'side') {
|
|
594
|
+
card.sideDy = -CARD_CONSTANTS.sideLineOffset;
|
|
595
|
+
card.el.style.width = '';
|
|
596
|
+
}
|
|
467
597
|
if (isKept(mode)) {
|
|
468
598
|
cancelGrace(card);
|
|
469
599
|
if (travel?.card === card) endTravel();
|
|
@@ -472,6 +602,8 @@ function setMode(card: CardRecord, mode: CardMode): void {
|
|
|
472
602
|
}
|
|
473
603
|
syncMode(card);
|
|
474
604
|
notify(card.source);
|
|
605
|
+
if (!arranging) syncControls();
|
|
606
|
+
hold();
|
|
475
607
|
}
|
|
476
608
|
|
|
477
609
|
/** P4: the card clicked, focused or opened last is on top. */
|
|
@@ -580,6 +712,12 @@ function watch(card: CardRecord): void {
|
|
|
580
712
|
|
|
581
713
|
/** P2, P6: place `card` next to its trigger, clear of every other open card. */
|
|
582
714
|
function place(card: CardRecord): void {
|
|
715
|
+
release(card);
|
|
716
|
+
if (card.away) {
|
|
717
|
+
placeAway(card);
|
|
718
|
+
watch(card);
|
|
719
|
+
return;
|
|
720
|
+
}
|
|
583
721
|
const others = ordered()
|
|
584
722
|
.filter((other) => other !== card)
|
|
585
723
|
.map((other) => other.el);
|
|
@@ -593,10 +731,32 @@ function place(card: CardRecord): void {
|
|
|
593
731
|
watch(card);
|
|
594
732
|
}
|
|
595
733
|
|
|
734
|
+
/**
|
|
735
|
+
* S11: each width menu size in pixels, in the card's text; Default is S1's
|
|
736
|
+
* widest. Measured once per root font size (the reader's text size, the
|
|
737
|
+
* browser's zoom), not on every layout pass.
|
|
738
|
+
*/
|
|
739
|
+
let sideWidthCache: { readonly font: string; readonly widths: Map<SideWidthPreset, number> } | null =
|
|
740
|
+
null;
|
|
741
|
+
function sideWidths(): Map<SideWidthPreset, number> {
|
|
742
|
+
const font = getComputedStyle(document.documentElement).fontSize;
|
|
743
|
+
if (sideWidthCache?.font !== font) {
|
|
744
|
+
sideWidthCache = {
|
|
745
|
+
font,
|
|
746
|
+
widths: sideWidthPixels(cardLayer(LAYER_VARIABLES), remToPx(CARD_CONSTANTS.sideWidthMax)),
|
|
747
|
+
};
|
|
748
|
+
}
|
|
749
|
+
return sideWidthCache.widths;
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
/** S1, S11: the widest a docked card gets is the width menu's size. */
|
|
596
753
|
const sideOptions = (): SideOptions => ({
|
|
597
754
|
gap: CARD_CONSTANTS.sideGap,
|
|
598
755
|
minWidth: remToPx(CARD_CONSTANTS.sideWidthMin),
|
|
599
|
-
maxWidth:
|
|
756
|
+
maxWidth:
|
|
757
|
+
sideWidth === 'default'
|
|
758
|
+
? remToPx(CARD_CONSTANTS.sideWidthMax)
|
|
759
|
+
: (sideWidths().get(sideWidth) ?? remToPx(CARD_CONSTANTS.sideWidthMax)),
|
|
600
760
|
});
|
|
601
761
|
|
|
602
762
|
/**
|
|
@@ -637,12 +797,39 @@ function placeMoved(card: CardRecord): void {
|
|
|
637
797
|
moveTo(card.el, keepBetween(at.x, width, sidebarsBetween(CARD_CONSTANTS.edge)), at.y);
|
|
638
798
|
}
|
|
639
799
|
|
|
800
|
+
/**
|
|
801
|
+
* P1: a card beside a trigger the reader folded away — its section collapsed,
|
|
802
|
+
* its fold closed — has nothing to stand next to, and the text that moved up
|
|
803
|
+
* into the trigger's place would run under it. It waits out of sight, keeping
|
|
804
|
+
* its size, and comes back beside its trigger when the trigger shows again.
|
|
805
|
+
* Returns whether the card is folded away.
|
|
806
|
+
*/
|
|
807
|
+
function foldAway(card: CardRecord): boolean {
|
|
808
|
+
const { trigger } = card;
|
|
809
|
+
// A closed `<details>` hides its content with `content-visibility: hidden`,
|
|
810
|
+
// which keeps the trigger's last box: only `checkVisibility` sees it gone.
|
|
811
|
+
// A trigger in a card that is itself folded away goes with it.
|
|
812
|
+
const folded =
|
|
813
|
+
trigger.isConnected &&
|
|
814
|
+
(!(typeof trigger.checkVisibility === 'function'
|
|
815
|
+
? trigger.checkVisibility()
|
|
816
|
+
: trigger.getClientRects().length > 0) ||
|
|
817
|
+
trigger.closest('[data-card-folded]') !== null);
|
|
818
|
+
card.el.toggleAttribute('data-card-folded', folded);
|
|
819
|
+
return folded;
|
|
820
|
+
}
|
|
821
|
+
|
|
640
822
|
/** P1: a card that is not docked goes back to its place after a layout change. */
|
|
641
823
|
function reanchor(card: CardRecord, measure: SideMeasure | null): void {
|
|
824
|
+
if (card.away) {
|
|
825
|
+
placeAway(card);
|
|
826
|
+
return;
|
|
827
|
+
}
|
|
642
828
|
if (card.mode === 'moved') {
|
|
643
829
|
placeMoved(card);
|
|
644
830
|
return;
|
|
645
831
|
}
|
|
832
|
+
if (foldAway(card)) return;
|
|
646
833
|
const { anchor } = card;
|
|
647
834
|
if (!anchor) return;
|
|
648
835
|
if (anchor.column) {
|
|
@@ -684,7 +871,8 @@ function columnTops(
|
|
|
684
871
|
extra: ColumnEntry | null = null,
|
|
685
872
|
): { left: number; docked: CardRecord[]; tops: Map<string, number>; origin: DOMRect } {
|
|
686
873
|
const skip = (card: CardRecord) => card === dragged?.card || card.key === extra?.key;
|
|
687
|
-
|
|
874
|
+
// K5: a card from another page has no line here; it is held at its edge.
|
|
875
|
+
const docked = ordered().filter((card) => card.mode === 'side' && !skip(card) && !card.away);
|
|
688
876
|
const left = columnLeft(column, slide, CARD_CONSTANTS.sideGap);
|
|
689
877
|
const right = left + column.width;
|
|
690
878
|
// Page coordinates, so the layout does not depend on the scroll position.
|
|
@@ -727,10 +915,52 @@ function layoutDocked(column: SideColumn, first: CardRecord | null): void {
|
|
|
727
915
|
const maxHeight = sideMaxHeight();
|
|
728
916
|
for (const card of docking) card.el.style.maxHeight = `${maxHeight}px`;
|
|
729
917
|
if (first) pointedKey = first.key;
|
|
918
|
+
for (const card of docking) card.el.style.width = card.width ? `${card.width}px` : '';
|
|
730
919
|
const { left, docked, tops, origin } = columnTops(column, slid, pointedKey);
|
|
731
920
|
for (const card of docked) {
|
|
732
921
|
moveTo(card.el, left, (tops.get(card.key) ?? 0) + origin.top);
|
|
733
922
|
}
|
|
923
|
+
// K5: a card from another page takes the column's edge; its edge of the window holds it.
|
|
924
|
+
for (const card of docking) {
|
|
925
|
+
if (card.away) moveTo(card.el, left, card.el.getBoundingClientRect().top);
|
|
926
|
+
}
|
|
927
|
+
coverRail();
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
/** S11: the width menu, once the reader has resized a card and a wider size fits. */
|
|
931
|
+
function widthMenu(): SideControlsState['width'] {
|
|
932
|
+
if (!sideWidthOffered) return null;
|
|
933
|
+
const widths = sideWidths();
|
|
934
|
+
const offered = offeredWidths(widths, closedRoom(CARD_CONSTANTS.sideGap));
|
|
935
|
+
if (offered.length < 2 && sideWidth === 'default') return null;
|
|
936
|
+
const custom = ordered().some((card) => card.mode === 'side' && card.width !== null);
|
|
937
|
+
return {
|
|
938
|
+
offered: offered.includes(sideWidth) ? offered : [...offered, sideWidth],
|
|
939
|
+
value: custom ? 'custom' : sideWidth,
|
|
940
|
+
characters: new Map(SIDE_WIDTH_OPTIONS.map((option) => [option.id, option.characters])),
|
|
941
|
+
};
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
/** S11: a size from the menu, for every docked card and the next ones. */
|
|
945
|
+
function chooseSideWidth(preset: SideWidthPreset): void {
|
|
946
|
+
sideWidth = preset;
|
|
947
|
+
writeSideWidth(preset);
|
|
948
|
+
for (const card of cards.values()) card.width = null;
|
|
949
|
+
arrange({ from: motionFrom() });
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
/**
|
|
953
|
+
* S11: a docked card wider than the room reaches over the rail (your width,
|
|
954
|
+
* or a size the menu offers only with both panels collapsed). The rail then
|
|
955
|
+
* goes under the card layer, so the card and its edge's handle stay whole;
|
|
956
|
+
* the header still covers both.
|
|
957
|
+
*/
|
|
958
|
+
function coverRail(): void {
|
|
959
|
+
const railLeft = railLeftEdge();
|
|
960
|
+
const over = ordered().some(
|
|
961
|
+
(card) => card.mode === 'side' && card.el.getBoundingClientRect().right > railLeft + 0.5,
|
|
962
|
+
);
|
|
963
|
+
document.documentElement.toggleAttribute('data-card-over-rail', over);
|
|
734
964
|
}
|
|
735
965
|
|
|
736
966
|
interface Motion {
|
|
@@ -814,6 +1044,9 @@ function arrange(options: ArrangeOptions = {}): void {
|
|
|
814
1044
|
arranging = true;
|
|
815
1045
|
try {
|
|
816
1046
|
stopMotion();
|
|
1047
|
+
// K2: every card back with the page while the layout measures it.
|
|
1048
|
+
releaseAll();
|
|
1049
|
+
for (const card of cards.values()) card.headerHeight = 0;
|
|
817
1050
|
const docked = ordered().filter((card) => card.mode === 'side');
|
|
818
1051
|
const needed =
|
|
819
1052
|
docked.length > 0 || keepLeft || ordered().some((card) => card.anchor?.column);
|
|
@@ -835,10 +1068,11 @@ function arrange(options: ArrangeOptions = {}): void {
|
|
|
835
1068
|
slid = !column ? 0 : keepLeft ? column.maxSlide : docking > 0 ? column.slide : 0;
|
|
836
1069
|
slideArticle(slid, column?.maxSlide ?? 0);
|
|
837
1070
|
if (column) {
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
1071
|
+
// S11: Default keeps S1's width, at most the room; a wider size is the
|
|
1072
|
+
// reader's choice, kept even where it reaches over the rail.
|
|
1073
|
+
const width =
|
|
1074
|
+
sideWidth === 'default' ? column.width : Math.max(column.width, sideOptions().maxWidth);
|
|
1075
|
+
cardLayer(LAYER_VARIABLES).style.setProperty('--explico-card-side-width', `${width}px`);
|
|
842
1076
|
}
|
|
843
1077
|
// The card being dragged goes where the pointer takes it, nowhere else.
|
|
844
1078
|
for (const card of ordered()) {
|
|
@@ -848,11 +1082,13 @@ function arrange(options: ArrangeOptions = {}): void {
|
|
|
848
1082
|
}
|
|
849
1083
|
if (column) layoutDocked(column, options.first ?? null);
|
|
850
1084
|
for (const card of fresh) if (isOpen(card) && card !== dragged?.card) place(card);
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
1085
|
+
columnInUse =
|
|
1086
|
+
column && (keepLeft || docking > 0)
|
|
1087
|
+
? { left: columnLeft(column, slid, CARD_CONSTANTS.sideGap), width: widthMenu() }
|
|
1088
|
+
: null;
|
|
1089
|
+
syncControls();
|
|
1090
|
+
if (!column || docking === 0) document.documentElement.removeAttribute('data-card-over-rail');
|
|
1091
|
+
holdNow();
|
|
856
1092
|
arrow?.redraw();
|
|
857
1093
|
if (options.from) play(options.from);
|
|
858
1094
|
} finally {
|
|
@@ -864,6 +1100,58 @@ function arrange(options: ArrangeOptions = {}): void {
|
|
|
864
1100
|
}
|
|
865
1101
|
}
|
|
866
1102
|
|
|
1103
|
+
/**
|
|
1104
|
+
* S9, S11, K1: the controls row. At the top of the side column while it is
|
|
1105
|
+
* in use, with "Article left", the width menu and "Carry cards"; else
|
|
1106
|
+
* "Carry cards" alone at the article's top right, while a card is kept (D7).
|
|
1107
|
+
*/
|
|
1108
|
+
function syncControls(): void {
|
|
1109
|
+
if (!sideControls) return;
|
|
1110
|
+
const anyKept = ordered().some((card) => isKept(card.mode));
|
|
1111
|
+
const article = document
|
|
1112
|
+
.querySelector<HTMLElement>('.main-pane .sl-markdown-content')
|
|
1113
|
+
?.getBoundingClientRect();
|
|
1114
|
+
const windowRight = document.documentElement.clientWidth;
|
|
1115
|
+
sideControls.update({
|
|
1116
|
+
visible: !!columnInUse || anyKept,
|
|
1117
|
+
at: columnInUse
|
|
1118
|
+
? { left: columnInUse.left }
|
|
1119
|
+
: {
|
|
1120
|
+
right:
|
|
1121
|
+
article && article.width > 0
|
|
1122
|
+
? Math.max(0, windowRight - article.right)
|
|
1123
|
+
: CARD_CONSTANTS.edge,
|
|
1124
|
+
},
|
|
1125
|
+
keepLeft: { visible: !!columnInUse, on: keepLeft },
|
|
1126
|
+
carry: { visible: anyKept && !standalone, on: carry },
|
|
1127
|
+
width: columnInUse?.width ?? null,
|
|
1128
|
+
});
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
/** K1: the reader's "Carry cards". */
|
|
1132
|
+
function setCarry(on: boolean): void {
|
|
1133
|
+
carry = on;
|
|
1134
|
+
writeCarry(on);
|
|
1135
|
+
if (on) {
|
|
1136
|
+
// K5: the cards kept here start their journey.
|
|
1137
|
+
saveCarried();
|
|
1138
|
+
} else {
|
|
1139
|
+
// K5: the journeys end; the cards from other pages go with them.
|
|
1140
|
+
awayQueue = [];
|
|
1141
|
+
awayBuilding.clear();
|
|
1142
|
+
awayStranded = [];
|
|
1143
|
+
for (const card of ordered().reverse()) if (isOpen(card) && card.away) close(card);
|
|
1144
|
+
forgetCarried(base);
|
|
1145
|
+
lastStored = null;
|
|
1146
|
+
}
|
|
1147
|
+
for (const card of cards.values()) {
|
|
1148
|
+
showOpenedAt(card);
|
|
1149
|
+
syncMark(card);
|
|
1150
|
+
}
|
|
1151
|
+
syncControls();
|
|
1152
|
+
hold();
|
|
1153
|
+
}
|
|
1154
|
+
|
|
867
1155
|
/** P1: the layout changed. While cards or the article are moving, wait until they stop. */
|
|
868
1156
|
function reanchorAll(): void {
|
|
869
1157
|
if (motion.size > 0) {
|
|
@@ -873,6 +1161,202 @@ function reanchorAll(): void {
|
|
|
873
1161
|
arrange();
|
|
874
1162
|
}
|
|
875
1163
|
|
|
1164
|
+
// ---------------------------------------------------------------------------
|
|
1165
|
+
// K2–K4: kept cards held at the window's edges while the reader carries them.
|
|
1166
|
+
// ---------------------------------------------------------------------------
|
|
1167
|
+
|
|
1168
|
+
/** K2: a held card back with the page, where the layout last put it. */
|
|
1169
|
+
function release(card: CardRecord): void {
|
|
1170
|
+
const { natural, el } = card;
|
|
1171
|
+
card.held = null;
|
|
1172
|
+
if (!natural) return;
|
|
1173
|
+
card.natural = null;
|
|
1174
|
+
el.style.position = '';
|
|
1175
|
+
el.style.left = natural.left;
|
|
1176
|
+
el.style.top = natural.top;
|
|
1177
|
+
el.style.maxHeight = natural.maxHeight;
|
|
1178
|
+
el.removeAttribute('data-card-held');
|
|
1179
|
+
el.removeAttribute('data-card-collapsed');
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
function releaseAll(): void {
|
|
1183
|
+
for (const card of cards.values()) release(card);
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
/** K3: how tall the card is collapsed to its header row. */
|
|
1187
|
+
function collapsedHeight(card: CardRecord): number {
|
|
1188
|
+
const { el } = card;
|
|
1189
|
+
if (el.hasAttribute('data-card-collapsed')) return el.getBoundingClientRect().height;
|
|
1190
|
+
el.setAttribute('data-card-collapsed', '');
|
|
1191
|
+
const height = el.getBoundingClientRect().height;
|
|
1192
|
+
el.removeAttribute('data-card-collapsed');
|
|
1193
|
+
return height;
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
/**
|
|
1197
|
+
* K2: where a kept card is with the page, in the viewport, at its full
|
|
1198
|
+
* height: where it is, or, while held, the place the layout gave it.
|
|
1199
|
+
*/
|
|
1200
|
+
function naturalBox(card: CardRecord, layer: DOMRect): Box {
|
|
1201
|
+
const rect = card.el.getBoundingClientRect();
|
|
1202
|
+
const { natural, held } = card;
|
|
1203
|
+
if (!natural) {
|
|
1204
|
+
card.fullHeight = rect.height;
|
|
1205
|
+
return rect;
|
|
1206
|
+
}
|
|
1207
|
+
if (held && !held.collapsed && held.maxHeight === null) card.fullHeight = rect.height;
|
|
1208
|
+
const left = layer.left + (Number.parseFloat(natural.left) || 0);
|
|
1209
|
+
const top = layer.top + (Number.parseFloat(natural.top) || 0);
|
|
1210
|
+
return { left, top, right: left + rect.width, bottom: top + card.fullHeight };
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
/** K2: the window under the header, and the side controls row a docked card stacks under. */
|
|
1214
|
+
function holdBand(): { top: number; bottom: number; blocks: Box[] } {
|
|
1215
|
+
const row = sideControls?.box() ?? null;
|
|
1216
|
+
return {
|
|
1217
|
+
top: headerBottom() + CARD_CONSTANTS.edge,
|
|
1218
|
+
bottom: document.documentElement.clientHeight - CARD_CONSTANTS.edge,
|
|
1219
|
+
blocks: row ? [row] : [],
|
|
1220
|
+
};
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
/** K2: fix a card at its edge of the window, or give it back to the page. */
|
|
1224
|
+
function applyHeld(card: CardRecord, held: Held | null, layer: DOMRect): void {
|
|
1225
|
+
if (!held) {
|
|
1226
|
+
release(card);
|
|
1227
|
+
return;
|
|
1228
|
+
}
|
|
1229
|
+
const { el } = card;
|
|
1230
|
+
const natural = card.natural ?? {
|
|
1231
|
+
left: el.style.left,
|
|
1232
|
+
top: el.style.top,
|
|
1233
|
+
maxHeight: el.style.maxHeight,
|
|
1234
|
+
};
|
|
1235
|
+
card.natural = natural;
|
|
1236
|
+
card.held = held;
|
|
1237
|
+
el.style.position = 'fixed';
|
|
1238
|
+
el.style.left = `${Math.round(layer.left + (Number.parseFloat(natural.left) || 0))}px`;
|
|
1239
|
+
el.style.top = `${Math.round(held.top)}px`;
|
|
1240
|
+
el.style.maxHeight =
|
|
1241
|
+
held.maxHeight === null ? natural.maxHeight : `${Math.round(held.maxHeight)}px`;
|
|
1242
|
+
el.dataset.cardHeld = held.edge;
|
|
1243
|
+
el.toggleAttribute('data-card-collapsed', held.collapsed);
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
/** K2: the one scroll listener that holds cards, on only while carry is on and a card is kept. */
|
|
1247
|
+
function listenForHold(on: boolean): void {
|
|
1248
|
+
if (on === holding) return;
|
|
1249
|
+
holding = on;
|
|
1250
|
+
if (on) window.addEventListener('scroll', onHoldScroll, { passive: true });
|
|
1251
|
+
else window.removeEventListener('scroll', onHoldScroll);
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
function onHoldScroll(): void {
|
|
1255
|
+
hold();
|
|
1256
|
+
arrow?.redraw();
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
/** K2–K4, after anything that changes the kept cards. A layout pass holds them once it is done. */
|
|
1260
|
+
function hold(): void {
|
|
1261
|
+
if (!arranging) holdNow();
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
/**
|
|
1265
|
+
* K2–K4: with carry on, every kept card whose place has scrolled past an
|
|
1266
|
+
* edge of the window is held at that edge, stacked and collapsed as
|
|
1267
|
+
* `heldAt` says; the others follow the page. A hovered card a kept child
|
|
1268
|
+
* holds open (C9) counts as kept. K4: a card opened from inside a held card
|
|
1269
|
+
* moves with it, and collapses with it. The card being dragged goes where
|
|
1270
|
+
* the pointer takes it.
|
|
1271
|
+
*/
|
|
1272
|
+
function holdNow(): void {
|
|
1273
|
+
const kept = carry
|
|
1274
|
+
? ordered().filter((card) => !isTransient(cards, card.key) && card !== dragged?.card)
|
|
1275
|
+
: [];
|
|
1276
|
+
const keptKeys = new Set(kept.map((card) => card.key));
|
|
1277
|
+
for (const card of cards.values()) if (!keptKeys.has(card.key)) release(card);
|
|
1278
|
+
listenForHold(kept.length > 0);
|
|
1279
|
+
if (kept.length === 0) {
|
|
1280
|
+
makeRoom(0);
|
|
1281
|
+
return;
|
|
1282
|
+
}
|
|
1283
|
+
const layer = cardLayer(LAYER_VARIABLES).getBoundingClientRect();
|
|
1284
|
+
// K4: up to the outermost kept card it was opened inside; a card from
|
|
1285
|
+
// another page (K5) has no trigger inside another and is held on its own.
|
|
1286
|
+
const rootOf = (card: CardRecord): CardRecord => {
|
|
1287
|
+
let root = card;
|
|
1288
|
+
for (
|
|
1289
|
+
let parent = card.away || !card.parentKey ? undefined : cards.get(card.parentKey);
|
|
1290
|
+
parent && keptKeys.has(parent.key);
|
|
1291
|
+
parent = parent.away || !parent.parentKey ? undefined : cards.get(parent.parentKey)
|
|
1292
|
+
) {
|
|
1293
|
+
root = parent;
|
|
1294
|
+
}
|
|
1295
|
+
return root;
|
|
1296
|
+
};
|
|
1297
|
+
const boxes = new Map(kept.map((card) => [card, naturalBox(card, layer)] as const));
|
|
1298
|
+
const roots = kept.filter((card) => rootOf(card) === card);
|
|
1299
|
+
const input: HoldCard[] = roots.map((card) => {
|
|
1300
|
+
if (!card.headerHeight) card.headerHeight = collapsedHeight(card);
|
|
1301
|
+
return {
|
|
1302
|
+
key: card.key,
|
|
1303
|
+
box: boxes.get(card) ?? card.el.getBoundingClientRect(),
|
|
1304
|
+
headerHeight: card.headerHeight,
|
|
1305
|
+
order: card.order,
|
|
1306
|
+
rank: card.rank,
|
|
1307
|
+
edge: card.away?.edge ?? null,
|
|
1308
|
+
};
|
|
1309
|
+
});
|
|
1310
|
+
const band = holdBand();
|
|
1311
|
+
const spots = heldAt(input, band, CARD_CONSTANTS.gap);
|
|
1312
|
+
// K5: a card from another page held at the bottom edge never leaves it.
|
|
1313
|
+
// The page grows by their stack, so its end scrolls clear of them.
|
|
1314
|
+
let room = 0;
|
|
1315
|
+
for (const card of roots) {
|
|
1316
|
+
const spot = spots.get(card.key);
|
|
1317
|
+
if (card.away && spot?.edge === 'bottom') {
|
|
1318
|
+
room = Math.max(room, band.bottom - spot.top + CARD_CONSTANTS.edge);
|
|
1319
|
+
}
|
|
1320
|
+
}
|
|
1321
|
+
makeRoom(room);
|
|
1322
|
+
for (const card of kept) {
|
|
1323
|
+
const root = rootOf(card);
|
|
1324
|
+
const spot = spots.get(root.key) ?? null;
|
|
1325
|
+
if (root === card || !spot) {
|
|
1326
|
+
applyHeld(card, spot, layer);
|
|
1327
|
+
continue;
|
|
1328
|
+
}
|
|
1329
|
+
const shift = spot.top - (boxes.get(root)?.top ?? 0);
|
|
1330
|
+
applyHeld(
|
|
1331
|
+
card,
|
|
1332
|
+
{
|
|
1333
|
+
edge: spot.edge,
|
|
1334
|
+
top: (boxes.get(card)?.top ?? 0) + shift,
|
|
1335
|
+
collapsed: spot.collapsed,
|
|
1336
|
+
maxHeight: null,
|
|
1337
|
+
},
|
|
1338
|
+
layer,
|
|
1339
|
+
);
|
|
1340
|
+
}
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
/** K5: the room at the end of the page that cards from other pages held at the bottom take. */
|
|
1344
|
+
let carriedRoom = 0;
|
|
1345
|
+
function makeRoom(px: number): void {
|
|
1346
|
+
const room = Math.ceil(px);
|
|
1347
|
+
if (room === carriedRoom) return;
|
|
1348
|
+
carriedRoom = room;
|
|
1349
|
+
const root = document.documentElement;
|
|
1350
|
+
if (room > 0) root.style.setProperty('--explico-carried-room', `${room}px`);
|
|
1351
|
+
else root.style.removeProperty('--explico-carried-room');
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
/** K3: a collapsed card the reader opens counts as the newest from now on. */
|
|
1355
|
+
function expand(card: CardRecord): void {
|
|
1356
|
+
card.rank = ++opened;
|
|
1357
|
+
hold();
|
|
1358
|
+
}
|
|
1359
|
+
|
|
876
1360
|
/** S6, S7: the Side button — into the side column, or back next to the trigger. */
|
|
877
1361
|
function toggleSide(card: CardRecord): void {
|
|
878
1362
|
const from = motionFrom();
|
|
@@ -896,17 +1380,69 @@ const awayFromTrigger = (mode: CardMode | undefined): boolean =>
|
|
|
896
1380
|
mode === 'side' || mode === 'moved';
|
|
897
1381
|
|
|
898
1382
|
/** S5: the arrow from a kept card to an occurrence of its item. Not while the card is dragged. */
|
|
899
|
-
function pointAt(card: CardRecord, target: HTMLElement): void {
|
|
1383
|
+
function pointAt(card: CardRecord, target: HTMLElement, lookalike = false): void {
|
|
900
1384
|
if (!arrow || !isKept(card.mode) || dragged?.card === card) return;
|
|
901
|
-
arrow.show(card.el, target);
|
|
1385
|
+
arrow.show(card.el, target, { lookalike });
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1388
|
+
/**
|
|
1389
|
+
* D4, K6: a card carried in from another page for one of that page's own
|
|
1390
|
+
* symbols, whose glyph is `glyph`: the same letters here mean something
|
|
1391
|
+
* else.
|
|
1392
|
+
*/
|
|
1393
|
+
const looksLike = (card: CardRecord, glyph: string): boolean =>
|
|
1394
|
+
!!card.away && card.el.dataset.cardScope === 'local' && card.el.dataset.cardGlyph === glyph;
|
|
1395
|
+
|
|
1396
|
+
/** D4: the reader points at `hit`; the newest card from another page whose symbol looks like it points back, dashed. */
|
|
1397
|
+
function pointLookalike(hit: Hit): void {
|
|
1398
|
+
const glyph = hit.source.glyphOf?.(hit.trigger);
|
|
1399
|
+
const own = cards.get(hit.key);
|
|
1400
|
+
// A kept card of its own already points at it, solid (C8, S5).
|
|
1401
|
+
if (!glyph || (own && isKept(own.mode))) return;
|
|
1402
|
+
const card = ordered()
|
|
1403
|
+
.reverse()
|
|
1404
|
+
.find((candidate) => candidate.key !== hit.key && looksLike(candidate, glyph));
|
|
1405
|
+
if (card) pointAt(card, hit.trigger, true);
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1408
|
+
/**
|
|
1409
|
+
* S5, K5, D4: pointing at a card from another page shows the nearest place
|
|
1410
|
+
* on this page it belongs to: an occurrence of its item (a shared symbol, a
|
|
1411
|
+
* reference to the same float), else, for a page's own symbol, one that
|
|
1412
|
+
* only looks like it, dashed.
|
|
1413
|
+
*/
|
|
1414
|
+
function pointFromAway(card: CardRecord): void {
|
|
1415
|
+
const glyph = card.el.dataset.cardScope === 'local' ? card.el.dataset.cardGlyph : undefined;
|
|
1416
|
+
const same = occurrencesOf(card.source, new Set([card.item]), null).get(card.item) ?? [];
|
|
1417
|
+
const alike = same.length === 0 && glyph ? (card.source.lookalikes?.(glyph) ?? []) : [];
|
|
1418
|
+
const box = card.el.getBoundingClientRect();
|
|
1419
|
+
const middle = (box.top + box.bottom) / 2;
|
|
1420
|
+
let nearest: HTMLElement | null = null;
|
|
1421
|
+
let distance = Number.POSITIVE_INFINITY;
|
|
1422
|
+
for (const target of same.length > 0 ? same : alike) {
|
|
1423
|
+
const rect = target.getClientRects()[0];
|
|
1424
|
+
if (!rect) continue;
|
|
1425
|
+
const far = Math.abs((rect.top + rect.bottom) / 2 - middle);
|
|
1426
|
+
if (far < distance) {
|
|
1427
|
+
nearest = target;
|
|
1428
|
+
distance = far;
|
|
1429
|
+
}
|
|
1430
|
+
}
|
|
1431
|
+
if (nearest) pointAt(card, nearest, same.length === 0);
|
|
902
1432
|
}
|
|
903
1433
|
|
|
904
1434
|
/** C8: move a card to another occurrence of its item. A docked card re-aligns to its line. */
|
|
905
1435
|
function reattach(card: CardRecord, trigger: HTMLElement): void {
|
|
1436
|
+
if (card.away) {
|
|
1437
|
+
// K5: a card from another page, attached here: this page is its page now.
|
|
1438
|
+
card.away = null;
|
|
1439
|
+
card.el.removeAttribute('data-card-away');
|
|
1440
|
+
}
|
|
906
1441
|
card.trigger = trigger;
|
|
907
1442
|
card.parentKey = cardContaining(trigger)?.key ?? null;
|
|
908
1443
|
if (card.mode !== 'side') {
|
|
909
1444
|
place(card);
|
|
1445
|
+
hold();
|
|
910
1446
|
return;
|
|
911
1447
|
}
|
|
912
1448
|
const from = motionFrom();
|
|
@@ -960,6 +1496,12 @@ const dragHost: DragHost = {
|
|
|
960
1496
|
begin(el) {
|
|
961
1497
|
const card = cardContaining(el);
|
|
962
1498
|
if (!card || card.el !== el) return null;
|
|
1499
|
+
if (card.held) {
|
|
1500
|
+
// K2: a held card leaves its edge from where it is.
|
|
1501
|
+
const rect = el.getBoundingClientRect();
|
|
1502
|
+
release(card);
|
|
1503
|
+
moveTo(el, rect.left, rect.top);
|
|
1504
|
+
}
|
|
963
1505
|
const measure = measureSide();
|
|
964
1506
|
const column = measure ? sideColumn(measure, sideOptions()) : null;
|
|
965
1507
|
const home = homeSpot(card, measure);
|
|
@@ -992,6 +1534,15 @@ const dragHost: DragHost = {
|
|
|
992
1534
|
return;
|
|
993
1535
|
}
|
|
994
1536
|
const from = motionFrom();
|
|
1537
|
+
if (card.away) {
|
|
1538
|
+
// K5: a card from another page goes to the edge it was dropped nearer.
|
|
1539
|
+
awayAt(
|
|
1540
|
+
card,
|
|
1541
|
+
outcome.kind === 'moved' ? outcome.left : card.el.getBoundingClientRect().left,
|
|
1542
|
+
outcome.top,
|
|
1543
|
+
);
|
|
1544
|
+
scheduleSave();
|
|
1545
|
+
}
|
|
995
1546
|
if (outcome.kind === 'side') {
|
|
996
1547
|
// D3: at the drop height; S2 resolves overlaps, the article slides (S3).
|
|
997
1548
|
setMode(card, 'side');
|
|
@@ -1029,10 +1580,12 @@ const dragHost: DragHost = {
|
|
|
1029
1580
|
grab,
|
|
1030
1581
|
);
|
|
1031
1582
|
const from = motionFrom();
|
|
1583
|
+
if (card.away) awayAt(card, at.x, at.y);
|
|
1032
1584
|
setMode(card, 'moved');
|
|
1033
1585
|
card.dropAt = at;
|
|
1034
1586
|
raise(card);
|
|
1035
1587
|
arrange({ from });
|
|
1588
|
+
scheduleSave();
|
|
1036
1589
|
},
|
|
1037
1590
|
home(el) {
|
|
1038
1591
|
const card = cardContaining(el);
|
|
@@ -1095,6 +1648,8 @@ function close(card: CardRecord): void {
|
|
|
1095
1648
|
if (undocking) arrange({ from });
|
|
1096
1649
|
// C9: the parent was kept open by this child; check it again.
|
|
1097
1650
|
if (parent && isOpen(parent) && parent.mode === 'hover') graceCheck(parent);
|
|
1651
|
+
if (!arranging) syncControls();
|
|
1652
|
+
hold();
|
|
1098
1653
|
}
|
|
1099
1654
|
|
|
1100
1655
|
/** C7: close the transient cards outside the chain of `hostKey`. */
|
|
@@ -1105,10 +1660,22 @@ function closeOutside(hostKey: string | null): void {
|
|
|
1105
1660
|
}
|
|
1106
1661
|
}
|
|
1107
1662
|
|
|
1108
|
-
|
|
1663
|
+
interface MountOptions {
|
|
1664
|
+
/** C10, K3: the order a restored card had. */
|
|
1665
|
+
readonly order?: number;
|
|
1666
|
+
/** K5: a card carried from another page. */
|
|
1667
|
+
readonly away?: Away;
|
|
1668
|
+
/** C9: the card it was opened from, when its trigger is not inside it here (K5). */
|
|
1669
|
+
readonly parentKey?: string | null;
|
|
1670
|
+
/** K7: where a restored card was opened. */
|
|
1671
|
+
readonly openedAt?: OpenedAt | null;
|
|
1672
|
+
}
|
|
1673
|
+
|
|
1674
|
+
function mount(hit: Hit, el: HTMLElement, mode: CardMode, options: MountOptions = {}): void {
|
|
1109
1675
|
el.dataset.cardKey = hit.key;
|
|
1110
1676
|
el.hidden = false;
|
|
1111
1677
|
el.style.visibility = 'hidden';
|
|
1678
|
+
el.toggleAttribute('data-card-away', !!options.away);
|
|
1112
1679
|
cardLayer(LAYER_VARIABLES).append(el);
|
|
1113
1680
|
const card: CardRecord = {
|
|
1114
1681
|
key: hit.key,
|
|
@@ -1116,13 +1683,24 @@ function mount(hit: Hit, el: HTMLElement, mode: CardMode): void {
|
|
|
1116
1683
|
source: hit.source,
|
|
1117
1684
|
el,
|
|
1118
1685
|
trigger: hit.trigger,
|
|
1119
|
-
parentKey:
|
|
1686
|
+
parentKey:
|
|
1687
|
+
options.parentKey !== undefined
|
|
1688
|
+
? options.parentKey
|
|
1689
|
+
: (cardContaining(hit.trigger)?.key ?? null),
|
|
1120
1690
|
mode,
|
|
1121
|
-
order: ++opened,
|
|
1691
|
+
order: options.order ?? ++opened,
|
|
1122
1692
|
anchor: null,
|
|
1123
1693
|
moved: null,
|
|
1124
1694
|
dropAt: null,
|
|
1125
1695
|
sideDy: -CARD_CONSTANTS.sideLineOffset,
|
|
1696
|
+
width: null,
|
|
1697
|
+
held: null,
|
|
1698
|
+
natural: null,
|
|
1699
|
+
fullHeight: 0,
|
|
1700
|
+
headerHeight: 0,
|
|
1701
|
+
rank: options.order ?? opened,
|
|
1702
|
+
away: options.away ?? null,
|
|
1703
|
+
openedAt: options.openedAt ?? null,
|
|
1126
1704
|
graceTimer: null,
|
|
1127
1705
|
flashTimer: null,
|
|
1128
1706
|
unwatch: noop,
|
|
@@ -1144,6 +1722,8 @@ function mount(hit: Hit, el: HTMLElement, mode: CardMode): void {
|
|
|
1144
1722
|
if (isOpen(card) && card.mode === 'hover') graceCheck(card);
|
|
1145
1723
|
});
|
|
1146
1724
|
notify(card.source);
|
|
1725
|
+
if (isKept(mode) && !arranging) syncControls();
|
|
1726
|
+
hold();
|
|
1147
1727
|
}
|
|
1148
1728
|
|
|
1149
1729
|
function settle(key: string, token: number, el: HTMLElement | null): void {
|
|
@@ -1220,11 +1800,12 @@ function onPointerOver(event: PointerEvent): void {
|
|
|
1220
1800
|
const host = cardContaining(event.target);
|
|
1221
1801
|
if (
|
|
1222
1802
|
host &&
|
|
1223
|
-
awayFromTrigger(host.mode) &&
|
|
1803
|
+
(awayFromTrigger(host.mode) || host.away) &&
|
|
1224
1804
|
arrow?.card !== host.el &&
|
|
1225
1805
|
!(event.relatedTarget instanceof Node && host.el.contains(event.relatedTarget))
|
|
1226
1806
|
) {
|
|
1227
|
-
|
|
1807
|
+
if (host.away) pointFromAway(host);
|
|
1808
|
+
else pointAt(host, host.trigger);
|
|
1228
1809
|
}
|
|
1229
1810
|
const hit = findTrigger(event.target);
|
|
1230
1811
|
if (!hit) return;
|
|
@@ -1241,6 +1822,7 @@ function onPointerOver(event: PointerEvent): void {
|
|
|
1241
1822
|
}
|
|
1242
1823
|
}
|
|
1243
1824
|
hoverOpen(hit);
|
|
1825
|
+
pointLookalike(hit);
|
|
1244
1826
|
}
|
|
1245
1827
|
|
|
1246
1828
|
function onPointerOut(event: PointerEvent): void {
|
|
@@ -1272,14 +1854,17 @@ function onFocusIn(event: FocusEvent): void {
|
|
|
1272
1854
|
const card = cardContaining(event.target);
|
|
1273
1855
|
if (card) raise(card);
|
|
1274
1856
|
const visible = event.target instanceof Element && event.target.matches(':focus-visible');
|
|
1275
|
-
if (card &&
|
|
1276
|
-
|
|
1857
|
+
if (card?.held?.collapsed && visible) expand(card); // K3: and so does the keyboard
|
|
1858
|
+
if (card && (awayFromTrigger(card.mode) || card.away) && visible && arrow?.card !== card.el) {
|
|
1859
|
+
if (card.away) pointFromAway(card);
|
|
1860
|
+
else pointAt(card, card.trigger);
|
|
1277
1861
|
}
|
|
1278
1862
|
const hit = findTrigger(event.target);
|
|
1279
1863
|
if (!hit || hit.trigger === refocusing) return;
|
|
1280
1864
|
// C1: keyboard focus opens a card; the focus a click leaves behind does not.
|
|
1281
1865
|
if (!visible) return;
|
|
1282
1866
|
hoverOpen(hit);
|
|
1867
|
+
pointLookalike(hit);
|
|
1283
1868
|
}
|
|
1284
1869
|
|
|
1285
1870
|
function onFocusOut(event: FocusEvent): void {
|
|
@@ -1290,9 +1875,23 @@ function onFocusOut(event: FocusEvent): void {
|
|
|
1290
1875
|
if (card && card.trigger === hit.trigger && card.mode === 'hover') graceCheck(card);
|
|
1291
1876
|
}
|
|
1292
1877
|
|
|
1293
|
-
/** C3, C4, C6, C8, S6, D5: clicks on a card's controls and on triggers. */
|
|
1878
|
+
/** C3, C4, C6, C8, S6, D5, K7: clicks on a card's controls and on triggers. */
|
|
1294
1879
|
function onClick(event: MouseEvent): void {
|
|
1295
1880
|
const host = cardContaining(event.target);
|
|
1881
|
+
const openedAt =
|
|
1882
|
+
host && event.target instanceof Element
|
|
1883
|
+
? event.target.closest<HTMLAnchorElement>('a[data-card-opened-at]')
|
|
1884
|
+
: null;
|
|
1885
|
+
if (host && openedAt) {
|
|
1886
|
+
// A modified click keeps its native meaning: the anchor, in a new tab.
|
|
1887
|
+
if (event.button !== 0 || event.ctrlKey || event.metaKey || event.shiftKey || event.altKey) {
|
|
1888
|
+
return;
|
|
1889
|
+
}
|
|
1890
|
+
event.preventDefault();
|
|
1891
|
+
event.stopPropagation();
|
|
1892
|
+
goToOpened(host, openedAt);
|
|
1893
|
+
return;
|
|
1894
|
+
}
|
|
1296
1895
|
const control =
|
|
1297
1896
|
host && event.target instanceof Element
|
|
1298
1897
|
? event.target.closest<HTMLElement>('[data-card-pin], [data-card-close], [data-card-side]')
|
|
@@ -1312,6 +1911,11 @@ function onClick(event: MouseEvent): void {
|
|
|
1312
1911
|
}
|
|
1313
1912
|
return;
|
|
1314
1913
|
}
|
|
1914
|
+
// K3: a click on a collapsed card opens it.
|
|
1915
|
+
if (host?.held?.collapsed) {
|
|
1916
|
+
expand(host);
|
|
1917
|
+
return;
|
|
1918
|
+
}
|
|
1315
1919
|
|
|
1316
1920
|
const hit = findTrigger(event.target);
|
|
1317
1921
|
if (!hit) return;
|
|
@@ -1396,6 +2000,16 @@ function occurrencesOf(
|
|
|
1396
2000
|
if (list) list.push(el);
|
|
1397
2001
|
else found.set(item, [el]);
|
|
1398
2002
|
}
|
|
2003
|
+
// An item's own anchor comes after its triggers, so their indices hold.
|
|
2004
|
+
if (!scope && source.ownAnchor) {
|
|
2005
|
+
for (const item of items) {
|
|
2006
|
+
const own = source.ownAnchor(item);
|
|
2007
|
+
if (!own || layer.contains(own)) continue;
|
|
2008
|
+
const list = found.get(item);
|
|
2009
|
+
if (list) list.push(own);
|
|
2010
|
+
else found.set(item, [own]);
|
|
2011
|
+
}
|
|
2012
|
+
}
|
|
1399
2013
|
return found;
|
|
1400
2014
|
}
|
|
1401
2015
|
|
|
@@ -1434,6 +2048,7 @@ function rememberCards(): void {
|
|
|
1434
2048
|
anchor: card.anchor,
|
|
1435
2049
|
moved: card.mode === 'moved' ? card.moved : null,
|
|
1436
2050
|
sideDy: card.mode === 'side' ? card.sideDy : null,
|
|
2051
|
+
width: card.width,
|
|
1437
2052
|
});
|
|
1438
2053
|
}
|
|
1439
2054
|
writeRememberedCards({
|
|
@@ -1444,10 +2059,14 @@ function rememberCards(): void {
|
|
|
1444
2059
|
|
|
1445
2060
|
/** Open a remembered card as it was: in its mode, at its place. */
|
|
1446
2061
|
function revive(hit: Hit, el: HTMLElement, saved: RememberedCard): void {
|
|
1447
|
-
mount(hit, el, saved.mode === 'hover' ? 'hover' : 'pinned'
|
|
2062
|
+
mount(hit, el, saved.mode === 'hover' ? 'hover' : 'pinned', {
|
|
2063
|
+
order: restoreOrder.get(saved),
|
|
2064
|
+
openedAt: 'openedAt' in saved ? (saved as CarriedCard).openedAt : null,
|
|
2065
|
+
});
|
|
1448
2066
|
const card = cards.get(hit.key);
|
|
1449
2067
|
if (!card || card.el !== el) return;
|
|
1450
2068
|
if (saved.anchor) card.anchor = saved.anchor;
|
|
2069
|
+
card.width = saved.width ?? null;
|
|
1451
2070
|
if (saved.mode === 'side') {
|
|
1452
2071
|
setMode(card, 'side');
|
|
1453
2072
|
if (saved.sideDy !== null) card.sideDy = saved.sideDy;
|
|
@@ -1511,6 +2130,429 @@ function restoreRemembered(): void {
|
|
|
1511
2130
|
}
|
|
1512
2131
|
}
|
|
1513
2132
|
|
|
2133
|
+
// ---------------------------------------------------------------------------
|
|
2134
|
+
// K7: "Opened at", back to where a kept card was opened (`./card-carry.ts`).
|
|
2135
|
+
// ---------------------------------------------------------------------------
|
|
2136
|
+
|
|
2137
|
+
/**
|
|
2138
|
+
* K8: while carrying is on, a kept card says whether its meaning holds on
|
|
2139
|
+
* every page — a globe: shared notation, citations, abbreviations, numbered
|
|
2140
|
+
* references — or only on its own page: a page and "this page", for one of
|
|
2141
|
+
* that page's own symbols (`data-card-scope="local"`). The page is the one
|
|
2142
|
+
* the card was read off (`data-card-defined-in`), which need not be where it
|
|
2143
|
+
* was opened: a symbol in an equation from another lesson is that lesson's.
|
|
2144
|
+
* Never the pin's icon.
|
|
2145
|
+
*/
|
|
2146
|
+
function syncMark(card: CardRecord): void {
|
|
2147
|
+
const mark = card.el.querySelector<HTMLElement>('[data-card-mark]');
|
|
2148
|
+
if (!mark) return;
|
|
2149
|
+
mark.hidden = !(carry && isKept(card.mode));
|
|
2150
|
+
if (mark.hidden) return;
|
|
2151
|
+
const own = card.el.dataset.cardScope === 'local';
|
|
2152
|
+
mark.dataset.cardMark = own ? 'page' : 'global';
|
|
2153
|
+
const where = card.el.dataset.cardDefinedIn;
|
|
2154
|
+
const label = !own
|
|
2155
|
+
? 'Means the same on every page of the course'
|
|
2156
|
+
: where
|
|
2157
|
+
? `Defined on ${where}: it means this only there`
|
|
2158
|
+
: 'Defined on this page: it means this only here';
|
|
2159
|
+
mark.title = label;
|
|
2160
|
+
mark.setAttribute('aria-label', label);
|
|
2161
|
+
}
|
|
2162
|
+
|
|
2163
|
+
/** K7: the place on its page a trigger sits in: a numbered equation, else its section. */
|
|
2164
|
+
function placeOf(trigger: HTMLElement): Pick<OpenedAt, 'anchor' | 'label' | 'name'> {
|
|
2165
|
+
const equation = trigger.closest<HTMLElement>('.keyed-equation[id]');
|
|
2166
|
+
const number = equation?.dataset.eqNumber;
|
|
2167
|
+
if (equation && number) {
|
|
2168
|
+
return { anchor: equation.id, label: `(${number})`, name: equation.dataset.eqShort ?? '' };
|
|
2169
|
+
}
|
|
2170
|
+
const list = trigger.closest<HTMLElement>('.notation-list[id]');
|
|
2171
|
+
if (list) return { anchor: list.id, label: 'the notation list', name: '' };
|
|
2172
|
+
// The last section heading before it in the article: a numbered section,
|
|
2173
|
+
// worked example or knowledge check, or, where the course shows no
|
|
2174
|
+
// numbers, an outline heading. A fold's bar and a memo are no sections.
|
|
2175
|
+
const article = document.querySelector<HTMLElement>('.main-pane .sl-markdown-content');
|
|
2176
|
+
const isSection = (candidate: HTMLElement) =>
|
|
2177
|
+
!!(candidate.dataset.secNumber || candidate.dataset.checkNumber || candidate.dataset.exNumber) ||
|
|
2178
|
+
(/^H[234]$/.test(candidate.tagName) &&
|
|
2179
|
+
!candidate.id.startsWith('fold-') &&
|
|
2180
|
+
!candidate.dataset.memoNumber &&
|
|
2181
|
+
!candidate.closest('figure, aside'));
|
|
2182
|
+
let heading: HTMLElement | null = null;
|
|
2183
|
+
for (const candidate of article?.querySelectorAll<HTMLElement>(
|
|
2184
|
+
'h2[id], h3[id], h4[id], h5[id], h6[id]',
|
|
2185
|
+
) ?? []) {
|
|
2186
|
+
if (!(candidate.compareDocumentPosition(trigger) & Node.DOCUMENT_POSITION_FOLLOWING)) break;
|
|
2187
|
+
if (isSection(candidate)) heading = candidate;
|
|
2188
|
+
}
|
|
2189
|
+
// Before the first section: the page's own heading.
|
|
2190
|
+
if (!heading) return { anchor: '_top', label: 'the start', name: '' };
|
|
2191
|
+
const title = readableText(heading.querySelector('.block-heading__title') ?? heading);
|
|
2192
|
+
return { anchor: heading.id, ...sectionPlace(heading.dataset, title) };
|
|
2193
|
+
}
|
|
2194
|
+
|
|
2195
|
+
/**
|
|
2196
|
+
* K7: where `card` was opened: its trigger's place on this page, or, for a
|
|
2197
|
+
* card opened from inside another, where the outermost one was opened.
|
|
2198
|
+
*/
|
|
2199
|
+
function ensureOpenedAt(card: CardRecord): OpenedAt | null {
|
|
2200
|
+
if (card.openedAt) return card.openedAt;
|
|
2201
|
+
const parent = card.parentKey ? cards.get(card.parentKey) : undefined;
|
|
2202
|
+
if (parent) {
|
|
2203
|
+
card.openedAt = ensureOpenedAt(parent);
|
|
2204
|
+
return card.openedAt;
|
|
2205
|
+
}
|
|
2206
|
+
const { trigger } = card;
|
|
2207
|
+
if (card.away || !trigger.isConnected) return null;
|
|
2208
|
+
const occurrence =
|
|
2209
|
+
occurrencesOf(card.source, new Set([card.item]), null).get(card.item)?.indexOf(trigger) ?? -1;
|
|
2210
|
+
if (occurrence < 0) return null;
|
|
2211
|
+
card.openedAt = {
|
|
2212
|
+
page: window.location.pathname,
|
|
2213
|
+
title: pageTitleOf(document) ?? '',
|
|
2214
|
+
...placeOf(trigger),
|
|
2215
|
+
layer: card.source.layer,
|
|
2216
|
+
item: card.item,
|
|
2217
|
+
occurrence,
|
|
2218
|
+
};
|
|
2219
|
+
return card.openedAt;
|
|
2220
|
+
}
|
|
2221
|
+
|
|
2222
|
+
/**
|
|
2223
|
+
* K7: a kept card's "Opened at" link, beside its "Go to …", while carrying
|
|
2224
|
+
* is on; none on a hovered card. Where it was opened is recorded on keeping
|
|
2225
|
+
* either way, for a journey started later.
|
|
2226
|
+
*/
|
|
2227
|
+
function showOpenedAt(card: CardRecord): void {
|
|
2228
|
+
let link = card.el.querySelector<HTMLAnchorElement>('a[data-card-opened-at]');
|
|
2229
|
+
const at = isKept(card.mode) ? ensureOpenedAt(card) : null;
|
|
2230
|
+
if (!at || !carry) {
|
|
2231
|
+
if (link) link.hidden = true;
|
|
2232
|
+
return;
|
|
2233
|
+
}
|
|
2234
|
+
if (!link) {
|
|
2235
|
+
link = document.createElement('a');
|
|
2236
|
+
link.className = 'hover-panel-action';
|
|
2237
|
+
link.dataset.cardOpenedAt = '';
|
|
2238
|
+
const label = document.createElement('span');
|
|
2239
|
+
label.dataset.openedAtLabel = '';
|
|
2240
|
+
const jump = document.createElement('span');
|
|
2241
|
+
jump.setAttribute('aria-hidden', 'true');
|
|
2242
|
+
jump.dataset.jumpArrow = '';
|
|
2243
|
+
link.append(label, jump);
|
|
2244
|
+
const actions = [...card.el.querySelectorAll<HTMLElement>('.hover-panel-action')];
|
|
2245
|
+
const last = actions[actions.length - 1];
|
|
2246
|
+
if (last) {
|
|
2247
|
+
last.after(link);
|
|
2248
|
+
} else {
|
|
2249
|
+
const row = document.createElement('p');
|
|
2250
|
+
row.append(link);
|
|
2251
|
+
card.el.querySelector('.hover-panel-scroll')?.append(row);
|
|
2252
|
+
}
|
|
2253
|
+
}
|
|
2254
|
+
const text = openedAtText(at);
|
|
2255
|
+
const label = link.querySelector<HTMLElement>('[data-opened-at-label]');
|
|
2256
|
+
if (label) label.textContent = text.label;
|
|
2257
|
+
link.title = text.title;
|
|
2258
|
+
link.href = isThisPage(at.page) ? `#${at.anchor}` : `${at.page}#${at.anchor}`;
|
|
2259
|
+
link.hidden = false;
|
|
2260
|
+
wireJumpArrow(link);
|
|
2261
|
+
}
|
|
2262
|
+
|
|
2263
|
+
/** K7: the trigger a card was opened at, on this page. */
|
|
2264
|
+
function triggerAt(at: OpenedAt): HTMLElement | null {
|
|
2265
|
+
const source = sources.find((candidate) => candidate.layer === at.layer);
|
|
2266
|
+
if (!source) return null;
|
|
2267
|
+
return occurrencesOf(source, new Set([at.item]), null).get(at.item)?.[at.occurrence] ?? null;
|
|
2268
|
+
}
|
|
2269
|
+
|
|
2270
|
+
/** K7: light a trigger up a moment, where an "Opened at" link brought the reader. */
|
|
2271
|
+
function flashOrigin(trigger: HTMLElement): void {
|
|
2272
|
+
trigger.classList.remove('is-card-origin');
|
|
2273
|
+
void trigger.offsetWidth; // restart the animation
|
|
2274
|
+
trigger.classList.add('is-card-origin');
|
|
2275
|
+
setTimeout(() => trigger.classList.remove('is-card-origin'), CARD_CONSTANTS.originFlashMs);
|
|
2276
|
+
}
|
|
2277
|
+
|
|
2278
|
+
/** K7: to the trigger itself, its section's anchor in the address bar. */
|
|
2279
|
+
function scrollToOrigin(trigger: HTMLElement, anchor: string): void {
|
|
2280
|
+
animatedScrollIntoView(trigger, { block: 'center' }, () => {
|
|
2281
|
+
if (window.location.hash !== `#${anchor}`) history.pushState(null, '', `#${anchor}`);
|
|
2282
|
+
flashOrigin(trigger);
|
|
2283
|
+
});
|
|
2284
|
+
}
|
|
2285
|
+
|
|
2286
|
+
/** K7: follow a card's "Opened at" link: here, to the trigger; elsewhere, there, then to it. */
|
|
2287
|
+
function goToOpened(card: CardRecord, link: HTMLAnchorElement): void {
|
|
2288
|
+
const at = card.openedAt;
|
|
2289
|
+
if (!at) return;
|
|
2290
|
+
if (!isThisPage(at.page)) {
|
|
2291
|
+
stashOpenedAt(at);
|
|
2292
|
+
window.location.assign(link.href);
|
|
2293
|
+
return;
|
|
2294
|
+
}
|
|
2295
|
+
const trigger = triggerAt(at);
|
|
2296
|
+
if (trigger && trigger.getClientRects().length > 0) {
|
|
2297
|
+
scrollToOrigin(trigger, at.anchor);
|
|
2298
|
+
} else {
|
|
2299
|
+
// Folded away: its section's anchor, as without JavaScript.
|
|
2300
|
+
window.location.hash = at.anchor;
|
|
2301
|
+
}
|
|
2302
|
+
}
|
|
2303
|
+
|
|
2304
|
+
/** K7: the trigger an "Opened at" link on another page brought the reader here for. */
|
|
2305
|
+
let arriving: OpenedAt | null = null;
|
|
2306
|
+
|
|
2307
|
+
/** K7: once its layer is here, scroll to it. */
|
|
2308
|
+
function arrive(): void {
|
|
2309
|
+
const at = arriving;
|
|
2310
|
+
if (!at || !sources.some((source) => source.layer === at.layer)) return;
|
|
2311
|
+
arriving = null;
|
|
2312
|
+
requestAnimationFrame(() => {
|
|
2313
|
+
const trigger = triggerAt(at);
|
|
2314
|
+
if (trigger && trigger.getClientRects().length > 0) scrollToOrigin(trigger, at.anchor);
|
|
2315
|
+
});
|
|
2316
|
+
}
|
|
2317
|
+
|
|
2318
|
+
// ---------------------------------------------------------------------------
|
|
2319
|
+
// K5: carrying the kept cards to every page of the course (`./card-carry.ts`).
|
|
2320
|
+
// ---------------------------------------------------------------------------
|
|
2321
|
+
|
|
2322
|
+
/** K5: the article column's left edge, against which a card's place across the window travels. */
|
|
2323
|
+
const articleLeftEdge = (): number => articleColumn(document.body)?.left ?? 0;
|
|
2324
|
+
|
|
2325
|
+
/**
|
|
2326
|
+
* K5: a card from another page, across the window where it was against the
|
|
2327
|
+
* article, at its edge of the window; `holdNow` keeps it there.
|
|
2328
|
+
*/
|
|
2329
|
+
function placeAway(card: CardRecord): void {
|
|
2330
|
+
const { away } = card;
|
|
2331
|
+
if (!away) return;
|
|
2332
|
+
const between = sidebarsBetween(CARD_CONSTANTS.edge);
|
|
2333
|
+
const { width, height } = card.el.getBoundingClientRect();
|
|
2334
|
+
const x = keepBetween(articleLeftEdge() + away.dx, width, between);
|
|
2335
|
+
const band = holdBand();
|
|
2336
|
+
moveTo(card.el, x, away.edge === 'top' ? band.top : band.bottom - height);
|
|
2337
|
+
}
|
|
2338
|
+
|
|
2339
|
+
/** K5: a card from another page, put at (`left`, `top`): the edge it is nearer, its place across. */
|
|
2340
|
+
function awayAt(card: CardRecord, left: number, top: number): void {
|
|
2341
|
+
const { away } = card;
|
|
2342
|
+
if (!away) return;
|
|
2343
|
+
const { height } = card.el.getBoundingClientRect();
|
|
2344
|
+
away.edge = top + height / 2 < document.documentElement.clientHeight / 2 ? 'top' : 'bottom';
|
|
2345
|
+
away.dx = Math.round(left - articleLeftEdge());
|
|
2346
|
+
}
|
|
2347
|
+
|
|
2348
|
+
const awayItemOf = (layer: string, saved: CarriedCard): string =>
|
|
2349
|
+
sources.find((source) => source.layer === layer)?.awayItem?.(saved.item, saved.page) ??
|
|
2350
|
+
saved.item;
|
|
2351
|
+
|
|
2352
|
+
/** K5: open a card rebuilt from its page, as it was there: in its mode, at its edge. */
|
|
2353
|
+
function mountAway(source: CardSource, saved: CarriedCard, el: HTMLElement): void {
|
|
2354
|
+
const item = awayItemOf(source.layer, saved);
|
|
2355
|
+
const key = `${source.layer}:${item}`;
|
|
2356
|
+
if (!carry || cards.has(key)) return;
|
|
2357
|
+
if (saved.picture) restorePicture(el, saved.picture);
|
|
2358
|
+
const parent = saved.parentKey
|
|
2359
|
+
? awayEntries.find((entry) => entry.page === saved.page && keyOf(entry) === saved.parentKey)
|
|
2360
|
+
: undefined;
|
|
2361
|
+
mount(
|
|
2362
|
+
// Its trigger is on its own page: this one stands in for it, never connected.
|
|
2363
|
+
{ source, trigger: document.createElement('span'), item, key },
|
|
2364
|
+
el,
|
|
2365
|
+
saved.mode === 'hover' ? 'hover' : 'pinned',
|
|
2366
|
+
{
|
|
2367
|
+
order: restoreOrder.get(saved),
|
|
2368
|
+
away: { saved, edge: saved.edge, dx: saved.dx },
|
|
2369
|
+
openedAt: saved.openedAt ?? null,
|
|
2370
|
+
parentKey: parent ? `${parent.layer}:${awayItemOf(parent.layer, parent)}` : null,
|
|
2371
|
+
},
|
|
2372
|
+
);
|
|
2373
|
+
const card = cards.get(key);
|
|
2374
|
+
if (!card || card.el !== el) return;
|
|
2375
|
+
card.width = saved.width ?? null;
|
|
2376
|
+
if (saved.mode === 'side') setMode(card, 'side');
|
|
2377
|
+
else if (saved.mode === 'moved') setMode(card, 'moved');
|
|
2378
|
+
}
|
|
2379
|
+
|
|
2380
|
+
/** K5: carried cards whose page could not be fetched; carried on, never retried on this page. */
|
|
2381
|
+
let awayStranded: CarriedCard[] = [];
|
|
2382
|
+
|
|
2383
|
+
/** K5: rebuild every waiting card from another page whose layer has registered. */
|
|
2384
|
+
function restoreAway(): void {
|
|
2385
|
+
for (const saved of [...awayQueue]) {
|
|
2386
|
+
const source = sources.find((candidate) => candidate.layer === saved.layer);
|
|
2387
|
+
if (!source) continue; // its layer has not registered yet
|
|
2388
|
+
awayQueue = awayQueue.filter((other) => other !== saved);
|
|
2389
|
+
if (!source.build) continue; // the layer's cards do not travel
|
|
2390
|
+
awayBuilding.add(saved);
|
|
2391
|
+
const done = (el: HTMLElement | null) => {
|
|
2392
|
+
awayBuilding.delete(saved);
|
|
2393
|
+
// Its page no longer has it: the journey ends.
|
|
2394
|
+
if (!el) return;
|
|
2395
|
+
mountAway(source, saved, el);
|
|
2396
|
+
arrange();
|
|
2397
|
+
};
|
|
2398
|
+
let built: HTMLElement | null | Promise<HTMLElement | null>;
|
|
2399
|
+
try {
|
|
2400
|
+
built = source.build(saved.item, saved.page);
|
|
2401
|
+
} catch {
|
|
2402
|
+
built = null;
|
|
2403
|
+
}
|
|
2404
|
+
if (built instanceof Promise) {
|
|
2405
|
+
built.then(done, () => {
|
|
2406
|
+
awayBuilding.delete(saved);
|
|
2407
|
+
awayStranded.push(saved);
|
|
2408
|
+
});
|
|
2409
|
+
} else {
|
|
2410
|
+
done(built);
|
|
2411
|
+
}
|
|
2412
|
+
}
|
|
2413
|
+
}
|
|
2414
|
+
|
|
2415
|
+
/** K5: the cards this page carries on, in their order, with the ones still on their way. */
|
|
2416
|
+
function carriedSet(): CarriedSet {
|
|
2417
|
+
const here = window.location.pathname;
|
|
2418
|
+
const article = articleLeftEdge();
|
|
2419
|
+
const entries: { readonly order: number; readonly card: CarriedCard }[] = [];
|
|
2420
|
+
// Each card saved so far, by its key here: its key and page in the set.
|
|
2421
|
+
const stored = new Map<string, { readonly key: string; readonly page: string }>();
|
|
2422
|
+
const lookups = new Map<string, Map<string, HTMLElement[]>>();
|
|
2423
|
+
const kept = ordered().filter((card) => !isTransient(cards, card.key));
|
|
2424
|
+
for (const card of kept) {
|
|
2425
|
+
const parent = card.parentKey ? stored.get(card.parentKey) : undefined;
|
|
2426
|
+
// A card opened from inside another travels only with it.
|
|
2427
|
+
if (card.parentKey !== null && !parent) continue;
|
|
2428
|
+
const original = card.away?.saved;
|
|
2429
|
+
let page: string;
|
|
2430
|
+
let item: string;
|
|
2431
|
+
let occurrence: number;
|
|
2432
|
+
if (original) {
|
|
2433
|
+
({ page, item, occurrence } = original);
|
|
2434
|
+
} else {
|
|
2435
|
+
if (!card.trigger.isConnected) continue;
|
|
2436
|
+
const scope = card.parentKey ? (cards.get(card.parentKey)?.el ?? null) : null;
|
|
2437
|
+
const lookup = `${card.source.layer}|${card.parentKey ?? ''}`;
|
|
2438
|
+
let triggers = lookups.get(lookup);
|
|
2439
|
+
if (!triggers) {
|
|
2440
|
+
const items = new Set(
|
|
2441
|
+
kept
|
|
2442
|
+
.filter(
|
|
2443
|
+
(other) =>
|
|
2444
|
+
other.source === card.source && other.parentKey === card.parentKey && !other.away,
|
|
2445
|
+
)
|
|
2446
|
+
.map((other) => other.item),
|
|
2447
|
+
);
|
|
2448
|
+
triggers = occurrencesOf(card.source, items, scope);
|
|
2449
|
+
lookups.set(lookup, triggers);
|
|
2450
|
+
}
|
|
2451
|
+
occurrence = triggers.get(card.item)?.indexOf(card.trigger) ?? -1;
|
|
2452
|
+
if (occurrence < 0) continue;
|
|
2453
|
+
// A card opened inside another counts its occurrence there, on that card's page.
|
|
2454
|
+
page = parent?.page ?? here;
|
|
2455
|
+
item = card.item;
|
|
2456
|
+
}
|
|
2457
|
+
const rect = card.el.getBoundingClientRect();
|
|
2458
|
+
const dx = card.away?.dx ?? Math.round(rect.left - article);
|
|
2459
|
+
const edge: HoldEdge =
|
|
2460
|
+
card.away?.edge ??
|
|
2461
|
+
card.held?.edge ??
|
|
2462
|
+
(rect.top + rect.height / 2 < document.documentElement.clientHeight / 2 ? 'top' : 'bottom');
|
|
2463
|
+
const picture = capturePicture(card.el) ?? original?.picture;
|
|
2464
|
+
const entry: CarriedCard = {
|
|
2465
|
+
layer: card.source.layer,
|
|
2466
|
+
item,
|
|
2467
|
+
occurrence,
|
|
2468
|
+
parentKey: parent?.key ?? null,
|
|
2469
|
+
mode: card.mode,
|
|
2470
|
+
anchor: original ? original.anchor : card.anchor,
|
|
2471
|
+
moved:
|
|
2472
|
+
card.mode === 'moved'
|
|
2473
|
+
? ((original ? original.moved : card.moved) ?? { x: dx, y: 0 })
|
|
2474
|
+
: null,
|
|
2475
|
+
sideDy: card.mode === 'side' ? (original ? original.sideDy : card.sideDy) : null,
|
|
2476
|
+
width: card.width,
|
|
2477
|
+
page,
|
|
2478
|
+
edge,
|
|
2479
|
+
dx,
|
|
2480
|
+
...(picture ? { picture } : {}),
|
|
2481
|
+
...(card.openedAt ? { openedAt: card.openedAt } : {}),
|
|
2482
|
+
};
|
|
2483
|
+
stored.set(card.key, { key: keyOf(entry), page });
|
|
2484
|
+
entries.push({ order: card.order, card: entry });
|
|
2485
|
+
}
|
|
2486
|
+
// Cards still on their way here travel on as they came.
|
|
2487
|
+
const saved = new Set(entries.map((entry) => carriedKeyOf(entry.card)));
|
|
2488
|
+
for (const pending of [...awayQueue, ...awayBuilding, ...awayStranded]) {
|
|
2489
|
+
if (saved.has(carriedKeyOf(pending))) continue;
|
|
2490
|
+
entries.push({ order: restoreOrder.get(pending) ?? Number.MAX_SAFE_INTEGER, card: pending });
|
|
2491
|
+
}
|
|
2492
|
+
entries.sort((a, b) => a.order - b.order);
|
|
2493
|
+
return {
|
|
2494
|
+
cards: entries.map((entry) => entry.card),
|
|
2495
|
+
pointed: pointedKey ? (stored.get(pointedKey)?.key ?? null) : null,
|
|
2496
|
+
};
|
|
2497
|
+
}
|
|
2498
|
+
|
|
2499
|
+
/** K5: store the carried set now, when it changed: this tab's, and the course's last. */
|
|
2500
|
+
function saveCarried(): void {
|
|
2501
|
+
if (saveTimer !== null) clearTimeout(saveTimer);
|
|
2502
|
+
saveTimer = null;
|
|
2503
|
+
if (!carry || quiet) return;
|
|
2504
|
+
// As it would come back: a reload that changed nothing writes nothing.
|
|
2505
|
+
const fresh = carriedSet();
|
|
2506
|
+
const set = decodeCarried(encodeCarried(fresh)) ?? fresh;
|
|
2507
|
+
if (encodeCarried(set) === lastStored) return;
|
|
2508
|
+
lastStored = writeCarried(base, set);
|
|
2509
|
+
}
|
|
2510
|
+
|
|
2511
|
+
/** K5: after any change to the kept cards, store the set once the change is done. */
|
|
2512
|
+
function scheduleSave(): void {
|
|
2513
|
+
if (!carry || quiet || saveTimer !== null) return;
|
|
2514
|
+
saveTimer = setTimeout(saveCarried, 0);
|
|
2515
|
+
}
|
|
2516
|
+
|
|
2517
|
+
/** K5: put back a stored set: its cards from this page at their triggers, the others from theirs. */
|
|
2518
|
+
function restoreCarriedSet(set: CarriedSet): void {
|
|
2519
|
+
restoreOrder.clear();
|
|
2520
|
+
for (const [index, card] of set.cards.entries()) restoreOrder.set(card, opened + index + 1);
|
|
2521
|
+
opened += set.cards.length;
|
|
2522
|
+
remembered = set.cards.filter((card) => isThisPage(card.page));
|
|
2523
|
+
awayQueue = set.cards.filter((card) => !isThisPage(card.page));
|
|
2524
|
+
awayEntries = [...awayQueue];
|
|
2525
|
+
awayStranded = [];
|
|
2526
|
+
rememberedPointed = set.pointed;
|
|
2527
|
+
lastStored = encodeCarried(set);
|
|
2528
|
+
}
|
|
2529
|
+
|
|
2530
|
+
/**
|
|
2531
|
+
* K5: a page the browser brings back as it was left (Back, Forward) has the
|
|
2532
|
+
* cards it had then; when another page changed the set since, it takes the
|
|
2533
|
+
* set as it is now.
|
|
2534
|
+
*/
|
|
2535
|
+
function resyncCarried(): void {
|
|
2536
|
+
if (!carry) return;
|
|
2537
|
+
const set = readCarried(base);
|
|
2538
|
+
if (encodeCarried(set) === lastStored) return;
|
|
2539
|
+
quiet = true;
|
|
2540
|
+
try {
|
|
2541
|
+
for (const card of ordered().reverse()) if (isOpen(card)) close(card);
|
|
2542
|
+
} finally {
|
|
2543
|
+
quiet = false;
|
|
2544
|
+
}
|
|
2545
|
+
restoreCarriedSet(set);
|
|
2546
|
+
restoreRemembered();
|
|
2547
|
+
restoreAway();
|
|
2548
|
+
}
|
|
2549
|
+
|
|
2550
|
+
/** As the page is left: the carried set, or, with carrying off, this page's card memory. */
|
|
2551
|
+
function leavePage(): void {
|
|
2552
|
+
if (carry) saveCarried();
|
|
2553
|
+
else rememberCards();
|
|
2554
|
+
}
|
|
2555
|
+
|
|
1514
2556
|
function install(): void {
|
|
1515
2557
|
if (layout) return;
|
|
1516
2558
|
layout = watchCardLayout(reanchorAll);
|
|
@@ -1529,22 +2571,48 @@ function install(): void {
|
|
|
1529
2571
|
stepLarge: CARD_CONSTANTS.keyboardStepLarge,
|
|
1530
2572
|
layer,
|
|
1531
2573
|
});
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
2574
|
+
sideControls = createSideControls(layer, {
|
|
2575
|
+
onKeepLeft: (on) => {
|
|
2576
|
+
keepLeft = on;
|
|
2577
|
+
writeKeepLeft(on);
|
|
2578
|
+
arrange({ from: motionFrom() });
|
|
2579
|
+
},
|
|
2580
|
+
onCarry: setCarry,
|
|
2581
|
+
onWidth: chooseSideWidth,
|
|
1536
2582
|
});
|
|
2583
|
+
sideWidth = readSideWidth();
|
|
2584
|
+
sideWidthOffered = readSideWidthOffered();
|
|
2585
|
+
document.addEventListener('pointerdown', onResizeDown, true);
|
|
2586
|
+
document.addEventListener('keydown', onResizeKey);
|
|
2587
|
+
document.addEventListener('dblclick', onResizeReset);
|
|
2588
|
+
// K1: off unless the reader or the course turned it on; K5: never in a
|
|
2589
|
+
// standalone export, which has no other pages.
|
|
2590
|
+
const config = readCardConfig();
|
|
2591
|
+
base = config.base;
|
|
2592
|
+
standalone = document.documentElement.hasAttribute('data-explico-standalone');
|
|
2593
|
+
carry = !standalone && readCarry(config.carry);
|
|
1537
2594
|
// S9: a reader who keeps the article left finds it there on every page.
|
|
1538
2595
|
keepLeft = readKeepLeft();
|
|
1539
2596
|
if (keepLeft) arrange();
|
|
1540
|
-
// The cards the reader
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
2597
|
+
// The cards the reader carries (K5), or, with carrying off, the ones this
|
|
2598
|
+
// page had when the reader last left it; restored as each layer registers.
|
|
2599
|
+
if (carry) {
|
|
2600
|
+
restoreCarriedSet(readCarried(base));
|
|
2601
|
+
} else {
|
|
2602
|
+
const page = readRememberedCards();
|
|
2603
|
+
remembered = [...page.cards];
|
|
2604
|
+
rememberedPointed = page.pointed;
|
|
2605
|
+
}
|
|
2606
|
+
window.addEventListener('pagehide', leavePage);
|
|
1545
2607
|
document.addEventListener('visibilitychange', () => {
|
|
1546
|
-
if (document.visibilityState === 'hidden')
|
|
2608
|
+
if (document.visibilityState === 'hidden') leavePage();
|
|
1547
2609
|
});
|
|
2610
|
+
window.addEventListener('pageshow', (event) => {
|
|
2611
|
+
if (event.persisted) resyncCarried();
|
|
2612
|
+
});
|
|
2613
|
+
// K7: an "Opened at" link from another page brought the reader here.
|
|
2614
|
+
const jump = takeOpenedAt();
|
|
2615
|
+
if (jump && isThisPage(jump.page)) arriving = jump;
|
|
1548
2616
|
document.addEventListener('pointerover', onPointerOver);
|
|
1549
2617
|
document.addEventListener('pointerout', onPointerOut);
|
|
1550
2618
|
document.addEventListener('pointermove', onPointerMove, { passive: true });
|
|
@@ -1558,6 +2626,94 @@ function install(): void {
|
|
|
1558
2626
|
window.addEventListener('resize', interruptTravel, { passive: true });
|
|
1559
2627
|
}
|
|
1560
2628
|
|
|
2629
|
+
// ---------------------------------------------------------------------------
|
|
2630
|
+
// S11: a docked card's right edge widens it.
|
|
2631
|
+
// ---------------------------------------------------------------------------
|
|
2632
|
+
|
|
2633
|
+
/** The docked card a resize handle belongs to. */
|
|
2634
|
+
function resizeTarget(node: EventTarget | null): { card: CardRecord; handle: HTMLElement } | null {
|
|
2635
|
+
const handle = node instanceof Element ? node.closest<HTMLElement>('[data-card-resize]') : null;
|
|
2636
|
+
const card = handle ? cardContaining(handle) : null;
|
|
2637
|
+
return handle && card && card.mode === 'side' ? { card, handle } : null;
|
|
2638
|
+
}
|
|
2639
|
+
|
|
2640
|
+
/** The narrowest a docked card gets, and the widest: to the window's edge, past the rail (S11). */
|
|
2641
|
+
function resizeBounds(card: CardRecord): { min: number; max: number } {
|
|
2642
|
+
const left = card.el.getBoundingClientRect().left;
|
|
2643
|
+
return {
|
|
2644
|
+
min: remToPx(CARD_CONSTANTS.sideWidthMin),
|
|
2645
|
+
max: Math.max(0, document.documentElement.clientWidth - CARD_CONSTANTS.edge - left),
|
|
2646
|
+
};
|
|
2647
|
+
}
|
|
2648
|
+
|
|
2649
|
+
function setCardWidth(card: CardRecord, width: number | null): void {
|
|
2650
|
+
card.width = width === null ? null : Math.round(width);
|
|
2651
|
+
card.el.style.width = card.width === null ? '' : `${card.width}px`;
|
|
2652
|
+
coverRail();
|
|
2653
|
+
}
|
|
2654
|
+
|
|
2655
|
+
/** Resizing one card offers the width menu from then on. */
|
|
2656
|
+
function offerWidthMenu(): void {
|
|
2657
|
+
if (sideWidthOffered) return;
|
|
2658
|
+
sideWidthOffered = true;
|
|
2659
|
+
writeSideWidthOffered();
|
|
2660
|
+
}
|
|
2661
|
+
|
|
2662
|
+
function onResizeDown(event: PointerEvent): void {
|
|
2663
|
+
const target = resizeTarget(event.target);
|
|
2664
|
+
if (!target || event.button !== 0) return;
|
|
2665
|
+
event.preventDefault();
|
|
2666
|
+
event.stopPropagation();
|
|
2667
|
+
const { card, handle } = target;
|
|
2668
|
+
const startX = event.clientX;
|
|
2669
|
+
const startWidth = card.el.getBoundingClientRect().width;
|
|
2670
|
+
const { min, max } = resizeBounds(card);
|
|
2671
|
+
const root = document.documentElement;
|
|
2672
|
+
root.setAttribute('data-card-resizing', '');
|
|
2673
|
+
handle.setPointerCapture(event.pointerId);
|
|
2674
|
+
const move = (moveEvent: PointerEvent) => {
|
|
2675
|
+
setCardWidth(card, Math.min(max, Math.max(min, startWidth + moveEvent.clientX - startX)));
|
|
2676
|
+
};
|
|
2677
|
+
const end = () => {
|
|
2678
|
+
handle.removeEventListener('pointermove', move);
|
|
2679
|
+
root.removeAttribute('data-card-resizing');
|
|
2680
|
+
offerWidthMenu();
|
|
2681
|
+
arrange();
|
|
2682
|
+
};
|
|
2683
|
+
handle.addEventListener('pointermove', move);
|
|
2684
|
+
handle.addEventListener('pointerup', end, { once: true });
|
|
2685
|
+
handle.addEventListener('pointercancel', end, { once: true });
|
|
2686
|
+
}
|
|
2687
|
+
|
|
2688
|
+
function onResizeKey(event: KeyboardEvent): void {
|
|
2689
|
+
const target = resizeTarget(event.target);
|
|
2690
|
+
if (!target) return;
|
|
2691
|
+
const { card } = target;
|
|
2692
|
+
if (event.key === 'Home') {
|
|
2693
|
+
event.preventDefault();
|
|
2694
|
+
setCardWidth(card, null);
|
|
2695
|
+
arrange();
|
|
2696
|
+
return;
|
|
2697
|
+
}
|
|
2698
|
+
const step = event.shiftKey ? CARD_CONSTANTS.keyboardStepLarge : CARD_CONSTANTS.keyboardStep;
|
|
2699
|
+
const delta = event.key === 'ArrowRight' ? step : event.key === 'ArrowLeft' ? -step : 0;
|
|
2700
|
+
if (delta === 0) return;
|
|
2701
|
+
event.preventDefault();
|
|
2702
|
+
const { min, max } = resizeBounds(card);
|
|
2703
|
+
const now = card.el.getBoundingClientRect().width;
|
|
2704
|
+
setCardWidth(card, Math.min(max, Math.max(min, now + delta)));
|
|
2705
|
+
offerWidthMenu();
|
|
2706
|
+
arrange();
|
|
2707
|
+
}
|
|
2708
|
+
|
|
2709
|
+
/** A double click on the edge gives the card back the column's width. */
|
|
2710
|
+
function onResizeReset(event: MouseEvent): void {
|
|
2711
|
+
const target = resizeTarget(event.target);
|
|
2712
|
+
if (!target) return;
|
|
2713
|
+
setCardWidth(target.card, null);
|
|
2714
|
+
arrange();
|
|
2715
|
+
}
|
|
2716
|
+
|
|
1561
2717
|
/**
|
|
1562
2718
|
* Hand a layer's triggers to the manager. A source registered again under
|
|
1563
2719
|
* the same `layer` replaces the old one and closes its cards.
|
|
@@ -1573,6 +2729,34 @@ export function registerCardSource(source: CardSource): void {
|
|
|
1573
2729
|
sources.splice(at, 1, source);
|
|
1574
2730
|
}
|
|
1575
2731
|
restoreRemembered();
|
|
2732
|
+
restoreAway();
|
|
2733
|
+
arrive();
|
|
2734
|
+
}
|
|
2735
|
+
|
|
2736
|
+
/**
|
|
2737
|
+
* Open `layer`'s card for `anchor` already kept, as a click on a trigger
|
|
2738
|
+
* pins one: `'pinned'` next to `anchor`, or `'side'` beside the article (S7:
|
|
2739
|
+
* a note says so when there is no room). `anchor` is a trigger or the item's
|
|
2740
|
+
* own anchor (`CardSource.ownAnchor`), a float's number for its toolbar. A
|
|
2741
|
+
* card already open for the item moves to `anchor` and takes the mode.
|
|
2742
|
+
*/
|
|
2743
|
+
export function keepCardFor(layer: string, anchor: HTMLElement, mode: 'pinned' | 'side'): void {
|
|
2744
|
+
const source = sources.find((candidate) => candidate.layer === layer);
|
|
2745
|
+
const item = source?.itemFor(anchor) ?? null;
|
|
2746
|
+
if (!source || item === null) return;
|
|
2747
|
+
const key = `${layer}:${item}`;
|
|
2748
|
+
let card = cards.get(key);
|
|
2749
|
+
if (card) {
|
|
2750
|
+
if (card.trigger !== anchor) reattach(card, anchor);
|
|
2751
|
+
if (card.mode === 'hover' || card.mode === 'moved') setMode(card, 'pinned');
|
|
2752
|
+
closeOutside(card.key);
|
|
2753
|
+
} else {
|
|
2754
|
+
request({ source, trigger: anchor, item, key }, 'pinned');
|
|
2755
|
+
card = cards.get(key);
|
|
2756
|
+
}
|
|
2757
|
+
if (!card) return;
|
|
2758
|
+
if ((mode === 'side') !== (card.mode === 'side')) toggleSide(card);
|
|
2759
|
+
raise(card);
|
|
1576
2760
|
}
|
|
1577
2761
|
|
|
1578
2762
|
/**
|