Skip to content

Feature/position values to specify the arrow head positions and snapping points #158

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 14 commits into from
Aug 12, 2025
Merged
5 changes: 5 additions & 0 deletions .changeset/clever-moments-judge.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"slidev-addon-fancy-arrow": minor
---

Arrow head positions and snapping points specified by absolute or percentage values
12 changes: 6 additions & 6 deletions components/FancyArrow.vue
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
<script setup lang="ts">
import { ref, computed, type Ref } from "vue";
import { compileArrowEndpointProps } from "./parse-option";
import {
useEndpointResolution,
type SnapPosition,
} from "./use-element-position";
compileArrowEndpointProps,
type SnapAnchorPoint,
} from "./parse-option";
import { useEndpointResolution } from "./use-element-position";
import { useRoughArrow, type AbsolutePosition } from "./use-rough-arrow";

const props = defineProps<{
Expand All @@ -14,8 +14,8 @@ const props = defineProps<{
q2?: string;
id1?: string; // Deprecated
id2?: string; // Deprecated
pos1?: SnapPosition;
pos2?: SnapPosition;
pos1?: SnapAnchorPoint;
pos2?: SnapAnchorPoint;
x1?: number | string;
y1?: number | string;
x2?: number | string;
Expand Down
75 changes: 72 additions & 3 deletions components/parse-option.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,41 @@ describe("parsePosition", () => {
" (100 ,200) ",
" (100, 200) ",
" (100 , 200) ",
"(+100,+200)",
] as const
).forEach((optionString) => {
it(`parses string with absolute option correctly: "${optionString}"`, () => {
it(`parses a string with an absolute position correctly: "${optionString}"`, () => {
// Assuming parsePosition is a function that processes the options
const parsed = parseArrowEndpointShorthand(optionString);
expect(parsed).toEqual({
x: 100,
y: 200,
x: { value: 100, unit: "px" },
y: { value: 200, unit: "px" },
});
});
});

(
[
"(10%,20%)",
"(10%, 20%)",
"(10% ,20%)",
"(10% , 20%)",
"( 10%,20%)",
" (10%,20%)",
"(10%,20%) ",
" (10%,20%) ",
" (10% ,20%) ",
" (10%, 20%) ",
" (10% , 20%) ",
"(+10%,+20%)",
] as const
).forEach((optionString) => {
it(`parses a string with a relative option correctly: "${optionString}"`, () => {
// Assuming parsePosition is a function that processes the options
const parsed = parseArrowEndpointShorthand(optionString);
expect(parsed).toEqual({
x: { value: 10, unit: "%" },
y: { value: 20, unit: "%" },
});
});
});
Expand All @@ -35,6 +62,48 @@ describe("parsePosition", () => {
["#target@left", ["#target", "left"]],
[".target@left", [".target", "left"]],
["[data-id=target]@left", ["[data-id=target]", "left"]],
[
"#target@(100,200)",
[
"#target",
{ x: { value: 100, unit: "px" }, y: { value: 200, unit: "px" } },
],
],
[
".target@(100,200)",
[
".target",
{ x: { value: 100, unit: "px" }, y: { value: 200, unit: "px" } },
],
],
[
"[data-id=target]@(10%,20%)",
[
"[data-id=target]",
{ x: { value: 10, unit: "%" }, y: { value: 20, unit: "%" } },
],
],
[
"#target@(10%,20%)",
[
"#target",
{ x: { value: 10, unit: "%" }, y: { value: 20, unit: "%" } },
],
],
[
".target@(10%,20%)",
[
".target",
{ x: { value: 10, unit: "%" }, y: { value: 20, unit: "%" } },
],
],
[
"[data-id=target]@(10%,20%)",
[
"[data-id=target]",
{ x: { value: 10, unit: "%" }, y: { value: 20, unit: "%" } },
],
],
] as const
).forEach(([optionString, [expectedQuery, expectedSnapPosition]]) => {
it(`parses string with snap target CSS selector correctly: "${optionString}"`, () => {
Expand Down
117 changes: 93 additions & 24 deletions components/parse-option.ts
Original file line number Diff line number Diff line change
@@ -1,39 +1,98 @@
import type { SnapPosition } from "./use-element-position";
import type { AbsolutePosition } from "./use-rough-arrow";
export const SNAP_ANCHOR_POINTS = [
"center",
"top",
"bottom",
"left",
"right",
"topleft",
"topright",
"bottomleft",
"bottomright",
] as const;
export type SnapAnchorPoint = (typeof SNAP_ANCHOR_POINTS)[number];

export interface LengthPercentage {
value: number;
unit: "px" | "%";
}
export interface Position {
x: LengthPercentage;
y: LengthPercentage;
}

export interface SnapTarget {
query: string;
snapPosition: SnapPosition | undefined;
snapPosition: SnapAnchorPoint | Position | undefined;
}

const absolutePositionRegex = /^\(\s*(\d+)\s*,\s*(\d+)\s*\)$/;
const snapTargetRegex = /^(\S+?)(@(\S+?))?$/;
const ZERO_LENGTH_PERCENTAGE: LengthPercentage = { value: 0, unit: "px" };

const lengthPercentageRegex = /(?<value>[+-]?\d+)(?<unit>%|px)?/;
const positionRegex = /^\(\s*(?<x>\S+)\s*,\s*(?<y>\S+)\s*\)$/;
const snapTargetRegex = /^(?<query>\S+?)(@(?<snapPosition>\S+?))?$/;

function parseLengthPercentage(
lengthString: string,
): LengthPercentage | undefined {
const match = lengthString.match(lengthPercentageRegex);
if (!match) {
return undefined;
}
const value = parseInt(match.groups?.value ?? "0", 10);
const unit = (match.groups?.unit ?? "px") as "px" | "%";
return { value, unit };
}

function parsePosition(positionString: string): Position | undefined {
const positionMatch = positionString.match(positionRegex);
if (!positionMatch) {
return undefined;
}

const x =
parseLengthPercentage(positionMatch.groups!.x) ?? ZERO_LENGTH_PERCENTAGE;
const y =
parseLengthPercentage(positionMatch.groups!.y) ?? ZERO_LENGTH_PERCENTAGE;
return {
x,
y,
};
}

function parseSnapPosition(
snapPositionString: string,
): SnapAnchorPoint | Position | undefined {
if ((SNAP_ANCHOR_POINTS as readonly string[]).includes(snapPositionString)) {
return snapPositionString as SnapAnchorPoint;
}

return parsePosition(snapPositionString);
}

/**
* The `arrowEndpointShorthand` can be in the format of a CSS selector with a snap position,
* or an absolute position in the format "(x,y)".
* or a position in the format "(x,y)".
* - For example, "[data-id=snap-target]" or "[data-id=snap-target]@left".
* - Or an absolute position like "(100,200)".
* - Or a position like "(100,200)", "(100px,200px)", or (10%,20%).
*/
export function parseArrowEndpointShorthand(
arrowEndpointShorthand: string,
): SnapTarget | AbsolutePosition {
): SnapTarget | Position {
arrowEndpointShorthand = arrowEndpointShorthand.trim();

const absolutePositionMatch = arrowEndpointShorthand.match(
absolutePositionRegex,
);
if (absolutePositionMatch) {
const x = parseInt(absolutePositionMatch[1], 10);
const y = parseInt(absolutePositionMatch[2], 10);
return { x, y };
const position = parsePosition(arrowEndpointShorthand);
if (position) {
return position;
}

const snapTargetMatch = arrowEndpointShorthand.match(snapTargetRegex);
if (snapTargetMatch) {
const query = snapTargetMatch[1];
const snapPosition = snapTargetMatch[3] as SnapPosition | undefined;
return { query, snapPosition };
const query = snapTargetMatch.groups!.query;
const snapPosition = snapTargetMatch.groups?.snapPosition;
return {
query,
snapPosition: snapPosition ? parseSnapPosition(snapPosition) : undefined,
};
}

throw new Error(`Invalid arrow endpoint format: ${arrowEndpointShorthand}`);
Expand All @@ -43,13 +102,13 @@ interface ArrowEndpointProps {
shorthand?: string;
q?: string;
id?: string; // Deprecated
pos?: SnapPosition;
pos?: SnapAnchorPoint;
x?: number | string;
y?: number | string;
}
export function compileArrowEndpointProps(
props: ArrowEndpointProps,
): SnapTarget | AbsolutePosition | undefined {
): SnapTarget | Position | undefined {
if (props.shorthand) {
try {
return parseArrowEndpointShorthand(props.shorthand);
Expand All @@ -62,21 +121,31 @@ export function compileArrowEndpointProps(
if (props.q) {
return {
query: props.q,
snapPosition: props.pos,
snapPosition: props.pos ? parseSnapPosition(props.pos) : undefined,
};
}
if (props.id) {
// Deprecated
return {
query: `#${props.id}`,
snapPosition: props.pos,
snapPosition: props.pos ? parseSnapPosition(props.pos) : undefined,
};
}

if (props.x != undefined || props.y != undefined) {
return {
x: Number(props.x ?? 0),
y: Number(props.y ?? 0),
x:
typeof props.x === "number"
? { value: props.x, unit: "px" }
: typeof props.x === "string"
? (parseLengthPercentage(props.x) ?? ZERO_LENGTH_PERCENTAGE)
: ZERO_LENGTH_PERCENTAGE,
y:
typeof props.y === "number"
? { value: props.y, unit: "px" }
: typeof props.y === "string"
? (parseLengthPercentage(props.y) ?? ZERO_LENGTH_PERCENTAGE)
: ZERO_LENGTH_PERCENTAGE,
};
}

Expand Down
Loading