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