@alphacifer/slidev-addon-theme 0.0.2 → 0.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -18,7 +18,7 @@ addons:
18
18
  ## Core
19
19
 
20
20
  - Components: `Date`, `QnA`, `Quote`, `ReflectedTitle`, `Speaker`
21
- - Layouts: `bg-center`, `table-of-contents`
21
+ - Layouts: `arc-toc`, `bg-center`, `table-of-contents`
22
22
 
23
23
  ## Shifting heading
24
24
 
@@ -40,6 +40,9 @@ pnpm --filter @alphacifer/slidev-addon-theme dev
40
40
  The preview deck is defined in `slides.md` and loads this package locally as a
41
41
  Slidev addon.
42
42
 
43
+ The preview deck includes rendered arc TOC examples for every supported item
44
+ count from one through seven.
45
+
43
46
  Build the preview without opening a browser:
44
47
 
45
48
  ```bash
@@ -0,0 +1,522 @@
1
+ <script setup lang="ts">
2
+ import { useSlideContext } from '@slidev/client';
3
+ import type { CSSProperties } from 'vue';
4
+ import {
5
+ computed,
6
+ nextTick,
7
+ onBeforeUnmount,
8
+ onMounted,
9
+ ref,
10
+ watch,
11
+ } from 'vue';
12
+
13
+ import {
14
+ calculateFullTocArcPoints,
15
+ calculateTocArticleOffsetPx,
16
+ calculateTocCenterAlignmentOffsetPx,
17
+ calculateTocConnectorEndX,
18
+ calculateTocItemCount,
19
+ calculateTocListGapRem,
20
+ calculateTocMarkerPositions,
21
+ calculateTocMiddleMarkerOffsetPx,
22
+ calculateUniformTocArticleWidthPx,
23
+ createTocArcPath,
24
+ createTocConnectorPath,
25
+ } from '../../utils/tableOfContents';
26
+ import { useMergedUnoAttrs } from '../../utils/useMergedUnoAttrs';
27
+
28
+ defineOptions({
29
+ inheritAttrs: false,
30
+ });
31
+
32
+ const props = defineProps({
33
+ maxItems: {
34
+ type: [Number, String],
35
+ default: 7,
36
+ },
37
+ maxDepth: {
38
+ type: [Number, String],
39
+ default: 1,
40
+ },
41
+ });
42
+
43
+ const { className, forwardedAttrs } = useMergedUnoAttrs(
44
+ 'alpha-arc-toc slidev-layout default arc-toc-layout',
45
+ );
46
+
47
+ const markerColors = [
48
+ '#ffad21',
49
+ '#24b9aa',
50
+ '#3188e4',
51
+ '#59d875',
52
+ '#f53270',
53
+ '#8b5cf6',
54
+ '#ef4444',
55
+ ] as const;
56
+
57
+ interface ITocConnector {
58
+ readonly color: string;
59
+ readonly path: string;
60
+ }
61
+
62
+ const { $slidev } = useSlideContext();
63
+ const rootElement = ref<HTMLElement>();
64
+ const arcPath = ref('');
65
+ const connectors = ref<readonly ITocConnector[]>([]);
66
+ const tocItemCount = computed(() => {
67
+ return calculateTocItemCount({
68
+ maximumCount: Number(props.maxItems),
69
+ availableCount: $slidev.nav.tocTree.length,
70
+ });
71
+ });
72
+ const markerPositions = computed(() => {
73
+ return calculateTocMarkerPositions(tocItemCount.value);
74
+ });
75
+ const tocContentStyle = computed(() => {
76
+ return {
77
+ '--toc-list-gap': `${calculateTocListGapRem(tocItemCount.value)}rem`,
78
+ left: `calc(40.4% + ${calculateTocMiddleMarkerOffsetPx(tocItemCount.value)}px)`,
79
+ } satisfies CSSProperties;
80
+ });
81
+ const markerStyles = computed(() => {
82
+ return markerPositions.value.map(
83
+ ({ offsetFromMiddlePx, rightShiftPx, topPercent }, index) => {
84
+ return {
85
+ '--marker-color': markerColors[index],
86
+ left: `calc(40.4% - ${offsetFromMiddlePx}px + ${rightShiftPx}px)`,
87
+ top: `${topPercent}%`,
88
+ } satisfies CSSProperties;
89
+ },
90
+ );
91
+ });
92
+
93
+ let connectorAnimationFrame: number | undefined;
94
+ let connectorMutationObserver: MutationObserver | undefined;
95
+ let connectorResizeObserver: ResizeObserver | undefined;
96
+
97
+ function isMiddleItem(index: number, count: number): boolean {
98
+ const middleIndex = (count - 1) / 2;
99
+ return Math.abs(index - middleIndex) < 1;
100
+ }
101
+
102
+ function updateConnectors(): void {
103
+ const root = rootElement.value;
104
+
105
+ if (!root || root.clientWidth === 0 || root.clientHeight === 0) {
106
+ arcPath.value = '';
107
+ connectors.value = [];
108
+ return;
109
+ }
110
+
111
+ const markers = Array.from(root.querySelectorAll<HTMLElement>('.toc-marker'));
112
+ const articles = Array.from(
113
+ root.querySelectorAll<HTMLElement>(
114
+ '.toc-content .slidev-toc-list-level-1 > .slidev-toc-item > a',
115
+ ),
116
+ ).slice(0, tocItemCount.value);
117
+ const count = Math.min(markers.length, articles.length);
118
+
119
+ articles.forEach((article) => {
120
+ article.style.width = 'max-content';
121
+ });
122
+ const uniformArticleWidthPx = calculateUniformTocArticleWidthPx(
123
+ articles.map((article) => {
124
+ return article.scrollWidth;
125
+ }),
126
+ );
127
+ articles.forEach((article) => {
128
+ article.style.width = `${uniformArticleWidthPx}px`;
129
+ });
130
+
131
+ articles.forEach((article, index) => {
132
+ const position = markerPositions.value[index];
133
+ const item = article.closest<HTMLElement>('.slidev-toc-item');
134
+
135
+ if (item && position) {
136
+ item.style.transform = `translateX(${calculateTocArticleOffsetPx(position)}px)`;
137
+ }
138
+ });
139
+
140
+ const rootRect = root.getBoundingClientRect();
141
+ const scaleX = rootRect.width / root.clientWidth;
142
+ const scaleY = rootRect.height / root.clientHeight;
143
+ const tocContent = root.querySelector<HTMLElement>('.toc-content');
144
+
145
+ arcPath.value = createTocArcPath(
146
+ calculateFullTocArcPoints({
147
+ width: root.clientWidth,
148
+ height: root.clientHeight,
149
+ }),
150
+ );
151
+
152
+ if (tocContent && count > 0) {
153
+ tocContent.style.transform = 'none';
154
+
155
+ const firstMiddleIndex = Math.floor((count - 1) / 2);
156
+ const lastMiddleIndex = Math.ceil((count - 1) / 2);
157
+ const middleIndices = [firstMiddleIndex, lastMiddleIndex];
158
+ const markerCenterY =
159
+ middleIndices.reduce((sum, index) => {
160
+ const rect = markers[index]?.getBoundingClientRect();
161
+ return sum + (rect ? rect.top + rect.height / 2 : 0);
162
+ }, 0) / middleIndices.length;
163
+ const articleCenterY =
164
+ middleIndices.reduce((sum, index) => {
165
+ const rect = articles[index]?.getBoundingClientRect();
166
+ return sum + (rect ? rect.top + rect.height / 2 : 0);
167
+ }, 0) / middleIndices.length;
168
+ const offsetPx = calculateTocCenterAlignmentOffsetPx(
169
+ markerCenterY,
170
+ articleCenterY,
171
+ scaleY,
172
+ );
173
+
174
+ tocContent.style.transform = `translateY(${offsetPx}px)`;
175
+ }
176
+
177
+ const nextConnectors: ITocConnector[] = [];
178
+
179
+ for (let index = 0; index < count; index += 1) {
180
+ const markerRect = markers[index]?.getBoundingClientRect();
181
+ const articleRect = articles[index]?.getBoundingClientRect();
182
+
183
+ if (!markerRect || !articleRect) {
184
+ continue;
185
+ }
186
+
187
+ const start = {
188
+ x: (markerRect.left + markerRect.width / 2 - rootRect.left) / scaleX,
189
+ y: (markerRect.top + markerRect.height / 2 - rootRect.top) / scaleY,
190
+ };
191
+ const end = {
192
+ x: calculateTocConnectorEndX({
193
+ articleLeftX: (articleRect.left - rootRect.left) / scaleX,
194
+ }),
195
+ y: (articleRect.top + articleRect.height / 2 - rootRect.top) / scaleY,
196
+ };
197
+
198
+ nextConnectors.push({
199
+ color: markerColors[index % markerColors.length] ?? markerColors[0],
200
+ path: createTocConnectorPath(start, end, isMiddleItem(index, count)),
201
+ });
202
+ }
203
+
204
+ connectors.value = nextConnectors;
205
+ }
206
+
207
+ function scheduleConnectorUpdate(): void {
208
+ if (connectorAnimationFrame !== undefined) {
209
+ cancelAnimationFrame(connectorAnimationFrame);
210
+ }
211
+
212
+ connectorAnimationFrame = requestAnimationFrame(() => {
213
+ connectorAnimationFrame = undefined;
214
+ updateConnectors();
215
+ });
216
+ }
217
+
218
+ onMounted(async () => {
219
+ await nextTick();
220
+ scheduleConnectorUpdate();
221
+
222
+ const root = rootElement.value;
223
+ if (!root) {
224
+ return;
225
+ }
226
+
227
+ connectorResizeObserver = new ResizeObserver(scheduleConnectorUpdate);
228
+ connectorResizeObserver.observe(root);
229
+ const tocContent = root.querySelector('.toc-content');
230
+ if (tocContent) {
231
+ connectorMutationObserver = new MutationObserver(scheduleConnectorUpdate);
232
+ connectorMutationObserver.observe(tocContent, {
233
+ childList: true,
234
+ subtree: true,
235
+ });
236
+ }
237
+ window.addEventListener('resize', scheduleConnectorUpdate);
238
+ });
239
+
240
+ watch(tocItemCount, async () => {
241
+ await nextTick();
242
+ scheduleConnectorUpdate();
243
+ });
244
+
245
+ onBeforeUnmount(() => {
246
+ connectorMutationObserver?.disconnect();
247
+ connectorResizeObserver?.disconnect();
248
+ window.removeEventListener('resize', scheduleConnectorUpdate);
249
+
250
+ if (connectorAnimationFrame !== undefined) {
251
+ cancelAnimationFrame(connectorAnimationFrame);
252
+ }
253
+ });
254
+ </script>
255
+
256
+ <template>
257
+ <div
258
+ ref="rootElement"
259
+ v-bind="forwardedAttrs()"
260
+ :class="className()"
261
+ >
262
+ <header class="toc-heading">
263
+ <slot>
264
+ <h1>Table of Contents</h1>
265
+ </slot>
266
+ </header>
267
+ <svg
268
+ class="toc-curve"
269
+ :viewBox="`0 0 ${rootElement?.clientWidth ?? 0} ${rootElement?.clientHeight ?? 0}`"
270
+ preserveAspectRatio="none"
271
+ aria-hidden="true"
272
+ >
273
+ <path :d="arcPath" />
274
+ </svg>
275
+ <div class="toc-markers" aria-hidden="true">
276
+ <span
277
+ v-for="(markerStyle, index) in markerStyles"
278
+ :key="index"
279
+ class="toc-marker"
280
+ :style="markerStyle"
281
+ ></span>
282
+ </div>
283
+ <svg
284
+ class="toc-connectors"
285
+ :viewBox="`0 0 ${rootElement?.clientWidth ?? 0} ${rootElement?.clientHeight ?? 0}`"
286
+ preserveAspectRatio="none"
287
+ aria-hidden="true"
288
+ >
289
+ <path
290
+ v-for="(connector, index) in connectors"
291
+ :key="index"
292
+ :d="connector.path"
293
+ :stroke="connector.color"
294
+ />
295
+ </svg>
296
+ <Toc
297
+ class="toc-content"
298
+ :data-item-count="tocItemCount"
299
+ :max-depth="props.maxDepth"
300
+ :style="tocContentStyle"
301
+ />
302
+ </div>
303
+ </template>
304
+
305
+ <style scoped>
306
+ .alpha-arc-toc {
307
+ --toc-orange: #ffad21;
308
+ --toc-teal: #24b9aa;
309
+ --toc-blue: #3188e4;
310
+ --toc-green: #59d875;
311
+ --toc-pink: #f53270;
312
+ --toc-purple: #8b5cf6;
313
+ --toc-red: #ef4444;
314
+
315
+ position: relative;
316
+ overflow: hidden;
317
+ padding: 0;
318
+ font-family: ui-sans-serif, system-ui, sans-serif;
319
+ }
320
+
321
+ .toc-heading {
322
+ position: absolute;
323
+ top: 50%;
324
+ left: 10%;
325
+ z-index: 2;
326
+ width: 25%;
327
+ transform: translateY(-50%);
328
+ }
329
+
330
+ .toc-heading :deep(h1) {
331
+ max-width: 8ch;
332
+ margin: 0;
333
+ font-family: inherit;
334
+ font-size: 2.65rem;
335
+ font-weight: 800;
336
+ line-height: 1.04;
337
+ letter-spacing: -0.045em;
338
+ text-transform: capitalize;
339
+ }
340
+
341
+ /* Slide markdown often contains explanatory copy after its heading. This
342
+ layout intentionally presents only the heading and the TOC labels. */
343
+ .toc-heading :deep(:not(h1)) {
344
+ display: none;
345
+ }
346
+
347
+ .toc-curve {
348
+ position: absolute;
349
+ inset: 0;
350
+ width: 100%;
351
+ height: 100%;
352
+ overflow: visible;
353
+ pointer-events: none;
354
+ }
355
+
356
+ .toc-curve path {
357
+ fill: none;
358
+ stroke: #d7d7d5;
359
+ stroke-width: 2;
360
+ vector-effect: non-scaling-stroke;
361
+ }
362
+
363
+ .toc-marker {
364
+ position: absolute;
365
+ z-index: 2;
366
+ width: 1rem;
367
+ height: 1rem;
368
+ border: 0.23rem solid #f8f8f7;
369
+ border-radius: 50%;
370
+ background: var(--marker-color);
371
+ box-shadow: 0 0 0 2px var(--marker-color);
372
+ transform: translate(-50%, -50%);
373
+ }
374
+
375
+ .toc-connectors {
376
+ position: absolute;
377
+ inset: 0;
378
+ z-index: 1;
379
+ width: 100%;
380
+ height: 100%;
381
+ overflow: visible;
382
+ pointer-events: none;
383
+ }
384
+
385
+ .toc-connectors path {
386
+ fill: none;
387
+ stroke-linecap: round;
388
+ stroke-linejoin: round;
389
+ stroke-width: 2.5;
390
+ vector-effect: non-scaling-stroke;
391
+ }
392
+
393
+ .toc-content {
394
+ position: absolute;
395
+ top: 9%;
396
+ left: 41.2%;
397
+ z-index: 2;
398
+ width: 55%;
399
+ }
400
+
401
+ .toc-content :deep(.slidev-toc-list) {
402
+ display: flex;
403
+ flex-direction: column;
404
+ gap: var(--toc-list-gap);
405
+ margin: 0;
406
+ padding: 0;
407
+ list-style: none;
408
+ }
409
+
410
+ .toc-content :deep(.slidev-toc-list-level-1 > .slidev-toc-item:nth-child(n + 8)) {
411
+ display: none;
412
+ }
413
+
414
+ .toc-content[data-item-count='1']
415
+ :deep(.slidev-toc-list-level-1 > .slidev-toc-item:nth-child(n + 2)),
416
+ .toc-content[data-item-count='2']
417
+ :deep(.slidev-toc-list-level-1 > .slidev-toc-item:nth-child(n + 3)),
418
+ .toc-content[data-item-count='3']
419
+ :deep(.slidev-toc-list-level-1 > .slidev-toc-item:nth-child(n + 4)),
420
+ .toc-content[data-item-count='4']
421
+ :deep(.slidev-toc-list-level-1 > .slidev-toc-item:nth-child(n + 5)),
422
+ .toc-content[data-item-count='5']
423
+ :deep(.slidev-toc-list-level-1 > .slidev-toc-item:nth-child(n + 6)),
424
+ .toc-content[data-item-count='6']
425
+ :deep(.slidev-toc-list-level-1 > .slidev-toc-item:nth-child(n + 7)) {
426
+ display: none;
427
+ }
428
+
429
+ .toc-content :deep(.slidev-toc-item) {
430
+ --toc-color: var(--toc-orange);
431
+
432
+ position: relative;
433
+ margin: 0;
434
+ padding: 0;
435
+ }
436
+
437
+ .toc-content :deep(.slidev-toc-item:nth-child(7n + 2)) {
438
+ --toc-color: var(--toc-teal);
439
+ }
440
+
441
+ .toc-content :deep(.slidev-toc-item:nth-child(7n + 3)) {
442
+ --toc-color: var(--toc-blue);
443
+ }
444
+
445
+ .toc-content :deep(.slidev-toc-item:nth-child(7n + 4)) {
446
+ --toc-color: var(--toc-green);
447
+ }
448
+
449
+ .toc-content :deep(.slidev-toc-item:nth-child(7n + 5)) {
450
+ --toc-color: var(--toc-pink);
451
+ }
452
+
453
+ .toc-content :deep(.slidev-toc-item:nth-child(7n + 6)) {
454
+ --toc-color: var(--toc-purple);
455
+ }
456
+
457
+ .toc-content :deep(.slidev-toc-item:nth-child(7n + 7)) {
458
+ --toc-color: var(--toc-red);
459
+ }
460
+
461
+ .toc-content :deep(.slidev-toc-item > a) {
462
+ position: relative;
463
+ display: flex;
464
+ min-width: 260px;
465
+ min-height: 2.3rem;
466
+ align-items: center;
467
+ justify-content: center;
468
+ width: 260px;
469
+ padding: 0.5rem 1.25rem;
470
+ border: 0 !important;
471
+ border-radius: 999px;
472
+ background: var(--toc-color);
473
+ box-shadow: 0.45rem 0.65rem 0.85rem rgb(15 23 42 / 18%);
474
+ color: white;
475
+ font-family: inherit;
476
+ font-size: 0.9rem;
477
+ font-weight: 700;
478
+ line-height: 1.15;
479
+ text-align: center;
480
+ text-decoration: none !important;
481
+ }
482
+
483
+ .toc-content :deep(.slidev-toc-list-level-1 > .slidev-toc-item > a) {
484
+ white-space: nowrap;
485
+ }
486
+
487
+ .toc-content :deep(.slidev-toc-item > a::before) {
488
+ display: none;
489
+ }
490
+
491
+ .toc-content :deep(.slidev-toc-item-active > a),
492
+ .toc-content :deep(.slidev-toc-item > a:hover) {
493
+ filter: brightness(1.06);
494
+ transform: translateX(0.35rem);
495
+ }
496
+
497
+ .toc-content :deep(.slidev-toc-list-level-2) {
498
+ gap: 0.35rem;
499
+ padding: 0.55rem 0 0 2rem;
500
+ }
501
+
502
+ .toc-content :deep(.slidev-toc-list-level-2 .slidev-toc-item::before) {
503
+ display: none;
504
+ }
505
+
506
+ .toc-content :deep(.slidev-toc-list-level-2 .slidev-toc-item > a) {
507
+ min-height: auto;
508
+ justify-content: flex-start;
509
+ padding: 0.15rem 0;
510
+ background: transparent;
511
+ box-shadow: none;
512
+ color: #525252;
513
+ font-size: 0.85rem;
514
+ font-weight: 500;
515
+ text-align: left;
516
+ }
517
+
518
+ .toc-content :deep(.slidev-toc-list-level-2 .slidev-toc-item > a::before) {
519
+ display: none;
520
+ }
521
+
522
+ </style>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alphacifer/slidev-addon-theme",
3
- "version": "0.0.2",
3
+ "version": "0.0.4",
4
4
  "description": "All Slidev addons for Alpha presentations",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -0,0 +1,11 @@
1
+ export function calculateTocCenterAlignmentOffsetPx(
2
+ markerCenterY: number,
3
+ articleCenterY: number,
4
+ scaleY: number,
5
+ ): number {
6
+ if (scaleY <= 0) {
7
+ return 0;
8
+ }
9
+
10
+ return (markerCenterY - articleCenterY) / scaleY;
11
+ }
@@ -0,0 +1,118 @@
1
+ export interface ITocMarkerPosition {
2
+ readonly offsetFromMiddlePx: number;
3
+ readonly rightShiftPx: number;
4
+ readonly topPercent: number;
5
+ }
6
+
7
+ export const MAXIMUM_TOC_ITEM_COUNT = 7;
8
+ const TOC_CANVAS_HEIGHT_PX = 552;
9
+ const TOC_VERTICAL_PADDING_PX = 40;
10
+ const ROOT_FONT_SIZE_PX = 16;
11
+ const TOC_ITEM_HEIGHT_REM = 2.3;
12
+ const TOC_ITEM_HEIGHT_PX = TOC_ITEM_HEIGHT_REM * ROOT_FONT_SIZE_PX;
13
+ const MARKER_TOP_PERCENT =
14
+ ((TOC_VERTICAL_PADDING_PX + TOC_ITEM_HEIGHT_PX / 2) / TOC_CANVAS_HEIGHT_PX) *
15
+ 100;
16
+ const MARKER_BOTTOM_PERCENT = 100 - MARKER_TOP_PERCENT;
17
+ const MARKER_HORIZONTAL_CURVE_PX = 9;
18
+ const MARKER_MIDDLE_RIGHT_SHIFT_PX = 24;
19
+ const MARKER_VERTICAL_STEP_PERCENT =
20
+ (MARKER_BOTTOM_PERCENT - MARKER_TOP_PERCENT) / (MAXIMUM_TOC_ITEM_COUNT - 1);
21
+
22
+ export function calculateTocMarkerPositions(
23
+ count: number,
24
+ ): readonly ITocMarkerPosition[] {
25
+ if (!Number.isInteger(count) || count <= 0) {
26
+ return [];
27
+ }
28
+
29
+ const boundedCount = Math.min(count, MAXIMUM_TOC_ITEM_COUNT);
30
+ const middleIndex = (boundedCount - 1) / 2;
31
+ const centerPercent = (MARKER_TOP_PERCENT + MARKER_BOTTOM_PERCENT) / 2;
32
+
33
+ return Array.from({ length: boundedCount }, (_, index) => {
34
+ const distanceFromMiddle = Math.abs(index - middleIndex);
35
+
36
+ return {
37
+ offsetFromMiddlePx:
38
+ distanceFromMiddle * distanceFromMiddle * MARKER_HORIZONTAL_CURVE_PX,
39
+ rightShiftPx: MARKER_MIDDLE_RIGHT_SHIFT_PX,
40
+ topPercent: Number(
41
+ (
42
+ centerPercent +
43
+ (index - middleIndex) * MARKER_VERTICAL_STEP_PERCENT
44
+ ).toFixed(2),
45
+ ),
46
+ };
47
+ });
48
+ }
49
+
50
+ export interface ICalculateTocItemCountParams {
51
+ readonly maximumCount: number;
52
+ readonly availableCount: number;
53
+ }
54
+
55
+ export function calculateTocItemCount({
56
+ maximumCount,
57
+ availableCount,
58
+ }: ICalculateTocItemCountParams): number {
59
+ const normalizedAvailableCount = Math.max(0, Math.trunc(availableCount));
60
+ const normalizedMaximumCount = Number.isFinite(maximumCount)
61
+ ? Math.max(1, Math.min(MAXIMUM_TOC_ITEM_COUNT, Math.trunc(maximumCount)))
62
+ : MAXIMUM_TOC_ITEM_COUNT;
63
+
64
+ return Math.min(normalizedAvailableCount, normalizedMaximumCount);
65
+ }
66
+
67
+ export function calculateTocMarkerCenterPercent(count: number): number {
68
+ const positions = calculateTocMarkerPositions(count);
69
+ const firstPosition = positions[0];
70
+ const lastPosition = positions.at(-1);
71
+
72
+ if (!firstPosition || !lastPosition) {
73
+ return 50;
74
+ }
75
+
76
+ return (firstPosition.topPercent + lastPosition.topPercent) / 2;
77
+ }
78
+
79
+ export function calculateTocMiddleMarkerOffsetPx(count: number): number {
80
+ const positions = calculateTocMarkerPositions(count);
81
+ const middlePosition = positions[Math.floor((positions.length - 1) / 2)];
82
+
83
+ if (!middlePosition) {
84
+ return 0;
85
+ }
86
+
87
+ return middlePosition.rightShiftPx - middlePosition.offsetFromMiddlePx;
88
+ }
89
+
90
+ export function calculateTocArticleOffsetPx(
91
+ position: ITocMarkerPosition,
92
+ ): number {
93
+ const connectorWidthPx = Math.max(
94
+ 22,
95
+ 48 - position.offsetFromMiddlePx * 0.45,
96
+ );
97
+
98
+ return (
99
+ position.rightShiftPx -
100
+ position.offsetFromMiddlePx +
101
+ connectorWidthPx -
102
+ MARKER_MIDDLE_RIGHT_SHIFT_PX
103
+ );
104
+ }
105
+
106
+ export function calculateTocListGapRem(count: number): number {
107
+ if (!Number.isInteger(count) || count <= 1) {
108
+ return 0;
109
+ }
110
+
111
+ const availableCenterSpanPx =
112
+ TOC_CANVAS_HEIGHT_PX - TOC_VERTICAL_PADDING_PX * 2 - TOC_ITEM_HEIGHT_PX;
113
+ const rowHeightRem =
114
+ availableCenterSpanPx / (MAXIMUM_TOC_ITEM_COUNT - 1) / ROOT_FONT_SIZE_PX;
115
+ const gapRem = rowHeightRem - TOC_ITEM_HEIGHT_REM;
116
+
117
+ return Number(gapRem.toFixed(2));
118
+ }
@@ -0,0 +1,10 @@
1
+ const MINIMUM_TOC_ARTICLE_WIDTH_PX = 260;
2
+
3
+ export function calculateUniformTocArticleWidthPx(
4
+ articleWidths: readonly number[],
5
+ ): number {
6
+ return articleWidths.reduce(
7
+ (longestWidth, width) => Math.max(longestWidth, width),
8
+ MINIMUM_TOC_ARTICLE_WIDTH_PX,
9
+ );
10
+ }
@@ -0,0 +1,80 @@
1
+ import {
2
+ calculateTocMarkerPositions,
3
+ MAXIMUM_TOC_ITEM_COUNT,
4
+ } from './calculateTocMarkerPositions';
5
+ import type { ITocConnectorPoint } from './createTocConnectorPath';
6
+
7
+ const TOC_ARC_LEFT_PERCENT = 40.4;
8
+
9
+ export interface ICalculateFullTocArcPointsParams {
10
+ readonly width: number;
11
+ readonly height: number;
12
+ }
13
+
14
+ function formatCoordinate(value: number): number {
15
+ return Number(value.toFixed(2));
16
+ }
17
+
18
+ export function calculateFullTocArcPoints({
19
+ width,
20
+ height,
21
+ }: ICalculateFullTocArcPointsParams): readonly ITocConnectorPoint[] {
22
+ return calculateTocMarkerPositions(MAXIMUM_TOC_ITEM_COUNT).map(
23
+ ({ offsetFromMiddlePx, rightShiftPx, topPercent }) => {
24
+ return {
25
+ x:
26
+ width * (TOC_ARC_LEFT_PERCENT / 100) -
27
+ offsetFromMiddlePx +
28
+ rightShiftPx,
29
+ y: height * (topPercent / 100),
30
+ };
31
+ },
32
+ );
33
+ }
34
+
35
+ export function createTocArcPath(
36
+ points: readonly ITocConnectorPoint[],
37
+ ): string {
38
+ const firstPoint = points[0];
39
+
40
+ if (!firstPoint || points.length < 2) {
41
+ return '';
42
+ }
43
+
44
+ if (points.length === 2) {
45
+ const lastPoint = points[1];
46
+
47
+ return lastPoint
48
+ ? `M ${formatCoordinate(firstPoint.x)} ${formatCoordinate(firstPoint.y)} L ${formatCoordinate(lastPoint.x)} ${formatCoordinate(lastPoint.y)}`
49
+ : '';
50
+ }
51
+
52
+ const commands = [
53
+ `M ${formatCoordinate(firstPoint.x)} ${formatCoordinate(firstPoint.y)}`,
54
+ ];
55
+
56
+ for (let index = 0; index < points.length - 1; index += 1) {
57
+ const point = points[index];
58
+ const previousPoint = points[index - 1] ?? point;
59
+ const nextPoint = points[index + 1];
60
+ const followingPoint = points[index + 2] ?? nextPoint;
61
+
62
+ if (!point || !previousPoint || !nextPoint || !followingPoint) {
63
+ continue;
64
+ }
65
+
66
+ const firstControlPoint = {
67
+ x: point.x + (nextPoint.x - previousPoint.x) / 6,
68
+ y: point.y + (nextPoint.y - previousPoint.y) / 6,
69
+ };
70
+ const secondControlPoint = {
71
+ x: nextPoint.x - (followingPoint.x - point.x) / 6,
72
+ y: nextPoint.y - (followingPoint.y - point.y) / 6,
73
+ };
74
+ commands.push(
75
+ `C ${formatCoordinate(firstControlPoint.x)} ${formatCoordinate(firstControlPoint.y)} ${formatCoordinate(secondControlPoint.x)} ${formatCoordinate(secondControlPoint.y)} ${formatCoordinate(nextPoint.x)} ${formatCoordinate(nextPoint.y)}`,
76
+ );
77
+ }
78
+
79
+ return commands.join(' ');
80
+ }
@@ -0,0 +1,43 @@
1
+ export interface ITocConnectorPoint {
2
+ readonly x: number;
3
+ readonly y: number;
4
+ }
5
+
6
+ const CONNECTOR_SKEW_WIDTH_PX = 24;
7
+ const CONNECTOR_ARTICLE_OVERLAP_PX = 12;
8
+
9
+ export interface ICalculateTocConnectorEndXParams {
10
+ readonly articleLeftX: number;
11
+ }
12
+
13
+ function formatCoordinate(value: number): number {
14
+ return Number(value.toFixed(2));
15
+ }
16
+
17
+ export function calculateTocConnectorEndX({
18
+ articleLeftX,
19
+ }: ICalculateTocConnectorEndXParams): number {
20
+ return articleLeftX + CONNECTOR_ARTICLE_OVERLAP_PX;
21
+ }
22
+
23
+ export function createTocConnectorPath(
24
+ start: ITocConnectorPoint,
25
+ end: ITocConnectorPoint,
26
+ isMiddle: boolean,
27
+ ): string {
28
+ const startX = formatCoordinate(start.x);
29
+ const startY = formatCoordinate(start.y);
30
+ const endX = formatCoordinate(end.x);
31
+ const endY = formatCoordinate(end.y);
32
+
33
+ if (isMiddle) {
34
+ return `M ${startX} ${startY} L ${endX} ${endY}`;
35
+ }
36
+
37
+ const availableWidth = Math.max(0, end.x - start.x);
38
+ const elbowX = formatCoordinate(
39
+ start.x + Math.min(CONNECTOR_SKEW_WIDTH_PX, availableWidth / 2),
40
+ );
41
+
42
+ return `M ${startX} ${startY} L ${elbowX} ${endY} L ${endX} ${endY}`;
43
+ }
@@ -0,0 +1,5 @@
1
+ export * from './calculateTocCenterAlignmentOffset';
2
+ export * from './calculateTocMarkerPositions';
3
+ export * from './calculateUniformTocArticleWidth';
4
+ export * from './createTocArcPath';
5
+ export * from './createTocConnectorPath';