-
1
-
2
-
3
-
4
-
5
-
6
-
7
-
8
-
9
-
10
-
11
-
12
-
13
-
14
-
15
-
16
-
17
-
18
-
19
-
20
-
21
-
22
-
23
-
24
-
25
-
26
-
27
-
28
-
29
-
30
-
31
-
32
-
33
-
34
-
35
-
36
-
37
-
38
-
39
-
40
-
41
-
42
-
43
-
44
-
45
-
46
-
47
-
48
-
49
-
50
-
51
-
52
-
53
-
54
-
55
-
56
-
57
-
58
-
59
-
60
-
61
-
62
-
63
-
64
-
65
-
66
-
67
-
68
-
69
-
70
-
71
-
72
-
73
-
74
-
75
-
76
-
77
-
78
-
79
-
80
-
81
-
82
-
83
-
84
-
85
-
86
-
87
-
88
-
89
-
90
-
91
-
92
-
93
-
94
-
95
-
96
-
97
-
98
-
99
-
100
-
101
-
102
-
103
-
104
-
105
-
106
-
107
-
108
-
109
-
110
-
111
-
112
-
113
-
114
-
115
-
116
-
117
-
118
-
119
-
120
-
121
-
122
-
123
-
124
-
125
-
126
-
127
-
128
-
129
-
130
-
131
-
132
-
133
-
134
-
135
-
136
-
137
-
138
-
139
-
140
-
141
-
142
-
143
-
144
-
145
-
146
-
147
-
148
-
149
-
150
-
151
-
152
-
153
-
154
-
155
-
156
-
157
-
158
-
159
-
160
-
161
-
162
-
163
-
164
-
165
-
166
-
167
-
168
-
169
-
170
-
171
-
172
-
173
-
174
-
175
-
176
-
177
-
178
-
179
-
180
-
181
-
182
-
183
-
184
-
185
-
186
-
187
-
188
-
189
-
190
-
191
-
192
-
193
-
194
import { LitElement, property } from "lit-element";
import type { Constructor } from "./utils";
export interface Positioned {
panX: number;
panY: number;
scale: number;
zoomSpeed: number;
panSpeed: number;
isMovable: boolean;
}
/**
* @property {number} panX
*/
export const PositionedMixin = <T extends Constructor<LitElement>>(
superClass: T
): T & Constructor<Positioned> => {
class Positioned extends superClass {
@property({
attribute: false,
})
panX: number = 0;
@property({
attribute: false,
})
panY: number = 0;
@property({
attribute: false,
})
scale: number = 1;
@property({
type: Number,
attribute: "zoom-speed",
})
zoomSpeed: number = 500;
@property({
type: Number,
attribute: "pan-speed",
})
panSpeed: number = 500;
get isMovable() {
return true;
}
#isDragModeOn: boolean = false;
constructor(...args: any[]) {
super(...args);
this.addEventListener(
"wheel",
(ev) => {
if (!this.isMovable) return;
ev.preventDefault();
if (ev.ctrlKey) {
// Performs zoom when ctrl key is pressed.
let { deltaY } = ev;
if (ev.deltaMode === 1) {
// Firefox quirk
deltaY *= 15;
}
const prevScale = this.scale;
this.scale *= 1 - deltaY / ((1000 - this.zoomSpeed) * 0.5);
// Performs pan to archive "zoom at the point" behavior (I don't know how to call it).
const offsetX = ev.offsetX - this.offsetWidth / 2;
const offsetY = ev.offsetY - this.offsetHeight / 2;
this.panX += offsetX / this.scale - offsetX / prevScale;
this.panY += offsetY / this.scale - offsetY / prevScale;
} else {
// Performs pan otherwise (to be close to native behavior)
// Adjusting panSpeed in order to make panSpeed=500 to match to the Figma's one.
const speed = this.panSpeed * 0.002;
this.panX -= (ev.deltaX * speed) / this.scale;
this.panY -= (ev.deltaY * speed) / this.scale;
}
},
// This component prevents every native wheel behavior on it.
{ passive: false }
);
this.addEventListener("pointermove", (ev) => {
// Performs pan only when middle buttons is pressed.
//
// 4 ... Auxiliary button (usually the mouse wheel button or middle button)
// https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent/buttons
if (!(ev.buttons & 4)) return;
ev.preventDefault();
// Moving amount of middle button+pointer move panning should matches to the actual
// pointer travel distance. Since translate goes after scaling, we need to scale
// delta too.
this.#movePanel(ev.movementX, ev.movementY);
});
// Listen to keyboard events to enable dragging when Space is pressed, just like in Figma
this.#listenToKeyboardEvents();
/** @private */
this.onmousedown = () => {
if (this.#isDragModeOn) {
document.body.style.cursor = "grabbing";
this.onmousemove = ({ movementX, movementY }: MouseEvent) => {
this.#movePanel(movementX, movementY);
};
// cleanup unnecessary listeners when user stops dragging
this.onmouseup = () => {
document.body.style.cursor = "grab";
this.onmousemove = null;
this.onmouseup = null;
};
}
};
}
disconnectedCallback() {
document.removeEventListener("keyup", this.#keyUp);
document.removeEventListener("keydown", this.#keyDown);
super.disconnectedCallback();
}
// Dispatch events when the position-related value changes.
updated(changedProperties: Parameters<LitElement["updated"]>[0]) {
super.updated(changedProperties);
if (changedProperties.has("scale")) {
this.dispatchEvent(
new CustomEvent<{ scale: number }>("scalechange", {
detail: {
scale: this.scale,
},
})
);
}
if (changedProperties.has("panX") || changedProperties.has("panY")) {
this.dispatchEvent(
new CustomEvent<{ x: number; y: number }>("positionchange", {
detail: {
x: this.panX,
y: this.panY,
},
})
);
}
}
#movePanel = (shiftX: number, shiftY: number) => {
this.panX += shiftX / this.scale / window.devicePixelRatio;
this.panY += shiftY / this.scale / window.devicePixelRatio;
};
// Enable drag mode when holding the spacebar
#keyDown = (event: KeyboardEvent) => {
if (event.code === "Space" && !this.#isDragModeOn) {
this.#isDragModeOn = true;
document.body.style.cursor = "grab";
}
};
// Disable drag mode when space lets the spacebar go
#keyUp = (event: KeyboardEvent) => {
if (event.code === "Space" && this.#isDragModeOn) {
this.#isDragModeOn = false;
document.body.style.cursor = "auto";
}
};
#listenToKeyboardEvents = () => {
document.addEventListener("keyup", this.#keyUp);
document.addEventListener("keydown", this.#keyDown);
};
}
return Positioned;
};