@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.
- package/LICENSE +21 -0
- package/README.md +203 -2
- package/REFERENCE.md +2198 -0
- package/dist/renderengine-workspace.umd.js +46905 -0
- package/dist/renderengine.umd.js +42223 -0
- package/examples/archimate.html +66 -0
- package/examples/diagram-editor.html +51 -0
- package/package.json +65 -4
- package/src/BaseRenderer.js +57 -0
- package/src/CanvasRenderer.js +194 -0
- package/src/Mat4.js +111 -0
- package/src/OrbitCamera.js +57 -0
- package/src/RendererFactory.js +50 -0
- package/src/SVGRenderer.js +439 -0
- package/src/archimate.js +723 -0
- package/src/archimateArchiFormat.js +1693 -0
- package/src/archimateArchiTypes.js +72 -0
- package/src/archimateArchive.js +140 -0
- package/src/archimateCSV.js +127 -0
- package/src/archimateDrawio.js +182 -0
- package/src/archimateExchangeFormat.js +546 -0
- package/src/archimateRelationshipTable.js +93 -0
- package/src/archimateSpecializations.js +161 -0
- package/src/chartCatalog.js +589 -0
- package/src/charting/ArchimateDiagramEditor.js +1624 -0
- package/src/charting/ArchimateSpecializationSelector.js +134 -0
- package/src/charting/Axis.js +68 -0
- package/src/charting/CameraSeeder.js +30 -0
- package/src/charting/CatalogDialog.js +92 -0
- package/src/charting/Chart.js +37 -0
- package/src/charting/Chart3D.js +99 -0
- package/src/charting/ChartBox.js +80 -0
- package/src/charting/ChartData.js +27 -0
- package/src/charting/ChartRegistry.js +76 -0
- package/src/charting/CheckboxTreeSelector.js +304 -0
- package/src/charting/ColorField.js +107 -0
- package/src/charting/ColorPalette.js +88 -0
- package/src/charting/CommandHistory.js +176 -0
- package/src/charting/DataFilter.js +52 -0
- package/src/charting/Diagram.js +36 -0
- package/src/charting/Diagram3D.js +29 -0
- package/src/charting/DiagramClipboard.js +31 -0
- package/src/charting/DiagramEditor.js +3330 -0
- package/src/charting/DiagramEditorCommand.js +110 -0
- package/src/charting/DiagramEditorCommands.js +237 -0
- package/src/charting/DiagramEditorEdgeCommands.js +142 -0
- package/src/charting/DiagramEditorState.js +98 -0
- package/src/charting/DiagramRegistry.js +86 -0
- package/src/charting/DiagramTypeRegistry.js +326 -0
- package/src/charting/DiagramViewPropertiesDialog.js +46 -0
- package/src/charting/Edge.js +113 -0
- package/src/charting/ErrorBars.js +37 -0
- package/src/charting/FilterSystem.js +285 -0
- package/src/charting/FindReplaceDialog.js +139 -0
- package/src/charting/FormDialog.js +385 -0
- package/src/charting/Gauge.js +58 -0
- package/src/charting/Hierarchy.js +175 -0
- package/src/charting/HintPanel.js +86 -0
- package/src/charting/ImageSelector.js +148 -0
- package/src/charting/InteractionManager.js +725 -0
- package/src/charting/Legend.js +58 -0
- package/src/charting/MenuBar.js +62 -0
- package/src/charting/ModelFileFormat.js +115 -0
- package/src/charting/OrbitControls.js +77 -0
- package/src/charting/Palette.js +119 -0
- package/src/charting/PopupMenu.js +117 -0
- package/src/charting/PropertiesPanel.js +155 -0
- package/src/charting/PropertyListEditor.js +96 -0
- package/src/charting/PropertyLog.js +92 -0
- package/src/charting/SettingsCascade.js +93 -0
- package/src/charting/SettingsDialog.js +53 -0
- package/src/charting/SettingsManager.js +179 -0
- package/src/charting/SettingsViewPropertiesDialog.js +58 -0
- package/src/charting/ShapePaths.js +27 -0
- package/src/charting/Statistics.js +134 -0
- package/src/charting/TemplateCatalog.js +59 -0
- package/src/charting/TemplateLibrary.js +103 -0
- package/src/charting/TextStyle.js +168 -0
- package/src/charting/ViewFit.js +55 -0
- package/src/charting/ViewMenu.js +70 -0
- package/src/charting/ViewPropertiesDialog.js +77 -0
- package/src/charting/ViewTabStrip.js +81 -0
- package/src/charting/archimate/ArchiFileFormat.js +32 -0
- package/src/charting/archimate/ArchimateCanvasEditor.js +815 -0
- package/src/charting/archimate/ArchimateCsvFileFormat.js +35 -0
- package/src/charting/archimate/ArchimateEdge.js +137 -0
- package/src/charting/archimate/ArchimateElementTemplate.js +195 -0
- package/src/charting/archimate/ArchimatePalette.js +102 -0
- package/src/charting/archimate/CsvImportDialog.js +105 -0
- package/src/charting/archimate/CustomViewpoints.js +74 -0
- package/src/charting/archimate/DerivedEdge.js +29 -0
- package/src/charting/archimate/DrawioFileFormat.js +24 -0
- package/src/charting/archimate/ElementFilterRule.js +21 -0
- package/src/charting/archimate/ExchangeFileFormat.js +28 -0
- package/src/charting/archimate/ViewGroupTemplate.js +137 -0
- package/src/charting/archimate/ViewLineEdge.js +84 -0
- package/src/charting/archimate/ViewpointRule.js +45 -0
- package/src/charting/archimate/archimateFileFormats.js +12 -0
- package/src/charting/archimate/archimateNames.js +5 -0
- package/src/charting/archimate/csvMapping.js +122 -0
- package/src/charting/archimate/elementMenuItems.js +32 -0
- package/src/charting/archimate/elementProperties.js +146 -0
- package/src/charting/archimate/elementTypeChoices.js +7 -0
- package/src/charting/archimate/elementTypeGroups.js +19 -0
- package/src/charting/archimate/index.js +17 -0
- package/src/charting/archimate/specializationCatalogDialog.js +64 -0
- package/src/charting/archimate/typeGlyph.js +29 -0
- package/src/charting/arrangement/Arrangement.js +112 -0
- package/src/charting/arrangement/ArrangementCommand.js +29 -0
- package/src/charting/arrangement/BackgroundCommand.js +41 -0
- package/src/charting/arrangement/BackgroundGroup.js +18 -0
- package/src/charting/arrangement/GroupCommand.js +46 -0
- package/src/charting/arrangement/ItemGroup.js +62 -0
- package/src/charting/arrangement/LockCommand.js +32 -0
- package/src/charting/arrangement/UngroupCommand.js +27 -0
- package/src/charting/arrangement/alignment.js +88 -0
- package/src/charting/arrangement/arrangeMenuItems.js +25 -0
- package/src/charting/arrangement/index.js +11 -0
- package/src/charting/arrangement/layouts.js +233 -0
- package/src/charting/canvas/ArchiHint.js +78 -0
- package/src/charting/canvas/CanvasBoxKind.js +39 -0
- package/src/charting/canvas/CanvasPalette.js +49 -0
- package/src/charting/canvas/CanvasTemplateLibrary.js +61 -0
- package/src/charting/canvas/CanvasTemplateMenu.js +151 -0
- package/src/charting/canvas/EditHintCommand.js +28 -0
- package/src/charting/canvas/canvasBoxKinds.js +27 -0
- package/src/charting/canvas/canvasBoxMenuItems.js +40 -0
- package/src/charting/canvas/defaultCanvasTemplates.js +186 -0
- package/src/charting/canvas/diagramBoxKinds.js +12 -0
- package/src/charting/canvas/hintMenuItems.js +49 -0
- package/src/charting/charts/ArcGauge.js +55 -0
- package/src/charting/charts/AreaChart.js +71 -0
- package/src/charting/charts/BarChart.js +80 -0
- package/src/charting/charts/BarChart3D.js +56 -0
- package/src/charting/charts/BarChart3DOrbit.js +53 -0
- package/src/charting/charts/BarChartBase.js +70 -0
- package/src/charting/charts/BarGauge.js +42 -0
- package/src/charting/charts/BoxPlotChart.js +41 -0
- package/src/charting/charts/BubbleChart.js +99 -0
- package/src/charting/charts/BubbleChart3D.js +78 -0
- package/src/charting/charts/BulletGauge.js +87 -0
- package/src/charting/charts/CalendarHeatmapChart.js +204 -0
- package/src/charting/charts/CandlestickChart.js +54 -0
- package/src/charting/charts/ChordDiagram.js +130 -0
- package/src/charting/charts/DensityChart.js +73 -0
- package/src/charting/charts/DialGauge.js +65 -0
- package/src/charting/charts/DonutChart.js +15 -0
- package/src/charting/charts/FunnelChart.js +47 -0
- package/src/charting/charts/GanttChart.js +201 -0
- package/src/charting/charts/GaugeChart.js +175 -0
- package/src/charting/charts/GeoMapChart.js +146 -0
- package/src/charting/charts/GroupedBarChart.js +88 -0
- package/src/charting/charts/GroupedDistributionChart.js +70 -0
- package/src/charting/charts/HeatmapChart.js +80 -0
- package/src/charting/charts/HierarchyChart.js +39 -0
- package/src/charting/charts/HistogramChart.js +45 -0
- package/src/charting/charts/KpiTile.js +72 -0
- package/src/charting/charts/LagPlotChart.js +41 -0
- package/src/charting/charts/LineChart.js +124 -0
- package/src/charting/charts/MultiRingGauge.js +54 -0
- package/src/charting/charts/OutlineChart.js +95 -0
- package/src/charting/charts/ParallelCoordinatesChart.js +130 -0
- package/src/charting/charts/PieChart.js +56 -0
- package/src/charting/charts/PipeChart.js +88 -0
- package/src/charting/charts/PopulationPyramidChart.js +106 -0
- package/src/charting/charts/QQPlotChart.js +64 -0
- package/src/charting/charts/RadarChart.js +76 -0
- package/src/charting/charts/RadialBarChart.js +96 -0
- package/src/charting/charts/RingGauge.js +23 -0
- package/src/charting/charts/SankeyDiagram.js +174 -0
- package/src/charting/charts/ScatterChart.js +104 -0
- package/src/charting/charts/StackedAreaChart.js +15 -0
- package/src/charting/charts/StackedBarChart.js +33 -0
- package/src/charting/charts/StackedBarChart3DOrbit.js +58 -0
- package/src/charting/charts/StreamGraphChart.js +131 -0
- package/src/charting/charts/SunburstChart.js +57 -0
- package/src/charting/charts/ThermometerGauge.js +43 -0
- package/src/charting/charts/TreemapChart.js +54 -0
- package/src/charting/charts/ViolinChart.js +37 -0
- package/src/charting/charts/WaterfallChart.js +66 -0
- package/src/charting/charts/WordCloudChart.js +104 -0
- package/src/charting/diagramModel/AddViewCommand.js +33 -0
- package/src/charting/diagramModel/DiagramModel.js +39 -0
- package/src/charting/diagramModel/DiagramModelSearch.js +22 -0
- package/src/charting/diagramModel/DiagramView.js +50 -0
- package/src/charting/diagramModel/ModelHistory.js +43 -0
- package/src/charting/diagramModel/ModelReport.js +153 -0
- package/src/charting/diagramModel/ModelSearch.js +223 -0
- package/src/charting/diagramModel/RemoveViewCommand.js +25 -0
- package/src/charting/diagramModel/RenameViewCommand.js +21 -0
- package/src/charting/diagramModel/ReplaceTextCommand.js +25 -0
- package/src/charting/diagramModel/ViewSetChecker.js +34 -0
- package/src/charting/diagramModel/ViewSetModel.js +243 -0
- package/src/charting/diagramModel/index.js +6 -0
- package/src/charting/diagramModel/nameTransforms.js +29 -0
- package/src/charting/diagrams/ArchimateCommands.js +141 -0
- package/src/charting/diagrams/ArchimateDiagram.js +263 -0
- package/src/charting/diagrams/ArchimateModel.js +1155 -0
- package/src/charting/diagrams/ArchimateModelChecker.js +125 -0
- package/src/charting/diagrams/ArchimateModelReport.js +128 -0
- package/src/charting/diagrams/ArchimateModelSearch.js +41 -0
- package/src/charting/diagrams/ArchimateValidator.js +357 -0
- package/src/charting/diagrams/CanvasView.js +112 -0
- package/src/charting/diagrams/ContextMenu.js +37 -0
- package/src/charting/diagrams/DataModelDiagram.js +86 -0
- package/src/charting/diagrams/DataModelRelationshipStrategy.js +38 -0
- package/src/charting/diagrams/DerivedRelationships.js +102 -0
- package/src/charting/diagrams/EbnfDiagram.js +39 -0
- package/src/charting/diagrams/EdgeGeometry.js +553 -0
- package/src/charting/diagrams/EditViewItemCommand.js +128 -0
- package/src/charting/diagrams/ElementInstance.js +157 -0
- package/src/charting/diagrams/GraphDiagram.js +1340 -0
- package/src/charting/diagrams/GraphDiagram3D.js +152 -0
- package/src/charting/diagrams/GraphMetrics.js +81 -0
- package/src/charting/diagrams/GridOverlay.js +137 -0
- package/src/charting/diagrams/ImportRowsCommand.js +62 -0
- package/src/charting/diagrams/LabelHelper.js +108 -0
- package/src/charting/diagrams/MergeElementsCommand.js +101 -0
- package/src/charting/diagrams/NestInstanceCommand.js +34 -0
- package/src/charting/diagrams/NodeGeometry.js +158 -0
- package/src/charting/diagrams/PlaceItemsCommand.js +77 -0
- package/src/charting/diagrams/RadialLayout.js +101 -0
- package/src/charting/diagrams/RelationshipInstance.js +93 -0
- package/src/charting/diagrams/RemoveGroupFromViewCommand.js +28 -0
- package/src/charting/diagrams/RemoveLineCommand.js +27 -0
- package/src/charting/diagrams/ShowElementCommand.js +43 -0
- package/src/charting/diagrams/SvgCoordinate.js +17 -0
- package/src/charting/diagrams/SvgZoom.js +127 -0
- package/src/charting/diagrams/Tooltip.js +62 -0
- package/src/charting/diagrams/ViewConnectionInstance.js +102 -0
- package/src/charting/diagrams/ViewObjectInstance.js +56 -0
- package/src/charting/diagrams/commands/AddEdgeCommand.js +34 -0
- package/src/charting/diagrams/commands/AddNodeCommand.js +28 -0
- package/src/charting/diagrams/commands/AddWaypointCommand.js +27 -0
- package/src/charting/diagrams/commands/CreateFolderCommand.js +27 -0
- package/src/charting/diagrams/commands/DeleteEdgeCommand.js +30 -0
- package/src/charting/diagrams/commands/DeleteFolderCommand.js +47 -0
- package/src/charting/diagrams/commands/DeleteNodeCommand.js +35 -0
- package/src/charting/diagrams/commands/DeleteWaypointCommand.js +24 -0
- package/src/charting/diagrams/commands/GraphCommand.js +65 -0
- package/src/charting/diagrams/commands/InvertEdgeCommand.js +24 -0
- package/src/charting/diagrams/commands/MoveNodeCommand.js +44 -0
- package/src/charting/diagrams/commands/MoveWaypointCommand.js +27 -0
- package/src/charting/diagrams/commands/RenameEdgeCommand.js +23 -0
- package/src/charting/diagrams/commands/RenameFolderCommand.js +31 -0
- package/src/charting/diagrams/commands/RenameNodeCommand.js +27 -0
- package/src/charting/diagrams/commands/ResizeNodeCommand.js +46 -0
- package/src/charting/diagrams/commands/UpdateEdgeSideCommand.js +40 -0
- package/src/charting/diagrams/commands/index.js +17 -0
- package/src/charting/diagrams/duplicates.js +49 -0
- package/src/charting/diagrams/nesting.js +30 -0
- package/src/charting/diagrams/roadmap.js +77 -0
- package/src/charting/edgeMarkers/ArrowMarker.js +15 -0
- package/src/charting/edgeMarkers/CircleMarker.js +13 -0
- package/src/charting/edgeMarkers/CrowsFootMarker.js +16 -0
- package/src/charting/edgeMarkers/DiamondMarker.js +15 -0
- package/src/charting/edgeMarkers/EdgeMarker.js +34 -0
- package/src/charting/edgeMarkers/HalfArrowMarker.js +8 -0
- package/src/charting/edgeMarkers/HollowDiamondMarker.js +18 -0
- package/src/charting/edgeMarkers/HollowTriangleMarker.js +18 -0
- package/src/charting/edgeMarkers/OpenArrowMarker.js +17 -0
- package/src/charting/edgeMarkers/index.js +27 -0
- package/src/charting/edgeStyle/EdgeStyleCascade.js +22 -0
- package/src/charting/edgeStyle/EdgeStyleDialog.js +51 -0
- package/src/charting/edgeStyle/EdgeStyleSchema.js +43 -0
- package/src/charting/edgeStyle/SetStyleCommand.js +12 -0
- package/src/charting/edgeStyle/index.js +4 -0
- package/src/charting/emphasis/EmphasisRule.js +42 -0
- package/src/charting/emphasis/HighlightRule.js +24 -0
- package/src/charting/gauges/ArcTrack.js +51 -0
- package/src/charting/gauges/GaugeAnimation.js +51 -0
- package/src/charting/gauges/GaugeBands.js +48 -0
- package/src/charting/gauges/GaugeScale.js +87 -0
- package/src/charting/gauges/GaugeTrack.js +44 -0
- package/src/charting/gauges/GaugeValue.js +61 -0
- package/src/charting/gauges/HorizontalTrack.js +14 -0
- package/src/charting/gauges/LinearTrack.js +29 -0
- package/src/charting/gauges/Sparkline.js +42 -0
- package/src/charting/gauges/VerticalTrack.js +17 -0
- package/src/charting/glyphs/ArrowGlyph.js +21 -0
- package/src/charting/glyphs/BatteryGlyph.js +18 -0
- package/src/charting/glyphs/ChevronGlyph.js +18 -0
- package/src/charting/glyphs/CircleGlyph.js +15 -0
- package/src/charting/glyphs/CloudGlyph.js +19 -0
- package/src/charting/glyphs/ComponentGlyph.js +18 -0
- package/src/charting/glyphs/ConstraintGlyph.js +24 -0
- package/src/charting/glyphs/ContractBoxGlyph.js +10 -0
- package/src/charting/glyphs/CubeGlyph.js +19 -0
- package/src/charting/glyphs/CylinderGlyph.js +19 -0
- package/src/charting/glyphs/DashedDoubleArrowGlyph.js +11 -0
- package/src/charting/glyphs/DeviceGlyph.js +17 -0
- package/src/charting/glyphs/DiamondGlyph.js +17 -0
- package/src/charting/glyphs/DocumentGlyph.js +17 -0
- package/src/charting/glyphs/DoubleArrowGlyph.js +23 -0
- package/src/charting/glyphs/DoubleCircleGlyph.js +24 -0
- package/src/charting/glyphs/EllipseGlyph.js +18 -0
- package/src/charting/glyphs/FactoryGlyph.js +20 -0
- package/src/charting/glyphs/FlagGlyph.js +16 -0
- package/src/charting/glyphs/GapCircleGlyph.js +17 -0
- package/src/charting/glyphs/GearGlyph.js +37 -0
- package/src/charting/glyphs/Glyph.js +98 -0
- package/src/charting/glyphs/GroupingBoxGlyph.js +13 -0
- package/src/charting/glyphs/HalfCircleGlyph.js +22 -0
- package/src/charting/glyphs/HexagonGlyph.js +28 -0
- package/src/charting/glyphs/IconGlyph.js +22 -0
- package/src/charting/glyphs/ImageGlyph.js +16 -0
- package/src/charting/glyphs/JunctionGlyph.js +17 -0
- package/src/charting/glyphs/LollipopGlyph.js +22 -0
- package/src/charting/glyphs/MagnifierGlyph.js +17 -0
- package/src/charting/glyphs/NoteGlyph.js +33 -0
- package/src/charting/glyphs/ObjectBoxGlyph.js +21 -0
- package/src/charting/glyphs/OrJunctionGlyph.js +17 -0
- package/src/charting/glyphs/ParallelogramGlyph.js +20 -0
- package/src/charting/glyphs/PersonGlyph.js +23 -0
- package/src/charting/glyphs/PillGlyph.js +23 -0
- package/src/charting/glyphs/PinGlyph.js +18 -0
- package/src/charting/glyphs/PrincipleGlyph.js +23 -0
- package/src/charting/glyphs/ProductBoxGlyph.js +16 -0
- package/src/charting/glyphs/RectGlyph.js +15 -0
- package/src/charting/glyphs/RoundedBoxGlyph.js +13 -0
- package/src/charting/glyphs/StackGlyph.js +14 -0
- package/src/charting/glyphs/StepsGlyph.js +20 -0
- package/src/charting/glyphs/StickFigureGlyph.js +32 -0
- package/src/charting/glyphs/SystemSoftwareBoxGlyph.js +16 -0
- package/src/charting/glyphs/TabbedBoxGlyph.js +37 -0
- package/src/charting/glyphs/TargetGlyph.js +23 -0
- package/src/charting/glyphs/WavyBoxGlyph.js +17 -0
- package/src/charting/glyphs/WheelGlyph.js +28 -0
- package/src/charting/glyphs/drawGlyph.js +28 -0
- package/src/charting/glyphs/glyphSvg.js +55 -0
- package/src/charting/glyphs/index.js +109 -0
- package/src/charting/modelTree/AnalysisPanel.js +159 -0
- package/src/charting/modelTree/DiagramTabs.js +217 -0
- package/src/charting/modelTree/ModelTree.js +164 -0
- package/src/charting/modelTree/ModelTreePanel.js +914 -0
- package/src/charting/modelTree/TreeBuilder.js +238 -0
- package/src/charting/modelTree/TreeModeRegistry.js +51 -0
- package/src/charting/modelTree/TreeViewMode.js +110 -0
- package/src/charting/modelTree/buildFilterTrees.js +138 -0
- package/src/charting/modelTree/modes/FlatTreeMode.js +46 -0
- package/src/charting/modelTree/modes/LayerGroupedTreeMode.js +62 -0
- package/src/charting/modelTree/modes/TypeGroupedTreeMode.js +60 -0
- package/src/charting/modelTree/treeLayers.js +11 -0
- package/src/charting/report/reportHtml.js +82 -0
- package/src/charting/report/reportViewer.js +118 -0
- package/src/charting/shapes/CircleShape.js +19 -0
- package/src/charting/shapes/PathShape.js +17 -0
- package/src/charting/shapes/RectShape.js +20 -0
- package/src/charting/shapes/Shape.js +20 -0
- package/src/charting/shapes/ShapeFactory.js +23 -0
- package/src/charting/storage/LocalJsonStore.js +43 -0
- package/src/charting/templates/ActorTemplate.js +36 -0
- package/src/charting/templates/BoxTemplate.js +27 -0
- package/src/charting/templates/CircleTemplate.js +18 -0
- package/src/charting/templates/ClassTemplate.js +20 -0
- package/src/charting/templates/CloudTemplate.js +43 -0
- package/src/charting/templates/ComponentTemplate.js +19 -0
- package/src/charting/templates/CylinderTemplate.js +30 -0
- package/src/charting/templates/DataStoreTemplate.js +16 -0
- package/src/charting/templates/DiamondTemplate.js +10 -0
- package/src/charting/templates/DocumentTemplate.js +32 -0
- package/src/charting/templates/EllipseTemplate.js +15 -0
- package/src/charting/templates/FinalStateTemplate.js +12 -0
- package/src/charting/templates/GlyphTemplate.js +47 -0
- package/src/charting/templates/HexagonTemplate.js +17 -0
- package/src/charting/templates/LifelineTemplate.js +20 -0
- package/src/charting/templates/ManualInputTemplate.js +12 -0
- package/src/charting/templates/NoteTemplate.js +23 -0
- package/src/charting/templates/OctagonTemplate.js +7 -0
- package/src/charting/templates/PackageTemplate.js +25 -0
- package/src/charting/templates/ParallelogramTemplate.js +11 -0
- package/src/charting/templates/PentagonTemplate.js +5 -0
- package/src/charting/templates/PolygonTemplate.js +18 -0
- package/src/charting/templates/PredefinedProcessTemplate.js +13 -0
- package/src/charting/templates/RectangleTemplate.js +28 -0
- package/src/charting/templates/RegularPolygonTemplate.js +21 -0
- package/src/charting/templates/RoundedRectangleTemplate.js +5 -0
- package/src/charting/templates/StadiumTemplate.js +8 -0
- package/src/charting/templates/StarTemplate.js +17 -0
- package/src/charting/templates/SwimlaneTemplate.js +18 -0
- package/src/charting/templates/Template.js +208 -0
- package/src/charting/templates/TrapezoidTemplate.js +11 -0
- package/src/charting/templates/TriangleTemplate.js +12 -0
- package/src/charting/templates/index.js +31 -0
- package/src/charting/templates/outline.js +59 -0
- package/src/charting/templates/svg.js +19 -0
- package/src/charting/textToShapes.js +61 -0
- package/src/charting/toolbar/ToolbarButton.js +38 -0
- package/src/charting/toolbar/ToolbarCheckbox.js +31 -0
- package/src/charting/toolbar/ToolbarItem.js +19 -0
- package/src/charting/toolbar/ToolbarMenu.js +36 -0
- package/src/charting/toolbar/ToolbarSelect.js +38 -0
- package/src/charting/toolbar/ToolbarSeparator.js +7 -0
- package/src/charting/toolbar/ToolbarText.js +22 -0
- package/src/charting/toolbar/index.js +8 -0
- package/src/charting/toolbar/pickFile.js +14 -0
- package/src/charting/viewDecorations.js +85 -0
- package/src/charting/zOrder.js +57 -0
- package/src/csvTable.js +61 -0
- package/src/datamodel.js +122 -0
- package/src/datawindow/BoundedWindow.js +36 -0
- package/src/datawindow/FlatDataWindow.js +34 -0
- package/src/datawindow/GraphDataWindow.js +40 -0
- package/src/datawindow/GroupedSampleDataWindow.js +16 -0
- package/src/datawindow/SeriesDataWindow.js +42 -0
- package/src/datawindow/index.js +31 -0
- package/src/diagramCatalog.js +30 -0
- package/src/ebnfDiagram.js +57 -0
- package/src/ebnfParser.js +129 -0
- package/src/exportImage.js +233 -0
- package/src/index.js +2636 -0
- package/src/itIcons.js +84 -0
- package/src/kernelExpr.js +243 -0
- package/src/propertiesDialog.js +463 -0
- package/src/railroadDiagram.js +571 -0
- package/src/treeModeCatalog.js +16 -0
- package/src/workspace/ContextBus.js +79 -0
- package/src/workspace/EditorShell.js +67 -0
- package/src/workspace/Part.js +163 -0
- package/src/workspace/PartFactory.js +49 -0
- package/src/workspace/PartRegistry.js +86 -0
- package/src/workspace/Workspace.js +321 -0
- package/src/workspace/archimateServices.js +33 -0
- package/src/workspace/dom.js +120 -0
- package/src/workspace/index.js +75 -0
- package/src/workspace/layout/GridNode.js +280 -0
- package/src/workspace/layout/LayoutNode.js +89 -0
- package/src/workspace/layout/PartNode.js +77 -0
- package/src/workspace/layout/SplitNode.js +114 -0
- package/src/workspace/layout/TabsNode.js +191 -0
- package/src/workspace/layout/index.js +24 -0
- package/src/workspace/parts/AdapterPart.js +33 -0
- package/src/workspace/parts/ArchimateCanvasPart.js +345 -0
- package/src/workspace/parts/ArchimateEditorPart.js +79 -0
- package/src/workspace/parts/ChartPart.js +115 -0
- package/src/workspace/parts/ContentPart.js +34 -0
- package/src/workspace/parts/DetailPart.js +46 -0
- package/src/workspace/parts/DiagramEditorPart.js +135 -0
- package/src/workspace/parts/EditorPart.js +388 -0
- package/src/workspace/parts/HtmlPart.js +25 -0
- package/src/workspace/parts/ListPart.js +57 -0
- package/src/workspace/parts/ModelFile.js +187 -0
- package/src/workspace/parts/ModelsPart.js +135 -0
- package/src/workspace/styles.js +258 -0
- package/src/workspace/themes.js +96 -0
- package/src/workspace/workArea.js +413 -0
- package/src/workspace/workAreaModel.js +197 -0
- package/src/workspace.js +16 -0
- package/src/zip/ZipArchive.js +160 -0
- package/src/zip/crc32.js +25 -0
- 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';
|