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
|
-
|
|
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
|
-
//
|
|
778
|
-
//
|
|
779
|
-
//
|
|
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.
|
|
810
|
-
*
|
|
811
|
-
*
|
|
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
|
|
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(
|
|
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
|
|
1713
|
-
//
|
|
1714
|
-
|
|
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(() =>
|
|
1717
|
-
|
|
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
|
|
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
|
-
|
|
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
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)
|