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.
- package/CHANGELOG.md +42 -0
- package/README.md +68 -12
- package/components/DrawnAnnotation.vue +1669 -0
- package/components/Kodee.vue +56 -24
- package/components/KodeeWrapper.vue +52 -8
- package/components/README.md +263 -0
- package/layouts/cover.vue +0 -5
- package/layouts/default.vue +0 -5
- package/layouts/intro.vue +0 -5
- package/package.json +23 -8
- package/slide-bottom.vue +30 -0
- package/styles/layout.css +16 -0
- package/.github/workflows/pages.yml +0 -54
- package/example.md +0 -221
- /package/{public → assets}/kodee-drinking.svg +0 -0
- /package/{public → assets}/kodee-greeting.svg +0 -0
- /package/{public → assets}/kodee-heart.svg +0 -0
- /package/{public → assets}/kodee-in-love.svg +0 -0
- /package/{public → assets}/kodee-jumping.svg +0 -0
- /package/{public → assets}/kodee-sitting.svg +0 -0
- /package/{public → assets}/kodee-tiny.svg +0 -0
- /package/{public → assets}/kodee-wave.svg +0 -0
- /package/{public → assets}/kodee-welcome.svg +0 -0
- /package/{public → assets}/kodee-wink.svg +0 -0
- /package/{public → assets}/kodee-winter.svg +0 -0
|
@@ -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>
|