diff --git a/Documentation.md b/Documentation.md index f10a83a..8863ff8 100644 --- a/Documentation.md +++ b/Documentation.md @@ -234,13 +234,11 @@ This editor currently has three different modes: - `edit`: The default mode, allows to view and edit the diagram. Creation of new elements is possible. Existing elements can be moved, modified, and deleted. Newly created diagrams are always in this mode. -- `annotated`: In this mode the diagram is read-only. The node annotations (refer to the DFD elements module) +- `view`: In this mode the diagram is read-only. The node annotations (refer to the DFD elements module) are displayed and can be viewed to get information about e.g. analysis validation errors. The user can still zoom and pan the diagram. Creation, deletion, and modification of elements is not possible. However the user can click a button to switch to the `edit` mode. Doing so will remove all node annotations and allow the user to edit the diagram again. -- `readonly`: This mode is similar to the `annotated` mode but does not allow switching back to the `edit` mode. - It is intended to be used when the diagram is from a generated source and should only be viewed. Diagrams with modes other than `edit` are not creatable using the editor. Diagrams with these modes are intended to be generated from some other source. @@ -249,7 +247,7 @@ This module contains the `EditorModeController` which manages the global editor All other modules that want to behave differently depending on the editor mode use this as a source of truth and subscribe to changes of the editor mode. Additionally, this module contains a UI that shows when the editor mode is not `edit` -and allows switching from `annotated` to `edit` mode. +and allows switching from `view` to `edit` mode. ### (DFD) Label diff --git a/src/common/commandPalette.ts b/src/common/commandPalette.ts index 3478f44..2f34f39 100644 --- a/src/common/commandPalette.ts +++ b/src/common/commandPalette.ts @@ -1,4 +1,4 @@ -import { injectable } from "inversify"; +import { inject, injectable } from "inversify"; import { ICommandPaletteActionProvider, LabeledAction, SModelRootImpl, CommitModelAction } from "sprotty"; import { Point } from "sprotty-protocol"; import { LoadDiagramAction } from "../features/serialize/load"; @@ -13,12 +13,15 @@ import "./commandPalette.css"; import { SaveDFDandDDAction } from "../features/serialize/saveDFDandDD"; import { LoadDFDandDDAction } from "../features/serialize/loadDFDandDD"; import { LoadPalladioAction } from "../features/serialize/loadPalladio"; +import { SettingsManager } from "../features/settingsMenu/SettingsManager"; /** * Provides possible actions for the command palette. */ @injectable() export class ServerCommandPaletteActionProvider implements ICommandPaletteActionProvider { + constructor(@inject(SettingsManager) protected readonly settings: SettingsManager) {} + async getActions( root: Readonly, _text: string, @@ -37,7 +40,7 @@ export class ServerCommandPaletteActionProvider implements ICommandPaletteAction new LabeledAction("Load default diagram", [LoadDefaultDiagramAction.create(), commitAction], "clear-all"), new LabeledAction("Fit to Screen", [fitToScreenAction], "layout"), new LabeledAction( - "Layout diagram", + "Layout diagram (Method: " + this.settings.layoutMethod + ")", [LayoutModelAction.create(), commitAction, fitToScreenAction], "layout", ), diff --git a/src/common/di.config.ts b/src/common/di.config.ts index bf6d548..c99d220 100644 --- a/src/common/di.config.ts +++ b/src/common/di.config.ts @@ -19,7 +19,6 @@ import { FitToScreenKeyListener as CenterDiagramKeyListener } from "./fitToScree import { DiagramModificationCommandStack } from "./customCommandStack"; import "./commonStyling.css"; -import { LightDarkSwitch } from "./lightDarkSwitch"; export const commonModule = new ContainerModule((bind, unbind, isBound, rebind) => { bind(ServerCommandPaletteActionProvider).toSelf().inSingletonScope(); @@ -34,10 +33,6 @@ export const commonModule = new ContainerModule((bind, unbind, isBound, rebind) bind(TYPES.IUIExtension).toService(HelpUI); bind(EDITOR_TYPES.DefaultUIElement).toService(HelpUI); - bind(LightDarkSwitch).toSelf().inSingletonScope(); - bind(TYPES.IUIExtension).toService(LightDarkSwitch); - bind(EDITOR_TYPES.DefaultUIElement).toService(LightDarkSwitch); - bind(DynamicChildrenProcessor).toSelf().inSingletonScope(); unbind(TYPES.ICommandStack); diff --git a/src/common/lightDarkSwitch.css b/src/common/lightDarkSwitch.css deleted file mode 100644 index 7e5f62d..0000000 --- a/src/common/lightDarkSwitch.css +++ /dev/null @@ -1,20 +0,0 @@ -div.light-dark-switch { - left: 20px; - bottom: 70px; - padding: 10px 10px; -} - -#light-dark-label #light-dark-button::before { - content: ""; - background-image: url("@fortawesome/fontawesome-free/svgs/regular/moon.svg"); - display: inline-block; - filter: invert(var(--dark-mode)); - height: 16px; - width: 16px; - background-size: 16px 16px; - vertical-align: text-top; -} - -#light-dark-switch:checked ~ label #light-dark-button::before { - background-image: url("@fortawesome/fontawesome-free/svgs/regular/sun.svg"); -} diff --git a/src/common/lightDarkSwitch.ts b/src/common/lightDarkSwitch.ts deleted file mode 100644 index 31ac999..0000000 --- a/src/common/lightDarkSwitch.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { injectable, multiInject } from "inversify"; -import "./lightDarkSwitch.css"; -import { AbstractUIExtension } from "sprotty"; - -export const SWITCHABLE = Symbol("Switchable"); - -export interface Switchable { - switchTheme(useDark: boolean): void; -} - -@injectable() -export class LightDarkSwitch extends AbstractUIExtension { - static readonly ID = "light-dark-switch"; - static useDarkMode = false; - - constructor(@multiInject(SWITCHABLE) protected switchables: Switchable[]) { - super(); - } - - id(): string { - return LightDarkSwitch.ID; - } - containerClass(): string { - return LightDarkSwitch.ID; - } - protected initializeContents(containerElement: HTMLElement): void { - containerElement.classList.add("ui-float"); - containerElement.innerHTML = ` - - - `; - - const checkbox = containerElement.querySelector("#light-dark-switch") as HTMLInputElement; - checkbox.addEventListener("change", () => { - this.changeDarkMode(checkbox.checked); - }); - - // use the default browser theme - if (window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches) { - checkbox.checked = true; - this.changeDarkMode(true); - } - } - - private changeDarkMode(useDark: boolean) { - const rootElement = document.querySelector(":root") as HTMLElement; - const sprottyElement = document.querySelector("#sprotty") as HTMLElement; - - const value = useDark ? "dark" : "light"; - rootElement.setAttribute("data-theme", value); - sprottyElement.setAttribute("data-theme", value); - this.switchables.forEach((s) => s.switchTheme(useDark)); - } -} diff --git a/src/features/autoLayout/command.ts b/src/features/autoLayout/command.ts index a03337b..4a680bf 100644 --- a/src/features/autoLayout/command.ts +++ b/src/features/autoLayout/command.ts @@ -1,8 +1,7 @@ -import { inject, optional } from "inversify"; +import { inject } from "inversify"; import { Command, CommandExecutionContext, SModelRootImpl, TYPES } from "sprotty"; import { Action, IModelLayoutEngine, SGraph, SModelRoot } from "sprotty-protocol"; import { LoadDiagramCommand } from "../serialize/load"; -import { EditorModeController } from "../editorMode/editorModeController"; export interface LayoutModelAction extends Action { kind: typeof LayoutModelAction.KIND; @@ -20,10 +19,6 @@ export namespace LayoutModelAction { export class LayoutModelCommand extends Command { static readonly KIND = LayoutModelAction.KIND; - @inject(EditorModeController) - @optional() - private editorModeController?: EditorModeController; - @inject(TYPES.IModelLayoutEngine) private readonly layoutEngine?: IModelLayoutEngine; @@ -31,11 +26,6 @@ export class LayoutModelCommand extends Command { private newModel?: SModelRootImpl; async execute(context: CommandExecutionContext): Promise { - if (this.editorModeController?.isReadOnly()) { - // We don't want to layout the model in read-only mode. - return context.root; - } - this.oldModelSchema = context.modelFactory.createSchema(context.root); if (!this.layoutEngine) throw new Error("Missing injects"); diff --git a/src/features/autoLayout/di.config.ts b/src/features/autoLayout/di.config.ts index dcc0121..23414a2 100644 --- a/src/features/autoLayout/di.config.ts +++ b/src/features/autoLayout/di.config.ts @@ -1,14 +1,15 @@ import { ContainerModule } from "inversify"; import { TYPES, configureCommand } from "sprotty"; -import { ElkFactory, ILayoutConfigurator } from "sprotty-elk"; +import { ElkFactory, ILayoutConfigurator, ILayoutPostprocessor } from "sprotty-elk"; import { LayoutModelCommand } from "./command"; -import { DfdElkLayoutEngine, DfdLayoutConfigurator, elkFactory } from "./layouter"; +import { CircleLayoutPostProcessor, DfdElkLayoutEngine, DfdLayoutConfigurator, elkFactory } from "./layouter"; import { AutoLayoutKeyListener } from "./keyListener"; export const autoLayoutModule = new ContainerModule((bind, unbind, isBound, rebind) => { bind(DfdElkLayoutEngine).toSelf().inSingletonScope(); bind(TYPES.IModelLayoutEngine).toService(DfdElkLayoutEngine); rebind(ILayoutConfigurator).to(DfdLayoutConfigurator); + bind(ILayoutPostprocessor).to(CircleLayoutPostProcessor).inSingletonScope(); bind(ElkFactory).toConstantValue(elkFactory); bind(TYPES.KeyListener).to(AutoLayoutKeyListener).inSingletonScope(); diff --git a/src/features/autoLayout/layouter.ts b/src/features/autoLayout/layouter.ts index cc8c461..8d6cda2 100644 --- a/src/features/autoLayout/layouter.ts +++ b/src/features/autoLayout/layouter.ts @@ -1,4 +1,4 @@ -import ElkConstructor from "elkjs/lib/elk.bundled"; +import ElkConstructor, { ElkExtendedEdge, ElkNode } from "elkjs/lib/elk.bundled"; import { injectable, inject } from "inversify"; import { DefaultLayoutConfigurator, @@ -6,31 +6,63 @@ import { ElkLayoutEngine, IElementFilter, ILayoutConfigurator, + ILayoutPostprocessor, } from "sprotty-elk"; import { SChildElementImpl, SShapeElementImpl, isBoundsAware } from "sprotty"; -import { SShapeElement, SGraph, SModelIndex } from "sprotty-protocol"; +import { SShapeElement, SGraph, SModelIndex, SEdge } from "sprotty-protocol"; import { ElkShape, LayoutOptions } from "elkjs"; +import { SettingsManager } from "../settingsMenu/SettingsManager"; +import { LayoutMethod } from "../settingsMenu/LayoutMethod"; export class DfdLayoutConfigurator extends DefaultLayoutConfigurator { + constructor(@inject(SettingsManager) protected readonly settings: SettingsManager) { + super(); + } + protected override graphOptions(_sgraph: SGraph, _index: SModelIndex): LayoutOptions { // Elk settings. See https://eclipse.dev/elk/reference.html for available options. return { - "org.eclipse.elk.algorithm": "org.eclipse.elk.layered", - "org.eclipse.elk.layered.spacing.nodeNodeBetweenLayers": "30.0", - "org.eclipse.elk.layered.spacing.edgeNodeBetweenLayers": "20.0", - "org.eclipse.elk.port.borderOffset": "14.0", - // Do not do micro layout for nodes, which includes the node dimensions etc. - // These are all automatically determined by our dfd node views - "org.eclipse.elk.omitNodeMicroLayout": "true", - // Balanced graph > straight edges - "org.eclipse.elk.layered.nodePlacement.favorStraightEdges": "false", - }; + [LayoutMethod.LINES]: { + "org.eclipse.elk.algorithm": "org.eclipse.elk.layered", + "org.eclipse.elk.layered.spacing.nodeNodeBetweenLayers": "30.0", + "org.eclipse.elk.layered.spacing.edgeNodeBetweenLayers": "20.0", + "org.eclipse.elk.port.borderOffset": "14.0", + // Do not do micro layout for nodes, which includes the node dimensions etc. + // These are all automatically determined by our dfd node views + "org.eclipse.elk.omitNodeMicroLayout": "true", + // Balanced graph > straight edges + "org.eclipse.elk.layered.nodePlacement.favorStraightEdges": "false", + }, + [LayoutMethod.WRAPPING]: { + "org.eclipse.elk.algorithm": "org.eclipse.elk.layered", + "org.eclipse.elk.layered.spacing.nodeNodeBetweenLayers": "10.0", //Save more space between layers (long names might break this!) + "org.eclipse.elk.layered.spacing.edgeNodeBetweenLayers": "5.0", //Save more space between layers (long names might break this!) + "org.eclipse.elk.edgeRouting": "ORTHOGONAL", //Edges should be routed orthogonal to each another + "org.eclipse.elk.layered.layering.strategy": "COFFMAN_GRAHAM", + "org.eclipse.elk.layered.compaction.postCompaction.strategy": "LEFT_RIGHT_CONSTRAINT_LOCKING", //Compact the resulting graph horizontally + "org.eclipse.elk.layered.wrapping.strategy": "MULTI_EDGE", //Allow wrapping of multiple edges + "org.eclipse.elk.layered.wrapping.correctionFactor": "2.0", //Allow the wrapping to occur earlier + // Do not do micro layout for nodes, which includes the node dimensions etc. + // These are all automatically determined by our dfd node views + "org.eclipse.elk.omitNodeMicroLayout": "true", + "org.eclipse.elk.port.borderOffset": "14.0", + }, + [LayoutMethod.CIRCLES]: { + "org.eclipse.elk.algorithm": "org.eclipse.elk.stress", + "org.eclipse.elk.force.repulsion": "5.0", + "org.eclipse.elk.force.iterations": "100", //Reduce iterations for faster formatting, did not notice differences with more iterations + "org.eclipse.elk.force.repulsivePower": "1", //Edges should repel vertices as well + // Do not do micro layout for nodes, which includes the node dimensions etc. + // These are all automatically determined by our dfd node views + "org.eclipse.elk.omitNodeMicroLayout": "true", + }, + }[this.settings.layoutMethod]; } } export const elkFactory = () => new ElkConstructor({ - algorithms: ["layered"], + algorithms: ["layered", "stress"], }); /** @@ -48,8 +80,10 @@ export class DfdElkLayoutEngine extends ElkLayoutEngine { @inject(ElkFactory) elkFactory: ElkFactory, @inject(IElementFilter) elementFilter: IElementFilter, @inject(ILayoutConfigurator) configurator: ILayoutConfigurator, + @inject(SettingsManager) protected readonly settings: SettingsManager, + @inject(ILayoutPostprocessor) protected readonly postprocessor: ILayoutPostprocessor, ) { - super(elkFactory, elementFilter, configurator); + super(elkFactory, elementFilter, configurator, undefined, postprocessor); } protected override transformShape(elkShape: ElkShape, sshape: SShapeElementImpl | SShapeElement): void { @@ -63,6 +97,13 @@ export class DfdElkLayoutEngine extends ElkLayoutEngine { } } + protected override transformEdge(sedge: SEdge, index: SModelIndex): ElkExtendedEdge { + // remove all middle points of edge and only keep source and target + const elkEdge = super.transformEdge(sedge, index); + elkEdge.sections = []; + return elkEdge; + } + protected override applyShape(sshape: SShapeElement, elkShape: ElkShape, index: SModelIndex): void { // Check if this is a port, if yes we want to center it on the node edge instead of putting it right next to the node at the edge if (this.getBasicType(sshape) === "port") { @@ -70,28 +111,289 @@ export class DfdElkLayoutEngine extends ElkLayoutEngine { // we can access the parent property and the bounds of the parent which is the node of this port. if (sshape instanceof SChildElementImpl && isBoundsAware(sshape.parent)) { const parent = sshape.parent; - if (elkShape.x && elkShape.width && elkShape.y && elkShape.height) { + if ( + elkShape.x !== undefined && + elkShape.width !== undefined && + elkShape.y !== undefined && + elkShape.height !== undefined + ) { // Note that the port x and y coordinates are relative to the parent node. // Move inwards from being adjacent to the node edge by half of the port width/height // depending on which edge the port is on. - if (elkShape.x <= 0) - // Left edge - elkShape.x += elkShape.width / 2; - if (elkShape.y <= 0) - // Top edge - elkShape.y += elkShape.height / 2; - if (elkShape.x >= parent.bounds.width) - // Right edge - elkShape.x -= elkShape.width / 2; - if (elkShape.y >= parent.bounds.height) - // Bottom edge - elkShape.y -= elkShape.height / 2; + // depending on the mode the ports may be placed differently + if (this.settings.layoutMethod === LayoutMethod.CIRCLES) { + if (elkShape.x <= 0) + // Left edge + elkShape.x -= elkShape.width / 2; + if (elkShape.y <= 0) + // Top edge + elkShape.y -= elkShape.height / 2; + if (elkShape.x >= parent.bounds.width) + // Right edge + elkShape.x -= elkShape.width / 2; + if (elkShape.y >= parent.bounds.height) + // Bottom edge + elkShape.y -= elkShape.height / 2; + } else { + if (elkShape.x <= 0) + // Left edge + elkShape.x += elkShape.width / 2; + if (elkShape.y <= 0) + // Top edge + elkShape.y += elkShape.height / 2; + if (elkShape.x >= parent.bounds.width) + // Right edge + elkShape.x -= elkShape.width / 2; + if (elkShape.y >= parent.bounds.height) + // Bottom edge + elkShape.y -= elkShape.height / 2; + } } } } super.applyShape(sshape, elkShape, index); } + + protected applyEdge(sedge: SEdge, elkEdge: ElkExtendedEdge, index: SModelIndex): void { + if (this.settings.layoutMethod === LayoutMethod.CIRCLES) { + // In the circles layout method, we want to make sure that the edge is not straight + // This is because the circles layout method does not support straight edges + elkEdge.sections = []; + } + super.applyEdge(sedge, elkEdge, index); + } +} + +@injectable() +export class CircleLayoutPostProcessor implements ILayoutPostprocessor { + private portToNodes: Map = new Map(); + private connectedPorts: Map = new Map(); + private nodeSquares: Map = new Map(); + + constructor(@inject(SettingsManager) protected readonly settings: SettingsManager) {} + + postprocess(elkGraph: ElkNode): void { + if (this.settings.layoutMethod !== LayoutMethod.CIRCLES) { + return; + } + this.connectedPorts = new Map(); + if (!elkGraph.edges || !elkGraph.children) { + return; + } + for (const edge of elkGraph.edges) { + for (const source of edge.sources) { + if (!this.connectedPorts.has(source)) { + this.connectedPorts.set(source, []); + } + for (const target of edge.targets) { + if (!this.connectedPorts.has(target)) { + this.connectedPorts.set(target, []); + } + this.connectedPorts.get(source)?.push(target); + this.connectedPorts.get(target)?.push(source); + } + } + } + + this.portToNodes = new Map(); + this.nodeSquares = new Map(); + for (const node of elkGraph.children) { + if (node.ports) { + for (const port of node.ports) { + this.portToNodes.set(port.id, node.id); + } + this.nodeSquares.set(node.id, this.getNodeSquare(node)); + } + } + + for (const [port, connected] of this.connectedPorts) { + if (connected.length === 0) { + continue; + } + const intersections = connected.map((connection) => { + const line = this.getLine(port, connection); + const node = this.portToNodes.get(port); + if (!node) { + return { x: 0, y: 0 }; + } + const square = this.nodeSquares.get(node); + if (!square) { + return { x: 0, y: 0 }; + } + const intersection = this.getIntersection(square, line); + return intersection; + }); + const average = { + x: intersections.reduce((sum, intersection) => sum + intersection.x, 0) / intersections.length, + y: intersections.reduce((sum, intersection) => sum + intersection.y, 0) / intersections.length, + }; + + const node = this.portToNodes.get(port); + if (!node) { + continue; + } + const square = this.nodeSquares.get(node); + if (!square) { + continue; + } + const closestPointOnEdge = { + x: average.x, + y: average.y, + }; + + const topEdge = { x1: square.x, y1: square.y, x2: square.x + square.width, y2: square.y }; + const bottomEdge = { + x1: square.x, + y1: square.y + square.height, + x2: square.x + square.width, + y2: square.y + square.height, + }; + const leftEdge = { x1: square.x, y1: square.y, x2: square.x, y2: square.y + square.height }; + const rightEdge = { + x1: square.x + square.width, + y1: square.y, + x2: square.x + square.width, + y2: square.y + square.height, + }; + const distances = [ + { distance: Math.abs(average.y - square.y), dimension: "y", edge: topEdge }, + { distance: Math.abs(average.y - (square.y + square.height)), dimension: "y", edge: bottomEdge }, + { distance: Math.abs(average.x - square.x), dimension: "x", edge: leftEdge }, + { distance: Math.abs(average.x - (square.x + square.width)), dimension: "x", edge: rightEdge }, + ]; + distances.sort((a, b) => a.distance - b.distance); + const closestEdge = distances[0].edge; + if (distances[0].dimension === "y") { + closestPointOnEdge.x = clamp(average.x, closestEdge.x1, closestEdge.x2); + closestPointOnEdge.y = closestEdge.y1; + } else { + closestPointOnEdge.x = closestEdge.x1; + closestPointOnEdge.y = clamp(average.y, closestEdge.y1, closestEdge.y2); + } + + const nodeElk = elkGraph.children.find((child) => child.id === node); + if (!nodeElk) { + continue; + } + const portElk = nodeElk.ports?.find((p) => p.id === port); + if (!portElk) { + continue; + } + portElk.x = closestPointOnEdge.x - (nodeElk.x ?? 0); + portElk.y = closestPointOnEdge.y - (nodeElk.y ?? 0); + } + } + + getNodeSquare(node: ElkNode): Square { + return { + x: node.x ?? 0, + y: node.y ?? 0, + width: node.width ?? 0, + height: node.height ?? 0, + }; + } + + getCenter(square: Square): { x: number; y: number } { + return { + x: square.x + square.width / 2, + y: square.y + square.height / 2, + }; + } + + getLine(port1: string, port2: string): Line { + const node1 = this.portToNodes.get(port1); + const node2 = this.portToNodes.get(port2); + if (!node1 || !node2) { + return { + x1: 0, + y1: 0, + x2: 0, + y2: 0, + }; + } + const square1 = this.nodeSquares.get(node1)!; + const square2 = this.nodeSquares.get(node2)!; + const center1 = this.getCenter(square1); + const center2 = this.getCenter(square2); + + return { + x1: center1.x, + y1: center1.y, + x2: center2.x, + y2: center2.y, + }; + } + + getIntersection(square: Square, line: Line): { x: number; y: number } { + const topLeft = { x: square.x, y: square.y }; + const topRight = { x: square.x + square.width, y: square.y }; + const bottomLeft = { x: square.x, y: square.y + square.height }; + const bottomRight = { x: square.x + square.width, y: square.y + square.height }; + + const intersections = [ + this.getLineIntersection(line, { x1: topLeft.x, y1: topLeft.y, x2: topRight.x, y2: topRight.y }), + this.getLineIntersection(line, { x1: topRight.x, y1: topRight.y, x2: bottomRight.x, y2: bottomRight.y }), + this.getLineIntersection(line, { + x1: bottomRight.x, + y1: bottomRight.y, + x2: bottomLeft.x, + y2: bottomLeft.y, + }), + this.getLineIntersection(line, { x1: bottomLeft.x, y1: bottomLeft.y, x2: topLeft.x, y2: topLeft.y }), + ]; + + const inLineBounds = intersections.filter((intersection) => { + return ( + intersection.x >= Math.min(line.x1, line.x2) && + intersection.x <= Math.max(line.x1, line.x2) && + intersection.y >= Math.min(line.y1, line.y2) && + intersection.y <= Math.max(line.y1, line.y2) + ); + }); + return inLineBounds[0] ?? { x: 0, y: 0 }; + } + + private getLineIntersection(line1: Line, line2: Line): { x: number; y: number } { + const x1 = line1.x1; + const y1 = line1.y1; + const x2 = line1.x2; + const y2 = line1.y2; + const x3 = line2.x1; + const y3 = line2.y1; + const x4 = line2.x2; + const y4 = line2.y2; + + const denominator = (x1 - x2) * (y3 - y4) - (y1 - y2) * (x3 - x4); + if (denominator === 0) { + return { x: 0, y: 0 }; + } + + const x = ((x1 * y2 - y1 * x2) * (x3 - x4) - (x1 - x2) * (x3 * y4 - y3 * x4)) / denominator; + const y = ((x1 * y2 - y1 * x2) * (y3 - y4) - (y1 - y2) * (x3 * y4 - y3 * x4)) / denominator; + + return { x, y }; + } +} + +interface Square { + x: number; + y: number; + width: number; + height: number; +} + +interface Line { + x1: number; + y1: number; + x2: number; + y2: number; +} + +function clamp(value: number, l1: number, l2: number): number { + const min = Math.min(l1, l2); + const max = Math.max(l1, l2); + return Math.max(min, Math.min(max, value)); } diff --git a/src/features/constraintMenu/ConstraintMenu.ts b/src/features/constraintMenu/ConstraintMenu.ts index 1f9b884..d560a46 100644 --- a/src/features/constraintMenu/ConstraintMenu.ts +++ b/src/features/constraintMenu/ConstraintMenu.ts @@ -18,7 +18,7 @@ import { AutoCompleteTree } from "./AutoCompletion"; import { TreeBuilder } from "./DslLanguage"; import { LabelTypeRegistry } from "../labels/labelTypeRegistry"; import { EditorModeController } from "../editorMode/editorModeController"; -import { Switchable } from "../../common/lightDarkSwitch"; +import { Switchable } from "../settingsMenu/themeManager"; import { AnalyzeDiagramAction } from "../serialize/analyze"; @injectable() diff --git a/src/features/constraintMenu/di.config.ts b/src/features/constraintMenu/di.config.ts index 7e2d4ac..a08ac1a 100644 --- a/src/features/constraintMenu/di.config.ts +++ b/src/features/constraintMenu/di.config.ts @@ -3,7 +3,7 @@ import { EDITOR_TYPES } from "../../utils"; import { ConstraintMenu } from "./ConstraintMenu"; import { TYPES } from "sprotty"; import { ConstraintRegistry } from "./constraintRegistry"; -import { SWITCHABLE } from "../../common/lightDarkSwitch"; +import { SWITCHABLE } from "../settingsMenu/themeManager"; // This module contains an UI extension that adds a tool palette to the editor. // This tool palette allows the user to create new nodes and edges. diff --git a/src/features/dfdElements/di.config.ts b/src/features/dfdElements/di.config.ts index 9b188cb..3ce2543 100644 --- a/src/features/dfdElements/di.config.ts +++ b/src/features/dfdElements/di.config.ts @@ -23,7 +23,7 @@ import { DfdNodeAnnotationUI, DfdNodeAnnotationUIMouseListener } from "./nodeAnn import { DFDBehaviorRefactorer, RefactorInputNameInDFDBehaviorCommand } from "./behaviorRefactorer"; import "./elementStyles.css"; -import { SWITCHABLE } from "../../common/lightDarkSwitch"; +import { SWITCHABLE } from "../settingsMenu/themeManager"; export const dfdElementsModule = new ContainerModule((bind, unbind, isBound, rebind) => { const context = { bind, unbind, isBound, rebind }; diff --git a/src/features/dfdElements/edges.tsx b/src/features/dfdElements/edges.tsx index be91701..590ac6b 100644 --- a/src/features/dfdElements/edges.tsx +++ b/src/features/dfdElements/edges.tsx @@ -1,5 +1,5 @@ /** @jsx svg */ -import { injectable } from "inversify"; +import { inject, injectable } from "inversify"; import { PolylineEdgeViewWithGapsOnIntersections, SEdgeImpl, @@ -13,12 +13,15 @@ import { import { VNode } from "snabbdom"; import { Point, angleOfPoint, toDegrees, SEdge, SLabel } from "sprotty-protocol"; import { DynamicChildrenEdge } from "./dynamicChildren"; +import { SettingsManager } from "../settingsMenu/SettingsManager"; export interface ArrowEdge extends SEdge { text?: string; } export class ArrowEdgeImpl extends DynamicChildrenEdge implements WithEditableLabel { + text?: string; + setChildren(schema: ArrowEdge): void { schema.children = [ { @@ -52,6 +55,10 @@ export class ArrowEdgeImpl extends DynamicChildrenEdge implements WithEditableLa @injectable() export class ArrowEdgeView extends PolylineEdgeViewWithGapsOnIntersections { + constructor(@inject(SettingsManager) protected readonly settings: SettingsManager) { + super(); + } + override render(edge: Readonly, context: RenderingContext, args?: IViewArgs): VNode | undefined { // In the default implementation children of the edge are always rendered, because they // may be visible when the rest of the edge is not. @@ -63,7 +70,31 @@ export class ArrowEdgeView extends PolylineEdgeViewWithGapsOnIntersections { return undefined; } - return super.render(edge, context, args); + return this.superRender(edge, context, args); + } + + superRender(edge: Readonly, context: RenderingContext, args?: IViewArgs): VNode | undefined { + const route = this.edgeRouterRegistry.route(edge, args); + if (route.length === 0) { + return this.renderDanglingEdge("Cannot compute route", edge, context); + } + if (!this.isVisible(edge, route, context)) { + if (edge.children.length === 0) { + return undefined; + } + // The children of an edge are not necessarily inside the bounding box of the route, + // so we need to render a group to ensure the children have a chance to be rendered. + return {this.settings.hideEdgeLabels ? [] : context.renderChildren(edge, { route })}; + } + + return ( + + {this.renderLine(edge, route, context, args)} + {this.renderAdditionals(edge, route, context)} + {this.renderJunctionPoints(edge, route, context, args)} + {this.settings.hideEdgeLabels ? [] : context.renderChildren(edge, { route })} + + ); } /** diff --git a/src/features/dfdElements/nodes.tsx b/src/features/dfdElements/nodes.tsx index a11c019..8fc0a78 100644 --- a/src/features/dfdElements/nodes.tsx +++ b/src/features/dfdElements/nodes.tsx @@ -41,6 +41,8 @@ export abstract class DfdNodeImpl extends DynamicChildrenNode implements WithEdi text: string = ""; labels: LabelAssignment[] = []; ports: SPort[] = []; + hideLabels: boolean = false; + minimumWidth: number = DfdNodeImpl.DEFAULT_WIDTH; annotation?: DfdNodeAnnotation; override setChildren(schema: DfdNode): void { @@ -83,7 +85,10 @@ export abstract class DfdNodeImpl extends DynamicChildrenNode implements WithEdi } protected calculateWidth(): number { - const textWidth = calculateTextSize(this.editableLabel?.text).width; + if (this.hideLabels) { + return this.minimumWidth + DfdNodeImpl.WIDTH_PADDING; + } + const textWidth = calculateTextSize(this.text).width; const labelWidths = this.labels.map( (labelAssignment) => DfdNodeLabelRenderer.computeLabelContent(labelAssignment)[1], ); @@ -154,7 +159,7 @@ export abstract class DfdNodeImpl extends DynamicChildrenNode implements WithEdi export class StorageNodeImpl extends DfdNodeImpl { protected override calculateHeight(): number { const hasLabels = this.labels.length > 0; - if (hasLabels) { + if (hasLabels && !this.hideLabels) { return ( StorageNodeImpl.LABEL_START_HEIGHT + this.labels.length * DfdNodeLabelRenderer.LABEL_SPACING_HEIGHT + @@ -205,7 +210,7 @@ export class StorageNodeView extends ShapeView { export class FunctionNodeImpl extends DfdNodeImpl { protected override calculateHeight(): number { const hasLabels = this.labels.length > 0; - if (hasLabels) { + if (hasLabels && !this.hideLabels) { return ( // height for text FunctionNodeImpl.LABEL_START_HEIGHT + @@ -258,7 +263,7 @@ export class FunctionNodeView extends ShapeView { export class IONodeImpl extends DfdNodeImpl { protected override calculateHeight(): number { const hasLabels = this.labels.length > 0; - if (hasLabels) { + if (hasLabels && !this.hideLabels) { return ( IONodeImpl.LABEL_START_HEIGHT + this.labels.length * DfdNodeLabelRenderer.LABEL_SPACING_HEIGHT + diff --git a/src/features/dfdElements/outputPortEditUi.ts b/src/features/dfdElements/outputPortEditUi.ts index 63980b2..226e138 100644 --- a/src/features/dfdElements/outputPortEditUi.ts +++ b/src/features/dfdElements/outputPortEditUi.ts @@ -32,7 +32,7 @@ import "monaco-editor/esm/vs/editor/contrib/hover/browser/hover"; import "monaco-editor/esm/vs/editor/contrib/inlineCompletions/browser/inlineCompletions.contribution.js"; import "./outputPortEditUi.css"; -import { LightDarkSwitch, Switchable } from "../../common/lightDarkSwitch"; +import { ThemeManager, Switchable } from "../settingsMenu/themeManager"; /** * Detects when a dfd output port is double clicked and shows the OutputPortEditUI @@ -418,7 +418,7 @@ export class OutputPortEditUI extends AbstractUIExtension implements Switchable new MonacoEditorDfdBehaviorCompletionProvider(this, this.labelTypeRegistry), ); - const monacoTheme = (LightDarkSwitch?.useDarkMode ?? true) ? "vs-dark" : "vs"; + const monacoTheme = (ThemeManager?.useDarkMode ?? true) ? "vs-dark" : "vs"; this.editor = monaco.editor.create(this.editorContainer, { minimap: { // takes too much space, not useful for our use case diff --git a/src/features/editorMode/command.ts b/src/features/editorMode/command.ts index b1acd4e..a1e67c1 100644 --- a/src/features/editorMode/command.ts +++ b/src/features/editorMode/command.ts @@ -60,7 +60,7 @@ export class ChangeEditorModeCommand extends Command { } private postModeSwitch(context: CommandExecutionContext): void { - if (this.oldMode === "annotated" && this.action.newMode === "edit") { + if (this.oldMode === "view" && this.action.newMode === "edit") { // Remove annotations when enabling editing this.oldNodeAnnotations.clear(); @@ -74,7 +74,7 @@ export class ChangeEditorModeCommand extends Command { } private undoPostModeSwitch(context: CommandExecutionContext): void { - if (this.oldMode === "annotated" && this.action.newMode === "edit") { + if (this.oldMode === "view" && this.action.newMode === "edit") { // Restore annotations when disabling editing this.oldNodeAnnotations.forEach((annotation, id) => { const element = context.root.index.getById(id); diff --git a/src/features/editorMode/di.config.ts b/src/features/editorMode/di.config.ts index 7c7acec..4920a48 100644 --- a/src/features/editorMode/di.config.ts +++ b/src/features/editorMode/di.config.ts @@ -1,13 +1,9 @@ import { ContainerModule } from "inversify"; -import { DeleteElementCommand, EditLabelMouseListener, MoveCommand, TYPES, configureCommand } from "sprotty"; +import { DeleteElementCommand, EditLabelMouseListener, TYPES, configureCommand } from "sprotty"; import { EditorModeController } from "./editorModeController"; import { EditorModeSwitchUi } from "./modeSwitchUi"; import { EDITOR_TYPES } from "../../utils"; -import { - EditorModeAwareDeleteElementCommand, - EditorModeAwareEditLabelMouseListener, - EditorModeAwareMoveCommand, -} from "./sprottyHooks"; +import { EditorModeAwareDeleteElementCommand, EditorModeAwareEditLabelMouseListener } from "./sprottyHooks"; import { ChangeEditorModeCommand } from "./command"; export const editorModeModule = new ContainerModule((bind, unbind, isBound, rebind) => { @@ -23,6 +19,5 @@ export const editorModeModule = new ContainerModule((bind, unbind, isBound, rebi // Sprotty hooks that hook into the edit label, move and edit module // to intercept model modifications to prevent them when the editor is in a read-only mode. rebind(EditLabelMouseListener).to(EditorModeAwareEditLabelMouseListener); - rebind(MoveCommand).to(EditorModeAwareMoveCommand); rebind(DeleteElementCommand).to(EditorModeAwareDeleteElementCommand); }); diff --git a/src/features/editorMode/editorModeController.ts b/src/features/editorMode/editorModeController.ts index 7ce217e..db4ac77 100644 --- a/src/features/editorMode/editorModeController.ts +++ b/src/features/editorMode/editorModeController.ts @@ -1,6 +1,6 @@ import { injectable } from "inversify"; -export type EditorMode = "edit" | "annotated" | "readonly"; +export type EditorMode = "edit" | "view"; /** * Holds the current editor mode in a central place. diff --git a/src/features/editorMode/modeSwitchUi.ts b/src/features/editorMode/modeSwitchUi.ts index bcba07e..26c976c 100644 --- a/src/features/editorMode/modeSwitchUi.ts +++ b/src/features/editorMode/modeSwitchUi.ts @@ -1,15 +1,12 @@ -import { AbstractUIExtension, ActionDispatcher, TYPES } from "sprotty"; +import { AbstractUIExtension } from "sprotty"; import { EditorMode, EditorModeController } from "./editorModeController"; import { inject, injectable } from "inversify"; -import { ChangeEditorModeAction } from "./command"; import "./modeSwitchUi.css"; /** * UI that shows the current editor mode (unless it is edit mode) * with details about the mode. - * For annotated mode the user can also choose to enable editing - * and switch the editor to edit mode. */ @injectable() export class EditorModeSwitchUi extends AbstractUIExtension { @@ -18,8 +15,6 @@ export class EditorModeSwitchUi extends AbstractUIExtension { constructor( @inject(EditorModeController) private readonly editorModeController: EditorModeController, - @inject(TYPES.IActionDispatcher) - private readonly actionDispatcher: ActionDispatcher, ) { super(); } @@ -44,34 +39,19 @@ export class EditorModeSwitchUi extends AbstractUIExtension { case "edit": this.containerElement.style.visibility = "hidden"; break; - case "readonly": + case "view": this.containerElement.style.visibility = "visible"; - this.renderReadonlyMode(); - break; - case "annotated": - this.containerElement.style.visibility = "visible"; - this.renderAnnotatedMode(); + this.renderViewMode(); break; default: throw new Error(`Unknown editor mode: ${mode}`); } } - private renderAnnotatedMode(): void { + private renderViewMode(): void { this.containerElement.innerHTML = ` - Currently viewing model annotations.
+ Currently viewing model in read only mode.
Enabling editing will remove the annotations.
- - `; - const enableEditingButton = this.containerElement.querySelector("#enableEditingButton"); - enableEditingButton?.addEventListener("click", () => { - this.actionDispatcher.dispatch(ChangeEditorModeAction.create("edit")); - }); - } - - private renderReadonlyMode(): void { - this.containerElement.innerHTML = ` - This diagram was generated and is readonly. `; } } diff --git a/src/features/editorMode/sprottyHooks.ts b/src/features/editorMode/sprottyHooks.ts index 1e2da90..59b070e 100644 --- a/src/features/editorMode/sprottyHooks.ts +++ b/src/features/editorMode/sprottyHooks.ts @@ -4,9 +4,7 @@ import { CommandReturn, DeleteElementCommand, EditLabelMouseListener, - MoveCommand, SModelElementImpl, - SModelRootImpl, } from "sprotty"; import { EditorModeController } from "./editorModeController"; import { Action } from "sprotty-protocol"; @@ -29,36 +27,6 @@ export class EditorModeAwareEditLabelMouseListener extends EditLabelMouseListene } } -@injectable() -export class EditorModeAwareMoveCommand extends MoveCommand { - @inject(EditorModeController) - private readonly editorModeController?: EditorModeController; - - execute(context: CommandExecutionContext): CommandReturn { - if (this.editorModeController?.isReadOnly()) { - return context.root; - } - - return super.execute(context); - } - - undo(context: CommandExecutionContext): Promise { - if (this.editorModeController?.isReadOnly()) { - return Promise.resolve(context.root); - } - - return super.undo(context); - } - - redo(context: CommandExecutionContext): Promise { - if (this.editorModeController?.isReadOnly()) { - return Promise.resolve(context.root); - } - - return super.redo(context); - } -} - @injectable() export class EditorModeAwareDeleteElementCommand extends DeleteElementCommand { @inject(EditorModeController) diff --git a/src/features/labels/labelRenderer.tsx b/src/features/labels/labelRenderer.tsx index b79a346..5dedfc5 100644 --- a/src/features/labels/labelRenderer.tsx +++ b/src/features/labels/labelRenderer.tsx @@ -6,6 +6,7 @@ import { calculateTextSize } from "../../utils"; import { LabelAssignment, LabelTypeRegistry, globalLabelTypeRegistry } from "./labelTypeRegistry"; import { DeleteLabelAssignmentAction } from "./commands"; import { ContainsDfdLabels } from "./elementFeature"; +import { SettingsManager } from "../settingsMenu/SettingsManager"; @injectable() export class DfdNodeLabelRenderer { @@ -16,6 +17,7 @@ export class DfdNodeLabelRenderer { constructor( @inject(TYPES.IActionDispatcher) private readonly actionDispatcher: IActionDispatcher, + @inject(SettingsManager) private readonly settingsManager: SettingsManager, @inject(LabelTypeRegistry) @optional() private readonly labelTypeRegistry?: LabelTypeRegistry, ) {} @@ -104,7 +106,10 @@ export class DfdNodeLabelRenderer { baseY: number, xOffset = 0, labelSpacing = DfdNodeLabelRenderer.LABEL_SPACING_HEIGHT, - ): VNode { + ): VNode | undefined { + if (this.settingsManager.simplifyNodeNames) { + return undefined; + } this.sortLabels(node.labels); return ( diff --git a/src/features/serialize/analyze.ts b/src/features/serialize/analyze.ts index 65a69cb..0433044 100644 --- a/src/features/serialize/analyze.ts +++ b/src/features/serialize/analyze.ts @@ -56,7 +56,7 @@ export class AnalyzeDiagramCommand extends Command { model: modelCopy, labelTypes: this.labelTypeRegistry?.getLabelTypes(), constraints: this.constraintRegistry?.getConstraints(), - editorMode: this.editorModeController?.getCurrentMode(), + mode: this.editorModeController?.getCurrentMode(), }; const diagramJson = JSON.stringify(diagram, undefined, 4); sendMessage("Json:" + diagramJson); diff --git a/src/features/serialize/defaultDiagram.json b/src/features/serialize/defaultDiagram.json index 2c4b322..6766dcd 100644 --- a/src/features/serialize/defaultDiagram.json +++ b/src/features/serialize/defaultDiagram.json @@ -612,5 +612,5 @@ ] } ], - "editorMode": "edit" + "mode": "edit" } diff --git a/src/features/serialize/load.ts b/src/features/serialize/load.ts index 17fccb9..baa174d 100644 --- a/src/features/serialize/load.ts +++ b/src/features/serialize/load.ts @@ -194,9 +194,9 @@ export class LoadDiagramCommand extends Command { if (this.editorModeController) { // Load editor mode this.oldEditorMode = this.editorModeController.getCurrentMode(); - this.newEditorMode = newDiagram?.editorMode; - if (newDiagram?.editorMode) { - this.editorModeController.setMode(newDiagram.editorMode); + this.newEditorMode = newDiagram?.mode; + if (newDiagram?.mode) { + this.editorModeController.setMode(newDiagram.mode); } else { this.editorModeController.setDefaultMode(); } diff --git a/src/features/serialize/loadDefaultDiagram.ts b/src/features/serialize/loadDefaultDiagram.ts index a771fc6..3112ead 100644 --- a/src/features/serialize/loadDefaultDiagram.ts +++ b/src/features/serialize/loadDefaultDiagram.ts @@ -78,8 +78,8 @@ export class LoadDefaultDiagramCommand extends Command { if (this.editorModeController) { this.oldEditorMode = this.editorModeController.getCurrentMode(); - if (defaultDiagram.editorMode) { - this.editorModeController.setMode(defaultDiagram.editorMode); + if (defaultDiagram.mode) { + this.editorModeController.setMode(defaultDiagram.mode); } else { this.editorModeController.setDefaultMode(); } @@ -112,8 +112,8 @@ export class LoadDefaultDiagramCommand extends Command { this.labelTypeRegistry?.registerLabelType(labelType); }); if (this.editorModeController) { - if (defaultDiagram.editorMode) { - this.editorModeController.setMode(defaultDiagram.editorMode); + if (defaultDiagram.mode) { + this.editorModeController.setMode(defaultDiagram.mode); } else { this.editorModeController.setDefaultMode(); } diff --git a/src/features/serialize/loadPalladio.ts b/src/features/serialize/loadPalladio.ts index 5abcbcd..56292c6 100644 --- a/src/features/serialize/loadPalladio.ts +++ b/src/features/serialize/loadPalladio.ts @@ -89,9 +89,7 @@ export class LoadPalladioCommand extends Command { ); // Construct the message format for WebSocket - const message = [ - ...fileContents.map(({ name, content }) => `${name}:${content}`), - ].join("---FILE---"); + const message = [...fileContents.map(({ name, content }) => `${name}:${content}`)].join("---FILE---"); // Send the structured message over WebSocket sendMessage(message); diff --git a/src/features/serialize/save.ts b/src/features/serialize/save.ts index 1eb681f..6a20e79 100644 --- a/src/features/serialize/save.ts +++ b/src/features/serialize/save.ts @@ -14,7 +14,7 @@ export interface SavedDiagram { model: SModelRoot; labelTypes?: LabelType[]; constraints?: Constraint[]; - editorMode?: EditorMode; + mode?: EditorMode; } export interface SaveDiagramAction extends Action { @@ -65,7 +65,7 @@ export class SaveDiagramCommand extends Command { model: modelCopy, labelTypes: this.labelTypeRegistry?.getLabelTypes(), constraints: this.constraintRegistry?.getConstraints(), - editorMode: this.editorModeController?.getCurrentMode(), + mode: this.editorModeController?.getCurrentMode(), }; const diagramJson = JSON.stringify(diagram, undefined, 4); const jsonBlob = new Blob([diagramJson], { type: "application/json" }); diff --git a/src/features/serialize/saveDFDandDD.ts b/src/features/serialize/saveDFDandDD.ts index 5d7d13c..91573d9 100644 --- a/src/features/serialize/saveDFDandDD.ts +++ b/src/features/serialize/saveDFDandDD.ts @@ -57,7 +57,7 @@ export class SaveDFDandDDCommand extends Command { model: modelCopy, labelTypes: this.labelTypeRegistry?.getLabelTypes(), constraints: this.constraintRegistry?.getConstraints(), - editorMode: this.editorModeController?.getCurrentMode(), + mode: this.editorModeController?.getCurrentMode(), }; const diagramJson = JSON.stringify(diagram, undefined, 4); sendMessage("Json2DFD:" + getModelFileName() + ":" + diagramJson); diff --git a/src/features/settingsMenu/LayoutMethod.ts b/src/features/settingsMenu/LayoutMethod.ts new file mode 100644 index 0000000..c2890be --- /dev/null +++ b/src/features/settingsMenu/LayoutMethod.ts @@ -0,0 +1,5 @@ +export enum LayoutMethod { + LINES = "Lines", + WRAPPING = "Wrapping Lines", + CIRCLES = "Circles", +} diff --git a/src/features/settingsMenu/SettingsManager.ts b/src/features/settingsMenu/SettingsManager.ts new file mode 100644 index 0000000..88d73db --- /dev/null +++ b/src/features/settingsMenu/SettingsManager.ts @@ -0,0 +1,77 @@ +import { inject, injectable } from "inversify"; +import { ActionDispatcher, TYPES } from "sprotty"; +import { ChangeEdgeLabelVisibilityAction, CompleteLayoutProcessAction, SimplifyNodeNamesAction } from "./actions"; +import { LayoutMethod } from "./LayoutMethod"; + +@injectable() +export class SettingsManager { + private _layoutMethod: LayoutMethod = LayoutMethod.LINES; + private _layoutMethodSelect?: HTMLSelectElement; + private _hideEdgeLabels = false; + private _hideEdgeLabelsCheckbox?: HTMLInputElement; + private _simplifyNodeNames = false; + private _simplifyNodeNamesCheckbox?: HTMLInputElement; + + constructor(@inject(TYPES.IActionDispatcher) protected readonly dispatcher: ActionDispatcher) {} + + public get layoutMethod(): LayoutMethod { + return this._layoutMethod; + } + + public set layoutMethod(layoutMethod: LayoutMethod) { + this._layoutMethod = layoutMethod; + if (this._layoutMethodSelect) { + this._layoutMethodSelect.value = layoutMethod; + } + } + + public bindLayoutMethodSelect(select: HTMLSelectElement) { + this._layoutMethodSelect = select; + this._layoutMethodSelect.value = this._layoutMethod; + this._layoutMethodSelect.addEventListener("change", () => { + this.dispatcher.dispatch( + CompleteLayoutProcessAction.create(this._layoutMethodSelect!.value as LayoutMethod), + ); + }); + } + + public get hideEdgeLabels(): boolean { + return this._hideEdgeLabels; + } + + public set hideEdgeLabels(hideEdgeLabels: boolean) { + this._hideEdgeLabels = hideEdgeLabels; + if (this._hideEdgeLabelsCheckbox) { + this._hideEdgeLabelsCheckbox.checked = hideEdgeLabels; + } + } + + public bindHideEdgeLabelsCheckbox(checkbox: HTMLInputElement) { + this._hideEdgeLabelsCheckbox = checkbox; + this._hideEdgeLabelsCheckbox.checked = this._hideEdgeLabels; + this._hideEdgeLabelsCheckbox.addEventListener("change", () => { + this.dispatcher.dispatch(ChangeEdgeLabelVisibilityAction.create(this._hideEdgeLabelsCheckbox!.checked)); + }); + } + + public get simplifyNodeNames(): boolean { + return this._simplifyNodeNames; + } + + public set simplifyNodeNames(simplifyNodeNames: boolean) { + this._simplifyNodeNames = simplifyNodeNames; + if (this._simplifyNodeNamesCheckbox) { + this._simplifyNodeNamesCheckbox.checked = simplifyNodeNames; + } + } + + public bindSimplifyNodeNamesCheckbox(checkbox: HTMLInputElement) { + this._simplifyNodeNamesCheckbox = checkbox; + this._simplifyNodeNamesCheckbox.checked = this._simplifyNodeNames; + this._simplifyNodeNamesCheckbox.addEventListener("change", () => { + this.dispatcher.dispatch( + SimplifyNodeNamesAction.create(this._simplifyNodeNamesCheckbox!.checked ? "hide" : "show"), + ); + }); + } +} diff --git a/src/features/settingsMenu/actions.ts b/src/features/settingsMenu/actions.ts new file mode 100644 index 0000000..5a2c50d --- /dev/null +++ b/src/features/settingsMenu/actions.ts @@ -0,0 +1,67 @@ +import { Action } from "sprotty-protocol"; +import { LayoutMethod } from "./LayoutMethod"; +import { Theme } from "./themeManager"; + +export interface SimplifyNodeNamesAction extends Action { + kind: typeof SimplifyNodeNamesAction.KIND; + mode: SimplifyNodeNamesAction.Mode; +} +export namespace SimplifyNodeNamesAction { + export const KIND = "simplify-node-names"; + export type Mode = "hide" | "show"; + + export function create(mode?: SimplifyNodeNamesAction.Mode): SimplifyNodeNamesAction { + return { + kind: KIND, + mode: mode ?? "hide", + }; + } +} + +export interface ChangeEdgeLabelVisibilityAction extends Action { + kind: typeof ChangeEdgeLabelVisibilityAction.KIND; + hide: boolean; +} +export namespace ChangeEdgeLabelVisibilityAction { + export const KIND = "hide-edge-labels"; + + export function create(hide: boolean = true): ChangeEdgeLabelVisibilityAction { + return { kind: KIND, hide }; + } +} + +export interface CompleteLayoutProcessAction extends Action { + kind: typeof CompleteLayoutProcessAction.KIND; + method: LayoutMethod; +} +export namespace CompleteLayoutProcessAction { + export const KIND = "complete-layout-process"; + + export function create(method: LayoutMethod): CompleteLayoutProcessAction { + return { kind: KIND, method }; + } +} + +export interface ChangeThemeAction extends Action { + kind: typeof ChangeThemeAction.KIND; + theme: Theme; +} +export namespace ChangeThemeAction { + export const KIND = "change-theme"; + + export function create(theme: Theme = Theme.SYSTEM_DEFAULT): ChangeThemeAction { + return { kind: KIND, theme }; + } +} + +export interface ReSnapPortsAfterChangeAction extends Action { + kind: typeof ReSnapPortsAfterChangeAction.KIND; +} + +export namespace ReSnapPortsAfterChangeAction { + export const KIND = "resnap-ports-after-change"; + + export function create(): Action { + return { kind: KIND }; + } +} diff --git a/src/features/settingsMenu/commands.ts b/src/features/settingsMenu/commands.ts new file mode 100644 index 0000000..23b19c6 --- /dev/null +++ b/src/features/settingsMenu/commands.ts @@ -0,0 +1,267 @@ +import { inject, injectable } from "inversify"; +import { + ActionDispatcher, + Command, + CommandExecutionContext, + CommandReturn, + CommitModelAction, + ISnapper, + SLabelImpl, + SModelRootImpl, + SPortImpl, + TYPES, +} from "sprotty"; +import { getBasicType, RedoAction, UndoAction } from "sprotty-protocol"; +import { DfdNodeImpl } from "../dfdElements/nodes"; +import { SettingsManager } from "./SettingsManager"; +import { + ChangeEdgeLabelVisibilityAction, + ChangeThemeAction, + CompleteLayoutProcessAction, + ReSnapPortsAfterChangeAction, + SimplifyNodeNamesAction, +} from "./actions"; +import { ArrowEdgeImpl } from "../dfdElements/edges"; +import { createDefaultFitToScreenAction } from "../../utils"; +import { LayoutMethod } from "./LayoutMethod"; +import { Theme, ThemeManager } from "./themeManager"; +import { LayoutModelAction } from "../autoLayout/command"; +import { snapPortsOfNode } from "../dfdElements/portSnapper"; +import { EditorModeController } from "../editorMode/editorModeController"; + +@injectable() +export class NodeNameReplacementRegistry { + private registry: Map = new Map(); + private nextNumber = 1; + + public get(id: string) { + const v = this.registry.get(id); + if (v !== undefined) { + return v; + } + const newName = this.nextNumber.toString(); + this.nextNumber++; + this.registry.set(id, newName); + return newName; + } +} + +@injectable() +export class SimplifyNodeNamesCommand extends Command { + static readonly KIND = SimplifyNodeNamesAction.KIND; + private readonly portMove: ReSnapPortsAfterChangeCommand; + + constructor( + @inject(TYPES.Action) private action: SimplifyNodeNamesAction, + @inject(SettingsManager) private settings: SettingsManager, + @inject(NodeNameReplacementRegistry) private registry: NodeNameReplacementRegistry, + @inject(TYPES.ISnapper) snapper: ISnapper, + @inject(EditorModeController) private editorModeController: EditorModeController, + ) { + super(); + this.portMove = new ReSnapPortsAfterChangeCommand(snapper); + } + + execute(context: CommandExecutionContext): CommandReturn { + this.perform(context, this.action.mode); + return this.portMove.execute(context); + } + undo(context: CommandExecutionContext): CommandReturn { + this.perform(context, this.action.mode === "hide" ? "show" : "hide"); + return this.portMove.undo(context); + } + redo(context: CommandExecutionContext): CommandReturn { + this.perform(context, this.action.mode); + return this.portMove.redo(context); + } + + private perform(context: CommandExecutionContext, mode: SimplifyNodeNamesAction.Mode): CommandReturn { + this.settings.simplifyNodeNames = mode === "hide"; + const nodes = context.root.children.filter((node) => getBasicType(node) === "node") as DfdNodeImpl[]; + nodes.forEach((node) => { + const label = node.children.find((element) => element.type === "label:positional") as + | SLabelImpl + | undefined; + if (!label) { + return; + } + label.text = mode === "hide" ? this.registry.get(node.id) : (node.text ?? ""); + node.hideLabels = mode === "hide"; + node.minimumWidth = mode === "hide" ? DfdNodeImpl.DEFAULT_WIDTH / 2 : DfdNodeImpl.DEFAULT_WIDTH; + }); + if (mode === "hide") { + this.editorModeController.setMode("view"); + } + + return context.root; + } +} + +@injectable() +export class ChangeEdgeLabelVisibilityCommand extends Command { + static readonly KIND = ChangeEdgeLabelVisibilityAction.KIND; + + constructor( + @inject(TYPES.Action) private action: ChangeEdgeLabelVisibilityAction, + @inject(SettingsManager) private settings: SettingsManager, + @inject(EditorModeController) private editorModeController: EditorModeController, + ) { + super(); + } + + execute(context: CommandExecutionContext): CommandReturn { + return this.perform(context, this.action.hide); + } + undo(context: CommandExecutionContext): CommandReturn { + return this.perform(context, !this.action.hide); + } + redo(context: CommandExecutionContext): CommandReturn { + return this.perform(context, this.action.hide); + } + + private perform(context: CommandExecutionContext, hide: boolean): SModelRootImpl { + this.settings.hideEdgeLabels = hide; + const edges = context.root.children.filter((node) => getBasicType(node) === "edge") as ArrowEdgeImpl[]; + edges.forEach((edge) => { + const label = edge.children.find((element) => element.type === "label:filled-background") as + | SLabelImpl + | undefined; + if (!label) { + return; + } + label.text = hide ? "" : (edge.text ?? ""); + }); + if (hide) { + this.editorModeController.setMode("view"); + } + + return context.root; + } +} + +@injectable() +export class CompleteLayoutProcessCommand extends Command { + static readonly KIND = CompleteLayoutProcessAction.KIND; + private previousMethod?: LayoutMethod; + + constructor( + @inject(TYPES.Action) private action: CompleteLayoutProcessAction, + @inject(TYPES.IActionDispatcher) private actionDispatcher: ActionDispatcher, + @inject(SettingsManager) private settings: SettingsManager, + ) { + super(); + } + + execute(context: CommandExecutionContext): CommandReturn { + console.info("CompleteLayoutProcessCommand", this.action.method); + this.previousMethod = this.settings.layoutMethod; + this.settings.layoutMethod = this.action.method; + this.actionDispatcher.dispatchAll([ + LayoutModelAction.create(), + CommitModelAction.create(), + createDefaultFitToScreenAction(context.root), + ]); + return context.root; + } + undo(context: CommandExecutionContext): CommandReturn { + this.settings.layoutMethod = this.previousMethod ?? LayoutMethod.LINES; + this.actionDispatcher.dispatch(UndoAction.create()); + return context.root; + } + redo(context: CommandExecutionContext): CommandReturn { + this.previousMethod = this.settings.layoutMethod; + this.settings.layoutMethod = this.action.method; + this.actionDispatcher.dispatch(RedoAction.create()); + return context.root; + } +} + +@injectable() +export class ChangeThemeCommand extends Command { + static readonly KIND = ChangeThemeAction.KIND; + private previousTheme?: Theme; + + constructor( + @inject(TYPES.Action) private action: ChangeThemeAction, + @inject(ThemeManager) private themeManager: ThemeManager, + ) { + super(); + } + + execute(context: CommandExecutionContext): CommandReturn { + this.previousTheme = this.themeManager.theme; + this.themeManager.theme = this.action.theme; + return context.root; + } + undo(context: CommandExecutionContext): CommandReturn { + this.themeManager.theme = this.previousTheme ?? Theme.SYSTEM_DEFAULT; + return context.root; + } + redo(context: CommandExecutionContext): CommandReturn { + this.previousTheme = this.themeManager.theme; + this.themeManager.theme = this.action.theme; + return context.root; + } +} + +@injectable() +export class ReSnapPortsAfterChangeCommand extends Command { + static readonly KIND = ReSnapPortsAfterChangeAction.KIND; + private previousPositions: Map = new Map(); + + constructor(@inject(TYPES.ISnapper) private readonly snapper: ISnapper) { + super(); + } + + execute(context: CommandExecutionContext): CommandReturn { + const model = context.root; + + model.children.forEach((node) => { + if (node instanceof DfdNodeImpl) { + this.savePortPositions(node); + } + }); + + model.children.forEach((node) => { + if (node instanceof DfdNodeImpl) { + snapPortsOfNode(node, this.snapper); + } + }); + return model; + } + undo(context: CommandExecutionContext): CommandReturn { + const model = context.root; + model.children.forEach((node) => { + if (node instanceof DfdNodeImpl) { + node.children.forEach((child) => { + if (child instanceof SPortImpl) { + const pos = this.previousPositions.get(child.id); + if (pos) { + child.position = pos; + } + } + }); + } + }); + return model; + } + redo(context: CommandExecutionContext): CommandReturn { + const model = context.root; + model.children.forEach((node) => { + if (node instanceof DfdNodeImpl) { + snapPortsOfNode(node, this.snapper); + } + }); + return model; + } + + private savePortPositions(element: DfdNodeImpl) { + element.children.forEach((child) => { + if (child instanceof SPortImpl) { + this.previousPositions.set(child.id, { x: child.position.x, y: child.position.y }); + } else if (child instanceof DfdNodeImpl) { + this.savePortPositions(child); + } + }); + } +} diff --git a/src/features/settingsMenu/di.config.ts b/src/features/settingsMenu/di.config.ts new file mode 100644 index 0000000..e299c6b --- /dev/null +++ b/src/features/settingsMenu/di.config.ts @@ -0,0 +1,28 @@ +import { ContainerModule } from "inversify"; +import { SettingsUI } from "./settingsMenu"; +import { ThemeManager } from "./themeManager"; +import { EDITOR_TYPES } from "../../utils"; +import { configureCommand, TYPES } from "sprotty"; +import { + ChangeEdgeLabelVisibilityCommand, + ChangeThemeCommand, + CompleteLayoutProcessCommand, + NodeNameReplacementRegistry, + SimplifyNodeNamesCommand, +} from "./commands"; +import { SettingsManager } from "./SettingsManager"; + +export const settingsModule = new ContainerModule((bind, unbind, isBound, rebind) => { + bind(SettingsManager).toSelf().inSingletonScope(); + bind(NodeNameReplacementRegistry).toSelf().inSingletonScope(); + bind(ThemeManager).toSelf().inSingletonScope(); + bind(SettingsUI).toSelf().inSingletonScope(); + bind(TYPES.IUIExtension).toService(SettingsUI); + bind(EDITOR_TYPES.DefaultUIElement).toService(SettingsUI); + const context = { bind, unbind, isBound, rebind }; + + configureCommand(context, SimplifyNodeNamesCommand); + configureCommand(context, ChangeEdgeLabelVisibilityCommand); + configureCommand(context, CompleteLayoutProcessCommand); + configureCommand(context, ChangeThemeCommand); +}); diff --git a/src/features/settingsMenu/settingsMenu.css b/src/features/settingsMenu/settingsMenu.css new file mode 100644 index 0000000..cc83959 --- /dev/null +++ b/src/features/settingsMenu/settingsMenu.css @@ -0,0 +1,109 @@ +div.settings-ui { + left: 20px; + bottom: 70px; + padding: 10px 10px; +} + +#settings-ui-accordion-label .accordion-button::before { + content: ""; + background-image: url("@fortawesome/fontawesome-free/svgs/solid/gear.svg"); + display: inline-block; + filter: invert(var(--dark-mode)); + height: 16px; + width: 16px; + background-size: 16px 16px; + vertical-align: text-top; +} + +#settings-content { + display: grid; + gap: 8px 6px; + + align-items: center; +} + +#settings-content > label { + grid-column-start: 1; +} + +#settings-content > input, +#settings-content > select, +#settings-content > label.switch { + grid-column-start: 2; +} + +#settings-content select { + background-color: var(--color-background); + color: var(--color-foreground); + border: 1px solid var(--color-foreground); + border-radius: 6px; +} + +.switch input:disabled + .slider { + background-color: color-mix(in srgb, var(--color-primary) 50%, #555 50%); +} + +.switch input:disabled + .slider:before { + background-color: color-mix(in srgb, var(--color-background) 50%, #555 50%); +} + +/* https://www.w3schools.com/HOWTO/howto_css_switch.asp */ +/* The switch - the box around the slider */ +.switch { + position: relative; + display: inline-block; + width: 30px; + height: 17px; +} + +/* Hide default HTML checkbox */ +.switch input { + opacity: 0; + width: 0; + height: 0; +} + +/* The slider */ +.slider { + position: absolute; + cursor: pointer; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: var(--color-background); + -webkit-transition: 0.4s; + transition: 0.4s; +} + +.slider:before { + position: absolute; + content: ""; + height: 13px; + width: 13px; + left: 2px; + bottom: 2px; + background-color: var(--color-primary); + -webkit-transition: 0.3s; + transition: 0.3s; +} + +input:checked + .slider { + background-color: var(--color-background); +} + +input:checked + .slider:before { + -webkit-transform: translateX(13px); + -ms-transform: translateX(13px); + transform: translateX(13px); + background-color: var(--color-foreground); +} + +/* Rounded sliders */ +.slider.round { + border-radius: 17px; +} + +.slider.round:before { + border-radius: 50%; +} diff --git a/src/features/settingsMenu/settingsMenu.ts b/src/features/settingsMenu/settingsMenu.ts new file mode 100644 index 0000000..9e743f8 --- /dev/null +++ b/src/features/settingsMenu/settingsMenu.ts @@ -0,0 +1,113 @@ +import { AbstractUIExtension, ActionDispatcher, TYPES } from "sprotty"; +import { inject, injectable } from "inversify"; + +import "./settingsMenu.css"; +import { Theme, ThemeManager } from "./themeManager"; +import { SettingsManager } from "./SettingsManager"; +import { LayoutMethod } from "./LayoutMethod"; +import { EditorModeController } from "../editorMode/editorModeController"; +import { ChangeEditorModeAction } from "../editorMode/command"; + +@injectable() +export class SettingsUI extends AbstractUIExtension { + static readonly ID = "settings-ui"; + + constructor( + @inject(SettingsManager) protected readonly settings: SettingsManager, + @inject(ThemeManager) protected readonly themeManager: ThemeManager, + @inject(EditorModeController) private editorModeController: EditorModeController, + @inject(TYPES.IActionDispatcher) protected readonly dispatcher: ActionDispatcher, + ) { + super(); + } + + id(): string { + return SettingsUI.ID; + } + + containerClass(): string { + return SettingsUI.ID; + } + + protected initializeContents(containerElement: HTMLElement): void { + containerElement.classList.add("ui-float"); + containerElement.innerHTML = ` + + +
+
+ + + + + + + + + + + + + +
+
+ `; + + // Set `settings-enabled` class on body element when keyboard shortcut overview is open. + const checkbox = containerElement.querySelector("#accordion-state-settings") as HTMLInputElement; + const bodyElement = document.querySelector("body") as HTMLBodyElement; + checkbox.addEventListener("change", () => { + if (checkbox.checked) { + bodyElement.classList.add("settings-enabled"); + } else { + bodyElement.classList.remove("settings-enabled"); + } + }); + + const layoutOptionSelect = containerElement.querySelector("#setting-layout-option") as HTMLSelectElement; + this.settings.bindLayoutMethodSelect(layoutOptionSelect); + + const themeOptionSelect = containerElement.querySelector("#setting-theme") as HTMLSelectElement; + this.themeManager.bindThemeSelect(themeOptionSelect); + + const hideEdgeLabelsCheckbox = containerElement.querySelector("#setting-hide-edge-labels") as HTMLInputElement; + this.settings.bindHideEdgeLabelsCheckbox(hideEdgeLabelsCheckbox); + + const simplifyNodeNamesCheckbox = containerElement.querySelector( + "#setting-simplify-node-names", + ) as HTMLInputElement; + this.settings.bindSimplifyNodeNamesCheckbox(simplifyNodeNamesCheckbox); + + const readOnlyCheckbox = containerElement.querySelector("#setting-read-only") as HTMLInputElement; + this.editorModeController.onModeChange((mode) => { + readOnlyCheckbox.checked = mode !== "edit"; + }); + if (this.editorModeController.isReadOnly()) { + readOnlyCheckbox.checked = true; + } + readOnlyCheckbox.addEventListener("change", () => { + this.dispatcher.dispatch(ChangeEditorModeAction.create(readOnlyCheckbox.checked ? "view" : "edit")); + }); + } +} diff --git a/src/features/settingsMenu/themeManager.ts b/src/features/settingsMenu/themeManager.ts new file mode 100644 index 0000000..df03c4d --- /dev/null +++ b/src/features/settingsMenu/themeManager.ts @@ -0,0 +1,68 @@ +import { inject, injectable, multiInject } from "inversify"; +import { ActionDispatcher, TYPES } from "sprotty"; +import { ChangeThemeAction } from "./actions"; + +export enum Theme { + LIGHT = "Light", + DARK = "Dark", + SYSTEM_DEFAULT = "System Default", +} + +export const SWITCHABLE = Symbol("Switchable"); + +export interface Switchable { + switchTheme(useDark: boolean): void; +} + +@injectable() +export class ThemeManager { + private static _theme: Theme = Theme.SYSTEM_DEFAULT; + private static SYSTEM_DEFAULT = + window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches ? Theme.DARK : Theme.LIGHT; + private themeSelect?: HTMLSelectElement; + + constructor( + @multiInject(SWITCHABLE) protected switchables: Switchable[], + @inject(TYPES.IActionDispatcher) protected readonly dispatcher: ActionDispatcher, + ) { + this.theme = ThemeManager.SYSTEM_DEFAULT; + } + + get useDarkMode(): boolean { + return ThemeManager.useDarkMode; + } + + static get useDarkMode(): boolean { + if (ThemeManager._theme == Theme.SYSTEM_DEFAULT) { + return ThemeManager.SYSTEM_DEFAULT == Theme.DARK; + } + return ThemeManager._theme == Theme.DARK; + } + + get theme(): Theme { + return ThemeManager._theme; + } + + set theme(theme: Theme) { + ThemeManager._theme = theme; + if (this.themeSelect) { + this.themeSelect.value = theme; + } + + const rootElement = document.querySelector(":root") as HTMLElement; + const sprottyElement = document.querySelector("#sprotty") as HTMLElement; + + const value = this.useDarkMode ? "dark" : "light"; + rootElement.setAttribute("data-theme", value); + sprottyElement.setAttribute("data-theme", value); + + this.switchables.forEach((s) => s.switchTheme(this.useDarkMode)); + } + + bindThemeSelect(themeSelect: HTMLSelectElement) { + this.themeSelect = themeSelect; + this.themeSelect.addEventListener("change", () => { + this.dispatcher.dispatch(ChangeThemeAction.create(themeSelect.value as Theme)); + }); + } +} diff --git a/src/index.ts b/src/index.ts index 2f5fe26..d560bb9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -29,6 +29,7 @@ import "sprotty/css/sprotty.css"; import "sprotty/css/edit-label.css"; import "./theme.css"; import "./page.css"; +import { settingsModule } from "./features/settingsMenu/di.config"; import { LoadDiagramAction } from "./features/serialize/load"; const container = new Container(); @@ -46,6 +47,7 @@ container.load(elkLayoutModule); // Custom modules that we provide ourselves container.load( commonModule, + settingsModule, noScrollLabelEditUiModule, autoLayoutModule, dfdElementsModule, diff --git a/src/utils.ts b/src/utils.ts index 27a5848..5f64cfa 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,5 +1,5 @@ import { SModelRootImpl } from "sprotty"; -import { FitToScreenAction, getBasicType } from "sprotty-protocol"; +import { FitToScreenAction, getBasicType, SModelRoot } from "sprotty-protocol"; /** * Type identifiers for use with inversify. @@ -18,8 +18,8 @@ export const FIT_TO_SCREEN_PADDING = 75; * Generates a fit to screen action that fits all nodes on the screen * with the default padding. */ -export function createDefaultFitToScreenAction(root: SModelRootImpl, animate = true): FitToScreenAction { - const elementIds = root.children.filter((child) => getBasicType(child) === "node").map((child) => child.id); +export function createDefaultFitToScreenAction(root: SModelRootImpl | SModelRoot, animate = true): FitToScreenAction { + const elementIds = root.children?.filter((child) => getBasicType(child) === "node").map((child) => child.id) ?? []; return FitToScreenAction.create(elementIds, { padding: FIT_TO_SCREEN_PADDING,