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