/* ![](https://raw.githubusercontent.com/zsviczian/obsidian-excalidraw-plugin/master/images/scripts-text-to-path.jpg) 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. ```javascript */ 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 } } }); }