From 981acc5b592c7cffccb4f3278fdddc22098e5970 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Mon, 9 Mar 2026 22:18:26 -0700 Subject: [PATCH] feat(canvas): arrow tool button + 4 arrow style variants Add arrow button to bottom toolbar with style submenu (smooth, straight, curved, sketchy). Click toggles connect mode, long-press/right-click opens style picker. Styles persist via localStorage and sync data. Co-Authored-By: Claude Opus 4.6 --- lib/folk-arrow.ts | 111 ++++++++++++++++++++++++++++++++---- website/canvas.html | 136 +++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 236 insertions(+), 11 deletions(-) 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", {