From f3e18b612421670754bd1ea47a4df6cac0d75104 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Thu, 1 Jan 2026 23:13:25 +0100 Subject: [PATCH] Add FolkArrow component for shape connections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Implement folk-arrow web component using perfect-arrows - Curved bezier arrows with perfect-freehand stroke styling - Dynamic position tracking via requestAnimationFrame - Connection mode: click source then target to create arrow - Sync arrow properties (sourceId, targetId, color) via Automerge 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- lib/community-sync.ts | 32 +++- lib/folk-arrow.ts | 354 ++++++++++++++++++++++++++++++++++++++++++ lib/index.ts | 1 + website/canvas.html | 83 +++++++++- 4 files changed, 462 insertions(+), 8 deletions(-) create mode 100644 lib/folk-arrow.ts diff --git a/lib/community-sync.ts b/lib/community-sync.ts index f8d5a87..273abe6 100644 --- a/lib/community-sync.ts +++ b/lib/community-sync.ts @@ -14,6 +14,8 @@ export interface ShapeData { // Arrow-specific sourceId?: string; targetId?: string; + color?: string; + strokeWidth?: number; // Wrapper-specific title?: string; icon?: string; @@ -311,12 +313,13 @@ export class CommunitySync extends EventTarget { data.content = (shape as any).content; } - // Add arrow connections - if ("sourceId" in shape) { - data.sourceId = (shape as any).sourceId; - } - if ("targetId" in shape) { - data.targetId = (shape as any).targetId; + // Add arrow properties + if (shape.tagName.toLowerCase() === "folk-arrow") { + const arrow = shape as any; + if (arrow.sourceId) data.sourceId = arrow.sourceId; + if (arrow.targetId) data.targetId = arrow.targetId; + if (arrow.color) data.color = arrow.color; + if (arrow.strokeWidth) data.strokeWidth = arrow.strokeWidth; } // Add wrapper properties @@ -462,6 +465,23 @@ export class CommunitySync extends EventTarget { } } + // Update arrow-specific properties + if (data.type === "folk-arrow") { + const arrow = shape as any; + if (data.sourceId !== undefined && arrow.sourceId !== data.sourceId) { + arrow.sourceId = data.sourceId; + } + if (data.targetId !== undefined && arrow.targetId !== data.targetId) { + arrow.targetId = data.targetId; + } + if (data.color !== undefined && arrow.color !== data.color) { + arrow.color = data.color; + } + if (data.strokeWidth !== undefined && arrow.strokeWidth !== data.strokeWidth) { + arrow.strokeWidth = data.strokeWidth; + } + } + // Update wrapper-specific properties if (data.type === "folk-wrapper") { const wrapper = shape as any; diff --git a/lib/folk-arrow.ts b/lib/folk-arrow.ts new file mode 100644 index 0000000..5e2e407 --- /dev/null +++ b/lib/folk-arrow.ts @@ -0,0 +1,354 @@ +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 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; + + #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(); + } + + 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"); + + if (sourceAttr) this.source = sourceAttr; + if (targetAttr) this.target = targetAttr; + if (colorAttr) this.#color = colorAttr; + if (strokeAttr) this.strokeWidth = parseFloat(strokeAttr); + + // 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(); + } + } + + #updateArrow() { + if (!this.#sourceRect || !this.#targetRect) { + this.style.clipPath = ""; + this.style.display = "none"; + 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, + ); + + 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; + } + + override createRenderRoot() { + const root = super.createRenderRoot(); + this.#updateArrow(); + return root; + } + + toJSON() { + return { + type: "folk-arrow", + id: this.id, + sourceId: this.sourceId, + targetId: this.targetId, + color: this.#color, + strokeWidth: this.#strokeWidth, + }; + } +} diff --git a/lib/index.ts b/lib/index.ts index 2c8bb02..06c27f3 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -22,6 +22,7 @@ export * from "./tags"; export * from "./folk-shape"; export * from "./folk-markdown"; export * from "./folk-wrapper"; +export * from "./folk-arrow"; // Sync export * from "./community-sync"; diff --git a/website/canvas.html b/website/canvas.html index b6371d7..9a4e2b9 100644 --- a/website/canvas.html +++ b/website/canvas.html @@ -126,9 +126,26 @@ } folk-markdown, - folk-wrapper { + folk-wrapper, + folk-arrow { position: absolute; } + + .connect-mode folk-markdown, + .connect-mode folk-wrapper { + cursor: crosshair; + } + + .connect-mode folk-markdown:hover, + .connect-mode folk-wrapper:hover { + outline: 2px dashed #3b82f6; + outline-offset: 4px; + } + + .connect-source { + outline: 3px solid #22c55e !important; + outline-offset: 4px !important; + } @@ -140,6 +157,7 @@
+ @@ -153,12 +171,13 @@