@readium/navigator 2.10.3 → 2.11.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/dist/audio/AudioNavigator.js +1 -1
- package/dist/audio/AudioPoolManager.js +1 -1
- package/dist/audio/engine/WebAudioEngine.js +1 -1
- package/dist/divina/DivinaNavigator.js +1 -0
- package/dist/divina/DivinaPageManager.js +1 -0
- package/dist/divina/DivinaPagedPresenter.js +1 -0
- package/dist/divina/DivinaPeripherals.js +1 -0
- package/dist/divina/DivinaScrolledPresenter.js +1 -0
- package/dist/divina/DivinaSpreader.js +1 -0
- package/dist/divina/DivinaVariantSelector.js +1 -0
- package/dist/divina/preferences/DivinaDefaults.js +1 -0
- package/dist/divina/preferences/DivinaPreferences.js +1 -0
- package/dist/divina/preferences/DivinaPreferencesEditor.js +1 -0
- package/dist/divina/preferences/DivinaSettings.js +1 -0
- package/dist/divina/protection/DivinaNavigatorProtector.js +1 -0
- package/dist/epub/EpubNavigator.js +1 -1
- package/dist/epub/frame/FrameBlobBuilder.js +3 -1
- package/dist/epub/frame/FrameManager.js +1 -1
- package/dist/epub/frame/FramePoolManager.js +1 -1
- package/dist/epub/fxl/FXLFrameManager.js +1 -1
- package/dist/epub/fxl/FXLFramePoolManager.js +1 -1
- package/dist/epub/fxl/FXLPeripherals.js +2 -2
- package/dist/index.js +1 -1
- package/dist/peripherals/KeyboardConditionBridge.js +1 -1
- package/dist/peripherals/KeyboardPeripherals.js +1 -1
- package/dist/webpub/WebPubFrameManager.js +1 -1
- package/dist/webpub/WebPubFramePoolManager.js +1 -1
- package/dist/webpub/WebPubNavigator.js +1 -1
- package/package.json +5 -5
- package/src/audio/AudioNavigator.ts +107 -5
- package/src/audio/AudioPoolManager.ts +14 -8
- package/src/audio/engine/AudioEngine.ts +34 -1
- package/src/audio/engine/WebAudioEngine.ts +48 -3
- package/src/divina/DivinaNavigator.ts +704 -0
- package/src/divina/DivinaPageManager.ts +322 -0
- package/src/divina/DivinaPagedPresenter.ts +508 -0
- package/src/divina/DivinaPeripherals.ts +630 -0
- package/src/divina/DivinaScrolledPresenter.ts +516 -0
- package/src/divina/DivinaSpreader.ts +202 -0
- package/src/divina/DivinaVariantSelector.ts +77 -0
- package/src/divina/index.ts +8 -0
- package/src/divina/preferences/DivinaDefaults.ts +37 -0
- package/src/divina/preferences/DivinaPreferences.ts +77 -0
- package/src/divina/preferences/DivinaPreferencesEditor.ts +96 -0
- package/src/divina/preferences/DivinaSettings.ts +38 -0
- package/src/divina/preferences/index.ts +4 -0
- package/src/divina/protection/DivinaNavigatorProtector.ts +44 -0
- package/src/epub/EpubNavigator.ts +73 -7
- package/src/epub/frame/FrameBlobBuilder.ts +144 -39
- package/src/epub/frame/FrameManager.ts +7 -9
- package/src/epub/frame/FramePoolManager.ts +106 -48
- package/src/epub/fxl/FXLFrameManager.ts +69 -26
- package/src/epub/fxl/FXLFramePoolManager.ts +98 -53
- package/src/epub/fxl/FXLPeripherals.ts +22 -1
- package/src/index.ts +1 -0
- package/src/peripherals/KeyboardConditionBridge.ts +15 -5
- package/src/peripherals/KeyboardPeripherals.ts +13 -7
- package/src/webpub/WebPubFrameManager.ts +7 -9
- package/src/webpub/WebPubFramePoolManager.ts +5 -2
- package/src/webpub/WebPubNavigator.ts +44 -0
- package/types/src/audio/AudioNavigator.d.ts +29 -0
- package/types/src/audio/engine/AudioEngine.d.ts +27 -1
- package/types/src/audio/engine/WebAudioEngine.d.ts +14 -2
- package/types/src/divina/DivinaNavigator.d.ts +119 -0
- package/types/src/divina/DivinaPageManager.d.ts +66 -0
- package/types/src/divina/DivinaPagedPresenter.d.ts +122 -0
- package/types/src/divina/DivinaPeripherals.d.ts +121 -0
- package/types/src/divina/DivinaScrolledPresenter.d.ts +125 -0
- package/types/src/divina/DivinaSpreader.d.ts +65 -0
- package/types/src/divina/DivinaVariantSelector.d.ts +30 -0
- package/types/src/divina/index.d.ts +8 -0
- package/types/src/divina/preferences/DivinaDefaults.d.ts +18 -0
- package/types/src/divina/preferences/DivinaPreferences.d.ts +33 -0
- package/types/src/divina/preferences/DivinaPreferencesEditor.d.ts +20 -0
- package/types/src/divina/preferences/DivinaSettings.d.ts +26 -0
- package/types/src/divina/preferences/index.d.ts +4 -0
- package/types/src/divina/protection/DivinaNavigatorProtector.d.ts +14 -0
- package/types/src/epub/EpubNavigator.d.ts +7 -1
- package/types/src/epub/frame/FrameBlobBuilder.d.ts +19 -3
- package/types/src/epub/frame/FramePoolManager.d.ts +5 -1
- package/types/src/epub/fxl/FXLFrameManager.d.ts +2 -0
- package/types/src/epub/fxl/FXLFramePoolManager.d.ts +8 -2
- package/types/src/epub/fxl/FXLPeripherals.d.ts +3 -0
- package/types/src/index.d.ts +1 -0
- package/types/src/peripherals/KeyboardPeripherals.d.ts +1 -0
- package/types/src/webpub/WebPubNavigator.d.ts +2 -0
|
@@ -0,0 +1,630 @@
|
|
|
1
|
+
import { FXLCoordinator, Point } from "../epub/fxl/FXLCoordinator.ts";
|
|
2
|
+
import { isTypedOMSupported, PanTracker, PinchTracker } from "../epub/fxl/FXLPeripherals.ts";
|
|
3
|
+
|
|
4
|
+
const MAX_SCALE = 6; // 6x zoom
|
|
5
|
+
const MIN_SCALE = 1.02;
|
|
6
|
+
const ZOOM_STEP = 0.5; // Zoom in/out increment (keyboard)
|
|
7
|
+
const ZOOM_OVERSCROLL_THRESHOLD = 50;
|
|
8
|
+
const CLICK_SLOP = 5; // px of movement below which a pointer interaction is a click/tap
|
|
9
|
+
const WHEEL_COOLDOWN = 200; // ms of wheel quiet before a new gesture can turn a page
|
|
10
|
+
const DOUBLE_TAP_WINDOW = 300; // ms between taps to count as a double tap
|
|
11
|
+
const DOUBLE_TAP_SLOP = 60; // px between taps to count as a double tap
|
|
12
|
+
|
|
13
|
+
export type DivinaManagerEventKey = "no_more" | "no_less" | "zoom" | "tap" | "click";
|
|
14
|
+
|
|
15
|
+
export interface DivinaPointerEvent {
|
|
16
|
+
x: number; // relative to the book element, multiplied by devicePixelRatio
|
|
17
|
+
y: number;
|
|
18
|
+
clientX: number;
|
|
19
|
+
clientY: number;
|
|
20
|
+
doNotDisturb: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The narrow surface of DivinaPagedPresenter that the peripherals interact with.
|
|
25
|
+
* Mirrors what FXLPeripherals uses from FXLFramePoolManager.
|
|
26
|
+
*/
|
|
27
|
+
export interface DivinaPagedManager {
|
|
28
|
+
readonly spineElement: HTMLDivElement;
|
|
29
|
+
readonly bookElement: HTMLDivElement;
|
|
30
|
+
readonly width: number;
|
|
31
|
+
readonly height: number;
|
|
32
|
+
readonly perPage: number;
|
|
33
|
+
readonly rtl: boolean;
|
|
34
|
+
readonly threshold: number;
|
|
35
|
+
readonly length: number; // Total slot count in the current mode
|
|
36
|
+
readonly currentSlide: number;
|
|
37
|
+
readonly currentBounds: DOMRect;
|
|
38
|
+
listener(key: DivinaManagerEventKey, data: unknown): void;
|
|
39
|
+
updateBookStyle(initial?: boolean): void;
|
|
40
|
+
updateSpineStyle(animate: boolean, fast?: boolean): void;
|
|
41
|
+
slideToCurrent(enableTransition?: boolean, fast?: boolean): void;
|
|
42
|
+
deselect(): void;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Input handling for the paged Divina presenter. Ported from FXLPeripherals
|
|
47
|
+
* (touch pan/pinch-zoom/swipe state machines) with additions for content that
|
|
48
|
+
* lives in the host DOM instead of iframes: click/tap synthesis, mouse wheel
|
|
49
|
+
* page turning (one page per wheel burst, xbreader/bibi-like), ctrl+wheel and
|
|
50
|
+
* double-click zoom.
|
|
51
|
+
*/
|
|
52
|
+
export class DivinaPeripherals {
|
|
53
|
+
private readonly manager: DivinaPagedManager;
|
|
54
|
+
private readonly coordinator: FXLCoordinator;
|
|
55
|
+
|
|
56
|
+
public dragState = 0;
|
|
57
|
+
private minimumMoved = false;
|
|
58
|
+
public pan: PanTracker = {
|
|
59
|
+
startX: 0,
|
|
60
|
+
endX: 0,
|
|
61
|
+
startY: 0,
|
|
62
|
+
overscrollX: 0,
|
|
63
|
+
overscrollY: 0,
|
|
64
|
+
letItGo: false,
|
|
65
|
+
preventClick: false,
|
|
66
|
+
translateX: 0,
|
|
67
|
+
translateY: 0,
|
|
68
|
+
touchID: 0
|
|
69
|
+
};
|
|
70
|
+
private pinch: PinchTracker = {
|
|
71
|
+
startDistance: 0,
|
|
72
|
+
startScale: 0,
|
|
73
|
+
target: { X: 0, Y: 0 },
|
|
74
|
+
touchN: 0,
|
|
75
|
+
startTranslate: { X: 0, Y: 0 }
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
// Scale
|
|
79
|
+
private _scale = 1;
|
|
80
|
+
public get scale() {
|
|
81
|
+
return this._scale;
|
|
82
|
+
}
|
|
83
|
+
private scaleDebouncer = 0;
|
|
84
|
+
public set scale(value: number) {
|
|
85
|
+
if(isNaN(value)) value = 1;
|
|
86
|
+
window.clearTimeout(this.scaleDebouncer);
|
|
87
|
+
this.scaleDebouncer = window.setTimeout(() => {
|
|
88
|
+
if(this.dragState === 0) {
|
|
89
|
+
if(this.scale < MIN_SCALE) {
|
|
90
|
+
this.pan.translateX = 0;
|
|
91
|
+
this.pan.translateY = 0;
|
|
92
|
+
this.clearPan();
|
|
93
|
+
this.manager.updateBookStyle();
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
this.manager.listener("zoom", value);
|
|
97
|
+
}, 100);
|
|
98
|
+
this._scale = value;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
private frameBounds: DOMRect | null = null;
|
|
102
|
+
private destroyed = false;
|
|
103
|
+
|
|
104
|
+
// Mouse click tracking
|
|
105
|
+
private mouseDown: Point | null = null;
|
|
106
|
+
private clickTimer = 0;
|
|
107
|
+
private lastTouchEnd = 0;
|
|
108
|
+
|
|
109
|
+
// Tap tracking (double-tap detection)
|
|
110
|
+
private tapTimer = 0;
|
|
111
|
+
private lastTapTime = 0;
|
|
112
|
+
private lastTapX = 0;
|
|
113
|
+
private lastTapY = 0;
|
|
114
|
+
|
|
115
|
+
// Wheel gesture state (one page turn per burst)
|
|
116
|
+
private wheelHot = false;
|
|
117
|
+
private wheelLastDir = 0;
|
|
118
|
+
private wheelLastMag = 0;
|
|
119
|
+
private wheelDecelCount = 0;
|
|
120
|
+
private wheelLastEvent = 0;
|
|
121
|
+
private wheelCooldownTimer = 0;
|
|
122
|
+
|
|
123
|
+
constructor(manager: DivinaPagedManager) {
|
|
124
|
+
this.manager = manager;
|
|
125
|
+
this.coordinator = new FXLCoordinator();
|
|
126
|
+
this.observe(this.manager.spineElement);
|
|
127
|
+
this.manager.bookElement.addEventListener("wheel", this.bwheelHandler, { passive: false });
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
private readonly btouchstartHandler = this.touchstartHandler.bind(this);
|
|
131
|
+
private readonly btouchendHandler = this.touchendHandler.bind(this);
|
|
132
|
+
private readonly btouchmoveHandler = this.touchmoveHandler.bind(this);
|
|
133
|
+
private readonly bdblclickHandler = this.dblclickHandler.bind(this);
|
|
134
|
+
private readonly bmousedownHandler = this.mousedownHandler.bind(this);
|
|
135
|
+
private readonly bmouseupHandler = this.mouseupHandler.bind(this);
|
|
136
|
+
private readonly bmousemoveHandler = this.mousemoveHandler.bind(this);
|
|
137
|
+
private readonly bwheelHandler = this.wheelHandler.bind(this);
|
|
138
|
+
|
|
139
|
+
observe(item: EventTarget) {
|
|
140
|
+
item.addEventListener("touchstart", this.btouchstartHandler as EventListener);
|
|
141
|
+
item.addEventListener("touchend", this.btouchendHandler as EventListener);
|
|
142
|
+
item.addEventListener("touchmove", this.btouchmoveHandler as EventListener, {
|
|
143
|
+
passive: true
|
|
144
|
+
});
|
|
145
|
+
item.addEventListener("dblclick", this.bdblclickHandler as EventListener, {
|
|
146
|
+
passive: true
|
|
147
|
+
});
|
|
148
|
+
item.addEventListener("mousedown", this.bmousedownHandler as EventListener);
|
|
149
|
+
item.addEventListener("mouseup", this.bmouseupHandler as EventListener);
|
|
150
|
+
item.addEventListener("mousemove", this.bmousemoveHandler as EventListener);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
unobserve(item: EventTarget) {
|
|
154
|
+
item.removeEventListener("touchstart", this.btouchstartHandler as EventListener);
|
|
155
|
+
item.removeEventListener("touchend", this.btouchendHandler as EventListener);
|
|
156
|
+
item.removeEventListener("touchmove", this.btouchmoveHandler as EventListener);
|
|
157
|
+
item.removeEventListener("dblclick", this.bdblclickHandler as EventListener);
|
|
158
|
+
item.removeEventListener("mousedown", this.bmousedownHandler as EventListener);
|
|
159
|
+
item.removeEventListener("mouseup", this.bmouseupHandler as EventListener);
|
|
160
|
+
item.removeEventListener("mousemove", this.bmousemoveHandler as EventListener);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
destroy() {
|
|
164
|
+
this.destroyed = true;
|
|
165
|
+
this.unobserve(this.manager.spineElement);
|
|
166
|
+
this.manager.bookElement.removeEventListener("wheel", this.bwheelHandler);
|
|
167
|
+
window.clearTimeout(this.scaleDebouncer);
|
|
168
|
+
window.clearTimeout(this.wheelCooldownTimer);
|
|
169
|
+
window.clearTimeout(this.clickTimer);
|
|
170
|
+
window.clearTimeout(this.tapTimer);
|
|
171
|
+
cancelAnimationFrame(this.moveFrame);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
private clearPan() {
|
|
175
|
+
this.pan.letItGo = false;
|
|
176
|
+
this.pan.touchID = 0;
|
|
177
|
+
this.pan.endX = 0;
|
|
178
|
+
this.pan.overscrollX = 0;
|
|
179
|
+
this.pan.overscrollY = 0;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
public clearPinch() {
|
|
183
|
+
this.pinch = {
|
|
184
|
+
startDistance: 0,
|
|
185
|
+
startScale: this.pinch.startScale,
|
|
186
|
+
target: { X: 0, Y: 0 },
|
|
187
|
+
touchN: 0,
|
|
188
|
+
startTranslate: { X: 0, Y: 0 }
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
public get isScaled() {
|
|
193
|
+
return this.scale > 1;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
public resetZoom() {
|
|
197
|
+
this.scale = 1;
|
|
198
|
+
this.pan.translateX = 0;
|
|
199
|
+
this.pan.translateY = 0;
|
|
200
|
+
this.manager.updateBookStyle();
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
private emitPointer(key: "tap" | "click", clientX: number, clientY: number) {
|
|
204
|
+
const rect = this.manager.bookElement.getBoundingClientRect();
|
|
205
|
+
const dpr = window.devicePixelRatio || 1;
|
|
206
|
+
const data: DivinaPointerEvent = {
|
|
207
|
+
x: (clientX - rect.left) * dpr,
|
|
208
|
+
y: (clientY - rect.top) * dpr,
|
|
209
|
+
clientX,
|
|
210
|
+
clientY,
|
|
211
|
+
doNotDisturb: this.pan.touchID > 0 || this.dragState > 0
|
|
212
|
+
};
|
|
213
|
+
this.manager.listener(key, data);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* touchstart event handler (ported from FXLPeripherals)
|
|
218
|
+
*/
|
|
219
|
+
touchstartHandler(e: TouchEvent) {
|
|
220
|
+
const ignoreSlider = ["TEXTAREA", "OPTION", "INPUT", "SELECT"].indexOf((e.target as Element).nodeName) !== -1;
|
|
221
|
+
if (ignoreSlider)
|
|
222
|
+
return;
|
|
223
|
+
|
|
224
|
+
e.stopPropagation();
|
|
225
|
+
this.frameBounds = this.manager.currentBounds;
|
|
226
|
+
this.coordinator.refreshOuterPixels(this.frameBounds);
|
|
227
|
+
|
|
228
|
+
switch (e.touches.length) {
|
|
229
|
+
case 3:
|
|
230
|
+
return;
|
|
231
|
+
case 2: {
|
|
232
|
+
// Pinch
|
|
233
|
+
e.preventDefault();
|
|
234
|
+
this.pinch.startDistance = this.coordinator.getTouchDistance(e);
|
|
235
|
+
const st2 = this.startTouch(e);
|
|
236
|
+
this.pan.startX = st2.X;
|
|
237
|
+
this.pan.startY = st2.Y;
|
|
238
|
+
|
|
239
|
+
this.dragState = 2;
|
|
240
|
+
this.manager.updateBookStyle(true);
|
|
241
|
+
if(!this.isScaled) {
|
|
242
|
+
this.pinch.target = { X: 0, Y: 0 };
|
|
243
|
+
this.pinch.startScale = this.scale;
|
|
244
|
+
} else {
|
|
245
|
+
this.pinch.target.X -= this.pan.translateX * (this.pinch.startScale / this.scale);
|
|
246
|
+
this.pinch.target.Y -= this.pan.translateY * (this.pinch.startScale / this.scale);
|
|
247
|
+
this.pinch.target = { X: 0, Y: 0 };
|
|
248
|
+
this.pinch.startScale = 1 / this.scale;
|
|
249
|
+
}
|
|
250
|
+
this.pinch.startTranslate = { X: this.pan.translateX, Y: this.pan.translateY };
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
// @ts-ignore
|
|
254
|
+
case 1:
|
|
255
|
+
this.pan.touchID = e.touches[0].identifier;
|
|
256
|
+
// Fallthrough on purpose
|
|
257
|
+
default:
|
|
258
|
+
if(this.dragState < 1) this.dragState = 1;
|
|
259
|
+
this.manager.updateBookStyle(true);
|
|
260
|
+
}
|
|
261
|
+
this.manager.updateSpineStyle(false);
|
|
262
|
+
const st = this.startTouch(e);
|
|
263
|
+
this.pan.startX = st.X;
|
|
264
|
+
this.pan.startY = st.Y;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
private startTouch(e: TouchEvent): Point {
|
|
268
|
+
const center = this.coordinator.getTouchCenter(e) || this.coordinator.getBibiEventCoord(e);
|
|
269
|
+
return {
|
|
270
|
+
X: (center.X - this.manager.width / 2) - this.pan.translateX * this.scale + this.manager.width / 2,
|
|
271
|
+
Y: (center.Y - this.manager.height / 2) - this.pan.translateY * this.scale + this.manager.height / 2
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* touchend event handler (ported from FXLPeripherals, adds tap synthesis)
|
|
277
|
+
*/
|
|
278
|
+
touchendHandler(e: TouchEvent) {
|
|
279
|
+
e.stopPropagation();
|
|
280
|
+
|
|
281
|
+
if(!e.touches || e.touches.length === 0) {
|
|
282
|
+
const wasPinch = this.pinch.touchN > 0;
|
|
283
|
+
if ((this.pan.endX && !this.isScaled)) {
|
|
284
|
+
if(this.pinch.touchN) {
|
|
285
|
+
this.pan.endX = this.pan.startX;
|
|
286
|
+
}
|
|
287
|
+
this.updateAfterDrag();
|
|
288
|
+
} else if(!this.pinch.touchN && Math.abs(this.pan.overscrollX) > ZOOM_OVERSCROLL_THRESHOLD && Math.abs(this.pan.overscrollY) < ZOOM_OVERSCROLL_THRESHOLD / 2) {
|
|
289
|
+
// Panned past the limits on the horizontal axis while zoomed in;
|
|
290
|
+
// simulates dragging while not scaled.
|
|
291
|
+
this.pan.startX = 0;
|
|
292
|
+
this.pan.endX = -this.pan.overscrollX;
|
|
293
|
+
this.updateAfterDrag();
|
|
294
|
+
} else if(!this.minimumMoved && !wasPinch && e.changedTouches?.length === 1) {
|
|
295
|
+
// Stationary single-finger touch: it's a tap. Suppress the
|
|
296
|
+
// compatibility mouse events and clear the drag state before
|
|
297
|
+
// emitting, so the tap isn't flagged as an in-progress drag.
|
|
298
|
+
if(e.cancelable) e.preventDefault();
|
|
299
|
+
this.lastTouchEnd = performance.now();
|
|
300
|
+
this.dragState = 0;
|
|
301
|
+
this.clearPan();
|
|
302
|
+
const { clientX, clientY } = e.changedTouches[0];
|
|
303
|
+
const now = performance.now();
|
|
304
|
+
if(now - this.lastTapTime < DOUBLE_TAP_WINDOW &&
|
|
305
|
+
Math.abs(clientX - this.lastTapX) + Math.abs(clientY - this.lastTapY) < DOUBLE_TAP_SLOP) {
|
|
306
|
+
// Double tap: cancel the pending single-tap action and toggle zoom
|
|
307
|
+
window.clearTimeout(this.tapTimer);
|
|
308
|
+
this.lastTapTime = 0;
|
|
309
|
+
this.toggleZoom(clientX, clientY);
|
|
310
|
+
} else {
|
|
311
|
+
this.lastTapTime = now;
|
|
312
|
+
this.lastTapX = clientX;
|
|
313
|
+
this.lastTapY = clientY;
|
|
314
|
+
// Delay so a double tap (zoom) can cancel the single-tap action
|
|
315
|
+
window.clearTimeout(this.tapTimer);
|
|
316
|
+
this.tapTimer = window.setTimeout(() => this.emitPointer("tap", clientX, clientY), DOUBLE_TAP_WINDOW);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
this.dragState = 0;
|
|
320
|
+
this.minimumMoved = false;
|
|
321
|
+
this.clearPinch();
|
|
322
|
+
} else if(e.touches.length === 1) {
|
|
323
|
+
// Back to only one touch from 2+
|
|
324
|
+
this.dragState = 1;
|
|
325
|
+
if(e.touches[0].identifier !== this.pan.touchID) {
|
|
326
|
+
this.pan.touchID = e.touches[0].identifier;
|
|
327
|
+
}
|
|
328
|
+
const st = this.startTouch(e);
|
|
329
|
+
this.pan.startX = st.X;
|
|
330
|
+
this.pan.startY = st.Y;
|
|
331
|
+
}
|
|
332
|
+
window.setTimeout(() => {
|
|
333
|
+
if(this.destroyed) return;
|
|
334
|
+
this.manager.updateBookStyle(true);
|
|
335
|
+
if(this.dragState === 0) {
|
|
336
|
+
if(this.scale < MIN_SCALE) {
|
|
337
|
+
this.pan.translateX = 0;
|
|
338
|
+
this.pan.translateY = 0;
|
|
339
|
+
}
|
|
340
|
+
this.clearPan();
|
|
341
|
+
}
|
|
342
|
+
this.manager.updateBookStyle(true);
|
|
343
|
+
}, 50);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
private moveFrame = 0;
|
|
347
|
+
|
|
348
|
+
// Reused typed transform for the per-frame drag write: allocating fresh
|
|
349
|
+
// CSSTransformValue/CSSTranslate objects per write is slower than string
|
|
350
|
+
// parsing; mutating a persistent one is faster than both
|
|
351
|
+
private dragTranslate: CSSTranslate | null = null;
|
|
352
|
+
private dragTransform: CSSTransformValue | null = null;
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* touchmove event handler (ported from FXLPeripherals)
|
|
356
|
+
*/
|
|
357
|
+
touchmoveHandler(e: TouchEvent) {
|
|
358
|
+
e.stopPropagation();
|
|
359
|
+
const coords = this.coordinator.getBibiEventCoord(e);
|
|
360
|
+
|
|
361
|
+
if ((Math.abs(this.pan.startY - coords.Y) + Math.abs(this.pan.startX - coords.X)) > CLICK_SLOP) {
|
|
362
|
+
if(!this.minimumMoved) {
|
|
363
|
+
this.manager.deselect();
|
|
364
|
+
this.minimumMoved = true;
|
|
365
|
+
}
|
|
366
|
+
if(this.dragState < 1) this.dragState = 1;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const currentDistance = this.coordinator?.getTouchDistance(e);
|
|
370
|
+
|
|
371
|
+
let updateBook = false;
|
|
372
|
+
|
|
373
|
+
const oldScale = this.scale;
|
|
374
|
+
if(this.dragState === 2 && currentDistance) {
|
|
375
|
+
this.pinch.touchN++;
|
|
376
|
+
if(this.pinch.touchN < 4) return;
|
|
377
|
+
let newScale = currentDistance / this.pinch.startDistance * this.scale;
|
|
378
|
+
if(newScale >= MAX_SCALE)
|
|
379
|
+
newScale = MAX_SCALE;
|
|
380
|
+
if(newScale <= MIN_SCALE)
|
|
381
|
+
newScale = 1;
|
|
382
|
+
this.scale = newScale;
|
|
383
|
+
this.pinch.startDistance = currentDistance;
|
|
384
|
+
updateBook = true;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
if (this.pan.letItGo === false) {
|
|
388
|
+
this.pan.letItGo = Math.abs(this.pan.startY - coords.Y) < Math.abs(this.pan.startX - coords.X);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
if ((this.dragState > 0 && this.isScaled) || this.dragState > 1) {
|
|
392
|
+
if(this.dragState === 1) {
|
|
393
|
+
const center = {
|
|
394
|
+
X: coords.X - this.manager.width / 2,
|
|
395
|
+
Y: coords.Y - this.manager.height / 2
|
|
396
|
+
};
|
|
397
|
+
this.pan.translateX = (center.X - (this.pan.startX - this.manager.width / 2)) * 1 / this.scale;
|
|
398
|
+
this.pan.translateY = (center.Y - (this.pan.startY - this.manager.height / 2)) * 1 / this.scale;
|
|
399
|
+
} else if(this.dragState === 2) {
|
|
400
|
+
const center = this.coordinator.getTouchCenter(e)!;
|
|
401
|
+
center.X -= this.manager.width / 2;
|
|
402
|
+
center.Y -= this.manager.height / 2;
|
|
403
|
+
|
|
404
|
+
let ptx = -center.X / oldScale;
|
|
405
|
+
ptx += center.X / this.scale;
|
|
406
|
+
this.pinch.target.X += ptx;
|
|
407
|
+
center.X += this.pinch.target.X * this.scale / this.pinch.startScale;
|
|
408
|
+
|
|
409
|
+
let pty = -center.Y / oldScale;
|
|
410
|
+
pty += center.Y / this.scale;
|
|
411
|
+
this.pinch.target.Y += pty;
|
|
412
|
+
center.Y += this.pinch.target.Y * this.scale / this.pinch.startScale;
|
|
413
|
+
|
|
414
|
+
this.pan.translateX = (center.X - (this.pan.startX - this.manager.width / 2)) * 1 / this.scale;
|
|
415
|
+
this.pan.translateY = (center.Y - (this.pan.startY - this.manager.height / 2)) * 1 / this.scale;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
const maxEdgeX = this.frameBounds!.width / 6;
|
|
419
|
+
const maxEdgeY = this.frameBounds!.height / 6;
|
|
420
|
+
|
|
421
|
+
if (this.pan.translateX < -maxEdgeX) {
|
|
422
|
+
this.pan.overscrollX = -(maxEdgeX + this.pan.translateX);
|
|
423
|
+
this.pan.translateX = -maxEdgeX;
|
|
424
|
+
}
|
|
425
|
+
if (this.pan.translateY < -maxEdgeY) {
|
|
426
|
+
this.pan.overscrollY = -(maxEdgeY + this.pan.translateY);
|
|
427
|
+
this.pan.translateY = -maxEdgeY;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
if (this.pan.translateX > maxEdgeX) {
|
|
431
|
+
this.pan.overscrollX = maxEdgeX - this.pan.translateX;
|
|
432
|
+
this.pan.translateX = maxEdgeX;
|
|
433
|
+
}
|
|
434
|
+
if (this.pan.translateY > maxEdgeY) {
|
|
435
|
+
this.pan.overscrollY = maxEdgeY - this.pan.translateY;
|
|
436
|
+
this.pan.translateY = maxEdgeY;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
updateBook = true;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
if(updateBook) {
|
|
443
|
+
this.manager.updateBookStyle();
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
if (this.dragState > 0 && this.pan.letItGo) {
|
|
448
|
+
this.pan.endX = coords.X;
|
|
449
|
+
|
|
450
|
+
const currentSlide = this.manager.currentSlide;
|
|
451
|
+
const currentOffset = currentSlide * (this.manager.width / this.manager.perPage);
|
|
452
|
+
const dragOffset = (this.pan.endX - this.pan.startX);
|
|
453
|
+
const offset = this.manager.rtl ? currentOffset + dragOffset : currentOffset - dragOffset;
|
|
454
|
+
// Snap the translation to device pixels: at fractional offsets the
|
|
455
|
+
// spread gutter is rasterized with a hairline gap between the pages
|
|
456
|
+
const dpr = window.devicePixelRatio || 1;
|
|
457
|
+
const snapped = Math.round((this.manager.rtl ? 1 : -1) * offset * dpr) / dpr;
|
|
458
|
+
|
|
459
|
+
cancelAnimationFrame(this.moveFrame);
|
|
460
|
+
this.moveFrame = requestAnimationFrame(() => {
|
|
461
|
+
requestAnimationFrame(() => {
|
|
462
|
+
if(this.destroyed) return;
|
|
463
|
+
if(isTypedOMSupported()) {
|
|
464
|
+
if(!this.dragTransform) {
|
|
465
|
+
this.dragTranslate = new CSSTranslate(CSS.px(0), CSS.px(0), CSS.px(0));
|
|
466
|
+
this.dragTransform = new CSSTransformValue([this.dragTranslate]);
|
|
467
|
+
}
|
|
468
|
+
(this.dragTranslate!.x as CSSUnitValue).value = snapped;
|
|
469
|
+
this.manager.spineElement.attributeStyleMap.set("transform", this.dragTransform);
|
|
470
|
+
} else {
|
|
471
|
+
this.manager.spineElement.style.transform = `translate3d(${snapped}px, 0, 0)`;
|
|
472
|
+
}
|
|
473
|
+
})
|
|
474
|
+
});
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* Double click/tap: toggle zoom between 1x and 2x, centered on the point.
|
|
480
|
+
*/
|
|
481
|
+
toggleZoom(clientX: number, clientY: number) {
|
|
482
|
+
if(this.isScaled) {
|
|
483
|
+
this.resetZoom();
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
const rect = this.manager.bookElement.getBoundingClientRect();
|
|
487
|
+
const x = clientX - rect.left;
|
|
488
|
+
const y = clientY - rect.top;
|
|
489
|
+
this.frameBounds = this.manager.currentBounds;
|
|
490
|
+
const maxEdgeX = this.frameBounds.width / 6;
|
|
491
|
+
const maxEdgeY = this.frameBounds.height / 6;
|
|
492
|
+
this.scale = 2;
|
|
493
|
+
this.pan.translateX = Math.max(-maxEdgeX, Math.min(maxEdgeX, this.manager.width / 2 - x));
|
|
494
|
+
this.pan.translateY = Math.max(-maxEdgeY, Math.min(maxEdgeY, this.manager.height / 2 - y));
|
|
495
|
+
this.manager.updateBookStyle();
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/** Zoom in/out by a keyboard step, clamped to [1, MAX_SCALE] */
|
|
499
|
+
zoomBy(delta: number) {
|
|
500
|
+
let newScale = this.scale + delta;
|
|
501
|
+
if(newScale >= MAX_SCALE) newScale = MAX_SCALE;
|
|
502
|
+
if(newScale <= MIN_SCALE) newScale = 1;
|
|
503
|
+
this.scale = newScale;
|
|
504
|
+
if(newScale === 1) {
|
|
505
|
+
this.pan.translateX = 0;
|
|
506
|
+
this.pan.translateY = 0;
|
|
507
|
+
}
|
|
508
|
+
this.manager.updateBookStyle();
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
zoomIn() { this.zoomBy(ZOOM_STEP); }
|
|
512
|
+
zoomOut() { this.zoomBy(-ZOOM_STEP); }
|
|
513
|
+
|
|
514
|
+
dblclickHandler(e: MouseEvent) {
|
|
515
|
+
window.clearTimeout(this.clickTimer); // Cancel the pending single-click action
|
|
516
|
+
this.toggleZoom(e.clientX, e.clientY);
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
private addTouch(e: any) {
|
|
520
|
+
e.touches = [{
|
|
521
|
+
pageX: e.pageX,
|
|
522
|
+
pageY: e.pageY
|
|
523
|
+
}];
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
mousedownHandler(e: MouseEvent) {
|
|
527
|
+
this.mouseDown = { X: e.clientX, Y: e.clientY };
|
|
528
|
+
if (this.isScaled) {
|
|
529
|
+
this.addTouch(e as any);
|
|
530
|
+
this.touchstartHandler(e as any);
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
mouseupHandler(e: MouseEvent) {
|
|
535
|
+
const down = this.mouseDown;
|
|
536
|
+
this.mouseDown = null;
|
|
537
|
+
if (this.isScaled) {
|
|
538
|
+
this.touchendHandler(e as any);
|
|
539
|
+
}
|
|
540
|
+
// Ignore compatibility mouse events synthesized after a touch tap,
|
|
541
|
+
// and the second mouseup of a double click (it belongs to dblclick)
|
|
542
|
+
if (performance.now() - this.lastTouchEnd < 700 || e.detail > 1) return;
|
|
543
|
+
if (down && e.button === 0 &&
|
|
544
|
+
Math.abs(e.clientX - down.X) + Math.abs(e.clientY - down.Y) <= CLICK_SLOP) {
|
|
545
|
+
// Delay so a double click (zoom) can cancel the single-click action
|
|
546
|
+
const { clientX, clientY } = e;
|
|
547
|
+
window.clearTimeout(this.clickTimer);
|
|
548
|
+
this.clickTimer = window.setTimeout(() => this.emitPointer("click", clientX, clientY), 250);
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
mousemoveHandler(e: MouseEvent) {
|
|
553
|
+
if (this.isScaled && e.buttons > 0) {
|
|
554
|
+
e.preventDefault();
|
|
555
|
+
this.addTouch(e as any);
|
|
556
|
+
this.touchmoveHandler(e as any);
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/**
|
|
561
|
+
* Wheel handler: ctrl+wheel zooms (trackpad pinch on Chrome), otherwise a
|
|
562
|
+
* wheel burst turns exactly one page. A new page turn requires either
|
|
563
|
+
* 200ms of quiet (fresh gesture), a direction reversal, or a re-acceleration
|
|
564
|
+
* after a decelerating tail (deliberate consecutive flicks).
|
|
565
|
+
*/
|
|
566
|
+
wheelHandler(e: WheelEvent) {
|
|
567
|
+
e.preventDefault();
|
|
568
|
+
|
|
569
|
+
if(e.ctrlKey) {
|
|
570
|
+
// Pinch-zoom gesture (trackpads report it as ctrl+wheel)
|
|
571
|
+
let newScale = this.scale * (1 - e.deltaY * 0.01);
|
|
572
|
+
if(newScale >= MAX_SCALE) newScale = MAX_SCALE;
|
|
573
|
+
if(newScale <= MIN_SCALE) newScale = 1;
|
|
574
|
+
this.scale = newScale;
|
|
575
|
+
this.manager.updateBookStyle();
|
|
576
|
+
return;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
const horizontal = Math.abs(e.deltaX) > Math.abs(e.deltaY);
|
|
580
|
+
const delta = horizontal ? e.deltaX : e.deltaY;
|
|
581
|
+
const mag = Math.abs(delta);
|
|
582
|
+
if(mag < 1) return;
|
|
583
|
+
// Horizontal wheel follows the visual direction (RTL flips);
|
|
584
|
+
// vertical wheel down always advances.
|
|
585
|
+
let dir = Math.sign(delta);
|
|
586
|
+
if(horizontal && this.manager.rtl) dir = -dir;
|
|
587
|
+
|
|
588
|
+
const now = performance.now();
|
|
589
|
+
const quiet = now - this.wheelLastEvent;
|
|
590
|
+
this.wheelLastEvent = now;
|
|
591
|
+
|
|
592
|
+
let gesture: "start" | "reverse" | "serial" | null = null;
|
|
593
|
+
if(quiet > WHEEL_COOLDOWN) gesture = "start";
|
|
594
|
+
else if(this.wheelLastDir !== 0 && dir !== this.wheelLastDir) gesture = "reverse";
|
|
595
|
+
else if(this.wheelDecelCount >= 3 && mag > this.wheelLastMag * 1.5) gesture = "serial";
|
|
596
|
+
|
|
597
|
+
this.wheelDecelCount = mag <= this.wheelLastMag ? this.wheelDecelCount + 1 : 0;
|
|
598
|
+
this.wheelLastMag = mag;
|
|
599
|
+
this.wheelLastDir = dir;
|
|
600
|
+
|
|
601
|
+
if(gesture !== null && (!this.wheelHot || gesture === "reverse" || gesture === "serial")) {
|
|
602
|
+
this.wheelHot = true;
|
|
603
|
+
if(gesture === "serial" || gesture === "reverse") this.wheelDecelCount = 0;
|
|
604
|
+
this.manager.listener(dir > 0 ? "no_more" : "no_less", undefined);
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
window.clearTimeout(this.wheelCooldownTimer);
|
|
608
|
+
this.wheelCooldownTimer = window.setTimeout(() => {
|
|
609
|
+
this.wheelHot = false;
|
|
610
|
+
this.wheelLastDir = 0;
|
|
611
|
+
this.wheelLastMag = 0;
|
|
612
|
+
this.wheelDecelCount = 0;
|
|
613
|
+
}, WHEEL_COOLDOWN);
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
/**
|
|
617
|
+
* Recalculate drag/swipe event and reposition the frame of the slider
|
|
618
|
+
*/
|
|
619
|
+
private updateAfterDrag() {
|
|
620
|
+
const movement = (this.manager.rtl ? -1 : 1) * (this.pan.endX - this.pan.startX);
|
|
621
|
+
const movementDistance = Math.abs(movement);
|
|
622
|
+
|
|
623
|
+
if (movement > 0 && movementDistance > this.manager.threshold && this.manager.length > this.manager.perPage) {
|
|
624
|
+
this.manager.listener("no_less", undefined);
|
|
625
|
+
} else if (movement < 0 && movementDistance > this.manager.threshold && this.manager.length > this.manager.perPage) {
|
|
626
|
+
this.manager.listener("no_more", undefined);
|
|
627
|
+
}
|
|
628
|
+
this.manager.slideToCurrent(true, true);
|
|
629
|
+
}
|
|
630
|
+
}
|