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:
parent
ddab22abc2
commit
f3e18b6124
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
|
@ -140,6 +157,7 @@
|
|||
<div id="toolbar">
|
||||
<button id="add-markdown" title="Add Markdown Note">📝 Note</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-out" title="Zoom Out">-</button>
|
||||
<button id="reset-view" title="Reset View">Reset</button>
|
||||
|
|
@ -153,12 +171,13 @@
|
|||
<div id="canvas"></div>
|
||||
|
||||
<script type="module">
|
||||
import { FolkShape, FolkMarkdown, FolkWrapper, CommunitySync } from "@lib";
|
||||
import { FolkShape, FolkMarkdown, FolkWrapper, FolkArrow, CommunitySync } from "@lib";
|
||||
|
||||
// Register custom elements
|
||||
FolkShape.define();
|
||||
FolkMarkdown.define();
|
||||
FolkWrapper.define();
|
||||
FolkArrow.define();
|
||||
|
||||
// Get community info from URL
|
||||
const hostname = window.location.hostname;
|
||||
|
|
@ -226,6 +245,14 @@
|
|||
let shape;
|
||||
|
||||
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":
|
||||
shape = document.createElement("folk-wrapper");
|
||||
if (data.title) shape.title = data.title;
|
||||
|
|
@ -319,6 +346,58 @@
|
|||
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
|
||||
let scale = 1;
|
||||
const minScale = 0.25;
|
||||
|
|
|
|||
Loading…
Reference in New Issue