From 5f536ebec8099247e9dfade7579a6dbe2fa0a78d Mon Sep 17 00:00:00 2001 From: "Yuichiro Tachibana (Tsuchiya)" Date: Tue, 12 Aug 2025 14:15:45 +0900 Subject: [PATCH 01/14] Percentage values for endpoint positions --- components/parse-option.test.ts | 28 +++++++++++++++-- components/parse-option.ts | 48 ++++++++++++++++++++---------- components/use-element-position.ts | 38 ++++++++++++++++++----- 3 files changed, 89 insertions(+), 25 deletions(-) diff --git a/components/parse-option.test.ts b/components/parse-option.test.ts index 525d598..1066b25 100644 --- a/components/parse-option.test.ts +++ b/components/parse-option.test.ts @@ -21,8 +21,32 @@ describe("parsePosition", () => { // 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%) ", + ] as const + ).forEach((optionString) => { + it(`parses string with absolute 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: "%" }, }); }); }); diff --git a/components/parse-option.ts b/components/parse-option.ts index c28e7c1..ec33aab 100644 --- a/components/parse-option.ts +++ b/components/parse-option.ts @@ -1,32 +1,44 @@ import type { SnapPosition } from "./use-element-position"; -import type { AbsolutePosition } from "./use-rough-arrow"; export interface SnapTarget { query: string; snapPosition: SnapPosition | undefined; } -const absolutePositionRegex = /^\(\s*(\d+)\s*,\s*(\d+)\s*\)$/; +export interface LengthPercentage { + value: number; + unit: "px" | "%"; +} +export interface Position { + x: LengthPercentage; + y: LengthPercentage; +} + +const positionRegex = + /^\(\s*(?[+-]?\d+)(?%|px)?\s*,\s*(?[+-]?\d+)(?%|px)?\s*\)$/; const snapTargetRegex = /^(\S+?)(@(\S+?))?$/; /** * 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 positionMatch = arrowEndpointShorthand.match(positionRegex); + if (positionMatch) { + const xValue = parseInt(positionMatch.groups?.xValue ?? "0", 10); + const xUnit = (positionMatch.groups?.xUnit ?? "px") as "px" | "%"; + const yValue = parseInt(positionMatch.groups?.yValue ?? "0", 10); + const yUnit = (positionMatch.groups?.yUnit ?? "px") as "px" | "%"; + return { + x: { value: xValue, unit: xUnit }, + y: { value: yValue, unit: yUnit }, + }; } const snapTargetMatch = arrowEndpointShorthand.match(snapTargetRegex); @@ -49,7 +61,7 @@ interface ArrowEndpointProps { } export function compileArrowEndpointProps( props: ArrowEndpointProps, -): SnapTarget | AbsolutePosition | undefined { +): SnapTarget | Position | undefined { if (props.shorthand) { try { return parseArrowEndpointShorthand(props.shorthand); @@ -75,8 +87,14 @@ export function compileArrowEndpointProps( if (props.x != undefined || props.y != undefined) { return { - x: Number(props.x ?? 0), - y: Number(props.y ?? 0), + x: { + value: Number(props.x ?? 0), + unit: "px", + }, + y: { + value: Number(props.y ?? 0), + unit: "px", + }, }; } diff --git a/components/use-element-position.ts b/components/use-element-position.ts index b0bdbeb..700bf58 100644 --- a/components/use-element-position.ts +++ b/components/use-element-position.ts @@ -6,9 +6,14 @@ import { onWatcherCleanup, type Ref, } from "vue"; -import { useSlideContext, useIsSlideActive } from "@slidev/client"; -import { AbsolutePosition } from "./use-rough-arrow"; -import { SnapTarget } from "./parse-option"; +import { + useSlideContext, + useIsSlideActive, + slideWidth, + slideHeight, +} from "@slidev/client"; +import type { AbsolutePosition } from "./use-rough-arrow"; +import type { SnapTarget, Position, LengthPercentage } from "./parse-option"; export type SnapPosition = | "top" @@ -20,10 +25,24 @@ export type SnapPosition = | "bottomleft" | "bottomright"; +function getAbsoluteValue( + lengthPercentage: LengthPercentage, + total: number, +): number { + if (lengthPercentage.unit === "px") { + return lengthPercentage.value; + } else if (lengthPercentage.unit === "%") { + return (lengthPercentage.value / 100) * total; + } else { + console.warn(`Unknown length percentage unit: ${lengthPercentage.unit}`); + return 0; + } +} + export function useEndpointResolution( slideContainerRef: Ref, rootElementRef: Ref, - endpointRef: Ref, + endpointRef: Ref, fallbackOption: { self: Ref; direction: "next" | "prev"; @@ -51,7 +70,7 @@ export function useEndpointResolution( }; } if (!("query" in endpoint)) { - // endpoint is AbsolutePosition + // endpoint is of type Position // so we don't need to resolve the element. return undefined; } @@ -65,7 +84,7 @@ export function useEndpointResolution( const point = ref(undefined); - // Sync endpointRef -> point in case where endpoint is AbsolutePosition + // Sync endpointRef -> point in case where endpoint is Position watch( endpointRef, (endpoint) => { @@ -73,7 +92,10 @@ export function useEndpointResolution( point.value = undefined; return; } else if ("x" in endpoint) { - point.value = { x: endpoint.x, y: endpoint.y }; + point.value = { + x: getAbsoluteValue(endpoint.x, slideWidth.value), + y: getAbsoluteValue(endpoint.y, slideHeight.value), + }; return; } }, @@ -83,7 +105,7 @@ export function useEndpointResolution( // Sync snappedElementInfo -> point in case where endpoint is SnapTarget const updateSnappedPosition = () => { if (!snappedElementInfo.value) { - // This case means endpoint is AbsolutePosition + // This case means endpoint is of type Position // so we don't need to update point in this method // as it's done in the watch above. return; From efab04ee92b7a23ddb5c1e8acc3dcb868a521fd0 Mon Sep 17 00:00:00 2001 From: "Yuichiro Tachibana (Tsuchiya)" Date: Tue, 12 Aug 2025 15:36:12 +0900 Subject: [PATCH 02/14] Percentage values for snap position --- components/parse-option.test.ts | 7 ++-- components/parse-option.ts | 52 +++++++++++++++++++----------- components/use-element-position.ts | 23 +++++++------ 3 files changed, 52 insertions(+), 30 deletions(-) diff --git a/components/parse-option.test.ts b/components/parse-option.test.ts index 1066b25..b71e123 100644 --- a/components/parse-option.test.ts +++ b/components/parse-option.test.ts @@ -15,9 +15,10 @@ 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({ @@ -26,6 +27,7 @@ describe("parsePosition", () => { }); }); }); + ( [ "(10%,20%)", @@ -39,9 +41,10 @@ describe("parsePosition", () => { " (10% ,20%) ", " (10%, 20%) ", " (10% , 20%) ", + "(+10%,+20%)", ] as const ).forEach((optionString) => { - it(`parses string with absolute option correctly: "${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({ diff --git a/components/parse-option.ts b/components/parse-option.ts index ec33aab..ac751c5 100644 --- a/components/parse-option.ts +++ b/components/parse-option.ts @@ -1,10 +1,5 @@ import type { SnapPosition } from "./use-element-position"; -export interface SnapTarget { - query: string; - snapPosition: SnapPosition | undefined; -} - export interface LengthPercentage { value: number; unit: "px" | "%"; @@ -14,9 +9,30 @@ export interface Position { y: LengthPercentage; } +export interface SnapTarget { + query: string; + snapPosition: SnapPosition | Position | undefined; +} + const positionRegex = /^\(\s*(?[+-]?\d+)(?%|px)?\s*,\s*(?[+-]?\d+)(?%|px)?\s*\)$/; -const snapTargetRegex = /^(\S+?)(@(\S+?))?$/; +const snapTargetRegex = /^(?\S+?)(@(?\S+?))?$/; + +function parsePosition(positionString: string): Position | undefined { + const positionMatch = positionString.match(positionRegex); + if (!positionMatch) { + return undefined; + } + + const xValue = parseInt(positionMatch.groups?.xValue ?? "0", 10); + const xUnit = (positionMatch.groups?.xUnit ?? "px") as "px" | "%"; + const yValue = parseInt(positionMatch.groups?.yValue ?? "0", 10); + const yUnit = (positionMatch.groups?.yUnit ?? "px") as "px" | "%"; + return { + x: { value: xValue, unit: xUnit }, + y: { value: yValue, unit: yUnit }, + }; +} /** * The `arrowEndpointShorthand` can be in the format of a CSS selector with a snap position, @@ -29,23 +45,21 @@ export function parseArrowEndpointShorthand( ): SnapTarget | Position { arrowEndpointShorthand = arrowEndpointShorthand.trim(); - const positionMatch = arrowEndpointShorthand.match(positionRegex); - if (positionMatch) { - const xValue = parseInt(positionMatch.groups?.xValue ?? "0", 10); - const xUnit = (positionMatch.groups?.xUnit ?? "px") as "px" | "%"; - const yValue = parseInt(positionMatch.groups?.yValue ?? "0", 10); - const yUnit = (positionMatch.groups?.yUnit ?? "px") as "px" | "%"; - return { - x: { value: xValue, unit: xUnit }, - y: { value: yValue, unit: yUnit }, - }; + 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 + ? (parsePosition(snapPosition) ?? (snapPosition as SnapPosition)) + : undefined, + }; } throw new Error(`Invalid arrow endpoint format: ${arrowEndpointShorthand}`); diff --git a/components/use-element-position.ts b/components/use-element-position.ts index 700bf58..e139b9e 100644 --- a/components/use-element-position.ts +++ b/components/use-element-position.ts @@ -125,15 +125,20 @@ export function useEndpointResolution( const width = rect.width / $scale.value; const height = rect.height / $scale.value; - if (snapPosition?.includes("right")) { - x += width; - } else if (!snapPosition?.includes("left")) { - x += width / 2; - } - if (snapPosition?.includes("bottom")) { - y += height; - } else if (!snapPosition?.includes("top")) { - y += height / 2; + if (typeof snapPosition === "string" || snapPosition == null) { + if (snapPosition?.includes("right")) { + x += width; + } else if (!snapPosition?.includes("left")) { + x += width / 2; + } + if (snapPosition?.includes("bottom")) { + y += height; + } else if (!snapPosition?.includes("top")) { + y += height / 2; + } + } else if (typeof snapPosition === "object") { + x += getAbsoluteValue(snapPosition.x, width); + y += getAbsoluteValue(snapPosition.y, height); } if (point.value?.x !== x || point.value?.y !== y) { From 22d0bf7ddeefdf9936cc8bb1397927c0bcab2a69 Mon Sep 17 00:00:00 2001 From: "Yuichiro Tachibana (Tsuchiya)" Date: Wed, 13 Aug 2025 00:26:16 +0900 Subject: [PATCH 03/14] Add test cases --- components/parse-option.test.ts | 42 +++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/components/parse-option.test.ts b/components/parse-option.test.ts index b71e123..3bc78f8 100644 --- a/components/parse-option.test.ts +++ b/components/parse-option.test.ts @@ -62,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}"`, () => { From 7360d279ff598e3bf91e7c0c473e697a3d1dc3db Mon Sep 17 00:00:00 2001 From: "Yuichiro Tachibana (Tsuchiya)" Date: Wed, 13 Aug 2025 00:45:07 +0900 Subject: [PATCH 04/14] Update slides.md --- slides.md | 46 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/slides.md b/slides.md index b1c2eea..fb88db4 100644 --- a/slides.md +++ b/slides.md @@ -628,6 +628,52 @@ If no snap target or absolute position is specified, the arrow will automaticall --- +# Specify the snapping point by position values + +
+ +
+ +🗼 + + + +🏘️ + + + +```html {2,8} + + +``` + +--- + # Contents on the arrow From 3f1a0defd142c10360eed2368a9bc5cdeaf0969e Mon Sep 17 00:00:00 2001 From: "Yuichiro Tachibana (Tsuchiya)" Date: Wed, 13 Aug 2025 00:47:01 +0900 Subject: [PATCH 05/14] Add changeset --- .changeset/clever-moments-judge.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/clever-moments-judge.md diff --git a/.changeset/clever-moments-judge.md b/.changeset/clever-moments-judge.md new file mode 100644 index 0000000..23b3473 --- /dev/null +++ b/.changeset/clever-moments-judge.md @@ -0,0 +1,5 @@ +--- +"slidev-addon-fancy-arrow": minor +--- + +Snapping points specified by position values From f7296c6c1e1e3c8871fba2e4eb7ba8fb65ab9eb1 Mon Sep 17 00:00:00 2001 From: "Yuichiro Tachibana (Tsuchiya)" Date: Wed, 13 Aug 2025 00:58:38 +0900 Subject: [PATCH 06/14] Make pos1 and pos2 accept position values --- components/parse-option.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/components/parse-option.ts b/components/parse-option.ts index ac751c5..f3989da 100644 --- a/components/parse-option.ts +++ b/components/parse-option.ts @@ -88,14 +88,18 @@ export function compileArrowEndpointProps( if (props.q) { return { query: props.q, - snapPosition: props.pos, + snapPosition: props.pos + ? (parsePosition(props.pos) ?? props.pos) + : undefined, }; } if (props.id) { // Deprecated return { query: `#${props.id}`, - snapPosition: props.pos, + snapPosition: props.pos + ? (parsePosition(props.pos) ?? props.pos) + : undefined, }; } From e4fac9378d9557ffbab4ba3be58412a8c92955bc Mon Sep 17 00:00:00 2001 From: "Yuichiro Tachibana (Tsuchiya)" Date: Wed, 13 Aug 2025 01:13:03 +0900 Subject: [PATCH 07/14] Refactoring --- components/FancyArrow.vue | 7 ++---- components/parse-option.ts | 35 +++++++++++++++++++++--------- components/use-element-position.ts | 10 --------- 3 files changed, 27 insertions(+), 25 deletions(-) diff --git a/components/FancyArrow.vue b/components/FancyArrow.vue index 8352d68..812f7f5 100644 --- a/components/FancyArrow.vue +++ b/components/FancyArrow.vue @@ -1,10 +1,7 @@