@peter.prib/renderengine 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (451) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +203 -2
  3. package/REFERENCE.md +2198 -0
  4. package/dist/renderengine-workspace.umd.js +46905 -0
  5. package/dist/renderengine.umd.js +42223 -0
  6. package/examples/archimate.html +66 -0
  7. package/examples/diagram-editor.html +51 -0
  8. package/package.json +65 -4
  9. package/src/BaseRenderer.js +57 -0
  10. package/src/CanvasRenderer.js +194 -0
  11. package/src/Mat4.js +111 -0
  12. package/src/OrbitCamera.js +57 -0
  13. package/src/RendererFactory.js +50 -0
  14. package/src/SVGRenderer.js +439 -0
  15. package/src/archimate.js +723 -0
  16. package/src/archimateArchiFormat.js +1693 -0
  17. package/src/archimateArchiTypes.js +72 -0
  18. package/src/archimateArchive.js +140 -0
  19. package/src/archimateCSV.js +127 -0
  20. package/src/archimateDrawio.js +182 -0
  21. package/src/archimateExchangeFormat.js +546 -0
  22. package/src/archimateRelationshipTable.js +93 -0
  23. package/src/archimateSpecializations.js +161 -0
  24. package/src/chartCatalog.js +589 -0
  25. package/src/charting/ArchimateDiagramEditor.js +1624 -0
  26. package/src/charting/ArchimateSpecializationSelector.js +134 -0
  27. package/src/charting/Axis.js +68 -0
  28. package/src/charting/CameraSeeder.js +30 -0
  29. package/src/charting/CatalogDialog.js +92 -0
  30. package/src/charting/Chart.js +37 -0
  31. package/src/charting/Chart3D.js +99 -0
  32. package/src/charting/ChartBox.js +80 -0
  33. package/src/charting/ChartData.js +27 -0
  34. package/src/charting/ChartRegistry.js +76 -0
  35. package/src/charting/CheckboxTreeSelector.js +304 -0
  36. package/src/charting/ColorField.js +107 -0
  37. package/src/charting/ColorPalette.js +88 -0
  38. package/src/charting/CommandHistory.js +176 -0
  39. package/src/charting/DataFilter.js +52 -0
  40. package/src/charting/Diagram.js +36 -0
  41. package/src/charting/Diagram3D.js +29 -0
  42. package/src/charting/DiagramClipboard.js +31 -0
  43. package/src/charting/DiagramEditor.js +3330 -0
  44. package/src/charting/DiagramEditorCommand.js +110 -0
  45. package/src/charting/DiagramEditorCommands.js +237 -0
  46. package/src/charting/DiagramEditorEdgeCommands.js +142 -0
  47. package/src/charting/DiagramEditorState.js +98 -0
  48. package/src/charting/DiagramRegistry.js +86 -0
  49. package/src/charting/DiagramTypeRegistry.js +326 -0
  50. package/src/charting/DiagramViewPropertiesDialog.js +46 -0
  51. package/src/charting/Edge.js +113 -0
  52. package/src/charting/ErrorBars.js +37 -0
  53. package/src/charting/FilterSystem.js +285 -0
  54. package/src/charting/FindReplaceDialog.js +139 -0
  55. package/src/charting/FormDialog.js +385 -0
  56. package/src/charting/Gauge.js +58 -0
  57. package/src/charting/Hierarchy.js +175 -0
  58. package/src/charting/HintPanel.js +86 -0
  59. package/src/charting/ImageSelector.js +148 -0
  60. package/src/charting/InteractionManager.js +725 -0
  61. package/src/charting/Legend.js +58 -0
  62. package/src/charting/MenuBar.js +62 -0
  63. package/src/charting/ModelFileFormat.js +115 -0
  64. package/src/charting/OrbitControls.js +77 -0
  65. package/src/charting/Palette.js +119 -0
  66. package/src/charting/PopupMenu.js +117 -0
  67. package/src/charting/PropertiesPanel.js +155 -0
  68. package/src/charting/PropertyListEditor.js +96 -0
  69. package/src/charting/PropertyLog.js +92 -0
  70. package/src/charting/SettingsCascade.js +93 -0
  71. package/src/charting/SettingsDialog.js +53 -0
  72. package/src/charting/SettingsManager.js +179 -0
  73. package/src/charting/SettingsViewPropertiesDialog.js +58 -0
  74. package/src/charting/ShapePaths.js +27 -0
  75. package/src/charting/Statistics.js +134 -0
  76. package/src/charting/TemplateCatalog.js +59 -0
  77. package/src/charting/TemplateLibrary.js +103 -0
  78. package/src/charting/TextStyle.js +168 -0
  79. package/src/charting/ViewFit.js +55 -0
  80. package/src/charting/ViewMenu.js +70 -0
  81. package/src/charting/ViewPropertiesDialog.js +77 -0
  82. package/src/charting/ViewTabStrip.js +81 -0
  83. package/src/charting/archimate/ArchiFileFormat.js +32 -0
  84. package/src/charting/archimate/ArchimateCanvasEditor.js +815 -0
  85. package/src/charting/archimate/ArchimateCsvFileFormat.js +35 -0
  86. package/src/charting/archimate/ArchimateEdge.js +137 -0
  87. package/src/charting/archimate/ArchimateElementTemplate.js +195 -0
  88. package/src/charting/archimate/ArchimatePalette.js +102 -0
  89. package/src/charting/archimate/CsvImportDialog.js +105 -0
  90. package/src/charting/archimate/CustomViewpoints.js +74 -0
  91. package/src/charting/archimate/DerivedEdge.js +29 -0
  92. package/src/charting/archimate/DrawioFileFormat.js +24 -0
  93. package/src/charting/archimate/ElementFilterRule.js +21 -0
  94. package/src/charting/archimate/ExchangeFileFormat.js +28 -0
  95. package/src/charting/archimate/ViewGroupTemplate.js +137 -0
  96. package/src/charting/archimate/ViewLineEdge.js +84 -0
  97. package/src/charting/archimate/ViewpointRule.js +45 -0
  98. package/src/charting/archimate/archimateFileFormats.js +12 -0
  99. package/src/charting/archimate/archimateNames.js +5 -0
  100. package/src/charting/archimate/csvMapping.js +122 -0
  101. package/src/charting/archimate/elementMenuItems.js +32 -0
  102. package/src/charting/archimate/elementProperties.js +146 -0
  103. package/src/charting/archimate/elementTypeChoices.js +7 -0
  104. package/src/charting/archimate/elementTypeGroups.js +19 -0
  105. package/src/charting/archimate/index.js +17 -0
  106. package/src/charting/archimate/specializationCatalogDialog.js +64 -0
  107. package/src/charting/archimate/typeGlyph.js +29 -0
  108. package/src/charting/arrangement/Arrangement.js +112 -0
  109. package/src/charting/arrangement/ArrangementCommand.js +29 -0
  110. package/src/charting/arrangement/BackgroundCommand.js +41 -0
  111. package/src/charting/arrangement/BackgroundGroup.js +18 -0
  112. package/src/charting/arrangement/GroupCommand.js +46 -0
  113. package/src/charting/arrangement/ItemGroup.js +62 -0
  114. package/src/charting/arrangement/LockCommand.js +32 -0
  115. package/src/charting/arrangement/UngroupCommand.js +27 -0
  116. package/src/charting/arrangement/alignment.js +88 -0
  117. package/src/charting/arrangement/arrangeMenuItems.js +25 -0
  118. package/src/charting/arrangement/index.js +11 -0
  119. package/src/charting/arrangement/layouts.js +233 -0
  120. package/src/charting/canvas/ArchiHint.js +78 -0
  121. package/src/charting/canvas/CanvasBoxKind.js +39 -0
  122. package/src/charting/canvas/CanvasPalette.js +49 -0
  123. package/src/charting/canvas/CanvasTemplateLibrary.js +61 -0
  124. package/src/charting/canvas/CanvasTemplateMenu.js +151 -0
  125. package/src/charting/canvas/EditHintCommand.js +28 -0
  126. package/src/charting/canvas/canvasBoxKinds.js +27 -0
  127. package/src/charting/canvas/canvasBoxMenuItems.js +40 -0
  128. package/src/charting/canvas/defaultCanvasTemplates.js +186 -0
  129. package/src/charting/canvas/diagramBoxKinds.js +12 -0
  130. package/src/charting/canvas/hintMenuItems.js +49 -0
  131. package/src/charting/charts/ArcGauge.js +55 -0
  132. package/src/charting/charts/AreaChart.js +71 -0
  133. package/src/charting/charts/BarChart.js +80 -0
  134. package/src/charting/charts/BarChart3D.js +56 -0
  135. package/src/charting/charts/BarChart3DOrbit.js +53 -0
  136. package/src/charting/charts/BarChartBase.js +70 -0
  137. package/src/charting/charts/BarGauge.js +42 -0
  138. package/src/charting/charts/BoxPlotChart.js +41 -0
  139. package/src/charting/charts/BubbleChart.js +99 -0
  140. package/src/charting/charts/BubbleChart3D.js +78 -0
  141. package/src/charting/charts/BulletGauge.js +87 -0
  142. package/src/charting/charts/CalendarHeatmapChart.js +204 -0
  143. package/src/charting/charts/CandlestickChart.js +54 -0
  144. package/src/charting/charts/ChordDiagram.js +130 -0
  145. package/src/charting/charts/DensityChart.js +73 -0
  146. package/src/charting/charts/DialGauge.js +65 -0
  147. package/src/charting/charts/DonutChart.js +15 -0
  148. package/src/charting/charts/FunnelChart.js +47 -0
  149. package/src/charting/charts/GanttChart.js +201 -0
  150. package/src/charting/charts/GaugeChart.js +175 -0
  151. package/src/charting/charts/GeoMapChart.js +146 -0
  152. package/src/charting/charts/GroupedBarChart.js +88 -0
  153. package/src/charting/charts/GroupedDistributionChart.js +70 -0
  154. package/src/charting/charts/HeatmapChart.js +80 -0
  155. package/src/charting/charts/HierarchyChart.js +39 -0
  156. package/src/charting/charts/HistogramChart.js +45 -0
  157. package/src/charting/charts/KpiTile.js +72 -0
  158. package/src/charting/charts/LagPlotChart.js +41 -0
  159. package/src/charting/charts/LineChart.js +124 -0
  160. package/src/charting/charts/MultiRingGauge.js +54 -0
  161. package/src/charting/charts/OutlineChart.js +95 -0
  162. package/src/charting/charts/ParallelCoordinatesChart.js +130 -0
  163. package/src/charting/charts/PieChart.js +56 -0
  164. package/src/charting/charts/PipeChart.js +88 -0
  165. package/src/charting/charts/PopulationPyramidChart.js +106 -0
  166. package/src/charting/charts/QQPlotChart.js +64 -0
  167. package/src/charting/charts/RadarChart.js +76 -0
  168. package/src/charting/charts/RadialBarChart.js +96 -0
  169. package/src/charting/charts/RingGauge.js +23 -0
  170. package/src/charting/charts/SankeyDiagram.js +174 -0
  171. package/src/charting/charts/ScatterChart.js +104 -0
  172. package/src/charting/charts/StackedAreaChart.js +15 -0
  173. package/src/charting/charts/StackedBarChart.js +33 -0
  174. package/src/charting/charts/StackedBarChart3DOrbit.js +58 -0
  175. package/src/charting/charts/StreamGraphChart.js +131 -0
  176. package/src/charting/charts/SunburstChart.js +57 -0
  177. package/src/charting/charts/ThermometerGauge.js +43 -0
  178. package/src/charting/charts/TreemapChart.js +54 -0
  179. package/src/charting/charts/ViolinChart.js +37 -0
  180. package/src/charting/charts/WaterfallChart.js +66 -0
  181. package/src/charting/charts/WordCloudChart.js +104 -0
  182. package/src/charting/diagramModel/AddViewCommand.js +33 -0
  183. package/src/charting/diagramModel/DiagramModel.js +39 -0
  184. package/src/charting/diagramModel/DiagramModelSearch.js +22 -0
  185. package/src/charting/diagramModel/DiagramView.js +50 -0
  186. package/src/charting/diagramModel/ModelHistory.js +43 -0
  187. package/src/charting/diagramModel/ModelReport.js +153 -0
  188. package/src/charting/diagramModel/ModelSearch.js +223 -0
  189. package/src/charting/diagramModel/RemoveViewCommand.js +25 -0
  190. package/src/charting/diagramModel/RenameViewCommand.js +21 -0
  191. package/src/charting/diagramModel/ReplaceTextCommand.js +25 -0
  192. package/src/charting/diagramModel/ViewSetChecker.js +34 -0
  193. package/src/charting/diagramModel/ViewSetModel.js +243 -0
  194. package/src/charting/diagramModel/index.js +6 -0
  195. package/src/charting/diagramModel/nameTransforms.js +29 -0
  196. package/src/charting/diagrams/ArchimateCommands.js +141 -0
  197. package/src/charting/diagrams/ArchimateDiagram.js +263 -0
  198. package/src/charting/diagrams/ArchimateModel.js +1155 -0
  199. package/src/charting/diagrams/ArchimateModelChecker.js +125 -0
  200. package/src/charting/diagrams/ArchimateModelReport.js +128 -0
  201. package/src/charting/diagrams/ArchimateModelSearch.js +41 -0
  202. package/src/charting/diagrams/ArchimateValidator.js +357 -0
  203. package/src/charting/diagrams/CanvasView.js +112 -0
  204. package/src/charting/diagrams/ContextMenu.js +37 -0
  205. package/src/charting/diagrams/DataModelDiagram.js +86 -0
  206. package/src/charting/diagrams/DataModelRelationshipStrategy.js +38 -0
  207. package/src/charting/diagrams/DerivedRelationships.js +102 -0
  208. package/src/charting/diagrams/EbnfDiagram.js +39 -0
  209. package/src/charting/diagrams/EdgeGeometry.js +553 -0
  210. package/src/charting/diagrams/EditViewItemCommand.js +128 -0
  211. package/src/charting/diagrams/ElementInstance.js +157 -0
  212. package/src/charting/diagrams/GraphDiagram.js +1340 -0
  213. package/src/charting/diagrams/GraphDiagram3D.js +152 -0
  214. package/src/charting/diagrams/GraphMetrics.js +81 -0
  215. package/src/charting/diagrams/GridOverlay.js +137 -0
  216. package/src/charting/diagrams/ImportRowsCommand.js +62 -0
  217. package/src/charting/diagrams/LabelHelper.js +108 -0
  218. package/src/charting/diagrams/MergeElementsCommand.js +101 -0
  219. package/src/charting/diagrams/NestInstanceCommand.js +34 -0
  220. package/src/charting/diagrams/NodeGeometry.js +158 -0
  221. package/src/charting/diagrams/PlaceItemsCommand.js +77 -0
  222. package/src/charting/diagrams/RadialLayout.js +101 -0
  223. package/src/charting/diagrams/RelationshipInstance.js +93 -0
  224. package/src/charting/diagrams/RemoveGroupFromViewCommand.js +28 -0
  225. package/src/charting/diagrams/RemoveLineCommand.js +27 -0
  226. package/src/charting/diagrams/ShowElementCommand.js +43 -0
  227. package/src/charting/diagrams/SvgCoordinate.js +17 -0
  228. package/src/charting/diagrams/SvgZoom.js +127 -0
  229. package/src/charting/diagrams/Tooltip.js +62 -0
  230. package/src/charting/diagrams/ViewConnectionInstance.js +102 -0
  231. package/src/charting/diagrams/ViewObjectInstance.js +56 -0
  232. package/src/charting/diagrams/commands/AddEdgeCommand.js +34 -0
  233. package/src/charting/diagrams/commands/AddNodeCommand.js +28 -0
  234. package/src/charting/diagrams/commands/AddWaypointCommand.js +27 -0
  235. package/src/charting/diagrams/commands/CreateFolderCommand.js +27 -0
  236. package/src/charting/diagrams/commands/DeleteEdgeCommand.js +30 -0
  237. package/src/charting/diagrams/commands/DeleteFolderCommand.js +47 -0
  238. package/src/charting/diagrams/commands/DeleteNodeCommand.js +35 -0
  239. package/src/charting/diagrams/commands/DeleteWaypointCommand.js +24 -0
  240. package/src/charting/diagrams/commands/GraphCommand.js +65 -0
  241. package/src/charting/diagrams/commands/InvertEdgeCommand.js +24 -0
  242. package/src/charting/diagrams/commands/MoveNodeCommand.js +44 -0
  243. package/src/charting/diagrams/commands/MoveWaypointCommand.js +27 -0
  244. package/src/charting/diagrams/commands/RenameEdgeCommand.js +23 -0
  245. package/src/charting/diagrams/commands/RenameFolderCommand.js +31 -0
  246. package/src/charting/diagrams/commands/RenameNodeCommand.js +27 -0
  247. package/src/charting/diagrams/commands/ResizeNodeCommand.js +46 -0
  248. package/src/charting/diagrams/commands/UpdateEdgeSideCommand.js +40 -0
  249. package/src/charting/diagrams/commands/index.js +17 -0
  250. package/src/charting/diagrams/duplicates.js +49 -0
  251. package/src/charting/diagrams/nesting.js +30 -0
  252. package/src/charting/diagrams/roadmap.js +77 -0
  253. package/src/charting/edgeMarkers/ArrowMarker.js +15 -0
  254. package/src/charting/edgeMarkers/CircleMarker.js +13 -0
  255. package/src/charting/edgeMarkers/CrowsFootMarker.js +16 -0
  256. package/src/charting/edgeMarkers/DiamondMarker.js +15 -0
  257. package/src/charting/edgeMarkers/EdgeMarker.js +34 -0
  258. package/src/charting/edgeMarkers/HalfArrowMarker.js +8 -0
  259. package/src/charting/edgeMarkers/HollowDiamondMarker.js +18 -0
  260. package/src/charting/edgeMarkers/HollowTriangleMarker.js +18 -0
  261. package/src/charting/edgeMarkers/OpenArrowMarker.js +17 -0
  262. package/src/charting/edgeMarkers/index.js +27 -0
  263. package/src/charting/edgeStyle/EdgeStyleCascade.js +22 -0
  264. package/src/charting/edgeStyle/EdgeStyleDialog.js +51 -0
  265. package/src/charting/edgeStyle/EdgeStyleSchema.js +43 -0
  266. package/src/charting/edgeStyle/SetStyleCommand.js +12 -0
  267. package/src/charting/edgeStyle/index.js +4 -0
  268. package/src/charting/emphasis/EmphasisRule.js +42 -0
  269. package/src/charting/emphasis/HighlightRule.js +24 -0
  270. package/src/charting/gauges/ArcTrack.js +51 -0
  271. package/src/charting/gauges/GaugeAnimation.js +51 -0
  272. package/src/charting/gauges/GaugeBands.js +48 -0
  273. package/src/charting/gauges/GaugeScale.js +87 -0
  274. package/src/charting/gauges/GaugeTrack.js +44 -0
  275. package/src/charting/gauges/GaugeValue.js +61 -0
  276. package/src/charting/gauges/HorizontalTrack.js +14 -0
  277. package/src/charting/gauges/LinearTrack.js +29 -0
  278. package/src/charting/gauges/Sparkline.js +42 -0
  279. package/src/charting/gauges/VerticalTrack.js +17 -0
  280. package/src/charting/glyphs/ArrowGlyph.js +21 -0
  281. package/src/charting/glyphs/BatteryGlyph.js +18 -0
  282. package/src/charting/glyphs/ChevronGlyph.js +18 -0
  283. package/src/charting/glyphs/CircleGlyph.js +15 -0
  284. package/src/charting/glyphs/CloudGlyph.js +19 -0
  285. package/src/charting/glyphs/ComponentGlyph.js +18 -0
  286. package/src/charting/glyphs/ConstraintGlyph.js +24 -0
  287. package/src/charting/glyphs/ContractBoxGlyph.js +10 -0
  288. package/src/charting/glyphs/CubeGlyph.js +19 -0
  289. package/src/charting/glyphs/CylinderGlyph.js +19 -0
  290. package/src/charting/glyphs/DashedDoubleArrowGlyph.js +11 -0
  291. package/src/charting/glyphs/DeviceGlyph.js +17 -0
  292. package/src/charting/glyphs/DiamondGlyph.js +17 -0
  293. package/src/charting/glyphs/DocumentGlyph.js +17 -0
  294. package/src/charting/glyphs/DoubleArrowGlyph.js +23 -0
  295. package/src/charting/glyphs/DoubleCircleGlyph.js +24 -0
  296. package/src/charting/glyphs/EllipseGlyph.js +18 -0
  297. package/src/charting/glyphs/FactoryGlyph.js +20 -0
  298. package/src/charting/glyphs/FlagGlyph.js +16 -0
  299. package/src/charting/glyphs/GapCircleGlyph.js +17 -0
  300. package/src/charting/glyphs/GearGlyph.js +37 -0
  301. package/src/charting/glyphs/Glyph.js +98 -0
  302. package/src/charting/glyphs/GroupingBoxGlyph.js +13 -0
  303. package/src/charting/glyphs/HalfCircleGlyph.js +22 -0
  304. package/src/charting/glyphs/HexagonGlyph.js +28 -0
  305. package/src/charting/glyphs/IconGlyph.js +22 -0
  306. package/src/charting/glyphs/ImageGlyph.js +16 -0
  307. package/src/charting/glyphs/JunctionGlyph.js +17 -0
  308. package/src/charting/glyphs/LollipopGlyph.js +22 -0
  309. package/src/charting/glyphs/MagnifierGlyph.js +17 -0
  310. package/src/charting/glyphs/NoteGlyph.js +33 -0
  311. package/src/charting/glyphs/ObjectBoxGlyph.js +21 -0
  312. package/src/charting/glyphs/OrJunctionGlyph.js +17 -0
  313. package/src/charting/glyphs/ParallelogramGlyph.js +20 -0
  314. package/src/charting/glyphs/PersonGlyph.js +23 -0
  315. package/src/charting/glyphs/PillGlyph.js +23 -0
  316. package/src/charting/glyphs/PinGlyph.js +18 -0
  317. package/src/charting/glyphs/PrincipleGlyph.js +23 -0
  318. package/src/charting/glyphs/ProductBoxGlyph.js +16 -0
  319. package/src/charting/glyphs/RectGlyph.js +15 -0
  320. package/src/charting/glyphs/RoundedBoxGlyph.js +13 -0
  321. package/src/charting/glyphs/StackGlyph.js +14 -0
  322. package/src/charting/glyphs/StepsGlyph.js +20 -0
  323. package/src/charting/glyphs/StickFigureGlyph.js +32 -0
  324. package/src/charting/glyphs/SystemSoftwareBoxGlyph.js +16 -0
  325. package/src/charting/glyphs/TabbedBoxGlyph.js +37 -0
  326. package/src/charting/glyphs/TargetGlyph.js +23 -0
  327. package/src/charting/glyphs/WavyBoxGlyph.js +17 -0
  328. package/src/charting/glyphs/WheelGlyph.js +28 -0
  329. package/src/charting/glyphs/drawGlyph.js +28 -0
  330. package/src/charting/glyphs/glyphSvg.js +55 -0
  331. package/src/charting/glyphs/index.js +109 -0
  332. package/src/charting/modelTree/AnalysisPanel.js +159 -0
  333. package/src/charting/modelTree/DiagramTabs.js +217 -0
  334. package/src/charting/modelTree/ModelTree.js +164 -0
  335. package/src/charting/modelTree/ModelTreePanel.js +914 -0
  336. package/src/charting/modelTree/TreeBuilder.js +238 -0
  337. package/src/charting/modelTree/TreeModeRegistry.js +51 -0
  338. package/src/charting/modelTree/TreeViewMode.js +110 -0
  339. package/src/charting/modelTree/buildFilterTrees.js +138 -0
  340. package/src/charting/modelTree/modes/FlatTreeMode.js +46 -0
  341. package/src/charting/modelTree/modes/LayerGroupedTreeMode.js +62 -0
  342. package/src/charting/modelTree/modes/TypeGroupedTreeMode.js +60 -0
  343. package/src/charting/modelTree/treeLayers.js +11 -0
  344. package/src/charting/report/reportHtml.js +82 -0
  345. package/src/charting/report/reportViewer.js +118 -0
  346. package/src/charting/shapes/CircleShape.js +19 -0
  347. package/src/charting/shapes/PathShape.js +17 -0
  348. package/src/charting/shapes/RectShape.js +20 -0
  349. package/src/charting/shapes/Shape.js +20 -0
  350. package/src/charting/shapes/ShapeFactory.js +23 -0
  351. package/src/charting/storage/LocalJsonStore.js +43 -0
  352. package/src/charting/templates/ActorTemplate.js +36 -0
  353. package/src/charting/templates/BoxTemplate.js +27 -0
  354. package/src/charting/templates/CircleTemplate.js +18 -0
  355. package/src/charting/templates/ClassTemplate.js +20 -0
  356. package/src/charting/templates/CloudTemplate.js +43 -0
  357. package/src/charting/templates/ComponentTemplate.js +19 -0
  358. package/src/charting/templates/CylinderTemplate.js +30 -0
  359. package/src/charting/templates/DataStoreTemplate.js +16 -0
  360. package/src/charting/templates/DiamondTemplate.js +10 -0
  361. package/src/charting/templates/DocumentTemplate.js +32 -0
  362. package/src/charting/templates/EllipseTemplate.js +15 -0
  363. package/src/charting/templates/FinalStateTemplate.js +12 -0
  364. package/src/charting/templates/GlyphTemplate.js +47 -0
  365. package/src/charting/templates/HexagonTemplate.js +17 -0
  366. package/src/charting/templates/LifelineTemplate.js +20 -0
  367. package/src/charting/templates/ManualInputTemplate.js +12 -0
  368. package/src/charting/templates/NoteTemplate.js +23 -0
  369. package/src/charting/templates/OctagonTemplate.js +7 -0
  370. package/src/charting/templates/PackageTemplate.js +25 -0
  371. package/src/charting/templates/ParallelogramTemplate.js +11 -0
  372. package/src/charting/templates/PentagonTemplate.js +5 -0
  373. package/src/charting/templates/PolygonTemplate.js +18 -0
  374. package/src/charting/templates/PredefinedProcessTemplate.js +13 -0
  375. package/src/charting/templates/RectangleTemplate.js +28 -0
  376. package/src/charting/templates/RegularPolygonTemplate.js +21 -0
  377. package/src/charting/templates/RoundedRectangleTemplate.js +5 -0
  378. package/src/charting/templates/StadiumTemplate.js +8 -0
  379. package/src/charting/templates/StarTemplate.js +17 -0
  380. package/src/charting/templates/SwimlaneTemplate.js +18 -0
  381. package/src/charting/templates/Template.js +208 -0
  382. package/src/charting/templates/TrapezoidTemplate.js +11 -0
  383. package/src/charting/templates/TriangleTemplate.js +12 -0
  384. package/src/charting/templates/index.js +31 -0
  385. package/src/charting/templates/outline.js +59 -0
  386. package/src/charting/templates/svg.js +19 -0
  387. package/src/charting/textToShapes.js +61 -0
  388. package/src/charting/toolbar/ToolbarButton.js +38 -0
  389. package/src/charting/toolbar/ToolbarCheckbox.js +31 -0
  390. package/src/charting/toolbar/ToolbarItem.js +19 -0
  391. package/src/charting/toolbar/ToolbarMenu.js +36 -0
  392. package/src/charting/toolbar/ToolbarSelect.js +38 -0
  393. package/src/charting/toolbar/ToolbarSeparator.js +7 -0
  394. package/src/charting/toolbar/ToolbarText.js +22 -0
  395. package/src/charting/toolbar/index.js +8 -0
  396. package/src/charting/toolbar/pickFile.js +14 -0
  397. package/src/charting/viewDecorations.js +85 -0
  398. package/src/charting/zOrder.js +57 -0
  399. package/src/csvTable.js +61 -0
  400. package/src/datamodel.js +122 -0
  401. package/src/datawindow/BoundedWindow.js +36 -0
  402. package/src/datawindow/FlatDataWindow.js +34 -0
  403. package/src/datawindow/GraphDataWindow.js +40 -0
  404. package/src/datawindow/GroupedSampleDataWindow.js +16 -0
  405. package/src/datawindow/SeriesDataWindow.js +42 -0
  406. package/src/datawindow/index.js +31 -0
  407. package/src/diagramCatalog.js +30 -0
  408. package/src/ebnfDiagram.js +57 -0
  409. package/src/ebnfParser.js +129 -0
  410. package/src/exportImage.js +233 -0
  411. package/src/index.js +2636 -0
  412. package/src/itIcons.js +84 -0
  413. package/src/kernelExpr.js +243 -0
  414. package/src/propertiesDialog.js +463 -0
  415. package/src/railroadDiagram.js +571 -0
  416. package/src/treeModeCatalog.js +16 -0
  417. package/src/workspace/ContextBus.js +79 -0
  418. package/src/workspace/EditorShell.js +67 -0
  419. package/src/workspace/Part.js +163 -0
  420. package/src/workspace/PartFactory.js +49 -0
  421. package/src/workspace/PartRegistry.js +86 -0
  422. package/src/workspace/Workspace.js +321 -0
  423. package/src/workspace/archimateServices.js +33 -0
  424. package/src/workspace/dom.js +120 -0
  425. package/src/workspace/index.js +75 -0
  426. package/src/workspace/layout/GridNode.js +280 -0
  427. package/src/workspace/layout/LayoutNode.js +89 -0
  428. package/src/workspace/layout/PartNode.js +77 -0
  429. package/src/workspace/layout/SplitNode.js +114 -0
  430. package/src/workspace/layout/TabsNode.js +191 -0
  431. package/src/workspace/layout/index.js +24 -0
  432. package/src/workspace/parts/AdapterPart.js +33 -0
  433. package/src/workspace/parts/ArchimateCanvasPart.js +345 -0
  434. package/src/workspace/parts/ArchimateEditorPart.js +79 -0
  435. package/src/workspace/parts/ChartPart.js +115 -0
  436. package/src/workspace/parts/ContentPart.js +34 -0
  437. package/src/workspace/parts/DetailPart.js +46 -0
  438. package/src/workspace/parts/DiagramEditorPart.js +135 -0
  439. package/src/workspace/parts/EditorPart.js +388 -0
  440. package/src/workspace/parts/HtmlPart.js +25 -0
  441. package/src/workspace/parts/ListPart.js +57 -0
  442. package/src/workspace/parts/ModelFile.js +187 -0
  443. package/src/workspace/parts/ModelsPart.js +135 -0
  444. package/src/workspace/styles.js +258 -0
  445. package/src/workspace/themes.js +96 -0
  446. package/src/workspace/workArea.js +413 -0
  447. package/src/workspace/workAreaModel.js +197 -0
  448. package/src/workspace.js +16 -0
  449. package/src/zip/ZipArchive.js +160 -0
  450. package/src/zip/crc32.js +25 -0
  451. package/src/zip/inflate.js +159 -0
@@ -0,0 +1,3330 @@
1
+ import { TemplateCatalog } from './TemplateCatalog.js';
2
+ import { Palette } from './Palette.js';
3
+ import { getTemplatesForDiagramType, getDiagramTypesByGroup } from './DiagramTypeRegistry.js';
4
+ import { EdgeGeometry } from './diagrams/EdgeGeometry.js';
5
+ import { AddEdgeCommand, DeleteEdgeCommand, AddWaypointCommand, DeleteWaypointCommand, MoveWaypointCommand, ReconnectEdgeCommand } from './DiagramEditorEdgeCommands.js';
6
+ import { CompositeCommand } from './CommandHistory.js';
7
+ import { getEdgeMarker } from './edgeMarkers/index.js';
8
+ import { PopupMenu } from './PopupMenu.js';
9
+ import { TextStyle, TEXT_STYLE_KEYS } from './TextStyle.js';
10
+ import { FormDialog } from './FormDialog.js';
11
+ import { NoteTemplate } from './templates/NoteTemplate.js';
12
+ import { parseShapeText, placeShapes } from './textToShapes.js';
13
+ import { ViewFit } from './ViewFit.js';
14
+ import { HighlightRule } from './emphasis/HighlightRule.js';
15
+ import { DiagramClipboard } from './DiagramClipboard.js';
16
+
17
+ /** How far each paste without a place lands from the last one. */
18
+ const PASTE_STEP = 20;
19
+
20
+ /** The presses that pan the view: the right button, the middle one, or Shift+left. */
21
+ const PAN_BUTTON = (e) => e.button === 2 || e.button === 1 || (e.button === 0 && e.shiftKey);
22
+ /** How far a press moves before it is a pan (a right-click under it opens the menu). */
23
+ const PAN_THRESHOLD = 4;
24
+
25
+ /** The fields a shape's properties dialog has for the shape itself (its text style comes from TextStyle). */
26
+ const SHAPE_FIELD_KEYS = Object.freeze(['name', 'x', 'y', 'zIndex', 'fillColor', 'strokeColor', 'textColor']);
27
+
28
+ /** How a line with a warning (edgeWarning) is drawn: red and dashed, as the graph engine draws an invalid relationship. */
29
+ const WARNING_COLOR = '#e74c3c';
30
+ const WARNING_DASH = '4 3';
31
+ import { EMPHASIS_OPACITY, strongerEmphasis } from './emphasis/EmphasisRule.js';
32
+ import { downloadPng, downloadSvg, copyImage, prepareSvgMarkup } from '../exportImage.js';
33
+ import { el } from '../workspace/dom.js';
34
+ import { applyViewDecorations } from './viewDecorations.js';
35
+ import { ToolbarButton, ToolbarMenu, ToolbarText } from './toolbar/index.js';
36
+ import { Edge } from './Edge.js';
37
+ import { PropertiesPanel } from './PropertiesPanel.js';
38
+ import {
39
+ MoveElementCommand, BringToFrontCommand, SendToBackCommand, RaiseCommand, LowerCommand,
40
+ ResizeCommand, RotateCommand, SetPropertiesCommand, DeleteTemplateCommand, CreateTemplateCommand,
41
+ } from './DiagramEditorCommands.js';
42
+ import { Arrangement, GroupCommand, UngroupCommand, LockCommand, arrangeMenuItems, align, distribute, matchSize, ALIGNMENTS, DISTRIBUTIONS, SIZE_MATCHES, layout, nestLayout, LAYOUTS } from './arrangement/index.js';
43
+ import {
44
+ EDGE_STYLE_PROPERTIES, LINE_ONLY_PROPERTIES, LINE_STYLE_DASH, EdgeStyleCascade, SetStyleCommand, EdgeStyleDialog,
45
+ } from './edgeStyle/index.js';
46
+
47
+ /**
48
+ * DiagramEditor — base class for diagram editors (templates, ArchiMate, ...).
49
+ *
50
+ * Draws Templates and Edges into an SVG and provides the shared editing
51
+ * machinery: selection, drag/resize/rotate, waypoints, reconnect, line-style
52
+ * levels, stacking order, undo via `history`, keyboard shortcuts, zoom/pan,
53
+ * the palette and toolbar items (`menuBarItems()`).
54
+ *
55
+ * A subclass that draws through another engine (ArchimateDiagramEditor)
56
+ * overrides `render()`, `history` and `_canvasSvg()`, passes
57
+ * `enableCanvasInteractions: false`, and keeps its own shell (tree, tabs,
58
+ * toolbar container) to itself.
59
+ */
60
+ export class DiagramEditor {
61
+ /**
62
+ * @param {Object} options
63
+ * @param {string} [options.diagramContainerId] - Id of the SVG (or its container) to draw into
64
+ * @param {string} [options.templateType] - Palette category (DiagramTypeRegistry key)
65
+ * @param {Map} [options.templates] - Bind to these shapes (e.g. a DiagramView's)
66
+ * @param {Map} [options.edges] - Bind to these lines
67
+ * @param {Arrangement} [options.arrangement] - Groups and locks (e.g. a DiagramView's); a new one by default
68
+ * @param {CommandHistory} [options.history]
69
+ * @param {Array} [options.edgeStyleLevels] - Line-style levels, most specific first
70
+ * @param {Function} [options.onTemplateTypeChange]
71
+ * @param {boolean} [options.enableKeyboardShortcuts] - Undo/redo/delete keys (default true)
72
+ * @param {boolean} [options.enableCanvasInteractions] - Zoom, pan and canvas clicks (default true)
73
+ */
74
+ constructor(options = {}) {
75
+ const {
76
+ diagramContainerId = 'diagram',
77
+ templateType = 'generic',
78
+ enableKeyboardShortcuts = true,
79
+ enableCanvasInteractions = true,
80
+ } = options;
81
+
82
+ this.diagramContainerId = diagramContainerId;
83
+ this.templateType = templateType;
84
+ /** The model whose views this editor shows (a ViewSetModel), when there is one: it names the checks and the image. */
85
+ this.model = options.model ?? null;
86
+
87
+ // Interaction capabilities
88
+ this._dragState = null;
89
+ this._history = options.history ?? null;
90
+ /** Teardown for listeners that outlive the canvas (document-level). */
91
+ this._disposers = [];
92
+
93
+ // Template management
94
+ // Bound to the caller's maps when given (e.g. a DiagramView), so edits need no copying back.
95
+ this.templates = options.templates ?? new Map(); // id -> Template
96
+ this.templateCatalog = null; // Set by subclass or initPaletteForDiagramType()
97
+ this.palette = null; // Set by subclass or initPalette()
98
+ this.onTemplateTypeChange = options.onTemplateTypeChange ?? null;
99
+ /** Line style defaults a line inherits, most specific first: [{key, label, style}]. */
100
+ this.edgeStyleLevels = options.edgeStyleLevels ?? [{ key: 'tool', label: 'Tool', style: {} }];
101
+ /** The current view's resolved settings, from the host (SettingsManager#getEffectiveSettings); see viewSettings(). */
102
+ this._viewSettings = options.viewSettings ?? null;
103
+ this.propertiesPanel = null; // Set by subclass or initPropertiesPanel()
104
+ /** The primary selected shape (handles and the properties panel follow it). */
105
+ this.selectedTemplateId = null;
106
+ /** Every selected shape, the primary one included (Shift/Ctrl+click adds and removes). */
107
+ this.selectedIds = new Set();
108
+ /** Which shapes move as one (groups) and which are fixed in place (locks). */
109
+ this.arrangement = options.arrangement ?? new Arrangement();
110
+
111
+ // Edge management
112
+ this.edges = options.edges ?? new Map(); // id -> Edge
113
+ this.selectedEdgeId = null;
114
+ this.connectMode = null; // 'click' or 'drag' or null
115
+ this.connectSource = null; // template id of source for click-to-connect
116
+
117
+ // Grid settings
118
+ /** Off unless asked for (the `showGrid` option, or ⚙ Settings → Grid), as the settings' default says. */
119
+ this.showGrid = options.showGrid ?? false;
120
+ this.gridSize = 20;
121
+
122
+ // Zoom and pan
123
+ this.zoomLevel = 1.0;
124
+ this.panX = 0;
125
+ this.panY = 0;
126
+
127
+ if (typeof window !== 'undefined') {
128
+ if (enableKeyboardShortcuts) this._initializeKeyboardShortcuts();
129
+ // Off for subclasses whose drawing engine owns zoom, pan and canvas clicks.
130
+ if (enableCanvasInteractions) {
131
+ this._initializeZoomAndPan();
132
+ this._initializeCanvasSelection();
133
+ }
134
+ }
135
+ }
136
+
137
+ /**
138
+ * Undo history that commands and the undo/redo shortcuts use. Subclasses
139
+ * whose history lives elsewhere (e.g. one per view) override this accessor.
140
+ * @type {CommandHistory|null}
141
+ */
142
+ get history() {
143
+ return this._history;
144
+ }
145
+
146
+ set history(history) {
147
+ this._history = history;
148
+ }
149
+
150
+ // ===== INTERACTION CAPABILITIES =====
151
+ // Provided by base class, can be used or overridden by subclasses
152
+
153
+ /** addEventListener that dispose() undoes. @protected */
154
+ _listen(target, type, fn, options) {
155
+ target.addEventListener(type, fn, options);
156
+ this._disposers.push(() => target.removeEventListener(type, fn, options));
157
+ }
158
+
159
+ /**
160
+ * Detach document-level listeners. Call before discarding an editor whose
161
+ * page lives on (e.g. when a host rebuilds the canvas), or stale editors
162
+ * keep answering keyboard shortcuts.
163
+ */
164
+ /** Stop listening, and draw no more (a part that redraws makes a new editor). */
165
+ dispose() {
166
+ this.disposed = true;
167
+ for (const off of this._disposers.splice(0)) off();
168
+ }
169
+
170
+ /**
171
+ * Initialize keyboard shortcuts (Ctrl+Z for undo, Ctrl+Y for redo).
172
+ * Calls undo()/redo() hooks that subclass implements.
173
+ * @protected
174
+ */
175
+ _initializeKeyboardShortcuts() {
176
+ if (typeof document === 'undefined') return;
177
+ this._listen(document, 'keydown', (e) => {
178
+ // Keys typed into a dialog field belong to that field (Backspace edits text, not the diagram).
179
+ if (e.target?.closest?.('input, textarea, select, [contenteditable="true"]')) return;
180
+ // Only the drawing on screen: an editor in a hidden tab (another model) leaves keys alone.
181
+ if (!this.isShown()) return;
182
+ if (e.key === 'Escape' && this.formatPainter) this.setFormatPainter(false);
183
+ if ((e.ctrlKey || e.metaKey) && e.key === 'z' && !e.shiftKey) {
184
+ e.preventDefault();
185
+ this.undo();
186
+ }
187
+ if ((e.ctrlKey || e.metaKey) && (e.key === 'y' || (e.key === 'z' && e.shiftKey))) {
188
+ e.preventDefault();
189
+ this.redo();
190
+ }
191
+ // Ctrl+G groups the selection, Ctrl+Shift+G ungroups, Ctrl+L locks or frees it.
192
+ if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'g' && this.selectedTemplateId) {
193
+ e.preventDefault();
194
+ if (e.shiftKey) this.ungroup(this.selectedTemplateId);
195
+ else this.groupSelection();
196
+ }
197
+ if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'l' && this.selectedTemplateId) {
198
+ e.preventDefault();
199
+ const ids = this.getSelectedIds();
200
+ this.setLocked(ids, !ids.every(id => this.arrangement.isLocked(id)));
201
+ }
202
+ // Ctrl+C copies the selection, Ctrl+X cuts it, Ctrl+V pastes, Ctrl+D duplicates.
203
+ if ((e.ctrlKey || e.metaKey) && !e.shiftKey && !e.altKey) {
204
+ const act = { c: () => this.copySelection(), x: () => this.cutSelection(), v: () => this.paste(), d: () => this.duplicateSelection() }[e.key.toLowerCase()];
205
+ if (act) { e.preventDefault(); act(); }
206
+ }
207
+ // Ctrl+A selects every shape.
208
+ if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'a') {
209
+ e.preventDefault();
210
+ this.selectTemplates(this.selectableIds());
211
+ }
212
+ // The arrow keys move the selection a grid step (one pixel without a grid; ten times with Shift).
213
+ const ARROWS = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] };
214
+ if (ARROWS[e.key] && this.selectedTemplateId && !e.ctrlKey && !e.metaKey && !e.altKey) {
215
+ e.preventDefault();
216
+ const step = (this.showGrid && this.gridSize ? this.gridSize : 1) * (e.shiftKey ? 10 : 1);
217
+ this.nudgeSelection(ARROWS[e.key][0] * step, ARROWS[e.key][1] * step);
218
+ }
219
+ if (e.key === 'Delete' || e.key === 'Backspace') {
220
+ e.preventDefault();
221
+ // Delete the selected shapes, or the selected line, once confirmed
222
+ if (this.selectedTemplateId) {
223
+ this.deleteSelection();
224
+ } else if (this.selectedEdgeId) {
225
+ if (confirm('Delete this edge?')) {
226
+ this.deleteEdge(this.selectedEdgeId);
227
+ }
228
+ }
229
+ }
230
+ });
231
+ }
232
+
233
+ /**
234
+ * Initialize zoom and pan controls.
235
+ * @protected
236
+ */
237
+ _initializeZoomAndPan() {
238
+ if (typeof document === 'undefined') return;
239
+ const container = document.getElementById(this.diagramContainerId);
240
+ if (!container) return;
241
+
242
+ // Zoom with mouse wheel
243
+ container.addEventListener('wheel', (e) => {
244
+ if (e.ctrlKey || e.metaKey) {
245
+ e.preventDefault();
246
+ const delta = e.deltaY > 0 ? 0.9 : 1.1;
247
+ this.zoomLevel = Math.max(0.1, Math.min(5, this.zoomLevel * delta));
248
+ this.render();
249
+ }
250
+ });
251
+
252
+ // Pan with the right button (a right-click that does not move still opens the menu),
253
+ // the middle button, or Shift+left; a plain left drag on empty canvas selects (a box).
254
+ let isPanning = false, panMoved = false;
255
+ let panStartX = 0, panStartY = 0;
256
+ let panStartPanX = 0, panStartPanY = 0;
257
+
258
+ container.addEventListener('mousedown', (e) => {
259
+ if (PAN_BUTTON(e)) {
260
+ isPanning = true;
261
+ panMoved = false;
262
+ panStartX = e.clientX;
263
+ panStartY = e.clientY;
264
+ panStartPanX = this.panX;
265
+ panStartPanY = this.panY;
266
+ if (e.button !== 2) e.preventDefault();
267
+ }
268
+ });
269
+
270
+ this._listen(document, 'mousemove', (e) => {
271
+ if (isPanning) {
272
+ const dx = e.clientX - panStartX;
273
+ const dy = e.clientY - panStartY;
274
+ if (!panMoved && Math.hypot(dx, dy) < PAN_THRESHOLD) return;
275
+ panMoved = true;
276
+ this.panX = panStartPanX + dx;
277
+ this.panY = panStartPanY + dy;
278
+ this.render();
279
+ }
280
+ });
281
+
282
+ this._listen(document, 'mouseup', () => {
283
+ isPanning = false;
284
+ });
285
+ // A right-drag that panned is not a right-click: no menu (listened first, before any shape's own).
286
+ container.addEventListener('contextmenu', (e) => {
287
+ if (!panMoved) return;
288
+ panMoved = false;
289
+ e.preventDefault();
290
+ e.stopPropagation();
291
+ }, true);
292
+ }
293
+
294
+ /**
295
+ * Attach all interaction handlers to an element (click, drag, properties, ordering).
296
+ * Subclass calls this once per element during render.
297
+ * @param {string} elementId - Element identifier
298
+ * @param {SVGElement} svgElement - SVG element to attach handlers to
299
+ * @protected
300
+ */
301
+ attachElementHandlers(elementId, svgElement) {
302
+ if (!svgElement) return;
303
+ svgElement.addEventListener('click', (e) => {
304
+ e.stopPropagation();
305
+ if (this.formatPainter) { this.formatPainterClick(elementId); return; }
306
+ this.selectTemplate(elementId, { extend: e.shiftKey || e.ctrlKey || e.metaKey });
307
+ });
308
+ this.enableDragHandling(elementId, svgElement);
309
+ this.enableDoubleClickProperties(elementId, svgElement);
310
+ this.enableContextMenu(elementId, svgElement);
311
+ }
312
+
313
+ /**
314
+ * Set up drag handling on an SVG element.
315
+ * Calls onDragEnd(id, dx, dy) hook that subclass implements.
316
+ * @param {string} elementId - Element identifier
317
+ * @param {SVGElement} svgElement - SVG element to attach handler to
318
+ * @protected
319
+ */
320
+ enableDragHandling(elementId, svgElement) {
321
+ if (!svgElement) return;
322
+ svgElement.addEventListener('mousedown', (e) => this._handleDragStart(elementId, e));
323
+ }
324
+
325
+ /**
326
+ * Start drag operation.
327
+ * @protected
328
+ */
329
+ _handleDragStart(elementId, e) {
330
+ if (e.button !== 0) return; // the right button pans, and opens the menu
331
+ // A drag, not the browser's text selection (or a drag of selected text).
332
+ e.preventDefault();
333
+ if (this.formatPainter) return; // a click paints; nothing moves
334
+ // Everything that moves with this shape: the selection it is part of, with their groups.
335
+ const movers = this.moversFor(elementId);
336
+ if (!movers.length) return; // something among them is locked
337
+ const dragStart = { x: e.clientX, y: e.clientY };
338
+ let dragAccum = { x: 0, y: 0 };
339
+
340
+ const onDrag = (e) => {
341
+ const dx = e.clientX - dragStart.x;
342
+ const dy = e.clientY - dragStart.y;
343
+ dragAccum.x += dx;
344
+ dragAccum.y += dy;
345
+ dragStart.x = e.clientX;
346
+ dragStart.y = e.clientY;
347
+ this.onDragMove(elementId, dx, dy); // Pass delta for this frame, not accumulated total
348
+ };
349
+
350
+ const endDrag = () => {
351
+ if (dragAccum.x !== 0 || dragAccum.y !== 0) {
352
+ this.onDragEnd(elementId, dragAccum.x, dragAccum.y);
353
+ }
354
+ document.removeEventListener('mousemove', onDrag);
355
+ document.removeEventListener('mouseup', endDrag);
356
+ this._dragState = null;
357
+ };
358
+
359
+ this._dragState = { elementId, startX: dragStart.x, startY: dragStart.y, movers };
360
+ document.addEventListener('mousemove', onDrag);
361
+ document.addEventListener('mouseup', endDrag);
362
+ }
363
+
364
+ /**
365
+ * Enable double-click to show properties dialog.
366
+ * Calls showPropertiesDialog(id) hook that subclass implements.
367
+ * @param {string} elementId - Element identifier
368
+ * @param {SVGElement} svgElement - SVG element to attach handler to
369
+ * @protected
370
+ */
371
+ enableDoubleClickProperties(elementId, svgElement) {
372
+ if (!svgElement) return;
373
+ svgElement.addEventListener('dblclick', (e) => {
374
+ e.stopPropagation();
375
+ this.showPropertiesDialog(elementId);
376
+ });
377
+ }
378
+
379
+ /**
380
+ * Enable right-click context menu with ordering options.
381
+ * Calls executeOrderCommand(id, operation) hook that subclass implements.
382
+ * @param {string} elementId - Element identifier
383
+ * @param {SVGElement} svgElement - SVG element to attach handler to
384
+ * @protected
385
+ */
386
+ enableContextMenu(elementId, svgElement) {
387
+ if (!svgElement) return;
388
+ svgElement.addEventListener('contextmenu', (e) => {
389
+ e.stopPropagation();
390
+ e.preventDefault();
391
+ this._showOrderingMenu(elementId, e.clientX, e.clientY);
392
+ });
393
+ }
394
+
395
+ /**
396
+ * Show context menu with ordering options.
397
+ * @protected
398
+ */
399
+ _showOrderingMenu(elementId, x, y) {
400
+ const template = this.getTemplate(elementId);
401
+ if (!template) return;
402
+ const many = this.selectedIds.has(elementId) && this.selectedIds.size > 1;
403
+ this._showMenu(x, y, [
404
+ many
405
+ ? { label: `⚙ Properties of ${this.selectedIds.size} shapes`, action: () => this.editSelectionProperties() }
406
+ : { label: '⚙ Properties', action: () => this.editTemplateProperties(template) },
407
+ { separator: true },
408
+ ...this.clipboardMenuItems(elementId),
409
+ ...this.formatMenuItems(elementId),
410
+ ...this.nestMenuItems(elementId),
411
+ { label: 'Fit to text', action: () => this.fitToText(this.selectedIds.has(elementId) ? this.getSelectedIds() : [elementId]) },
412
+ { separator: true },
413
+ this.orderMenuItem(elementId),
414
+ ...this.arrangeMenuItems(elementId),
415
+ ...this.alignMenuItems(),
416
+ ...(() => { const extra = this.shapeMenuExtras(elementId); return extra.length ? [{ separator: true }, ...extra] : []; })(),
417
+ { separator: true },
418
+ { label: '🗑 Delete', danger: true, action: () => { if (confirm(`Delete "${template.name}"?`)) this.deleteTemplate(elementId); } },
419
+ ]);
420
+ }
421
+
422
+ /** More items for a shape's menu (a subclass's: save as template, ...). */
423
+ shapeMenuExtras(_elementId) {
424
+ return [];
425
+ }
426
+
427
+ /**
428
+ * The menu for empty canvas at `at` (drawing coordinates); none here, a
429
+ * subclass offers its own (place a template, ...).
430
+ */
431
+ canvasMenuItems(at) {
432
+ return [{ label: 'Add from text…', action: () => this.askAddFromText(at) }];
433
+ }
434
+
435
+ /**
436
+ * Close context menu.
437
+ * @protected
438
+ */
439
+ _closeContextMenu() {
440
+ PopupMenu.close();
441
+ }
442
+
443
+ /**
444
+ * Show context menu for edges (delete and properties).
445
+ * @private
446
+ */
447
+ _showEdgeContextMenu(edgeId, x, y) {
448
+ const edge = this.getEdge(edgeId);
449
+ if (!edge) return;
450
+ const at = this._clientToDiagram(x, y);
451
+ const bends = edge.waypoints.length;
452
+ this._showMenu(x, y, [
453
+ { label: '⚙ Properties', action: () => this._showEdgeProperties(edge) },
454
+ { separator: true },
455
+ ...(at ? [{ label: 'Add waypoint here', action: () => this.addWaypoint(edgeId, at) }] : []),
456
+ ...(bends ? [{ label: `Straighten (remove ${bends} waypoint${bends === 1 ? '' : 's'})`, action: () => this.clearWaypoints(edgeId) }] : []),
457
+ { separator: true },
458
+ { label: '🗑 Delete', danger: true, action: () => { if (confirm('Delete this edge?')) this.deleteEdge(edgeId); } },
459
+ ]);
460
+ }
461
+
462
+ /** Items whose stacking order the ordering commands manage (see zOrder.js). */
463
+ layerItems() {
464
+ return this.getAllTemplates();
465
+ }
466
+
467
+ /**
468
+ * Change a template's place in the stacking order as one undo step.
469
+ * @param {string} elementId
470
+ * @param {'front'|'back'|'raise'|'lower'} operation
471
+ */
472
+ /**
473
+ * The "Order" submenu for a shape: bring to front, send to back, raise,
474
+ * lower, each through executeOrderCommand. Every editor's shape menu uses
475
+ * it, so ordering reads and works the same everywhere.
476
+ */
477
+ orderMenuItem(elementId) {
478
+ return {
479
+ label: DiagramEditor.ORDER_MENU_LABEL,
480
+ items: DiagramEditor.ORDER_OPERATIONS.map(([operation, label]) => ({
481
+ label, action: () => this.executeOrderCommand(elementId, operation),
482
+ })),
483
+ };
484
+ }
485
+
486
+ /**
487
+ * Group, ungroup, lock and unlock for a shape's menu. They act on the
488
+ * selection when the shape is part of it, else on the shape (and an item in
489
+ * a group locks or frees its whole group).
490
+ */
491
+ arrangeMenuItems(elementId) {
492
+ const ids = this.selectedIds.has(elementId) ? [elementId, ...this.getSelectedIds().filter(id => id !== elementId)] : [elementId];
493
+ return arrangeMenuItems(this.arrangement, ids, {
494
+ group: (items) => this.groupSelection(items),
495
+ ungroup: (id) => this.ungroup(id),
496
+ setLocked: (items, locked) => this.setLocked(items, locked),
497
+ });
498
+ }
499
+
500
+ /**
501
+ * Whether a shape is fixed: locked itself or in a locked group (a template's
502
+ * background, say). A fixed shape is not moved, resized, reordered, edited or
503
+ * deleted until it is unlocked.
504
+ */
505
+ isFixed(id) {
506
+ return this.arrangement.isLocked(id);
507
+ }
508
+
509
+ /** Say that something is locked and how to change it. */
510
+ tellLocked(name = 'This', id = this.selectedTemplateId) {
511
+ const group = this.arrangement.groupOf(id ?? '');
512
+ new FormDialog({
513
+ title: 'Locked',
514
+ description: `${name} is locked${group?.pinnedToBack ? ' (part of the background)' : ''}. Unlock it (right-click, Unlock) to change it.`,
515
+ fields: [],
516
+ submitLabel: 'OK',
517
+ document: this.document ?? document,
518
+ onApply: () => {},
519
+ }).open();
520
+ }
521
+
522
+ /** Group the selection (or the given shapes) so they move as one. One undo step. */
523
+ groupSelection(ids = this.getSelectedIds()) {
524
+ if (this.arrangement.movers(ids).size < 2) return null;
525
+ const cmd = new GroupCommand(this.arrangement, ids);
526
+ this._executeCommand(cmd);
527
+ return cmd.groupId;
528
+ }
529
+
530
+ /** Dissolve the group a shape is in. One undo step. */
531
+ ungroup(id) {
532
+ if (!this.arrangement.groupOf(id)) return;
533
+ this._executeCommand(new UngroupCommand(this.arrangement, id));
534
+ }
535
+
536
+ /** Fix shapes (or their groups) in place, or free them. One undo step. */
537
+ setLocked(ids, locked) {
538
+ this._executeCommand(new LockCommand(this.arrangement, ids, locked));
539
+ }
540
+
541
+ /**
542
+ * The shapes that move when this one is dragged: the selection it is part of
543
+ * (or just it), with their groups and what each contains, as shapes on this
544
+ * diagram. Empty when any of them is locked.
545
+ */
546
+ moversFor(elementId) {
547
+ const ids = this.selectedIds.has(elementId) ? this.getSelectedIds() : [elementId];
548
+ if (!this.arrangement.canMove(ids)) return [];
549
+ const movers = new Set(this.arrangement.movers(ids));
550
+ // What a container holds goes with it, however deep.
551
+ const queue = [...movers];
552
+ while (queue.length) {
553
+ for (const inner of this.containedIn(queue.shift())) {
554
+ if (!movers.has(inner)) { movers.add(inner); queue.push(inner); }
555
+ }
556
+ }
557
+ return [...movers].filter(id => this.getTemplate(id));
558
+ }
559
+
560
+ /**
561
+ * The shapes drawn inside this one, which move with it (one level; their
562
+ * own contents follow). None here; a subclass with containers (a group, a
563
+ * canvas block) says what each holds.
564
+ * @returns {Array<string>}
565
+ */
566
+ containedIn(templateId) {
567
+ return [];
568
+ }
569
+
570
+ executeOrderCommand(elementId, operation) {
571
+ const CommandClass = {
572
+ front: BringToFrontCommand,
573
+ back: SendToBackCommand,
574
+ raise: RaiseCommand,
575
+ lower: LowerCommand,
576
+ }[operation];
577
+ const template = this.getTemplate(elementId);
578
+ if (!this.history || !CommandClass || !template) return;
579
+ const cmd = new CommandClass(this, elementId);
580
+ this.history.execute(cmd);
581
+ this.render();
582
+ this._log(`${template.name} - ${cmd.label} (Z:${template.zIndex})`);
583
+ }
584
+
585
+ /**
586
+ * Default undo implementation. Uses this.history.
587
+ */
588
+ undo() {
589
+ if (this.history && this.history.undo()) {
590
+ this.render();
591
+ this._log('Undo');
592
+ return true;
593
+ }
594
+ return false;
595
+ }
596
+
597
+ /**
598
+ * Default redo implementation. Uses this.history.
599
+ */
600
+ redo() {
601
+ if (this.history && this.history.redo()) {
602
+ this.render();
603
+ this._log('Redo');
604
+ return true;
605
+ }
606
+ return false;
607
+ }
608
+
609
+ /**
610
+ * Log a message to the UI log element (if it exists).
611
+ * @param {string} msg - Message to log
612
+ * @protected
613
+ */
614
+ _log(msg) {
615
+ if (typeof document === 'undefined') return;
616
+ const log = document.getElementById('log');
617
+ if (!log) return;
618
+ const line = document.createElement('div');
619
+ line.textContent = msg;
620
+ log.appendChild(line);
621
+ if (log.children.length > 5) log.removeChild(log.firstChild);
622
+ }
623
+
624
+ /**
625
+ * Render all templates. Subclass can override to add custom logic.
626
+ * Base implementation calls render() on each template in z-order.
627
+ * @protected
628
+ */
629
+ render() {
630
+ if (typeof document === 'undefined' || this.disposed) return;
631
+ const container = document.getElementById(this.diagramContainerId);
632
+ if (!container) return;
633
+
634
+ // Grid in its own group, outside the zoom/pan transform; removed when turned off.
635
+ if (this.showGrid && container.tagName === 'svg') {
636
+ let gridGroup = container.querySelector('#grid-layer');
637
+ if (!gridGroup) {
638
+ gridGroup = document.createElementNS('http://www.w3.org/2000/svg', 'g');
639
+ gridGroup.id = 'grid-layer';
640
+ container.insertBefore(gridGroup, container.firstChild);
641
+ }
642
+ gridGroup.innerHTML = '';
643
+ this._renderGrid(container, gridGroup);
644
+ } else {
645
+ container.querySelector('#grid-layer')?.remove();
646
+ }
647
+
648
+ const group = container.querySelector('#elements');
649
+ if (!group) return;
650
+ group.innerHTML = '';
651
+
652
+ // Apply zoom and pan transform only to elements, not grid
653
+ group.setAttribute('transform', `translate(${this.panX}, ${this.panY}) scale(${this.zoomLevel})`);
654
+
655
+ // Sort templates by z-index and render each
656
+ const sorted = Array.from(this.templates.values())
657
+ .sort((a, b) => (a.zIndex || 0) - (b.zIndex || 0));
658
+
659
+ for (const template of sorted) {
660
+ if (template.visible === false) continue;
661
+ const emphasis = this.templateEmphasis(template);
662
+ if (emphasis === 'hidden') continue;
663
+ template.render({ group: this._emphasisGroup(group, emphasis, { 'data-template-id': template.id }), editor: this });
664
+ }
665
+
666
+ // Render edges
667
+ this._renderEdges(group);
668
+
669
+ // The selection's groups and other selected shapes, then the primary shape's handles.
670
+ this._renderArrangement(group);
671
+ const selected = this.getSelectedTemplate();
672
+ if (selected) {
673
+ this._renderHandles(container, group, selected);
674
+ }
675
+ // The view's background, title band and watermark, on the element that holds the drawing.
676
+ const host = container.parentElement;
677
+ if (host && container.tagName === 'svg') this.decorateView(host, this.viewSettings());
678
+ this._notifyRendered();
679
+ }
680
+
681
+ /**
682
+ * Call `fn` after every render (subclasses' renders included), e.g. so a
683
+ * MenuBar can refresh the zoom label. Returns an unsubscribe function.
684
+ */
685
+ onRendered(fn) {
686
+ (this._renderedListeners ??= []).push(fn);
687
+ return () => { this._renderedListeners = this._renderedListeners.filter(l => l !== fn); };
688
+ }
689
+
690
+ /**
691
+ * Call `fn({ templateId, edgeId })` whenever the selection changes: a shape
692
+ * or line selected, or nothing (both null). Returns an unsubscribe function.
693
+ */
694
+ onSelectionChange(fn) {
695
+ (this._selectionListeners ??= []).push(fn);
696
+ return () => { this._selectionListeners = this._selectionListeners.filter(l => l !== fn); };
697
+ }
698
+
699
+ /** @protected */
700
+ _notifySelection() {
701
+ const state = { templateId: this.selectedTemplateId ?? null, edgeId: this.selectedEdgeId ?? null };
702
+ for (const fn of this._selectionListeners ?? []) fn(state);
703
+ }
704
+
705
+ /** @protected */
706
+ _notifyRendered() {
707
+ for (const fn of this._renderedListeners ?? []) fn(this);
708
+ }
709
+
710
+ /**
711
+ * Toolbar items this editor contributes to a MenuBar: zoom, connect mode and
712
+ * a Settings menu (grid, line-style levels). Subclasses override or extend.
713
+ * @returns {Array<ToolbarItem>}
714
+ */
715
+ menuBarItems() {
716
+ const zoomBy = (factor) => {
717
+ this.zoomLevel = Math.min(5, Math.max(0.1, this.zoomLevel * factor));
718
+ this.render();
719
+ };
720
+ return [
721
+ new ToolbarButton('−', () => zoomBy(0.9), { title: 'Zoom out' }),
722
+ new ToolbarText(() => `${Math.round(this.zoomLevel * 100)}%`, { minWidth: '40px', textAlign: 'center' }),
723
+ new ToolbarButton('+', () => zoomBy(1.1), { title: 'Zoom in' }),
724
+ new ToolbarButton('🔗', () => { this.connectMode = !this.connectMode; }, {
725
+ title: 'Connect mode: drag between shapes to draw a line', active: () => !!this.connectMode,
726
+ }),
727
+ new ToolbarButton('🖌', () => this.setFormatPainter(!this.formatPainter), {
728
+ title: 'Format painter: click a shape to pick up its format, then click shapes to give it to them (Esc to stop)', active: () => !!this.formatPainter,
729
+ }),
730
+ new ToolbarMenu('Arrange', () => this.alignMenuItems({ always: true })),
731
+ new ToolbarMenu('View', () => this.viewMenuItems()),
732
+ new ToolbarMenu('⚙ Settings', () => this.settingsMenuItems()),
733
+ ];
734
+ }
735
+
736
+ /** Ask for the text to highlight shapes by (FormDialog), then highlight them. */
737
+ askHighlight() {
738
+ new FormDialog({
739
+ title: 'Highlight',
740
+ description: 'Shapes whose name contains this text stay as they are; the others are dimmed. Leave it empty to clear.',
741
+ fields: [{ key: 'text', label: 'Name contains', type: 'text', value: this.highlight?.text ?? '' }],
742
+ submitLabel: 'Highlight',
743
+ document: this.document ?? globalThis.document,
744
+ onApply: ({ text }) => this.setHighlight(text),
745
+ }).open();
746
+ }
747
+
748
+ /**
749
+ * The View menu: zoom to fit, reset zoom, Highlight… and, when the editor
750
+ * has a model, Check model…. A subclass adds its own.
751
+ */
752
+ viewMenuItems() {
753
+ return [
754
+ { label: 'Zoom to fit', action: () => this.zoomToFit() },
755
+ { label: 'Reset zoom', action: () => this.resetZoom() },
756
+ { separator: true },
757
+ { label: 'Highlight…', action: () => this.askHighlight() },
758
+ ...(this.highlight ? [{ label: `Clear highlight ("${this.highlight.text}")`, action: () => this.setHighlight('') }] : []),
759
+ ...(this.model?.checker ? [{ separator: true }, { label: 'Check model…', action: () => this.showModelCheck() }] : []),
760
+ { separator: true },
761
+ { label: 'Colour by…', action: () => this.askColourBy() },
762
+ { label: 'Add legend of the kinds shown', action: () => this.addKindLegend() },
763
+ ];
764
+ }
765
+
766
+ /** Export the current view as a picture: PNG, SVG, or a copy to the clipboard. */
767
+ imageMenuItems() {
768
+ return [
769
+ { label: 'Export image (PNG)', action: () => this.exportImage('png') },
770
+ { label: 'Export image (SVG)', action: () => this.exportImage('svg') },
771
+ { label: 'Copy image', action: () => this.exportImage('copy') },
772
+ ];
773
+ }
774
+
775
+ /** Whether the drawing is on the page and not hidden (in a tab not selected, say). */
776
+ isShown() {
777
+ const host = this.drawingHost();
778
+ if (!host) return false;
779
+ return typeof host.getClientRects === 'function' ? host.getClientRects().length > 0 : true;
780
+ }
781
+
782
+ /** The element the drawing is in (its <svg>, or one holding it). */
783
+ drawingHost() {
784
+ const doc = this.document ?? (typeof document !== 'undefined' ? document : null);
785
+ return doc?.getElementById(this.diagramContainerId) ?? null;
786
+ }
787
+
788
+ /** What an exported picture is called: the current view's name. */
789
+ imageName() {
790
+ const view = this.model?.currentView;
791
+ return view?.title || view?.name || 'diagram';
792
+ }
793
+
794
+ /**
795
+ * The current view as a picture: 'png' or 'svg' downloads it, 'copy' puts a
796
+ * PNG on the clipboard (or downloads it where the browser cannot). The grid,
797
+ * the selection and its handles are left out.
798
+ * @param {'png'|'svg'|'copy'} format
799
+ */
800
+ exportImage(format = 'png') {
801
+ const host = this.drawingHost();
802
+ if (!host) return null;
803
+ const name = this.imageName();
804
+ return this._withPlainDrawing(() => {
805
+ if (format === 'svg') return downloadSvg(host, { name });
806
+ if (format === 'copy') return copyImage(host, { name });
807
+ return downloadPng(host, { name });
808
+ });
809
+ }
810
+
811
+ /**
812
+ * Run `fn` with the drawing shown without grid or selection, then put them
813
+ * back. The image functions read the drawing before they first wait.
814
+ * @protected
815
+ */
816
+ _withPlainDrawing(fn) {
817
+ const saved = { showGrid: this.showGrid, selectedTemplateId: this.selectedTemplateId, selectedIds: new Set(this.selectedIds), selectedEdgeId: this.selectedEdgeId };
818
+ Object.assign(this, { showGrid: false, selectedTemplateId: null, selectedIds: new Set(), selectedEdgeId: null });
819
+ this.render();
820
+ try {
821
+ return fn();
822
+ } finally {
823
+ Object.assign(this, saved);
824
+ this.render();
825
+ }
826
+ }
827
+
828
+ /**
829
+ * The current view as a picture for a report (ModelReport): the drawing as
830
+ * standalone SVG, cropped to what is drawn (with `margin`), without grid or
831
+ * selection, and a box per shape that has a page of its own (`reportRef`).
832
+ * Null when there is no drawing on the page, or nothing drawn.
833
+ * @returns {{svg: string, viewBox: {x, y, width, height}, areas: Array<{ref, x, y, width, height}>}|null}
834
+ */
835
+ viewPicture({ margin = 20, background = '#ffffff' } = {}) {
836
+ const host = this.drawingHost();
837
+ const bounds = this.contentBounds();
838
+ if (host?.tagName?.toLowerCase() !== 'svg' || !bounds) return null;
839
+ const viewBox = { x: bounds.x - margin, y: bounds.y - margin, width: bounds.width + 2 * margin, height: bounds.height + 2 * margin };
840
+ return this._withPlainDrawing(() => {
841
+ const copy = host.cloneNode(true);
842
+ copy.querySelector('#grid-layer')?.remove();
843
+ copy.querySelector('#elements')?.removeAttribute('transform');
844
+ copy.removeAttribute('style');
845
+ copy.removeAttribute('id');
846
+ copy.setAttribute('viewBox', `${viewBox.x} ${viewBox.y} ${viewBox.width} ${viewBox.height}`);
847
+ const svg = prepareSvgMarkup(new (this.document?.defaultView ?? globalThis).XMLSerializer().serializeToString(copy), { width: viewBox.width, height: viewBox.height, background });
848
+ const areas = [...this.templates.values()]
849
+ .filter(t => t.visible !== false && this.templateEmphasis(t) !== 'hidden')
850
+ .sort((a, b) => (a.zIndex || 0) - (b.zIndex || 0))
851
+ .map(t => ({ ref: this.reportRef(t), x: t.left, y: t.top, width: t.right - t.left, height: t.bottom - t.top }))
852
+ .filter(area => area.ref);
853
+ return { svg, viewBox, areas };
854
+ });
855
+ }
856
+
857
+ /** What a shape's page in a report is (`{kind, id}`, as ModelReport#key takes them), or null: none here. */
858
+ reportRef(template) {
859
+ return null;
860
+ }
861
+
862
+ /** The drawing's extent in drawing coordinates (ViewFit.union of the visible shapes), or null. */
863
+ contentBounds() {
864
+ return ViewFit.union([...this.templates.values()].filter(t => t.visible !== false)
865
+ .map(t => ({ left: t.left, top: t.top, right: t.right, bottom: t.bottom })));
866
+ }
867
+
868
+ /**
869
+ * What the drawing shows, in its own units (an <svg> with a viewBox is not
870
+ * in pixels): the screen box mapped through the SVG's screen transform.
871
+ * @returns {{x, y, width, height}|null}
872
+ */
873
+ /** The middle of what is in sight, in diagram coordinates (where a new item goes). */
874
+ visibleCentre() {
875
+ const rect = this.drawingHost()?.getBoundingClientRect?.();
876
+ return (rect?.width && this._clientToDiagram(rect.left + rect.width / 2, rect.top + rect.height / 2)) || { x: 300, y: 200 };
877
+ }
878
+
879
+ visibleArea() {
880
+ const host = this.drawingHost();
881
+ const rect = host?.getBoundingClientRect?.();
882
+ if (!rect?.width) return null;
883
+ const ctm = host.getScreenCTM?.();
884
+ if (!ctm || !host.createSVGPoint) return { x: 0, y: 0, width: rect.width, height: rect.height };
885
+ const inverse = ctm.inverse();
886
+ const at = (x, y) => Object.assign(host.createSVGPoint(), { x, y }).matrixTransform(inverse);
887
+ const a = at(rect.left, rect.top), b = at(rect.right, rect.bottom);
888
+ return { x: a.x, y: a.y, width: b.x - a.x, height: b.y - a.y };
889
+ }
890
+
891
+ /** Zoom and pan so everything drawn is in sight (ViewFit.transform). @returns {boolean} whether it zoomed */
892
+ zoomToFit({ margin = 20 } = {}) {
893
+ const bounds = this.contentBounds();
894
+ const area = this.visibleArea();
895
+ if (!bounds || !area) return false;
896
+ const { scale, panX, panY } = ViewFit.transform(bounds, area, margin);
897
+ Object.assign(this, { zoomLevel: scale, panX, panY });
898
+ this.render();
899
+ return true;
900
+ }
901
+
902
+ /** Back to 100%, unpanned. */
903
+ resetZoom() {
904
+ Object.assign(this, { zoomLevel: 1, panX: 0, panY: 0 });
905
+ this.render();
906
+ }
907
+
908
+ /**
909
+ * Check the model (its `checker()`: a ViewSetChecker, or a notation's
910
+ * subclass) and list the findings; choosing one shows it (revealFinding).
911
+ * @returns {Array<Object>} the findings
912
+ */
913
+ showModelCheck() {
914
+ const checker = this.model?.checker?.(this.checkerOptions());
915
+ if (!checker) return [];
916
+ const findings = checker.run();
917
+ let dialog = null;
918
+ const list = el({
919
+ style: { gridColumn: '1 / -1', display: 'flex', flexDirection: 'column', gap: '4px', minWidth: '420px' },
920
+ children: findings.length ? findings.map(finding => ({
921
+ style: { display: 'flex', gap: '4px', alignItems: 'stretch' },
922
+ children: [
923
+ {
924
+ tag: 'button',
925
+ attrs: { type: 'button', title: 'Show it', 'data-code': finding.code },
926
+ style: { flex: '1', textAlign: 'left', padding: '4px 6px', border: '1px solid #ddd', borderRadius: '3px', background: finding.severity === 'warning' ? '#fff6e5' : '#f5f8fc', cursor: 'pointer' },
927
+ text: `${finding.severity === 'warning' ? '⚠' : 'ℹ'} ${finding.message}`,
928
+ on: { click: () => { dialog?.close(); this.revealFinding(finding); } },
929
+ },
930
+ ...this.findingActions(finding).map(act => ({
931
+ tag: 'button', attrs: { type: 'button', 'data-action': act.label }, text: act.label,
932
+ style: { padding: '4px 8px', cursor: 'pointer' },
933
+ on: { click: () => { dialog?.close(); act.action(); } },
934
+ })),
935
+ ],
936
+ })) : [{ text: 'Nothing found: the model passes every check.' }],
937
+ }, this.document);
938
+ dialog = new FormDialog({
939
+ title: `Model check: ${findings.length} finding${findings.length === 1 ? '' : 's'}`,
940
+ fields: [{ key: 'findings', label: '', type: 'custom', element: list, read: () => null }],
941
+ submitLabel: 'Close',
942
+ document: this.document ?? document,
943
+ onApply: () => {},
944
+ }).open();
945
+ return findings;
946
+ }
947
+
948
+ /** What can be done about a finding, beside showing it (`[{label, action}]`): nothing here; a subclass offers its own (Merge…). */
949
+ findingActions(finding) {
950
+ return [];
951
+ }
952
+
953
+ /** Options for the model's checker (a subclass passes its notation version, say). */
954
+ checkerOptions() {
955
+ return {};
956
+ }
957
+
958
+ /** Show what a finding names: its view, or the shape on this view. */
959
+ revealFinding(finding) {
960
+ const [id] = finding.ids;
961
+ if (finding.kind === 'view') this.model?.switchTo?.(id);
962
+ else if (this.templates.has(id)) { this.selectTemplate(id); this.render(); }
963
+ }
964
+
965
+ /**
966
+ * What the ⚙ Settings menu offers: grid, grid size and the line-style
967
+ * levels. A subclass adds its own settings in front.
968
+ */
969
+ settingsMenuItems() {
970
+ return [
971
+ { label: `Grid: ${this.showGrid ? 'on' : 'off'}`, action: () => { this.showGrid = !this.showGrid; this.render(); } },
972
+ { label: `Grid size: ${this.gridSize}px…`, action: () => this.editGridSize() },
973
+ ...(this.edgeStyleLevels.length ? [{ separator: true }] : []),
974
+ ...this.edgeStyleLevels.map(level => ({
975
+ label: `Line style: ${level.label} defaults…`,
976
+ action: () => this.editLineStyle(level.key),
977
+ })),
978
+ ];
979
+ }
980
+
981
+ /** Ask for a new grid spacing. */
982
+ editGridSize() {
983
+ new FormDialog({
984
+ title: 'Grid size',
985
+ fields: [{ key: 'gridSize', label: 'Spacing (px)', type: 'number', value: this.gridSize, min: 5, max: 200, step: 5 }],
986
+ onApply: ({ gridSize }) => {
987
+ if (Number.isFinite(gridSize) && gridSize >= 1) {
988
+ this.gridSize = gridSize;
989
+ this.render();
990
+ }
991
+ },
992
+ }).open();
993
+ }
994
+
995
+ /**
996
+ * Render grid on SVG canvas.
997
+ * Grid fills the entire visible area based on container size and zoom/pan.
998
+ * @private
999
+ */
1000
+ _renderGrid(svg, group) {
1001
+ const gridSize = this.gridSize || 20;
1002
+
1003
+ // Draw grid covering the entire viewBox plus extra for panning
1004
+ // Use generous bounds to ensure complete coverage at any zoom/pan
1005
+ const minX = -1000;
1006
+ const maxX = 2000;
1007
+ const minY = -1000;
1008
+ const maxY = 1500;
1009
+
1010
+ // Draw vertical lines
1011
+ for (let x = Math.floor(minX / gridSize) * gridSize; x <= maxX; x += gridSize) {
1012
+ const line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
1013
+ line.setAttribute('x1', x);
1014
+ line.setAttribute('y1', minY);
1015
+ line.setAttribute('x2', x);
1016
+ line.setAttribute('y2', maxY);
1017
+ line.setAttribute('stroke', '#e0e0e0');
1018
+ line.setAttribute('stroke-width', 0.5 / this.zoomLevel);
1019
+ line.setAttribute('pointer-events', 'none');
1020
+ group.appendChild(line);
1021
+ }
1022
+
1023
+ // Draw horizontal lines
1024
+ for (let y = Math.floor(minY / gridSize) * gridSize; y <= maxY; y += gridSize) {
1025
+ const line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
1026
+ line.setAttribute('x1', minX);
1027
+ line.setAttribute('y1', y);
1028
+ line.setAttribute('x2', maxX);
1029
+ line.setAttribute('y2', y);
1030
+ line.setAttribute('stroke', '#e0e0e0');
1031
+ line.setAttribute('stroke-width', 0.5 / this.zoomLevel);
1032
+ line.setAttribute('pointer-events', 'none');
1033
+ group.appendChild(line);
1034
+ }
1035
+ }
1036
+
1037
+ /**
1038
+ * Render resize/rotate handles around selected template.
1039
+ * @private
1040
+ */
1041
+ /**
1042
+ * A dashed outline round each group a selected shape is in, an outline on
1043
+ * each selected shape besides the primary one, and a lock on each locked
1044
+ * selected shape.
1045
+ * @protected
1046
+ */
1047
+ _renderArrangement(group) {
1048
+ const ids = this.getSelectedIds();
1049
+ if (!ids.length) return;
1050
+ const layer = this._svg('g', { class: 'arrangement', 'pointer-events': 'none' });
1051
+ const box = (t) => {
1052
+ const w = t.width || 120, h = t.height || 60;
1053
+ return { x1: (t.x || 0) - w / 2, y1: (t.y || 0) - h / 2, x2: (t.x || 0) + w / 2, y2: (t.y || 0) + h / 2 };
1054
+ };
1055
+ const drawn = new Set();
1056
+ for (const id of ids) {
1057
+ const g = this.arrangement.groupOf(id);
1058
+ if (!g || drawn.has(g.id)) continue;
1059
+ drawn.add(g.id);
1060
+ const boxes = [...g.memberIds].map(m => this.getTemplate(m)).filter(Boolean).map(box);
1061
+ if (!boxes.length) continue;
1062
+ const pad = 8;
1063
+ const x1 = Math.min(...boxes.map(b => b.x1)) - pad, y1 = Math.min(...boxes.map(b => b.y1)) - pad;
1064
+ const x2 = Math.max(...boxes.map(b => b.x2)) + pad, y2 = Math.max(...boxes.map(b => b.y2)) + pad;
1065
+ layer.appendChild(this._svg('rect', {
1066
+ class: 'group-outline', 'data-group-id': g.id, x: x1, y: y1, width: x2 - x1, height: y2 - y1,
1067
+ fill: 'none', stroke: '#8e44ad', 'stroke-width': 1.5, 'stroke-dasharray': '8,4', rx: 4,
1068
+ }));
1069
+ }
1070
+ for (const id of ids) {
1071
+ const t = this.getTemplate(id);
1072
+ if (!t) continue;
1073
+ const b = box(t);
1074
+ if (id !== this.selectedTemplateId) {
1075
+ layer.appendChild(this._svg('rect', {
1076
+ class: 'selection-outline', x: b.x1, y: b.y1, width: b.x2 - b.x1, height: b.y2 - b.y1,
1077
+ fill: 'none', stroke: '#0066cc', 'stroke-width': 1.5, 'stroke-dasharray': '4,3',
1078
+ }));
1079
+ }
1080
+ if (this.arrangement.isLocked(id)) {
1081
+ const lock = this._svg('text', { class: 'lock-mark', x: b.x1 - 2, y: b.y1 - 4, 'font-size': 12 });
1082
+ lock.textContent = '🔒';
1083
+ layer.appendChild(lock);
1084
+ }
1085
+ }
1086
+ group.appendChild(layer);
1087
+ }
1088
+
1089
+ _renderHandles(container, group, template) {
1090
+ // A locked shape is fixed: no resize or rotate handles (its outline and lock show it is selected).
1091
+ const fixed = this.arrangement.isLocked(template.id);
1092
+ const x = template.x || 0;
1093
+ const y = template.y || 0;
1094
+ const w = template.width || 120;
1095
+ const h = template.height || 60;
1096
+ const rotation = template.rotation || 0;
1097
+
1098
+ // Create handles group
1099
+ const handleGroup = document.createElementNS('http://www.w3.org/2000/svg', 'g');
1100
+ handleGroup.setAttribute('class', 'handles');
1101
+ handleGroup.setAttribute('pointer-events', 'none');
1102
+
1103
+ // Selection outline
1104
+ const outline = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
1105
+ outline.setAttribute('x', x - w / 2);
1106
+ outline.setAttribute('y', y - h / 2);
1107
+ outline.setAttribute('width', w);
1108
+ outline.setAttribute('height', h);
1109
+ outline.setAttribute('fill', 'none');
1110
+ outline.setAttribute('stroke', '#0066cc');
1111
+ outline.setAttribute('stroke-width', 2);
1112
+ outline.setAttribute('stroke-dasharray', '5,5');
1113
+ if (rotation !== 0) {
1114
+ outline.setAttribute('transform', `rotate(${rotation} ${x} ${y})`);
1115
+ }
1116
+ handleGroup.appendChild(outline);
1117
+
1118
+ // Resize handles (corners)
1119
+ const handleSize = 8;
1120
+ const corners = [
1121
+ { cx: x - w / 2, cy: y - h / 2, type: 'nw' },
1122
+ { cx: x + w / 2, cy: y - h / 2, type: 'ne' },
1123
+ { cx: x - w / 2, cy: y + h / 2, type: 'sw' },
1124
+ { cx: x + w / 2, cy: y + h / 2, type: 'se' },
1125
+ ];
1126
+
1127
+ for (const corner of corners) {
1128
+ const handle = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
1129
+ handle.setAttribute('x', corner.cx - handleSize / 2);
1130
+ handle.setAttribute('y', corner.cy - handleSize / 2);
1131
+ handle.setAttribute('width', handleSize);
1132
+ handle.setAttribute('height', handleSize);
1133
+ handle.setAttribute('fill', '#0066cc');
1134
+ handle.setAttribute('stroke', '#fff');
1135
+ handle.setAttribute('stroke-width', 1);
1136
+ handle.setAttribute('class', `resize-handle ${corner.type}`);
1137
+ handle.setAttribute('pointer-events', 'auto');
1138
+ handle.setAttribute('cursor', `${corner.type}-resize`);
1139
+
1140
+ handle.addEventListener('mousedown', (e) => this._startResize(e, template, corner.type));
1141
+ if (!fixed) handleGroup.appendChild(handle);
1142
+ }
1143
+
1144
+ // Rotation handle (top center)
1145
+ const rotateHandle = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
1146
+ rotateHandle.setAttribute('cx', x);
1147
+ rotateHandle.setAttribute('cy', y - h / 2 - 20);
1148
+ rotateHandle.setAttribute('r', 6);
1149
+ rotateHandle.setAttribute('fill', '#ff6600');
1150
+ rotateHandle.setAttribute('stroke', '#fff');
1151
+ rotateHandle.setAttribute('stroke-width', 1);
1152
+ rotateHandle.setAttribute('class', 'rotate-handle');
1153
+ rotateHandle.setAttribute('pointer-events', 'auto');
1154
+ rotateHandle.setAttribute('cursor', 'grab');
1155
+
1156
+ rotateHandle.addEventListener('mousedown', (e) => this._startRotate(e, template));
1157
+ // Only shapes that can turn get the handle (ArchiMate notation does not).
1158
+ if (template.constructor.rotatable && !fixed) handleGroup.appendChild(rotateHandle);
1159
+
1160
+ // Link handle (right side) — for drag-to-connect
1161
+ const linkHandle = document.createElementNS('http://www.w3.org/2000/svg', 'g');
1162
+ linkHandle.setAttribute('class', 'link-handle');
1163
+ linkHandle.setAttribute('pointer-events', 'auto');
1164
+ linkHandle.setAttribute('cursor', 'crosshair');
1165
+
1166
+ const linkCircle = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
1167
+ linkCircle.setAttribute('cx', x + w / 2 + 15);
1168
+ linkCircle.setAttribute('cy', y);
1169
+ linkCircle.setAttribute('r', 8);
1170
+ linkCircle.setAttribute('fill', '#00cc00');
1171
+ linkCircle.setAttribute('stroke', '#fff');
1172
+ linkCircle.setAttribute('stroke-width', 2);
1173
+ linkHandle.appendChild(linkCircle);
1174
+
1175
+ const linkText = document.createElementNS('http://www.w3.org/2000/svg', 'text');
1176
+ linkText.setAttribute('x', x + w / 2 + 15);
1177
+ linkText.setAttribute('y', y + 4);
1178
+ linkText.setAttribute('text-anchor', 'middle');
1179
+ linkText.setAttribute('font-size', '12');
1180
+ linkText.setAttribute('fill', '#fff');
1181
+ linkText.setAttribute('pointer-events', 'none');
1182
+ linkText.textContent = '🔗';
1183
+ linkHandle.appendChild(linkText);
1184
+
1185
+ linkHandle.addEventListener('mousedown', (e) => this._startConnect(e, template));
1186
+ handleGroup.appendChild(linkHandle);
1187
+
1188
+ group.appendChild(handleGroup);
1189
+ }
1190
+
1191
+ /**
1192
+ * Render all edges as SVG lines.
1193
+ * @private
1194
+ */
1195
+ /**
1196
+ * The current view's resolved settings: background, title band and
1197
+ * watermark (drawn round the drawing), how invalid relationships show, ...
1198
+ * The host hands a function in as the `viewSettings` option; none: {}.
1199
+ */
1200
+ viewSettings() {
1201
+ return this._viewSettings?.() ?? {};
1202
+ }
1203
+
1204
+ /**
1205
+ * What is wrong with a line: `{ message, highlight }`, or null. The message is
1206
+ * the line's tooltip; with `highlight` the line is also drawn red and dashed
1207
+ * (its own colour and dashes win). None here; a subclass checks its own.
1208
+ */
1209
+ edgeWarning(edge) {
1210
+ return null;
1211
+ }
1212
+
1213
+ /**
1214
+ * The rules that draw some shapes and lines faintly or not at all
1215
+ * (EmphasisRule): here the highlight, when one is set. A subclass adds its
1216
+ * own (a viewpoint, ...).
1217
+ * @returns {Array<EmphasisRule>}
1218
+ */
1219
+ emphasisRules() {
1220
+ return this.highlight ? [this.highlight] : [];
1221
+ }
1222
+
1223
+ /** How a shape is drawn: the strongest level any rule gives (null: as usual). */
1224
+ templateEmphasis(template) {
1225
+ return this.emphasisRules().reduce((level, rule) => strongerEmphasis(level, rule.template(template, this)), null);
1226
+ }
1227
+
1228
+ /** How a line is drawn: the strongest level any rule gives; hidden when a shape at either end is. */
1229
+ edgeEmphasis(edge) {
1230
+ const ends = [edge.sourceId, edge.targetId].map(id => this.templates.get(id));
1231
+ if (ends.some(t => t && this.templateEmphasis(t) === 'hidden')) return 'hidden';
1232
+ return this.emphasisRules().reduce((level, rule) => strongerEmphasis(level, rule.edge(edge, this)), null);
1233
+ }
1234
+
1235
+ /** Highlight the shapes whose name contains `text`, dimming the rest (HighlightRule); empty text clears it. */
1236
+ setHighlight(text) {
1237
+ this.highlight = String(text ?? '').trim() ? new HighlightRule(text) : null;
1238
+ this.render();
1239
+ }
1240
+
1241
+ /** Where a shape or line is drawn: `group`, or for a dimmed or faded one a see-through group in it. @private */
1242
+ _emphasisGroup(group, emphasis, attrs = {}) {
1243
+ if (!emphasis) return group;
1244
+ const g = this._svg('g', { opacity: EMPHASIS_OPACITY[emphasis], 'data-emphasis': emphasis, ...attrs });
1245
+ group.appendChild(g);
1246
+ return g;
1247
+ }
1248
+
1249
+ _renderEdges(parent) {
1250
+ const cascade = this.edgeStyleCascade();
1251
+ for (const edge of this.getAllEdges()) {
1252
+ if (!edge.visible) continue;
1253
+ const emphasis = this.edgeEmphasis(edge);
1254
+ if (emphasis === 'hidden') continue;
1255
+ const { style } = cascade.resolve(edge);
1256
+ const edgePath = this._edgePath(edge, style);
1257
+ if (!edgePath) continue;
1258
+ const selected = edge.id === this.selectedEdgeId;
1259
+ // A dimmed or faded line's parts go in one see-through group.
1260
+ const group = this._emphasisGroup(parent, emphasis, { 'data-edge-id': edge.id });
1261
+ const warning = this.edgeWarning(edge);
1262
+
1263
+ // Wide invisible stroke so thin edges are easy to hit.
1264
+ const hitArea = this._svg('path', {
1265
+ d: edgePath.d, stroke: 'transparent', 'stroke-width': 12, fill: 'none',
1266
+ 'pointer-events': 'stroke', cursor: 'pointer', 'data-edge-id': edge.id,
1267
+ });
1268
+ // A line that is only drawn (edge.editable false) takes no clicks; it still shows its tooltip.
1269
+ if (edge.editable !== false) hitArea.addEventListener('click', (e) => {
1270
+ e.stopPropagation();
1271
+ this.selectEdge(edge.id);
1272
+ });
1273
+ if (edge.editable !== false) hitArea.addEventListener('dblclick', (e) => {
1274
+ e.stopPropagation();
1275
+ const p = this._clientToDiagram(e.clientX, e.clientY);
1276
+ if (p) this.addWaypoint(edge.id, p);
1277
+ });
1278
+ if (edge.editable !== false) hitArea.addEventListener('contextmenu', (e) => {
1279
+ e.stopPropagation();
1280
+ e.preventDefault();
1281
+ this.selectEdge(edge.id);
1282
+ this._showEdgeContextMenu(edge.id, e.clientX, e.clientY);
1283
+ });
1284
+ const tip = [warning && `⚠️ ${warning.message}`, edge.tooltip].filter(Boolean).join('\n');
1285
+ if (tip) {
1286
+ const title = this._svg('title');
1287
+ title.textContent = tip;
1288
+ hitArea.appendChild(title);
1289
+ }
1290
+ group.appendChild(hitArea);
1291
+
1292
+ const line = this._svg('path', {
1293
+ d: edgePath.d,
1294
+ // A warned line is red and dashed unless it has a colour (or dashes) of its own.
1295
+ stroke: warning?.highlight && !edge.strokeColor ? WARNING_COLOR : style.strokeColor, 'stroke-width': style.strokeWidth,
1296
+ 'stroke-dasharray': LINE_STYLE_DASH[style.lineStyle] ?? (warning?.highlight ? WARNING_DASH : null), fill: 'none', 'pointer-events': 'none',
1297
+ class: [selected ? 'edge selected' : 'edge', warning?.highlight && 'edge-warning'].filter(Boolean).join(' '), 'data-edge-id': edge.id,
1298
+ });
1299
+ group.appendChild(line);
1300
+
1301
+ this._renderEdgeMarkers(group, edgePath, style);
1302
+ if (selected) this._renderEdgeHandles(group, edge, edgePath, line, style);
1303
+
1304
+ if (edge.label) {
1305
+ const text = this._svg('text', {
1306
+ x: (edgePath.x1 + edgePath.x2) / 2, y: (edgePath.y1 + edgePath.y2) / 2 - 8,
1307
+ 'text-anchor': 'middle', 'font-size': 12, fill: '#333', 'pointer-events': 'none',
1308
+ });
1309
+ text.textContent = edge.label;
1310
+ group.appendChild(text);
1311
+ }
1312
+ }
1313
+ }
1314
+
1315
+ /**
1316
+ * The levels a line's style inherits from, most specific first. Hosts set
1317
+ * `edgeStyleLevels` (e.g. diagram → model → tool); each is `{key, label, style}`.
1318
+ */
1319
+ edgeStyleCascade() {
1320
+ return new EdgeStyleCascade(this.edgeStyleLevels);
1321
+ }
1322
+
1323
+ /** A line's effective style after the cascade. */
1324
+ resolveEdgeStyle(edge) {
1325
+ return this.edgeStyleCascade().resolve(edge).style;
1326
+ }
1327
+
1328
+ /**
1329
+ * Geometry of an edge between its templates' outlines, or null if an end is
1330
+ * missing. `style.attach === 'sides'` attaches each unpinned end at the middle
1331
+ * of the side facing the other end (or the nearest waypoint); 'polyline'
1332
+ * routing bends around the other shapes.
1333
+ */
1334
+ _edgePath(edge, style = this.resolveEdgeStyle(edge)) {
1335
+ const source = this.getTemplate(edge.sourceId);
1336
+ const target = this.getTemplate(edge.targetId);
1337
+ if (!source || !target) return null;
1338
+ const box = (t) => ({ w: t.width, h: t.height });
1339
+ let sourceSide = edge.sourceSide || 'auto';
1340
+ let targetSide = edge.targetSide || 'auto';
1341
+ if (style.attach === 'sides') {
1342
+ const first = edge.waypoints[0] ?? target, last = edge.waypoints[edge.waypoints.length - 1] ?? source;
1343
+ if (sourceSide === 'auto') sourceSide = EdgeGeometry.facingSide(source, box(source), first);
1344
+ if (targetSide === 'auto') targetSide = EdgeGeometry.facingSide(target, box(target), last);
1345
+ }
1346
+ const obstacles = style.routing !== 'polyline' ? [] : [...this.templates.values()]
1347
+ .filter(t => t !== source && t !== target && t.visible !== false)
1348
+ .map(t => ({ id: t.id, x: t.x, y: t.y, r: Math.max(t.width, t.height) / 2 }));
1349
+ const info = (t) => ({ boundaryPoint: (dx, dy) => t.boundaryPoint(dx, dy) });
1350
+ return EdgeGeometry.computeEdgePath(
1351
+ info(source), { x: source.x, y: source.y }, box(source),
1352
+ info(target), { x: target.x, y: target.y }, box(target),
1353
+ { waypoints: edge.waypoints, sourceSide, targetSide, routing: style.routing }, style.routing, obstacles
1354
+ );
1355
+ }
1356
+
1357
+ /** Anchors and waypoints in order: the polyline a waypointed edge follows. */
1358
+ _edgeVertices(edge, edgePath = this._edgePath(edge)) {
1359
+ return [{ x: edgePath.x1, y: edgePath.y1 }, ...edge.waypoints, { x: edgePath.x2, y: edgePath.y2 }];
1360
+ }
1361
+
1362
+ /**
1363
+ * Handles for the selected edge: end dots, draggable waypoints (double-click
1364
+ * or right-click to delete), and a hollow handle mid-segment that adds a
1365
+ * waypoint when clicked or dragged.
1366
+ * @private
1367
+ */
1368
+ _renderEdgeHandles(group, edge, edgePath, line, style) {
1369
+ const vertices = this._edgeVertices(edge, edgePath);
1370
+ const straightSegments = edge.waypoints.length > 0 || style.routing === 'straight';
1371
+
1372
+ [['source', vertices[0]], ['target', vertices[vertices.length - 1]]].forEach(([end, point]) => {
1373
+ const dot = this._svg('circle', {
1374
+ cx: point.x, cy: point.y, r: 5, fill: '#0066cc', stroke: '#fff', 'stroke-width': 1.5,
1375
+ class: 'edge-endpoint', cursor: 'crosshair', 'data-end': end,
1376
+ });
1377
+ dot.appendChild(this._svg('title')).textContent = 'Drag onto another shape to reconnect';
1378
+ dot.addEventListener('mousedown', (e) => {
1379
+ if (e.button !== 0) return;
1380
+ e.stopPropagation();
1381
+ e.preventDefault();
1382
+ this._dragEdgeEnd(e, edge, end, end === 'source' ? vertices[vertices.length - 1] : vertices[0]);
1383
+ });
1384
+ dot.addEventListener('click', (e) => e.stopPropagation());
1385
+ group.appendChild(dot);
1386
+ });
1387
+
1388
+ // A routed edge without waypoints (curved/orthogonal) has one segment whose midpoint lies on the drawn path.
1389
+ // (SVGPoint's x/y are prototype getters, so copy them explicitly rather than spreading.)
1390
+ const onPath = straightSegments ? null : line.getPointAtLength(line.getTotalLength() / 2);
1391
+ const midpoints = straightSegments
1392
+ ? vertices.slice(1).map((b, i) => ({ x: (vertices[i].x + b.x) / 2, y: (vertices[i].y + b.y) / 2, index: i }))
1393
+ : [{ x: onPath.x, y: onPath.y, index: 0 }];
1394
+ for (const mid of midpoints) {
1395
+ const handle = this._svg('circle', {
1396
+ cx: mid.x, cy: mid.y, r: 4, fill: '#fff', stroke: '#ff9900', 'stroke-width': 1.5,
1397
+ class: 'waypoint-add', cursor: 'copy', 'data-edge-id': edge.id,
1398
+ });
1399
+ handle.appendChild(this._svg('title')).textContent = 'Drag or click to add a bend';
1400
+ handle.addEventListener('mousedown', (e) => {
1401
+ if (e.button !== 0) return;
1402
+ e.stopPropagation();
1403
+ e.preventDefault();
1404
+ this._dragNewWaypoint(e, edge, mid.index, { x: mid.x, y: mid.y });
1405
+ });
1406
+ handle.addEventListener('click', (e) => e.stopPropagation());
1407
+ group.appendChild(handle);
1408
+ }
1409
+
1410
+ edge.waypoints.forEach((wp, i) => {
1411
+ const handle = this._svg('circle', {
1412
+ cx: wp.x, cy: wp.y, r: 5, fill: '#ff9900', stroke: '#fff', 'stroke-width': 1.5,
1413
+ class: 'waypoint', cursor: 'move', 'data-edge-id': edge.id, 'data-waypoint-index': i,
1414
+ });
1415
+ handle.appendChild(this._svg('title')).textContent = 'Drag to move; double-click to remove';
1416
+ handle.addEventListener('mousedown', (e) => {
1417
+ if (e.button !== 0) return;
1418
+ e.stopPropagation();
1419
+ e.preventDefault();
1420
+ this._dragWaypoint(e, edge, i);
1421
+ });
1422
+ handle.addEventListener('click', (e) => e.stopPropagation());
1423
+ handle.addEventListener('dblclick', (e) => {
1424
+ e.stopPropagation();
1425
+ this.removeWaypoint(edge.id, i);
1426
+ });
1427
+ handle.addEventListener('contextmenu', (e) => {
1428
+ e.stopPropagation();
1429
+ e.preventDefault();
1430
+ this._showMenu(e.clientX, e.clientY, [
1431
+ { label: 'Delete waypoint', danger: true, action: () => this.removeWaypoint(edge.id, i) },
1432
+ ]);
1433
+ });
1434
+ group.appendChild(handle);
1435
+ });
1436
+ }
1437
+
1438
+ _svg(tag, attrs = {}) {
1439
+ const node = document.createElementNS('http://www.w3.org/2000/svg', tag);
1440
+ for (const [k, v] of Object.entries(attrs)) {
1441
+ if (v != null) node.setAttribute(k, v);
1442
+ }
1443
+ return node;
1444
+ }
1445
+
1446
+ /**
1447
+ * Select an edge.
1448
+ * @param {string} edgeId
1449
+ */
1450
+ selectEdge(edgeId) {
1451
+ const edge = this.getEdge(edgeId);
1452
+ // Re-selecting must not re-render: that would swap the hit area out from
1453
+ // under the pointer and break a double-click on the line.
1454
+ if (!edge || edge.editable === false || (this.selectedEdgeId === edgeId && !this.selectedTemplateId)) return;
1455
+ this.selectedEdgeId = edgeId;
1456
+ this.selectedTemplateId = null;
1457
+ this.propertiesPanel?.render();
1458
+ this._log(`Selected edge: ${edge.label || edge.id}`);
1459
+ this._notifySelection();
1460
+ this.render();
1461
+ }
1462
+
1463
+ /** Deselect any template or edge (clicking empty canvas). */
1464
+ clearSelection() {
1465
+ if (!this.selectedEdgeId && !this.selectedTemplateId && !this.selectedIds.size) return;
1466
+ this.selectedEdgeId = null;
1467
+ this.selectedTemplateId = null;
1468
+ this.selectedIds.clear();
1469
+ this.propertiesPanel?.render();
1470
+ this.render();
1471
+ this._notifySelection();
1472
+ }
1473
+
1474
+ /**
1475
+ * Render endpoint markers (arrow, diamond, circle, crow's foot). Each is
1476
+ * oriented along the direction the path actually arrives at its end, so
1477
+ * curved, orthogonal and waypoint edges point into their nodes correctly.
1478
+ * @private
1479
+ */
1480
+ _renderEdgeMarkers(group, edgePath, style) {
1481
+ const color = style.strokeColor;
1482
+ const size = 6 + style.strokeWidth;
1483
+ const { start, end } = EdgeGeometry.endDirections(edgePath.d);
1484
+ const ends = [
1485
+ [style.startMarker, { x: edgePath.x1, y: edgePath.y1 }, start],
1486
+ [style.endMarker, { x: edgePath.x2, y: edgePath.y2 }, end],
1487
+ ];
1488
+ for (const [value, tip, dir] of ends) {
1489
+ const marker = getEdgeMarker(value);
1490
+ if (!marker || (!dir.x && !dir.y)) continue;
1491
+ for (const { tag, attrs } of marker.shapes(tip, dir, size, color)) {
1492
+ const node = document.createElementNS('http://www.w3.org/2000/svg', tag);
1493
+ for (const [k, v] of Object.entries({ ...attrs, 'pointer-events': 'none' })) node.setAttribute(k, v);
1494
+ group.appendChild(node);
1495
+ }
1496
+ }
1497
+ }
1498
+
1499
+ /**
1500
+ * Start resizing a template.
1501
+ * @private
1502
+ */
1503
+ _startResize(e, template, corner) {
1504
+ e.stopPropagation();
1505
+ if (this.arrangement.isLocked(template.id)) return;
1506
+ const startX = e.clientX;
1507
+ const startY = e.clientY;
1508
+ const startW = template.width || 120;
1509
+ const startH = template.height || 60;
1510
+
1511
+ const onMove = (e) => {
1512
+ const dx = e.clientX - startX;
1513
+ const dy = e.clientY - startY;
1514
+ let deltaW = 0, deltaH = 0;
1515
+
1516
+ if (corner.includes('e')) deltaW = dx;
1517
+ if (corner.includes('w')) deltaW = -dx;
1518
+ if (corner.includes('s')) deltaH = dy;
1519
+ if (corner.includes('n')) deltaH = -dy;
1520
+
1521
+ template.width = Math.max(30, startW + deltaW);
1522
+ template.height = Math.max(30, startH + deltaH);
1523
+ this.render();
1524
+ };
1525
+
1526
+ const onEnd = () => {
1527
+ const endW = template.width || 120;
1528
+ const endH = template.height || 60;
1529
+ const deltaW = endW - startW;
1530
+ const deltaH = endH - startH;
1531
+
1532
+ if (deltaW !== 0 || deltaH !== 0) {
1533
+ // Revert to start state, then execute command
1534
+ template.width = startW;
1535
+ template.height = startH;
1536
+
1537
+ if (ResizeCommand && this.history) {
1538
+ const cmd = new ResizeCommand(this, template.id, deltaW, deltaH);
1539
+ this.history.execute(cmd);
1540
+ this.render();
1541
+ }
1542
+ }
1543
+
1544
+ document.removeEventListener('mousemove', onMove);
1545
+ document.removeEventListener('mouseup', onEnd);
1546
+ };
1547
+
1548
+ document.addEventListener('mousemove', onMove);
1549
+ document.addEventListener('mouseup', onEnd);
1550
+ }
1551
+
1552
+ /**
1553
+ * Start rotating a template.
1554
+ * @private
1555
+ */
1556
+ _startRotate(e, template) {
1557
+ if (this.arrangement.isLocked(template.id)) return;
1558
+ e.stopPropagation();
1559
+ const startX = template.x || 0;
1560
+ const startY = template.y || 0;
1561
+ const startRotation = template.rotation || 0;
1562
+ const startMouseX = e.clientX;
1563
+ const startMouseY = e.clientY;
1564
+
1565
+ const onMove = (e) => {
1566
+ const dx = e.clientX - startMouseX;
1567
+ const dy = e.clientY - startMouseY;
1568
+ const angle = Math.atan2(dy, dx) * 180 / Math.PI;
1569
+ template.rotation = (startRotation + angle) % 360;
1570
+ this.render();
1571
+ };
1572
+
1573
+ const onEnd = () => {
1574
+ const endRotation = template.rotation || 0;
1575
+ const deltaRotation = endRotation - startRotation;
1576
+
1577
+ if (deltaRotation !== 0) {
1578
+ // Revert to start state, then execute command
1579
+ template.rotation = startRotation;
1580
+
1581
+ if (RotateCommand && this.history) {
1582
+ const cmd = new RotateCommand(this, template.id, deltaRotation);
1583
+ this.history.execute(cmd);
1584
+ this.render();
1585
+ }
1586
+ }
1587
+
1588
+ document.removeEventListener('mousemove', onMove);
1589
+ document.removeEventListener('mouseup', onEnd);
1590
+ };
1591
+
1592
+ document.addEventListener('mousemove', onMove);
1593
+ document.addEventListener('mouseup', onEnd);
1594
+ }
1595
+
1596
+ /**
1597
+ * Start drag-to-connect from a template's link handle.
1598
+ * Creates visual feedback and allows drawing edges to other templates.
1599
+ * @private
1600
+ */
1601
+ _startConnect(e, template) {
1602
+ e.stopPropagation();
1603
+ const sourceX = template.x || 0;
1604
+ const sourceY = template.y || 0;
1605
+ const sourceId = template.id;
1606
+
1607
+ const container = document.getElementById(this.diagramContainerId);
1608
+ const svg = container?.tagName === 'svg' ? container : container?.querySelector('svg');
1609
+ if (!svg) return;
1610
+
1611
+ // Create temporary visual line
1612
+ const line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
1613
+ line.setAttribute('x1', sourceX);
1614
+ line.setAttribute('y1', sourceY);
1615
+ line.setAttribute('x2', sourceX);
1616
+ line.setAttribute('y2', sourceY);
1617
+ line.setAttribute('stroke', '#0066cc');
1618
+ line.setAttribute('stroke-width', 2);
1619
+ line.setAttribute('stroke-dasharray', '5,5');
1620
+ line.setAttribute('pointer-events', 'none');
1621
+ line.setAttribute('class', 'connection-preview');
1622
+
1623
+ const elementsGroup = svg.querySelector('#elements');
1624
+ if (elementsGroup) {
1625
+ elementsGroup.appendChild(line);
1626
+ } else {
1627
+ return;
1628
+ }
1629
+
1630
+ // Helper to find element with data-template-id
1631
+ const findTemplateElement = (el) => {
1632
+ let current = el;
1633
+ while (current && current !== svg) {
1634
+ if (current.getAttribute && current.getAttribute('data-template-id')) {
1635
+ return current;
1636
+ }
1637
+ current = current.parentElement;
1638
+ }
1639
+ return null;
1640
+ };
1641
+
1642
+ const onMouseMove = (moveEvent) => {
1643
+ const p = this._clientToDiagram(moveEvent.clientX, moveEvent.clientY);
1644
+ if (p) {
1645
+ line.setAttribute('x2', p.x);
1646
+ line.setAttribute('y2', p.y);
1647
+ }
1648
+
1649
+ // Check for hover over other elements
1650
+ const elements = document.elementsFromPoint(moveEvent.clientX, moveEvent.clientY);
1651
+ let targetElement = null;
1652
+ // Reset all first
1653
+ for (const el of svg.querySelectorAll('[data-template-id]')) {
1654
+ el.style.filter = '';
1655
+ el.style.opacity = '1';
1656
+ }
1657
+ // Find and highlight target
1658
+ for (const el of elements) {
1659
+ const templateEl = findTemplateElement(el);
1660
+ if (templateEl) {
1661
+ const tid = templateEl.getAttribute('data-template-id');
1662
+ if (tid && tid !== sourceId) {
1663
+ targetElement = templateEl;
1664
+ templateEl.style.filter = 'drop-shadow(0 0 12px #00aa00)';
1665
+ templateEl.style.opacity = '0.8';
1666
+ break;
1667
+ }
1668
+ }
1669
+ }
1670
+ };
1671
+
1672
+ const onMouseUp = (upEvent) => {
1673
+ document.removeEventListener('mousemove', onMouseMove);
1674
+ document.removeEventListener('mouseup', onMouseUp);
1675
+
1676
+ // Remove preview line
1677
+ if (line.parentNode) line.parentNode.removeChild(line);
1678
+
1679
+ // Check if we released over a valid target
1680
+ const elements = document.elementsFromPoint(upEvent.clientX, upEvent.clientY);
1681
+ let targetId = null;
1682
+ for (const el of elements) {
1683
+ const templateEl = findTemplateElement(el);
1684
+ if (templateEl) {
1685
+ const tid = templateEl.getAttribute('data-template-id');
1686
+ if (tid && tid !== sourceId) {
1687
+ targetId = tid;
1688
+ break;
1689
+ }
1690
+ }
1691
+ }
1692
+
1693
+ // Reset all element styles
1694
+ for (const el of svg.querySelectorAll('[data-template-id]')) {
1695
+ el.style.filter = '';
1696
+ el.style.opacity = '1';
1697
+ }
1698
+
1699
+ // Connect if dropped on another shape (one undo step).
1700
+ if (targetId) this.connect(sourceId, targetId);
1701
+ };
1702
+
1703
+ document.addEventListener('mousemove', onMouseMove);
1704
+ document.addEventListener('mouseup', onMouseUp);
1705
+ }
1706
+
1707
+ /**
1708
+ * Client (mouse) coordinates → diagram coordinates: the space templates and
1709
+ * waypoints live in, after pan and zoom.
1710
+ * @returns {{x: number, y: number}|null}
1711
+ */
1712
+ _clientToDiagram(clientX, clientY) {
1713
+ const svg = this._canvasSvg();
1714
+ const space = svg?.querySelector('#elements') ?? svg;
1715
+ const ctm = space?.getScreenCTM();
1716
+ if (!ctm) return null;
1717
+ const pt = svg.createSVGPoint();
1718
+ pt.x = clientX;
1719
+ pt.y = clientY;
1720
+ const p = pt.matrixTransform(ctm.inverse());
1721
+ return { x: p.x, y: p.y };
1722
+ }
1723
+
1724
+ /**
1725
+ * The SVG element the diagram is drawn into. Subclasses that draw through
1726
+ * another engine override this, so coordinate helpers keep working.
1727
+ * @protected
1728
+ */
1729
+ _canvasSvg() {
1730
+ const container = document.getElementById(this.diagramContainerId);
1731
+ return container?.tagName === 'svg' ? container : container?.querySelector('svg') ?? null;
1732
+ }
1733
+
1734
+ /** Snap a diagram point to the grid while the grid is shown. */
1735
+ _snapToGrid(p) {
1736
+ if (!this.showGrid || !this.gridSize) return { x: p.x, y: p.y };
1737
+ const g = this.gridSize;
1738
+ return { x: Math.round(p.x / g) * g, y: Math.round(p.y / g) * g };
1739
+ }
1740
+
1741
+ _executeCommand(cmd) {
1742
+ if (this.history) this.history.execute(cmd);
1743
+ else cmd.execute();
1744
+ this.render();
1745
+ }
1746
+
1747
+ /**
1748
+ * Add a bend to an edge, inserted into the path segment nearest `point` so
1749
+ * the route keeps its order. Undoable.
1750
+ */
1751
+ addWaypoint(edgeId, point) {
1752
+ const edge = this.getEdge(edgeId);
1753
+ if (!edge) return;
1754
+ const p = this._snapToGrid(point);
1755
+ const index = EdgeGeometry.nearestSegmentInsertIndex(this._edgeVertices(edge), p.x, p.y);
1756
+ this._executeCommand(new AddWaypointCommand(this, edgeId, p.x, p.y, index));
1757
+ }
1758
+
1759
+ /** Remove one bend. Undoable. */
1760
+ removeWaypoint(edgeId, index) {
1761
+ const edge = this.getEdge(edgeId);
1762
+ if (!edge || index < 0 || index >= edge.waypoints.length) return;
1763
+ this._executeCommand(new DeleteWaypointCommand(this, edgeId, index));
1764
+ }
1765
+
1766
+ /** Remove every bend in one undo step. */
1767
+ clearWaypoints(edgeId) {
1768
+ const edge = this.getEdge(edgeId);
1769
+ if (!edge?.waypoints.length) return;
1770
+ // Delete from the end so earlier indices stay valid; undo restores in reverse.
1771
+ const deletes = edge.waypoints.map((_, i) => new DeleteWaypointCommand(this, edgeId, i)).reverse();
1772
+ this._executeCommand(new CompositeCommand('Straighten edge', deletes));
1773
+ }
1774
+
1775
+ /**
1776
+ * Track the pointer until release, calling onMove(snappedDiagramPoint, event)
1777
+ * for each move and onEnd(moved, upEvent) once.
1778
+ * @private
1779
+ */
1780
+ _trackPointer(e, onMove, onEnd) {
1781
+ const startX = e.clientX, startY = e.clientY;
1782
+ let moved = false;
1783
+ const move = (ev) => {
1784
+ if (!moved && Math.hypot(ev.clientX - startX, ev.clientY - startY) < 3) return;
1785
+ moved = true;
1786
+ const p = this._clientToDiagram(ev.clientX, ev.clientY);
1787
+ if (p) onMove(this._snapToGrid(p), ev);
1788
+ };
1789
+ const up = (ev) => {
1790
+ document.removeEventListener('mousemove', move);
1791
+ document.removeEventListener('mouseup', up);
1792
+ // Re-rendering replaces the handle, so the browser's click lands on the
1793
+ // canvas; don't let it read as "click on empty canvas".
1794
+ this._suppressCanvasClick = true;
1795
+ onEnd(moved, ev);
1796
+ };
1797
+ document.addEventListener('mousemove', move);
1798
+ document.addEventListener('mouseup', up);
1799
+ }
1800
+
1801
+ /** Drag an existing waypoint; one undoable move on release. @private */
1802
+ _dragWaypoint(e, edge, index) {
1803
+ const start = { ...edge.waypoints[index] };
1804
+ this._trackPointer(e, (p) => {
1805
+ edge.moveWaypoint(index, p.x, p.y);
1806
+ this.render();
1807
+ }, (moved) => {
1808
+ if (!moved) return;
1809
+ const end = { ...edge.waypoints[index] };
1810
+ edge.moveWaypoint(index, start.x, start.y);
1811
+ if (end.x !== start.x || end.y !== start.y) {
1812
+ this._executeCommand(new MoveWaypointCommand(this, edge.id, index, start.x, start.y, end.x, end.y));
1813
+ } else {
1814
+ this.render();
1815
+ }
1816
+ });
1817
+ }
1818
+
1819
+ /**
1820
+ * Pull a new waypoint out of a segment's midpoint handle. A plain click adds
1821
+ * it at the midpoint; a drag places it where released. One undoable add.
1822
+ * @private
1823
+ */
1824
+ _dragNewWaypoint(e, edge, index, midpoint) {
1825
+ let current = this._snapToGrid(midpoint);
1826
+ edge.addWaypoint(current.x, current.y, index);
1827
+ this._trackPointer(e, (p) => {
1828
+ current = p;
1829
+ edge.moveWaypoint(index, p.x, p.y);
1830
+ this.render();
1831
+ }, () => {
1832
+ edge.removeWaypoint(index);
1833
+ this._executeCommand(new AddWaypointCommand(this, edge.id, current.x, current.y, index));
1834
+ });
1835
+ }
1836
+
1837
+ /** Clicking empty canvas clears the selection. @private */
1838
+ _initializeCanvasSelection() {
1839
+ const container = document.getElementById(this.diagramContainerId);
1840
+ if (!container) return;
1841
+ container.addEventListener('mousedown', (e) => {
1842
+ this._suppressCanvasClick = false;
1843
+ // On empty canvas (not a shape, a line or a handle): drag a box to select what is inside it.
1844
+ const onItem = e.target.closest?.('[data-template-id], [data-edge-id], [data-handle], .resize-handle, .edge-handle');
1845
+ if (e.button !== 0 || e.shiftKey || onItem || this.connectMode) return;
1846
+ // Not the browser's own text selection: that would leave labels selected, and a later
1847
+ // press on one would drag the text instead of the shape.
1848
+ e.preventDefault();
1849
+ this._startMarquee(e, container);
1850
+ });
1851
+ // Empty canvas: what canvasMenuItems offers there (a shape's own menu stops the event first).
1852
+ container.addEventListener('contextmenu', (e) => {
1853
+ const at = this._clientToDiagram(e.clientX, e.clientY);
1854
+ const paste = this.canClipboardPaste() ? [{ label: '📋 Paste here', action: () => this.paste({ at }) }] : [];
1855
+ const own = this.canvasMenuItems(at);
1856
+ const items = [...paste, ...(paste.length && own.length ? [{ separator: true }] : []), ...own];
1857
+ if (!items.length) return;
1858
+ e.preventDefault();
1859
+ this._showMenu(e.clientX, e.clientY, items);
1860
+ });
1861
+ container.addEventListener('click', (e) => {
1862
+ if (this._suppressCanvasClick || e.shiftKey) {
1863
+ this._suppressCanvasClick = false;
1864
+ return;
1865
+ }
1866
+ this.clearSelection();
1867
+ });
1868
+ }
1869
+
1870
+ /**
1871
+ * Drag a selection box from where the mouse went down: on release, the
1872
+ * shapes wholly inside it are selected (added to the selection with Ctrl
1873
+ * or Cmd). A press without a drag is a plain click on the canvas.
1874
+ * @private
1875
+ */
1876
+ _startMarquee(down, container) {
1877
+ const start = this._clientToDiagram(down.clientX, down.clientY);
1878
+ const group = container.querySelector('#elements');
1879
+ if (!start || !group) return;
1880
+ const extend = down.ctrlKey || down.metaKey;
1881
+ let box = null;
1882
+ const rectOf = (e) => {
1883
+ const p = this._clientToDiagram(e.clientX, e.clientY) ?? start;
1884
+ return { x: Math.min(start.x, p.x), y: Math.min(start.y, p.y), width: Math.abs(p.x - start.x), height: Math.abs(p.y - start.y) };
1885
+ };
1886
+ const onMove = (e) => {
1887
+ if (!box && Math.hypot(e.clientX - down.clientX, e.clientY - down.clientY) < 4) return;
1888
+ box ??= group.appendChild(this._svg('rect', { class: 'marquee', fill: 'rgba(0, 102, 204, 0.08)', stroke: '#0066cc', 'stroke-dasharray': '4 3', 'pointer-events': 'none' }));
1889
+ const r = rectOf(e);
1890
+ for (const [k, v] of Object.entries(r)) box.setAttribute(k, String(v));
1891
+ };
1892
+ const onUp = (e) => {
1893
+ document.removeEventListener('mousemove', onMove);
1894
+ document.removeEventListener('mouseup', onUp);
1895
+ if (!box) return; // no drag: the click clears the selection as usual
1896
+ box.remove();
1897
+ this._suppressCanvasClick = true;
1898
+ this.selectTemplates(this.templatesInside(rectOf(e)), { extend });
1899
+ };
1900
+ document.addEventListener('mousemove', onMove);
1901
+ document.addEventListener('mouseup', onUp);
1902
+ }
1903
+
1904
+ /**
1905
+ * Small context menu at a screen position (see PopupMenu for the item shape).
1906
+ * @private
1907
+ */
1908
+ _showMenu(x, y, items) {
1909
+ this._closeContextMenu();
1910
+ PopupMenu.show(x, y, items);
1911
+ }
1912
+
1913
+ /**
1914
+ * Edit a template's name, position, stacking value and colours as one undo
1915
+ * step. Opened from the shape's right-click menu and by double-clicking it.
1916
+ */
1917
+ editTemplateProperties(template) {
1918
+ if (this.isFixed(template.id)) { this.tellLocked(`"${template.name || 'This shape'}"`, template.id); return; }
1919
+ const conceptFields = this.conceptFields(template);
1920
+ new FormDialog({
1921
+ title: `Properties: ${template.name || template.id}`,
1922
+ fields: [
1923
+ { key: 'name', label: 'Name', type: 'text', value: template.name ?? '' },
1924
+ { key: 'x', label: 'X', type: 'number', value: Math.round(template.x) },
1925
+ { key: 'y', label: 'Y', type: 'number', value: Math.round(template.y) },
1926
+ { key: 'zIndex', label: 'Z-index', type: 'number', value: template.zIndex ?? 0 },
1927
+ ...this.appearanceFields(template),
1928
+ ...conceptFields,
1929
+ ],
1930
+ idPrefix: 'template-props-',
1931
+ onApply: (values) => {
1932
+ const commands = [
1933
+ this.shapeCommand(template, values),
1934
+ conceptFields.length && this.conceptCommand(template, values),
1935
+ ].filter(Boolean);
1936
+ if (!commands.length) return;
1937
+ this._executeCommand(commands.length === 1 ? commands[0] : new CompositeCommand(`Edit ${template.name}`, commands));
1938
+ this._log?.(`Updated ${template.name}`);
1939
+ },
1940
+ }).open();
1941
+ }
1942
+
1943
+ /**
1944
+ * Edit the selected shapes together (Archi's Properties with several
1945
+ * selected): their colours and text style, plus what `manyConceptFields`
1946
+ * adds. Every field starts as "keep each one's own"; what is set applies to
1947
+ * them all, as one undo step. With one shape: its own dialog.
1948
+ * @param {Array<string>} [ids] - Default: the selection
1949
+ */
1950
+ editSelectionProperties(ids = this.getSelectedIds()) {
1951
+ const templates = ids.map(id => this.getTemplate(id)).filter(Boolean);
1952
+ if (templates.length < 2) { if (templates[0]) this.editTemplateProperties(templates[0]); return; }
1953
+ const locked = templates.find(t => this.isFixed(t.id));
1954
+ if (locked) { this.tellLocked(`"${locked.name || 'A selected shape'}"`, locked.id); return; }
1955
+ new FormDialog({
1956
+ title: `Properties: ${templates.length} shapes`,
1957
+ description: 'Untick Keep on a field to give it to every selected shape.',
1958
+ fields: [...FormDialog.many(templates.map(t => this.appearanceFields(t))), ...this.manyConceptFields(templates)],
1959
+ idPrefix: 'selection-props-',
1960
+ onApply: (values) => {
1961
+ const command = this.selectionPropertiesCommand(templates, values);
1962
+ if (!command) return;
1963
+ this._executeCommand(command);
1964
+ this._log?.(`Updated ${templates.length} shapes`);
1965
+ },
1966
+ }).open();
1967
+ }
1968
+
1969
+ /**
1970
+ * The edit of several shapes from `editSelectionProperties`' values (a field
1971
+ * left on Keep is undefined and changes nothing): one step, or null.
1972
+ */
1973
+ selectionPropertiesCommand(templates, values) {
1974
+ const set = Object.fromEntries(Object.entries(values).filter(([, v]) => v !== undefined));
1975
+ const commands = [
1976
+ ...templates.map(t => this.shapeCommand(t, set)),
1977
+ this.manyConceptCommand(templates, set),
1978
+ ].filter(Boolean);
1979
+ return commands.length ? new CompositeCommand(`Edit ${templates.length} shapes`, commands) : null;
1980
+ }
1981
+
1982
+ /** A shape's colours and text style as dialog fields: one shape's dialog, and several shapes' together. */
1983
+ appearanceFields(template) {
1984
+ return [
1985
+ { key: 'fillColor', label: 'Fill colour', type: 'color', value: template.fillColor },
1986
+ { key: 'strokeColor', label: 'Border colour', type: 'color', value: template.strokeColor },
1987
+ { key: 'textColor', label: 'Text colour', type: 'color', value: template.textColor, allowNone: false },
1988
+ { type: 'heading', label: 'Text' },
1989
+ ...TextStyle.fields(this._textStyleOf(template)),
1990
+ ];
1991
+ }
1992
+
1993
+ /**
1994
+ * The shape's own fields among dialog values (name, place, colours, text
1995
+ * style; fields not among them are left alone) as one SetPropertiesCommand,
1996
+ * or null when nothing changes.
1997
+ */
1998
+ shapeCommand(template, values) {
1999
+ const own = Object.fromEntries(Object.entries(values).filter(([k]) => SHAPE_FIELD_KEYS.includes(k)));
2000
+ // A colour left empty stays unset.
2001
+ const patch = {
2002
+ ...Object.fromEntries(Object.entries(own).filter(([k, v]) => v !== template[k] && !(v === '' && template[k] == null))),
2003
+ ...TextStyle.changes(this._textStyleOf(template), TextStyle.fromValues(values)),
2004
+ };
2005
+ return Object.keys(patch).length ? new SetPropertiesCommand(template, patch, `Edit ${template.name}`) : null;
2006
+ }
2007
+
2008
+ /** @private */
2009
+ _textStyleOf(template) {
2010
+ return Object.fromEntries(TEXT_STYLE_KEYS.map(k => [k, template[k]]));
2011
+ }
2012
+
2013
+ /** More fields for several shapes' dialog: what they stand for. None here. */
2014
+ manyConceptFields(templates) {
2015
+ return [];
2016
+ }
2017
+
2018
+ /** The command applying `manyConceptFields`' values (null: nothing changed). */
2019
+ manyConceptCommand(templates, values) {
2020
+ return null;
2021
+ }
2022
+
2023
+ /**
2024
+ * Edit one line: its label and side pins, plus any style it overrides
2025
+ * (each style field can instead inherit). One undo step.
2026
+ * @private
2027
+ */
2028
+ _showEdgeProperties(edge) {
2029
+ const conceptFields = this.edgeConceptFields(edge);
2030
+ const conceptKeys = new Set(conceptFields.map(f => f.key).filter(Boolean));
2031
+ new EdgeStyleDialog({
2032
+ title: 'Line properties',
2033
+ target: edge,
2034
+ // Only what the line sets for itself (an ArchiMate line's dashes and arrows come from its kind).
2035
+ properties: edge.styleProperties ?? EDGE_STYLE_PROPERTIES,
2036
+ fixedProperties: LINE_ONLY_PROPERTIES,
2037
+ extraFields: conceptFields,
2038
+ inherited: this.edgeStyleCascade().inheritedBy('line'),
2039
+ onApply: (values) => {
2040
+ const patch = Object.fromEntries(Object.entries(values).filter(([k]) => !conceptKeys.has(k)));
2041
+ const commands = [new SetStyleCommand(edge, patch, 'Edit line'), conceptFields.length && this.edgeConceptCommand(edge, values)].filter(Boolean);
2042
+ this._executeCommand(commands.length === 1 ? commands[0] : new CompositeCommand('Edit line', commands));
2043
+ },
2044
+ }).open();
2045
+ }
2046
+
2047
+ /**
2048
+ * More fields for a shape's properties dialog: what the shape stands for
2049
+ * (an ArchiMate element's type, documentation, properties, ...). None here.
2050
+ * @returns {Array<Object>} FormDialog fields
2051
+ */
2052
+ conceptFields(template) {
2053
+ return [];
2054
+ }
2055
+
2056
+ /** The command applying `conceptFields`' values (null: nothing changed). */
2057
+ conceptCommand(template, values) {
2058
+ return null;
2059
+ }
2060
+
2061
+ /** More fields for a line's properties dialog: what the line stands for. None here. */
2062
+ edgeConceptFields(edge) {
2063
+ return [];
2064
+ }
2065
+
2066
+ /** The command applying `edgeConceptFields`' values (null: nothing changed). */
2067
+ edgeConceptCommand(edge, values) {
2068
+ return null;
2069
+ }
2070
+
2071
+ /**
2072
+ * Edit the line-style defaults of one level in `edgeStyleLevels` (e.g.
2073
+ * 'view', 'model', 'tool'). Lines that do not override a value pick up the
2074
+ * change. One undo step.
2075
+ */
2076
+ editLineStyle(levelKey) {
2077
+ const cascade = this.edgeStyleCascade();
2078
+ const level = cascade.level(levelKey);
2079
+ if (!level) throw new Error(`No line style level "${levelKey}"`);
2080
+ new EdgeStyleDialog({
2081
+ title: `Line style — ${level.label} defaults`,
2082
+ target: level.style,
2083
+ properties: EDGE_STYLE_PROPERTIES,
2084
+ inherited: cascade.inheritedBy(levelKey),
2085
+ onApply: (patch) => this._executeCommand(new SetStyleCommand(level.style, patch, `${level.label} line style`)),
2086
+ }).open();
2087
+ }
2088
+
2089
+ /** Id of the topmost template under a screen point, skipping `exclude`. @private */
2090
+ _templateIdAt(clientX, clientY, exclude = []) {
2091
+ for (const node of document.elementsFromPoint(clientX, clientY)) {
2092
+ const id = node.closest?.('[data-template-id]')?.getAttribute('data-template-id');
2093
+ if (id && !exclude.includes(id) && this.getTemplate(id)) return id;
2094
+ }
2095
+ return null;
2096
+ }
2097
+
2098
+ _setDropHighlight(templateId, on) {
2099
+ if (!templateId) return;
2100
+ const node = document.getElementById(this.diagramContainerId)?.querySelector(`[data-template-id="${CSS.escape(templateId)}"]`);
2101
+ if (node) node.style.filter = on ? 'drop-shadow(0 0 8px #00aa00)' : '';
2102
+ }
2103
+
2104
+ /**
2105
+ * Drag one end of the selected edge onto another template to reconnect it.
2106
+ * Released anywhere else (or on the other end's template) it snaps back.
2107
+ * @private
2108
+ */
2109
+ _dragEdgeEnd(e, edge, end, fixedPoint) {
2110
+ const group = document.getElementById(this.diagramContainerId)?.querySelector('#elements');
2111
+ const preview = this._svg('line', {
2112
+ x1: fixedPoint.x, y1: fixedPoint.y, x2: fixedPoint.x, y2: fixedPoint.y,
2113
+ stroke: '#0066cc', 'stroke-width': 2, 'stroke-dasharray': '5 5', 'pointer-events': 'none', class: 'reconnect-preview',
2114
+ });
2115
+ group?.appendChild(preview);
2116
+ const otherId = end === 'source' ? edge.targetId : edge.sourceId;
2117
+ let hovered = null;
2118
+ const hover = (id) => {
2119
+ if (id === hovered) return;
2120
+ this._setDropHighlight(hovered, false);
2121
+ this._setDropHighlight(id, true);
2122
+ hovered = id;
2123
+ };
2124
+
2125
+ this._trackPointer(e, (p, ev) => {
2126
+ preview.setAttribute('x2', p.x);
2127
+ preview.setAttribute('y2', p.y);
2128
+ hover(this._templateIdAt(ev.clientX, ev.clientY, [otherId]));
2129
+ }, (moved, ev) => {
2130
+ hover(null);
2131
+ preview.remove();
2132
+ const targetId = moved ? this._templateIdAt(ev.clientX, ev.clientY, [otherId]) : null;
2133
+ const currentId = end === 'source' ? edge.sourceId : edge.targetId;
2134
+ if (targetId && targetId !== currentId) {
2135
+ this._executeCommand(new ReconnectEdgeCommand(this, edge.id, end, targetId));
2136
+ } else {
2137
+ this.render();
2138
+ }
2139
+ });
2140
+ }
2141
+
2142
+ /**
2143
+ * Add a template to the diagram.
2144
+ * @param {Template} template
2145
+ */
2146
+ addTemplate(template) {
2147
+ this.templates.set(template.id, template);
2148
+ }
2149
+
2150
+ /**
2151
+ * Remove a template from the diagram.
2152
+ * @param {string} templateId
2153
+ */
2154
+ removeTemplate(templateId) {
2155
+ this.templates.delete(templateId);
2156
+ this.selectedIds.delete(templateId);
2157
+ if (this.selectedTemplateId === templateId) {
2158
+ this.selectedTemplateId = this.selectedIds.values().next().value ?? null;
2159
+ }
2160
+ }
2161
+
2162
+ /**
2163
+ * Delete a template (with undo support).
2164
+ * @param {string} templateId
2165
+ */
2166
+ deleteTemplate(templateId) {
2167
+ const template = this.getTemplate(templateId);
2168
+ if (!template) return;
2169
+ if (this.isFixed(templateId)) { this.tellLocked(`"${template.name || 'This shape'}"`, templateId); return; }
2170
+ this._executeCommand(this.deleteTemplateCommand(templateId));
2171
+ }
2172
+
2173
+ /** The command that deletes a shape and the lines joined to it, as one undo step. */
2174
+ deleteTemplateCommand(templateId) {
2175
+ return this.deleteTemplatesCommand([templateId]);
2176
+ }
2177
+
2178
+ /**
2179
+ * The command that deletes some shapes and every line joined to any of
2180
+ * them (each line once), as one undo step. Lines only drawn (not
2181
+ * editable) are left to their owner.
2182
+ */
2183
+ deleteTemplatesCommand(templateIds) {
2184
+ const ids = new Set(templateIds);
2185
+ const lines = this.getAllEdges().filter(e => e.editable !== false && (ids.has(e.sourceId) || ids.has(e.targetId)));
2186
+ return new CompositeCommand(this.deleteLabel([...ids]), [
2187
+ ...lines.map(edge => this.deleteEdgeCommand(edge.id)),
2188
+ ...[...ids].map(id => this.removeTemplateCommand(id)),
2189
+ ]);
2190
+ }
2191
+
2192
+ // ===== Fit to text =====
2193
+
2194
+ /**
2195
+ * The size a shape's label needs (its lines at its text size, about 0.6
2196
+ * of the size a character), never under `min`. Text is not measured
2197
+ * (that needs a page), so it is close, not exact.
2198
+ */
2199
+ static textSize(text, fontPixels = 12, { padding = 24, min = { width: 60, height: 30 } } = {}) {
2200
+ const lines = String(text ?? '').split('\n');
2201
+ const longest = Math.max(...lines.map(l => l.length));
2202
+ return {
2203
+ width: Math.max(min.width, Math.round(longest * fontPixels * 0.6 + padding)),
2204
+ height: Math.max(min.height, Math.round(lines.length * fontPixels * 1.3 + padding * 0.8)),
2205
+ };
2206
+ }
2207
+
2208
+ /** The room a shape's text needs around it, and its smallest size (textSize's options): a subclass's shapes may need more. */
2209
+ textFit(template) {
2210
+ return { padding: 24, min: { width: 60, height: 30 } };
2211
+ }
2212
+
2213
+ /** Size shapes to their labels (textSize), each keeping its top-left corner. One undo step; a locked one stops it. */
2214
+ fitToText(ids) {
2215
+ const templates = ids.map(id => this.getTemplate(id)).filter(Boolean);
2216
+ if (!templates.length) return null;
2217
+ if (!this.arrangement.canMove(templates.map(t => t.id))) { this.tellLocked('A shape among them'); return null; }
2218
+ const steps = templates.map(t => {
2219
+ const { width, height } = DiagramEditor.textSize(t.name, TextStyle.pixels(t.fontSize) ?? 12, this.textFit(t));
2220
+ if (width === t.width && height === t.height) return null;
2221
+ const left = t.x - t.width / 2, top = t.y - t.height / 2;
2222
+ return new SetPropertiesCommand(t, { width, height, x: left + width / 2, y: top + height / 2 }, 'Fit to text');
2223
+ }).filter(Boolean);
2224
+ if (!steps.length) return null;
2225
+ const cmd = new CompositeCommand('Fit to text', steps);
2226
+ this._executeCommand(cmd);
2227
+ return cmd;
2228
+ }
2229
+
2230
+ // ===== Text to shapes =====
2231
+
2232
+ /** The kinds of shape text can be made into, `[[key, label]]`: the palette's here; a subclass offers its own (element types). */
2233
+ shapeTypes() {
2234
+ const entries = this.templateCatalog?.getAll().map(e => [e.type, e.name])
2235
+ ?? getTemplatesForDiagramType(this.templateType).map(t => [t.id, t.name]);
2236
+ return entries;
2237
+ }
2238
+
2239
+ /** A `types=` name as a shapeTypes key (its key or its label, any case), or null. */
2240
+ resolveShapeType(name) {
2241
+ const wanted = String(name).trim().toLowerCase();
2242
+ return this.shapeTypes().find(([key, label]) => key.toLowerCase() === wanted || label.toLowerCase() === wanted)?.[0] ?? null;
2243
+ }
2244
+
2245
+ /**
2246
+ * New shapes from text (textToShapes.js: a name a line, or `types=`
2247
+ * columns), their top-left at `at`: one undo step, selected after.
2248
+ * @returns {{command: (Command|null), unknownTypes: Array<string>}}
2249
+ */
2250
+ addFromText(text, type, at = this.legendPosition()) {
2251
+ const parsed = parseShapeText(text, { defaultType: type, resolveType: (t) => this.resolveShapeType(t) });
2252
+ const placed = placeShapes(parsed, at).filter(item => item.type);
2253
+ const command = placed.length ? this.createShapesCommand(placed) : null;
2254
+ if (command) {
2255
+ this._executeCommand(command);
2256
+ this.selectTemplates(command.createdIds ?? []);
2257
+ }
2258
+ return { command, unknownTypes: parsed.unknownTypes };
2259
+ }
2260
+
2261
+ /**
2262
+ * The command that creates shapes from `[{name, type, documentation?, x, y}]`
2263
+ * (x, y their centre): here palette shapes; a subclass makes its own
2264
+ * (model elements). Its `createdIds` are the new shapes.
2265
+ */
2266
+ createShapesCommand(items) {
2267
+ const stamp = Date.now().toString(36);
2268
+ const entryOf = (type) => this.templateCatalog?.get(type) ?? getTemplatesForDiagramType(this.templateType).find(t => t.id === type);
2269
+ const created = items.map((item, i) => {
2270
+ const t = entryOf(item.type)?.factory(`${item.type}-${stamp}-${i}`, item.x, item.y);
2271
+ if (t) t.name = item.name;
2272
+ return t;
2273
+ }).filter(Boolean);
2274
+ const cmd = new CompositeCommand(`Add ${created.length} shape${created.length === 1 ? '' : 's'} from text`, created.map(t => new CreateTemplateCommand(this, t)));
2275
+ cmd.createdIds = created.map(t => t.id);
2276
+ return cmd;
2277
+ }
2278
+
2279
+ /** Add from text…: the names (one a line, or `types=` columns) and their kind, placed at `at`. */
2280
+ askAddFromText(at) {
2281
+ const types = this.shapeTypes();
2282
+ new FormDialog({
2283
+ title: 'Add from text',
2284
+ description: 'One name a line (a tab, then its documentation). Or a first line types=<type>,<type>,… and tab-separated columns, a row of shapes a line.',
2285
+ fields: [
2286
+ { key: 'text', label: 'Names', type: 'textarea', rows: 10, value: '' },
2287
+ { key: 'type', label: 'Kind', type: 'select', value: types[0]?.[0], choices: types },
2288
+ ],
2289
+ submitLabel: 'Add',
2290
+ idPrefix: 'from-text-',
2291
+ document: this.document ?? globalThis.document,
2292
+ validate: ({ text }) => {
2293
+ const { items, unknownTypes } = parseShapeText(text, { resolveType: (t) => this.resolveShapeType(t) });
2294
+ if (unknownTypes.length) return `Not a kind here: ${unknownTypes.join(', ')}`;
2295
+ return items.length ? null : 'No names to add';
2296
+ },
2297
+ onApply: ({ text, type }) => this.addFromText(text, type, at),
2298
+ }).open();
2299
+ }
2300
+
2301
+ // ===== Colour by, and legends =====
2302
+
2303
+ /** Colours offered for the values of what shapes are coloured by, in order (soft, told apart easily). */
2304
+ static COLOUR_PALETTE = Object.freeze(['#a6cee3', '#b2df8a', '#fb9a99', '#fdbf6f', '#cab2d6', '#ffff99', '#8dd3c7', '#bebada', '#fb8072', '#80b1d3', '#fccde5', '#d9d9d9']);
2305
+
2306
+ /** What shapes can be coloured by, `[[key, label]]`: their kind here; a subclass adds its own (properties, layers). */
2307
+ colourKeys(templates = [...this.templates.values()]) {
2308
+ return [['kind', 'Kind of shape']];
2309
+ }
2310
+
2311
+ /** A shape's value for a colourKeys key, as text (undefined: it has none, and is left as it is). */
2312
+ colourValue(template, key) {
2313
+ return key === 'kind' ? this.layoutCategory(template) : undefined;
2314
+ }
2315
+
2316
+ /** The values of `key` among the shapes, in order of first appearance, each with its shapes. */
2317
+ colourValues(key, templates = [...this.templates.values()].filter(t => t.visible !== false)) {
2318
+ const values = new Map();
2319
+ for (const t of templates) {
2320
+ const value = this.colourValue(t, key);
2321
+ if (value === undefined || value === null || value === '') continue;
2322
+ const text = String(value);
2323
+ if (!values.has(text)) values.set(text, []);
2324
+ values.get(text).push(t);
2325
+ }
2326
+ return values;
2327
+ }
2328
+
2329
+ /**
2330
+ * Colour the shapes by what `key` says of them (a heat map): each value's
2331
+ * shapes filled with its colour (`colours`, by value; a value left out or
2332
+ * empty is left as it is), and with `legend` a legend of the colours
2333
+ * under the drawing. One undo step; locked shapes are left as they are.
2334
+ * @returns {Command|null}
2335
+ */
2336
+ colourByCommand(key, colours, { legend = false } = {}) {
2337
+ const label = this.colourKeys().find(([k]) => k === key)?.[1] ?? key;
2338
+ const steps = [];
2339
+ for (const [value, templates] of this.colourValues(key)) {
2340
+ const fillColor = colours[value];
2341
+ if (!fillColor) continue;
2342
+ for (const t of templates) if (!this.isFixed(t.id) && t.fillColor !== fillColor) steps.push(new SetPropertiesCommand(t, { fillColor }, 'Colour'));
2343
+ }
2344
+ if (legend) {
2345
+ const entries = [...this.colourValues(key).keys()].filter(v => colours[v]).map(v => ({ label: v, fillColor: colours[v] }));
2346
+ const cmd = entries.length && this.legendCommand(entries, label);
2347
+ if (cmd) steps.push(cmd);
2348
+ }
2349
+ return steps.length ? new CompositeCommand(`Colour by ${label}`, steps) : null;
2350
+ }
2351
+
2352
+ /** Colour by… : what to colour by, then the colour of each of its values (and whether to add a legend). */
2353
+ askColourBy() {
2354
+ const keys = this.colourKeys();
2355
+ const doc = this.document ?? globalThis.document;
2356
+ new FormDialog({
2357
+ title: 'Colour by',
2358
+ description: 'Fill each shape with the colour of its value (a heat map). Undo puts the colours back.',
2359
+ fields: [{ key: 'key', label: 'Colour by', type: 'select', value: keys[0]?.[0], choices: keys }],
2360
+ submitLabel: 'Next',
2361
+ document: doc,
2362
+ onApply: ({ key }) => this.askColours(key),
2363
+ }).open();
2364
+ }
2365
+
2366
+ /** The colour of each value of `key` (the palette's by default), then colour by it. @protected */
2367
+ askColours(key) {
2368
+ const values = [...this.colourValues(key).keys()];
2369
+ const palette = this.constructor.COLOUR_PALETTE;
2370
+ const label = this.colourKeys().find(([k]) => k === key)?.[1] ?? key;
2371
+ if (!values.length) { (globalThis.alert ?? (() => {}))(`No shape here has a value for ${label}.`); return; }
2372
+ new FormDialog({
2373
+ title: `Colour by ${label}`,
2374
+ fields: [
2375
+ ...values.map((value, i) => ({ key: `value-${i}`, label: value, type: 'color', value: palette[i % palette.length] })),
2376
+ { key: 'legend', label: 'Add a legend', type: 'checkbox', value: true },
2377
+ ],
2378
+ submitLabel: 'Colour',
2379
+ idPrefix: 'colour-by-',
2380
+ document: this.document ?? globalThis.document,
2381
+ onApply: (got) => {
2382
+ const colours = Object.fromEntries(values.map((value, i) => [value, got[`value-${i}`] === 'none' ? '' : got[`value-${i}`]]));
2383
+ const cmd = this.colourByCommand(key, colours, { legend: !!got.legend });
2384
+ if (cmd) this._executeCommand(cmd);
2385
+ },
2386
+ }).open();
2387
+ }
2388
+
2389
+ /** A legend of the kinds of shape on the view, each in the colour of its first shape. One undo step. */
2390
+ addKindLegend() {
2391
+ const cmd = this.legendCommand(this.kindLegendEntries(), 'Legend');
2392
+ if (cmd) this._executeCommand(cmd);
2393
+ return cmd;
2394
+ }
2395
+
2396
+ /** The kind legend's entries, `[{label, fillColor}]`: each kind of shape here (a subclass: each element type). */
2397
+ kindLegendEntries() {
2398
+ const kinds = new Map();
2399
+ for (const t of this.templates.values()) {
2400
+ if (t.visible === false) continue;
2401
+ const kind = this.layoutCategory(t);
2402
+ if (!kinds.has(kind)) kinds.set(kind, t.fillColor);
2403
+ }
2404
+ return [...kinds].map(([label, fillColor]) => ({ label, fillColor }));
2405
+ }
2406
+
2407
+ /** Where a legend goes: under the drawing, at its left. @protected */
2408
+ legendPosition() {
2409
+ const shapes = [...this.templates.values()];
2410
+ if (!shapes.length) return { x: 40, y: 40 };
2411
+ return { x: Math.min(...shapes.map(t => t.x - t.width / 2)), y: Math.max(...shapes.map(t => t.y + t.height / 2)) + 40 };
2412
+ }
2413
+
2414
+ /**
2415
+ * The command that adds a legend: a title and a box per entry, in a row
2416
+ * under the drawing (legendPosition). Here, notes; a subclass makes its own
2417
+ * kind of box. Null with no entries.
2418
+ * @param {Array<{label: string, fillColor: string}>} entries
2419
+ * @param {string} title
2420
+ */
2421
+ legendCommand(entries, title) {
2422
+ if (!entries.length) return null;
2423
+ const { x, y } = this.legendPosition();
2424
+ const stamp = Date.now().toString(36);
2425
+ const note = (i, name, fillColor, width = 120) => new NoteTemplate(`legend-${stamp}-${i}`, name, { x: x + width / 2 + i * 130, y: y + 25, width, height: 40, fillColor });
2426
+ const boxes = [note(0, title, '#ffffff'), ...entries.map((e, i) => note(i + 1, e.label, e.fillColor))];
2427
+ return new CompositeCommand(`Add legend: ${title}`, boxes.map(t => new CreateTemplateCommand(this, t)));
2428
+ }
2429
+
2430
+ // ===== Format painter =====
2431
+
2432
+ /**
2433
+ * Pick up a shape's format (Template#format: paint and text style) for
2434
+ * pasteFormat and the format painter. Returns it, or null.
2435
+ */
2436
+ copyFormat(templateId) {
2437
+ const template = this.getTemplate(templateId);
2438
+ if (!template) return null;
2439
+ this.copiedFormat = template.format();
2440
+ this._log?.(`Copied the format of ${template.name || 'the shape'}`);
2441
+ return this.copiedFormat;
2442
+ }
2443
+
2444
+ /**
2445
+ * Give shapes the copied format (each the keys it has among its
2446
+ * `formatKeys`), as one undo step. Null when nothing changes or none is
2447
+ * copied; a locked shape stops it.
2448
+ */
2449
+ pasteFormat(templateIds = this.getSelectedIds()) {
2450
+ const format = this.copiedFormat;
2451
+ const templates = templateIds.map(id => this.getTemplate(id)).filter(Boolean);
2452
+ if (!format || !templates.length) return null;
2453
+ const locked = templates.find(t => this.isFixed(t.id));
2454
+ if (locked) { this.tellLocked(`"${locked.name || 'A shape'}"`, locked.id); return null; }
2455
+ const steps = templates.map(t => {
2456
+ const own = t.format();
2457
+ const patch = Object.fromEntries(t.constructor.formatKeys.filter(k => k in format && own[k] !== format[k]).map(k => [k, format[k]]));
2458
+ return Object.keys(patch).length ? new SetPropertiesCommand(t, patch, 'Paste format') : null;
2459
+ }).filter(Boolean);
2460
+ if (!steps.length) return null;
2461
+ const cmd = new CompositeCommand('Paste format', steps);
2462
+ this._executeCommand(cmd);
2463
+ return cmd;
2464
+ }
2465
+
2466
+ /**
2467
+ * Turn the format painter on or off (Archi's Format Painter). While on, a
2468
+ * click on a shape picks up its format when none is held, and gives the
2469
+ * held one to each shape clicked after; Esc or the 🖌 button turns it off.
2470
+ * Turned on with a shape selected, it starts with that shape's format.
2471
+ */
2472
+ setFormatPainter(on) {
2473
+ this.formatPainter = !!on;
2474
+ if (on) {
2475
+ this.copiedFormat = null;
2476
+ if (this.selectedTemplateId) this.copyFormat(this.selectedTemplateId);
2477
+ }
2478
+ const host = this.drawingHost?.();
2479
+ if (host?.style) host.style.cursor = on ? 'copy' : '';
2480
+ this._log?.(on ? 'Format painter: click shapes to give them the format' : 'Format painter off');
2481
+ }
2482
+
2483
+ /** A click on a shape while the format painter is on. @protected */
2484
+ formatPainterClick(templateId) {
2485
+ if (!this.copiedFormat) { this.copyFormat(templateId); return null; }
2486
+ return this.pasteFormat([templateId]);
2487
+ }
2488
+
2489
+ /** Copy format, and Paste format when one is held (to the selection, when the shape is part of it). */
2490
+ formatMenuItems(elementId) {
2491
+ const targets = () => (this.selectedIds.has(elementId) ? this.getSelectedIds() : [elementId]);
2492
+ return [
2493
+ { label: '🖌 Copy format', action: () => this.copyFormat(elementId) },
2494
+ ...(this.copiedFormat ? [{ label: 'Paste format', action: () => this.pasteFormat(targets()) }] : []),
2495
+ ];
2496
+ }
2497
+
2498
+ // ===== Copy and paste =====
2499
+
2500
+ /** Which editors can paste what this one copies (DiagramClipboard kind). A subclass whose copies differ names its own. */
2501
+ static clipboardKind = 'diagram';
2502
+
2503
+ /** Whether the clipboard holds something this editor can paste. */
2504
+ canClipboardPaste() {
2505
+ return DiagramClipboard.has(this.constructor.clipboardKind);
2506
+ }
2507
+
2508
+ /** Cut, Copy and Duplicate for a shape's menu (the selection, when it is part of one). */
2509
+ clipboardMenuItems(elementId) {
2510
+ const select = () => { if (!this.selectedIds.has(elementId)) this.selectTemplates([elementId]); };
2511
+ return [
2512
+ { label: '✂ Cut', action: () => { select(); this.cutSelection(); } },
2513
+ { label: '⧉ Copy', action: () => { select(); this.copySelection(); } },
2514
+ { label: 'Duplicate', action: () => { select(); this.duplicateSelection(); } },
2515
+ ];
2516
+ }
2517
+
2518
+ /** The selected shapes that can be copied. @private */
2519
+ _copyableSelection() {
2520
+ return this.getSelectedIds().filter(id => this.getTemplate(id));
2521
+ }
2522
+
2523
+ /** The top-left corner of some shapes. @private */
2524
+ _cornerOf(ids) {
2525
+ const ts = ids.map(id => this.getTemplate(id)).filter(Boolean);
2526
+ return { x: Math.min(...ts.map(t => t.x - t.width / 2)), y: Math.min(...ts.map(t => t.y - t.height / 2)) };
2527
+ }
2528
+
2529
+ /** Copy the selection to the clipboard. Returns what was copied, or null. */
2530
+ copySelection() {
2531
+ const ids = this._copyableSelection();
2532
+ const payload = ids.length ? this.copyPayload(ids) : null;
2533
+ if (!payload) return null;
2534
+ DiagramClipboard.put(this.constructor.clipboardKind, payload, this._cornerOf(ids));
2535
+ return payload;
2536
+ }
2537
+
2538
+ /** Copy the selection and delete it, as one undo step (no question: it can be pasted back). */
2539
+ cutSelection() {
2540
+ const ids = this._copyableSelection();
2541
+ if (!ids.length || ids.some(id => this.isFixed(id))) return null;
2542
+ if (!this.copySelection()) return null;
2543
+ const cmd = this.deleteTemplatesCommand(ids);
2544
+ cmd.label = ids.length === 1 ? 'Cut' : `Cut ${ids.length} shapes`;
2545
+ this.selectedTemplateId = null;
2546
+ this.selectedIds.clear();
2547
+ this._executeCommand(cmd);
2548
+ this._notifySelection();
2549
+ return cmd;
2550
+ }
2551
+
2552
+ /**
2553
+ * Paste the clipboard as one undo step, and select what was pasted: with
2554
+ * `at`, its top-left corner there; otherwise a little further along each
2555
+ * time, so pastes do not land on each other.
2556
+ * @returns {Command|null}
2557
+ */
2558
+ paste({ at = null } = {}) {
2559
+ const entry = DiagramClipboard.get(this.constructor.clipboardKind);
2560
+ if (!entry) return null;
2561
+ const step = PASTE_STEP * ++entry.pastes;
2562
+ const [dx, dy] = at ? [at.x - entry.origin.x, at.y - entry.origin.y] : [step, step];
2563
+ return this._pasteRun(entry.payload, dx, dy);
2564
+ }
2565
+
2566
+ /** Copy the selection and paste it a little along, without touching the clipboard. One undo step. */
2567
+ duplicateSelection() {
2568
+ const ids = this._copyableSelection();
2569
+ const payload = ids.length ? this.copyPayload(ids) : null;
2570
+ return payload ? this._pasteRun(payload, PASTE_STEP, PASTE_STEP, 'Duplicate') : null;
2571
+ }
2572
+
2573
+ /** @private */
2574
+ _pasteRun(payload, dx, dy, label = 'Paste') {
2575
+ const result = this.pasteCommand(payload, { dx, dy, label });
2576
+ if (!result?.command) return null;
2577
+ this._executeCommand(result.command);
2578
+ this.selectTemplates(result.ids);
2579
+ return result.command;
2580
+ }
2581
+
2582
+ /**
2583
+ * What a copy of these shapes is: whatever pasteCommand needs to make them
2584
+ * again. Here: each shape's class and fields, and the lines between them.
2585
+ * @returns {Object|null}
2586
+ */
2587
+ copyPayload(ids) {
2588
+ const keep = new Set(ids);
2589
+ return {
2590
+ templates: ids.map(id => { const t = this.getTemplate(id); return { Class: t.constructor, fields: t.serialize() }; }),
2591
+ edges: this.getAllEdges().filter(e => e.editable !== false && keep.has(e.sourceId) && keep.has(e.targetId)).map(e => e.clone()),
2592
+ };
2593
+ }
2594
+
2595
+ /**
2596
+ * The command that makes a copy's shapes and lines again, moved by dx, dy,
2597
+ * with new ids; and those ids.
2598
+ * @returns {{command: Command, ids: Array<string>}|null}
2599
+ */
2600
+ pasteCommand(payload, { dx = 0, dy = 0, label = 'Paste' } = {}) {
2601
+ const newId = (old) => { let n = 1; while (this.templates.has(`${old}-${n}`) || taken.has(`${old}-${n}`)) n++; taken.add(`${old}-${n}`); return `${old}-${n}`; };
2602
+ const taken = new Set();
2603
+ const ids = new Map();
2604
+ const shapes = payload.templates.map(({ Class, fields }) => {
2605
+ const id = newId(fields.id);
2606
+ ids.set(fields.id, id);
2607
+ const { id: _, name, ...rest } = fields;
2608
+ return new Class(id, name, { ...rest, x: fields.x + dx, y: fields.y + dy });
2609
+ });
2610
+ const lines = payload.edges.map(edge => {
2611
+ const copy = edge.clone();
2612
+ copy.id = newId(edge.id);
2613
+ copy.sourceId = ids.get(edge.sourceId);
2614
+ copy.targetId = ids.get(edge.targetId);
2615
+ copy.waypoints = (edge.waypoints ?? []).map(p => ({ x: p.x + dx, y: p.y + dy }));
2616
+ return copy;
2617
+ });
2618
+ const command = new CompositeCommand(label, [
2619
+ ...shapes.map(t => new CreateTemplateCommand(this, t)),
2620
+ ...lines.map(e => new AddEdgeCommand(this, e)),
2621
+ ]);
2622
+ return { command, ids: shapes.map(t => t.id) };
2623
+ }
2624
+
2625
+ /** What deleting these shapes is called (the undo step's name). */
2626
+ deleteLabel(templateIds) {
2627
+ return templateIds.length === 1 ? `Delete ${this.getTemplate(templateIds[0])?.name ?? 'shape'}` : `Delete ${templateIds.length} shapes`;
2628
+ }
2629
+
2630
+ /**
2631
+ * The command that takes one shape away (its lines are dealt with by the
2632
+ * caller). A subclass whose shapes stand for something else (an ArchiMate
2633
+ * view's elements, its groups) returns its own.
2634
+ */
2635
+ removeTemplateCommand(templateId) {
2636
+ return new DeleteTemplateCommand(this, templateId);
2637
+ }
2638
+
2639
+ /**
2640
+ * Delete the selected shapes (with their lines), as one undo step, once
2641
+ * confirmed; a fixed one among them stops it. Returns the command, or null.
2642
+ */
2643
+ deleteSelection({ confirm = (message) => globalThis.confirm?.(message) ?? true } = {}) {
2644
+ const ids = this.getSelectedIds().filter(id => this.getTemplate(id));
2645
+ if (!ids.length) return null;
2646
+ const fixed = ids.find(id => this.isFixed(id));
2647
+ if (fixed) { this.tellLocked(`"${this.getTemplate(fixed).name || 'A shape'}"`, fixed); return null; }
2648
+ const message = ids.length === 1 ? `Delete "${this.getTemplate(ids[0]).name}"?` : `Delete ${ids.length} shapes?`;
2649
+ if (!confirm(message)) return null;
2650
+ const cmd = this.deleteTemplatesCommand(ids);
2651
+ this.selectedTemplateId = null;
2652
+ this.selectedIds.clear();
2653
+ this._executeCommand(cmd);
2654
+ this._notifySelection();
2655
+ return cmd;
2656
+ }
2657
+
2658
+ /**
2659
+ * Select these shapes (or add them to the selection), the last one the
2660
+ * primary. Shapes not drawn are left out.
2661
+ */
2662
+ selectTemplates(templateIds, { extend = false } = {}) {
2663
+ const ids = templateIds.filter(id => this.getTemplate(id));
2664
+ if (!extend) this.selectedIds.clear();
2665
+ for (const id of ids) this.selectedIds.add(id);
2666
+ this.selectedTemplateId = ids.at(-1) ?? (extend ? this.selectedTemplateId : null);
2667
+ this.selectedEdgeId = null;
2668
+ this.render();
2669
+ this._notifySelection();
2670
+ }
2671
+
2672
+ /** Every shape that can be selected: drawn, and not hidden. */
2673
+ selectableIds() {
2674
+ return [...this.templates.values()]
2675
+ .filter(t => t.visible !== false && this.templateEmphasis(t) !== 'hidden')
2676
+ .map(t => t.id);
2677
+ }
2678
+
2679
+ /** The shapes wholly inside a rectangle in diagram coordinates ({x, y, width, height}, any corner first). */
2680
+ templatesInside({ x, y, width, height }) {
2681
+ const [left, right] = [x, x + width].sort((a, b) => a - b);
2682
+ const [top, bottom] = [y, y + height].sort((a, b) => a - b);
2683
+ return this.selectableIds().filter(id => {
2684
+ const t = this.getTemplate(id);
2685
+ return t.x - t.width / 2 >= left && t.x + t.width / 2 <= right && t.y - t.height / 2 >= top && t.y + t.height / 2 <= bottom;
2686
+ });
2687
+ }
2688
+
2689
+ /**
2690
+ * Move the selection (with its groups and what it holds) by dx, dy, as one
2691
+ * undo step; nothing when any of it is fixed. Returns the command, or null.
2692
+ */
2693
+ nudgeSelection(dx, dy) {
2694
+ if (!this.selectedTemplateId) return null;
2695
+ const ids = this.moversFor(this.selectedTemplateId);
2696
+ if (!ids.length) return null;
2697
+ const moves = ids.map(id => new MoveElementCommand(this, id, dx, dy));
2698
+ const cmd = moves.length === 1 ? moves[0] : new CompositeCommand('Move', moves);
2699
+ this._executeCommand(cmd);
2700
+ return cmd;
2701
+ }
2702
+
2703
+ // ===== ALIGN, DISTRIBUTE, MATCH SIZE =====
2704
+
2705
+ /**
2706
+ * Align, Distribute and Match size submenus over the selection
2707
+ * (alignment.js): align and match size need two shapes, distribute three.
2708
+ * With `always` (the toolbar's Arrange menu) they are there but greyed
2709
+ * out when there are too few; otherwise (a shape's menu) left out. Then
2710
+ * Layout (layouts.js): the selection, or with fewer than two selected the
2711
+ * whole view.
2712
+ */
2713
+ alignMenuItems({ always = false } = {}) {
2714
+ const count = this.getSelectedIds().length;
2715
+ const menu = (label, choices, need, act) => ({
2716
+ label, disabled: count < need || undefined,
2717
+ items: choices.map(([key, text]) => ({ label: text, disabled: count < need || undefined, action: () => act(key) })),
2718
+ });
2719
+ const items = [
2720
+ menu('Align', ALIGNMENTS, 2, (edge) => this.alignSelection(edge)),
2721
+ menu('Distribute', DISTRIBUTIONS, 3, (direction) => this.distributeSelection(direction)),
2722
+ menu('Match size', SIZE_MATCHES, 2, (dimension) => this.matchSelectionSize(dimension)),
2723
+ menu(count >= 2 ? 'Layout' : 'Layout the view', LAYOUTS, 0, (kind) => this.layoutShapes(kind)),
2724
+ ];
2725
+ if (always) return items;
2726
+ return count >= 2 ? [{ separator: true }, ...items.filter(i => !i.disabled)] : [];
2727
+ }
2728
+
2729
+ /** The selection's boxes (`{id, left, top, width, height}`) and the primary one's, its reference. @protected */
2730
+ _selectionBoxes() {
2731
+ const boxes = this.getSelectedIds().map(id => this._boxOf(id));
2732
+ return { boxes, reference: boxes.find(b => b.id === this.selectedTemplateId) ?? boxes.at(-1) };
2733
+ }
2734
+
2735
+ /** A shape's box: `{id, left, top, width, height}`. @protected */
2736
+ _boxOf(id) {
2737
+ const t = this.getTemplate(id);
2738
+ return { id, left: t.left ?? t.x - t.width / 2, top: t.top ?? t.y - t.height / 2, width: t.width, height: t.height };
2739
+ }
2740
+
2741
+ // ===== LAYOUTS =====
2742
+
2743
+ /** What a layout rearranges: the selection (two or more), else every shape; not what a container among them holds (it goes with it). */
2744
+ layoutTargetIds() {
2745
+ const selected = this.getSelectedIds();
2746
+ const ids = (selected.length >= 2 ? selected : this.selectableIds()).filter(id => this.getTemplate(id));
2747
+ const inner = new Set(ids.flatMap(id => this.containedIn(id)));
2748
+ return ids.filter(id => !inner.has(id));
2749
+ }
2750
+
2751
+ /**
2752
+ * Lay shapes out (layouts.js: grid, circle, tree, force-directed, by
2753
+ * kind), keeping the area's top-left corner, along the lines between
2754
+ * them. One undo step; what a container holds goes with it; a locked
2755
+ * shape stops it.
2756
+ * @param {string} kind - One of LAYOUTS' keys
2757
+ * @param {Array<string>} [ids] - Default: layoutTargetIds()
2758
+ */
2759
+ layoutShapes(kind, ids = this.layoutTargetIds()) {
2760
+ if (ids.length < 2) return null;
2761
+ const boxes = ids.map(id => this._boxOf(id));
2762
+ const edges = this.getAllEdges().map(e => ({ source: e.sourceId, target: e.targetId }));
2763
+ const placed = layout(kind, boxes, { edges, categoryOf: (b) => this.layoutCategory(this.getTemplate(b.id)) });
2764
+ return this._placeBoxes(boxes, placed, `Layout: ${LAYOUTS.find(([k]) => k === kind)?.[1] ?? kind}`);
2765
+ }
2766
+
2767
+ /** A shape's kind for the "by kind" layout: its shape class here (an ArchiMate editor: its layer). */
2768
+ layoutCategory(template) {
2769
+ return template.constructor.name.replace(/Template$/, '');
2770
+ }
2771
+
2772
+ /** The shapes that are this one's parts, drawn inside it by nestPartsInside. None here; a subclass with nesting says. */
2773
+ partsOf(templateId) {
2774
+ return [];
2775
+ }
2776
+
2777
+ /**
2778
+ * Draw a shape's parts inside it (`partsOf`), theirs inside them,
2779
+ * however deep (nestLayout): each nested (`nestCommand`), placed in a
2780
+ * grid below its parent's label, the parent grown to hold them. One undo
2781
+ * step; a locked shape stops it.
2782
+ */
2783
+ nestPartsInside(templateId) {
2784
+ if (!this.partsOf(templateId).length) return null;
2785
+ const plan = nestLayout(templateId, (id) => this.partsOf(id), (id) => this._boxOf(id));
2786
+ if (!this.arrangement.canMove([...plan.keys()])) { this.tellLocked('A shape among them'); return null; }
2787
+ const steps = [];
2788
+ for (const [id, box] of plan) {
2789
+ const t = this.getTemplate(id);
2790
+ const patch = { x: box.left + box.width / 2, y: box.top + box.height / 2, width: box.width, height: box.height };
2791
+ const changed = Object.fromEntries(Object.entries(patch).filter(([k, v]) => t[k] !== v));
2792
+ if (Object.keys(changed).length) steps.push(new SetPropertiesCommand(t, changed, 'Nest parts'));
2793
+ if (box.parentId && this.parentOf(id) !== box.parentId) steps.push(this.nestCommand(id, box.parentId));
2794
+ }
2795
+ const cmd = new CompositeCommand(`Nest the parts of ${this.getTemplate(templateId).name || 'a shape'}`, steps.filter(Boolean));
2796
+ this._executeCommand(cmd);
2797
+ return cmd;
2798
+ }
2799
+
2800
+ /** "Nest its parts inside", for a shape that has parts on this view. */
2801
+ nestMenuItems(elementId) {
2802
+ return this.partsOf(elementId).length ? [{ label: 'Nest its parts inside', action: () => this.nestPartsInside(elementId) }] : [];
2803
+ }
2804
+
2805
+ /** Line the selection up with the primary shape's edge or middle ('left', 'centre', 'right', 'top', 'middle', 'bottom'). One undo step. */
2806
+ alignSelection(edge) {
2807
+ const { boxes, reference } = this._selectionBoxes();
2808
+ if (boxes.length < 2) return null;
2809
+ const label = `Align ${ALIGNMENTS.find(([k]) => k === edge)?.[1].toLowerCase() ?? edge}`;
2810
+ return this._placeBoxes(boxes, align(boxes, edge, reference), label);
2811
+ }
2812
+
2813
+ /** Space the selection evenly ('horizontal' or 'vertical'), the outermost two staying. One undo step. */
2814
+ distributeSelection(direction) {
2815
+ const { boxes } = this._selectionBoxes();
2816
+ if (boxes.length < 3) return null;
2817
+ return this._placeBoxes(boxes, distribute(boxes, direction), `Distribute ${direction}ly`);
2818
+ }
2819
+
2820
+ /** Give the selection the primary shape's width, height or both ('width', 'height', 'both'), each keeping its top-left. One undo step. */
2821
+ matchSelectionSize(dimension) {
2822
+ const { boxes, reference } = this._selectionBoxes();
2823
+ if (boxes.length < 2) return null;
2824
+ const sized = matchSize(boxes, dimension, reference);
2825
+ if (!sized.length) return null;
2826
+ if (!this.arrangement.canMove(sized.map(s => s.id))) { this.tellLocked('A shape in the selection'); return null; }
2827
+ const steps = sized.map(({ id, width, height }) => {
2828
+ const t = this.getTemplate(id);
2829
+ const was = boxes.find(b => b.id === id);
2830
+ return new SetPropertiesCommand(t, { width, height, x: was.left + width / 2, y: was.top + height / 2 }, 'Match size');
2831
+ });
2832
+ const cmd = new CompositeCommand('Match size', steps);
2833
+ this._executeCommand(cmd);
2834
+ return cmd;
2835
+ }
2836
+
2837
+ /**
2838
+ * Move boxes to their new places (`{id, left, top}`), what each holds
2839
+ * going with it, as one step; nothing when any of them is fixed.
2840
+ * @protected
2841
+ */
2842
+ _placeBoxes(boxes, placed, label) {
2843
+ if (!placed.length) return null;
2844
+ if (!this.arrangement.canMove(placed.map(p => p.id))) { this.tellLocked('A shape in the selection'); return null; }
2845
+ const deltas = new Map(placed.map(p => {
2846
+ const was = boxes.find(b => b.id === p.id);
2847
+ return [p.id, { dx: p.left - was.left, dy: p.top - was.top }];
2848
+ }));
2849
+ // What a moved container holds moves with it, unless it is in the selection and placed itself.
2850
+ const selected = new Set(boxes.map(b => b.id));
2851
+ for (const [id, delta] of [...deltas]) {
2852
+ const queue = [id];
2853
+ while (queue.length) {
2854
+ for (const inner of this.containedIn(queue.shift())) {
2855
+ if (selected.has(inner) || deltas.has(inner) || !this.getTemplate(inner)) continue;
2856
+ deltas.set(inner, delta);
2857
+ queue.push(inner);
2858
+ }
2859
+ }
2860
+ }
2861
+ const moves = [...deltas].filter(([, d]) => d.dx || d.dy).map(([id, { dx, dy }]) => new MoveElementCommand(this, id, dx, dy));
2862
+ const cmd = new CompositeCommand(label, moves);
2863
+ this._executeCommand(cmd);
2864
+ return cmd;
2865
+ }
2866
+
2867
+ /**
2868
+ * Get a template by id.
2869
+ * @param {string} templateId
2870
+ * @returns {Template|undefined}
2871
+ */
2872
+ getTemplate(templateId) {
2873
+ return this.templates.get(templateId);
2874
+ }
2875
+
2876
+ /**
2877
+ * Get all templates.
2878
+ * @returns {Template[]}
2879
+ */
2880
+ getAllTemplates() {
2881
+ return Array.from(this.templates.values());
2882
+ }
2883
+
2884
+ // ===== EDGE MANAGEMENT =====
2885
+
2886
+ /**
2887
+ * Add an edge to the diagram.
2888
+ * @param {Edge} edge
2889
+ */
2890
+ addEdge(edge) {
2891
+ this.edges.set(edge.id, edge);
2892
+ }
2893
+
2894
+ /**
2895
+ * Remove an edge from the diagram.
2896
+ * @param {string} edgeId
2897
+ */
2898
+ removeEdge(edgeId) {
2899
+ this.edges.delete(edgeId);
2900
+ if (this.selectedEdgeId === edgeId) {
2901
+ this.selectedEdgeId = null;
2902
+ }
2903
+ }
2904
+
2905
+ /**
2906
+ * Delete an edge (with undo support).
2907
+ * @param {string} edgeId
2908
+ */
2909
+ deleteEdge(edgeId) {
2910
+ if (!this.getEdge(edgeId)) return;
2911
+ this._executeCommand(this.deleteEdgeCommand(edgeId));
2912
+ }
2913
+
2914
+ /** The command that deletes a line. A subclass may return its own. */
2915
+ deleteEdgeCommand(edgeId) {
2916
+ return new DeleteEdgeCommand(this, edgeId);
2917
+ }
2918
+
2919
+ /**
2920
+ * Connect two shapes with a new line, as one undo step.
2921
+ * @returns {Command|null} the command run, or null when there is nothing to connect
2922
+ */
2923
+ connect(sourceId, targetId) {
2924
+ if (!sourceId || !targetId || sourceId === targetId) return null;
2925
+ if (!this.getTemplate(sourceId) || !this.getTemplate(targetId)) return null;
2926
+ const cmd = this.connectCommand(sourceId, targetId);
2927
+ if (!cmd) return null;
2928
+ this._executeCommand(cmd);
2929
+ this._log?.(`Connected: ${this.getTemplate(sourceId)?.name} → ${this.getTemplate(targetId)?.name}`);
2930
+ return cmd;
2931
+ }
2932
+
2933
+ /**
2934
+ * The command that connects two shapes: a new plain line. A subclass whose
2935
+ * lines stand for something else (an ArchiMate relationship) returns its
2936
+ * own, or null to refuse the connection.
2937
+ */
2938
+ connectCommand(sourceId, targetId) {
2939
+ const edgeId = `edge_${Date.now()}_${Math.random().toString(36).slice(2, 11)}`;
2940
+ return new AddEdgeCommand(this, new Edge(edgeId, sourceId, targetId));
2941
+ }
2942
+
2943
+ /**
2944
+ * Get an edge by id.
2945
+ * @param {string} edgeId
2946
+ * @returns {Edge|undefined}
2947
+ */
2948
+ getEdge(edgeId) {
2949
+ return this.edges.get(edgeId);
2950
+ }
2951
+
2952
+ /**
2953
+ * Get all edges.
2954
+ * @returns {Edge[]}
2955
+ */
2956
+ getAllEdges() {
2957
+ return Array.from(this.edges.values());
2958
+ }
2959
+
2960
+ /**
2961
+ * Start click-to-connect mode. Selects source template for connection.
2962
+ * @param {string} templateId
2963
+ */
2964
+ startClickToConnect(templateId) {
2965
+ if (this.connectSource === templateId) {
2966
+ this.connectSource = null;
2967
+ this._log('Connect mode cancelled');
2968
+ } else {
2969
+ this.connectSource = templateId;
2970
+ this._log(`Connect from: ${this.getTemplate(templateId).name}`);
2971
+ }
2972
+ }
2973
+
2974
+ /**
2975
+ * Complete click-to-connect by selecting target template.
2976
+ * @param {string} targetId
2977
+ * @returns {Edge|null}
2978
+ */
2979
+ completeClickToConnect(targetId) {
2980
+ if (!this.connectSource || this.connectSource === targetId) {
2981
+ this.connectSource = null;
2982
+ return null;
2983
+ }
2984
+ const sourceId = this.connectSource;
2985
+ this.connectSource = null;
2986
+ return this.connect(sourceId, targetId)?.edge ?? null;
2987
+ }
2988
+
2989
+ /**
2990
+ * Build the palette for a diagram category, with a picker for switching category.
2991
+ * @param {HTMLElement} paletteContainer - Element the palette renders into
2992
+ * @param {string} [templateType] - DiagramTypeRegistry key; defaults to this.templateType
2993
+ */
2994
+ initPaletteForDiagramType(paletteContainer, templateType = null) {
2995
+ if (typeof window === 'undefined') return;
2996
+ this.paletteContainer = paletteContainer;
2997
+ this.setPaletteCategory(templateType || this.templateType || 'generic');
2998
+ this._acceptPaletteDrops();
2999
+ }
3000
+
3001
+ /**
3002
+ * Switch the palette to another DiagramTypeRegistry category. Shapes already
3003
+ * on the canvas are untouched. Fires `onTemplateTypeChange(key)` if set.
3004
+ */
3005
+ setPaletteCategory(templateType) {
3006
+ const catalog = new TemplateCatalog();
3007
+ for (const tmpl of getTemplatesForDiagramType(templateType)) {
3008
+ catalog.register(tmpl.id, { name: tmpl.name, factory: tmpl.factory });
3009
+ }
3010
+ const changed = this.templateType !== templateType;
3011
+ this.templateType = templateType;
3012
+ this._mountPalette(catalog, {
3013
+ categories: getDiagramTypesByGroup(),
3014
+ category: templateType,
3015
+ onCategoryChange: (key) => this.setPaletteCategory(key),
3016
+ });
3017
+ if (changed) this.onTemplateTypeChange?.(templateType);
3018
+ }
3019
+
3020
+ /**
3021
+ * Initialize the palette from a caller-built catalog (no category picker).
3022
+ * @param {TemplateCatalog} catalog
3023
+ * @param {HTMLElement} paletteContainer
3024
+ * @protected
3025
+ */
3026
+ initPalette(catalog, paletteContainer) {
3027
+ if (typeof window === 'undefined') return;
3028
+ this.paletteContainer = paletteContainer;
3029
+ this._mountPalette(catalog, {});
3030
+ this._acceptPaletteDrops();
3031
+ }
3032
+
3033
+ _mountPalette(catalog, options) {
3034
+ this.templateCatalog = catalog;
3035
+ this.palette = new Palette({ catalog, editor: this, container: this.paletteContainer, ...options });
3036
+ this.palette.render();
3037
+ }
3038
+
3039
+ /** Palette tiles carry their template type; drop them as new shapes. @private */
3040
+ _acceptPaletteDrops() {
3041
+ this._paletteDrop ??= this.addDropHandler({
3042
+ accepts: (dt) => Array.from(dt.types).some(t => t.toLowerCase() === 'templatetype'),
3043
+ drop: (dt, point, event) => this.palette?.handleDrop(event, point.x, point.y),
3044
+ });
3045
+ }
3046
+
3047
+ /**
3048
+ * Accept something dropped onto the canvas. Handlers are tried in order and
3049
+ * the first whose `accepts(dataTransfer)` is true takes the drop; while
3050
+ * dragging only `dataTransfer.types` is readable, the data itself arrives
3051
+ * with `drop(dataTransfer, point, event)`, where `point` is in diagram
3052
+ * coordinates (pan/zoom applied). The canvas is outlined while a drop it
3053
+ * accepts is over it.
3054
+ * @param {{accepts: (dt: DataTransfer) => boolean, drop: (dt: DataTransfer, point: {x: number, y: number}, event: DragEvent) => void}} handler
3055
+ * @returns {() => void} removes the handler
3056
+ */
3057
+ addDropHandler(handler) {
3058
+ (this._dropHandlers ??= []).push(handler);
3059
+ this._enableCanvasDrop();
3060
+ return () => { this._dropHandlers = this._dropHandlers.filter(h => h !== handler); };
3061
+ }
3062
+
3063
+ /**
3064
+ * Accept image files dropped onto (or pasted while focus is inside) the
3065
+ * canvas, calling `onImage(dataURL, point)`. A paste lands at the centre.
3066
+ */
3067
+ enableImageDrop(onImage) {
3068
+ const imageIn = (files) => Array.from(files ?? []).find(file => file.type.startsWith('image/'));
3069
+ const read = (file, point) => {
3070
+ const reader = new FileReader();
3071
+ reader.onload = (e) => onImage(e.target.result, point);
3072
+ reader.readAsDataURL(file);
3073
+ };
3074
+ this.addDropHandler({
3075
+ accepts: (dt) => Array.from(dt.types).includes('Files'),
3076
+ drop: (dt, point) => { const file = imageIn(dt.files); if (file) read(file, point); },
3077
+ });
3078
+ this._listen(document, 'paste', (e) => {
3079
+ const container = this._dropContainer();
3080
+ const file = imageIn(e.clipboardData?.files);
3081
+ if (!container?.contains(e.target) || !file) return;
3082
+ e.preventDefault();
3083
+ const r = container.getBoundingClientRect();
3084
+ read(file, this._clientToDiagram(r.left + r.width / 2, r.top + r.height / 2) ?? { x: r.width / 2, y: r.height / 2 });
3085
+ });
3086
+ }
3087
+
3088
+ /** The element that receives canvas drops: the diagram container. @protected */
3089
+ _dropContainer() {
3090
+ return document.getElementById(this.diagramContainerId);
3091
+ }
3092
+
3093
+ /**
3094
+ * Bind drag-and-drop once per container. Capture phase, so a drop a handler
3095
+ * accepts is claimed before whatever draws inside the container sees it.
3096
+ * @private
3097
+ */
3098
+ _enableCanvasDrop() {
3099
+ if (typeof document === 'undefined') return;
3100
+ const container = this._dropContainer();
3101
+ if (!container || this._dropTarget === container) return;
3102
+ this._dropTarget = container;
3103
+ const handlerFor = (e) => (this._dropHandlers ?? []).find(h => h.accepts(e.dataTransfer));
3104
+ const highlight = (on) => Object.assign(container.style, { outline: on ? '2px dashed #5470c6' : '', outlineOffset: on ? '-2px' : '' });
3105
+
3106
+ container.addEventListener('dragover', (e) => {
3107
+ if (!handlerFor(e)) return;
3108
+ e.preventDefault();
3109
+ e.dataTransfer.dropEffect = 'copy';
3110
+ highlight(true);
3111
+ }, true);
3112
+ container.addEventListener('dragleave', (e) => {
3113
+ if (!container.contains(e.relatedTarget)) highlight(false);
3114
+ }, true);
3115
+ container.addEventListener('drop', (e) => {
3116
+ highlight(false);
3117
+ const handler = handlerFor(e);
3118
+ if (!handler) return;
3119
+ e.preventDefault();
3120
+ e.stopImmediatePropagation();
3121
+ const point = this._clientToDiagram(e.clientX, e.clientY);
3122
+ if (point) handler.drop(e.dataTransfer, point, e);
3123
+ }, true);
3124
+ }
3125
+
3126
+ /**
3127
+ * Decorate a diagram container with a view's background, title and
3128
+ * watermark (see viewDecorations.js for the fields).
3129
+ */
3130
+ decorateView(container, decorations) {
3131
+ if (container) applyViewDecorations(container, decorations);
3132
+ }
3133
+
3134
+ /** A UI option's value (e.g. a toolbar select or toggle), or `fallback` if unset. */
3135
+ getOption(key, fallback) {
3136
+ return this._options?.has(key) ? this._options.get(key) : fallback;
3137
+ }
3138
+
3139
+ setOption(key, value) {
3140
+ (this._options ??= new Map()).set(key, value);
3141
+ }
3142
+
3143
+ /**
3144
+ * Initialize properties panel.
3145
+ * @protected
3146
+ */
3147
+ initPropertiesPanel() {
3148
+ if (typeof window === 'undefined') return;
3149
+
3150
+ if (!PropertiesPanel) return;
3151
+
3152
+ this.propertiesPanel = new PropertiesPanel({
3153
+ editor: this,
3154
+ });
3155
+ this.propertiesPanel.render();
3156
+ }
3157
+
3158
+ /**
3159
+ * Select a template and show in properties panel.
3160
+ * Toggles selection if same template clicked twice.
3161
+ * @param {string} templateId
3162
+ */
3163
+ /**
3164
+ * Select a shape. A plain click selects it alone (or clears it if it was the
3165
+ * only one selected); with `extend` (Shift/Ctrl/⌘+click) it is added to the
3166
+ * selection, or taken out if already in it.
3167
+ * @param {string} templateId
3168
+ * @param {{extend?: boolean}} [options]
3169
+ */
3170
+ selectTemplate(templateId, { extend = false } = {}) {
3171
+ if (extend) {
3172
+ if (this.selectedIds.has(templateId)) {
3173
+ this.selectedIds.delete(templateId);
3174
+ if (this.selectedTemplateId === templateId) this.selectedTemplateId = [...this.selectedIds].at(-1) ?? null;
3175
+ } else {
3176
+ this.selectedIds.add(templateId);
3177
+ this.selectedTemplateId = templateId;
3178
+ }
3179
+ } else if (this.selectedTemplateId === templateId && this.selectedIds.size <= 1) {
3180
+ // Toggle if clicking the same, only, template
3181
+ this.selectedTemplateId = null;
3182
+ this.selectedIds.clear();
3183
+ } else {
3184
+ this.selectedTemplateId = templateId;
3185
+ this.selectedIds = new Set([templateId]);
3186
+ }
3187
+ this.selectedEdgeId = null;
3188
+
3189
+ const template = this.selectedTemplateId ? this.getTemplate(this.selectedTemplateId) : null;
3190
+ if (this.propertiesPanel) {
3191
+ if (template) {
3192
+ this.propertiesPanel.setSelected(template);
3193
+ } else {
3194
+ this.propertiesPanel.render(); // Clear selection
3195
+ }
3196
+ }
3197
+ this.render();
3198
+ this._notifySelection();
3199
+ }
3200
+
3201
+ /**
3202
+ * Get currently selected template.
3203
+ * @returns {Template|null}
3204
+ */
3205
+ getSelectedTemplate() {
3206
+ return this.selectedTemplateId ? this.getTemplate(this.selectedTemplateId) : null;
3207
+ }
3208
+
3209
+ /** Ids of every selected shape still on the diagram, the primary one included. */
3210
+ getSelectedIds() {
3211
+ const ids = new Set(this.selectedIds);
3212
+ if (this.selectedTemplateId) ids.add(this.selectedTemplateId);
3213
+ return [...ids].filter(id => this.getTemplate(id));
3214
+ }
3215
+
3216
+ /**
3217
+ * Called during drag. Default moves template, subclass can override.
3218
+ * Works with template-based diagrams; can also work with model-based diagrams.
3219
+ * @param {string} elementId - Element identifier
3220
+ * @param {number} deltaX - Pixels moved in X since last drag event
3221
+ * @param {number} deltaY - Pixels moved in Y since last drag event
3222
+ */
3223
+ onDragMove(elementId, deltaX, deltaY) {
3224
+ // The shape with whatever moves with it (its selection and groups).
3225
+ const ids = this._dragState?.movers ?? [elementId];
3226
+ let moved = false;
3227
+ for (const id of ids) {
3228
+ const template = this.getTemplate(id);
3229
+ if (!template) continue;
3230
+ template.x += deltaX;
3231
+ template.y += deltaY;
3232
+ moved = true;
3233
+ }
3234
+ if (moved) this.render();
3235
+ }
3236
+
3237
+ /**
3238
+ * Called at end of drag. Default creates MoveElementCommand.
3239
+ * Works with template-based diagrams; can also work with model-based diagrams.
3240
+ */
3241
+ onDragEnd(elementId, deltaX, deltaY) {
3242
+ if (!MoveElementCommand) return;
3243
+
3244
+ // Try template-based first (template diagram pattern)
3245
+ const ids = (this._dragState?.movers ?? [elementId]).filter(id => this.getTemplate(id));
3246
+ if (!ids.length || !this.history) return;
3247
+ // Where they were dropped: into a shape that holds them, or out of their parent.
3248
+ const nesting = this.nestingChanges(ids);
3249
+ // Put them back, then move them all (and nest them) as one undo step.
3250
+ for (const id of ids) {
3251
+ const t = this.getTemplate(id);
3252
+ t.x -= deltaX;
3253
+ t.y -= deltaY;
3254
+ }
3255
+ const steps = [
3256
+ ...ids.map(id => new MoveElementCommand(this, id, deltaX, deltaY)),
3257
+ ...nesting.map(({ childId, parentId }) => this.nestCommand(childId, parentId)).filter(Boolean),
3258
+ ];
3259
+ this.history.execute(steps.length === 1 ? steps[0] : new CompositeCommand('Move', steps));
3260
+ this.render();
3261
+ this._log(`Moved ${ids.length === 1 ? this.getTemplate(ids[0]).name : `${ids.length} shapes`}`);
3262
+ if (nesting.length) this.nested(nesting);
3263
+ }
3264
+
3265
+ // ===== Nesting (a shape drawn inside another) =====
3266
+
3267
+ /**
3268
+ * After a move, as the shapes lie now: each moved shape that is not moving
3269
+ * with its parent, whose parent changes. Its new parent is the innermost
3270
+ * shape not moved that wholly holds its box and `canHold` it; none (null)
3271
+ * takes it out of the one it was in.
3272
+ * @param {Array<string>} movedIds
3273
+ * @returns {Array<{childId: string, parentId: (string|null), previousId: (string|null)}>}
3274
+ */
3275
+ nestingChanges(movedIds) {
3276
+ const moved = new Set(movedIds);
3277
+ const box = (t) => ({ left: t.x - t.width / 2, top: t.y - t.height / 2, right: t.x + t.width / 2, bottom: t.y + t.height / 2 });
3278
+ const holds = (outer, inner) => outer.left <= inner.left && outer.top <= inner.top && outer.right >= inner.right && outer.bottom >= inner.bottom;
3279
+ const changes = [];
3280
+ for (const childId of movedIds) {
3281
+ const previousId = this.parentOf(childId);
3282
+ if (previousId && moved.has(previousId)) continue; // it goes with its parent
3283
+ const child = this.getTemplate(childId);
3284
+ if (!child) continue;
3285
+ const inner = box(child);
3286
+ const parent = [...this.templates.values()]
3287
+ .filter(t => !moved.has(t.id) && t.visible !== false && this.canHold(t.id, childId) && holds(box(t), inner))
3288
+ .sort((a, b) => a.width * a.height - b.width * b.height)[0];
3289
+ const parentId = parent?.id ?? null;
3290
+ if (parentId !== previousId) changes.push({ childId, parentId, previousId });
3291
+ }
3292
+ return changes;
3293
+ }
3294
+
3295
+ /** The shape this one is drawn inside (nested in), or null. None here: a subclass with nesting says. */
3296
+ parentOf(templateId) {
3297
+ return null;
3298
+ }
3299
+
3300
+ /** Whether a shape can be nested in another. Never here; a subclass with nesting says when. */
3301
+ canHold(parentId, childId) {
3302
+ return false;
3303
+ }
3304
+
3305
+ /** The command that nests a shape in another (or, with null, takes it out); null when it can't. */
3306
+ nestCommand(childId, parentId) {
3307
+ return null;
3308
+ }
3309
+
3310
+ /** After a move nested shapes or took them out (nestingChanges' list): a subclass's follow-up. None here. */
3311
+ nested(changes) {}
3312
+
3313
+ /**
3314
+ * Show properties dialog. Opens edit modal if template exists.
3315
+ */
3316
+ showPropertiesDialog(templateId) {
3317
+ const template = this.getTemplate(templateId);
3318
+ if (!template || typeof document === 'undefined') return;
3319
+ if (this.selectedIds.has(templateId) && this.selectedIds.size > 1) { this.editSelectionProperties(); return; }
3320
+ this.selectTemplate(templateId);
3321
+ this.editTemplateProperties(template);
3322
+ }
3323
+
3324
+ }
3325
+
3326
+ /** Stacking-order operations offered in a shape's Order submenu: [operation, label]. */
3327
+ DiagramEditor.ORDER_OPERATIONS = Object.freeze([
3328
+ ['front', 'Bring to Front'], ['back', 'Send to Back'], ['raise', 'Raise'], ['lower', 'Lower'],
3329
+ ]);
3330
+ DiagramEditor.ORDER_MENU_LABEL = '⬍ Order';