399 lines
		
	
	
		
			12 KiB
		
	
	
	
		
			Markdown
		
	
	
	
	
	
			
		
		
	
	
			399 lines
		
	
	
		
			12 KiB
		
	
	
	
		
			Markdown
		
	
	
	
	
	
| /*
 | |
| 
 | |
| 
 | |
| 
 | |
| Download this file and save to your Obsidian Vault including the first line, or open it in "Raw" and copy the entire contents to Obsidian.
 | |
| 
 | |
| 
 | |
| 
 | |
| This script performs automatic layout for the selected top-level grouping objects. It is powered by [elkjs](https://github.com/kieler/elkjs) and needs to be connected to the Internet.
 | |
| 
 | |
| 
 | |
| See documentation for more details:
 | |
| https://zsviczian.github.io/obsidian-excalidraw-plugin/ExcalidrawScriptsEngine.html
 | |
| 
 | |
| ```javascript
 | |
| */
 | |
| 
 | |
| if (
 | |
|   !ea.verifyMinimumPluginVersion ||
 | |
|   !ea.verifyMinimumPluginVersion("1.5.21")
 | |
| ) {
 | |
|   new Notice(
 | |
|     "This script requires a newer version of Excalidraw. Please install the latest version."
 | |
|   );
 | |
|   return;
 | |
| }
 | |
| 
 | |
| settings = ea.getScriptSettings();
 | |
| //set default values on first run
 | |
| if (!settings["Layout Options JSON"]) {
 | |
|   settings = {
 | |
|     "Layout Options JSON": {
 | |
|       height: "450px",
 | |
|       value: `{\n      "org.eclipse.elk.layered.crossingMinimization.semiInteractive": "true",\n      "org.eclipse.elk.layered.considerModelOrder.components": "FORCE_MODEL_ORDER"\n}`,
 | |
|       description: `You can use layout options to configure the layout algorithm. A list of all options and further details of their exact effects is available in <a href="http://www.eclipse.org/elk/reference.html" rel="nofollow">ELK's documentation</a>.`,
 | |
|     },
 | |
|   };
 | |
|   ea.setScriptSettings(settings);
 | |
| } 
 | |
| 
 | |
| if (typeof ELK === "undefined") {
 | |
|   loadELK(doAutoLayout);
 | |
| } else {
 | |
|   doAutoLayout();
 | |
| }
 | |
| 
 | |
| async function doAutoLayout() {
 | |
|   const selectedElements = ea.getViewSelectedElements();
 | |
|   const groups = ea
 | |
|     .getMaximumGroups(selectedElements)
 | |
|     .map((g) => g.filter((el) => el.containerId == null)) // ignore text in stickynote
 | |
|     .filter((els) => els.length > 0);
 | |
| 
 | |
|   const stickynotesMap = selectedElements
 | |
|     .filter((el) => el.containerId != null)
 | |
|     .reduce((result, el) => {
 | |
|       result.set(el.containerId, el);
 | |
|       return result;
 | |
|     }, new Map());
 | |
| 
 | |
|   const elk = new ELK();
 | |
|   const knownLayoutAlgorithms = await elk.knownLayoutAlgorithms();
 | |
|   const layoutAlgorithms = knownLayoutAlgorithms
 | |
|     .map((knownLayoutAlgorithm) => ({
 | |
|       id: knownLayoutAlgorithm.id,
 | |
|       displayText:
 | |
|         knownLayoutAlgorithm.id === "org.eclipse.elk.layered" ||
 | |
|         knownLayoutAlgorithm.id === "org.eclipse.elk.radial" ||
 | |
|         knownLayoutAlgorithm.id === "org.eclipse.elk.mrtree"
 | |
|           ? "* " +
 | |
|             knownLayoutAlgorithm.name +
 | |
|             ": " +
 | |
|             knownLayoutAlgorithm.description
 | |
|           : knownLayoutAlgorithm.name + ": " + knownLayoutAlgorithm.description,
 | |
|     }))
 | |
|     .sort((lha, rha) => lha.displayText.localeCompare(rha.displayText));
 | |
| 
 | |
|   const layoutAlgorithmsSimple = knownLayoutAlgorithms
 | |
|     .map((knownLayoutAlgorithm) => ({
 | |
|       id: knownLayoutAlgorithm.id,
 | |
|       displayText:
 | |
|         knownLayoutAlgorithm.id === "org.eclipse.elk.layered" ||
 | |
|         knownLayoutAlgorithm.id === "org.eclipse.elk.radial" ||
 | |
|         knownLayoutAlgorithm.id === "org.eclipse.elk.mrtree"
 | |
|           ? "* " + knownLayoutAlgorithm.name
 | |
|           : knownLayoutAlgorithm.name,
 | |
|     }))
 | |
|     .sort((lha, rha) => lha.displayText.localeCompare(rha.displayText));
 | |
| 
 | |
|   // const knownOptions = knownLayoutAlgorithms
 | |
|   //   .reduce(
 | |
|   //     (result, knownLayoutAlgorithm) => [
 | |
|   //       ...result,
 | |
|   //       ...knownLayoutAlgorithm.knownOptions,
 | |
|   //     ],
 | |
|   //     []
 | |
|   //   )
 | |
|   //   .filter((value, index, self) => self.indexOf(value) === index) // remove duplicates
 | |
|   //   .sort((lha, rha) => lha.localeCompare(rha));
 | |
|   // console.log("knownOptions", knownOptions);
 | |
| 
 | |
|   const selectedAlgorithm = await utils.suggester(
 | |
|     layoutAlgorithms.map((algorithmInfo) => algorithmInfo.displayText),
 | |
|     layoutAlgorithms.map((algorithmInfo) => algorithmInfo.id),
 | |
|     "Layout algorithm"
 | |
|   );
 | |
| 
 | |
|   const knownNodePlacementStrategy = [
 | |
|     "SIMPLE",
 | |
|     "INTERACTIVE",
 | |
|     "LINEAR_SEGMENTS",
 | |
|     "BRANDES_KOEPF",
 | |
|     "NETWORK_SIMPLEX",
 | |
|   ];
 | |
| 
 | |
|   const knownDirections = [
 | |
|     "UNDEFINED",
 | |
|     "RIGHT",
 | |
|     "LEFT",
 | |
|     "DOWN",
 | |
|     "UP"
 | |
|   ];
 | |
| 
 | |
|   let nodePlacementStrategy = "BRANDES_KOEPF";
 | |
|   let componentComponentSpacing = "10";
 | |
|   let nodeNodeSpacing = "100";
 | |
|   let nodeNodeBetweenLayersSpacing = "100";
 | |
|   let discoComponentLayoutAlgorithm = "org.eclipse.elk.layered";
 | |
|   let direction = "UNDEFINED";
 | |
| 
 | |
|   if (selectedAlgorithm === "org.eclipse.elk.layered") {
 | |
|     nodePlacementStrategy = await utils.suggester(
 | |
|       knownNodePlacementStrategy,
 | |
|       knownNodePlacementStrategy,
 | |
|       "Node placement strategy"
 | |
|     );
 | |
| 
 | |
|     selectedDirection = await utils.suggester(
 | |
|       knownDirections,
 | |
|       knownDirections,
 | |
|       "Direction"
 | |
|     );
 | |
|     direction = selectedDirection??"UNDEFINED";
 | |
|   } else if (selectedAlgorithm === "org.eclipse.elk.disco") {
 | |
|     const componentLayoutAlgorithms = layoutAlgorithmsSimple.filter(al => al.id !== "org.eclipse.elk.disco");
 | |
|     const selectedDiscoComponentLayoutAlgorithm = await utils.suggester(
 | |
|       componentLayoutAlgorithms.map((algorithmInfo) => algorithmInfo.displayText),
 | |
|       componentLayoutAlgorithms.map((algorithmInfo) => algorithmInfo.id),
 | |
|       "Disco Connected Components Layout Algorithm"
 | |
|     );
 | |
|     discoComponentLayoutAlgorithm = selectedDiscoComponentLayoutAlgorithm??"org.eclipse.elk.layered";
 | |
|   }
 | |
| 
 | |
|   if (
 | |
|     selectedAlgorithm === "org.eclipse.elk.box" ||
 | |
|     selectedAlgorithm === "org.eclipse.elk.rectpacking"
 | |
|   ) {
 | |
|     nodeNodeSpacing = await utils.inputPrompt("Node Spacing", "number", "10");
 | |
|   } else {
 | |
|     let userSpacingStr = await utils.inputPrompt(
 | |
|       "Components Spacing, Node Spacing, Node Node Between Layers Spacing",
 | |
|       "number, number, number",
 | |
|       "10, 100, 100"
 | |
|     );
 | |
|     let userSpacingArr = (userSpacingStr??"").split(",");
 | |
|     componentComponentSpacing = userSpacingArr[0] ?? "10";
 | |
|     nodeNodeSpacing = userSpacingArr[1] ?? "100";
 | |
|     nodeNodeBetweenLayersSpacing = userSpacingArr[2] ?? "100";
 | |
|   }
 | |
| 
 | |
|   let layoutOptionsJson = {};
 | |
|   try {
 | |
|     layoutOptionsJson = JSON.parse(settings["Layout Options JSON"].value);
 | |
|   } catch (e) {
 | |
|     new Notice(
 | |
|       "Error reading Layout Options JSON, see developer console for more information",
 | |
|       4000
 | |
|     );
 | |
|     console.log(e);
 | |
|   }
 | |
| 
 | |
|   layoutOptionsJson["elk.algorithm"] = selectedAlgorithm;
 | |
|   layoutOptionsJson["org.eclipse.elk.spacing.componentComponent"] =
 | |
|     componentComponentSpacing;
 | |
|   layoutOptionsJson["org.eclipse.elk.spacing.nodeNode"] = nodeNodeSpacing;
 | |
|   layoutOptionsJson["org.eclipse.elk.layered.spacing.nodeNodeBetweenLayers"] =
 | |
|     nodeNodeBetweenLayersSpacing;
 | |
|   layoutOptionsJson["org.eclipse.elk.layered.nodePlacement.strategy"] =
 | |
|     nodePlacementStrategy;
 | |
|   layoutOptionsJson["org.eclipse.elk.disco.componentCompaction.componentLayoutAlgorithm"] = 
 | |
|     discoComponentLayoutAlgorithm;
 | |
|   layoutOptionsJson["org.eclipse.elk.direction"] = direction;
 | |
| 
 | |
|   const graph = {
 | |
|     id: "root",
 | |
|     layoutOptions: layoutOptionsJson,
 | |
|     children: [],
 | |
|     edges: [],
 | |
|   };
 | |
| 
 | |
|   let groupMap = new Map();
 | |
|   let targetElkMap = new Map();
 | |
|   let arrowEls = [];
 | |
| 
 | |
|   for (let i = 0; i < groups.length; i++) {
 | |
|     const elements = groups[i];
 | |
|     if (
 | |
|       elements.length === 1 &&
 | |
|       (elements[0].type === "arrow" || elements[0].type === "line")
 | |
|     ) {
 | |
|       if (
 | |
|         elements[0].type === "arrow" &&
 | |
|         elements[0].startBinding &&
 | |
|         elements[0].endBinding
 | |
|       ) {
 | |
|         arrowEls.push(elements[0]);
 | |
|       }
 | |
|     } else {
 | |
|       let elkId = "g" + i;
 | |
|       elements.reduce((result, el) => {
 | |
|         result.set(el.id, elkId);
 | |
|         return result;
 | |
|       }, targetElkMap);
 | |
| 
 | |
|       const box = ea.getBoundingBox(elements);
 | |
|       groupMap.set(elkId, {
 | |
|         elements: elements,
 | |
|         boundingBox: box,
 | |
|       });
 | |
| 
 | |
|       graph.children.push({
 | |
|         id: elkId,
 | |
|         width: box.width,
 | |
|         height: box.height,
 | |
|         x: box.topX,
 | |
|         y: box.topY,
 | |
|       });
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   for (let i = 0; i < arrowEls.length; i++) {
 | |
|     const arrowEl = arrowEls[i];
 | |
|     const startElkId = targetElkMap.get(arrowEl.startBinding.elementId);
 | |
|     const endElkId = targetElkMap.get(arrowEl.endBinding.elementId);
 | |
| 
 | |
|     graph.edges.push({
 | |
|       id: "e" + i,
 | |
|       sources: [startElkId],
 | |
|       targets: [endElkId],
 | |
|     });
 | |
|   }
 | |
| 
 | |
|   const initTopX =
 | |
|     Math.min(...Array.from(groupMap.values()).map((v) => v.boundingBox.topX)) -
 | |
|     12;
 | |
|   const initTopY =
 | |
|     Math.min(...Array.from(groupMap.values()).map((v) => v.boundingBox.topY)) -
 | |
|     12;
 | |
| 
 | |
|   elk
 | |
|     .layout(graph)
 | |
|     .then((resultGraph) => {
 | |
|       for (const elkEl of resultGraph.children) {
 | |
|         const group = groupMap.get(elkEl.id);
 | |
|         for (const groupEl of group.elements) {
 | |
|           const originalDistancX = groupEl.x - group.boundingBox.topX;
 | |
|           const originalDistancY = groupEl.y - group.boundingBox.topY;
 | |
|           const groupElDistanceX =
 | |
|             elkEl.x + initTopX + originalDistancX - groupEl.x;
 | |
|           const groupElDistanceY =
 | |
|             elkEl.y + initTopY + originalDistancY - groupEl.y;
 | |
| 
 | |
|           groupEl.x = groupEl.x + groupElDistanceX;
 | |
|           groupEl.y = groupEl.y + groupElDistanceY;
 | |
| 
 | |
|           if (stickynotesMap.has(groupEl.id)) {
 | |
|             const stickynote = stickynotesMap.get(groupEl.id);
 | |
|             stickynote.x = stickynote.x + groupElDistanceX;
 | |
|             stickynote.y = stickynote.y + groupElDistanceY;
 | |
|           }
 | |
|         }
 | |
|       }
 | |
| 
 | |
|       ea.copyViewElementsToEAforEditing(selectedElements);
 | |
|       ea.addElementsToView(false, false);
 | |
| 
 | |
|       normalizeSelectedArrows();
 | |
|     })
 | |
|     .catch(console.error);
 | |
| }
 | |
| 
 | |
| function loadELK(doAfterLoaded) {
 | |
|   let script = document.createElement("script");
 | |
|   script.onload = function () {
 | |
|     if (typeof ELK !== "undefined") {
 | |
|       doAfterLoaded();
 | |
|     }
 | |
|   };
 | |
|   script.src =
 | |
|     "https://cdn.jsdelivr.net/npm/elkjs@0.8.2/lib/elk.bundled.min.js";
 | |
|   document.head.appendChild(script);
 | |
| }
 | |
| 
 | |
| /*
 | |
|  * Normalize Selected Arrows
 | |
|  */
 | |
| 
 | |
| function normalizeSelectedArrows() {
 | |
|   let gapValue = 2;
 | |
| 
 | |
|   const selectedIndividualArrows = ea.getMaximumGroups(ea.getViewSelectedElements())
 | |
|     .reduce((result, g) => [...result, ...g.filter(el => el.type === 'arrow')], []);
 | |
| 
 | |
|   const allElements = ea.getViewElements();
 | |
|   for (const arrow of selectedIndividualArrows) {
 | |
|     const startBindingEl = allElements.filter(
 | |
|       (el) => el.id === (arrow.startBinding || {}).elementId
 | |
|     )[0];
 | |
|     const endBindingEl = allElements.filter(
 | |
|       (el) => el.id === (arrow.endBinding || {}).elementId
 | |
|     )[0];
 | |
| 
 | |
|     if (startBindingEl) {
 | |
|       recalculateStartPointOfLine(
 | |
|         arrow,
 | |
|         startBindingEl,
 | |
|         endBindingEl,
 | |
|         gapValue
 | |
|       );
 | |
|     }
 | |
|     if (endBindingEl) {
 | |
|       recalculateEndPointOfLine(arrow, endBindingEl, startBindingEl, gapValue);
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   ea.copyViewElementsToEAforEditing(selectedIndividualArrows);
 | |
|   ea.addElementsToView(false, false);
 | |
| }
 | |
| 
 | |
| function recalculateStartPointOfLine(line, el, elB, gapValue) {
 | |
|   const aX = el.x + el.width / 2;
 | |
|   const bX =
 | |
|     line.points.length <= 2 && elB
 | |
|       ? elB.x + elB.width / 2
 | |
|       : line.x + line.points[1][0];
 | |
|   const aY = el.y + el.height / 2;
 | |
|   const bY =
 | |
|     line.points.length <= 2 && elB
 | |
|       ? elB.y + elB.height / 2
 | |
|       : line.y + line.points[1][1];
 | |
| 
 | |
|   line.startBinding.gap = gapValue;
 | |
|   line.startBinding.focus = 0;
 | |
|   const intersectA = ea.intersectElementWithLine(
 | |
|     el,
 | |
|     [bX, bY],
 | |
|     [aX, aY],
 | |
|     line.startBinding.gap
 | |
|   );
 | |
| 
 | |
|   if (intersectA.length > 0) {
 | |
|     line.points[0] = [0, 0];
 | |
|     for (let i = 1; i < line.points.length; i++) {
 | |
|       line.points[i][0] -= intersectA[0][0] - line.x;
 | |
|       line.points[i][1] -= intersectA[0][1] - line.y;
 | |
|     }
 | |
|     line.x = intersectA[0][0];
 | |
|     line.y = intersectA[0][1];
 | |
|   }
 | |
| }
 | |
| 
 | |
| function recalculateEndPointOfLine(line, el, elB, gapValue) {
 | |
|   const aX = el.x + el.width / 2;
 | |
|   const bX =
 | |
|     line.points.length <= 2 && elB
 | |
|       ? elB.x + elB.width / 2
 | |
|       : line.x + line.points[line.points.length - 2][0];
 | |
|   const aY = el.y + el.height / 2;
 | |
|   const bY =
 | |
|     line.points.length <= 2 && elB
 | |
|       ? elB.y + elB.height / 2
 | |
|       : line.y + line.points[line.points.length - 2][1];
 | |
| 
 | |
|   line.endBinding.gap = gapValue;
 | |
|   line.endBinding.focus = 0;
 | |
|   const intersectA = ea.intersectElementWithLine(
 | |
|     el,
 | |
|     [bX, bY],
 | |
|     [aX, aY],
 | |
|     line.endBinding.gap
 | |
|   );
 | |
| 
 | |
|   if (intersectA.length > 0) {
 | |
|     line.points[line.points.length - 1] = [
 | |
|       intersectA[0][0] - line.x,
 | |
|       intersectA[0][1] - line.y,
 | |
|     ];
 | |
|   }
 | |
| } |