slidev-theme-kotlin 0.0.9 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1948 @@
1
+ <script lang="ts">
2
+ // Module scope, shared by every annotation instance. Several annotations on
3
+ // one slide all measure the same content on the same animation frame while a
4
+ // Magic Move step or a transition is travelling; scanning the slide once per
5
+ // frame and letting each instance convert the shared viewport rects into its
6
+ // own coordinates removes the most expensive duplicated work.
7
+
8
+ // Elements considered while measuring obstacles, capped: the collectors run on
9
+ // animation frames while the slide moves, and a slide with more elements than
10
+ // this has no room for an automatic label anyway.
11
+ const OBSTACLE_ELEMENT_CAP = 600
12
+ const MEDIA_TAGS = ['IMG', 'VIDEO', 'CANVAS', 'SVG']
13
+
14
+ /**
15
+ * Elements whose whole box matters to label placement, not just their rendered
16
+ * text: a label must not land in the padding of a card or in code-window
17
+ * chrome. `.card` and `.kodee-character` are the Kotlin theme's callouts and
18
+ * mascot.
19
+ *
20
+ * Code surfaces deliberately are not in this list. Their rendered code lines
21
+ * are still text obstacles, but their unused body is a useful, readable place
22
+ * for a short explanation. The title/tab strip remains a whole-box obstacle:
23
+ * a label in that chrome looks like a broken window title rather than a note.
24
+ */
25
+ const BLOCK_OBSTACLES = '.slidev-code-block-title, .slidev-code-group-tabs, .card, table, blockquote, .kodee-character'
26
+
27
+ function isMedia(element: HTMLElement) {
28
+ return MEDIA_TAGS.includes(element.tagName.toUpperCase())
29
+ }
30
+
31
+ /**
32
+ * The slide elements obstacle collection looks at. Excludes annotation
33
+ * strokes and labels, content still hidden behind a later click, and the
34
+ * internals of inline SVGs — parts of an illustration do not count on their
35
+ * own; the whole graphic does.
36
+ */
37
+ function obstacleCandidates(slide: HTMLElement): HTMLElement[] {
38
+ return Array.from(slide.querySelectorAll<HTMLElement>('*'))
39
+ .slice(0, OBSTACLE_ELEMENT_CAP)
40
+ .filter(element => !element.closest('.annotation-ignore, .slidev-vclick-hidden')
41
+ && !element.parentElement?.closest('svg'))
42
+ }
43
+
44
+ /** One slide scan, in viewport coordinates, valid for the current frame. */
45
+ interface ObstacleScan {
46
+ /** Whole boxes of images, videos, canvases and inline SVG roots. */
47
+ media: DOMRect[]
48
+ /** Whole boxes of BLOCK_OBSTACLES elements, padding included. */
49
+ blocks: DOMRect[]
50
+ /** Tight boxes of the rendered text lines. */
51
+ texts: DOMRect[]
52
+ }
53
+
54
+ const scanCache = new Map<HTMLElement, ObstacleScan>()
55
+
56
+ function scanObstacles(slide: HTMLElement): ObstacleScan {
57
+ const cached = scanCache.get(slide)
58
+ if (cached)
59
+ return cached
60
+
61
+ const scan: ObstacleScan = { media: [], blocks: [], texts: [] }
62
+ for (const element of obstacleCandidates(slide)) {
63
+ if (isMedia(element)) {
64
+ const rect = element.getBoundingClientRect()
65
+ if (rect.width >= 6 && rect.height >= 6)
66
+ scan.media.push(rect)
67
+ continue
68
+ }
69
+ if (element.matches(BLOCK_OBSTACLES)) {
70
+ const rect = element.getBoundingClientRect()
71
+ if (rect.width >= 6 && rect.height >= 6)
72
+ scan.blocks.push(rect)
73
+ }
74
+ // Read direct text nodes from every element rather than only using leaf
75
+ // element boxes. A list item commonly contains both plain text and a <b>
76
+ // target; its plain text otherwise disappears from the obstacle map.
77
+ for (const node of Array.from(element.childNodes)) {
78
+ if (node.nodeType !== Node.TEXT_NODE || !node.nodeValue?.trim())
79
+ continue
80
+ const range = document.createRange()
81
+ range.selectNodeContents(node)
82
+ for (const rect of Array.from(range.getClientRects())) {
83
+ if (rect.width >= 4 && rect.height >= 4)
84
+ scan.texts.push(rect)
85
+ }
86
+ }
87
+ }
88
+
89
+ scanCache.set(slide, scan)
90
+ // The scan stays valid until the next frame paints: annotations measuring in
91
+ // the same frame reuse it, and nothing they draw is part of it.
92
+ requestAnimationFrame(() => scanCache.delete(slide))
93
+ return scan
94
+ }
95
+ </script>
96
+
97
+ <script setup lang="ts">
98
+ import type { Options as RoughOptions } from 'roughjs/bin/core'
99
+ import type { ClicksContext } from '@slidev/types'
100
+ import type { ComputedRef, InjectionKey, Ref } from 'vue'
101
+ import { injectLocal } from '@vueuse/core'
102
+ import { useSlideContext } from '@slidev/client'
103
+ // The explicit `.ts` extension is required: Slidev's client ships bare TypeScript
104
+ // sources, and the bundler resolves this subpath literally.
105
+ import { injectionClicksContext } from '@slidev/client/constants.ts'
106
+ import rough from 'roughjs'
107
+ import { computed, nextTick, onBeforeUnmount, onMounted, provide, reactive, ref, shallowRef, toRef, watch, watchEffect } from 'vue'
108
+
109
+ /**
110
+ * Hand-drawn annotation for anything on a slide.
111
+ *
112
+ * It marks an element (or an exact piece of text inside it, which is the only
113
+ * way to reach a token inside a Shiki code block) with a rough-notation style
114
+ * circle, underline, box or strike-through, and optionally connects that mark
115
+ * to a second element on the slide or to a text label.
116
+ *
117
+ * A label is placed out of the slide's normal flow: its position is searched at
118
+ * runtime so it never overlaps other content — nor the labels other annotations
119
+ * wrote on earlier clicks — preferring downwards for marks in the upper half of
120
+ * the slide and upwards for marks in the lower half. Pass
121
+ * `label-x` / `label-y` (percentages of the slide) to place it by hand instead.
122
+ *
123
+ * Annotations nest, and two that share a click draw one after the other: the
124
+ * inner one starts when the one around it has finished, so "point at it, then
125
+ * circle it" is a single reveal rather than two clicks. Pass `:sequential="false"`
126
+ * to draw them at the same time instead.
127
+ *
128
+ * Everything is measured in the slide's own coordinate system, so annotations
129
+ * keep their proportions in the presenter view, in exports and at any window
130
+ * size. Geometry is re-measured while Shiki Magic Move animates, which makes
131
+ * the mark follow the token it belongs to.
132
+ *
133
+ * Mistakes in the markup — text that never appears, a selector or target that
134
+ * matches nothing, a click that cannot be read — are reported on the browser
135
+ * console, once per annotation.
136
+ */
137
+ type MarkType = 'circle' | 'underline' | 'box' | 'strike-through' | 'none'
138
+
139
+ const props = withDefaults(defineProps<{
140
+ /** Shape drawn on the source. Kept as the short form of `sourceType`. */
141
+ type?: MarkType
142
+ /** Shape drawn on the source. Overrides `type` when supplied. */
143
+ sourceType?: MarkType
144
+ /** Element to annotate, searched inside the default slot. */
145
+ selector?: string
146
+ /** Exact text to annotate inside the slot. Works inside Shiki and Magic Move. */
147
+ text?: string
148
+ /** Which occurrence of `text` to annotate, 1-based. */
149
+ occurrence?: number
150
+ /** Mark every visual line of a wrapped or multi-line match. */
151
+ multiline?: boolean
152
+ /** Extra space between the annotated box and the mark, in slide pixels. */
153
+ padding?: number
154
+ /** Connect the mark to this element instead of to a label. */
155
+ target?: string
156
+ /** Point inside the target, as a percentage of its box. */
157
+ targetX?: number
158
+ targetY?: number
159
+ /** Radius of the mark drawn at the target point, as a percentage of its width. */
160
+ targetRadius?: number
161
+ /** Shape drawn at the target point. Defaults to `circle`. */
162
+ targetType?: MarkType
163
+ /** Show the default target circle when `targetType` is omitted. */
164
+ targetMark?: boolean
165
+ /** Text label. Placed automatically unless `labelX` / `labelY` are given. */
166
+ label?: string
167
+ /** Label centre, as a percentage of the slide. Disables automatic placement. */
168
+ labelX?: number
169
+ labelY?: number
170
+ /** Optional maximum label width in slide pixels, before it wraps. */
171
+ labelWidth?: number
172
+ /** Preferred side for automatic placement. `auto` picks by vertical position. */
173
+ placement?: 'auto' | 'up' | 'down' | 'left' | 'right'
174
+ /** Smallest distance between the mark and the label, in slide pixels. */
175
+ gap?: number
176
+ /** Space the label keeps from everything else on the slide. */
177
+ clearance?: number
178
+ /** Extra elements the label must not cover. */
179
+ avoidSelector?: string
180
+ /** Draw the leader line between the mark and the label or target. */
181
+ connect?: boolean
182
+ /** Arrow head at the end of the leader line. */
183
+ arrow?: boolean
184
+ /** Sideways bow of the leader line, as a fraction of its length (capped at 40 slide pixels). */
185
+ curve?: number
186
+ /**
187
+ * Passed straight to rough.js, which draws every stroke, so everything it
188
+ * understands works here: `roughness`, `bowing`, `disableMultiStroke`,
189
+ * `curveFitting`, `seed`, … Colour and stroke width are the exception: they
190
+ * come from `color` and `strokeWidth`, exactly as in Rough Notation, because
191
+ * they are applied in CSS, which is what animates the drawing.
192
+ */
193
+ options?: RoughOptions
194
+ /** How many times each shape is drawn over itself — the sketchy redraw. */
195
+ iterations?: number
196
+ /**
197
+ * Stroke and label colour. The default reads the `--drawn-annotation-color`
198
+ * CSS variable, so a theme recolours every annotation by defining it — at
199
+ * `:root` or any narrower scope. Where nobody defines it, the strokes
200
+ * follow the surrounding text, which is Rough Notation's own default.
201
+ */
202
+ color?: string
203
+ /** Stroke width, in slide pixels. */
204
+ strokeWidth?: number
205
+ /** How long drawing one stage takes, in milliseconds. */
206
+ duration?: number
207
+ at?: number | string
208
+ /**
209
+ * Start drawing only once the annotation this one sits inside has finished,
210
+ * when the two share a click. That is what turns "point at it, then circle
211
+ * it" into a single reveal instead of two clicks, without anyone having to
212
+ * time it by hand. Turn off to draw both at once.
213
+ */
214
+ sequential?: boolean
215
+ /**
216
+ * Click that takes the annotation away again, so it only belongs to the
217
+ * steps it describes. Exclusive, like the end of a `v-click` range: with
218
+ * `:at="1" :until="2"` the annotation is on screen for click 1 only.
219
+ */
220
+ until?: number | string
221
+ /**
222
+ * `at` and `until` in one, for the common case of an annotation that belongs
223
+ * to a single click: `:on="1"` is `:at="1" :until="2"`. The end counts from
224
+ * the label's click when a later `labelAt` pushes it past the mark, so a
225
+ * label is never taken away before it has been read. Give `at` and `until`
226
+ * separately for an annotation that stays on screen for several clicks.
227
+ */
228
+ on?: number | string
229
+ /**
230
+ * Take `at` as a step of its own inside the slot's Magic Move block, instead
231
+ * of sharing a click with one of its code steps. Every step from `at` onwards
232
+ * is pushed one click later, which leaves a click that only draws the
233
+ * annotation, on a code block that stays still. Annotations keep counting in
234
+ * the slide's own clicks, so `at` is always the click in the URL, but inside
235
+ * such a block they have to be given as plain numbers.
236
+ */
237
+ insert?: boolean
238
+ /**
239
+ * Wait for the annotated element to stop moving before drawing. A Magic Move
240
+ * step or a slide transition keeps it travelling for a while, and a mark
241
+ * drawn on top of that reads as an error. Turn off to draw right away.
242
+ */
243
+ wait?: boolean
244
+ /** Click that writes the label. Defaults to `at`. */
245
+ labelAt?: number | string
246
+ /** Follow the annotated element while Magic Move or a transition animates it. */
247
+ track?: boolean
248
+ }>(), {
249
+ type: 'underline',
250
+ sourceType: undefined,
251
+ selector: '[data-annotate]',
252
+ text: undefined,
253
+ occurrence: 1,
254
+ multiline: undefined,
255
+ padding: 4,
256
+ target: undefined,
257
+ targetX: 50,
258
+ targetY: 50,
259
+ targetRadius: 3,
260
+ targetType: undefined,
261
+ targetMark: true,
262
+ label: undefined,
263
+ labelX: undefined,
264
+ labelY: undefined,
265
+ labelWidth: undefined,
266
+ placement: 'auto',
267
+ gap: 28,
268
+ clearance: 16,
269
+ avoidSelector: undefined,
270
+ connect: true,
271
+ arrow: false,
272
+ curve: 0.12,
273
+ options: undefined,
274
+ iterations: 2,
275
+ // A CSS expression, not a colour value: it only ever lands in style
276
+ // bindings. Anything that needs an actual colour cannot read this raw.
277
+ color: 'var(--drawn-annotation-color, currentColor)',
278
+ strokeWidth: 2,
279
+ // Keep the hand-drawn stage aligned with Magic Move and page transitions.
280
+ duration: 500,
281
+ sequential: true,
282
+ at: undefined,
283
+ until: undefined,
284
+ on: undefined,
285
+ insert: false,
286
+ wait: true,
287
+ labelAt: undefined,
288
+ track: true,
289
+ })
290
+
291
+ // Carries the unshifted clicks context past an annotation that inserts a click.
292
+ // A string key, because `<script setup>` runs per component: a Symbol created
293
+ // here would be a different one in every annotation, and never match.
294
+ const realClicksKey = '$$drawn-annotation-clicks' as unknown as InjectionKey<ClicksContext>
295
+ /**
296
+ * What an annotation tells the ones nested inside it: the click it is drawn on,
297
+ * and the moment after that click at which it has finished drawing. A nested
298
+ * annotation on the same click starts there, so the two read as one sequence.
299
+ */
300
+ interface SequenceContext {
301
+ click: ComputedRef<number> | Ref<number>
302
+ end: ComputedRef<number>
303
+ }
304
+ const sequenceKey = '$$drawn-annotation-sequence' as unknown as InjectionKey<SequenceContext>
305
+
306
+ interface Box {
307
+ left: number
308
+ top: number
309
+ right: number
310
+ bottom: number
311
+ width: number
312
+ height: number
313
+ cx: number
314
+ cy: number
315
+ }
316
+
317
+ // `on` is `at` and `until` in one, so everything that resolves a click reads it
318
+ // through here rather than the raw prop. Named apart from the `at` prop so the
319
+ // two can never be confused, in the script or in the template.
320
+ const atClick = computed(() => props.on ?? props.at)
321
+
322
+ // Warnings deduplicate: geometry is re-measured on every animation frame while
323
+ // something on the slide moves, and a mistake in the markup should read as one
324
+ // clear message, not a stream of identical ones.
325
+ const warned = new Set<string>()
326
+ function warn(message: string) {
327
+ if (warned.has(message))
328
+ return
329
+ warned.add(message)
330
+ console.warn(`[DrawnAnnotation] ${message}`)
331
+ }
332
+
333
+ // The types arrive from Markdown, so a typo is a string TypeScript never saw.
334
+ const MARK_TYPES = ['underline', 'circle', 'box', 'strike-through', 'none'] as const
335
+ function resolveMarkType(value: unknown, prop: string, fallback: MarkType): MarkType {
336
+ if ((MARK_TYPES as readonly unknown[]).includes(value))
337
+ return value as MarkType
338
+ warn(`Unknown ${prop} "${value}". Use one of ${MARK_TYPES.join(', ')}; drawing ${fallback} instead.`)
339
+ return fallback
340
+ }
341
+
342
+ // `type` remains the concise, backwards-compatible source shape. The explicit
343
+ // endpoint props make connectors readable when their two marks differ.
344
+ const sourceMarkType = computed(() => resolveMarkType(
345
+ props.sourceType ?? props.type,
346
+ props.sourceType === undefined ? 'type' : 'source-type',
347
+ 'underline',
348
+ ))
349
+ const targetMarkType = computed(() => props.targetType === undefined
350
+ ? (props.targetMark ? 'circle' : 'none')
351
+ : resolveMarkType(props.targetType, 'target-type', 'circle'))
352
+
353
+ const PLACEMENTS = ['auto', 'up', 'down', 'left', 'right'] as const
354
+ const resolvedPlacement = computed(() => {
355
+ if ((PLACEMENTS as readonly string[]).includes(props.placement))
356
+ return props.placement
357
+ warn(`Unknown placement "${props.placement}". Use one of ${PLACEMENTS.join(', ')}; placing the label automatically instead.`)
358
+ return 'auto'
359
+ })
360
+
361
+ // The label and the target each drive several stages, so their presence is
362
+ // decided once, and an empty string counts as absent everywhere.
363
+ const hasLabel = computed(() => !!props.label)
364
+ const hasTarget = computed(() => !!props.target)
365
+
366
+ if (props.on !== undefined && (props.at !== undefined || props.until !== undefined))
367
+ warn('`on` is `at` and `until` in one, so the separate `at` / `until` given here are ignored. Use either `on` alone, or `at` and `until`.')
368
+ if (props.labelAt !== undefined && props.label === undefined)
369
+ warn('`label-at` names the click that writes the label, but no `label` was given.')
370
+
371
+ const container = ref<HTMLElement>()
372
+ const clickMarker = ref<HTMLElement>()
373
+ const labelMarker = ref<HTMLElement>()
374
+ const overlay = ref<SVGSVGElement>()
375
+ const labelEl = ref<HTMLElement>()
376
+
377
+ // The mark and the label can share one click or be spread over two, which is
378
+ // what makes "mark it, then name it" possible from Markdown.
379
+ const resolvedClick = ref(Number.POSITIVE_INFINITY)
380
+ const resolvedLabelClick = ref(Number.POSITIVE_INFINITY)
381
+ const geometryPainted = ref(false)
382
+ const showImmediately = ref(false)
383
+ // The real, unshifted clicks context. An annotation that inserts a click hands
384
+ // its slot a shifted one, so a nested annotation would otherwise count in the
385
+ // shifted clicks and land a click late. Passing the real context down keeps
386
+ // every `at` in the slide's own clicks, which is what the URL shows.
387
+ // `injectLocal`, not `inject`: slot content resolves what the component that
388
+ // renders the slot provided, which plain `inject` walks straight past. Slidev's
389
+ // own clicks context reaches nested components the same way.
390
+ const outerClicksContext = injectLocal(realClicksKey, null)
391
+ const slideContext = useSlideContext()
392
+ const $nav = slideContext.$nav
393
+ const $clicksContext = outerClicksContext ?? slideContext.$clicksContext
394
+ const $clicks = toRef($clicksContext, 'current')
395
+ // Slidev mounts the previous and next slides ahead of time. An annotation on
396
+ // one of those hidden slides must not paint before that slide becomes current,
397
+ // otherwise the view-transition snapshot captures the finished mark.
398
+ const isCurrentSlide = computed(() => slideContext.$page.value === $nav.value.currentSlideNo)
399
+ provide(realClicksKey, $clicksContext)
400
+ // Slidev's click ordering runs in the context the markers are rendered in, and
401
+ // that one is shifted here. So these annotations resolve and register their
402
+ // clicks themselves, from the numbers on the props.
403
+ // Slidev deliberately normalizes `v-click="0"` to click 1. Resolve an
404
+ // annotation on click 0 ourselves so it can be present before the first click.
405
+ const startsOnInitialSlide = computed(() => Number(atClick.value) === 0)
406
+ const manualClicks = computed(() => props.insert || !!outerClicksContext || startsOnInitialSlide.value)
407
+ const painted = computed(() => geometryPainted.value || showImmediately.value)
408
+ // True once the animations triggered by the current click have finished.
409
+ // Drawing is held back until then, so a mark never appears on top of a Magic
410
+ // Move step or a slide transition that is still travelling.
411
+ const settled = ref(!props.wait)
412
+ // An annotation with `until` is only shown while the thing it points at is on
413
+ // screen: past that click every stage hides again, in one step.
414
+ // `on` is the same thing derived from the resolved clicks, so the shorthand
415
+ // keeps working under `insert`, where the number given is not the click it ends
416
+ // up on.
417
+ const rangeEnd = computed(() => {
418
+ if (props.on !== undefined) {
419
+ const last = Math.max(resolvedClick.value, resolvedLabelClick.value)
420
+ return Number.isFinite(last) ? last + 1 : Number.POSITIVE_INFINITY
421
+ }
422
+ if (props.until === undefined)
423
+ return Number.POSITIVE_INFINITY
424
+ const until = Number(props.until)
425
+ if (Number.isFinite(until))
426
+ return until
427
+ warn(`\`until\` must be a plain click number, but got ${JSON.stringify(props.until)}. Ignoring it, so the annotation stays on screen.`)
428
+ return Number.POSITIVE_INFINITY
429
+ })
430
+ const withinRange = computed(() => $clicks.value < rangeEnd.value)
431
+ // Expose the short exit window to the slide so content revealed by this same
432
+ // click can wait until the annotation is gone. The flag is temporary rather
433
+ // than derived directly from rangeEnd: otherwise every later reveal on the
434
+ // slide would inherit the delay too.
435
+ const fadingOut = ref(false)
436
+ /** How long the strokes' exit fade takes, mirrored into CSS as `--exit-fade`. */
437
+ const EXIT_FADE_DURATION = 300
438
+ let exitFadeTimer: ReturnType<typeof setTimeout> | undefined
439
+ watch(withinRange, (inside, wasInside) => {
440
+ clearTimeout(exitFadeTimer)
441
+ fadingOut.value = wasInside && !inside && geometryPainted.value
442
+ if (fadingOut.value)
443
+ exitFadeTimer = setTimeout(() => fadingOut.value = false, EXIT_FADE_DURATION)
444
+ })
445
+ const reached = (click: Ref<number>) => computed(() => isCurrentSlide.value
446
+ && $clicks.value >= click.value
447
+ && withinRange.value
448
+ && painted.value)
449
+
450
+ /**
451
+ * Holds a stage back until everything has stopped moving, and then keeps it
452
+ * open. Only the entrance waits: a stage that is already drawn stays drawn
453
+ * through the next Magic Move step instead of blinking off and on again.
454
+ */
455
+ function entrance(isReached: ComputedRef<boolean>) {
456
+ const opened = ref(false)
457
+ watchEffect(() => {
458
+ if (!isReached.value)
459
+ opened.value = false
460
+ else if (settled.value)
461
+ opened.value = true
462
+ })
463
+ return computed(() => isReached.value && opened.value)
464
+ }
465
+
466
+ const active = entrance(reached(resolvedClick))
467
+ const labelActive = entrance(reached(resolvedLabelClick))
468
+
469
+ // `insert` gives the annotation a click inside the slot's Magic Move block. The
470
+ // block keeps its own registration, but reads a click count that stands still
471
+ // on the annotation's click, so its later steps move one click along with it.
472
+ // Provided after `useSlideContext()` above, which must still see the real one.
473
+ const insertAt = computed(() => {
474
+ const click = Number(atClick.value)
475
+ return Number.isFinite(click) ? click : resolvedClick.value
476
+ })
477
+ if (props.insert) {
478
+ const shifted = new Proxy($clicksContext, {
479
+ get(target, key, receiver) {
480
+ if (key === 'current')
481
+ return target.current - (target.current >= insertAt.value ? 1 : 0)
482
+ // Anything reading the shifted count needs one more real click to reach
483
+ // its last step, so the slide is told about that click here. `delta` goes
484
+ // with it, to keep automatic click ordering after the block in step.
485
+ if (key === 'register') {
486
+ return (element: unknown, info: { delta: number, max: number } | null) => {
487
+ const shift = info && info.max >= insertAt.value ? 1 : 0
488
+ target.register(element as never, info && {
489
+ // An element placed by hand contributes nothing to that ordering.
490
+ delta: info.delta ? info.delta + shift : 0,
491
+ max: info.max + shift,
492
+ })
493
+ }
494
+ }
495
+ const value = Reflect.get(target, key, receiver)
496
+ return typeof value === 'function' ? value.bind(target) : value
497
+ },
498
+ })
499
+ provide(injectionClicksContext, shallowRef(shifted))
500
+ }
501
+
502
+ // The enclosing annotation, if this one is nested inside another. Annotations
503
+ // that share a click then draw one after the other rather than all at once.
504
+ const outerSequence = injectLocal(sequenceKey, null)
505
+ // Where this annotation's reveal starts, counted from its own click. Only an
506
+ // annotation sharing the enclosing one's click waits for it: one that has a
507
+ // click of its own is already a step later, and starts straight away.
508
+ const sequenceStart = computed(() => {
509
+ if (!props.sequential || !outerSequence)
510
+ return 0
511
+ return outerSequence.click.value === resolvedClick.value ? outerSequence.end.value : 0
512
+ })
513
+
514
+ // Stage lengths, as fractions of `duration`. Shared by the delay computation,
515
+ // the sequence bookkeeping and the CSS custom properties in the template, so
516
+ // the timings cannot drift apart.
517
+ const LEADER_FRACTION = 0.5
518
+ const ARROW_FRACTION = 0.2
519
+ const TARGET_FRACTION = 0.5
520
+ const LABEL_HEAD_START = 0.1
521
+ /** How long the label's opacity fade takes, mirrored into CSS as `--label-fade`. */
522
+ const LABEL_FADE_DURATION = 250
523
+
524
+ // Whether a leader line is drawn at all: it needs somewhere to go.
525
+ const connectsLine = computed(() => props.connect && (hasTarget.value || hasLabel.value))
526
+ const hasTargetStage = computed(() => hasTarget.value && targetMarkType.value !== 'none')
527
+
528
+ // A label that waits for a click of its own starts writing straight away. One
529
+ // that shares the mark's click keeps the staggered timing of a single reveal.
530
+ // A connector is deliberately three distinct stages: source mark, leader,
531
+ // target mark. An arrow belongs to the leader stage, and a same-click label
532
+ // waits until every visible stroke has landed.
533
+ const delays = computed(() => {
534
+ const start = sequenceStart.value
535
+ const sourceEnd = start + (sourceMarkType.value === 'none' ? 0 : props.duration)
536
+ const leaderEnd = sourceEnd + (connectsLine.value ? props.duration * LEADER_FRACTION : 0)
537
+ const arrowEnd = leaderEnd + (connectsLine.value && props.arrow ? props.duration * ARROW_FRACTION : 0)
538
+ const targetEnd = arrowEnd + (hasTargetStage.value ? props.duration * TARGET_FRACTION : 0)
539
+ return {
540
+ mark: start,
541
+ leader: sourceEnd,
542
+ arrow: leaderEnd,
543
+ target: arrowEnd,
544
+ label: start + (resolvedLabelClick.value > resolvedClick.value
545
+ ? props.duration * LABEL_HEAD_START
546
+ : targetEnd - start),
547
+ }
548
+ })
549
+
550
+ // When the last stroke that belongs to this click has landed. Read from the
551
+ // props rather than from the measured geometry, so a nested annotation knows
552
+ // where to start before anything has been drawn.
553
+ const sequenceEnd = computed(() => {
554
+ const { duration } = props
555
+ const stages = [sequenceStart.value]
556
+ if (sourceMarkType.value !== 'none')
557
+ stages.push(delays.value.mark + duration)
558
+ if (connectsLine.value) {
559
+ stages.push(delays.value.leader + duration * LEADER_FRACTION)
560
+ if (props.arrow)
561
+ stages.push(delays.value.arrow + duration * ARROW_FRACTION)
562
+ }
563
+ if (hasTargetStage.value)
564
+ stages.push(delays.value.target + duration * TARGET_FRACTION)
565
+ if (hasLabel.value && resolvedLabelClick.value <= resolvedClick.value)
566
+ stages.push(delays.value.label + LABEL_FADE_DURATION)
567
+ return Math.max(...stages)
568
+ })
569
+ provide(sequenceKey, { click: resolvedClick, end: sequenceEnd })
570
+
571
+ const geometry = reactive({
572
+ width: 1440,
573
+ height: 810,
574
+ mark: [] as string[],
575
+ targetMark: [] as string[],
576
+ leader: [] as string[],
577
+ arrow: [] as string[],
578
+ labelLeft: 0,
579
+ labelTop: 0,
580
+ // Undefined leaves the label at its natural, single-line width. A width is
581
+ // assigned only when fitting it on the slide requires wrapping.
582
+ labelWidth: undefined as number | undefined,
583
+ labelPlaced: false,
584
+ ready: false,
585
+ })
586
+
587
+ /**
588
+ * Replaces one of the path lists only when it actually changed. Geometry is
589
+ * re-measured on animation frames, and an unconditional assignment would be a
590
+ * new array every time — re-rendering and re-patching every <path> element
591
+ * even in a frame where nothing moved.
592
+ */
593
+ function assignPaths(key: 'mark' | 'leader' | 'arrow' | 'targetMark', next: string[]) {
594
+ const previous = geometry[key]
595
+ if (previous.length === next.length && previous.every((d, index) => d === next[index]))
596
+ return
597
+ geometry[key] = next
598
+ }
599
+
600
+ // Identity of this annotation's own clicks in the slide's click count, used
601
+ // when it resolves them itself instead of leaving that to the `v-click` marker.
602
+ const ownClicks = Symbol('drawn-annotation-clicks') as unknown as HTMLElement
603
+
604
+ /** Reads a click straight off a prop, for the stages that skip the marker. */
605
+ function manualClick(value: number | string | undefined, prop: string) {
606
+ const click = Number(value)
607
+ if (Number.isFinite(click) && click >= 0)
608
+ return click
609
+ warn(`\`${prop}\` must be a non-negative plain click number on an annotation that resolves clicks itself — one on click 0, with \`insert\`, or nested inside one. Got ${JSON.stringify(value)}; falling back to click 1.`)
610
+ return 1
611
+ }
612
+
613
+ /** The click Slidev resolved onto a marker, once its directive has run. */
614
+ function markerClick(marker: HTMLElement | undefined) {
615
+ const click = Number(marker?.dataset.slidevClicksStart ?? Number.NaN)
616
+ if (Number.isFinite(click))
617
+ return click
618
+ warn('Slidev resolved no click for this annotation, so it is never drawn. It has to be rendered on a slide for `at` to take part in click ordering.')
619
+ return Number.POSITIVE_INFINITY
620
+ }
621
+
622
+ const generator = rough.generator()
623
+
624
+ // A stable seed keeps the wobble identical across re-measurements, so the mark
625
+ // glides with a Magic Move transition instead of re-drawing itself every frame.
626
+ const seed = computed(() => hashSeed(`${sourceMarkType.value}:${targetMarkType.value}:${props.text ?? props.selector}:${props.label ?? props.target ?? ''}`))
627
+
628
+ function hashSeed(value: string) {
629
+ let hash = 2166136261
630
+ for (let i = 0; i < value.length; i++)
631
+ hash = Math.imul(hash ^ value.charCodeAt(i), 16777619)
632
+ // Never zero: rough.js reads a zero seed as "roll a random one", which would
633
+ // re-randomise the wobble on every re-measurement.
634
+ return Math.abs(hash) % 2147483646 + 1
635
+ }
636
+
637
+ // Rough Notation's exact stroke recipe, so a mark looks like Slidev's own
638
+ // `v-mark` out of the box: roughness 1.5, single strokes redrawn `iterations`
639
+ // times, except the circle's ellipse, which is the one true multi-stroke
640
+ // shape. Only the seed is ours — Rough Notation rolls a random one, but a
641
+ // stable seed is what lets the mark glide with a Magic Move transition
642
+ // instead of re-drawing itself every frame. `options` overrides any of it.
643
+ function roughOptions(variant: 'single' | 'double' = 'single'): RoughOptions {
644
+ return {
645
+ roughness: 1.5,
646
+ bowing: 1,
647
+ maxRandomnessOffset: 2,
648
+ disableMultiStroke: variant !== 'double',
649
+ seed: seed.value,
650
+ ...props.options,
651
+ }
652
+ }
653
+
654
+ function toPaths(drawable: ReturnType<typeof generator.line>) {
655
+ return generator.toPaths(drawable).map(path => path.d)
656
+ }
657
+
658
+ function makeBox(left: number, top: number, right: number, bottom: number): Box {
659
+ return {
660
+ left,
661
+ top,
662
+ right,
663
+ bottom,
664
+ width: right - left,
665
+ height: bottom - top,
666
+ cx: (left + right) / 2,
667
+ cy: (top + bottom) / 2,
668
+ }
669
+ }
670
+
671
+ function unionBox(boxes: Box[]) {
672
+ return makeBox(
673
+ Math.min(...boxes.map(box => box.left)),
674
+ Math.min(...boxes.map(box => box.top)),
675
+ Math.max(...boxes.map(box => box.right)),
676
+ Math.max(...boxes.map(box => box.bottom)),
677
+ )
678
+ }
679
+
680
+ /**
681
+ * A Range reports one client rect for each rendered inline fragment. Shiki
682
+ * makes those fragments syntax-token spans, so one continuous selected line
683
+ * such as `package org.jetbrains.example` commonly has several rects. Combine
684
+ * only fragments from this one range that share a visual line; separate lines
685
+ * stay separate for the `multiline` option.
686
+ */
687
+ function mergeVisualLineBoxes(boxes: Box[]): Box[] {
688
+ const lines: Box[] = []
689
+ const sorted = [...boxes].sort((a, b) => a.cy - b.cy || a.left - b.left)
690
+
691
+ for (const box of sorted) {
692
+ const line = lines.find((candidate) => {
693
+ // Text in one line can have slightly different bounds for, for example,
694
+ // superscripted or differently-sized inline text. A quarter of the
695
+ // smaller fragment's height accepts that while keeping adjacent lines
696
+ // distinct, even when their line boxes touch.
697
+ const tolerance = Math.max(1, Math.min(candidate.height, box.height) / 4)
698
+ return Math.abs(candidate.cy - box.cy) <= tolerance
699
+ })
700
+ if (line)
701
+ Object.assign(line, unionBox([line, box]))
702
+ else
703
+ lines.push(box)
704
+ }
705
+
706
+ return lines
707
+ }
708
+
709
+ function padBox(box: Box, padding: number) {
710
+ return makeBox(box.left - padding, box.top - padding, box.right + padding, box.bottom + padding)
711
+ }
712
+
713
+ /**
714
+ * Finds the requested occurrence of `needle` across the slot's text nodes. Text
715
+ * inside a code block is split over one span per token, so the match regularly
716
+ * starts and ends in different nodes. The number of matches is reported either
717
+ * way, so a miss can be explained rather than silently swallowed.
718
+ */
719
+ function textRange(root: HTMLElement, needle: string, occurrence: number): { range?: Range, matches: number } {
720
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT, {
721
+ acceptNode(node) {
722
+ const parent = node.nodeType === Node.TEXT_NODE ? node.parentElement : node as HTMLElement
723
+ // Skip our own overlay, and the snapshot Magic Move is animating out, so
724
+ // a match is never made against text that is on its way off the slide.
725
+ if (!parent || parent.closest('svg, .annotation-ignore, .shiki-magic-move-leave, .shiki-magic-move-leave-to'))
726
+ return NodeFilter.FILTER_REJECT
727
+ if (node.nodeType === Node.ELEMENT_NODE)
728
+ return (node as HTMLElement).tagName === 'BR' ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_SKIP
729
+ return NodeFilter.FILTER_ACCEPT
730
+ },
731
+ })
732
+
733
+ // Magic Move renders line breaks as <br> instead of newline characters, so
734
+ // they are folded back into the searched text to keep line-aware matches
735
+ // working in both kinds of code block.
736
+ const segments: { node?: Text, text: string }[] = []
737
+ let value = ''
738
+ let node: Node | null
739
+ // eslint-disable-next-line no-cond-assign
740
+ while ((node = walker.nextNode())) {
741
+ const segment = node.nodeType === Node.TEXT_NODE
742
+ ? { node: node as Text, text: (node as Text).data }
743
+ : { text: '\n' }
744
+ segments.push(segment)
745
+ value += segment.text
746
+ }
747
+
748
+ const starts: number[] = []
749
+ for (let index = value.indexOf(needle); index >= 0; index = value.indexOf(needle, index + 1))
750
+ starts.push(index)
751
+ const start = starts[Math.max(1, occurrence) - 1]
752
+ if (start === undefined)
753
+ return { matches: starts.length }
754
+
755
+ const end = start + needle.length
756
+ let offset = 0
757
+ let startNode: Text | undefined
758
+ let endNode: Text | undefined
759
+ let startOffset = 0
760
+ let endOffset = 0
761
+ for (const segment of segments) {
762
+ const next = offset + segment.text.length
763
+ if (segment.node) {
764
+ if (!startNode && start >= offset && start < next) {
765
+ startNode = segment.node
766
+ startOffset = start - offset
767
+ }
768
+ if (startNode && end > offset && end <= next) {
769
+ endNode = segment.node
770
+ endOffset = end - offset
771
+ break
772
+ }
773
+ // A match that ends on a line break ends inside a <br>, which cannot hold
774
+ // a range boundary. Trail the last real node the match covered instead.
775
+ if (startNode && end > next) {
776
+ endNode = segment.node
777
+ endOffset = segment.text.length
778
+ }
779
+ }
780
+ offset = next
781
+ }
782
+ if (!startNode || !endNode)
783
+ return { matches: starts.length }
784
+
785
+ const range = document.createRange()
786
+ range.setStart(startNode, startOffset)
787
+ range.setEnd(endNode, endOffset)
788
+ return { range, matches: starts.length }
789
+ }
790
+
791
+ function slideRoot() {
792
+ return overlay.value?.closest<HTMLElement>('.slidev-layout') ?? overlay.value?.parentElement ?? undefined
793
+ }
794
+
795
+ let resizeObserver: ResizeObserver | undefined
796
+ let mutationObserver: MutationObserver | undefined
797
+ let frame = 0
798
+ let paintFrame = 0
799
+ let trackFrame = 0
800
+ let trackUntil = 0
801
+ let mounted = false
802
+ // Fingerprint of the mark's last measured boxes. While the slide animates, a
803
+ // frame in which the mark has not moved recomputes nothing.
804
+ let lastMarkKey = ''
805
+ // The images whose load re-measures geometry, kept so unmount removes exactly
806
+ // the listeners that were added.
807
+ const watchedImages: HTMLImageElement[] = []
808
+ // Each wait gets an identity so a completion from the previous click cannot
809
+ // release the next one. The Web Animations API covers CSS transitions, CSS
810
+ // animations and Magic Move's generated animations with the same `finished`
811
+ // promise, including their real delay and duration.
812
+ let settleRun = 0
813
+
814
+ function relevantAnimations() {
815
+ const root = container.value
816
+ if (!root?.getAnimations)
817
+ return []
818
+
819
+ // A view transition is owned by the document, not by the incoming slide's
820
+ // slot. Its pseudo-elements therefore never appear in the annotation
821
+ // container's subtree, even though they are exactly the animation we must
822
+ // wait for before drawing on the new slide.
823
+ const viewTransitionAnimations = typeof document !== 'undefined'
824
+ ? document.getAnimations().filter((animation) => {
825
+ const pseudo = (animation.effect as KeyframeEffect | null)?.pseudoElement
826
+ return typeof pseudo === 'string' && pseudo.startsWith('::view-transition-')
827
+ })
828
+ : []
829
+ const animations = [...root.getAnimations({ subtree: true }), ...viewTransitionAnimations]
830
+
831
+ return [...new Set(animations)].filter((animation) => {
832
+ if (animation.playState !== 'running' && !animation.pending)
833
+ return false
834
+ const target = (animation.effect as KeyframeEffect | null)?.target
835
+ const pseudo = (animation.effect as KeyframeEffect | null)?.pseudoElement
836
+ // Pseudo-elements belong to the page transition and are intentionally
837
+ // included. For regular elements, never wait for our own strokes or label:
838
+ // they only start after `settled` opens the annotation.
839
+ const belongsToPageTransition = typeof pseudo === 'string' && pseudo.startsWith('::view-transition-')
840
+ const belongsToSlot = target instanceof Element && !target.closest('.annotation-ignore')
841
+ return (belongsToPageTransition || belongsToSlot)
842
+ && Number.isFinite(animation.effect?.getComputedTiming().endTime)
843
+ })
844
+ }
845
+
846
+ function nextFrame() {
847
+ return new Promise<void>(resolve => requestAnimationFrame(() => resolve()))
848
+ }
849
+
850
+ /**
851
+ * Release the entrance when the animations that actually started for this
852
+ * click finish. Re-check after each batch because Magic Move can create the
853
+ * next transition while completing the previous one. A final frame lets the
854
+ * browser commit the finished layout before it is measured for drawing.
855
+ */
856
+ const VIEW_TRANSITION_START_GRACE = 100
857
+
858
+ async function settleAfterAnimations(run: number, grace = 0) {
859
+ // Slidev starts document.startViewTransition() after a short timeout. A
860
+ // preloaded annotation can therefore observe the route change before the
861
+ // browser has created the view-transition pseudo-elements. Give that start
862
+ // callback time to run instead of concluding that there is nothing to wait
863
+ // for.
864
+ if (grace > 0)
865
+ await new Promise<void>(resolve => setTimeout(resolve, grace))
866
+ await nextTick()
867
+ await nextFrame()
868
+ while (mounted && run === settleRun) {
869
+ const animations = relevantAnimations()
870
+ if (!animations.length) {
871
+ await nextFrame()
872
+ if (mounted && run === settleRun && !relevantAnimations().length) {
873
+ // Settle before measuring: the settled pass is the one full
874
+ // recomputation per click that the animation-frame passes lean on.
875
+ settled.value = true
876
+ updateGeometry()
877
+ }
878
+ return
879
+ }
880
+ await Promise.allSettled(animations.map(animation => animation.finished))
881
+ await nextFrame()
882
+ }
883
+ }
884
+
885
+ /**
886
+ * Marks the annotation as moving again and keeps measuring until it stops.
887
+ * Called on every click, because that is what starts a Magic Move step.
888
+ */
889
+ function unsettle(grace = 0) {
890
+ const run = ++settleRun
891
+ // A click can restyle the slide, so measured label sizes go stale here —
892
+ // once per click, which leaves them shared by every frame of the animation.
893
+ labelSizeCache.clear()
894
+ if (!props.wait || !props.track) {
895
+ settled.value = true
896
+ return
897
+ }
898
+ settled.value = false
899
+ track(0)
900
+ void settleAfterAnimations(run, grace)
901
+ }
902
+
903
+ function scheduleUpdate() {
904
+ // Late asynchronous callers — a font that finishes loading, an image that
905
+ // decodes — must not schedule work on an annotation that is already gone.
906
+ if (!mounted)
907
+ return
908
+ cancelAnimationFrame(frame)
909
+ frame = requestAnimationFrame(updateGeometry)
910
+ }
911
+
912
+ /**
913
+ * Keeps re-measuring while an animation moves the annotated content. The
914
+ * animation's `finished` promise controls the entrance; this loop only keeps
915
+ * the geometry attached to the moving element until then.
916
+ */
917
+ // A plain click has no moving geometry. Keep a short tracking window for Vue to
918
+ // apply that click, while mutations from Magic Move retain the longer window.
919
+ const CLICK_TRACK_DURATION = 120
920
+
921
+ function track(duration = 700) {
922
+ if (!props.track)
923
+ return
924
+ // A still-hidden (or already-ended) annotation has nothing on screen that
925
+ // must glide with the animation. Its geometry is refreshed once per click by
926
+ // the settled measurement instead of on every frame — including the strokes
927
+ // other annotations route around while it waits for its click.
928
+ if (!withinRange.value || $clicks.value < Math.min(resolvedClick.value, resolvedLabelClick.value))
929
+ return
930
+ trackUntil = Math.max(trackUntil, performance.now() + duration)
931
+ if (trackFrame)
932
+ return
933
+ const step = () => {
934
+ updateGeometry()
935
+ if (mounted && (performance.now() < trackUntil || !settled.value)) {
936
+ trackFrame = requestAnimationFrame(step)
937
+ }
938
+ else {
939
+ trackFrame = 0
940
+ // One last pass once everything has settled.
941
+ scheduleUpdate()
942
+ }
943
+ }
944
+ trackFrame = requestAnimationFrame(step)
945
+ }
946
+
947
+ // Whether the annotated element or text has been seen at least once. An
948
+ // annotation whose text belongs to one Magic Move step legitimately spends the
949
+ // other steps unmatched, so only a match that never existed is worth a warning.
950
+ let everFound = false
951
+
952
+ // A missing element only counts as an authoring mistake once the annotation's
953
+ // click has been reached and the slide has stopped moving; before that, it may
954
+ // simply not have arrived yet.
955
+ const missingIsAnError = computed(() =>
956
+ isCurrentSlide.value && settled.value && $clicks.value >= resolvedClick.value && withinRange.value)
957
+
958
+ /** Explains why nothing was drawn, once something should have been. */
959
+ function explainMissing(match?: { range?: Range, matches: number }) {
960
+ if (everFound || !missingIsAnError.value)
961
+ return
962
+ if (!props.text)
963
+ warn(`Selector "${props.selector}" matched nothing inside the slot. Put the attribute on the element to annotate, or use \`text\` to mark code.`)
964
+ else if (!match || match.matches === 0)
965
+ warn(`Text "${props.text}" was not found in the slot. It has to match the rendered text exactly — and inside a Magic Move block, be part of the step the annotation is drawn on.`)
966
+ else if (match.matches < Math.max(1, props.occurrence))
967
+ warn(`Text "${props.text}" ${match.matches === 1 ? 'matches only once' : `matches only ${match.matches} times`}, but \`occurrence\` asks for match ${props.occurrence}.`)
968
+ else
969
+ warn(`Text "${props.text}" was found, but has no size on screen right now, so there is nothing to draw around.`)
970
+ }
971
+
972
+ function updateGeometry() {
973
+ const root = container.value
974
+ const svg = overlay.value
975
+ if (!root || !svg)
976
+ return
977
+
978
+ // Freeze the final on-screen geometry during the exit. The click that ends
979
+ // this annotation may simultaneously unhide new v-click content; measuring
980
+ // that content as a fresh obstacle would make the label or leader jump just
981
+ // before it starts fading.
982
+ if (fadingOut.value)
983
+ return
984
+
985
+ const overlayBox = svg.getBoundingClientRect()
986
+ const width = svg.clientWidth
987
+ const height = svg.clientHeight
988
+ if (!overlayBox.width || !overlayBox.height || !width || !height)
989
+ return
990
+
991
+ // DOMRects carry Slidev's presentation scale. Convert them back into the
992
+ // slide's own coordinate system so strokes and offsets scale with the slide.
993
+ const scaleX = width / overlayBox.width
994
+ const scaleY = height / overlayBox.height
995
+ const toLocal = (rect: DOMRect): Box => makeBox(
996
+ (rect.left - overlayBox.left) * scaleX,
997
+ (rect.top - overlayBox.top) * scaleY,
998
+ (rect.right - overlayBox.left) * scaleX,
999
+ (rect.bottom - overlayBox.top) * scaleY,
1000
+ )
1001
+
1002
+ const match = props.text ? textRange(root, props.text, props.occurrence) : undefined
1003
+ const source = props.text ? undefined : root.querySelector<HTMLElement>(props.selector)
1004
+ const rects = match?.range
1005
+ ? Array.from(match.range.getClientRects()).filter(rect => rect.width > 0.5 && rect.height > 0.5)
1006
+ : source
1007
+ ? [source.getBoundingClientRect()]
1008
+ : []
1009
+
1010
+ if (!rects.length) {
1011
+ // The text is not on the slide right now — a Magic Move step that does not
1012
+ // contain it, for instance. While the annotation still belongs to the
1013
+ // current click, leave it out rather than guessing. Once its range has
1014
+ // ended, however, retain the last geometry long enough for the strokes to
1015
+ // fade; clearing it here would remove the SVG paths in a single frame.
1016
+ if (withinRange.value)
1017
+ geometry.ready = false
1018
+ explainMissing(match)
1019
+ return
1020
+ }
1021
+ everFound = true
1022
+
1023
+ // A text Range is frequently fragmented by Shiki token spans. Treat only
1024
+ // fragments of this requested range on the same visual line as one box before
1025
+ // `multiline` decides whether to draw one shape per line or one around the
1026
+ // complete match. Selector annotations keep their element's single box.
1027
+ const rawBoxes = rects.map(toLocal)
1028
+ const boxes = match?.range ? mergeVisualLineBoxes(rawBoxes) : rawBoxes
1029
+ const marked = unionBox(boxes)
1030
+ const multiline = props.multiline ?? (sourceMarkType.value === 'underline' || sourceMarkType.value === 'strike-through')
1031
+ const shapeBoxes = multiline ? boxes : [marked]
1032
+
1033
+ // While the slide is still animating, a mark that has not moved since the
1034
+ // previous frame needs nothing recomputed: the paths, the leader and the
1035
+ // label placement all derive from it. The settled pass at the end of every
1036
+ // click always recomputes, because other content can have moved around a
1037
+ // stationary mark.
1038
+ const markKey = `${width}x${height}|${shapeBoxes.map(box => `${box.left.toFixed(2)},${box.top.toFixed(2)},${box.right.toFixed(2)},${box.bottom.toFixed(2)}`).join(';')}`
1039
+ if (!settled.value && geometry.ready && markKey === lastMarkKey)
1040
+ return
1041
+ lastMarkKey = markKey
1042
+
1043
+ geometry.width = width
1044
+ geometry.height = height
1045
+ assignPaths('mark', shapeBoxes.flatMap(box => markPaths(box, sourceMarkType.value)))
1046
+
1047
+ paintDestination(root, toLocal, marked)
1048
+
1049
+ const firstMeasurement = !geometry.ready
1050
+ geometry.ready = true
1051
+ if (firstMeasurement) {
1052
+ // Hold the active class back until the freshly inserted paths have been
1053
+ // painted once in their hidden state, otherwise opening a URL that already
1054
+ // carries ?clicks=N shows them without an entrance animation.
1055
+ nextTick(() => {
1056
+ if (mounted)
1057
+ paintFrame = requestAnimationFrame(() => geometryPainted.value = true)
1058
+ })
1059
+ }
1060
+ }
1061
+
1062
+ // The shapes mirror Rough Notation's renderer: lines and boxes are redrawn
1063
+ // `iterations` times, lines alternating direction, and a circle is one
1064
+ // multi-stroke ellipse per pair of iterations, sized to the padded box.
1065
+ function markPaths(box: Box, type: MarkType, padding = props.padding): string[] {
1066
+ const iterations = Math.max(1, Math.round(props.iterations))
1067
+ const paths: string[] = []
1068
+ switch (type) {
1069
+ case 'none':
1070
+ return []
1071
+ case 'underline':
1072
+ case 'strike-through': {
1073
+ const y = type === 'underline' ? box.bottom + padding : box.cy
1074
+ for (let i = 0; i < iterations; i++) {
1075
+ paths.push(...toPaths(i % 2
1076
+ ? generator.line(box.right, y, box.left, y, roughOptions())
1077
+ : generator.line(box.left, y, box.right, y, roughOptions())))
1078
+ }
1079
+ return paths
1080
+ }
1081
+ case 'box': {
1082
+ const outer = padBox(box, padding)
1083
+ for (let i = 0; i < iterations; i++)
1084
+ paths.push(...toPaths(generator.rectangle(outer.left, outer.top, outer.width, outer.height, roughOptions())))
1085
+ return paths
1086
+ }
1087
+ case 'circle':
1088
+ default: {
1089
+ const outer = padBox(box, padding)
1090
+ const doubles = Math.floor(iterations / 2)
1091
+ for (let i = 0; i < doubles; i++)
1092
+ paths.push(...toPaths(generator.ellipse(outer.cx, outer.cy, outer.width, outer.height, roughOptions('double'))))
1093
+ for (let i = 0; i < iterations - doubles * 2; i++)
1094
+ paths.push(...toPaths(generator.ellipse(outer.cx, outer.cy, outer.width, outer.height, roughOptions())))
1095
+ return paths
1096
+ }
1097
+ }
1098
+ }
1099
+
1100
+ /** Draws the leader and positions the label. */
1101
+ function paintDestination(
1102
+ root: HTMLElement,
1103
+ toLocal: (rect: DOMRect) => Box,
1104
+ marked: Box,
1105
+ ) {
1106
+ // Computed into locals first: the stable rough.js seed makes an unchanged
1107
+ // layout produce identical path strings, which assignPaths then drops
1108
+ // instead of re-patching the SVG.
1109
+ const paths = { leader: [] as string[], arrow: [] as string[], targetMark: [] as string[] }
1110
+ paintDestinationInto(paths, root, toLocal, marked)
1111
+ assignPaths('leader', paths.leader)
1112
+ assignPaths('arrow', paths.arrow)
1113
+ assignPaths('targetMark', paths.targetMark)
1114
+ }
1115
+
1116
+ function paintDestinationInto(
1117
+ paths: { leader: string[], arrow: string[], targetMark: string[] },
1118
+ root: HTMLElement,
1119
+ toLocal: (rect: DOMRect) => Box,
1120
+ marked: Box,
1121
+ ) {
1122
+ const markBox = padBox(marked, sourceMarkType.value === 'none' ? 4 : props.padding + 6)
1123
+ const targetEl = props.target ? (root.querySelector<HTMLElement>(props.target) ?? document.querySelector<HTMLElement>(props.target)) : undefined
1124
+ if (props.target && !targetEl && missingIsAnError.value)
1125
+ warn(`Target "${props.target}" matched nothing on the slide, so there is no element to connect to.`)
1126
+ const targetBox = targetEl ? toLocal(targetEl.getBoundingClientRect()) : undefined
1127
+
1128
+ let destination: Box | undefined
1129
+ let endPoint: { x: number, y: number } | undefined
1130
+
1131
+ if (targetBox) {
1132
+ const x = targetBox.left + targetBox.width * props.targetX / 100
1133
+ const y = targetBox.top + targetBox.height * props.targetY / 100
1134
+ const radius = Math.max(6, targetBox.width * props.targetRadius / 100)
1135
+ destination = makeBox(x - radius, y - radius, x + radius, y + radius)
1136
+ endPoint = { x, y }
1137
+ // The target is its own stage after the connection. A zero padding keeps
1138
+ // target-radius the exact outer size authors position over screenshots.
1139
+ paths.targetMark = markPaths(destination, targetMarkType.value, 0)
1140
+ }
1141
+
1142
+ if (hasLabel.value) {
1143
+ // Start with the label's natural width, which is a single line. Only give
1144
+ // it a maximum width when that line cannot stay on the slide or cannot be
1145
+ // placed clear of the slide's content.
1146
+ // An annotation nested in a positioned component (for example
1147
+ // InlineCompilerError) has an SVG canvas limited to that component. The
1148
+ // label is allowed to leave that canvas, so constrain it to the slide,
1149
+ // expressed in the SVG's local coordinates, rather than to the canvas.
1150
+ const slide = slideRoot() ?? root
1151
+ const label = fitLabel(root, toLocal, destination ?? markBox, toLocal(slide.getBoundingClientRect()))
1152
+ geometry.labelLeft = label.box.cx
1153
+ geometry.labelTop = label.box.cy
1154
+ geometry.labelWidth = label.width
1155
+ geometry.labelPlaced = true
1156
+ if (!destination) {
1157
+ destination = label.box
1158
+ endPoint = undefined
1159
+ }
1160
+ }
1161
+ else {
1162
+ geometry.labelPlaced = false
1163
+ }
1164
+
1165
+ if (!destination || !props.connect)
1166
+ return
1167
+
1168
+ const route = routeLeader(root, toLocal, markBox, destination, endPoint)
1169
+ if (!route)
1170
+ return
1171
+ const { start, end, c1x, c1y, c2x, c2y } = route
1172
+ paths.leader = toPaths(generator.path(
1173
+ `M ${start.x} ${start.y} C ${c1x} ${c1y}, ${c2x} ${c2y}, ${end.x} ${end.y}`,
1174
+ roughOptions(),
1175
+ ))
1176
+
1177
+ if (props.arrow) {
1178
+ // Aim the head along the tangent of the curve, not along the chord.
1179
+ const angle = Math.atan2(end.y - c2y, end.x - c2x)
1180
+ const size = 16 + props.strokeWidth * 2
1181
+ const wing = (spread: number) => [
1182
+ end.x - Math.cos(angle + spread) * size,
1183
+ end.y - Math.sin(angle + spread) * size,
1184
+ ] as [number, number]
1185
+ paths.arrow = [
1186
+ ...toPaths(generator.linearPath([wing(0.42), [end.x, end.y]], roughOptions())),
1187
+ ...toPaths(generator.linearPath([wing(-0.42), [end.x, end.y]], roughOptions())),
1188
+ ]
1189
+ }
1190
+ }
1191
+
1192
+ interface Point { x: number, y: number }
1193
+
1194
+ function unitVector(x: number, y: number): Point | undefined {
1195
+ const length = Math.hypot(x, y)
1196
+ return length > 0.001 ? { x: x / length, y: y / length } : undefined
1197
+ }
1198
+
1199
+ // The bow of a leader is capped in slide pixels: proportional to length alone,
1200
+ // a long leader turns into a swooping gesture instead of a connection.
1201
+ const LEADER_MAX_BOW = 40
1202
+
1203
+ /**
1204
+ * The leader's bezier between two points. `out` is the direction the line
1205
+ * leaves the mark and `into` the direction it arrives at its destination; the
1206
+ * curve turns smoothly from one to the other, and `side` adds a gentle bow.
1207
+ * The arrival tangent stays aimed at the destination — that is what makes the
1208
+ * line read as pointing at the label rather than curling flat and sweeping
1209
+ * past it.
1210
+ */
1211
+ function leaderCurve(start: Point, end: Point, side: 1 | -1, out?: Point, into?: Point) {
1212
+ const dx = end.x - start.x
1213
+ const dy = end.y - start.y
1214
+ const distance = Math.hypot(dx, dy)
1215
+ const along = unitVector(dx, dy) ?? { x: 1, y: 0 }
1216
+ const bend = Math.min(distance * props.curve, LEADER_MAX_BOW) * side
1217
+ const perpX = -along.y * bend
1218
+ const perpY = along.x * bend
1219
+ const reach = distance * 0.38
1220
+ const outDir = out ?? along
1221
+ const inDir = into ?? along
1222
+ return {
1223
+ start,
1224
+ end,
1225
+ distance,
1226
+ c1x: start.x + outDir.x * reach + perpX,
1227
+ c1y: start.y + outDir.y * reach + perpY,
1228
+ // Most of the bow sits near the exit; the arrival keeps pointing where it
1229
+ // is going, so the line lands on the label instead of flattening out.
1230
+ c2x: end.x - inDir.x * reach + perpX * 0.35,
1231
+ c2y: end.y - inDir.y * reach + perpY * 0.35,
1232
+ }
1233
+ }
1234
+
1235
+ type Curve = ReturnType<typeof leaderCurve>
1236
+
1237
+ /**
1238
+ * How much of the curve runs through the given boxes, and how much ink it
1239
+ * spends overall, both in slide pixels and both sampled from the same points.
1240
+ * The arc length is what prices a detour: a route that dodges text is only
1241
+ * worth taking when it saves more crossing than the extra line it draws.
1242
+ */
1243
+ function measureCurve(curve: Curve, obstacles: Box[]) {
1244
+ const samples = 24
1245
+ let inside = 0
1246
+ let length = 0
1247
+ let px = 0
1248
+ let py = 0
1249
+ for (let i = 0; i <= samples; i++) {
1250
+ const t = i / samples
1251
+ const u = 1 - t
1252
+ const x = u ** 3 * curve.start.x + 3 * u ** 2 * t * curve.c1x + 3 * u * t ** 2 * curve.c2x + t ** 3 * curve.end.x
1253
+ const y = u ** 3 * curve.start.y + 3 * u ** 2 * t * curve.c1y + 3 * u * t ** 2 * curve.c2y + t ** 3 * curve.end.y
1254
+ if (obstacles.some(box => x >= box.left && x <= box.right && y >= box.top && y <= box.bottom))
1255
+ inside++
1256
+ if (i)
1257
+ length += Math.hypot(x - px, y - py)
1258
+ px = x
1259
+ py = y
1260
+ }
1261
+ return { crossing: inside / (samples + 1) * curve.distance, length }
1262
+ }
1263
+
1264
+ /**
1265
+ * Chooses where the leader leaves the mark and which way it bows. The obvious
1266
+ * straight exit towards the destination regularly runs through the rest of the
1267
+ * sentence the mark sits in, which reads as a strike-through of text the
1268
+ * annotation has nothing to do with. So candidate exits along the mark's
1269
+ * border are tried, bowing to either side, against the rendered text of the
1270
+ * slide — with the extra ink of a detour priced in, so a route that dodges
1271
+ * text is only chosen when it saves more crossing than the line it adds. The
1272
+ * straight exit is also only given up when another exit saves a substantial
1273
+ * amount of crossing: brushing past a neighbouring line is part of the
1274
+ * hand-drawn look, and a leader that trades it for a wide swing reads as
1275
+ * starting from the wrong place.
1276
+ */
1277
+ function routeLeader(root: HTMLElement, toLocal: (rect: DOMRect) => Box, markBox: Box, destination: Box, endPoint?: Point): Curve | undefined {
1278
+ const contains = (box: Box, x: number, y: number) => x >= box.left && x <= box.right && y >= box.top && y <= box.bottom
1279
+ // Only text near the line's own neighbourhood matters, and never the words
1280
+ // being marked or the destination itself.
1281
+ const region = padBox(unionBox([markBox, destination]), 120)
1282
+ const obstacles = collectTextObstacles(root, toLocal).filter(box =>
1283
+ overlapArea(box, region) > 0
1284
+ && !contains(markBox, box.cx, box.cy)
1285
+ && !contains(destination, box.cx, box.cy))
1286
+
1287
+ const defaultStart = edgePoint(markBox, destination.cx, destination.cy)
1288
+ const exits: Point[] = [
1289
+ { x: markBox.cx, y: markBox.top },
1290
+ { x: markBox.cx, y: markBox.bottom },
1291
+ { x: markBox.left, y: markBox.cy },
1292
+ { x: markBox.right, y: markBox.cy },
1293
+ { x: markBox.left, y: markBox.top },
1294
+ { x: markBox.right, y: markBox.top },
1295
+ { x: markBox.left, y: markBox.bottom },
1296
+ { x: markBox.right, y: markBox.bottom },
1297
+ ].sort((a, b) =>
1298
+ Math.hypot(a.x - defaultStart.x, a.y - defaultStart.y)
1299
+ - Math.hypot(b.x - defaultStart.x, b.y - defaultStart.y))
1300
+
1301
+ // Where a route from `start` ends, and the direction it arrives in. A target
1302
+ // is met at the given point; a label is met at its nearest edge — a drawn
1303
+ // leader stops at the edge of the words it points at, it does not travel on
1304
+ // towards their centre.
1305
+ const endFor = (start: Point) => {
1306
+ if (endPoint) {
1307
+ const end = backOff(endPoint, start, targetMarkType.value !== 'none' ? destination.width / 2 : 6)
1308
+ return { end, into: unitVector(endPoint.x - end.x, endPoint.y - end.y) }
1309
+ }
1310
+ const nearest = {
1311
+ x: clamp(start.x, destination.left, destination.right),
1312
+ y: clamp(start.y, destination.top, destination.bottom),
1313
+ }
1314
+ const into = unitVector(nearest.x - start.x, nearest.y - start.y)
1315
+ const end = into ? { x: nearest.x - into.x * 6, y: nearest.y - into.y * 6 } : nearest
1316
+ return { end, into }
1317
+ }
1318
+
1319
+ let straight: { curve: Curve, crossing: number, cost: number } | undefined
1320
+ let best: { curve: Curve, crossing: number, cost: number } | undefined
1321
+ // What the shortest reasonable route spends; anything beyond it is detour.
1322
+ const defaultEnd = endFor(defaultStart).end
1323
+ const directLength = Math.hypot(defaultEnd.x - defaultStart.x, defaultEnd.y - defaultStart.y)
1324
+ for (const [index, start] of [defaultStart, ...exits].entries()) {
1325
+ const { end, into: arrival } = endFor(start)
1326
+ const chord = unitVector(end.x - start.x, end.y - start.y)
1327
+ if (!chord)
1328
+ continue
1329
+ // Leave the mark outward through the chosen exit, leaning toward the
1330
+ // destination so the curve can never double back around the mark, and
1331
+ // arrive pointing into the destination.
1332
+ const outward = unitVector(start.x - markBox.cx, start.y - markBox.cy) ?? chord
1333
+ const out = unitVector(outward.x + chord.x, outward.y + chord.y) ?? chord
1334
+ const into = arrival ?? chord
1335
+ for (const side of [1, -1] as const) {
1336
+ const curve = leaderCurve(start, end, side, out, into)
1337
+ if (curve.distance < 4)
1338
+ continue
1339
+ // Crossing text costs its length, a detour costs part of the extra ink
1340
+ // it spends; the small terms only break ties.
1341
+ const { crossing, length } = measureCurve(curve, obstacles)
1342
+ const detour = Math.max(0, length - directLength)
1343
+ const cost = crossing + detour * 0.4 + index * 2 + (side < 0 ? 1 : 0)
1344
+ if (index === 0 && (!straight || cost < straight.cost))
1345
+ straight = { curve, crossing, cost }
1346
+ if (!best || cost < best.cost)
1347
+ best = { curve, crossing, cost }
1348
+ }
1349
+ }
1350
+ if (!straight)
1351
+ return best?.curve
1352
+ if (best && best.crossing < straight.crossing * 0.5 && straight.crossing - best.crossing > 24)
1353
+ return best.curve
1354
+ return straight.curve
1355
+ }
1356
+
1357
+ /**
1358
+ * The slide's text and media, as the tight boxes of the rendered lines rather
1359
+ * than the elements around them. This is what a leader line must not strike
1360
+ * through: crossing a card's padding looks deliberate, crossing its words
1361
+ * reads as marking text the annotation has nothing to do with.
1362
+ */
1363
+ function collectTextObstacles(root: HTMLElement, toLocal: (rect: DOMRect) => Box): Box[] {
1364
+ const slide = slideRoot() ?? root
1365
+ const scan = scanObstacles(slide)
1366
+ const boxes: Box[] = [...scan.media, ...scan.texts].map(toLocal)
1367
+ // The marks and labels of the other annotations on the slide. A leader that
1368
+ // dives through a circled word or a written label reads as marking it. Their
1369
+ // paths are in the DOM from the first measurement — merely hidden until
1370
+ // their click — so routing around them is stable rather than a jump on the
1371
+ // click that reveals them.
1372
+ for (const svg of Array.from(slide.querySelectorAll<SVGSVGElement>('svg.annotation-overlay'))) {
1373
+ if (svg === overlay.value)
1374
+ continue
1375
+ for (const path of Array.from(svg.querySelectorAll<SVGPathElement>('.annotation-mark, .annotation-target'))) {
1376
+ const rect = path.getBoundingClientRect()
1377
+ if (rect.width >= 4 || rect.height >= 4)
1378
+ boxes.push(toLocal(rect))
1379
+ }
1380
+ }
1381
+ for (const label of Array.from(slide.querySelectorAll<HTMLElement>('.annotation-label.is-placed'))) {
1382
+ if (label === labelEl.value)
1383
+ continue
1384
+ const rect = label.getBoundingClientRect()
1385
+ if (rect.width >= 6 && rect.height >= 6)
1386
+ boxes.push(toLocal(rect))
1387
+ }
1388
+ return boxes
1389
+ }
1390
+
1391
+ // Stands in for a label that cannot be measured yet, so placement still has a
1392
+ // plausible box to work with on the very first frame.
1393
+ const FALLBACK_LABEL_HEIGHT = 40
1394
+
1395
+ // The label never sits closer to a slide edge than this, in slide pixels.
1396
+ const SLIDE_MARGIN = 24
1397
+
1398
+ // Sideways nudges tried for each gap while placing the label, nearest first.
1399
+ const LATERAL_OFFSETS = (() => {
1400
+ const offsets = [0]
1401
+ for (let step = 40; step <= 400; step += 40)
1402
+ offsets.push(step, -step)
1403
+ return offsets
1404
+ })()
1405
+
1406
+ // Measured label sizes per width cap. Writing a candidate max-width and
1407
+ // reading the resulting box back forces a synchronous layout, and the answer
1408
+ // only changes with the label's text or font — never from frame to frame while
1409
+ // the slide animates. Sizes are in slide coordinates, so they survive window
1410
+ // resizes too. Cleared on every click and whenever the props or fonts change.
1411
+ const labelSizeCache = new Map<number | 'natural', { width: number, height: number }>()
1412
+
1413
+ function measureLabel(toLocal: (rect: DOMRect) => Box, maxWidth?: number) {
1414
+ const cached = labelSizeCache.get(maxWidth ?? 'natural')
1415
+ if (cached)
1416
+ return cached
1417
+
1418
+ const label = labelEl.value
1419
+ if (!label)
1420
+ return { width: maxWidth ?? 0, height: FALLBACK_LABEL_HEIGHT }
1421
+
1422
+ // Measuring each candidate directly lets placement try a single line first
1423
+ // without committing that temporary width to the rendered label.
1424
+ const previousMaxWidth = label.style.maxWidth
1425
+ if (maxWidth === undefined)
1426
+ label.style.removeProperty('max-width')
1427
+ else
1428
+ label.style.maxWidth = `${maxWidth}px`
1429
+ const rect = label.getBoundingClientRect()
1430
+ label.style.maxWidth = previousMaxWidth
1431
+
1432
+ // Only a real measurement is worth remembering; the fallbacks stand in for a
1433
+ // label that cannot be measured yet.
1434
+ if (!rect.width)
1435
+ return { width: maxWidth ?? 0, height: FALLBACK_LABEL_HEIGHT }
1436
+ const box = toLocal(rect)
1437
+ const size = { width: box.width, height: box.height }
1438
+ labelSizeCache.set(maxWidth ?? 'natural', size)
1439
+ return size
1440
+ }
1441
+
1442
+ /**
1443
+ * Finds the widest label that can stay inside the slide and clear its
1444
+ * obstacles. The unbounded measurement is always tried first, so ordinary
1445
+ * labels remain a single, readable line instead of inheriting an arbitrary
1446
+ * short line length.
1447
+ */
1448
+ function fitLabel(
1449
+ root: HTMLElement,
1450
+ toLocal: (rect: DOMRect) => Box,
1451
+ anchor: Box,
1452
+ bounds: Box,
1453
+ ): { box: Box, width: number | undefined } {
1454
+ // Collected once for every width candidate tried below: the obstacles do not
1455
+ // depend on how the label wraps. Inflated so the label breathes — a label
1456
+ // that ends up flush against the bottom of a code block reads as part of it.
1457
+ // An explicitly positioned label ignores obstacles entirely.
1458
+ const obstacles = props.labelX !== undefined || props.labelY !== undefined
1459
+ ? []
1460
+ : collectObstacles(root, toLocal)
1461
+ .map(box => padBox(box, props.clearance))
1462
+ .concat(padBox(anchor, 8))
1463
+ const natural = measureLabel(toLocal)
1464
+ const unwrapped = placeLabel(anchor, natural, bounds, obstacles)
1465
+ const fitsSlide = natural.width <= bounds.width - SLIDE_MARGIN * 2 && natural.height <= bounds.height - SLIDE_MARGIN * 2
1466
+ const respectsExplicitMaximum = props.labelWidth === undefined || natural.width <= props.labelWidth
1467
+
1468
+ if (fitsSlide && unwrapped.overlap === 0 && respectsExplicitMaximum)
1469
+ return { box: unwrapped.box, width: undefined }
1470
+
1471
+ // An explicit `label-width` remains a useful author override. Without one,
1472
+ // the slide edges are the only width limit.
1473
+ const maximum = Math.min(natural.width, props.labelWidth ?? natural.width, bounds.width - SLIDE_MARGIN * 2)
1474
+ const minimum = Math.min(maximum, 160)
1475
+ let best: { box: Box, width: number, overlap: number } | undefined
1476
+
1477
+ // Work from wide to narrow: the first collision-free result has the fewest
1478
+ // line breaks. A modest step keeps the search cheap while the slide tracks
1479
+ // Magic Move animations.
1480
+ for (let cap = maximum; cap >= minimum; cap -= 48) {
1481
+ const size = measureLabel(toLocal, cap)
1482
+ const placed = placeLabel(anchor, size, bounds, obstacles)
1483
+ if (placed.overlap === 0)
1484
+ return { box: placed.box, width: cap }
1485
+ if (!best || placed.overlap < best.overlap)
1486
+ best = { box: placed.box, width: cap, overlap: placed.overlap }
1487
+ }
1488
+
1489
+ // Include the lower bound when the step above did not land on it.
1490
+ if (!best || best.width !== minimum) {
1491
+ const size = measureLabel(toLocal, minimum)
1492
+ const placed = placeLabel(anchor, size, bounds, obstacles)
1493
+ if (placed.overlap === 0)
1494
+ return { box: placed.box, width: minimum }
1495
+ if (!best || placed.overlap < best.overlap)
1496
+ best = { box: placed.box, width: minimum, overlap: placed.overlap }
1497
+ }
1498
+
1499
+ return best ? { box: best.box, width: best.width } : { box: unwrapped.box, width: undefined }
1500
+ }
1501
+
1502
+ function placeLabel(
1503
+ anchor: Box,
1504
+ size: { width: number, height: number },
1505
+ bounds: Box,
1506
+ obstacles: Box[],
1507
+ ): { box: Box, overlap: number } {
1508
+ const halfW = size.width / 2
1509
+ const halfH = size.height / 2
1510
+ const centred = (cx: number, cy: number) => makeBox(cx - halfW, cy - halfH, cx + halfW, cy + halfH)
1511
+
1512
+ if (props.labelX !== undefined || props.labelY !== undefined) {
1513
+ return {
1514
+ box: centred(
1515
+ props.labelX !== undefined ? bounds.left + bounds.width * props.labelX / 100 : anchor.cx,
1516
+ props.labelY !== undefined ? bounds.top + bounds.height * props.labelY / 100 : anchor.cy,
1517
+ ),
1518
+ overlap: 0,
1519
+ }
1520
+ }
1521
+
1522
+ const placement = resolvedPlacement.value
1523
+ const preferred = placement === 'auto'
1524
+ ? (anchor.cy < bounds.cy ? 'down' : 'up')
1525
+ : placement
1526
+ const directions = placement === 'auto'
1527
+ ? [preferred, preferred === 'down' ? 'up' : 'down', 'right', 'left'] as const
1528
+ : [preferred] as const
1529
+
1530
+ let best: { box: Box, overlap: number, score: number } | undefined
1531
+
1532
+ for (const [order, direction] of directions.entries()) {
1533
+ for (let gap = props.gap; gap <= props.gap + 260; gap += 20) {
1534
+ for (const lateral of LATERAL_OFFSETS) {
1535
+ const vertical = direction === 'up' || direction === 'down'
1536
+ const cx = vertical
1537
+ ? anchor.cx + lateral
1538
+ : direction === 'right' ? anchor.right + gap + halfW : anchor.left - gap - halfW
1539
+ const cy = vertical
1540
+ ? (direction === 'down' ? anchor.bottom + gap + halfH : anchor.top - gap - halfH)
1541
+ : anchor.cy + lateral
1542
+
1543
+ const box = centred(
1544
+ clamp(cx, bounds.left + SLIDE_MARGIN + halfW, bounds.right - SLIDE_MARGIN - halfW),
1545
+ clamp(cy, bounds.top + SLIDE_MARGIN + halfH, bounds.bottom - SLIDE_MARGIN - halfH),
1546
+ )
1547
+ const overlap = obstacles.reduce((total, obstacle) => total + overlapArea(box, obstacle), 0)
1548
+ const score = overlap * 6 + gap + Math.abs(lateral) * 0.6 + order * 400
1549
+ // A clear spot always beats an overlapping one, and among clear spots
1550
+ // the best-scoring one wins: taking the first found would let a small
1551
+ // gap with a long drift along the mark beat a slightly larger gap that
1552
+ // stays level with it, which reads as belonging to something else.
1553
+ const better = !best
1554
+ || (overlap === 0 && best.overlap > 0)
1555
+ || ((overlap === 0) === (best.overlap === 0) && score < best.score)
1556
+ if (better)
1557
+ best = { box, overlap, score }
1558
+ }
1559
+ }
1560
+ }
1561
+
1562
+ // The loops above always run at least once, so `best` is always set; the
1563
+ // fallback only satisfies the types.
1564
+ return best ?? { box: centred(anchor.cx, anchor.cy), overlap: Number.POSITIVE_INFINITY }
1565
+ }
1566
+
1567
+ /**
1568
+ * Everything the label has to stay clear of: every rendered text fragment and
1569
+ * image on the slide, plus whole boxes for content whose padding also matters
1570
+ * and anything the slide opted in through `avoid-selector`. Cards and similar
1571
+ * blocks remain solid obstacles, but a code block's body is intentionally not:
1572
+ * its blank area is a good label surface. Its title/tab strip is a block
1573
+ * obstacle, so annotations can never be placed in window chrome.
1574
+ */
1575
+ function collectObstacles(root: HTMLElement, toLocal: (rect: DOMRect) => Box): Box[] {
1576
+ const slide = slideRoot() ?? root
1577
+ const scan = scanObstacles(slide)
1578
+ const boxes: Box[] = [...scan.blocks, ...scan.media, ...scan.texts].map(toLocal)
1579
+ if (props.avoidSelector) {
1580
+ for (const element of Array.from(slide.querySelectorAll<HTMLElement>(props.avoidSelector)))
1581
+ boxes.push(toLocal(element.getBoundingClientRect()))
1582
+ }
1583
+ // The labels other annotations have already written. Only the ones from an
1584
+ // earlier click count — a later label avoids an earlier one, never the other
1585
+ // way round — so two labels can never chase each other across the slide.
1586
+ const ourLabel = labelEl.value
1587
+ for (const other of Array.from(slide.querySelectorAll<HTMLElement>('.annotation-label.is-placed'))) {
1588
+ if (other === ourLabel)
1589
+ continue
1590
+ const click = Number(other.dataset.click ?? Number.NaN)
1591
+ if (!Number.isFinite(click))
1592
+ continue
1593
+ const ours = resolvedLabelClick.value
1594
+ const earlier = click < ours
1595
+ || (click === ours && !!ourLabel && !!(other.compareDocumentPosition(ourLabel) & Node.DOCUMENT_POSITION_FOLLOWING))
1596
+ if (!earlier)
1597
+ continue
1598
+ const rect = other.getBoundingClientRect()
1599
+ if (rect.width >= 6 && rect.height >= 6)
1600
+ boxes.push(toLocal(rect))
1601
+ }
1602
+ return boxes
1603
+ }
1604
+
1605
+ function overlapArea(a: Box, b: Box) {
1606
+ const w = Math.min(a.right, b.right) - Math.max(a.left, b.left)
1607
+ const h = Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top)
1608
+ return w > 0 && h > 0 ? w * h : 0
1609
+ }
1610
+
1611
+ function clamp(value: number, min: number, max: number) {
1612
+ return Math.min(Math.max(value, min), max)
1613
+ }
1614
+
1615
+ /** Where a ray leaving the centre of `box` towards a point crosses its border. */
1616
+ function edgePoint(box: Box, towardX: number, towardY: number, extra = 0) {
1617
+ const dx = towardX - box.cx
1618
+ const dy = towardY - box.cy
1619
+ if (!dx && !dy)
1620
+ return { x: box.cx, y: box.cy }
1621
+ const scale = Math.min(
1622
+ dx ? (box.width / 2) / Math.abs(dx) : Number.POSITIVE_INFINITY,
1623
+ dy ? (box.height / 2) / Math.abs(dy) : Number.POSITIVE_INFINITY,
1624
+ )
1625
+ const length = Math.hypot(dx, dy)
1626
+ const reach = scale + extra / length
1627
+ return { x: box.cx + dx * reach, y: box.cy + dy * reach }
1628
+ }
1629
+
1630
+ function backOff(point: { x: number, y: number }, from: { x: number, y: number }, distance: number) {
1631
+ const dx = point.x - from.x
1632
+ const dy = point.y - from.y
1633
+ const length = Math.hypot(dx, dy) || 1
1634
+ return { x: point.x - dx / length * distance, y: point.y - dy / length * distance }
1635
+ }
1636
+
1637
+ onMounted(async () => {
1638
+ mounted = true
1639
+ // Registered synchronously, while the clicks context still accepts it.
1640
+ if (manualClicks.value) {
1641
+ resolvedClick.value = manualClick(atClick.value, props.on !== undefined ? 'on' : 'at')
1642
+ resolvedLabelClick.value = props.labelAt === undefined
1643
+ ? resolvedClick.value
1644
+ : manualClick(props.labelAt, 'label-at')
1645
+ $clicksContext.register(ownClicks, { delta: 0, max: Math.max(resolvedClick.value, resolvedLabelClick.value) })
1646
+ }
1647
+ await nextTick()
1648
+ if (!manualClicks.value) {
1649
+ // Slidev resolves automatic click ordering in the directive and stores the
1650
+ // result on the marker. Reading it here keeps the first painted state
1651
+ // unambiguously hidden, unlike styling a v-click element directly.
1652
+ resolvedClick.value = markerClick(clickMarker.value)
1653
+ // A label without a click of its own follows the mark, rather than taking
1654
+ // the next click in the slide's automatic ordering.
1655
+ resolvedLabelClick.value = props.labelAt === undefined
1656
+ ? resolvedClick.value
1657
+ : markerClick(labelMarker.value)
1658
+ }
1659
+ // Navigating backwards remounts the slide. If this click was already reached,
1660
+ // render the final state instead of replaying the entrance animation.
1661
+ showImmediately.value = isCurrentSlide.value
1662
+ && $clicks.value >= resolvedClick.value
1663
+ && withinRange.value
1664
+ unsettle()
1665
+ scheduleUpdate()
1666
+
1667
+ resizeObserver = new ResizeObserver(scheduleUpdate)
1668
+ if (container.value)
1669
+ resizeObserver.observe(container.value)
1670
+ if (overlay.value)
1671
+ resizeObserver.observe(overlay.value)
1672
+ const source = container.value?.querySelector(props.selector)
1673
+ if (source)
1674
+ resizeObserver.observe(source)
1675
+ for (const image of Array.from(container.value?.querySelectorAll('img') ?? [])) {
1676
+ image.addEventListener('load', scheduleUpdate)
1677
+ watchedImages.push(image)
1678
+ }
1679
+
1680
+ // Watch the code blocks only. Observing the whole slot would pick up our own
1681
+ // paths and spin the tracking loop forever.
1682
+ const animated = container.value?.querySelectorAll('.slidev-code, .slidev-code-magic-move, pre.shiki')
1683
+ if (animated?.length) {
1684
+ mutationObserver = new MutationObserver(() => track())
1685
+ for (const element of Array.from(animated))
1686
+ mutationObserver.observe(element, { childList: true, subtree: true, characterData: true, attributes: true, attributeFilter: ['style', 'class'] })
1687
+ }
1688
+
1689
+ document.fonts?.ready.then(() => {
1690
+ labelSizeCache.clear()
1691
+ scheduleUpdate()
1692
+ })
1693
+ window.addEventListener('resize', scheduleUpdate)
1694
+ })
1695
+
1696
+ onBeforeUnmount(() => {
1697
+ mounted = false
1698
+ settleRun++
1699
+ clearTimeout(exitFadeTimer)
1700
+ $clicksContext.unregister(ownClicks)
1701
+ cancelAnimationFrame(frame)
1702
+ cancelAnimationFrame(paintFrame)
1703
+ cancelAnimationFrame(trackFrame)
1704
+ resizeObserver?.disconnect()
1705
+ mutationObserver?.disconnect()
1706
+ for (const image of watchedImages.splice(0))
1707
+ image.removeEventListener('load', scheduleUpdate)
1708
+ window.removeEventListener('resize', scheduleUpdate)
1709
+ })
1710
+
1711
+ // Magic Move and click transitions are driven by the click count, and both move
1712
+ // the annotated element for a while after it changes.
1713
+ watch($clicks, () => {
1714
+ unsettle()
1715
+ track(CLICK_TRACK_DURATION)
1716
+ }, { flush: 'sync' })
1717
+
1718
+ // Preloaded slides can already contain their annotations before navigation
1719
+ // starts, so mount-time detection alone is not enough. Watching this annotation's
1720
+ // active-slide state also covers a preloaded component becoming visible inside
1721
+ // document.startViewTransition(); the grace period lets the browser create the
1722
+ // document-level pseudo-elements before the first animation scan.
1723
+ watch(isCurrentSlide, (current) => {
1724
+ if (current) {
1725
+ unsettle(VIEW_TRANSITION_START_GRACE)
1726
+ track()
1727
+ }
1728
+ }, { flush: 'sync' })
1729
+
1730
+ // Any prop can change what is drawn or where the label may go, so re-measure
1731
+ // on all of them instead of maintaining a list that can silently go stale.
1732
+ // The caches assume unchanged props, so they are dropped along the way.
1733
+ watch(props, () => {
1734
+ labelSizeCache.clear()
1735
+ lastMarkKey = ''
1736
+ scheduleUpdate()
1737
+ })
1738
+ </script>
1739
+
1740
+ <template>
1741
+ <div ref="container" class="drawn-annotation" :class="{ 'is-fading-out': fadingOut }">
1742
+ <!-- The marker takes part in Slidev's click ordering while component state
1743
+ drives the drawing. This avoids a mount-time v-click class race. -->
1744
+ <span ref="clickMarker" v-click="manualClicks ? false : atClick" class="click-marker annotation-ignore" />
1745
+ <span v-if="props.labelAt !== undefined" ref="labelMarker" v-click="manualClicks ? false : props.labelAt" class="click-marker annotation-ignore" />
1746
+ <svg
1747
+ ref="overlay"
1748
+ class="annotation-overlay annotation-ignore"
1749
+ :viewBox="`0 0 ${geometry.width} ${geometry.height}`"
1750
+ preserveAspectRatio="none"
1751
+ aria-hidden="true"
1752
+ :style="{
1753
+ '--annotation-color': props.color,
1754
+ '--annotation-width': `${props.strokeWidth}px`,
1755
+ '--leader-delay': `${delays.leader}ms`,
1756
+ '--leader-duration': `${duration * LEADER_FRACTION}ms`,
1757
+ '--arrow-delay': `${delays.arrow}ms`,
1758
+ '--arrow-duration': `${duration * ARROW_FRACTION}ms`,
1759
+ '--exit-fade': `${EXIT_FADE_DURATION}ms`,
1760
+ }"
1761
+ >
1762
+ <template v-if="geometry.ready">
1763
+ <g :class="{ 'is-active': active }">
1764
+ <!-- The duration is split over the strokes and they draw one after
1765
+ another, which is Rough Notation's sketchy redraw. -->
1766
+ <path
1767
+ v-for="(d, index) in geometry.mark"
1768
+ :key="`mark-${index}`"
1769
+ class="annotation-stroke annotation-mark"
1770
+ pathLength="1"
1771
+ :style="{
1772
+ '--path-duration': `${duration / geometry.mark.length}ms`,
1773
+ '--path-delay': `${delays.mark + index * duration / geometry.mark.length}ms`,
1774
+ }"
1775
+ :d="d"
1776
+ />
1777
+ <path
1778
+ v-for="(d, index) in geometry.leader"
1779
+ :key="`leader-${index}`"
1780
+ class="annotation-stroke annotation-leader"
1781
+ pathLength="1"
1782
+ :d="d"
1783
+ />
1784
+ <path
1785
+ v-for="(d, index) in geometry.targetMark"
1786
+ :key="`target-${index}`"
1787
+ class="annotation-stroke annotation-target"
1788
+ pathLength="1"
1789
+ :style="{
1790
+ '--path-duration': `${duration * TARGET_FRACTION / geometry.targetMark.length}ms`,
1791
+ '--path-delay': `${delays.target + index * duration * TARGET_FRACTION / geometry.targetMark.length}ms`,
1792
+ }"
1793
+ :d="d"
1794
+ />
1795
+ <path
1796
+ v-for="(d, index) in geometry.arrow"
1797
+ :key="`arrow-${index}`"
1798
+ class="annotation-stroke annotation-arrow"
1799
+ pathLength="1"
1800
+ :d="d"
1801
+ />
1802
+ </g>
1803
+ </template>
1804
+ </svg>
1805
+ <div
1806
+ v-if="hasLabel"
1807
+ ref="labelEl"
1808
+ class="annotation-label annotation-ignore"
1809
+ :class="{ 'is-active': labelActive && geometry.ready, 'is-placed': geometry.labelPlaced }"
1810
+ :data-click="resolvedLabelClick"
1811
+ :style="{
1812
+ '--annotation-color': props.color,
1813
+ '--label-delay': `${delays.label}ms`,
1814
+ '--label-fade': `${LABEL_FADE_DURATION}ms`,
1815
+ 'left': `${geometry.labelLeft}px`,
1816
+ 'top': `${geometry.labelTop}px`,
1817
+ 'maxWidth': geometry.labelWidth === undefined ? undefined : `${geometry.labelWidth}px`,
1818
+ }"
1819
+ aria-hidden="true"
1820
+ >
1821
+ {{ props.label }}
1822
+ </div>
1823
+ <slot />
1824
+ <!-- Keep the live region mounted so screen readers announce the label when
1825
+ its click is reached. The positioned copy is presentation-only. -->
1826
+ <span
1827
+ class="annotation-label-status annotation-ignore"
1828
+ role="status"
1829
+ aria-live="polite"
1830
+ aria-atomic="true"
1831
+ >{{ labelActive && geometry.ready ? props.label : '' }}</span>
1832
+ </div>
1833
+ </template>
1834
+
1835
+ <style scoped>
1836
+ /* display: contents keeps the wrapped Markdown in exactly the layout it had
1837
+ before being annotated. The absolute overlay uses the slide as its canvas. */
1838
+ .drawn-annotation { display: contents; }
1839
+ /* A `v-click` around an annotation lands its hidden class on this element, and
1840
+ `display: contents` generates no box for `opacity: 0` to act on, so the
1841
+ wrapped Markdown would stay on screen. `visibility` is inherited by the
1842
+ children instead, which hides them and keeps the space they take, exactly as
1843
+ Slidev hides them when no annotation is in the way. */
1844
+ .drawn-annotation.slidev-vclick-hidden { visibility: hidden; }
1845
+ .click-marker { position: absolute; width: 0; height: 0; overflow: hidden; }
1846
+ .annotation-label-status {
1847
+ position: absolute;
1848
+ width: 1px;
1849
+ height: 1px;
1850
+ overflow: hidden;
1851
+ clip-path: inset(50%);
1852
+ white-space: nowrap;
1853
+ }
1854
+
1855
+ .annotation-overlay {
1856
+ position: absolute;
1857
+ inset: 0;
1858
+ z-index: 4;
1859
+ display: block;
1860
+ width: 100%;
1861
+ height: 100%;
1862
+ overflow: visible;
1863
+ pointer-events: none;
1864
+ }
1865
+
1866
+ .annotation-stroke {
1867
+ fill: none;
1868
+ stroke: var(--annotation-color);
1869
+ stroke-width: var(--annotation-width);
1870
+ stroke-linecap: round;
1871
+ stroke-linejoin: round;
1872
+ stroke-dasharray: 1;
1873
+ stroke-dashoffset: 1;
1874
+ opacity: 0;
1875
+ /* Entering durations are applied below. On exit, keep the completed path in
1876
+ place while its opacity eases away, then reset it after the fade. This is
1877
+ especially important for click-ranged annotations: an instant dash reset
1878
+ made them disappear much more harshly than the content around them.
1879
+ `--exit-fade` mirrors the EXIT_FADE_DURATION constant in the script. */
1880
+ transition:
1881
+ stroke-dashoffset 1ms linear var(--exit-fade, 300ms),
1882
+ opacity var(--exit-fade, 300ms) ease-out;
1883
+ }
1884
+
1885
+ .is-active .annotation-mark {
1886
+ stroke-dashoffset: 0;
1887
+ opacity: 1;
1888
+ transition-duration: var(--path-duration), 1ms;
1889
+ transition-delay: var(--path-delay), var(--path-delay);
1890
+ }
1891
+
1892
+ .is-active .annotation-leader {
1893
+ stroke-dashoffset: 0;
1894
+ opacity: 1;
1895
+ transition-duration: var(--leader-duration), 1ms;
1896
+ transition-delay: var(--leader-delay), var(--leader-delay);
1897
+ }
1898
+
1899
+ .is-active .annotation-target {
1900
+ stroke-dashoffset: 0;
1901
+ opacity: 1;
1902
+ transition-duration: var(--path-duration), 1ms;
1903
+ transition-delay: var(--path-delay), var(--path-delay);
1904
+ }
1905
+
1906
+ .is-active .annotation-arrow {
1907
+ stroke-dashoffset: 0;
1908
+ opacity: 1;
1909
+ transition-duration: var(--arrow-duration), 1ms;
1910
+ transition-delay: var(--arrow-delay), var(--arrow-delay);
1911
+ }
1912
+
1913
+ .annotation-label {
1914
+ position: absolute;
1915
+ z-index: 5;
1916
+ transform: translate(-50%, -50%);
1917
+ color: var(--annotation-color);
1918
+ /* Themeable, like `--drawn-annotation-color`: a theme restyles every label
1919
+ by defining these variables, at :root or any narrower scope. */
1920
+ font-family: var(--drawn-annotation-label-font, 'JetBrains Sans', var(--slidev-font-sans), sans-serif);
1921
+ font-size: var(--drawn-annotation-label-size, 28px);
1922
+ font-weight: var(--drawn-annotation-label-weight, 800);
1923
+ line-height: 1.2;
1924
+ text-align: center;
1925
+ text-wrap: balance;
1926
+ width: max-content;
1927
+ pointer-events: none;
1928
+ opacity: 0;
1929
+ visibility: hidden;
1930
+ transition: opacity var(--label-fade, 250ms) ease-out;
1931
+ }
1932
+
1933
+ /* Measured before it is placed, so keep it laid out but invisible until then. */
1934
+ .annotation-label.is-placed { visibility: visible; }
1935
+
1936
+ .annotation-label.is-active {
1937
+ opacity: 1;
1938
+ transition-delay: var(--label-delay);
1939
+ }
1940
+
1941
+ @media (prefers-reduced-motion: reduce) {
1942
+ .annotation-stroke,
1943
+ .annotation-label {
1944
+ transition-duration: 1ms !important;
1945
+ transition-delay: 0ms !important;
1946
+ }
1947
+ }
1948
+ </style>