import { getBoxToBoxArrow } from "perfect-arrows"; import { getStroke, type StrokeOptions } from "perfect-freehand"; import { FolkElement } from "./folk-element"; import { css } from "./tags"; // Point interface for bezier curves interface Point { x: number; y: number; } // Utility functions for bezier curve rendering function lerp(a: Point, b: Point, t: number): Point { return { x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t }; } function distanceSquared(a: Point, b: Point): number { const dx = b.x - a.x; const dy = b.y - a.y; return dx * dx + dy * dy; } function distance(a: Point, b: Point): number { return Math.sqrt(distanceSquared(a, b)); } function flatness(points: readonly Point[], offset: number): number { const p1 = points[offset + 0]; const p2 = points[offset + 1]; const p3 = points[offset + 2]; const p4 = points[offset + 3]; let ux = 3 * p2.x - 2 * p1.x - p4.x; ux *= ux; let uy = 3 * p2.y - 2 * p1.y - p4.y; uy *= uy; let vx = 3 * p3.x - 2 * p4.x - p1.x; vx *= vx; let vy = 3 * p3.y - 2 * p4.y - p1.y; vy *= vy; if (ux < vx) ux = vx; if (uy < vy) uy = vy; return ux + uy; } function getPointsOnBezierCurveWithSplitting( points: readonly Point[], offset: number, tolerance: number, outPoints: Point[] = [], ): Point[] { if (flatness(points, offset) < tolerance) { const p0 = points[offset + 0]; if (outPoints.length) { const d = distance(outPoints[outPoints.length - 1], p0); if (d > 1) { outPoints.push(p0); } } else { outPoints.push(p0); } outPoints.push(points[offset + 3]); } else { const t = 0.5; const p1 = points[offset + 0]; const p2 = points[offset + 1]; const p3 = points[offset + 2]; const p4 = points[offset + 3]; const q1 = lerp(p1, p2, t); const q2 = lerp(p2, p3, t); const q3 = lerp(p3, p4, t); const r1 = lerp(q1, q2, t); const r2 = lerp(q2, q3, t); const red = lerp(r1, r2, t); getPointsOnBezierCurveWithSplitting([p1, q1, r1, red], 0, tolerance, outPoints); getPointsOnBezierCurveWithSplitting([red, r2, q3, p4], 0, tolerance, outPoints); } return outPoints; } function pointsOnBezierCurves(points: readonly Point[], tolerance: number = 0.15): Point[] { const newPoints: Point[] = []; const numSegments = (points.length - 1) / 3; for (let i = 0; i < numSegments; i++) { const offset = i * 3; getPointsOnBezierCurveWithSplitting(points, offset, tolerance, newPoints); } return newPoints; } function getSvgPathFromStroke(stroke: number[][]): string { if (stroke.length === 0) return ""; for (const point of stroke) { point[0] = Math.round(point[0] * 100) / 100; point[1] = Math.round(point[1] * 100) / 100; } const d = stroke.reduce( (acc, [x0, y0], i, arr) => { const [x1, y1] = arr[(i + 1) % arr.length]; acc.push(x0, y0, (x0 + x1) / 2, (y0 + y1) / 2); return acc; }, ["M", ...stroke[0], "Q"] as (string | number)[], ); d.push("Z"); return d.join(" "); } const styles = css` :host { display: block; position: absolute; inset: 0; pointer-events: none; z-index: -1; } `; declare global { interface HTMLElementTagNameMap { "folk-arrow": FolkArrow; } } export type ArrowStyle = "smooth" | "straight" | "curved" | "sketchy"; export class FolkArrow extends FolkElement { static override tagName = "folk-arrow"; static styles = styles; #sourceSelector: string = ""; #targetSelector: string = ""; #sourceElement: Element | null = null; #targetElement: Element | null = null; #sourceRect: DOMRect | null = null; #targetRect: DOMRect | null = null; #resizeObserver: ResizeObserver; #color: string = "#374151"; #strokeWidth: number = 3; #arrowStyle: ArrowStyle = "smooth"; #options: StrokeOptions = { size: 7, thinning: 0.5, smoothing: 0.5, streamline: 0.5, simulatePressure: true, easing: (t) => t, start: { taper: 50, easing: (t) => t, cap: true, }, end: { taper: 0, easing: (t) => t, cap: true, }, }; constructor() { super(); this.#resizeObserver = new ResizeObserver(() => this.#updateArrow()); } get source() { return this.#sourceSelector; } set source(value: string) { this.#sourceSelector = value; this.#observeSource(); this.requestUpdate("source"); } get target() { return this.#targetSelector; } set target(value: string) { this.#targetSelector = value; this.#observeTarget(); this.requestUpdate("target"); } get sourceId() { return this.#sourceSelector.replace("#", ""); } set sourceId(value: string) { this.source = `#${value}`; } get targetId() { return this.#targetSelector.replace("#", ""); } set targetId(value: string) { this.target = `#${value}`; } get color() { return this.#color; } set color(value: string) { this.#color = value; this.#updateArrow(); } get strokeWidth() { return this.#strokeWidth; } set strokeWidth(value: number) { this.#strokeWidth = value; this.#options.size = value * 2 + 1; this.#updateArrow(); } get arrowStyle(): ArrowStyle { return this.#arrowStyle; } set arrowStyle(value: ArrowStyle) { if (value === this.#arrowStyle) return; this.#arrowStyle = value; this.setAttribute("arrow-style", value); this.#updateArrow(); } override connectedCallback() { super.connectedCallback(); // Parse attributes const sourceAttr = this.getAttribute("source"); const targetAttr = this.getAttribute("target"); const colorAttr = this.getAttribute("color"); const strokeAttr = this.getAttribute("stroke-width"); const styleAttr = this.getAttribute("arrow-style"); if (sourceAttr) this.source = sourceAttr; if (targetAttr) this.target = targetAttr; if (colorAttr) this.#color = colorAttr; if (strokeAttr) this.strokeWidth = parseFloat(strokeAttr); if (styleAttr) this.#arrowStyle = styleAttr as ArrowStyle; // Start animation frame loop for position updates this.#startPositionTracking(); } override disconnectedCallback() { super.disconnectedCallback(); this.#resizeObserver.disconnect(); this.#stopPositionTracking(); } #animationFrameId: number | null = null; #startPositionTracking() { const track = () => { this.#updateRects(); this.#updateArrow(); this.#animationFrameId = requestAnimationFrame(track); }; this.#animationFrameId = requestAnimationFrame(track); } #stopPositionTracking() { if (this.#animationFrameId !== null) { cancelAnimationFrame(this.#animationFrameId); this.#animationFrameId = null; } } #observeSource() { if (this.#sourceElement) { this.#resizeObserver.unobserve(this.#sourceElement); } if (this.#sourceSelector) { this.#sourceElement = document.querySelector(this.#sourceSelector); if (this.#sourceElement) { this.#resizeObserver.observe(this.#sourceElement); } } } #observeTarget() { if (this.#targetElement) { this.#resizeObserver.unobserve(this.#targetElement); } if (this.#targetSelector) { this.#targetElement = document.querySelector(this.#targetSelector); if (this.#targetElement) { this.#resizeObserver.observe(this.#targetElement); } } } #updateRects() { if (this.#sourceElement) { this.#sourceRect = this.#sourceElement.getBoundingClientRect(); } if (this.#targetElement) { this.#targetRect = this.#targetElement.getBoundingClientRect(); } } #svg: SVGSVGElement | null = null; #updateArrow() { if (!this.#sourceRect || !this.#targetRect) { this.style.clipPath = ""; this.style.display = "none"; if (this.#svg) this.#svg.innerHTML = ""; return; } this.style.display = ""; const [sx, sy, cx, cy, ex, ey] = getBoxToBoxArrow( this.#sourceRect.x, this.#sourceRect.y, this.#sourceRect.width, this.#sourceRect.height, this.#targetRect.x, this.#targetRect.y, this.#targetRect.width, this.#targetRect.height, ); if (this.#arrowStyle === "smooth") { // Original behavior: perfect-freehand tapered stroke via clipPath if (this.#svg) this.#svg.style.display = "none"; this.style.clipPath = ""; this.style.backgroundColor = ""; const points = pointsOnBezierCurves([ { x: sx, y: sy }, { x: cx, y: cy }, { x: ex, y: ey }, { x: ex, y: ey }, ]); const stroke = getStroke(points, this.#options); const path = getSvgPathFromStroke(stroke); this.style.clipPath = `path('${path}')`; this.style.backgroundColor = this.#color; } else { // SVG-based rendering for other styles this.style.clipPath = ""; this.style.backgroundColor = ""; if (!this.#svg) return; this.#svg.style.display = ""; const sw = this.#strokeWidth; let pathD = ""; let arrowheadPoints = ""; if (this.#arrowStyle === "straight") { pathD = `M ${sx} ${sy} L ${ex} ${ey}`; } else if (this.#arrowStyle === "curved") { pathD = `M ${sx} ${sy} Q ${cx} ${cy} ${ex} ${ey}`; } else if (this.#arrowStyle === "sketchy") { // Jitter the control point for hand-drawn feel const jitter = () => (Math.random() - 0.5) * 12; const jcx = cx + jitter(); const jcy = cy + jitter(); pathD = `M ${sx + jitter() * 0.3} ${sy + jitter() * 0.3} Q ${jcx} ${jcy} ${ex} ${ey}`; } // Compute arrowhead at the end point // Get the tangent direction at the end of the path let dx: number, dy: number; if (this.#arrowStyle === "straight") { dx = ex - sx; dy = ey - sy; } else { // For curves, tangent at end = end - control dx = ex - cx; dy = ey - cy; } const len = Math.sqrt(dx * dx + dy * dy) || 1; const ux = dx / len; const uy = dy / len; const headLen = sw * 4; const headW = sw * 2.5; // Perpendicular const px = -uy; const py = ux; const p1x = ex - ux * headLen + px * headW; const p1y = ey - uy * headLen + py * headW; const p2x = ex - ux * headLen - px * headW; const p2y = ey - uy * headLen - py * headW; if (this.#arrowStyle === "sketchy") { const j = () => (Math.random() - 0.5) * 3; arrowheadPoints = `${p1x + j()},${p1y + j()} ${ex + j()},${ey + j()} ${p2x + j()},${p2y + j()}`; } else { arrowheadPoints = `${p1x},${p1y} ${ex},${ey} ${p2x},${p2y}`; } this.#svg.innerHTML = ` `; } } override createRenderRoot() { const root = super.createRenderRoot(); this.#svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); this.#svg.style.cssText = "position:absolute;inset:0;width:100%;height:100%;overflow:visible;pointer-events:none;"; (root as ShadowRoot).appendChild(this.#svg); this.#updateArrow(); return root; } toJSON() { return { type: "folk-arrow", id: this.id, sourceId: this.sourceId, targetId: this.targetId, color: this.#color, strokeWidth: this.#strokeWidth, arrowStyle: this.#arrowStyle, }; } }