slidev-theme-kotlin 0.12.0 → 0.12.1

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 CHANGED
@@ -2,6 +2,12 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.12.1
6
+
7
+ ### Fixed
8
+
9
+ - Preserve visible `DrawnAnnotation` marks and `InlineCompilerError` diagnostics across adjacent cross-slide Magic Move transitions when their targets remain in place. Matching ink now hands off without replaying its entrance animation, while moved or unrelated targets still animate normally.
10
+
5
11
  ## 0.12.0
6
12
 
7
13
  ### Added
@@ -29,6 +29,8 @@ import { injectionClicksContext } from '@slidev/client/constants.ts'
29
29
  import rough from 'roughjs'
30
30
  import { findTextInSegments } from './code-text-match'
31
31
  import type { TextSegment } from './code-text-match'
32
+ import { rememberTransitionInk, takeTransitionInk } from './transition-ink'
33
+ import type { TransitionInkBox } from './transition-ink'
32
34
  import { annotationLabelText, renderAnnotationLabel } from './annotation-label-markdown'
33
35
  import { localLabelWidthToSlideFraction, localPointToSlideFraction, nudgeConnector, nudgeLabelWidth, slideFractionPointToLocal, translateConnector, validateDrawnAnnotationGeometry } from './drawn-annotation/geometry'
34
36
  import type { DrawnAnnotationGeometry, PersistedAnnotationGeometry } from './drawn-annotation/geometry'
@@ -317,6 +319,17 @@ const hasLabel = computed(() => !!props.label)
317
319
  const labelHtml = computed(() => renderAnnotationLabel(props.label ?? ''))
318
320
  const labelText = computed(() => annotationLabelText(props.label ?? ''))
319
321
  const hasTarget = computed(() => !!props.target)
322
+ // Cross-slide Magic Move mounts a separate annotation component on each page.
323
+ // This identity lets a visible mark hand its ink to an equivalent neighbour;
324
+ // the geometry hand-off below still requires their final positions to match.
325
+ const transitionInkIdentity = computed(() => JSON.stringify([
326
+ sourceMarkType.value, targetMarkType.value, props.selector, props.text,
327
+ props.occurrence, props.multiline, props.padding, props.target,
328
+ props.targetX, props.targetY, props.targetRadius, props.label,
329
+ props.placement, props.gap, props.connect, props.arrow, props.curve,
330
+ props.iterations, props.color, props.strokeWidth, props.duration,
331
+ props.geometry, props.options,
332
+ ]))
320
333
  // A source-only mark exposes no visual-editor operation. It remains valid:
321
334
  // only labels and connectors have geometry that an editor can manipulate.
322
335
  const editorRelevant = computed(() => hasLabel.value || (props.connect && hasTarget.value))
@@ -392,6 +405,10 @@ const painted = computed(() => geometryPainted.value || showImmediately.value)
392
405
  // Drawing is held back until then, so a mark never appears on top of a Magic
393
406
  // Move step or a slide transition that is still travelling.
394
407
  const settled = ref(!props.wait)
408
+ // A matching annotation was already visible on the preceding Magic Move page.
409
+ // Let the incoming SVG appear complete after the move, rather than replaying
410
+ // its stroke-dash entrance from scratch.
411
+ const skipEntranceAnimation = ref(false)
395
412
  // An annotation with `until` is only shown while the thing it points at is on
396
413
  // screen: past that click every stage hides again, in one step.
397
414
  // `on` is the same thing derived from the resolved clicks, so the shorthand
@@ -754,6 +771,20 @@ let unregisterEditorActions: (() => void) | undefined
754
771
  // Fingerprint of the mark's last measured boxes. While the slide animates, a
755
772
  // frame in which the mark has not moved recomputes nothing.
756
773
  let lastMarkKey = ''
774
+ // The marked bounds are retained in concrete-slide fractions. They are used
775
+ // only to hand already-visible ink to an equivalent annotation on the next
776
+ // cross-slide Magic Move page.
777
+ let lastMarkTransitionBox: TransitionInkBox | undefined
778
+ let lastVisibleMarkTransitionBox: TransitionInkBox | undefined
779
+ watch(active, (isActive) => {
780
+ if (isActive && lastMarkTransitionBox)
781
+ lastVisibleMarkTransitionBox = lastMarkTransitionBox
782
+ // Keep the outgoing page's final visible bounds long enough for the
783
+ // isCurrentSlide watcher to hand them to its neighbour. A click-range exit,
784
+ // by contrast, really removes the annotation and must not carry stale ink.
785
+ else if (isCurrentSlide.value)
786
+ lastVisibleMarkTransitionBox = undefined
787
+ })
757
788
  // Content boxes an automatic label avoids, in local SVG coordinates. The DOM
758
789
  // scan is refreshed on discrete re-measures (click settles, resizes, prop or
759
790
  // draft changes) and reused by the per-frame tracking passes, which must not
@@ -767,16 +798,25 @@ const watchedImages: HTMLImageElement[] = []
767
798
  // animations and Magic Move's generated animations with the same `finished`
768
799
  // promise, including their real delay and duration.
769
800
  let settleRun = 0
801
+ // A Magic Move renderer replaces individual token animations as it progresses.
802
+ // Its DOM classes, observed below, are the reliable final-settlement signal.
803
+ let magicMoveWasObserved = false
770
804
 
771
805
  function relevantAnimations() {
772
- const root = container.value
806
+ // A wrapped annotation owns its content, but a self-closing annotation is
807
+ // `display: contents` and its target is a following sibling. In particular,
808
+ // that is how an annotation points into a top-level Magic Move code fence.
809
+ // Query the concrete slide rather than the annotation container, otherwise
810
+ // its animation is invisible here and we settle at the view transition's
811
+ // end instead of at the final Shiki layout.
812
+ const root = slideRoot() ?? searchRoot()
773
813
  if (!root?.getAnimations)
774
814
  return []
775
815
 
776
816
  // A view transition is owned by the document, not by the incoming slide's
777
- // slot. Its pseudo-elements therefore never appear in the annotation
778
- // container's subtree, even though they are exactly the animation we must
779
- // wait for before drawing on the new slide.
817
+ // layout. Its pseudo-elements therefore never appear in this subtree, even
818
+ // though they are exactly the animation we must wait for before drawing on
819
+ // the new slide.
780
820
  const viewTransitionAnimations = typeof document !== 'undefined'
781
821
  ? document.getAnimations().filter((animation) => {
782
822
  const pseudo = (animation.effect as KeyframeEffect | null)?.pseudoElement
@@ -806,9 +846,9 @@ function nextFrame() {
806
846
 
807
847
  /**
808
848
  * Release the entrance when the animations that actually started for this
809
- * click finish. Re-check after each batch because Magic Move can create the
810
- * next transition while completing the previous one. A final frame lets the
811
- * browser commit the finished layout before it is measured for drawing.
849
+ * click finish. Magic Move replaces its individual token animations while it
850
+ * runs, so its observed DOM classes, rather than a potentially stale
851
+ * `Animation.finished` promise, release the final measurement.
812
852
  */
813
853
  const VIEW_TRANSITION_START_GRACE = 100
814
854
 
@@ -824,6 +864,13 @@ async function settleAfterAnimations(run: number, grace = 0) {
824
864
  await nextFrame()
825
865
  while (mounted && run === settleRun) {
826
866
  const animations = relevantAnimations()
867
+ if (isMagicMoveInProgress()) {
868
+ // The MutationObserver sees the final class removal and settles then.
869
+ // Do not await these token animations: Shiki may replace one while it is
870
+ // running, leaving that old promise unresolved.
871
+ magicMoveWasObserved = true
872
+ return
873
+ }
827
874
  if (!animations.length) {
828
875
  await nextFrame()
829
876
  if (mounted && run === settleRun && !relevantAnimations().length) {
@@ -831,6 +878,7 @@ async function settleAfterAnimations(run: number, grace = 0) {
831
878
  // recomputation per click that the animation-frame passes lean on.
832
879
  settled.value = true
833
880
  updateGeometry()
881
+ inheritTransitionInk()
834
882
  }
835
883
  return
836
884
  }
@@ -839,12 +887,34 @@ async function settleAfterAnimations(run: number, grace = 0) {
839
887
  }
840
888
  }
841
889
 
890
+ function isMagicMoveInProgress() {
891
+ const root = slideRoot() ?? searchRoot()
892
+ return !!root?.querySelector([
893
+ '.shiki-magic-move-enter-active',
894
+ '.shiki-magic-move-leave-active',
895
+ '.shiki-magic-move-move',
896
+ '.shiki-magic-move-container-resize',
897
+ '.shiki-magic-move-container-restyle',
898
+ ].join(', '))
899
+ }
900
+
901
+ function settleAfterMagicMove() {
902
+ if (!magicMoveWasObserved || isMagicMoveInProgress() || !mounted || !isCurrentSlide.value)
903
+ return
904
+ magicMoveWasObserved = false
905
+ settled.value = true
906
+ updateGeometry()
907
+ inheritTransitionInk()
908
+ }
909
+
842
910
  /**
843
911
  * Marks the annotation as moving again and keeps measuring until it stops.
844
912
  * Called on every click, because that is what starts a Magic Move step.
845
913
  */
846
914
  function unsettle(grace = 0) {
847
915
  const run = ++settleRun
916
+ magicMoveWasObserved = false
917
+ skipEntranceAnimation.value = false
848
918
  // A click can restyle the slide, so measured label sizes and content
849
919
  // obstacles go stale here — once per click, which leaves them shared by
850
920
  // every frame of the animation.
@@ -1014,6 +1084,9 @@ function updateGeometry() {
1014
1084
 
1015
1085
  geometry.width = width
1016
1086
  geometry.height = height
1087
+ lastMarkTransitionBox = transitionInkBox(marked)
1088
+ if (active.value)
1089
+ lastVisibleMarkTransitionBox = lastMarkTransitionBox
1017
1090
  assignPaths('mark', shapeBoxes.flatMap(box => markPaths(box, sourceMarkType.value)))
1018
1091
 
1019
1092
  paintDestination(root, toLocal, marked)
@@ -1070,6 +1143,37 @@ function markPaths(box: Box, type: MarkType, padding = props.padding): string[]
1070
1143
  }
1071
1144
 
1072
1145
  /** Draws the leader and positions the label. */
1146
+ function transitionInkBox(marked: Box): TransitionInkBox | undefined {
1147
+ const svg = overlay.value
1148
+ const slide = slideRoot()
1149
+ if (!svg || !slide || !geometry.width || !geometry.height)
1150
+ return undefined
1151
+ const overlayBox = svg.getBoundingClientRect()
1152
+ const slideBox = slide.getBoundingClientRect()
1153
+ if (!overlayBox.width || !overlayBox.height || !slideBox.width || !slideBox.height)
1154
+ return undefined
1155
+ const left = overlayBox.left + marked.left / geometry.width * overlayBox.width
1156
+ const top = overlayBox.top + marked.top / geometry.height * overlayBox.height
1157
+ const right = overlayBox.left + marked.right / geometry.width * overlayBox.width
1158
+ const bottom = overlayBox.top + marked.bottom / geometry.height * overlayBox.height
1159
+ return {
1160
+ left: (left - slideBox.left) / slideBox.width,
1161
+ top: (top - slideBox.top) / slideBox.height,
1162
+ right: (right - slideBox.left) / slideBox.width,
1163
+ bottom: (bottom - slideBox.top) / slideBox.height,
1164
+ }
1165
+ }
1166
+
1167
+ function isCrossSlideMagicMove() {
1168
+ return !!slideRoot()?.querySelector('.magic-move-between')
1169
+ }
1170
+
1171
+ function inheritTransitionInk() {
1172
+ if (!isCurrentSlide.value || !withinRange.value || !lastMarkTransitionBox || !isCrossSlideMagicMove())
1173
+ return
1174
+ skipEntranceAnimation.value = takeTransitionInk(transitionInkIdentity.value, slideContext.$page.value, lastMarkTransitionBox)
1175
+ }
1176
+
1073
1177
  function paintDestination(
1074
1178
  root: HTMLElement,
1075
1179
  toLocal: (rect: DOMRect) => Box,
@@ -1701,21 +1805,31 @@ function connectObservers() {
1701
1805
  resizeObserver.observe(container.value)
1702
1806
  if (overlay.value)
1703
1807
  resizeObserver.observe(overlay.value)
1704
- const source = container.value?.querySelector(props.selector)
1808
+ const observedRoot = slideRoot() ?? searchRoot()
1809
+ const source = observedRoot?.querySelector<HTMLElement>(props.selector)
1705
1810
  if (source)
1706
1811
  resizeObserver.observe(source)
1707
- for (const image of Array.from(container.value?.querySelectorAll('img') ?? [])) {
1812
+ for (const image of Array.from(observedRoot?.querySelectorAll('img') ?? [])) {
1708
1813
  image.addEventListener('load', scheduleUpdate)
1709
1814
  watchedImages.push(image)
1710
1815
  }
1711
1816
 
1712
- // Watch the code blocks only. Observing the whole slot would pick up our own
1713
- // paths and spin the tracking loop forever.
1714
- const animated = container.value?.querySelectorAll('.slidev-code, .slidev-code-magic-move, pre.shiki')
1817
+ // Watch code blocks under the concrete slide, not just under this component:
1818
+ // a self-closing annotation points at following sibling fences. Observing
1819
+ // only these blocks avoids picking up our own SVG paths and spinning the
1820
+ // tracking loop forever.
1821
+ const animated = observedRoot?.querySelectorAll('.slidev-code, .slidev-code-magic-move, pre.shiki')
1715
1822
  if (animated?.length) {
1716
- mutationObserver = new MutationObserver(() => track())
1717
- for (const element of Array.from(animated))
1823
+ mutationObserver = new MutationObserver(() => {
1824
+ if (isMagicMoveInProgress())
1825
+ magicMoveWasObserved = true
1826
+ track()
1827
+ settleAfterMagicMove()
1828
+ })
1829
+ for (const element of Array.from(animated)) {
1830
+ resizeObserver.observe(element)
1718
1831
  mutationObserver.observe(element, { childList: true, subtree: true, characterData: true, attributes: true, attributeFilter: ['style', 'class'] })
1832
+ }
1719
1833
  }
1720
1834
 
1721
1835
  window.addEventListener('resize', scheduleUpdate)
@@ -1769,11 +1883,15 @@ watch($clicks, () => {
1769
1883
  // document-level pseudo-elements before the first animation scan.
1770
1884
  watch(isCurrentSlide, (current) => {
1771
1885
  if (current) {
1886
+ lastVisibleMarkTransitionBox = undefined
1772
1887
  connectObservers()
1773
1888
  unsettle(VIEW_TRANSITION_START_GRACE)
1774
1889
  track()
1775
1890
  }
1776
1891
  else {
1892
+ if (lastVisibleMarkTransitionBox && isCrossSlideMagicMove())
1893
+ rememberTransitionInk(transitionInkIdentity.value, slideContext.$page.value, lastVisibleMarkTransitionBox)
1894
+ lastVisibleMarkTransitionBox = undefined
1777
1895
  disconnectObservers()
1778
1896
  // Cancel in-flight measurement too: a settle pass or tracking loop begun
1779
1897
  // before navigating away would keep calling updateGeometry on a slide
@@ -2273,7 +2391,7 @@ onBeforeUnmount(() => {
2273
2391
  <div
2274
2392
  ref="container"
2275
2393
  class="drawn-annotation"
2276
- :class="{ 'is-fading-out': fadingOut }"
2394
+ :class="{ 'is-fading-out': fadingOut, 'skip-entrance-animation': skipEntranceAnimation }"
2277
2395
  >
2278
2396
  <!-- The marker takes part in Slidev's click ordering while component state
2279
2397
  drives the drawing. This avoids a mount-time v-click class race. -->
@@ -2482,6 +2600,12 @@ onBeforeUnmount(() => {
2482
2600
  opacity var(--exit-fade, 300ms) ease-out;
2483
2601
  }
2484
2602
 
2603
+ .skip-entrance-animation .annotation-stroke,
2604
+ .skip-entrance-animation .annotation-label {
2605
+ transition-duration: 0ms !important;
2606
+ transition-delay: 0ms !important;
2607
+ }
2608
+
2485
2609
  .is-active .annotation-mark {
2486
2610
  stroke-dashoffset: 0;
2487
2611
  opacity: 1;
@@ -2,9 +2,11 @@
2
2
  import type { ClicksInfo } from '@slidev/types'
3
3
  import type { TextSegment } from './code-text-match'
4
4
  import { computed, nextTick, onBeforeUnmount, onMounted, ref, shallowRef, useId, useTemplateRef, watch } from 'vue'
5
- import { useNav, useSlideContext } from '@slidev/client'
5
+ import { useIsSlideActive, useNav, useSlideContext } from '@slidev/client'
6
6
  import { useMutationObserver, useResizeObserver } from '@vueuse/core'
7
7
  import { findTextInSegments } from './code-text-match'
8
+ import { rememberTransitionInk, takeTransitionInk } from './transition-ink'
9
+ import type { TransitionInkBox } from './transition-ink'
8
10
 
9
11
  const props = defineProps<{
10
12
  /** One-based source line number inside the single fenced code block this component wraps. Optional when `text` identifies the target. */
@@ -27,8 +29,10 @@ const props = defineProps<{
27
29
  on?: number | string
28
30
  }>()
29
31
 
30
- const { $clicksContext, $clicks, $scale, $zoom } = useSlideContext()
32
+ const slideContext = useSlideContext()
33
+ const { $clicksContext, $clicks, $scale, $zoom } = slideContext
31
34
  const { isPrintMode } = useNav()
35
+ const isCurrentSlide = useIsSlideActive()
32
36
  const id = useId()
33
37
  const container = useTemplateRef<HTMLElement>('container')
34
38
  const position = ref({
@@ -52,6 +56,12 @@ const textMatches = ref(0)
52
56
  const hasTarget = computed(() => !!targetRange.value)
53
57
  // True while Shiki Magic Move is transitioning between steps.
54
58
  const animating = ref(false)
59
+ const skipEntranceAnimation = ref(false)
60
+ const transitionInkIdentity = computed(() => JSON.stringify([
61
+ props.line, props.text, props.occurrence, props.message,
62
+ ]))
63
+ let lastTargetTransitionBox: TransitionInkBox | undefined
64
+ let lastVisibleTargetTransitionBox: TransitionInkBox | undefined
55
65
 
56
66
  // Slidev reserves click 0 for the pre-click state and warns when anything
57
67
  // registers at 0, so `at` values <= 0 mean "visible from the start".
@@ -94,6 +104,8 @@ onMounted(() => {
94
104
  $clicksContext.register(id, clickInfo.value)
95
105
  })
96
106
  const visible = computed(() => {
107
+ if (!isCurrentSlide.value)
108
+ return false
97
109
  const info = clickInfo.value
98
110
  if (!info)
99
111
  return true
@@ -132,6 +144,49 @@ let magicMoveFrame: number | undefined
132
144
  let codeHost: HTMLElement | null = null
133
145
  let isMagicMove = false
134
146
 
147
+ function isCrossSlideMagicMove(host: HTMLElement) {
148
+ return !!(host.closest('.slidev-layout') ?? host).querySelector('.magic-move-between')
149
+ }
150
+
151
+ function transitionInkBox(left: number, top: number, right: number, bottom: number, slide: DOMRect): TransitionInkBox | undefined {
152
+ if (!slide.width || !slide.height)
153
+ return undefined
154
+ return {
155
+ left: (left - slide.left) / slide.width,
156
+ top: (top - slide.top) / slide.height,
157
+ right: (right - slide.left) / slide.width,
158
+ bottom: (bottom - slide.top) / slide.height,
159
+ }
160
+ }
161
+
162
+ function relevantMagicMoveAnimations(host: HTMLElement) {
163
+ const root = host.closest<HTMLElement>('.slidev-layout') ?? host
164
+ const pageAnimations = document.getAnimations().filter((animation) => {
165
+ const pseudo = (animation.effect as KeyframeEffect | null)?.pseudoElement
166
+ return typeof pseudo === 'string' && pseudo.startsWith('::view-transition-')
167
+ })
168
+ const animations = [...root.getAnimations({ subtree: true }), ...pageAnimations]
169
+ return [...new Set(animations)].filter((animation) => {
170
+ if (animation.playState !== 'running' && !animation.pending)
171
+ return false
172
+ const effect = animation.effect as KeyframeEffect | null
173
+ const target = effect?.target
174
+ const pseudo = effect?.pseudoElement
175
+ return (typeof pseudo === 'string' && pseudo.startsWith('::view-transition-'))
176
+ || (target instanceof Element && !!target.closest('.magic-move-between, .slidev-code-magic-move, .shiki-magic-move-container'))
177
+ }).filter(animation => Number.isFinite(animation.effect?.getComputedTiming().endTime))
178
+ }
179
+
180
+ function isMagicMoveAnimating(host: HTMLElement) {
181
+ return !!host.querySelector(MAGIC_MOVE_ANIMATING) || relevantMagicMoveAnimations(host).length > 0
182
+ }
183
+
184
+ function inheritTransitionInk(host: HTMLElement) {
185
+ if (!visible.value || !lastTargetTransitionBox || !isCrossSlideMagicMove(host))
186
+ return
187
+ skipEntranceAnimation.value = takeTransitionInk(transitionInkIdentity.value, slideContext.$page.value, lastTargetTransitionBox)
188
+ }
189
+
135
190
  function findCodeHost(host: HTMLElement): HTMLElement | null {
136
191
  if (!codeHost?.isConnected) {
137
192
  // Shiki Magic Move v2 renders tokens directly in a <pre>, separated by
@@ -369,7 +424,20 @@ function sync() {
369
424
  targets.value = nextTargets
370
425
  }
371
426
 
372
- animating.value = !!host.querySelector(MAGIC_MOVE_ANIMATING)
427
+ const wasAnimating = animating.value
428
+ animating.value = isMagicMoveAnimating(host)
429
+ if (wasAnimating && !animating.value) {
430
+ inheritTransitionInk(host)
431
+ // A preloaded page's visibility watcher can run before Slidev commits the
432
+ // incoming route, then be invalidated by that commit. This is the actual
433
+ // settled edge, so open explicitly rather than depending on that earlier
434
+ // watcher to fire a second time. The hand-off class makes unchanged ink
435
+ // instantaneous; moved ink still uses the normal Transition entrance.
436
+ if (visible.value && hasTarget.value) {
437
+ clearTimeout(revealTimer)
438
+ revealed.value = true
439
+ }
440
+ }
373
441
  if (animating.value)
374
442
  sawAnimating = true
375
443
  if (import.meta.env.DEV && !warnedClassDrift && !isPrintMode.value && stepSwaps >= 2 && !sawAnimating) {
@@ -443,6 +511,9 @@ function sync() {
443
511
  top: `${(bottom - hostBox.top) / scale - 1}px`,
444
512
  width: `${(right - left) / scale}px`,
445
513
  }
514
+ lastTargetTransitionBox = transitionInkBox(left, top, right, bottom, slideBox)
515
+ if (revealed.value)
516
+ lastVisibleTargetTransitionBox = lastTargetTransitionBox
446
517
  }
447
518
 
448
519
  // Coalesce observer bursts into one sync per frame: during a Magic Move
@@ -523,6 +594,27 @@ watch([visible, hasTarget, animating], ([isVisible, targetPresent, isAnimating])
523
594
  revealTimer = setTimeout(reveal, 100)
524
595
  })
525
596
 
597
+ watch(revealed, (isRevealed) => {
598
+ if (isRevealed && lastTargetTransitionBox)
599
+ lastVisibleTargetTransitionBox = lastTargetTransitionBox
600
+ else if (isCurrentSlide.value)
601
+ lastVisibleTargetTransitionBox = undefined
602
+ })
603
+
604
+ watch(isCurrentSlide, (current) => {
605
+ if (current) {
606
+ lastVisibleTargetTransitionBox = undefined
607
+ skipEntranceAnimation.value = false
608
+ scheduleSync()
609
+ return
610
+ }
611
+ const host = container.value
612
+ if (host && lastVisibleTargetTransitionBox && isCrossSlideMagicMove(host))
613
+ rememberTransitionInk(transitionInkIdentity.value, slideContext.$page.value, lastVisibleTargetTransitionBox)
614
+ lastVisibleTargetTransitionBox = undefined
615
+ revealed.value = false
616
+ }, { flush: 'sync' })
617
+
526
618
  // A target that is absent from the current code step has nothing to anchor to,
527
619
  // so the diagnostic stays hidden. Silent in production, but tell the slide
528
620
  // author after the longest Magic Move transition has had time to finish.
@@ -557,7 +649,7 @@ watch([visible, hasTarget, () => props.line, () => props.text, () => props.occur
557
649
  </script>
558
650
 
559
651
  <template>
560
- <div ref="container" class="inline-compiler-error-host">
652
+ <div ref="container" class="inline-compiler-error-host" :class="{ 'skip-entrance-animation': skipEntranceAnimation }">
561
653
  <slot />
562
654
  <!-- Screen readers only announce a live region reliably when its content
563
655
  changes while it is already mounted, so this one exists permanently
@@ -593,6 +685,7 @@ watch([visible, hasTarget, () => props.line, () => props.text, () => props.occur
593
685
  .compiler-error-leave-active { transition: opacity .18s ease; }
594
686
  .compiler-error-enter-from,
595
687
  .compiler-error-leave-to { opacity: 0; }
688
+ .inline-compiler-error-host.skip-entrance-animation .compiler-error-enter-active { transition-duration: 0ms; }
596
689
  .inline-compiler-error-message i { color: var(--inline-compiler-error-color, #f05b5b); font-size: .55em; font-style: normal; }
597
690
  .inline-compiler-error-underline {
598
691
  position: absolute;
@@ -0,0 +1,26 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { rememberTransitionInk, takeTransitionInk } from './transition-ink'
3
+
4
+ const box = { left: .1, top: .2, right: .3, bottom: .25 }
5
+
6
+ describe('transition ink hand-off', () => {
7
+ it('carries identical ink between adjacent pages exactly once', () => {
8
+ rememberTransitionInk('identical', 10, box)
9
+
10
+ expect(takeTransitionInk('identical', 11, box)).toBe(true)
11
+ expect(takeTransitionInk('identical', 11, box)).toBe(false)
12
+ })
13
+
14
+ it('does not carry ink from a non-adjacent page or to a moved target', () => {
15
+ rememberTransitionInk('non-adjacent', 10, box)
16
+ expect(takeTransitionInk('non-adjacent', 12, box)).toBe(false)
17
+
18
+ rememberTransitionInk('moved', 10, box)
19
+ expect(takeTransitionInk('moved', 11, { ...box, top: .21, bottom: .26 })).toBe(false)
20
+ })
21
+
22
+ it('accepts sub-pixel layout differences at the standard slide size', () => {
23
+ rememberTransitionInk('sub-pixel', 10, box)
24
+ expect(takeTransitionInk('sub-pixel', 11, { ...box, left: .101, right: .301 })).toBe(true)
25
+ })
26
+ })
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Carries annotation ink across one cross-slide Magic Move transition.
3
+ *
4
+ * Slidev keeps both pages mounted but each page has its own annotation
5
+ * component instance. A matching annotation on the next page therefore has no
6
+ * component state to tell it that the ink was already visible. This tiny,
7
+ * module-local hand-off bridges that one navigation boundary. It deliberately
8
+ * matches both a semantic identity and the final normalized bounds: two equal
9
+ * annotations elsewhere on a slide must still draw independently.
10
+ */
11
+ export interface TransitionInkBox {
12
+ left: number
13
+ top: number
14
+ right: number
15
+ bottom: number
16
+ }
17
+
18
+ interface TransitionInk extends TransitionInkBox {
19
+ page: number
20
+ }
21
+
22
+ const inkByIdentity = new Map<string, TransitionInk[]>()
23
+
24
+ /** Record ink that was visible when its page was left. */
25
+ export function rememberTransitionInk(identity: string, page: number, box: TransitionInkBox) {
26
+ const records = inkByIdentity.get(identity) ?? []
27
+ // Repeated active/inactive updates for the same annotation replace its old
28
+ // hand-off instead of accumulating stale geometry.
29
+ const existing = records.findIndex(record => record.page === page)
30
+ const record = { page, ...box }
31
+ if (existing >= 0)
32
+ records[existing] = record
33
+ else
34
+ records.push(record)
35
+ inkByIdentity.set(identity, records)
36
+ }
37
+
38
+ /**
39
+ * Consume ink from the immediately adjacent page when it occupies the same
40
+ * final position. Coordinates are fractions of the concrete slide, so this
41
+ * survives presenter scaling and a differently sized browser window.
42
+ */
43
+ export function takeTransitionInk(identity: string, page: number, box: TransitionInkBox) {
44
+ const records = inkByIdentity.get(identity)
45
+ if (!records?.length)
46
+ return false
47
+
48
+ const adjacent = records
49
+ .map((record, index) => ({ record, index }))
50
+ .filter(({ record }) => Math.abs(record.page - page) === 1)
51
+ if (!adjacent.length)
52
+ return false
53
+
54
+ const distance = (record: TransitionInk) => Math.max(
55
+ Math.abs(record.left - box.left),
56
+ Math.abs(record.top - box.top),
57
+ Math.abs(record.right - box.right),
58
+ Math.abs(record.bottom - box.bottom),
59
+ )
60
+ const closest = adjacent.reduce((best, candidate) =>
61
+ distance(candidate.record) < distance(best.record) ? candidate : best,
62
+ )
63
+
64
+ // About three to four slide pixels at the standard 1440×810 canvas. This
65
+ // accepts sub-pixel browser/layout differences without treating a moved code
66
+ // line as the same annotation.
67
+ if (distance(closest.record) > 0.003)
68
+ return false
69
+
70
+ records.splice(closest.index, 1)
71
+ if (records.length)
72
+ inkByIdentity.set(identity, records)
73
+ else
74
+ inkByIdentity.delete(identity)
75
+ return true
76
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "slidev-theme-kotlin",
3
- "version": "0.12.0",
3
+ "version": "0.12.1",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./annotation-editor": {
package/styles/layout.css CHANGED
@@ -313,6 +313,13 @@ html.dark .slidev-code-wrapper:is(.code-window-icon--postgresql, :has(code.code-
313
313
  font-family: var(--drawn-annotation-code-font);
314
314
  }
315
315
 
316
+ /* Inline Markdown code in a quote is part of its prose, not a separate code
317
+ chip. This mirrors annotation labels and overrides Slidev's default inline
318
+ code background only within blockquotes. */
319
+ .slidev-layout blockquote :not(pre) > code {
320
+ background: transparent;
321
+ }
322
+
316
323
  /* Let a click-ranged annotation finish its exit fade before the content it
317
324
  replaces begins its own click transition. */
318
325
  .slidev-layout:has(.drawn-annotation.is-fading-out)