diff --git a/lib/folk-arrow.ts b/lib/folk-arrow.ts
index 5e2e407..41dbb71 100644
--- a/lib/folk-arrow.ts
+++ b/lib/folk-arrow.ts
@@ -131,6 +131,8 @@ declare global {
}
}
+export type ArrowStyle = "smooth" | "straight" | "curved" | "sketchy";
+
export class FolkArrow extends FolkElement {
static override tagName = "folk-arrow";
static styles = styles;
@@ -144,6 +146,7 @@ export class FolkArrow extends FolkElement {
#resizeObserver: ResizeObserver;
#color: string = "#374151";
#strokeWidth: number = 3;
+ #arrowStyle: ArrowStyle = "smooth";
#options: StrokeOptions = {
size: 7,
@@ -224,6 +227,17 @@ export class FolkArrow extends FolkElement {
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();
@@ -232,11 +246,13 @@ export class FolkArrow extends FolkElement {
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();
@@ -301,10 +317,13 @@ export class FolkArrow extends FolkElement {
}
}
+ #svg: SVGSVGElement | null = null;
+
#updateArrow() {
if (!this.#sourceRect || !this.#targetRect) {
this.style.clipPath = "";
this.style.display = "none";
+ if (this.#svg) this.#svg.innerHTML = "";
return;
}
@@ -321,22 +340,93 @@ export class FolkArrow extends FolkElement {
this.#targetRect.height,
);
- const points = pointsOnBezierCurves([
- { x: sx, y: sy },
- { x: cx, y: cy },
- { x: ex, y: ey },
- { x: ex, y: ey },
- ]);
+ 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 stroke = getStroke(points, this.#options);
- const path = getSvgPathFromStroke(stroke);
+ const points = pointsOnBezierCurves([
+ { x: sx, y: sy },
+ { x: cx, y: cy },
+ { x: ex, y: ey },
+ { x: ex, y: ey },
+ ]);
- this.style.clipPath = `path('${path}')`;
- this.style.backgroundColor = this.#color;
+ 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;
}
@@ -349,6 +439,7 @@ export class FolkArrow extends FolkElement {
targetId: this.targetId,
color: this.#color,
strokeWidth: this.#strokeWidth,
+ arrowStyle: this.#arrowStyle,
};
}
}
diff --git a/website/canvas.html b/website/canvas.html
index 85a1284..5070920 100644
--- a/website/canvas.html
+++ b/website/canvas.html
@@ -470,6 +470,63 @@
color: var(--rs-accent);
}
+ /* Arrow tool button + style menu */
+ .tool-arrow-wrap {
+ position: relative;
+ }
+
+ #arrow-style-menu {
+ display: none;
+ position: absolute;
+ bottom: calc(100% + 10px);
+ left: 50%;
+ transform: translateX(-50%);
+ width: 150px;
+ background: var(--rs-toolbar-panel-bg);
+ border-radius: 10px;
+ box-shadow: var(--rs-shadow-lg);
+ padding: 6px;
+ z-index: 1002;
+ flex-direction: column;
+ gap: 2px;
+ }
+
+ #arrow-style-menu.open {
+ display: flex;
+ }
+
+ #arrow-style-menu button {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ width: 100%;
+ padding: 6px 10px;
+ border: none;
+ border-radius: 6px;
+ background: transparent;
+ color: var(--rs-toolbar-btn-text);
+ cursor: pointer;
+ font-size: 12px;
+ text-align: left;
+ white-space: nowrap;
+ transition: background 0.15s;
+ }
+
+ #arrow-style-menu button:hover {
+ background: var(--rs-toolbar-btn-hover);
+ }
+
+ #arrow-style-menu button.active {
+ background: var(--rs-accent);
+ color: #fff;
+ }
+
+ #arrow-style-menu .arrow-icon {
+ font-size: 14px;
+ width: 20px;
+ text-align: center;
+ }
+
/* Feed mode: hide bottom toolbar */
#canvas.feed-mode ~ #bottom-toolbar {
display: none;
@@ -2035,6 +2092,17 @@
+
@@ -3151,6 +3219,7 @@
if (data.targetId) shape.targetId = data.targetId;
if (data.color) shape.color = data.color;
if (data.strokeWidth) shape.strokeWidth = data.strokeWidth;
+ if (data.arrowStyle) shape.arrowStyle = data.arrowStyle;
shape.id = data.id;
return shape; // Arrows don't have position/size
case "folk-wrapper":
@@ -4139,6 +4208,9 @@
arrow.sourceId = connectSource.id;
arrow.targetId = target.id;
arrow.color = colors[Math.floor(Math.random() * colors.length)];
+ if (typeof arrowStyle !== "undefined" && arrowStyle !== "smooth") {
+ arrow.arrowStyle = arrowStyle;
+ }
canvasContent.appendChild(arrow);
sync.registerShape(arrow);
@@ -4245,7 +4317,7 @@
const map = { pencil: "tool-pencil", line: "tool-line", rect: "tool-rect", circle: "tool-circle", eraser: "tool-eraser" };
document.getElementById(map[wbTool])?.classList.add("active");
} else if (connectMode) {
- // Connect mode active (no toolbar button — toggled via 'A' key)
+ document.getElementById("tool-arrow")?.classList.add("active");
} else if (pendingTool) {
// Check if pending tool maps to a bottom toolbar button
if (pendingTool.tagName === "folk-markdown" && pendingTool.props?.__postCreate) {
@@ -4292,6 +4364,68 @@
setWbTool("eraser");
});
+ // ── Arrow tool with style submenu ──
+ let arrowStyle = localStorage.getItem("rspace_arrow_style") || "smooth";
+ const arrowBtn = document.getElementById("tool-arrow");
+ const arrowMenu = document.getElementById("arrow-style-menu");
+ let arrowLongPress = null;
+
+ // Sync active class on menu buttons
+ function syncArrowMenu() {
+ arrowMenu.querySelectorAll("button").forEach(b => {
+ b.classList.toggle("active", b.dataset.arrowStyle === arrowStyle);
+ });
+ }
+ syncArrowMenu();
+
+ // Single click → toggle connect mode
+ arrowBtn.addEventListener("click", (e) => {
+ e.stopPropagation();
+ if (arrowLongPress) { clearTimeout(arrowLongPress); arrowLongPress = null; }
+ if (arrowMenu.classList.contains("open")) return; // don't toggle if menu is open
+ toggleConnectMode();
+ });
+
+ // Long-press → open style menu
+ arrowBtn.addEventListener("pointerdown", (e) => {
+ arrowLongPress = setTimeout(() => {
+ arrowLongPress = null;
+ e.preventDefault();
+ arrowMenu.classList.toggle("open");
+ syncArrowMenu();
+ }, 300);
+ });
+ arrowBtn.addEventListener("pointerup", () => { if (arrowLongPress) { clearTimeout(arrowLongPress); arrowLongPress = null; } });
+ arrowBtn.addEventListener("pointerleave", () => { if (arrowLongPress) { clearTimeout(arrowLongPress); arrowLongPress = null; } });
+
+ // Right-click → open style menu
+ arrowBtn.addEventListener("contextmenu", (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ if (arrowLongPress) { clearTimeout(arrowLongPress); arrowLongPress = null; }
+ arrowMenu.classList.toggle("open");
+ syncArrowMenu();
+ });
+
+ // Style button clicks
+ arrowMenu.querySelectorAll("button").forEach(btn => {
+ btn.addEventListener("click", (e) => {
+ e.stopPropagation();
+ arrowStyle = btn.dataset.arrowStyle;
+ localStorage.setItem("rspace_arrow_style", arrowStyle);
+ syncArrowMenu();
+ arrowMenu.classList.remove("open");
+ if (!connectMode) toggleConnectMode();
+ });
+ });
+
+ // Close menu on outside click
+ document.addEventListener("click", (e) => {
+ if (!e.target.closest(".tool-arrow-wrap")) {
+ arrowMenu.classList.remove("open");
+ }
+ });
+
document.getElementById("tool-sticky").addEventListener("click", () => {
if (wbTool) setWbTool(null);
setPendingTool("folk-markdown", {