Skip to content

Commit d08777a

Browse files
whitphxCopilot
andauthored
Feature/animation (#149)
* Animate the arc path * Animate arrow heads but the polygon type * Animate polygon arrow head * Add changeset * Refactoring * Fix * Fix for merged code * Refactoring * fix * Add comment * Add animationDuration and animationDelay props and create examples * Fix animation on arc * fix * Set visibility style to the stroke paths as well * Improve style * Fix the default duration * Make the animation false by default * Fix per-path duration * fix * Update components/use-rough-arrow.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * fix * Fix regex * Update components/use-rough-arrow.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Refactoring * Fix copilot fix * Update components/split-path.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
1 parent a1329d5 commit d08777a

File tree

6 files changed

+252
-10
lines changed

6 files changed

+252
-10
lines changed

.changeset/silver-aliens-prove.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"slidev-addon-fancy-arrow": minor
3+
---
4+
5+
Animation

components/FancyArrow.vue

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,9 @@ const props = defineProps<{
2828
headSize?: number | string;
2929
roughness?: number | string;
3030
seed?: number | string;
31+
animated?: boolean;
32+
animationDuration?: number | string;
33+
animationDelay?: number | string;
3134
}>();
3235
3336
const root = ref<HTMLElement>();
@@ -87,6 +90,21 @@ const { arrowSvg, textPosition } = useRoughArrow({
8790
headSize: props.headSize ? Number(props.headSize) : null,
8891
roughness: props.roughness ? Number(props.roughness) : undefined,
8992
seed: props.seed ? Number(props.seed) : undefined,
93+
animation:
94+
props.animated || props.animationDuration || props.animationDelay
95+
? {
96+
duration:
97+
props.animationDuration != null
98+
? Number(props.animationDuration)
99+
: undefined,
100+
delay:
101+
props.animationDelay != null
102+
? Number(props.animationDelay)
103+
: undefined,
104+
}
105+
: undefined,
106+
strokeAnimationKeyframeName: "rough-arrow-dash",
107+
fillAnimationKeyframeName: "rough-arrow-fill",
90108
});
91109
</script>
92110

@@ -119,3 +137,25 @@ const { arrowSvg, textPosition } = useRoughArrow({
119137
</div>
120138
</div>
121139
</template>
140+
141+
<style>
142+
@keyframes rough-arrow-dash {
143+
from {
144+
/*
145+
We set visibility: hidden when constructing the SVG,
146+
which is necessary to hide unexpected fragments before starting animation,
147+
and we also want to make them visible right after starting animation.
148+
*/
149+
visibility: visible;
150+
}
151+
to {
152+
stroke-dashoffset: 0;
153+
visibility: visible;
154+
}
155+
}
156+
@keyframes rough-arrow-fill {
157+
to {
158+
visibility: visible;
159+
}
160+
}
161+
</style>

components/split-path.test.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { describe, it, expect } from "vitest";
2+
import { splitPathDefinition } from "./split-path";
3+
4+
describe("splitPathDefinition", () => {
5+
it("should split a path definition into segments", () => {
6+
const d = "M10 10 L20 20 M30 30 M-10 -10";
7+
const segments = splitPathDefinition(d);
8+
expect(segments).toEqual(["M10 10 L20 20", "M30 30", "M-10 -10"]);
9+
});
10+
});

components/split-path.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
export function splitPathDefinition(d: string): string[] {
2+
const segments = d.split(/(?=M(?![a-z]))/);
3+
return segments.map((s) => s.trim()).filter((s) => s !== "");
4+
}
5+
6+
export function splitPath(path: SVGPathElement): SVGPathElement[] {
7+
// Split <path d="M ... M ..." /> into <path d="M ..." /> elements.
8+
const d = path.getAttribute("d");
9+
if (!d) {
10+
return [];
11+
}
12+
return splitPathDefinition(d).map((segment) => {
13+
const newPath = path.cloneNode() as SVGPathElement;
14+
newPath.setAttribute("d", segment);
15+
return newPath;
16+
});
17+
}

components/use-rough-arrow.ts

Lines changed: 112 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
import { computed, type Ref } from "vue";
22
import roughjs from "roughjs";
3+
import { splitPath } from "./split-path";
34

45
type RoughSVG = ReturnType<typeof roughjs.svg>;
56

7+
const DEFAULT_ANIMATION_DURATION = 800; // Same as https://github.com/rough-stuff/rough-notation/blob/668ba82ac89c903d6f59c9351b9b85855da9882c/src/model.ts#L3C14-L3C47
8+
69
const createArrowHeadSvg = (
710
roughSvg: RoughSVG,
811
lineLength: number,
@@ -18,11 +21,18 @@ const createArrowHeadSvg = (
1821

1922
const g = document.createElementNS("http://www.w3.org/2000/svg", "g");
2023

24+
function addAllChildren(anotherGroup: SVGGElement) {
25+
// `for (... of anotherGroup.children)` doesn't work well: the second child and the latter will be discarded somehow.
26+
for (const child of Array.from(anotherGroup.children)) {
27+
g.appendChild(child);
28+
}
29+
}
30+
2131
if (type === "line") {
22-
g.appendChild(roughSvg.line(x1, y1, 0, 0, options));
23-
g.appendChild(roughSvg.line(x2, y2, 0, 0, options));
32+
addAllChildren(roughSvg.line(x1, y1, 0, 0, options));
33+
addAllChildren(roughSvg.line(x2, y2, 0, 0, options));
2434
} else if (type === "polygon") {
25-
g.appendChild(
35+
addAllChildren(
2636
roughSvg.polygon(
2737
[
2838
[x1, y1],
@@ -53,6 +63,12 @@ export function useRoughArrow(props: {
5363
seed?: number;
5464
twoWay: boolean;
5565
centerPositionParam: number;
66+
animation?: {
67+
duration?: number;
68+
delay?: number;
69+
};
70+
strokeAnimationKeyframeName: string;
71+
fillAnimationKeyframeName: string;
5672
}) {
5773
const {
5874
point1: point1Ref,
@@ -64,6 +80,9 @@ export function useRoughArrow(props: {
6480
seed,
6581
twoWay,
6682
centerPositionParam,
83+
animation,
84+
strokeAnimationKeyframeName,
85+
fillAnimationKeyframeName,
6786
} = props;
6887
const baseOptions = {
6988
// We don't support the `bowing` param because it's not so effective for arc.
@@ -105,7 +124,7 @@ export function useRoughArrow(props: {
105124
const angle =
106125
Math.atan2(point2.y - point1.y, point2.x - point1.x) - Math.PI / 2;
107126
return {
108-
svg,
127+
svgPath: svg.getElementsByTagName("path")[0],
109128
angle1: angle,
110129
angle2: angle,
111130
lineLength: Math.hypot(point2.x - point1.x, point2.y - point1.y),
@@ -191,7 +210,7 @@ export function useRoughArrow(props: {
191210
};
192211

193212
return {
194-
svg,
213+
svgPath: svg.getElementsByTagName("path")[0],
195214
angle1,
196215
angle2,
197216
lineLength: R * (endAngle - startAngle),
@@ -241,7 +260,7 @@ export function useRoughArrow(props: {
241260
`translate(${point2Ref.value.x},${point2Ref.value.y}) rotate(${(arcData.value.angle2 * 180) / Math.PI + (centerPositionParam >= 0 ? 90 : -90)})`,
242261
);
243262
if (!twoWay) {
244-
return { arrowHeadForwardSvg, arrowHeadBackwardSvg: null };
263+
return { arrowHeadForwardSvg, arrowHeadBackwardSvg: null, lineLength };
245264
}
246265

247266
const arrowHeadBackwardSvg = createArrowHeadSvg(
@@ -254,7 +273,7 @@ export function useRoughArrow(props: {
254273
"transform",
255274
`translate(${point1Ref.value.x},${point1Ref.value.y}) rotate(${(arcData.value.angle1 * 180) / Math.PI + (centerPositionParam >= 0 ? -90 : 90)})`,
256275
);
257-
return { arrowHeadBackwardSvg, arrowHeadForwardSvg };
276+
return { arrowHeadBackwardSvg, arrowHeadForwardSvg, lineLength };
258277
});
259278

260279
const arrowSvg = computed(() => {
@@ -264,17 +283,100 @@ export function useRoughArrow(props: {
264283
return null;
265284
}
266285

267-
g.appendChild(arcData.value.svg);
268-
286+
const arcPath = arcData.value.svgPath;
269287
const arrowHeadBackwardSvg = arrowHeadData.value.arrowHeadBackwardSvg;
270288
const arrowHeadForwardSvg = arrowHeadData.value.arrowHeadForwardSvg;
271289

290+
// RoughSVG.arc() may generate <path> element whose `d` attribute contains multiple segments like `M... M...`.
291+
// Such paths don't be animated as expected, so we split them into multiple <path> elements that only contain `d` with only one `M`
292+
// and animate them individually.
293+
const splitPaths = splitPath(arcPath);
294+
splitPaths.forEach((path) => g.appendChild(path));
272295
g.appendChild(arrowHeadForwardSvg);
273-
274296
if (arrowHeadBackwardSvg) {
275297
g.appendChild(arrowHeadBackwardSvg);
276298
}
277299

300+
if (animation) {
301+
interface AnimationSegment {
302+
length: number;
303+
strokedPaths: SVGPathElement[];
304+
filledPaths: SVGPathElement[];
305+
}
306+
const segments: AnimationSegment[] = [];
307+
308+
segments.push({
309+
length: arcData.value.lineLength,
310+
strokedPaths: splitPaths,
311+
filledPaths: [],
312+
});
313+
314+
function getArrowHeadAnimationSegment(
315+
arrowHeadG: SVGGElement,
316+
length: number,
317+
): AnimationSegment {
318+
const strokedPaths: SVGPathElement[] = [];
319+
const filledPaths: SVGPathElement[] = [];
320+
arrowHeadG.childNodes.forEach((child) => {
321+
if (child instanceof SVGPathElement) {
322+
const stroke = child.getAttribute("stroke");
323+
const fill = child.getAttribute("fill");
324+
if (stroke && stroke !== "none") {
325+
strokedPaths.push(child);
326+
} else if (fill && fill !== "none") {
327+
filledPaths.push(child);
328+
}
329+
}
330+
});
331+
return {
332+
strokedPaths: strokedPaths,
333+
filledPaths: filledPaths,
334+
length,
335+
};
336+
}
337+
338+
segments.push(
339+
getArrowHeadAnimationSegment(
340+
arrowHeadForwardSvg,
341+
arrowHeadData.value.lineLength * 2,
342+
),
343+
);
344+
if (arrowHeadBackwardSvg) {
345+
segments.push(
346+
getArrowHeadAnimationSegment(
347+
arrowHeadBackwardSvg,
348+
arrowHeadData.value.lineLength * 2,
349+
),
350+
);
351+
}
352+
353+
const totalLength = segments
354+
.map((s) => s.length)
355+
.reduce((a, b) => a + b, 0);
356+
357+
const { duration = DEFAULT_ANIMATION_DURATION, delay = 0 } = animation;
358+
let currentDelay = delay;
359+
// Animation impl inspired by https://github.com/rough-stuff/rough-notation/blob/668ba82ac89c903d6f59c9351b9b85855da9882c/src/render.ts#L222-L235
360+
for (const segment of segments) {
361+
const segmentDuration = (segment.length / totalLength) * duration;
362+
const pathDuration = segmentDuration / segment.strokedPaths.length;
363+
segment.strokedPaths.forEach((path, index) => {
364+
const pathDelay =
365+
currentDelay +
366+
(index / segment.strokedPaths.length) * segmentDuration;
367+
path.style.animation = `${strokeAnimationKeyframeName} ${pathDuration}ms ease-out ${pathDelay}ms forwards`;
368+
path.style.strokeDashoffset = `${segment.length}`;
369+
path.style.strokeDasharray = `${segment.length}`;
370+
path.style.visibility = "hidden";
371+
});
372+
currentDelay += segmentDuration;
373+
segment.filledPaths.forEach((path) => {
374+
path.style.animation = `${fillAnimationKeyframeName} ${segmentDuration}ms ease-out ${currentDelay}ms forwards`;
375+
path.style.visibility = "hidden";
376+
});
377+
}
378+
}
379+
278380
return g.innerHTML;
279381
});
280382

slides.md

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -560,6 +560,74 @@ If no snap target or absolute position is specified, the arrow will automaticall
560560

561561
---
562562

563+
# Animation
564+
565+
<div grid="~ cols-3 gap-4" mt-6 h-85>
566+
567+
<div bg-gray:10 p-4 border="~ gray/50 rounded-lg" flex="~ col">
568+
569+
### Animated
570+
571+
<FancyArrow
572+
animated
573+
x1="120" y1="200" x2="260" y2="280"
574+
/>
575+
576+
<div grow-1><!-- Placeholder--></div>
577+
578+
```html {2}
579+
<FancyArrow
580+
animated
581+
x1="120" y1="200" x2="260" y2="280"
582+
/>
583+
```
584+
585+
</div>
586+
587+
<div bg-gray:10 p-4 border="~ gray/50 rounded-lg" flex="~ col">
588+
589+
### Custom duration
590+
591+
<FancyArrow
592+
animationDuration="3000"
593+
x1="420" y1="200" x2="560" y2="280"
594+
/>
595+
596+
<div grow-1><!-- Placeholder--></div>
597+
598+
```html {2}
599+
<FancyArrow
600+
animationDuration="3000"
601+
x1="420" y1="200" x2="560" y2="280"
602+
/>
603+
```
604+
605+
</div>
606+
607+
<div bg-gray:10 p-4 border="~ gray/50 rounded-lg" flex="~ col">
608+
609+
### Custom delay
610+
611+
<FancyArrow
612+
animationDelay="1000"
613+
x1="720" y1="200" x2="860" y2="280"
614+
/>
615+
616+
<div grow-1><!-- Placeholder--></div>
617+
618+
```html {2}
619+
<FancyArrow
620+
animationDelay="1000"
621+
x1="720" y1="200" x2="860" y2="280"
622+
/>
623+
```
624+
625+
</div>
626+
627+
</div>
628+
629+
---
630+
563631
# Contents on the arrow
564632

565633
<FancyArrow x1="100" y1="100" x2="200" y2="200" >

0 commit comments

Comments
 (0)