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 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-09 22:18:26 -07:00
parent c2b821ba0d
commit 981acc5b59
2 changed files with 236 additions and 11 deletions

View File

@ -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 = `
<path d="${pathD}" fill="none" stroke="${this.#color}" stroke-width="${sw}"
stroke-linecap="round" stroke-linejoin="round"
${this.#arrowStyle === "sketchy" ? 'stroke-dasharray="0"' : ""} />
<polygon points="${arrowheadPoints}" fill="${this.#color}" />
`;
}
}
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,
};
}
}

View File

@ -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 @@
<button class="tool-btn" id="tool-eraser" title="Eraser (E)">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 20H7L3 16a1 1 0 010-1.4l9.6-9.6a1 1 0 011.4 0l7 7a1 1 0 010 1.4L15 20"/><line x1="18" y1="13" x2="11" y2="6"/></svg>
</button>
<div class="tool-arrow-wrap">
<button class="tool-btn" id="tool-arrow" title="Arrow (A)">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="5" y1="19" x2="19" y2="5"/><polyline points="10 5 19 5 19 14"/></svg>
</button>
<div id="arrow-style-menu">
<button data-arrow-style="smooth" class="active"><span class="arrow-icon">~&gt;</span> Smooth</button>
<button data-arrow-style="straight"><span class="arrow-icon">&rarr;</span> Straight</button>
<button data-arrow-style="curved"><span class="arrow-icon">&nearr;</span> Curved</button>
<button data-arrow-style="sketchy"><span class="arrow-icon">&#9999;</span> Sketchy</button>
</div>
</div>
<span class="tool-sep"></span>
<div id="recent-tools"></div>
<span class="tool-sep" id="recent-sep" style="display:none"></span>
@ -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", {