Skip to content

Commit ab936c7

Browse files
authored
GLSP-1531: Introduce viewport change event (#438)
* GLSP-1531: Introduce viewport change event Fixes eclipse-glsp/glsp/issues/1531 * Address review feedback
1 parent 2f70b50 commit ab936c7

File tree

8 files changed

+263
-49
lines changed

8 files changed

+263
-49
lines changed

packages/client/src/base/command-stack.ts

Lines changed: 19 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/********************************************************************************
2-
* Copyright (c) 2019-2024 EclipseSource and others.
2+
* Copyright (c) 2019-2025 EclipseSource and others.
33
*
44
* This program and the accompanying materials are made available under the
55
* terms of the Eclipse Public License v. 2.0 which is available at
@@ -15,25 +15,37 @@
1515
********************************************************************************/
1616
import {
1717
CommandExecutionContext,
18+
CommandExecutionData,
1819
CommandStack,
1920
Disposable,
2021
DisposableCollection,
22+
Emitter,
2123
Event,
2224
GModelRoot,
2325
ICommand,
24-
LazyInjector,
25-
SetModelCommand,
26-
UpdateModelCommand
26+
ICommandStack,
27+
LazyInjector
2728
} from '@eclipse-glsp/sprotty';
28-
import { inject, injectable, preDestroy } from 'inversify';
29+
import { inject, injectable, postConstruct, preDestroy } from 'inversify';
2930
import { EditorContextService } from './editor-context-service';
3031

3132
@injectable()
32-
export class GLSPCommandStack extends CommandStack implements Disposable {
33+
export class GLSPCommandStack extends CommandStack implements ICommandStack, Disposable {
3334
@inject(LazyInjector)
3435
protected lazyInjector: LazyInjector;
3536
protected toDispose = new DisposableCollection();
3637

38+
protected onCommandExecutedEmitter = new Emitter<CommandExecutionData>();
39+
get onCommandExecuted(): Event<CommandExecutionData> {
40+
return this.onCommandExecutedEmitter.event;
41+
}
42+
43+
@postConstruct()
44+
protected override initialize(): void {
45+
super.initialize();
46+
this.toDispose.push(this.onCommandExecutedEmitter);
47+
}
48+
3749
@preDestroy()
3850
dispose(): void {
3951
this.toDispose.dispose();
@@ -95,13 +107,7 @@ export class GLSPCommandStack extends CommandStack implements Disposable {
95107
}
96108
override async execute(command: ICommand): Promise<GModelRoot> {
97109
const result = await super.execute(command);
98-
if (command instanceof SetModelCommand || command instanceof UpdateModelCommand) {
99-
this.notifyListeners(result);
100-
}
110+
this.onCommandExecutedEmitter.fire({ command, newRoot: result });
101111
return result;
102112
}
103-
104-
protected notifyListeners(root: Readonly<GModelRoot>): void {
105-
this.editorContext.notifyModelRootChanged(root, this);
106-
}
107113
}

packages/client/src/base/default.module.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ import { FocusStateChangedAction } from './focus/focus-state-change-action';
4646
import { FocusTracker } from './focus/focus-tracker';
4747
import { DiagramLoader } from './model/diagram-loader';
4848
import { GLSPModelSource } from './model/glsp-model-source';
49+
import { ModelChangeService } from './model/model-change-service';
4950
import { DefaultModelInitializationConstraint, ModelInitializationConstraint } from './model/model-initialization-constraint';
5051
import { GModelRegistry } from './model/model-registry';
5152
import { GLSPMousePositionTracker } from './mouse-position-tracker';
@@ -77,6 +78,7 @@ export const defaultModule = new FeatureModule(
7778
bind(TYPES.IEditorContextServiceProvider).toProvider<EditorContextService>(
7879
ctx => async () => ctx.container.get(EditorContextService)
7980
);
81+
bind(TYPES.IModelChangeService).to(ModelChangeService).inSingletonScope();
8082

8183
configureActionHandler(context, SetEditModeAction.KIND, EditorContextService);
8284
configureActionHandler(context, SetDirtyStateAction.KIND, EditorContextService);

packages/client/src/base/editor-context-service.ts

Lines changed: 22 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,8 @@
1515
********************************************************************************/
1616
import {
1717
Action,
18-
AnyObject,
1918
Args,
2019
Bounds,
21-
CommandStack,
2220
Disposable,
2321
DisposableCollection,
2422
EditMode,
@@ -44,6 +42,7 @@ import {
4442
import { inject, injectable, postConstruct, preDestroy } from 'inversify';
4543
import { FocusChange, FocusTracker } from './focus/focus-tracker';
4644
import { IDiagramOptions, IDiagramStartup } from './model/diagram-loader';
45+
import { IModelChangeService, ViewportChange } from './model/model-change-service';
4746
import { SelectionChange, SelectionService } from './selection-service';
4847

4948
/**
@@ -85,6 +84,9 @@ export class EditorContextService implements IActionHandler, Disposable, IDiagra
8584
@inject(SelectionService)
8685
protected selectionService: SelectionService;
8786

87+
@inject(TYPES.IModelChangeService)
88+
protected modelChangeService: IModelChangeService;
89+
8890
@inject(MousePositionTracker)
8991
protected mousePositionTracker: MousePositionTracker;
9092

@@ -118,13 +120,11 @@ export class EditorContextService implements IActionHandler, Disposable, IDiagra
118120
return this.onDirtyStateChangedEmitter.event;
119121
}
120122

121-
protected _modelRoot?: Readonly<GModelRoot>;
122123
/**
123124
* Event that is fired when the model root of the diagram changes i.e. after the `CommandStack` has processed a model update.
124125
*/
125-
protected onModelRootChangedEmitter = new Emitter<Readonly<GModelRoot>>();
126126
get onModelRootChanged(): Event<Readonly<GModelRoot>> {
127-
return this.onModelRootChangedEmitter.event;
127+
return this.modelChangeService.onModelRootChanged;
128128
}
129129

130130
/**
@@ -143,6 +143,14 @@ export class EditorContextService implements IActionHandler, Disposable, IDiagra
143143
return this.selectionService.onSelectionChanged;
144144
}
145145

146+
/**
147+
* Event that is fired when the viewport of the diagram changes i.e. after the `CommandStack` has processed a viewport update.
148+
* By default, this event is only fired if the viewport was changed via a `SetViewportCommand` or `BoundsAwareViewportCommand`
149+
*/
150+
get onViewportChanged(): Event<ViewportChange> {
151+
return this.modelChangeService.onViewportChanged;
152+
}
153+
146154
protected toDispose = new DisposableCollection();
147155

148156
@postConstruct()
@@ -151,6 +159,11 @@ export class EditorContextService implements IActionHandler, Disposable, IDiagra
151159
this.toDispose.push(this.onEditModeChangedEmitter, this.onDirtyStateChangedEmitter);
152160
}
153161

162+
@preDestroy()
163+
dispose(): void {
164+
this.toDispose.dispose();
165+
}
166+
154167
preLoadDiagram(): MaybePromise<void> {
155168
this.lazyInjector.getAll<IGModelRootListener>(TYPES.IGModelRootListener).forEach(listener => {
156169
this.onModelRootChanged(event => listener.modelRootChanged(event));
@@ -160,11 +173,6 @@ export class EditorContextService implements IActionHandler, Disposable, IDiagra
160173
});
161174
}
162175

163-
@preDestroy()
164-
dispose(): void {
165-
this.toDispose.dispose();
166-
}
167-
168176
get(args?: Args): EditorContext {
169177
return {
170178
selectedElementIds: Array.from(this.selectionService.getSelectedElementIDs()),
@@ -181,21 +189,6 @@ export class EditorContextService implements IActionHandler, Disposable, IDiagra
181189
};
182190
}
183191

184-
/**
185-
* Notifies the service about a model root change. This method should not be called
186-
* directly. It is called by the `CommandStack` after a model update has been processed.
187-
* @throws an error if the notifier is not a `CommandStack`
188-
* @param root the new model root
189-
* @param notifier the object that triggered the model root change
190-
*/
191-
notifyModelRootChanged(root: Readonly<GModelRoot>, notifier: AnyObject): void {
192-
if (!(notifier instanceof CommandStack)) {
193-
throw new Error('Invalid model root change notification. Notifier is not an instance of `CommandStack`.');
194-
}
195-
this._modelRoot = root;
196-
this.onModelRootChangedEmitter.fire(root);
197-
}
198-
199192
handle(action: Action): void {
200193
if (SetEditModeAction.is(action)) {
201194
this.handleSetEditModeAction(action);
@@ -234,14 +227,14 @@ export class EditorContextService implements IActionHandler, Disposable, IDiagra
234227
}
235228

236229
get modelRoot(): Readonly<GModelRoot> {
237-
if (!this._modelRoot) {
230+
if (!this.modelChangeService.currentRoot) {
238231
throw new Error('Model root not available yet');
239232
}
240-
return this._modelRoot;
233+
return this.modelChangeService.currentRoot;
241234
}
242235

243236
get viewport(): Readonly<GModelRoot & Viewport> | undefined {
244-
return this._modelRoot ? findParentByFeature(this._modelRoot, isViewport) : undefined;
237+
return this.modelRoot ? findParentByFeature(this.modelRoot, isViewport) : undefined;
245238
}
246239

247240
get viewportData(): Readonly<Viewport> {
@@ -255,7 +248,7 @@ export class EditorContextService implements IActionHandler, Disposable, IDiagra
255248

256249
get canvasBounds(): Readonly<Bounds> {
257250
// default value aligned with the initialization of canvasBounds in GModelRoot
258-
return this._modelRoot?.canvasBounds ?? Bounds.EMPTY;
251+
return this.modelRoot?.canvasBounds ?? Bounds.EMPTY;
259252
}
260253

261254
get selectedElements(): Readonly<GModelElement>[] {

packages/client/src/base/focus/focus-tracker.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,8 @@
1313
*
1414
* SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0
1515
********************************************************************************/
16-
import { Action, Emitter, Event, IActionHandler, ICommand, TYPES, ViewerOptions } from '@eclipse-glsp/sprotty';
17-
import { inject, injectable } from 'inversify';
16+
import { Action, Disposable, Emitter, Event, IActionHandler, ICommand, TYPES, ViewerOptions } from '@eclipse-glsp/sprotty';
17+
import { inject, injectable, preDestroy } from 'inversify';
1818
import { FocusStateChangedAction } from './focus-state-change-action';
1919

2020
export interface FocusChange {
@@ -29,7 +29,7 @@ export interface FocusChange {
2929
* Allows querying of the current focus state and the focused root diagram element and the currently focused element within the diagram.
3030
*/
3131
@injectable()
32-
export class FocusTracker implements IActionHandler {
32+
export class FocusTracker implements IActionHandler, Disposable {
3333
protected inActiveCssClass = 'inactive';
3434
protected _hasFocus = true;
3535
protected _focusElement: HTMLOrSVGElement | null;
@@ -77,4 +77,9 @@ export class FocusTracker implements IActionHandler {
7777
}
7878
this.onFocusChangedEmitter.fire({ hasFocus: this.hasFocus, focusElement: this.focusElement, diagramElement: this.diagramElement });
7979
}
80+
81+
@preDestroy()
82+
dispose(): void {
83+
this.onFocusChangedEmitter.dispose();
84+
}
8085
}
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
/********************************************************************************
2+
* Copyright (c) 2025 EclipseSource and others.
3+
*
4+
* This program and the accompanying materials are made available under the
5+
* terms of the Eclipse Public License v. 2.0 which is available at
6+
* http://www.eclipse.org/legal/epl-2.0.
7+
*
8+
* This Source Code may also be made available under the following Secondary
9+
* Licenses when the conditions for such availability set forth in the Eclipse
10+
* Public License v. 2.0 are satisfied: GNU General Public License, version 2
11+
* with the GNU Classpath Exception which is available at
12+
* https://www.gnu.org/software/classpath/license.html.
13+
*
14+
* SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0
15+
********************************************************************************/
16+
import {
17+
BoundsAwareViewportCommand,
18+
Disposable,
19+
DisposableCollection,
20+
Emitter,
21+
Event,
22+
GModelRoot,
23+
ICommand,
24+
ICommandStack,
25+
LazyInjector,
26+
SetModelCommand,
27+
SetViewportCommand,
28+
TYPES,
29+
UpdateModelCommand,
30+
Viewport,
31+
almostEquals,
32+
isViewport
33+
} from '@eclipse-glsp/sprotty';
34+
import { inject, postConstruct, preDestroy } from 'inversify';
35+
36+
/**
37+
* Service that tracks changes to the model root and the viewport.
38+
* Allows to register listeners that are notified when the model root or the viewport changes.
39+
* The current model root can be queried at any time.
40+
*/
41+
export interface IModelChangeService {
42+
/** The current model root */
43+
readonly currentRoot: Readonly<GModelRoot> | undefined;
44+
/**
45+
* Event that is fired when the model root of the diagram changes i.e. after the `CommandStack` has processed a model update.
46+
*/
47+
onModelRootChanged: Event<Readonly<GModelRoot>>;
48+
49+
/**
50+
* Event that is fired when the viewport of the diagram changes i.e. after the `CommandStack` has processed a viewport update.
51+
* By default, this event is only fired if the viewport was changed via a `SetViewportCommand` or `BoundsAwareViewportCommand`
52+
*/
53+
onViewportChanged: Event<ViewportChange>;
54+
}
55+
56+
/**
57+
* Event data for the {@link IModelChangeService.onViewportChanged} event.
58+
*/
59+
export interface ViewportChange {
60+
/** The new viewport */
61+
newViewport: Readonly<Viewport>;
62+
/** The old viewport */
63+
oldViewport?: Readonly<Viewport>;
64+
}
65+
66+
export class ModelChangeService implements IModelChangeService, Disposable {
67+
@inject(LazyInjector)
68+
protected lazyInjector: LazyInjector;
69+
protected _currentRoot?: Readonly<GModelRoot>;
70+
protected lastViewport?: Readonly<Viewport>;
71+
protected toDispose = new DisposableCollection();
72+
73+
get currentRoot(): Readonly<GModelRoot> | undefined {
74+
return this._currentRoot;
75+
}
76+
77+
protected get commandStack(): ICommandStack {
78+
return this.lazyInjector.get<ICommandStack>(TYPES.ICommandStack);
79+
}
80+
81+
protected onModelRootChangedEmitter = new Emitter<Readonly<GModelRoot>>();
82+
get onModelRootChanged(): Event<Readonly<GModelRoot>> {
83+
return this.onModelRootChangedEmitter.event;
84+
}
85+
86+
protected onViewportChangedEmitter = new Emitter<ViewportChange>();
87+
get onViewportChanged(): Event<ViewportChange> {
88+
return this.onViewportChangedEmitter.event;
89+
}
90+
91+
@postConstruct()
92+
protected initialize(): void {
93+
this.toDispose.push(this.onModelRootChangedEmitter, this.onViewportChangedEmitter);
94+
this.commandStack.onCommandExecuted(data => this.handleCommandExecution(data.command, data.newRoot));
95+
}
96+
97+
@preDestroy()
98+
dispose(): void {
99+
this.toDispose.dispose();
100+
}
101+
102+
protected handleCommandExecution(command: ICommand, newRoot: GModelRoot): void {
103+
if (this.isModelRootChangeCommand(command)) {
104+
this.handleModelRootChangeCommand(command, newRoot);
105+
}
106+
if (this.isViewportChangeCommand(command)) {
107+
this.handleViewportChangeCommand(command, newRoot);
108+
}
109+
}
110+
111+
protected isModelRootChangeCommand(command: ICommand): boolean {
112+
return command instanceof SetModelCommand || command instanceof UpdateModelCommand;
113+
}
114+
115+
protected isViewportChangeCommand(command: ICommand): boolean {
116+
return command instanceof SetViewportCommand || command instanceof BoundsAwareViewportCommand;
117+
}
118+
119+
protected handleModelRootChangeCommand(command: ICommand, newRoot: GModelRoot): void {
120+
this._currentRoot = newRoot;
121+
this.lastViewport = this.toViewport(newRoot);
122+
this.onModelRootChangedEmitter.fire(newRoot);
123+
}
124+
125+
protected handleViewportChangeCommand(command: ICommand, newRoot: GModelRoot): void {
126+
const viewport = this.toViewport(newRoot);
127+
if (!viewport) {
128+
return;
129+
}
130+
131+
if (this.hasViewportChanged(viewport)) {
132+
this.onViewportChangedEmitter.fire({ newViewport: viewport, oldViewport: this.lastViewport });
133+
this.lastViewport = viewport;
134+
}
135+
}
136+
137+
protected hasViewportChanged(newViewport: Readonly<Viewport>): boolean {
138+
if (!this.lastViewport) {
139+
return true;
140+
}
141+
return !(
142+
almostEquals(newViewport.zoom, this.lastViewport.zoom) &&
143+
almostEquals(newViewport.scroll.x, this.lastViewport.scroll.x) &&
144+
almostEquals(newViewport.scroll.y, this.lastViewport.scroll.y)
145+
);
146+
}
147+
148+
protected toViewport(root: Readonly<GModelRoot>): Readonly<Viewport> | undefined {
149+
return isViewport(root) ? { scroll: root.scroll, zoom: root.zoom } : undefined;
150+
}
151+
}

0 commit comments

Comments
 (0)