Add FolkArrow component for shape connections

- 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 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-01-01 23:13:25 +01:00
parent ddab22abc2
commit f3e18b6124
4 changed files with 462 additions and 8 deletions

View File

@ -14,6 +14,8 @@ export interface ShapeData {
// Arrow-specific // Arrow-specific
sourceId?: string; sourceId?: string;
targetId?: string; targetId?: string;
color?: string;
strokeWidth?: number;
// Wrapper-specific // Wrapper-specific
title?: string; title?: string;
icon?: string; icon?: string;
@ -311,12 +313,13 @@ export class CommunitySync extends EventTarget {
data.content = (shape as any).content; data.content = (shape as any).content;
} }
// Add arrow connections // Add arrow properties
if ("sourceId" in shape) { if (shape.tagName.toLowerCase() === "folk-arrow") {
data.sourceId = (shape as any).sourceId; const arrow = shape as any;
} if (arrow.sourceId) data.sourceId = arrow.sourceId;
if ("targetId" in shape) { if (arrow.targetId) data.targetId = arrow.targetId;
data.targetId = (shape as any).targetId; if (arrow.color) data.color = arrow.color;
if (arrow.strokeWidth) data.strokeWidth = arrow.strokeWidth;
} }
// Add wrapper properties // 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 // Update wrapper-specific properties
if (data.type === "folk-wrapper") { if (data.type === "folk-wrapper") {
const wrapper = shape as any; const wrapper = shape as any;

354
lib/folk-arrow.ts Normal file
View File

@ -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,
};
}
}

View File

@ -22,6 +22,7 @@ export * from "./tags";
export * from "./folk-shape"; export * from "./folk-shape";
export * from "./folk-markdown"; export * from "./folk-markdown";
export * from "./folk-wrapper"; export * from "./folk-wrapper";
export * from "./folk-arrow";
// Sync // Sync
export * from "./community-sync"; export * from "./community-sync";

View File

@ -126,9 +126,26 @@
} }
folk-markdown, folk-markdown,
folk-wrapper { folk-wrapper,
folk-arrow {
position: absolute; 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;
}
</style> </style>
</head> </head>
<body> <body>
@ -140,6 +157,7 @@
<div id="toolbar"> <div id="toolbar">
<button id="add-markdown" title="Add Markdown Note">📝 Note</button> <button id="add-markdown" title="Add Markdown Note">📝 Note</button>
<button id="add-wrapper" title="Add Wrapped Card">🗂️ Card</button> <button id="add-wrapper" title="Add Wrapped Card">🗂️ Card</button>
<button id="add-arrow" title="Connect Shapes">🔗 Connect</button>
<button id="zoom-in" title="Zoom In">+</button> <button id="zoom-in" title="Zoom In">+</button>
<button id="zoom-out" title="Zoom Out">-</button> <button id="zoom-out" title="Zoom Out">-</button>
<button id="reset-view" title="Reset View">Reset</button> <button id="reset-view" title="Reset View">Reset</button>
@ -153,12 +171,13 @@
<div id="canvas"></div> <div id="canvas"></div>
<script type="module"> <script type="module">
import { FolkShape, FolkMarkdown, FolkWrapper, CommunitySync } from "@lib"; import { FolkShape, FolkMarkdown, FolkWrapper, FolkArrow, CommunitySync } from "@lib";
// Register custom elements // Register custom elements
FolkShape.define(); FolkShape.define();
FolkMarkdown.define(); FolkMarkdown.define();
FolkWrapper.define(); FolkWrapper.define();
FolkArrow.define();
// Get community info from URL // Get community info from URL
const hostname = window.location.hostname; const hostname = window.location.hostname;
@ -226,6 +245,14 @@
let shape; let shape;
switch (data.type) { switch (data.type) {
case "folk-arrow":
shape = document.createElement("folk-arrow");
if (data.sourceId) shape.sourceId = data.sourceId;
if (data.targetId) shape.targetId = data.targetId;
if (data.color) shape.color = data.color;
if (data.strokeWidth) shape.strokeWidth = data.strokeWidth;
shape.id = data.id;
return shape; // Arrows don't have position/size
case "folk-wrapper": case "folk-wrapper":
shape = document.createElement("folk-wrapper"); shape = document.createElement("folk-wrapper");
if (data.title) shape.title = data.title; if (data.title) shape.title = data.title;
@ -319,6 +346,58 @@
sync.registerShape(shape); sync.registerShape(shape);
}); });
// Arrow connection mode
let connectMode = false;
let connectSource = null;
const addArrowBtn = document.getElementById("add-arrow");
addArrowBtn.addEventListener("click", () => {
connectMode = !connectMode;
addArrowBtn.classList.toggle("active", connectMode);
canvas.classList.toggle("connect-mode", connectMode);
if (!connectMode && connectSource) {
connectSource.classList.remove("connect-source");
connectSource = null;
}
});
// Handle shape clicks for connection mode
canvas.addEventListener("click", (e) => {
if (!connectMode) return;
const target = e.target.closest("folk-markdown, folk-wrapper");
if (!target || !target.id) return;
e.stopPropagation();
if (!connectSource) {
// First click - select source
connectSource = target;
target.classList.add("connect-source");
} else if (target !== connectSource) {
// Second click - create arrow
const arrowId = `arrow-${Date.now()}-${++shapeCounter}`;
const colors = ["#374151", "#ef4444", "#f59e0b", "#22c55e", "#3b82f6", "#8b5cf6"];
const arrow = document.createElement("folk-arrow");
arrow.id = arrowId;
arrow.sourceId = connectSource.id;
arrow.targetId = target.id;
arrow.color = colors[Math.floor(Math.random() * colors.length)];
canvas.appendChild(arrow);
sync.registerShape(arrow);
// Reset connection mode
connectSource.classList.remove("connect-source");
connectSource = null;
connectMode = false;
addArrowBtn.classList.remove("active");
canvas.classList.remove("connect-mode");
}
});
// Zoom controls // Zoom controls
let scale = 1; let scale = 1;
const minScale = 0.25; const minScale = 0.25;