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
|
// 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;
|
||||||
|
|
|
||||||
|
|
@ -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-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";
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue