/*

This script allows you to fit a text element along a selected path: line, arrow, freedraw, ellipse, rectangle, or diamond. You can select either a path or a text element, or both:
- If only a path is selected, you will be prompted to provide the text.
- If only a text element is selected and it was previously fitted to a path, the script will use the original path if it is still present in the scene.
- If both a text and a path are selected, the script will fit the text to the selected path.
If the path is a perfect circle, you will be prompted to choose whether to fit the text above or below the circle.
After fitting, the text will no longer be editable as a standard text element, but you’ll be able to edit it with this script. Text on path cannot function as a markdown link. Emojis are not supported.
*/
if(!ea.verifyMinimumPluginVersion || !ea.verifyMinimumPluginVersion("2.12.0")) {
new Notice("This script requires a newer version of Excalidraw. Please install the latest version.");
return;
}
els = ea.getViewSelectedElements();
let pathEl = els.find(el=>["ellipse", "rectangle", "diamond", "line", "arrow", "freedraw"].includes(el.type));
const textEl = els.find(el=>el.type === "text");
const tempElementIDs = [];
const win = ea.targetView.ownerWindow;
let pathElID = textEl?.customData?.text2Path?.pathElID;
if(!pathEl) {
if (pathElID) {
pathEl = ea.getViewElements().find(el=>el.id === pathElID);
pathElID = pathEl?.id;
}
if(!pathElID) {
new Notice("Please select a text element and a valid path element (ellipse, rectangle, diamond, line, arrow, or freedraw)");
return;
}
} else {
pathElID = pathEl.id;
}
const originalPathType = pathEl.type;
const originalPathDirectionLR = ["line","arrow","freedraw"].includes(pathEl.type)
? (pathEl.points[pathEl.points.length-1][0] < 0 ? true : false)
: true;
const st = ea.getExcalidrawAPI().getAppState();
const fontSize = textEl?.fontSize ?? st.currentItemFontSize;
const fontFamily = textEl?.fontFamily ?? st.currentItemFontFamily;
ea.style.fontSize = fontSize;
ea.style.fontFamily = fontFamily;
const fontHeight = ea.measureText("M").height*1.3;
// Remove isCircle check and introduce isClosedShape
const isPathLinear = ["line", "arrow", "freedraw"].includes(pathEl.type);
const isClosedShape = ["ellipse", "rectangle", "diamond"].includes(originalPathType) || (pathEl.type === "line" && pathEl.polygon);
// Expand and convert all closed shapes to line elements uniformly
if(!isPathLinear) {
ea.copyViewElementsToEAforEditing([pathEl]);
pathEl = ea.getElement(pathEl.id);
if (pathEl.type === "line" && isClosedShape && isClockwise(pathEl.points)) {
pathEl.points = pathEl.points.reverse();
}
pathEl.x -= fontHeight/2;
pathEl.y -= fontHeight/2;
pathEl.width += fontHeight;
pathEl.height += fontHeight;
tempElementIDs.push(pathEl.id);
switch (pathEl.type) {
case "rectangle":
pathEl = rectangleToLine(pathEl);
break;
case "ellipse":
pathEl = ellipseToLine(pathEl);
break;
case "diamond":
pathEl = diamondToLine(pathEl);
break;
}
tempElementIDs.push(pathEl.id);
}
// ---------------------------------------------------------
// Convert path to SVG and use real path for text placement.
// ---------------------------------------------------------
let isLeftToRight = true;
let pathElBottom = null;
if (
(["line", "arrow"].includes(pathEl.type) && pathEl.roundness !== null) ||
pathEl.type === "freedraw"
) {
[pathEl, isLeftToRight, pathElBottom] = await convertBezierToPoints();
} else if (pathEl.points) {
isLeftToRight = pathEl.points[pathEl.points.length - 1][0] >= 0;
}
// ---------------------------------------------------------
// Retrieve original settings from text-on-path customData
// ---------------------------------------------------------
let currentOffsetPct = textEl?.customData?.text2Path?.offsetPct ?? 0;
let currentDistanceOffset = textEl?.customData?.text2Path?.distanceOffset ?? 0;
let currentLetterSpacing = textEl?.customData?.text2Path?.letterSpacing ?? 0;
// Map legacy archAbove to currentIsReversed for backwards compatibility
let currentIsReversed = textEl?.customData?.text2Path?.isReversed ?? (textEl?.customData?.text2Path?.archAbove === false ? true : false);
let currentPlaceInside = textEl?.customData?.text2Path?.placeInside ?? false;
let currentText = textEl?.customData?.text2Path
? textEl.customData.text2Path.text
: textEl?.text ?? "";
currentText = currentText.replace(/ \n/g," ").replace(/\n /g, " ").replace(/\n/g," ");
let generatedIDs = [];
let updateTimeout = null;
let textUpdateTimeout = null;
function isClockwise(points) {
if(points.length <=3 ) return true
return points[points.length-2] > 0
}
async function updatePath() {
if (!currentText || currentText.trim() === "") return;
ea.clear();
let elementsToDelete = [];
// Target generated elements from this session, or the original text element
// and its associated path characters if editing a pre-existing path
if (generatedIDs.length > 0) {
elementsToDelete = ea.getViewElements().filter(el => generatedIDs.includes(el.id));
} else if (textEl && !textEl.isDeleted) {
if (textEl?.customData?.text2Path) {
const pathID = textEl.customData.text2Path.pathID;
elementsToDelete = ea.getViewElements().filter(el => el.customData?.text2Path && el.customData.text2Path.pathID === pathID);
} else {
elementsToDelete = [textEl];
}
}
if (elementsToDelete.length > 0) {
ea.copyViewElementsToEAforEditing(elementsToDelete);
ea.getElements().forEach(el => { el.isDeleted = true; });
}
// Re-apply style rules to the EA workbench context
ea.style.fontSize = fontSize;
ea.style.fontFamily = fontFamily;
ea.style.strokeColor = textEl?.strokeColor ?? st.currentItemStrokeColor;
ea.style.opacity = textEl?.opacity ?? st.currentItemOpacity;
generatedIDs = [];
// Apply fitTextToShape universally
await fitTextToShape();
}
// -------------------------------------
// Floating Modal UI
// -------------------------------------
const modal = new ea.FloatingModal(ea.plugin.app);
// Constrain the modal width
modal.modalEl.style.width = "400px";
modal.modalEl.style.maxWidth = "100%";
let outsideClickHandler; // Store reference to remove it later
modal.onOpen = () => {
modal.contentEl.empty();
// Text Input
const textSetting = new ea.obsidian.Setting(modal.contentEl)
.setName("Text")
.addTextArea(text => {
text.setValue(currentText)
.onChange(val => {
currentText = val.replace(/ \n/g," ").replace(/\n /g, " ").replace(/\n/g," ");
if (textUpdateTimeout) clearTimeout(textUpdateTimeout);
textUpdateTimeout = setTimeout(() => {
updatePath();
}, 1000);
});
// Make text area fill its container
text.inputEl.style.width = "100%";
text.inputEl.style.minHeight = "80px";
text.inputEl.style.resize = "vertical";
});
// Force block layout so the textarea sits below the label and takes 100% width
textSetting.settingEl.style.display = "block";
textSetting.controlEl.style.width = "100%";
textSetting.controlEl.style.marginTop = "8px";
// Offset Slider (Now applies to all shapes)
const offsetSetting = new ea.obsidian.Setting(modal.contentEl)
.setName("Slide text along the path")
.addSlider(slider => {
slider.setLimits(-50, 50, 0.1)
.setValue(currentOffsetPct)
.onChange(val => {
currentOffsetPct = val;
// 500ms Throttle for continuous sliding to keep performance smooth
if (updateTimeout) clearTimeout(updateTimeout);
updateTimeout = setTimeout(() => {
updatePath();
}, 500);
});
// Make the slider stretch to fill the control element
slider.sliderEl.style.width = "100%";
});
// Tell the control container to expand and take up all remaining width
offsetSetting.controlEl.style.flexGrow = "1";
offsetSetting.controlEl.style.width = "100%";
offsetSetting.infoEl.style.flex = "0 1 auto";
const distanceSetting = new ea.obsidian.Setting(modal.contentEl)
.setName("Distance from line")
.addSlider(slider => {
slider.setLimits(-50, 50, 1)
.setValue(currentDistanceOffset)
.onChange(val => {
currentDistanceOffset = val;
if (updateTimeout) clearTimeout(updateTimeout);
updateTimeout = setTimeout(() => { updatePath(); }, 500);
});
slider.sliderEl.style.width = "100%";
});
distanceSetting.controlEl.style.flexGrow = "1";
distanceSetting.controlEl.style.width = "100%";
distanceSetting.infoEl.style.flex = "0 1 auto";
const spacingSetting = new ea.obsidian.Setting(modal.contentEl)
.setName("Character spacing")
.addSlider(slider => {
slider.setLimits(-25, 50, 1)
.setValue(currentLetterSpacing)
.onChange(val => {
currentLetterSpacing = val;
if (updateTimeout) clearTimeout(updateTimeout);
updateTimeout = setTimeout(() => { updatePath(); }, 500);
});
slider.sliderEl.style.width = "100%";
});
spacingSetting.controlEl.style.flexGrow = "1";
spacingSetting.controlEl.style.width = "100%";
spacingSetting.infoEl.style.flex = "0 1 auto";
new ea.obsidian.Setting(modal.contentEl)
.setName("Reverse text")
.setDesc("Flips the text direction (useful for placing text on the inside or bottom of a shape).")
.addToggle(toggle => {
toggle.setValue(currentIsReversed)
.onChange(val => {
currentIsReversed = val;
updatePath();
});
});
const placementLabel = isClosedShape ? "Place inside shape" : "Place on opposite side";
const placementDesc = isClosedShape
? "Places the text on the inside of the shape's boundary."
: "Places the text on the other side of the path.";
new ea.obsidian.Setting(modal.contentEl)
.setName(placementLabel)
.setDesc(placementDesc)
.addToggle(toggle => {
toggle.setValue(currentPlaceInside)
.onChange(val => {
currentPlaceInside = val;
updatePath();
});
});
// Action Buttons
const btnContainer = modal.contentEl.createDiv({ attr: { style: "display: flex; gap: 10px; justify-content: flex-end; margin-top: 15px;" } });
const updateBtn = btnContainer.createEl("button", { text: "Update Preview" });
updateBtn.onclick = () => {
if (updateTimeout) clearTimeout(updateTimeout);
if (textUpdateTimeout) clearTimeout(textUpdateTimeout);
updatePath();
};
const closeBtn = btnContainer.createEl("button", { text: "Done", cls: "mod-cta" });
closeBtn.onclick = async () => {
if (updateTimeout) clearTimeout(updateTimeout);
if (textUpdateTimeout) clearTimeout(textUpdateTimeout);
await updatePath();
modal.close();
};
// Add outside click listener
outsideClickHandler = (e) => {
// If the click is outside the modal element
if (modal.modalEl && !modal.modalEl.contains(e.target)) {
modal.close();
}
};
// Delay attaching the listener slightly so the click that opened the script
// doesn't immediately trigger the close.
setTimeout(() => {
ea.targetView.ownerWindow.addEventListener("pointerdown", outsideClickHandler);
}, 100);
};
modal.onClose = () => {
if (updateTimeout) clearTimeout(updateTimeout);
if (outsideClickHandler) {
ea.targetView.ownerWindow.removeEventListener("pointerdown", outsideClickHandler);
}
};
modal.enableKeyCapture();
modal.open();
// Trigger initial calculation
if (currentText.trim() !== "") {
await updatePath();
}
//----------------------------------------
//----------------------------------------
// Supporting functions
//----------------------------------------
//----------------------------------------
function transposeElements(ids) {
const dims = ea.measureText("M");
ea.getElements().filter(el=>ids.has(el.id)).forEach(el=>{
el.x -= dims.width/2;
el.y -= dims.height/2;
})
}
// Function to convert any shape to a series of points along its path
function calculatePathPoints(element) {
const points = [];
let minX = 0, minY = 0, maxX = 0, maxY = 0;
if (element.points && element.points.length > 0) {
minX = Math.min(...element.points.map(p => p[0]));
minY = Math.min(...element.points.map(p => p[1]));
maxX = Math.max(...element.points.map(p => p[0]));
maxY = Math.max(...element.points.map(p => p[1]));
} else {
maxX = element.width;
maxY = element.height;
}
const cx = element.x + minX + (maxX - minX) / 2;
const cy = element.y + minY + (maxY - minY) / 2;
const angle = element.angle || 0;
// Get absolute coordinates of all points, accounting for rotation
const absolutePoints = element.points.map(point => {
const sx = point[0] + element.x;
const sy = point[1] + element.y;
if (angle !== 0) {
const cos = Math.cos(angle);
const sin = Math.sin(angle);
const rx = cos * (sx - cx) - sin * (sy - cy) + cx;
const ry = sin * (sx - cx) + cos * (sy - cy) + cy;
return [rx, ry];
}
return [sx, sy];
});
// If it's a closed polygon, ensure the last point connects to the first
if (element.polygon) {
const firstPt = absolutePoints[0];
const lastPt = absolutePoints[absolutePoints.length - 1];
if (Math.abs(firstPt[0] - lastPt[0]) > 0.001 || Math.abs(firstPt[1] - lastPt[1]) > 0.001) {
absolutePoints.push([...firstPt]);
}
}
// Calculate segment information
let segments = [];
for (let i = 0; i < absolutePoints.length - 1; i++) {
const p0 = absolutePoints[i];
const p1 = absolutePoints[i+1];
const dx = p1[0] - p0[0];
const dy = p1[1] - p0[1];
const segmentLength = Math.sqrt(dx * dx + dy * dy);
// Skip zero-length segments to prevent angle corruption
if (segmentLength < 0.001) continue;
const angleSeg = Math.atan2(dy, dx);
segments.push({ p0, p1, length: segmentLength, angle: angleSeg });
}
// Sample points along each segment
for (const segment of segments) {
const numSamplePoints = Math.max(2, Math.ceil(segment.length / 5)); // 1 point every 5 pixels
for (let i = 0; i < numSamplePoints; i++) {
const t = i / (numSamplePoints - 1);
const x = segment.p0[0] + t * (segment.p1[0] - segment.p0[0]);
const y = segment.p0[1] + t * (segment.p1[1] - segment.p0[1]);
points.push([x, y, segment.angle]);
}
}
return points;
}
// Function to distribute text along any path
function distributeTextAlongPath(text, pathPoints, pathID, objectIDs, offset = 0, isLeftToRight, isClosed = false, isReversed = false, isInside = false, isBottomEdge = false) {
if (pathPoints.length === 0) return;
const originalText = text;
// Determine if we need to draw the characters backwards based on path direction and reversal setting
let shouldReverseString = !isLeftToRight;
if (isReversed) {
shouldReverseString = !shouldReverseString;
}
if (shouldReverseString) {
text = text.split('').reverse().join('');
}
let pathLength = 0;
let pathSegments = [];
let accumulatedLength = 0;
for (let i = 1; i < pathPoints.length; i++) {
const [x1, y1] = [pathPoints[i-1][0], pathPoints[i-1][1]];
const [x2, y2] = [pathPoints[i][0], pathPoints[i][1]];
const segLength = Math.sqrt(Math.pow(x2 - x1, 2) + Math.pow(y2 - y1, 2));
if (segLength < 0.001) continue;
pathSegments.push({
startPoint: [x1, y1], // Create new arrays to safely mutate them later
endPoint: [x2, y2],
length: segLength,
startDist: accumulatedLength,
endDist: accumulatedLength + segLength
});
accumulatedLength += segLength;
pathLength += segLength;
}
// --- Trimming logic to remove freedraw terminal hooks ---
if (originalPathType === "freedraw" && !isClosed && pathSegments.length > 0) {
let trimDist = 15;
while (pathSegments.length > 0 && trimDist > 0) {
const lastSeg = pathSegments[pathSegments.length - 1];
if (lastSeg.length <= trimDist) {
trimDist -= lastSeg.length;
pathSegments.pop();
} else {
const ratio = (lastSeg.length - trimDist) / lastSeg.length;
lastSeg.endPoint[0] = lastSeg.startPoint[0] + (lastSeg.endPoint[0] - lastSeg.startPoint[0]) * ratio;
lastSeg.endPoint[1] = lastSeg.startPoint[1] + (lastSeg.endPoint[1] - lastSeg.startPoint[1]) * ratio;
lastSeg.length -= trimDist;
lastSeg.endDist -= trimDist;
break;
}
}
pathLength = pathSegments.length > 0 ? pathSegments[pathSegments.length - 1].endDist : 0;
}
if (pathSegments.length === 0) return;
// Pre-calculate contextual widths to preserve natural kerning
const substrWidths = [];
const spaceWidth = ea.measureText(" ").width;
for (let i = 0; i <= text.length; i++) {
const sub = text.substring(0, i);
substrWidths.push(ea.measureText(sub + " ").width - spaceWidth);
}
// Calculate the exact target distance (center-to-center) on a straight line
const centers = [];
for (let i = 0; i < text.length; i++) {
centers.push((substrWidths[i] + substrWidths[i+1]) / 2);
}
// Helper to get the exact projected coordinate on the offset path
function getProjectedPoint(s, dy) {
let actualDist = s;
if (isClosed && pathLength > 0) {
actualDist = ((s % pathLength) + pathLength) % pathLength;
}
let pointInfo = getPointAtDistance(actualDist, pathSegments, pathLength);
if (!pointInfo) return null;
const rotAngle = pointInfo.angle + (isLeftToRight ? 0 : Math.PI);
const rotatedDx = -dy * Math.sin(rotAngle);
const rotatedDy = dy * Math.cos(rotAngle);
return {
x: pointInfo.x - rotatedDx,
y: pointInfo.y - rotatedDy,
angle: rotAngle
};
}
let currentS = offset;
let lastPlacedPoint = null;
for (let i = 0; i < text.length; i++) {
const character = text.substring(i, i+1);
const charMetrics = ea.measureText(character);
const charPixelWidth = charMetrics.width;
const charPixelHeight = charMetrics.height;
// Determine the vertical shift required to place the text
let dy = 0;
if (["line", "arrow", "freedraw"].includes(originalPathType)) {
// For open lines, shift UP so the text sits on top of the line
const margin = ea.style.fontSize * 0.2;
dy = (charPixelHeight / 2) + margin;
// Apply custom distance offset (mapped to percentage of fontSize)
dy += (currentDistanceOffset / 100) * ea.style.fontSize;
// If we are using the bottom edge, its normal vectors actually point UP into the stroke.
// So we must invert dy to push the text DOWN away from the stroke edge.
// if (isBottomEdge) dy = -dy;
if (isInside) dy = -dy;
} else {
// For shapes, the path was already expanded outwards by fontHeight/2 earlier in the script.
// Therefore, the base path is exactly where the text centers should be.
dy = 0;
// Apply custom distance offset (mapped to percentage of fontSize)
dy += (currentDistanceOffset / 100) * ea.style.fontSize;
if (isInside) dy = -dy - fontHeight;
}
// Target spatial distance from the previous character center
let targetDist = i === 0 ? centers[0] : centers[i] - centers[i-1];
// Apply custom letter spacing (scale mapped to percentage of font size)
if (i > 0) {
targetDist += (currentLetterSpacing / 100) * ea.style.fontSize;
if (targetDist < 1) targetDist = 1; // Prevent backward steps or collapsed kerning
}
if (i === 0) {
// Find the starting reference point at 'offset'
lastPlacedPoint = getProjectedPoint(offset, dy);
}
let safety = 0;
let currPt = getProjectedPoint(currentS, dy);
let dist = (currPt && lastPlacedPoint) ? Math.hypot(currPt.x - lastPlacedPoint.x, currPt.y - lastPlacedPoint.y) : 0;
// Ray-marching: Step forward along the base path until the 2D Euclidean distance
// between the character centers exactly matches the natural kerning distance.
while (dist < targetDist && safety < 10000) {
currentS += 0.5; // High-precision half-pixel steps
currPt = getProjectedPoint(currentS, dy);
if (currPt && lastPlacedPoint) {
dist = Math.hypot(currPt.x - lastPlacedPoint.x, currPt.y - lastPlacedPoint.y);
}
safety++;
}
lastPlacedPoint = currPt;
if (!currPt) continue;
// Center the visual bounding box on that exact structural center
const drawX = currPt.x - charPixelWidth / 2;
const drawY = currPt.y - charPixelHeight / 2;
// If reversing the text, rotate the characters 180 degrees to keep them upright
ea.style.angle = currPt.angle + (isReversed ? Math.PI : 0);
ea.style.textAlign = "left";
ea.style.verticalAlign = "top";
const charID = ea.addText(drawX, drawY, character);
// Pass custom properties back into the customData to be persisted
ea.addAppendUpdateCustomData(charID, {
text2Path: {
pathID,
text: originalText,
pathElID,
isReversed,
offsetPct: currentOffsetPct,
distanceOffset: currentDistanceOffset,
letterSpacing: currentLetterSpacing,
placeInside: isInside
}
});
objectIDs.push(charID);
}
}
// Helper function to find a point at a specific distance along the path
// Enhanced to include extrapolation with a stabilizing window to prevent terminal jitters
function getPointAtDistance(distance, segments, totalLength) {
if (!segments || segments.length === 0) return null;
// Extrapolate backwards if distance is negative
if (distance <= 0) {
let refSegIdx = 0;
let accumLen = 0;
// Look ahead at least 5 pixels to get a stable starting angle, bypassing pen-down hooks
while(refSegIdx < segments.length - 1 && accumLen < 5) {
accumLen += segments[refSegIdx].length;
refSegIdx++;
}
const refSeg = segments[refSegIdx];
const firstSeg = segments[0];
const angle = Math.atan2(refSeg.endPoint[1] - firstSeg.startPoint[1], refSeg.endPoint[0] - firstSeg.startPoint[0]);
return {
x: firstSeg.startPoint[0] + Math.cos(angle) * distance,
y: firstSeg.startPoint[1] + Math.sin(angle) * distance,
angle: angle
};
}
// Extrapolate forwards if distance exceeds path length
if (distance >= totalLength) {
let refSegIdx = segments.length - 1;
let accumLen = 0;
// Look behind at least 5 pixels to get a stable ending angle, bypassing pen-lift hooks
while(refSegIdx > 0 && accumLen < 5) {
accumLen += segments[refSegIdx].length;
refSegIdx--;
}
const refSeg = segments[refSegIdx];
const lastSeg = segments[segments.length - 1];
const angle = Math.atan2(lastSeg.endPoint[1] - refSeg.startPoint[1], lastSeg.endPoint[0] - refSeg.startPoint[0]);
const over = distance - totalLength;
return {
x: lastSeg.endPoint[0] + Math.cos(angle) * over,
y: lastSeg.endPoint[1] + Math.sin(angle) * over,
angle: angle
};
}
// Interpolate along the matching segment
const segment = segments.find(seg => distance >= seg.startDist && distance <= seg.endDist) || segments[segments.length - 1];
const t = (distance - segment.startDist) / segment.length;
const x = segment.startPoint[0] + t * (segment.endPoint[0] - segment.startPoint[0]);
const y = segment.startPoint[1] + t * (segment.endPoint[1] - segment.startPoint[1]);
const angle = Math.atan2(segment.endPoint[1] - segment.startPoint[1], segment.endPoint[0] - segment.startPoint[0]);
return { x, y, angle };
}
async function convertBezierToPoints() {
const svgPadding = 100;
let isLeftToRight = true;
async function getSVGForPath() {
let el = ea.getElement(pathEl.id);
if(!el) {
ea.copyViewElementsToEAforEditing([pathEl]);
el = ea.getElement(pathEl.id);
}
el.roughness = 0;
el.fillStyle = "solid";
el.backgroundColor = "transparent";
const svgElement = await ea.createSVG(undefined, false, undefined, undefined, 'light', svgPadding);
ea.clear();
return svgElement;
}
const svgElement = await getSVGForPath();
if (svgElement) {
const pathElSVG = svgElement.querySelector('path');
if (pathElSVG) {
// Extract only the first continuous subpath to avoid disconnected jumps
const d = pathElSVG.getAttribute('d');
const subpaths = d.match(/[Mm][^Mm]*/g);
let workingPath = pathElSVG;
if (subpaths && subpaths.length > 1) {
workingPath = document.createElementNS('http://www.w3.org/2000/svg', 'path');
workingPath.setAttribute('d', subpaths[0]);
}
function samplePathPoints(pathNode, step = 5) {
const points = [];
const totalLength = pathNode.getTotalLength();
for (let len = 0; len <= totalLength; len += step) {
const pt = pathNode.getPointAtLength(len);
points.push([pt.x, pt.y]);
}
const lastPt = pathNode.getPointAtLength(totalLength);
if (
points.length === 0 ||
points[points.length - 1][0] !== lastPt.x ||
points[points.length - 1][1] !== lastPt.y
) {
points.push([lastPt.x, lastPt.y]);
}
return points;
}
let points = samplePathPoints(workingPath, 5);
const cx = pathEl.x + pathEl.width / 2;
const cy = pathEl.y + pathEl.height / 2;
const angle = pathEl.angle || 0;
points = points.map(([x, y]) => {
let sx = pathEl.x + x;
let sy = pathEl.y + y;
if (angle !== 0) {
const cos = Math.cos(angle);
const sin = Math.sin(angle);
const rx = cos * (sx - cx) - sin * (sy - cy) + cx;
const ry = sin * (sx - cx) + cos * (sy - cy) + cy;
return [rx, ry];
}
return [sx, sy];
});
isLeftToRight = pathEl.points[pathEl.points.length-1][0] >= 0;
let pointsTop = points;
let pointsBottom = null;
// Handle Freedraw Trimming and Bottom Edge Extraction
if (pathEl.type === "freedraw" && points.length > 10) {
// Perfect Freehand creates a looped polygon.
// The first half traces one edge, the second half traces the return edge.
const half = Math.floor(points.length / 2);
let topEdge = points.slice(0, half);
// Reverse the bottom edge so it flows Start->End, matching the top edge
let bottomEdge = points.slice(half).reverse();
// Trim the rounded end-caps (usually ~6 points at both ends)
const TRIM = 6;
if (topEdge.length > TRIM * 2) topEdge = topEdge.slice(TRIM, topEdge.length - TRIM);
if (bottomEdge.length > TRIM * 2) bottomEdge = bottomEdge.slice(TRIM, bottomEdge.length - TRIM);
// Ensure topEdge is actually the visual Top
// If drawn Right-to-Left, the SVG winding order might map the first half to the bottom
if (!isLeftToRight) {
const temp = topEdge;
topEdge = bottomEdge;
bottomEdge = temp;
}
pointsTop = topEdge;
pointsBottom = bottomEdge;
}
// FIX: robustly handle simulated A->B->A strokes on lines and Double-Rounds on polygons
else if (points.length > 10) {
const firstPt = points[0];
const lastPt = points[points.length - 1];
const isLoop = Math.hypot(firstPt[0] - lastPt[0], firstPt[1] - lastPt[1]) < 3;
if (pathEl.polygon) {
// Polygons: find the FIRST time the path returns to the start to get exactly one round
let loopEndIdx = -1;
for (let i = 10; i < points.length; i++) {
if (Math.hypot(points[i][0] - firstPt[0], points[i][1] - firstPt[1]) < 5) {
loopEndIdx = i;
break;
}
}
if (loopEndIdx !== -1 && loopEndIdx < points.length * 0.9) {
pointsTop = points.slice(0, loopEndIdx + 1);
}
} else if (isLoop) {
// Open Lines: If it loops back, Excalidraw drew A->B->A. Slice in half to keep A->B.
if (!isLeftToRight) points = points.reverse();
pointsTop = points.slice(0, Math.ceil(points.length / 2));
}
}
if (pointsTop.length > 1) {
ea.clear();
ea.style.backgroundColor="transparent";
ea.style.roughness = 0;
ea.style.strokeWidth = 1;
ea.style.roundness = null;
// We create line elements for BOTH the top and bottom edges in the EA workbench
// so we can dynamically swap them inside fitTextToShape when the user toggles settings.
const lineId = ea.addLine(pointsTop);
const line = ea.getElement(lineId);
line.polygon = pathEl.polygon;
tempElementIDs.push(lineId);
let lineBottom = null;
if (pointsBottom && pointsBottom.length > 1) {
const lineBottomId = ea.addLine(pointsBottom);
lineBottom = ea.getElement(lineBottomId);
lineBottom.polygon = pathEl.polygon;
tempElementIDs.push(lineBottomId);
}
return [line, isLeftToRight, lineBottom];
} else {
new Notice("Could not extract enough points from SVG path.");
}
} else {
new Notice("No path element found in SVG.");
}
}
return [pathEl, isLeftToRight, null];
}
/**
* Converts an ellipse element to a line element
* @param {Object} ellipse - The ellipse element to convert
* @param {number} pointDensity - Optional number of points to generate (defaults to 64)
* @returns {string} The ID of the created line element
*/
function ellipseToLine(ellipse, pointDensity = 64) {
if (!ellipse || ellipse.type !== "ellipse") {
throw new Error("Input must be an ellipse element");
}
// Calculate points along the ellipse perimeter
const stepSize = (Math.PI * 2) / pointDensity;
const points = drawEllipse(
ellipse.x,
ellipse.y,
ellipse.width,
ellipse.height,
ellipse.angle,
0,
Math.PI * 2,
stepSize
);
// Save original styling to apply to the new line
const originalStyling = {
strokeColor: ellipse.strokeColor,
strokeWidth: ellipse.strokeWidth,
backgroundColor: ellipse.backgroundColor,
fillStyle: ellipse.fillStyle,
roughness: ellipse.roughness,
strokeSharpness: ellipse.strokeSharpness,
frameId: ellipse.frameId,
groupIds: [...ellipse.groupIds],
opacity: ellipse.opacity
};
// Use current style
const prevStyle = {...ea.style};
// Apply ellipse styling to the line
ea.style.strokeColor = originalStyling.strokeColor;
ea.style.strokeWidth = originalStyling.strokeWidth;
ea.style.backgroundColor = originalStyling.backgroundColor;
ea.style.fillStyle = originalStyling.fillStyle;
ea.style.roughness = originalStyling.roughness;
ea.style.strokeSharpness = originalStyling.strokeSharpness;
ea.style.opacity = originalStyling.opacity;
// Create the line and close it
const lineId = ea.addLine(points);
const line = ea.getElement(lineId);
// Make it a polygon to close the path
line.polygon = true;
// Transfer grouping and frame information
line.frameId = originalStyling.frameId;
line.groupIds = originalStyling.groupIds;
// Restore previous style
ea.style = prevStyle;
return ea.getElement(lineId);
// Helper function from the Split Ellipse script
function drawEllipse(x, y, width, height, angle = 0, start = 0, end = Math.PI*2, step = Math.PI/32) {
const ellipse = (t) => {
const spanningVector = rotateVector([width/2*Math.cos(t), height/2*Math.sin(t)], angle);
const baseVector = [x+width/2, y+height/2];
return addVectors([baseVector, spanningVector]);
}
if(end <= start) end = end + Math.PI*2;
let points = [];
const almostEnd = end - step/2;
for (let t = start; t < almostEnd; t = t + step) {
points.push(ellipse(t));
}
points.push(ellipse(end));
return points;
}
function rotateVector(vec, ang) {
var cos = Math.cos(ang);
var sin = Math.sin(ang);
return [vec[0] * cos - vec[1] * sin, vec[0] * sin + vec[1] * cos];
}
function addVectors(vectors) {
return vectors.reduce((acc, vec) => [acc[0] + vec[0], acc[1] + vec[1]], [0, 0]);
}
}
/**
* Converts a rectangle element to a line element
* @param {Object} rectangle - The rectangle element to convert
* @param {number} pointDensity - Optional number of points to generate for curved segments (defaults to 16)
* @returns {string} The ID of the created line element
*/
function rectangleToLine(rectangle, pointDensity = 16) {
if (!rectangle || rectangle.type !== "rectangle") {
throw new Error("Input must be a rectangle element");
}
// Save original styling to apply to the new line
const originalStyling = {
strokeColor: rectangle.strokeColor,
strokeWidth: rectangle.strokeWidth,
backgroundColor: rectangle.backgroundColor,
fillStyle: rectangle.fillStyle,
roughness: rectangle.roughness,
strokeSharpness: rectangle.strokeSharpness,
frameId: rectangle.frameId,
groupIds: [...rectangle.groupIds],
opacity: rectangle.opacity
};
// Use current style
const prevStyle = {...ea.style};
// Apply rectangle styling to the line
ea.style.strokeColor = originalStyling.strokeColor;
ea.style.strokeWidth = originalStyling.strokeWidth;
ea.style.backgroundColor = originalStyling.backgroundColor;
ea.style.fillStyle = originalStyling.fillStyle;
ea.style.roughness = originalStyling.roughness;
ea.style.strokeSharpness = originalStyling.strokeSharpness;
ea.style.opacity = originalStyling.opacity;
// Calculate points for the rectangle perimeter
const points = generateRectanglePoints(rectangle, pointDensity);
// Create the line and close it
const lineId = ea.addLine(points);
const line = ea.getElement(lineId);
// Make it a polygon to close the path
line.polygon = true;
// Transfer grouping and frame information
line.frameId = originalStyling.frameId;
line.groupIds = originalStyling.groupIds;
// Restore previous style
ea.style = prevStyle;
return ea.getElement(lineId);
// Helper function to generate rectangle points with optional rounded corners
function generateRectanglePoints(rectangle, pointDensity) {
const { x, y, width, height, angle = 0 } = rectangle;
const centerX = x + width / 2;
const centerY = y + height / 2;
// If no roundness, create a simple rectangle
if (!rectangle.roundness) {
const corners = [
[x, y], // top-left
[x + width, y], // top-right
[x + width, y + height], // bottom-right
[x, y + height], // bottom-left
[x,y] //origo
];
// Apply rotation if needed
if (angle !== 0) {
return corners.map(point => rotatePoint(point, [centerX, centerY], angle));
}
return corners;
}
// Handle rounded corners
const points = [];
// Calculate corner radius using Excalidraw's algorithm
const cornerRadius = getCornerRadius(Math.min(width, height), rectangle);
const clampedRadius = Math.min(cornerRadius, width / 2, height / 2);
// Corner positions
const topLeft = [x + clampedRadius, y + clampedRadius];
const topRight = [x + width - clampedRadius, y + clampedRadius];
const bottomRight = [x + width - clampedRadius, y + height - clampedRadius];
const bottomLeft = [x + clampedRadius, y + height - clampedRadius];
// Add top-left corner arc
points.push(...createArc(
topLeft[0], topLeft[1], clampedRadius, Math.PI, Math.PI * 1.5, pointDensity));
// Add top edge
points.push([x + clampedRadius, y], [x + width - clampedRadius, y]);
// Add top-right corner arc
points.push(...createArc(
topRight[0], topRight[1], clampedRadius, Math.PI * 1.5, Math.PI * 2, pointDensity));
// Add right edge
points.push([x + width, y + clampedRadius], [x + width, y + height - clampedRadius]);
// Add bottom-right corner arc
points.push(...createArc(
bottomRight[0], bottomRight[1], clampedRadius, 0, Math.PI * 0.5, pointDensity));
// Add bottom edge
points.push([x + width - clampedRadius, y + height], [x + clampedRadius, y + height]);
// Add bottom-left corner arc
points.push(...createArc(
bottomLeft[0], bottomLeft[1], clampedRadius, Math.PI * 0.5, Math.PI, pointDensity));
// Add left edge
points.push([x, y + height - clampedRadius], [x, y + clampedRadius]);
// Apply rotation if needed
if (angle !== 0) {
return points.map(point => rotatePoint(point, [centerX, centerY], angle));
}
return points;
}
// Helper function to create an arc of points
function createArc(centerX, centerY, radius, startAngle, endAngle, pointDensity) {
const points = [];
const angleStep = (endAngle - startAngle) / pointDensity;
for (let i = 0; i <= pointDensity; i++) {
const angle = startAngle + i * angleStep;
const x = centerX + radius * Math.cos(angle);
const y = centerY + radius * Math.sin(angle);
points.push([x, y]);
}
return points;
}
// Helper function to rotate a point around a center
function rotatePoint(point, center, angle) {
const sin = Math.sin(angle);
const cos = Math.cos(angle);
// Translate point to origin
const x = point[0] - center[0];
const y = point[1] - center[1];
// Rotate point
const xNew = x * cos - y * sin;
const yNew = x * sin + y * cos;
// Translate point back
return [xNew + center[0], yNew + center[1]];
}
}
function getCornerRadius(x, element) {
const fixedRadiusSize = element.roundness?.value ?? 32;
const CUTOFF_SIZE = fixedRadiusSize / 0.25;
if (x <= CUTOFF_SIZE) {
return x * 0.25;
}
return fixedRadiusSize;
}
/**
* Converts a diamond element to a line element
* @param {Object} diamond - The diamond element to convert
* @param {number} pointDensity - Optional number of points to generate for curved segments (defaults to 16)
* @returns {string} The ID of the created line element
*/
function diamondToLine(diamond, pointDensity = 16) {
if (!diamond || diamond.type !== "diamond") {
throw new Error("Input must be a diamond element");
}
// Save original styling to apply to the new line
const originalStyling = {
strokeColor: diamond.strokeColor,
strokeWidth: diamond.strokeWidth,
backgroundColor: diamond.backgroundColor,
fillStyle: diamond.fillStyle,
roughness: diamond.roughness,
strokeSharpness: diamond.strokeSharpness,
frameId: diamond.frameId,
groupIds: [...diamond.groupIds],
opacity: diamond.opacity
};
// Use current style
const prevStyle = {...ea.style};
// Apply diamond styling to the line
ea.style.strokeColor = originalStyling.strokeColor;
ea.style.strokeWidth = originalStyling.strokeWidth;
ea.style.backgroundColor = originalStyling.backgroundColor;
ea.style.fillStyle = originalStyling.fillStyle;
ea.style.roughness = originalStyling.roughness;
ea.style.strokeSharpness = originalStyling.strokeSharpness;
ea.style.opacity = originalStyling.opacity;
// Calculate points for the diamond perimeter
const points = generateDiamondPoints(diamond, pointDensity);
// Create the line and close it
const lineId = ea.addLine(points);
const line = ea.getElement(lineId);
// Make it a polygon to close the path
line.polygon = true;
// Transfer grouping and frame information
line.frameId = originalStyling.frameId;
line.groupIds = originalStyling.groupIds;
// Restore previous style
ea.style = prevStyle;
return ea.getElement(lineId);
function generateDiamondPoints(diamond, pointDensity) {
const { x, y, width, height, angle = 0 } = diamond;
const cx = x + width / 2;
const cy = y + height / 2;
// Diamond corners
const top = [cx, y];
const right = [x + width, cy];
const bottom = [cx, y + height];
const left = [x, cy];
if (!diamond.roundness) {
const corners = [top, right, bottom, left, top];
if (angle !== 0) {
return corners.map(pt => rotatePoint(pt, [cx, cy], angle));
}
return corners;
}
// Clamp radius
const r = Math.min(
getCornerRadius(Math.min(width, height) / 2, diamond),
width / 2,
height / 2
);
// For a diamond, the rounded corner is a *bezier* between the two adjacent edge points, not a circular arc.
// Excalidraw uses a quadratic bezier for each corner, with the control point at the corner itself.
// Calculate edge directions
function sub(a, b) { return [a[0] - b[0], a[1] - b[1]]; }
function add(a, b) { return [a[0] + b[0], a[1] + b[1]]; }
function norm([x, y]) {
const len = Math.hypot(x, y);
return [x / len, y / len];
}
function scale([x, y], s) { return [x * s, y * s]; }
// For each corner, move along both adjacent edges by r to get arc endpoints
// Order: top, right, bottom, left
const corners = [top, right, bottom, left];
const next = [right, bottom, left, top];
const prev = [left, top, right, bottom];
// For each corner, calculate the two points where the straight segments meet the arc
const arcPoints = [];
for (let i = 0; i < 4; ++i) {
const c = corners[i];
const n = next[i];
const p = prev[i];
const toNext = norm(sub(n, c));
const toPrev = norm(sub(p, c));
arcPoints.push([
add(c, scale(toPrev, r)), // start of arc (from previous edge)
add(c, scale(toNext, r)), // end of arc (to next edge)
c // control point for bezier
]);
}
// Helper: quadratic bezier between p0 and p2 with control p1
function bezier(p0, p1, p2, density) {
const pts = [];
for (let i = 0; i <= density; ++i) {
const t = i / density;
const mt = 1 - t;
pts.push([
mt*mt*p0[0] + 2*mt*t*p1[0] + t*t*p2[0],
mt*mt*p0[1] + 2*mt*t*p1[1] + t*t*p2[1]
]);
}
return pts;
}
// Build path: for each corner, straight line to arc start, then bezier to arc end using corner as control
let pts = [];
for (let i = 0; i < 4; ++i) {
const prevArc = arcPoints[(i + 3) % 4];
const arc = arcPoints[i];
if (i === 0) {
pts.push(arc[0]);
} else {
pts.push(arc[0]);
}
// Quadratic bezier from arc[0] to arc[1] with control at arc[2] (the corner)
pts.push(...bezier(arc[0], arc[2], arc[1], pointDensity));
}
pts.push(arcPoints[0][0]); // close
if (angle !== 0) {
return pts.map(pt => rotatePoint(pt, [cx, cy], angle));
}
return pts;
}
// Helper function to create an arc between two points
function createArcBetweenPoints(startPoint, endPoint, centerX, centerY, pointDensity) {
const startAngle = Math.atan2(startPoint[1] - centerY, startPoint[0] - centerX);
const endAngle = Math.atan2(endPoint[1] - centerY, endPoint[0] - centerX);
// Ensure angles are in correct order for arc drawing
let adjustedEndAngle = endAngle;
if (endAngle < startAngle) {
adjustedEndAngle += 2 * Math.PI;
}
const points = [];
const angleStep = (adjustedEndAngle - startAngle) / pointDensity;
// Start with the straight line to arc start
points.push(startPoint);
// Create arc points
for (let i = 1; i < pointDensity; i++) {
const angle = startAngle + i * angleStep;
const distance = Math.hypot(startPoint[0] - centerX, startPoint[1] - centerY);
const x = centerX + distance * Math.cos(angle);
const y = centerY + distance * Math.sin(angle);
points.push([x, y]);
}
// Add the end point of the arc
points.push(endPoint);
return points;
}
// Helper function to rotate a point around a center
function rotatePoint(point, center, angle) {
const sin = Math.sin(angle);
const cos = Math.cos(angle);
// Translate point to origin
const x = point[0] - center[0];
const y = point[1] - center[1];
// Rotate point
const xNew = x * cos - y * sin;
const yNew = x * sin + y * cos;
// Translate point back
return [xNew + center[0], yNew + center[1]];
}
}
async function addToView() {
ea.getElements()
.filter(el=>el.type==="text" && el.text === " " && !el.isDeleted)
.forEach(el=>tempElementIDs.push(el.id));
tempElementIDs.forEach(elID=>{
delete ea.elementsDict[elID];
});
await ea.addElementsToView(false, false, true);
}
// ------------------------------------------------------------
// Convert any shape type to a series of points along a path
// In practice this only applies to ellipses and straight lines
// ------------------------------------------------------------
async function fitTextToShape() {
// Swap to the bottom path if it's a freedraw and the user reversed the text or placed it inside
let activePathEl = pathEl;
if (originalPathType === "freedraw" && pathElBottom) {
if (currentPlaceInside) {
activePathEl = pathElBottom;
}
}
const pathPoints = calculatePathPoints(activePathEl);
let pathLength = 0;
for (let i = 1; i < pathPoints.length; i++) {
const dx = pathPoints[i][0] - pathPoints[i-1][0];
const dy = pathPoints[i][1] - pathPoints[i-1][1];
pathLength += Math.sqrt(dx*dx + dy*dy);
}
const textWidth = ea.measureText(currentText).width;
let offsetValue = 0;
const effectiveCurrentOffsetPct = originalPathDirectionLR ? -currentOffsetPct : currentOffsetPct;
if (pathEl.polygon) {
// With double-loops removed from the path array, pathLength is now 1 round.
// 100 divisor maps +/- 50% slider range exactly to 1 full loop length.
offsetValue = (effectiveCurrentOffsetPct / 100) * pathLength;
} else {
// Open path calibration
if (effectiveCurrentOffsetPct < 0) {
offsetValue = (Math.abs(effectiveCurrentOffsetPct) / 50) * -textWidth;
} else {
offsetValue = (effectiveCurrentOffsetPct / 50) * pathLength;
}
}
const pathID = ea.generateElementId();
let objectIDs = [];
distributeTextAlongPath(
currentText,
pathPoints,
pathID,
objectIDs,
offsetValue,
isLeftToRight,
pathEl.polygon,
currentIsReversed,
currentPlaceInside,
activePathEl === pathElBottom // Pass a flag to invert dy if using the bottom edge
);
const groupID = ea.addToGroup(objectIDs);
generatedIDs.push(...objectIDs);
await addToView();
const selectedElementIds = Object.fromEntries(
objectIDs.map(id => [id, true])
);
ea.viewUpdateScene({
appState: {
selectedElementIds,
selectedGroupIds: { [groupID]: true }
}
});
}