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:
parent
c2b821ba0d
commit
981acc5b59
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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">~></span> Smooth</button>
|
||||
<button data-arrow-style="straight"><span class="arrow-icon">→</span> Straight</button>
|
||||
<button data-arrow-style="curved"><span class="arrow-icon">↗</span> Curved</button>
|
||||
<button data-arrow-style="sketchy"><span class="arrow-icon">✏</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", {
|
||||
|
|
|
|||
Loading…
Reference in New Issue