From dd7abe2547f7640faf84b190ccd3a167f6eee73b Mon Sep 17 00:00:00 2001 From: zsviczian Date: Sun, 25 May 2025 19:18:00 +0200 Subject: [PATCH] 0.18.0-17 - Text to Path --- ea-scripts/Fit Text to Path.md | 1079 +++++++++++++++++ .../{Text Arch.svg => Fit Text to Path.svg} | 0 ea-scripts/Text Arch.md | 613 ---------- ea-scripts/To Line.md | 476 ++++++++ ea-scripts/directory-info.json | 2 +- ea-scripts/index-new.md | 26 +- ...text-aura.jpg => scripts-text-to-path.jpg} | Bin package.json | 2 +- src/core/main.ts | 2 +- src/lang/locale/en.ts | 3 +- src/shared/Dialogs/Messages.ts | 2 + src/types/excalidrawLib.d.ts | 13 +- src/types/excalidrawViewTypes.ts | 1 + src/utils/utils.ts | 55 + src/view/ExcalidrawView.ts | 8 +- src/view/managers/CanvasNodeFactory.ts | 8 +- 16 files changed, 1651 insertions(+), 639 deletions(-) create mode 100644 ea-scripts/Fit Text to Path.md rename ea-scripts/{Text Arch.svg => Fit Text to Path.svg} (100%) delete mode 100644 ea-scripts/Text Arch.md create mode 100644 ea-scripts/To Line.md rename images/{scripts-text-aura.jpg => scripts-text-to-path.jpg} (100%) diff --git a/ea-scripts/Fit Text to Path.md b/ea-scripts/Fit Text to Path.md new file mode 100644 index 0000000..8ae1cc7 --- /dev/null +++ b/ea-scripts/Fit Text to Path.md @@ -0,0 +1,1079 @@ +/* +![](https://raw.githubusercontent.com/zsviczian/obsidian-excalidraw-plugin/master/images/text-arch.jpg) + +This script allows you to fit a text element along a selected path (line, arrow, freedraw, ellipse, rectangle, or diamond) in Excalidraw. 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 or 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, +}))?.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 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 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() { + 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=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); + // The `spacing` parameter (ea.measureText("i").width*0.3) is not used by distributeTextAlongPath + // as character advances are now calculated from substring widths. + // Pass 0 for spacing, or remove it if it's truly unused. + // For now, let's assume it might be intended for *extra* spacing beyond natural kerning. + // However, the current distributeTextAlongPath doesn't use the `spacing` parameter. + // Let's remove charWidths, charHeights, and spacing from the call as they are not used. + 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)); +} \ No newline at end of file diff --git a/ea-scripts/Text Arch.svg b/ea-scripts/Fit Text to Path.svg similarity index 100% rename from ea-scripts/Text Arch.svg rename to ea-scripts/Fit Text to Path.svg diff --git a/ea-scripts/Text Arch.md b/ea-scripts/Text Arch.md deleted file mode 100644 index 0bd0a79..0000000 --- a/ea-scripts/Text Arch.md +++ /dev/null @@ -1,613 +0,0 @@ -/* -![](https://raw.githubusercontent.com/zsviczian/obsidian-excalidraw-plugin/master/images/text-arch.jpg) - -This script allows you to fit a text element along a selected path (line, arrow, freedraw, ellipse, rectangle, or diamond) in Excalidraw. 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 or function as a markdown link. Emojis are not supported. - -```javascript -*/ -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 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 aspectRatio = pathEl.width/pathEl.height; -const isCircle = pathEl.type === "ellipse" && aspectRatio > 0.9 && aspectRatio < 1.1; - - -// --------------------------------------------------------- -// Convert path to SVG and use real path for text placement. -// --------------------------------------------------------- -if((["line", "arrow"].includes(pathEl.type) && pathEl.roundness !== null) || ["freedraw", "rectangle", "diamond"].includes(pathEl.type)) { - pathEl = 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, -})).replace(" \n"," ").replace("\n ", " ").replace("\n"," "); - -if(!text) { - new Notice("No text provided!"); - return; -} - -// ------------------------------------- -// Copy font style to ExcalidrawAutomate -// ------------------------------------- -const st = ea.getExcalidrawAPI().getAppState(); -ea.style.fontSize = textEl?.fontSize ?? st.currentItemFontSize; -ea.style.fontFamily = textEl?.fontFamily ?? st.currentItemFontFamily; -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;}); - } -} - -// -------------------------------------------------------- -// Use original text arch algorithm in case shape is circle -// -------------------------------------------------------- -if (isCircle) { - const r = (pathEl.width+pathEl.height)/4; - 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 { - ea.copyViewElementsToEAforEditing([textEl]); - } - ea.getElements().forEach(el=>{el.isDeleted = true;}); - - // Define center point of the ellipse - const centerX = pathEl.x + r; - const centerY = pathEl.y + r; - - 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=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 ea.addElementsToView(false, false, true); - ea.selectElementsInView(ea.getViewElements().filter(el=>letterSet.has(el.id))); - return; -} - -// ------------------------------------------------------------ -// Convert any shape type to a series of points along a path -// In practice this only applies to ellipses and streight lines -// ------------------------------------------------------------ -const pathPoints = calculatePathPoints(pathEl); - - -// Calculate character metrics for spacing -const charWidths = []; -const charHeights = []; -let totalTextWidth = 0; -for (let i = 0; i < text.length; i++) { - const character = text.substring(i, i+1); - const charWidth = ea.measureText(character).width; - const charHeight = ea.measureText(character).height; - charWidths.push(charWidth); - charHeights.push(charHeight); - totalTextWidth += charWidth; -} - -// Generate a unique ID for this text arch -const pathID = ea.generateElementId(); -let objectIDs = []; - -// Place text along the path with natural spacing -const offset = isCircle ? 0 : (parseInt(win.TextArchOffset ?? initialOffset) || 0); -distributeTextAlongPath(text, pathPoints, pathID, objectIDs, charWidths, charHeights, ea.measureText("i").width*0.3, offset); - -// Add all text characters to a group -const groupID = ea.addToGroup(objectIDs); -const letterSet = new Set(objectIDs); -await ea.addElementsToView(false, false, true); -ea.selectElementsInView(ea.getViewElements().filter(el=>letterSet.has(el.id))); - -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 closed shapes by converting them to points along their perimeter - if (["ellipse", "rectangle", "diamond"].includes(element.type)) { - return getClosedShapePoints(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 get points along the perimeter of a closed shape -function getClosedShapePoints(element) { - let points = []; - - if (element.type === "ellipse") { - const centerX = element.x + element.width / 2; - const centerY = element.y + element.height / 2; - const rx = element.width / 2; - const ry = element.height / 2; - - // Sample points along the ellipse perimeter - const numPoints = 64; - for (let i = 0; i < numPoints; i++) { - const angle = (i / numPoints) * 2 * Math.PI; - const x = centerX + rx * Math.cos(angle); - const y = centerY + ry * Math.sin(angle); - - // Calculate tangent angle - const tangentAngle = angle + Math.PI / 2; // Tangent is perpendicular to radius - - points.push([x, y, tangentAngle]); - } - - // Close the loop - points.push(points[0]); - } - else if (element.type === "rectangle" || element.type === "diamond") { - let corners; - - if (element.type === "rectangle") { - const x = element.x; - const y = element.y; - const width = element.width; - const height = element.height; - - corners = [ - [x, y], // top-left - [x, y + height], // bottom-left - [x + width, y + height], // bottom-right - [x + width, y], // top-right - [x, y] // back to start - ]; - } - else { // Diamond - const x = element.x; - const y = element.y; - const width = element.width; - const height = element.height; - const centerX = x + width / 2; - const centerY = y + height / 2; - - corners = [ - [centerX, y], // top - [x + width, centerY], // right - [centerX, y + height], // bottom - [x, centerY], // left - [centerX, y] // back to top - ]; - } - - // Sample points along each side of the polygon - for (let i = 0; i < corners.length - 1; i++) { - const [x1, y1] = corners[i]; - const [x2, y2] = corners[i + 1]; - - const dx = x2 - x1; - const dy = y2 - y1; - const sideLength = Math.sqrt(dx*dx + dy*dy); - const angle = Math.atan2(dy, dx); - - // Sample points based on side length - const numPoints = Math.max(2, Math.ceil(sideLength / 5)); // 1 point every 5 pixels - - for (let j = 0; j < numPoints; j++) { - const t = j / (numPoints - 1); - const x = x1 + t * dx; - const y = y1 + t * dy; - // Fix: Don't add an additional 90 degrees for rectangle and diamond - points.push([x, y, angle]); - } - } - } - - return points; -} - -// Function to distribute text along any path -function distributeTextAlongPath(text, pathPoints, pathID, objectIDs, charWidths, charHeights, spacing, offset = 0) { - if (pathPoints.length === 0) return; - - // 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; - } - - // Total length needed for text with natural spacing - const totalTextLength = charWidths.reduce((sum, width) => sum + width, 0) + - (text.length - 1) * spacing; - - // Place characters with natural spacing - // Apply the offset to the starting position - let currentDist = offset; - - for (let i = 0; i < text.length; i++) { - const character = text.substring(i, i+1); - const charWidth = charWidths[i]; - - // Find point on path for this character - let pointInfo = getPointAtDistance(currentDist, 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]; - 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 - const distanceFromEnd = currentDist - pathLength; - - // Position character extending beyond the path - x = lastPoint[0] + Math.cos(angle) * distanceFromEnd; - y = lastPoint[1] + Math.sin(angle) * distanceFromEnd; - } - - // Add the character to the drawing - ea.style.angle = angle; - const charPixelWidth = charWidths[i]; - const charPixelHeight = charHeights[i]; - const charID = ea.addText(x - charPixelWidth/2, y - charPixelHeight/2, character); - ea.addAppendUpdateCustomData(charID, { - text2Path: {pathID, text, pathElID, offset} - }); - objectIDs.push(charID); - - // Move to next character position with natural spacing - currentDist += charWidth + spacing; - } - - 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() { - async function getSVGForPath() { - ea.copyViewElementsToEAforEditing([pathEl]); - const el = ea.getElements()[0]; - el.roughness = 0; - const svgDoc = await ea.createSVG(); - ea.clear(); - return svgDoc; - } - - const svgDoc = await getSVGForPath(); - - // --- Add below: create a line element from the SVG path --- - - if (svgDoc) { - // Find the element in the SVG - const pathElSVG = svgDoc.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 - - // --- Align the new line's first point to the original element's first point --- - // Find the original element's first point (relative to its x/y) - const origFirst = pathEl.type === "rectangle" - ? [pathEl.x, pathEl.y] - : (pathEl.type === "diamond" - ? [pathEl.x + pathEl.width/2, pathEl.y] - : [pathEl.x + pathEl.points[0][0], pathEl.y + pathEl.points[0][1]]); - // Find the SVG's first point (relative to its g transform) - // Get the transform - const g = pathElSVG.closest('g'); - let dx = 0, dy = 0; - if (g) { - const m = g.getAttribute('transform'); - // Parse translate(x y) - const match = m && m.match(/translate\(([-\d.]+)[ ,]([-\d.]+)/); - if (match) { - dx = parseFloat(match[1]); - dy = parseFloat(match[2]); - } - } - // SVG points are relative to the group transform - const svgFirst = [points[0][0] + dx, points[0][1] + dy]; - // Calculate delta - const deltaX = origFirst[0] - svgFirst[0]; - const deltaY = origFirst[1] - svgFirst[1]; - // Apply delta to all points - points = points.map(([x, y]) => [x + dx + deltaX, y + dy + deltaY]); - // Trim the very last point - if (points.length > 2) { - points = points.slice(0, -1); - } - - if (points.length > 1) { - ea.clear(); - const lineId = ea.addLine(points); - const line = ea.getElement(lineId); - line.isDeleted = true; - return line; - } else { - new Notice("Could not extract enough points from SVG path."); - } - } else { - new Notice("No path element found in SVG."); - } - } - return pathEl; -} \ No newline at end of file diff --git a/ea-scripts/To Line.md b/ea-scripts/To Line.md new file mode 100644 index 0000000..16d413b --- /dev/null +++ b/ea-scripts/To Line.md @@ -0,0 +1,476 @@ +/** + * 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 + ```js*/ +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 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 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 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]]; + } +} + +const el = ea.getViewSelectedElement(); +switch (el.type) { + case "rectangle": + rectangleToLine(el); + break; + case "ellipse": + ellipseToLine(el); + break; + case "diamond": + diamondToLine(el); + break; +} +ea.addElementsToView(); \ No newline at end of file diff --git a/ea-scripts/directory-info.json b/ea-scripts/directory-info.json index 69ef035..11efa39 100644 --- a/ea-scripts/directory-info.json +++ b/ea-scripts/directory-info.json @@ -1 +1 @@ -[{"fname":"Mindmap connector.md","mtime":1658686599427},{"fname":"Mindmap connector.svg","mtime":1658686599427},{"fname":"Add Connector Point.md","mtime":1735420748564},{"fname":"Add Connector Point.svg","mtime":1645944722000},{"fname":"Add Link to Existing File and Open.md","mtime":1647807918345},{"fname":"Add Link to Existing File and Open.svg","mtime":1645964261000},{"fname":"Add Link to New Page and Open.md","mtime":1654168862138},{"fname":"Add Link to New Page and Open.svg","mtime":1645960639000},{"fname":"Add Next Step in Process.md","mtime":1688304760357},{"fname":"Add Next Step in Process.svg","mtime":1645960639000},{"fname":"Box Each Selected Groups.md","mtime":1645305706000},{"fname":"Box Each Selected Groups.svg","mtime":1645967510000},{"fname":"Box Selected Elements.md","mtime":1645305706000},{"fname":"Box Selected Elements.svg","mtime":1645960639000},{"fname":"Change shape of selected elements.md","mtime":1652701169236},{"fname":"Change shape of selected elements.svg","mtime":1645960775000},{"fname":"Connect elements.md","mtime":1645305706000},{"fname":"Connect elements.svg","mtime":1645960639000},{"fname":"Convert freedraw to line.md","mtime":1645305706000},{"fname":"Convert freedraw to line.svg","mtime":1645960639000},{"fname":"Convert selected text elements to sticky notes.md","mtime":1670169501383},{"fname":"Convert selected text elements to sticky notes.svg","mtime":1645960639000},{"fname":"Convert text to link with folder and alias.md","mtime":1641639819000},{"fname":"Convert text to link with folder and alias.svg","mtime":1645960639000},{"fname":"Copy Selected Element Styles to Global.md","mtime":1642232088000},{"fname":"Copy Selected Element Styles to Global.svg","mtime":1645960639000},{"fname":"Create new markdown file and embed into active drawing.md","mtime":1640866935000},{"fname":"Create new markdown file and embed into active drawing.svg","mtime":1645960639000},{"fname":"Darken background color.md","mtime":1663059051059},{"fname":"Darken background color.svg","mtime":1645960639000},{"fname":"Elbow connectors.md","mtime":1671126911490},{"fname":"Elbow connectors.svg","mtime":1645960639000},{"fname":"Expand rectangles horizontally keep text centered.md","mtime":1646563692000},{"fname":"Expand rectangles horizontally keep text centered.svg","mtime":1645967510000},{"fname":"Expand rectangles horizontally.md","mtime":1644950235000},{"fname":"Expand rectangles horizontally.svg","mtime":1645967510000},{"fname":"Expand rectangles vertically keep text centered.md","mtime":1646563692000},{"fname":"Expand rectangles vertically keep text centered.svg","mtime":1645967510000},{"fname":"Expand rectangles vertically.md","mtime":1658686599427},{"fname":"Expand rectangles vertically.svg","mtime":1645967510000},{"fname":"Fixed horizontal distance between centers.md","mtime":1646743234000},{"fname":"Fixed horizontal distance between centers.svg","mtime":1645960639000},{"fname":"Fixed inner distance.md","mtime":1646743234000},{"fname":"Fixed inner distance.svg","mtime":1645960639000},{"fname":"Fixed spacing.md","mtime":1646743234000},{"fname":"Fixed spacing.svg","mtime":1645967510000},{"fname":"Fixed vertical distance between centers.md","mtime":1646743234000},{"fname":"Fixed vertical distance between centers.svg","mtime":1645967510000},{"fname":"Fixed vertical distance.md","mtime":1646743234000},{"fname":"Fixed vertical distance.svg","mtime":1645967510000},{"fname":"Lighten background color.md","mtime":1663059051059},{"fname":"Lighten background color.svg","mtime":1645959546000},{"fname":"Modify background color opacity.md","mtime":1644924415000},{"fname":"Modify background color opacity.svg","mtime":1645944722000},{"fname":"Normalize Selected Arrows.md","mtime":1670403743278},{"fname":"Normalize Selected Arrows.svg","mtime":1645960639000},{"fname":"Organic Line.md","mtime":1672920172531},{"fname":"Organic Line.svg","mtime":1645964261000},{"fname":"Organic Line Legacy.md","mtime":1690607372668},{"fname":"Organic Line Legacy.svg","mtime":1690607372668},{"fname":"README.md","mtime":1645175700000},{"fname":"Repeat Elements.md","mtime":1663059051059},{"fname":"Repeat Elements.svg","mtime":1645960639000},{"fname":"Reverse arrows.md","mtime":1645305706000},{"fname":"Reverse arrows.svg","mtime":1645960639000},{"fname":"Scribble Helper.md","mtime":1747585400113},{"fname":"Scribble Helper.svg","mtime":1645944722000},{"fname":"Select Elements of Type.md","mtime":1643464321000},{"fname":"Select Elements of Type.svg","mtime":1645960639000},{"fname":"Set Dimensions.md","mtime":1645305706000},{"fname":"Set Dimensions.svg","mtime":1645944722000},{"fname":"Set Font Family.md","mtime":1645305706000},{"fname":"Set Font Family.svg","mtime":1645944722000},{"fname":"Set Grid.md","mtime":1693725826368},{"fname":"Set Grid.svg","mtime":1645960639000},{"fname":"Set Link Alias.md","mtime":1645305706000},{"fname":"Set Link Alias.svg","mtime":1645960639000},{"fname":"Set Stroke Width of Selected Elements.md","mtime":1748189277103},{"fname":"Set Stroke Width of Selected Elements.svg","mtime":1645960639000},{"fname":"Set Text Alignment.md","mtime":1645305706000},{"fname":"Set Text Alignment.svg","mtime":1645960639000},{"fname":"Set background color of unclosed line object by adding a shadow clone.md","mtime":1681665030892},{"fname":"Set background color of unclosed line object by adding a shadow clone.svg","mtime":1645960639000},{"fname":"Split text by lines.md","mtime":1705160236797},{"fname":"Split text by lines.svg","mtime":1645944722000},{"fname":"Zoom to Fit Selected Elements.md","mtime":1640770602000},{"fname":"Zoom to Fit Selected Elements.svg","mtime":1645960639000},{"fname":"directory-info.json","mtime":1646583437000},{"fname":"index-new.md","mtime":1645986149000},{"fname":"index.md","mtime":1645175700000},{"fname":"Grid Selected Images.md","mtime":1701630797839},{"fname":"Grid Selected Images.svg","mtime":1649614401982},{"fname":"Palette loader.md","mtime":1686511890942},{"fname":"Palette loader.svg","mtime":1649614401982},{"fname":"Rename Image.md","mtime":1663678478785},{"fname":"Rename Image.svg","mtime":1663678478785},{"fname":"Text Arch.md","mtime":1747585400113},{"fname":"Text Arch.svg","mtime":1670403743278},{"fname":"Deconstruct selected elements into new drawing.md","mtime":1735252821829},{"fname":"Deconstruct selected elements into new drawing.svg","mtime":1668541145255},{"fname":"Slideshow.md","mtime":1737818186265},{"fname":"Slideshow.svg","mtime":1670017348333},{"fname":"Auto Layout.md","mtime":1670403743278},{"fname":"Auto Layout.svg","mtime":1670175947081},{"fname":"Uniform size.md","mtime":1670175947081},{"fname":"Uniform size.svg","mtime":1670175947081},{"fname":"Mindmap format.md","mtime":1684484694228},{"fname":"Mindmap format.svg","mtime":1674944958059},{"fname":"Text to Sticky Notes.md","mtime":1678537561724},{"fname":"Text to Sticky Notes.svg","mtime":1678537561724},{"fname":"Folder Note Core - Make Current Drawing a Folder.md","mtime":1678973697470},{"fname":"Folder Note Core - Make Current Drawing a Folder.svg","mtime":1678973697470},{"fname":"Invert colors.md","mtime":1708870608219},{"fname":"Invert colors.svg","mtime":1678973697470},{"fname":"PDF Page Text to Clipboard.md","mtime":1683984041712},{"fname":"PDF Page Text to Clipboard.svg","mtime":1680418321236},{"fname":"Excalidraw Collaboration Frame.md","mtime":1687881495985},{"fname":"Excalidraw Collaboration Frame.svg","mtime":1687881495985},{"fname":"Create DrawIO file.md","mtime":1688243858267},{"fname":"Create DrawIO file.svg","mtime":1688243858267},{"fname":"Ellipse Selected Elements.md","mtime":1690131476331},{"fname":"Ellipse Selected Elements.svg","mtime":1690131476331},{"fname":"Select Similar Elements.md","mtime":1736077716908},{"fname":"Select Similar Elements.svg","mtime":1691270949338},{"fname":"Toggle Grid.md","mtime":1692125382945},{"fname":"Toggle Grid.svg","mtime":1692124753386},{"fname":"Split Ellipse.md","mtime":1747585400113},{"fname":"Split Ellipse.svg","mtime":1693134104356},{"fname":"Text Aura.md","mtime":1693731979540},{"fname":"Text Aura.svg","mtime":1693731979540},{"fname":"Boolean Operations.md","mtime":1747585400113},{"fname":"Boolean Operations.svg","mtime":1695746839537},{"fname":"Concatenate lines.md","mtime":1736073478644},{"fname":"Concatenate lines.svg","mtime":1696175301525},{"fname":"GPT-Draw-a-UI.md","mtime":1703324727900},{"fname":"GPT-Draw-a-UI.svg","mtime":1700511998048},{"fname":"ExcaliAI.md","mtime":1722056859912},{"fname":"ExcaliAI.svg","mtime":1701011028767},{"fname":"Repeat Texts.md","mtime":1701969627758},{"fname":"Repeat Texts.svg","mtime":1701969627758},{"fname":"Relative Font Size Cycle.md","mtime":1701969627758},{"fname":"Relative Font Size Cycle.svg","mtime":1701969627758},{"fname":"Golden Ratio.md","mtime":1725174200469},{"fname":"Golden Ratio.svg","mtime":1702812404286},{"fname":"Crop Vintage Mask.md","mtime":1706565166174},{"fname":"Crop Vintage Mask.svg","mtime":1705836797730},{"fname":"Custom Zoom.md", "mtime": 1710424027192},{"fname":"Custom Zoom.svg", "mtime":1710424027192},{"fname":"Excalidraw Writing Machine.md", "mtime":1724677454036},{"fname":"Excalidraw Writing Machine.svg", "mtime":1724356709706},{"fname":"Reset LaTeX Size.md", "mtime":1725296010813},{"fname":"Reset LaTeX Size.svg", "mtime":1725296010813},{"fname":"Shade Master.md", "mtime":1735380817866},{"fname":"Shade Master.svg", "mtime":1735252821829},{"fname":"Image Occlusion.md", "mtime":1735465948353},{"fname":"Image Occlusion.svg", "mtime":1735465948353},{"fname":"Full-Year Calendar Generator.md", "mtime":1735465948353},{"fname":"Full-Year Calendar Generator.svg", "mtime":1735465948353}] \ No newline at end of file +[{"fname":"Mindmap connector.md","mtime":1658686599427},{"fname":"Mindmap connector.svg","mtime":1658686599427},{"fname":"Add Connector Point.md","mtime":1735420748564},{"fname":"Add Connector Point.svg","mtime":1645944722000},{"fname":"Add Link to Existing File and Open.md","mtime":1647807918345},{"fname":"Add Link to Existing File and Open.svg","mtime":1645964261000},{"fname":"Add Link to New Page and Open.md","mtime":1654168862138},{"fname":"Add Link to New Page and Open.svg","mtime":1645960639000},{"fname":"Add Next Step in Process.md","mtime":1688304760357},{"fname":"Add Next Step in Process.svg","mtime":1645960639000},{"fname":"Box Each Selected Groups.md","mtime":1645305706000},{"fname":"Box Each Selected Groups.svg","mtime":1645967510000},{"fname":"Box Selected Elements.md","mtime":1645305706000},{"fname":"Box Selected Elements.svg","mtime":1645960639000},{"fname":"Change shape of selected elements.md","mtime":1652701169236},{"fname":"Change shape of selected elements.svg","mtime":1645960775000},{"fname":"Connect elements.md","mtime":1645305706000},{"fname":"Connect elements.svg","mtime":1645960639000},{"fname":"Convert freedraw to line.md","mtime":1645305706000},{"fname":"Convert freedraw to line.svg","mtime":1645960639000},{"fname":"Convert selected text elements to sticky notes.md","mtime":1670169501383},{"fname":"Convert selected text elements to sticky notes.svg","mtime":1645960639000},{"fname":"Convert text to link with folder and alias.md","mtime":1641639819000},{"fname":"Convert text to link with folder and alias.svg","mtime":1645960639000},{"fname":"Copy Selected Element Styles to Global.md","mtime":1642232088000},{"fname":"Copy Selected Element Styles to Global.svg","mtime":1645960639000},{"fname":"Create new markdown file and embed into active drawing.md","mtime":1640866935000},{"fname":"Create new markdown file and embed into active drawing.svg","mtime":1645960639000},{"fname":"Darken background color.md","mtime":1663059051059},{"fname":"Darken background color.svg","mtime":1645960639000},{"fname":"Elbow connectors.md","mtime":1671126911490},{"fname":"Elbow connectors.svg","mtime":1645960639000},{"fname":"Expand rectangles horizontally keep text centered.md","mtime":1646563692000},{"fname":"Expand rectangles horizontally keep text centered.svg","mtime":1645967510000},{"fname":"Expand rectangles horizontally.md","mtime":1644950235000},{"fname":"Expand rectangles horizontally.svg","mtime":1645967510000},{"fname":"Expand rectangles vertically keep text centered.md","mtime":1646563692000},{"fname":"Expand rectangles vertically keep text centered.svg","mtime":1645967510000},{"fname":"Expand rectangles vertically.md","mtime":1658686599427},{"fname":"Expand rectangles vertically.svg","mtime":1645967510000},{"fname":"Fixed horizontal distance between centers.md","mtime":1646743234000},{"fname":"Fixed horizontal distance between centers.svg","mtime":1645960639000},{"fname":"Fixed inner distance.md","mtime":1646743234000},{"fname":"Fixed inner distance.svg","mtime":1645960639000},{"fname":"Fixed spacing.md","mtime":1646743234000},{"fname":"Fixed spacing.svg","mtime":1645967510000},{"fname":"Fixed vertical distance between centers.md","mtime":1646743234000},{"fname":"Fixed vertical distance between centers.svg","mtime":1645967510000},{"fname":"Fixed vertical distance.md","mtime":1646743234000},{"fname":"Fixed vertical distance.svg","mtime":1645967510000},{"fname":"Lighten background color.md","mtime":1663059051059},{"fname":"Lighten background color.svg","mtime":1645959546000},{"fname":"Modify background color opacity.md","mtime":1644924415000},{"fname":"Modify background color opacity.svg","mtime":1645944722000},{"fname":"Normalize Selected Arrows.md","mtime":1670403743278},{"fname":"Normalize Selected Arrows.svg","mtime":1645960639000},{"fname":"Organic Line.md","mtime":1672920172531},{"fname":"Organic Line.svg","mtime":1645964261000},{"fname":"Organic Line Legacy.md","mtime":1690607372668},{"fname":"Organic Line Legacy.svg","mtime":1690607372668},{"fname":"README.md","mtime":1645175700000},{"fname":"Repeat Elements.md","mtime":1663059051059},{"fname":"Repeat Elements.svg","mtime":1645960639000},{"fname":"Reverse arrows.md","mtime":1645305706000},{"fname":"Reverse arrows.svg","mtime":1645960639000},{"fname":"Scribble Helper.md","mtime":1747585400113},{"fname":"Scribble Helper.svg","mtime":1645944722000},{"fname":"Select Elements of Type.md","mtime":1643464321000},{"fname":"Select Elements of Type.svg","mtime":1645960639000},{"fname":"Set Dimensions.md","mtime":1645305706000},{"fname":"Set Dimensions.svg","mtime":1645944722000},{"fname":"Set Font Family.md","mtime":1645305706000},{"fname":"Set Font Family.svg","mtime":1645944722000},{"fname":"Set Grid.md","mtime":1693725826368},{"fname":"Set Grid.svg","mtime":1645960639000},{"fname":"Set Link Alias.md","mtime":1645305706000},{"fname":"Set Link Alias.svg","mtime":1645960639000},{"fname":"Set Stroke Width of Selected Elements.md","mtime":1748189277103},{"fname":"Set Stroke Width of Selected Elements.svg","mtime":1645960639000},{"fname":"Set Text Alignment.md","mtime":1645305706000},{"fname":"Set Text Alignment.svg","mtime":1645960639000},{"fname":"Set background color of unclosed line object by adding a shadow clone.md","mtime":1681665030892},{"fname":"Set background color of unclosed line object by adding a shadow clone.svg","mtime":1645960639000},{"fname":"Split text by lines.md","mtime":1705160236797},{"fname":"Split text by lines.svg","mtime":1645944722000},{"fname":"Zoom to Fit Selected Elements.md","mtime":1640770602000},{"fname":"Zoom to Fit Selected Elements.svg","mtime":1645960639000},{"fname":"directory-info.json","mtime":1646583437000},{"fname":"index-new.md","mtime":1645986149000},{"fname":"index.md","mtime":1645175700000},{"fname":"Grid Selected Images.md","mtime":1701630797839},{"fname":"Grid Selected Images.svg","mtime":1649614401982},{"fname":"Palette loader.md","mtime":1686511890942},{"fname":"Palette loader.svg","mtime":1649614401982},{"fname":"Rename Image.md","mtime":1663678478785},{"fname":"Rename Image.svg","mtime":1663678478785},{"fname":"Text to Path.md","mtime":1748193397572},{"fname":"Text to Path.svg","mtime":1748193397572},{"fname":"Deconstruct selected elements into new drawing.md","mtime":1735252821829},{"fname":"Deconstruct selected elements into new drawing.svg","mtime":1668541145255},{"fname":"Slideshow.md","mtime":1737818186265},{"fname":"Slideshow.svg","mtime":1670017348333},{"fname":"Auto Layout.md","mtime":1670403743278},{"fname":"Auto Layout.svg","mtime":1670175947081},{"fname":"Uniform size.md","mtime":1670175947081},{"fname":"Uniform size.svg","mtime":1670175947081},{"fname":"Mindmap format.md","mtime":1684484694228},{"fname":"Mindmap format.svg","mtime":1674944958059},{"fname":"Text to Sticky Notes.md","mtime":1678537561724},{"fname":"Text to Sticky Notes.svg","mtime":1678537561724},{"fname":"Folder Note Core - Make Current Drawing a Folder.md","mtime":1678973697470},{"fname":"Folder Note Core - Make Current Drawing a Folder.svg","mtime":1678973697470},{"fname":"Invert colors.md","mtime":1708870608219},{"fname":"Invert colors.svg","mtime":1678973697470},{"fname":"PDF Page Text to Clipboard.md","mtime":1683984041712},{"fname":"PDF Page Text to Clipboard.svg","mtime":1680418321236},{"fname":"Excalidraw Collaboration Frame.md","mtime":1687881495985},{"fname":"Excalidraw Collaboration Frame.svg","mtime":1687881495985},{"fname":"Create DrawIO file.md","mtime":1688243858267},{"fname":"Create DrawIO file.svg","mtime":1688243858267},{"fname":"Ellipse Selected Elements.md","mtime":1690131476331},{"fname":"Ellipse Selected Elements.svg","mtime":1690131476331},{"fname":"Select Similar Elements.md","mtime":1736077716908},{"fname":"Select Similar Elements.svg","mtime":1691270949338},{"fname":"Toggle Grid.md","mtime":1692125382945},{"fname":"Toggle Grid.svg","mtime":1692124753386},{"fname":"Split Ellipse.md","mtime":1747585400113},{"fname":"Split Ellipse.svg","mtime":1693134104356},{"fname":"Text Aura.md","mtime":1693731979540},{"fname":"Text Aura.svg","mtime":1693731979540},{"fname":"Boolean Operations.md","mtime":1747585400113},{"fname":"Boolean Operations.svg","mtime":1695746839537},{"fname":"Concatenate lines.md","mtime":1736073478644},{"fname":"Concatenate lines.svg","mtime":1696175301525},{"fname":"GPT-Draw-a-UI.md","mtime":1703324727900},{"fname":"GPT-Draw-a-UI.svg","mtime":1700511998048},{"fname":"ExcaliAI.md","mtime":1722056859912},{"fname":"ExcaliAI.svg","mtime":1701011028767},{"fname":"Repeat Texts.md","mtime":1701969627758},{"fname":"Repeat Texts.svg","mtime":1701969627758},{"fname":"Relative Font Size Cycle.md","mtime":1701969627758},{"fname":"Relative Font Size Cycle.svg","mtime":1701969627758},{"fname":"Golden Ratio.md","mtime":1725174200469},{"fname":"Golden Ratio.svg","mtime":1702812404286},{"fname":"Crop Vintage Mask.md","mtime":1706565166174},{"fname":"Crop Vintage Mask.svg","mtime":1705836797730},{"fname":"Custom Zoom.md", "mtime": 1710424027192},{"fname":"Custom Zoom.svg", "mtime":1710424027192},{"fname":"Excalidraw Writing Machine.md", "mtime":1724677454036},{"fname":"Excalidraw Writing Machine.svg", "mtime":1724356709706},{"fname":"Reset LaTeX Size.md", "mtime":1725296010813},{"fname":"Reset LaTeX Size.svg", "mtime":1725296010813},{"fname":"Shade Master.md", "mtime":1735380817866},{"fname":"Shade Master.svg", "mtime":1735252821829},{"fname":"Image Occlusion.md", "mtime":1735465948353},{"fname":"Image Occlusion.svg", "mtime":1735465948353},{"fname":"Full-Year Calendar Generator.md", "mtime":1735465948353},{"fname":"Full-Year Calendar Generator.svg", "mtime":1735465948353}] \ No newline at end of file diff --git a/ea-scripts/index-new.md b/ea-scripts/index-new.md index f9472f1..81bbb9d 100644 --- a/ea-scripts/index-new.md +++ b/ea-scripts/index-new.md @@ -73,8 +73,8 @@ I would love to include your contribution in the script library. If you have a s |
|[[#Set Font Family]]| |
|[[#Set Text Alignment]]| |
|[[#Split text by lines]]| -|
|[[#Text Arch]]| |
|[[#Text Aura]]| +|
|[[#Text to Path]]| |
|[[#Text to Sticky Notes]]| ## Styling and Appearance @@ -596,24 +596,24 @@ https://raw.githubusercontent.com/zsviczian/obsidian-excalidraw-plugin/master/ea ```
Author@zsviczian
SourceFile on GitHub
DescriptionSplit lines of text into separate text elements for easier reorganization
-## Text Arch -```excalidraw-script-install -https://raw.githubusercontent.com/zsviczian/obsidian-excalidraw-plugin/master/ea-scripts/Text%20Arch.md -``` -
Author@zsviczian
SourceFile on GitHub
DescriptionThis script allows you to fit a text element along a selected path (line, arrow, freedraw, ellipse, rectangle, or diamond) in Excalidraw. 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 or function as a markdown link. Emojis are not supported.
-
- ## Text Aura ```excalidraw-script-install https://raw.githubusercontent.com/zsviczian/obsidian-excalidraw-plugin/master/ea-scripts/Text%20Aura.md ```
Author@zsviczian
SourceFile on GitHub
DescriptionSelect a single text element, or a text element in a container. The container must have a transparent background.
The script will add an aura to the text by adding 4 copies of the text each with the inverted stroke color of the original text element and with a very small X and Y offset. The resulting 4 + 1 (original) text elements or containers will be grouped.
If you copy a color string on the clipboard before running the script, the script will use that color instead of the inverted color.
+## Text to Path +```excalidraw-script-install +https://raw.githubusercontent.com/zsviczian/obsidian-excalidraw-plugin/master/ea-scripts/Text%20to%20Path.md +``` +
Author@zsviczian
SourceFile on GitHub
DescriptionThis 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 or function as a markdown link. Emojis are not supported.
+
+ ## Toggle Grid ```excalidraw-script-install https://raw.githubusercontent.com/zsviczian/obsidian-excalidraw-plugin/master/ea-scripts/Toggle%20Grid.md diff --git a/images/scripts-text-aura.jpg b/images/scripts-text-to-path.jpg similarity index 100% rename from images/scripts-text-aura.jpg rename to images/scripts-text-to-path.jpg diff --git a/package.json b/package.json index 677d9f2..91e297c 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,7 @@ "license": "MIT", "dependencies": { "@popperjs/core": "^2.11.8", - "@zsviczian/excalidraw": "0.18.0-16", + "@zsviczian/excalidraw": "0.18.0-17", "chroma-js": "^2.4.2", "clsx": "^2.0.0", "@zsviczian/colormaster": "^1.2.2", diff --git a/src/core/main.ts b/src/core/main.ts index 95b0ce2..233f0d9 100644 --- a/src/core/main.ts +++ b/src/core/main.ts @@ -566,7 +566,7 @@ export default class ExcalidrawPlugin extends Plugin { } } this.packageManager.getPackageMap().forEach(({excalidrawLib}) => { - (excalidrawLib as typeof ExcalidrawLib).registerLocalFont({metrics: fontMetrics as any, icon: null}, fourthFontDataURL); + (excalidrawLib as typeof ExcalidrawLib).registerLocalFont({metrics: fontMetrics as any}, fourthFontDataURL); }); // Add fonts to open Obsidian documents for(const ownerDocument of this.getOpenObsidianDocuments()) { diff --git a/src/lang/locale/en.ts b/src/lang/locale/en.ts index fea7a4e..2e80d98 100644 --- a/src/lang/locale/en.ts +++ b/src/lang/locale/en.ts @@ -749,7 +749,7 @@ FILENAME_HEAD: "Filename", "ExcalidrawAutomate is a scripting and automation API for Excalidraw. Unfortunately, the documentation of the API is sparse. " + "I recommend reading the ExcalidrawAutomate.d.ts file, " + "visiting the ExcalidrawAutomate How-to page - though the information " + - "here has not been updated for a long while -, and finally to enable the field suggester below. The field suggester will show you the available " + + "here has not been updated for a long while -, and finally to enable the field suggester below. The field suggester will show you the available " + "functions, their parameters and short description as you type. The field suggester is the most up-to-date documentation of the API.", FIELD_SUGGESTER_NAME: "Enable Field Suggester", FIELD_SUGGESTER_DESC: @@ -991,6 +991,7 @@ FILENAME_HEAD: "Filename", //Utils.ts UPDATE_AVAILABLE: `A newer version of Excalidraw is available in Community Plugins.\n\nYou are using ${PLUGIN_VERSION}.\nThe latest is`, + SCRIPT_UPDATES_AVAILABLE: `Script updates available - check the script store.\n\n${DEVICE.isDesktop ? `This message is available in console.log (${DEVICE.isMacOS ? "CMD+OPT+i" : "CTRL+SHIFT+i"})\n\n` : ""}If you have organized scripts into subfolders under the script store folder and have multiple copies of the same script, you may need to clean up unused versions to clear this alert. For private copies of scripts that should not be updated, store them outside the script store folder.`, ERROR_PNG_TOO_LARGE: "Error exporting PNG - PNG too large, try a smaller resolution", //modifierkeyHelper.ts diff --git a/src/shared/Dialogs/Messages.ts b/src/shared/Dialogs/Messages.ts index de583d9..bb4f9e7 100644 --- a/src/shared/Dialogs/Messages.ts +++ b/src/shared/Dialogs/Messages.ts @@ -29,6 +29,8 @@ I build this plugin in my free time, as a labor of love. Curious about the philo - If you enter line editor mode by CTRL/CMD + clicking on the line, the lock-point will be marked for easier editing. Look for the polygon action on the elements panel to break the polygon if required. - Override "Focus on Existing Tab" setting for the "Open the back-of-the-note for the selected image in a popout window" action. "Open the back-of-the-note" will open a new popout every time. - I significantly extended the features of the Text Arch script. It can now fit text to a wide range of paths and shapes, and allows for the text to be edited and refitted to other paths. +- Better element unlock. Single left click on a locked element will display an unlock button. [#9546](https://github.com/excalidraw/excalidraw/pull/9546) +- On startup Excalidraw will alert you if there are installed scripts that have available updates. `, "2.11.1": ` ## Fixed: diff --git a/src/types/excalidrawLib.d.ts b/src/types/excalidrawLib.d.ts index 24307c4..fc52c82 100644 --- a/src/types/excalidrawLib.d.ts +++ b/src/types/excalidrawLib.d.ts @@ -1,8 +1,8 @@ import { RestoredDataState } from "@zsviczian/excalidraw/types/excalidraw/data/restore"; import { ImportedDataState } from "@zsviczian/excalidraw/types/excalidraw/data/types"; -import { BoundingBox } from "@zsviczian/excalidraw/types/excalidraw/element/bounds"; +import { BoundingBox } from "@zsviczian/excalidraw/types/element/src"; import { ElementsMap, ExcalidrawBindableElement, ExcalidrawElement, ExcalidrawFrameElement, ExcalidrawFrameLikeElement, ExcalidrawTextContainer, ExcalidrawTextElement, FontFamilyValues, FontString, NonDeleted, NonDeletedExcalidrawElement, Theme } from "@zsviczian/excalidraw/types/element/src/types"; -import { FontMetadata } from "@zsviczian/excalidraw/types/excalidraw/fonts/FontMetadata"; +import { FontMetadata } from "@zsviczian/excalidraw/types/common/src"; import { AppState, BinaryFiles, DataURL, GenerateDiagramToCode, Zoom } from "@zsviczian/excalidraw/types/excalidraw/types"; import { Mutable } from "@zsviczian/excalidraw/types/common/src/utility-types"; import { GlobalPoint } from "@zsviczian/excalidraw/types/math/src/types"; @@ -146,6 +146,15 @@ declare namespace ExcalidrawLib { elementsMap: ElementsMap, ): ExcalidrawElement[][]; + function getFontMetrics(fontFamily: ExcalidrawTextElement["fontFamily"], fontSize?:number): { + unitsPerEm: number, + ascender: number, + descender: number, + lineHeight: number, + baseline: number, + fontString: string + } + function measureText( text: string, font: FontString, diff --git a/src/types/excalidrawViewTypes.ts b/src/types/excalidrawViewTypes.ts index d3369ac..3f8c9c7 100644 --- a/src/types/excalidrawViewTypes.ts +++ b/src/types/excalidrawViewTypes.ts @@ -27,6 +27,7 @@ export interface ViewSemaphores { //flag to prevent overwriting the changes the user makes in an embeddable view editing the back side of the drawing embeddableIsEditingSelf: boolean; popoutUnload: boolean; //the unloaded Excalidraw view was the last leaf in the popout window + viewloaded: boolean; //onLayoutReady in view.onload has completed. viewunload: boolean; //first time initialization of the view scriptsReady: boolean; diff --git a/src/utils/utils.ts b/src/utils/utils.ts index 6d3cbf4..67cb666 100644 --- a/src/utils/utils.ts +++ b/src/utils/utils.ts @@ -16,6 +16,7 @@ import { getCommonBoundingBox, DEVICE, getContainerElement, + SCRIPT_INSTALL_FOLDER, } from "../constants/constants"; import ExcalidrawPlugin from "../core/main"; import { ExcalidrawElement, ExcalidrawImageElement, ExcalidrawTextElement, ImageCrop } from "@zsviczian/excalidraw/types/element/src/types"; @@ -33,6 +34,7 @@ import Pool from "es6-promise-pool"; import { FileData } from "../shared/EmbeddedFileLoader"; import { t } from "src/lang/helpers"; import ExcalidrawScene from "src/shared/svgToExcalidraw/elements/ExcalidrawScene"; +import { log } from "./debugHelper"; declare const PLUGIN_VERSION:string; declare var LZString: any; @@ -82,6 +84,9 @@ export async function checkExcalidrawVersion() { t("UPDATE_AVAILABLE") + ` ${latestVersion}`, ); } + + // Check for script updates + await checkScriptUpdates(); } catch (e) { console.log({ where: "Utils/checkExcalidrawVersion", error: e }); } @@ -91,6 +96,56 @@ export async function checkExcalidrawVersion() { }, 28800000); //reset after 8 hours }; +async function checkScriptUpdates() { + try { + if (!EXCALIDRAW_PLUGIN?.settings?.scriptFolderPath) { + return; + } + + const folder = `${EXCALIDRAW_PLUGIN.settings.scriptFolderPath}/${SCRIPT_INSTALL_FOLDER}/`; + const installedScripts = EXCALIDRAW_PLUGIN.app.vault.getFiles() + .filter(f => f.path.startsWith(folder) && f.extension === "md"); + + if (installedScripts.length === 0) { + return; + } + + // Get directory info from GitHub + const files = new Map(); + const directoryInfo = JSON.parse( + await request({ + url: "https://raw.githubusercontent.com/zsviczian/obsidian-excalidraw-plugin/master/ea-scripts/directory-info.json", + }), + ); + directoryInfo.forEach((f: any) => files.set(f.fname, f.mtime)); + + if (files.size === 0) { + return; + } + + // Check if any installed scripts have updates + const updates:string[] = []; + let hasUpdates = false; + for (const scriptFile of installedScripts) { + const filename = scriptFile.name; + if (files.has(filename)) { + const mtime = files.get(filename); + if (mtime > scriptFile.stat.mtime) { + updates.push(scriptFile.path.split(folder)?.[1]?.split(".md")[0]); + hasUpdates = true; + } + } + } + + if (hasUpdates) { + const message = `${t("SCRIPT_UPDATES_AVAILABLE")}\n\n${updates.sort().join("\n")}`; + new Notice(message,8000+updates.length*1000); + log(message); + } + } catch (e) { + console.log({ where: "Utils/checkScriptUpdates", error: e }); + } +} const random = new Random(Date.now()); export function randomInteger () { diff --git a/src/view/ExcalidrawView.ts b/src/view/ExcalidrawView.ts index a9abd25..e14ea7c 100644 --- a/src/view/ExcalidrawView.ts +++ b/src/view/ExcalidrawView.ts @@ -315,6 +315,7 @@ export default class ExcalidrawView extends TextFileView implements HoverParent{ warnAboutLinearElementLinkClick: true, embeddableIsEditingSelf: false, popoutUnload: false, + viewloaded: false, viewunload: false, scriptsReady: false, justLoaded: false, @@ -1656,8 +1657,8 @@ export default class ExcalidrawView extends TextFileView implements HoverParent{ if(!Boolean(this?.plugin?.activeLeafChangeEventHandler)) return; if (Boolean(this.plugin.activeLeafChangeEventHandler) && (this?.app?.workspace?.activeLeaf === this.leaf)) { this.plugin.activeLeafChangeEventHandler(this.leaf); - } - this.canvasNodeFactory.initialize(); + } + await this.canvasNodeFactory.initialize(); this.contentEl.addClass("excalidraw-view"); //https://github.com/zsviczian/excalibrain/issues/28 await this.addSlidingPanesListner(); //awaiting this because when using workspaces, onLayoutReady comes too early @@ -1694,6 +1695,7 @@ export default class ExcalidrawView extends TextFileView implements HoverParent{ this.registerDomEvent(this.ownerWindow, "keyup", onKeyUp, false); //this.registerDomEvent(this.contentEl, "mouseleave", onBlurOrLeave, false); //https://github.com/zsviczian/obsidian-excalidraw-plugin/issues/2004 this.registerDomEvent(this.ownerWindow, "blur", onBlurOrLeave, false); + this.semaphores.viewloaded = true; }); this.setupAutosaveTimer(); @@ -2359,7 +2361,7 @@ export default class ExcalidrawView extends TextFileView implements HoverParent{ } await this.plugin.awaitInit(); let counter = 0; - while ((!this.file || !this.plugin.fourthFontLoaded) && counter++<50) await sleep(50); + while ((!this.semaphores.viewloaded || !this.file || !this.plugin.fourthFontLoaded) && counter++<50) await sleep(50); if(!this.file) return; this.compatibilityMode = this.file.extension === "excalidraw"; await this.plugin.loadSettings(); diff --git a/src/view/managers/CanvasNodeFactory.ts b/src/view/managers/CanvasNodeFactory.ts index e10fde6..10b82f6 100644 --- a/src/view/managers/CanvasNodeFactory.ts +++ b/src/view/managers/CanvasNodeFactory.ts @@ -50,17 +50,17 @@ export class CanvasNodeFactory { } public async initialize() { - //@ts-ignore + const app = this.view.app; const canvasPlugin = app.internalPlugins.plugins["canvas"]; if(!canvasPlugin._loaded) { await canvasPlugin.load(); } const doc = this.view.ownerDocument; - const rootSplit:WorkspaceSplit = new (WorkspaceSplit as ConstructableWorkspaceSplit)(this.view.app.workspace, "vertical"); - rootSplit.getRoot = () => this.view.app.workspace[doc === document ? 'rootSplit' : 'floatingSplit']; + const rootSplit:WorkspaceSplit = new (WorkspaceSplit as ConstructableWorkspaceSplit)(app.workspace, "vertical"); + rootSplit.getRoot = () => app.workspace[doc === document ? 'rootSplit' : 'floatingSplit']; rootSplit.getContainer = () => getContainerForDocument(doc); - this.leaf = this.view.app.workspace.createLeafInParent(rootSplit, 0); + this.leaf = app.workspace.createLeafInParent(rootSplit, 0); this.canvas = canvasPlugin.views.canvas(this.leaf).canvas; this.initialized = true; }