mirror of
https://github.com/zsviczian/obsidian-excalidraw-plugin.git
synced 2025-08-06 05:46:28 +00:00
1078 lines
36 KiB
Markdown
1078 lines
36 KiB
Markdown
/*
|
|

|
|
|
|
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 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;
|
|
|
|
const aspectRatio = pathEl.width/pathEl.height;
|
|
const isCircle = pathEl.type === "ellipse" && aspectRatio > 0.9 && aspectRatio < 1.1;
|
|
const isPathLinear = ["line", "arrow", "freedraw"].includes(pathEl.type);
|
|
if(!isCircle && !isPathLinear) {
|
|
ea.copyViewElementsToEAforEditing([pathEl]);
|
|
pathEl = ea.getElement(pathEl.id);
|
|
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;
|
|
if(
|
|
(["line", "arrow"].includes(pathEl.type) && pathEl.roundness !== null) ||
|
|
pathEl.type === "freedraw"
|
|
) {
|
|
[pathEl, isLeftToRight] = await convertBezierToPoints();
|
|
}
|
|
|
|
// ---------------------------------------------------------
|
|
// Retreive original text from text-on-path customData
|
|
// ---------------------------------------------------------
|
|
const initialOffset = textEl?.customData?.text2Path?.offset ?? 0;
|
|
const initialArchAbove = textEl?.customData?.text2Path?.archAbove ?? true;
|
|
|
|
const text = (await utils.inputPrompt({
|
|
header: "Edit",
|
|
value: textEl?.customData?.text2Path
|
|
? textEl.customData.text2Path.text
|
|
: textEl?.text ?? "",
|
|
lines: 3,
|
|
customComponents: isCircle ? circleArchControl : offsetControl,
|
|
draggable: true,
|
|
}))?.replace(" \n"," ").replace("\n ", " ").replace("\n"," ");
|
|
|
|
if(!text) {
|
|
new Notice("No text provided!");
|
|
return;
|
|
}
|
|
|
|
// -------------------------------------
|
|
// Copy font style to ExcalidrawAutomate
|
|
// -------------------------------------
|
|
ea.style.fontSize = fontSize;
|
|
ea.style.fontFamily = fontFamily;
|
|
ea.style.strokeColor = textEl?.strokeColor ?? st.currentItemStrokeColor;
|
|
ea.style.opacity = textEl?.opacity ?? st.currentItemOpacity;
|
|
|
|
// -----------------------------------
|
|
// Delete previous text arch if exists
|
|
// -----------------------------------
|
|
if (textEl?.customData?.text2Path) {
|
|
const pathID = textEl.customData.text2Path.pathID;
|
|
const elements = ea.getViewElements().filter(el=>el.customData?.text2Path && el.customData.text2Path.pathID === pathID);
|
|
ea.copyViewElementsToEAforEditing(elements);
|
|
ea.getElements().forEach(el=>{el.isDeleted = true;});
|
|
} else {
|
|
if(textEl) {
|
|
ea.copyViewElementsToEAforEditing([textEl]);
|
|
ea.getElements().forEach(el=>{el.isDeleted = true;});
|
|
}
|
|
}
|
|
|
|
if(isCircle) {
|
|
await fitTextToCircle();
|
|
} else {
|
|
await fitTextToShape();
|
|
}
|
|
|
|
|
|
//----------------------------------------
|
|
//----------------------------------------
|
|
// 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 create the circle arch position control in the dialog
|
|
function circleArchControl(container) {
|
|
if (typeof win.ArchPosition === "undefined") {
|
|
win.ArchPosition = initialArchAbove;
|
|
}
|
|
|
|
const archContainer = container.createDiv();
|
|
archContainer.style.display = "flex";
|
|
archContainer.style.alignItems = "center";
|
|
archContainer.style.marginBottom = "8px";
|
|
|
|
const label = archContainer.createEl("label");
|
|
label.textContent = "Arch position:";
|
|
label.style.marginRight = "10px";
|
|
label.style.fontWeight = "bold";
|
|
|
|
const select = archContainer.createEl("select");
|
|
|
|
// Add options for above/below
|
|
const aboveOption = select.createEl("option");
|
|
aboveOption.value = "true";
|
|
aboveOption.text = "Above";
|
|
|
|
const belowOption = select.createEl("option");
|
|
belowOption.value = "false";
|
|
belowOption.text = "Below";
|
|
|
|
// Set the default value
|
|
select.value = win.ArchPosition ? "true" : "false";
|
|
|
|
select.addEventListener("change", (e) => {
|
|
win.ArchPosition = e.target.value === "true";
|
|
});
|
|
}
|
|
|
|
// Function to create the offset input control in the dialog
|
|
function offsetControl(container) {
|
|
if (!win.TextArchOffset) win.TextArchOffset = initialOffset.toString();
|
|
|
|
const offsetContainer = container.createDiv();
|
|
offsetContainer.style.display = "flex";
|
|
offsetContainer.style.alignItems = "center";
|
|
offsetContainer.style.marginBottom = "8px";
|
|
|
|
const label = offsetContainer.createEl("label");
|
|
label.textContent = "Offset (px):";
|
|
label.style.marginRight = "10px";
|
|
label.style.fontWeight = "bold";
|
|
|
|
const input = offsetContainer.createEl("input");
|
|
input.type = "number";
|
|
input.value = win.TextArchOffset;
|
|
input.placeholder = "0";
|
|
input.style.width = "60px";
|
|
input.style.padding = "4px";
|
|
|
|
input.addEventListener("input", (e) => {
|
|
const val = e.target.value.trim();
|
|
if (val === "" || !isNaN(parseInt(val))) {
|
|
win.TextArchOffset = val;
|
|
} else {
|
|
e.target.value = win.TextArchOffset || "0";
|
|
}
|
|
});
|
|
}
|
|
|
|
// Function to convert any shape to a series of points along its path
|
|
function calculatePathPoints(element) {
|
|
// Handle lines, arrows, and freedraw paths
|
|
const points = [];
|
|
|
|
// Get absolute coordinates of all points
|
|
const absolutePoints = element.points.map(point => [
|
|
point[0] + element.x,
|
|
point[1] + element.y
|
|
]);
|
|
|
|
// 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);
|
|
const angle = Math.atan2(dy, dx);
|
|
|
|
segments.push({
|
|
p0, p1, length: segmentLength, angle
|
|
});
|
|
}
|
|
|
|
// Sample points along each segment
|
|
for (const segment of segments) {
|
|
// Number of points to sample depends on segment length
|
|
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) {
|
|
if (pathPoints.length === 0) return;
|
|
|
|
const {baseline} = ExcalidrawLib.getFontMetrics(ea.style.fontFamily, ea.style.fontSize);
|
|
|
|
const originalText = text;
|
|
if(!isLeftToRight) {
|
|
text = text.split('').reverse().join('');
|
|
}
|
|
|
|
// Calculate path length
|
|
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));
|
|
|
|
pathSegments.push({
|
|
startPoint: pathPoints[i-1],
|
|
endPoint: pathPoints[i],
|
|
length: segLength,
|
|
startDist: accumulatedLength,
|
|
endDist: accumulatedLength + segLength
|
|
});
|
|
|
|
accumulatedLength += segLength;
|
|
pathLength += segLength;
|
|
}
|
|
|
|
// Precompute substring widths for kerning-accurate placement
|
|
const substrWidths = [];
|
|
for (let i = 0; i <= text.length; i++) {
|
|
substrWidths.push(ea.measureText(text.substring(0, i)).width);
|
|
}
|
|
|
|
// The actual distance along the path for a character's center is `offset + charCenter`.
|
|
for (let i = 0; i < text.length; i++) {
|
|
const character = text.substring(i, i+1);
|
|
const charHeight = ea.measureText(character).height;
|
|
|
|
// Advance for this character (kerning-aware)
|
|
const prevWidth = substrWidths[i];
|
|
const nextWidth = substrWidths[i+1];
|
|
const charAdvance = nextWidth - prevWidth;
|
|
|
|
// Center of this character in the full text
|
|
const charCenter = isLeftToRight
|
|
? (i === 0 ? charAdvance / 2 : prevWidth + charAdvance / 2)
|
|
: prevWidth + charAdvance / 2; // For RTL, text is reversed, so this logic still holds for the reversed string
|
|
|
|
// Target distance along the path for the character's center
|
|
const targetDistOnPath = offset + charCenter;
|
|
|
|
// Find point on path for the BASELINE at the center of this character
|
|
let pointInfo = getPointAtDistance(targetDistOnPath, pathSegments, pathLength);
|
|
let x, y, angle;
|
|
if (pointInfo) {
|
|
x = pointInfo.x;
|
|
y = pointInfo.y;
|
|
angle = pointInfo.angle;
|
|
} else {
|
|
// We're beyond the path, continue in the direction of the last segment
|
|
const lastSegment = pathSegments[pathSegments.length - 1];
|
|
if (!lastSegment) { // Should not happen if pathPoints.length > 0
|
|
// Fallback if somehow pathSegments is empty but pathPoints was not
|
|
x = pathPoints[0]?.[0] ?? 0;
|
|
y = pathPoints[0]?.[1] ?? 0;
|
|
angle = pathPoints[0]?.[2] ?? 0;
|
|
} else {
|
|
const lastPoint = lastSegment.endPoint;
|
|
const secondLastPoint = lastSegment.startPoint;
|
|
angle = Math.atan2(
|
|
lastPoint[1] - secondLastPoint[1],
|
|
lastPoint[0] - secondLastPoint[0]
|
|
);
|
|
|
|
// Calculate how far past the end of the path this character's center should be
|
|
const distanceFromEnd = targetDistOnPath - pathLength;
|
|
|
|
// Position character extending beyond the path
|
|
x = lastPoint[0] + Math.cos(angle) * distanceFromEnd;
|
|
y = lastPoint[1] + Math.sin(angle) * distanceFromEnd;
|
|
}
|
|
}
|
|
|
|
// Use baseline offset directly (already in px)
|
|
const baselineOffset = baseline;
|
|
|
|
// Place the character so its baseline is on the path and horizontally centered
|
|
const drawX = x - charAdvance / 2;
|
|
const drawY = y - baselineOffset/2;
|
|
|
|
ea.style.angle = angle + (isLeftToRight ? 0 : Math.PI);
|
|
const charID = ea.addText(drawX, drawY, character);
|
|
ea.addAppendUpdateCustomData(charID, {
|
|
text2Path: {pathID, text: originalText, pathElID, offset}
|
|
});
|
|
objectIDs.push(charID);
|
|
}
|
|
|
|
transposeElements(new Set(objectIDs));
|
|
}
|
|
|
|
// Helper function to find a point at a specific distance along the path
|
|
function getPointAtDistance(distance, segments, totalLength) {
|
|
if (distance > totalLength) return null;
|
|
|
|
// Find the segment where this distance falls
|
|
const segment = segments.find(seg =>
|
|
distance >= seg.startDist && distance <= seg.endDist
|
|
);
|
|
|
|
if (!segment) return null;
|
|
|
|
// Calculate position within the segment
|
|
const t = (distance - segment.startDist) / segment.length;
|
|
const [x1, y1, angle1] = segment.startPoint;
|
|
const [x2, y2, angle2] = segment.endPoint;
|
|
|
|
// Linear interpolation
|
|
const x = x1 + t * (x2 - x1);
|
|
const y = y1 + t * (y2 - y1);
|
|
|
|
// Use the segment's angle
|
|
const angle = angle1;
|
|
|
|
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 {topX, topY, width, height} = ea.getBoundingBox(ea.getElements());
|
|
const svgElement = await ea.createSVG(undefined,false,undefined,undefined,'light',svgPadding);
|
|
ea.clear();
|
|
return {
|
|
svgElement,
|
|
boundingBox: {topX, topY, width, height}
|
|
};
|
|
}
|
|
|
|
const {svgElement, boundingBox} = await getSVGForPath();
|
|
|
|
if (svgElement) {
|
|
// Find the <path> element in the SVG
|
|
const pathElSVG = svgElement.querySelector('path');
|
|
if (pathElSVG) {
|
|
// Use SVGPathElement's getPointAtLength to sample points along the path
|
|
function samplePathPoints(pathElSVG, step = 15) {
|
|
const points = [];
|
|
const totalLength = pathElSVG.getTotalLength();
|
|
for (let len = 0; len <= totalLength; len += step) {
|
|
const pt = pathElSVG.getPointAtLength(len);
|
|
points.push([pt.x, pt.y]);
|
|
}
|
|
// Ensure last point is included
|
|
const lastPt = pathElSVG.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(pathElSVG, 15); // 15 px step, adjust for smoothness
|
|
|
|
// --- Map SVG coordinates back to Excalidraw coordinate system ---
|
|
// Get the <g> transform
|
|
const g = pathElSVG.closest('g');
|
|
let dx = 0, dy = 0;
|
|
if (g) {
|
|
const m = g.getAttribute('transform');
|
|
// Parse translate(x y) from transform
|
|
const match = m && m.match(/translate\(([-\d.]+)[ ,]([-\d.]+)/);
|
|
if (match) {
|
|
dx = parseFloat(match[1]);
|
|
dy = parseFloat(match[2]);
|
|
}
|
|
}
|
|
|
|
// Calculate the scale factor from SVG space to actual element space
|
|
const svgContentWidth = boundingBox.width;
|
|
const svgContentHeight = boundingBox.height;
|
|
|
|
// The transform dy includes both padding and element positioning within SVG
|
|
// We need to subtract the padding from the transform to get the actual element offset
|
|
const elementOffsetY = dy - svgPadding;
|
|
|
|
isLeftToRight = pathEl.points[pathEl.points.length-1][0] >=0;
|
|
|
|
points = points.map(([x, y]) => [
|
|
boundingBox.topX + (x - dx) + svgPadding + (isLeftToRight ? 0 : boundingBox.width*2),
|
|
pathEl.y + y
|
|
]);
|
|
|
|
// For freedraw paths, we typically want only the top half of the outline
|
|
// The SVG path traces the entire perimeter, but we want just the top edge
|
|
// Trim to get approximately the first half of the path points
|
|
if (points.length > 3) {
|
|
if(!isLeftToRight && pathEl.type === "freedraw") {
|
|
points = points.reverse();
|
|
}
|
|
points = points.slice(0, Math.ceil(points.length / 2)-2); //DO NOT REMOVE THE -2 !!!!!
|
|
}
|
|
|
|
if (points.length > 1) {
|
|
ea.clear();
|
|
ea.style.backgroundColor="transparent";
|
|
ea.style.roughness = 0;
|
|
ea.style.strokeWidth = 1;
|
|
ea.style.roundness = null;
|
|
const lineId = ea.addLine(points);
|
|
const line = ea.getElement(lineId);
|
|
tempElementIDs.push(lineId);
|
|
return [line, isLeftToRight];
|
|
} else {
|
|
new Notice("Could not extract enough points from SVG path.");
|
|
}
|
|
} else {
|
|
new Notice("No path element found in SVG.");
|
|
}
|
|
}
|
|
return [pathEl, isLeftToRight];
|
|
}
|
|
|
|
/**
|
|
* 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);
|
|
}
|
|
|
|
async function fitTextToCircle() {
|
|
const r = (pathEl.width+pathEl.height)/4 + fontHeight/2;
|
|
const archAbove = win.ArchPosition ?? initialArchAbove;
|
|
|
|
if (textEl?.customData?.text2Path) {
|
|
const pathID = textEl.customData.text2Path.pathID;
|
|
const elements = ea.getViewElements().filter(el=>el.customData?.text2Path && el.customData.text2Path.pathID === pathID);
|
|
ea.copyViewElementsToEAforEditing(elements);
|
|
} else {
|
|
if(textEl) ea.copyViewElementsToEAforEditing([textEl]);
|
|
}
|
|
ea.getElements().forEach(el=>{el.isDeleted = true;});
|
|
|
|
// Define center point of the ellipse
|
|
const centerX = pathEl.x + r - fontHeight/2;
|
|
const centerY = pathEl.y + r - fontHeight/2;
|
|
|
|
function circlePoint(angle) {
|
|
// Calculate point exactly on the ellipse's circumference
|
|
return [
|
|
centerX + r * Math.sin(angle),
|
|
centerY - r * Math.cos(angle)
|
|
];
|
|
}
|
|
|
|
// Calculate the text width to center it properly
|
|
const textWidth = ea.measureText(text).width;
|
|
|
|
// Calculate starting angle based on arch position
|
|
// For "Arch above", start at top (0 radians)
|
|
// For "Arch below", start at bottom (π radians)
|
|
const startAngle = archAbove ? 0 : Math.PI;
|
|
|
|
// Calculate how much of the circle arc the text will occupy
|
|
const arcLength = textWidth / r;
|
|
|
|
// Set the starting rotation to center the text at the top/bottom point
|
|
let rot = startAngle - arcLength / 2;
|
|
|
|
const pathID = ea.generateElementId();
|
|
|
|
let objectIDs = [];
|
|
|
|
for(
|
|
archAbove ? i=0 : i=text.length-1;
|
|
archAbove ? i<text.length : i>=0;
|
|
archAbove ? i++ : i--
|
|
) {
|
|
const character = text.substring(i,i+1);
|
|
const charMetrics = ea.measureText(character);
|
|
const charWidth = charMetrics.width / r;
|
|
// Adjust rotation to position the current character
|
|
const charAngle = rot + charWidth / 2;
|
|
// Calculate point on the circle's edge
|
|
const [baseX, baseY] = circlePoint(charAngle);
|
|
|
|
// Center each character horizontally and vertically
|
|
// Use the actual character width and height for precise placement
|
|
const charPixelWidth = charMetrics.width;
|
|
const charPixelHeight = charMetrics.height;
|
|
// Place the character so its center is on the circle
|
|
const x = baseX - charPixelWidth / 2;
|
|
const y = baseY - charPixelHeight / 2;
|
|
|
|
// Set rotation for the character to align with the tangent of the circle
|
|
// No additional 90 degree rotation needed
|
|
ea.style.angle = charAngle + (archAbove ? 0 : Math.PI);
|
|
|
|
const charID = ea.addText(x, y, character);
|
|
ea.addAppendUpdateCustomData(charID, {
|
|
text2Path: {pathID, text, pathElID, archAbove, offset: 0}
|
|
});
|
|
objectIDs.push(charID);
|
|
|
|
rot += charWidth;
|
|
}
|
|
|
|
const groupID = ea.addToGroup(objectIDs);
|
|
const letterSet = new Set(objectIDs);
|
|
await addToView();
|
|
ea.selectElementsInView(ea.getViewElements().filter(el=>letterSet.has(el.id) && !el.isDeleted));
|
|
}
|
|
|
|
// ------------------------------------------------------------
|
|
// Convert any shape type to a series of points along a path
|
|
// In practice this only applies to ellipses and streight lines
|
|
// ------------------------------------------------------------
|
|
async function fitTextToShape() {
|
|
const pathPoints = calculatePathPoints(pathEl);
|
|
|
|
// Generate a unique ID for this text arch
|
|
const pathID = ea.generateElementId();
|
|
let objectIDs = [];
|
|
|
|
// Place text along the path with natural spacing
|
|
const offsetValue = (parseInt(win.TextArchOffset ?? initialOffset) || 0);
|
|
|
|
distributeTextAlongPath(text, pathPoints, pathID, objectIDs, offsetValue, isLeftToRight);
|
|
|
|
// Add all text characters to a group
|
|
const groupID = ea.addToGroup(objectIDs);
|
|
const letterSet = new Set(objectIDs);
|
|
await addToView();
|
|
ea.selectElementsInView(ea.getViewElements().filter(el=>letterSet.has(el.id) && !el.isDeleted));
|
|
} |