gds-lens 1.0.3 → 1.1.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/CHANGELOG.md +44 -1
- package/README.md +4 -4
- package/dist/esm/gds-lens.js +126 -1
- package/dist/inline-wasm/gds-lens.js +126 -1
- package/dist/web/gds-lens.js +126 -1
- package/package.json +1 -1
- package/src/viewer.css +19 -1
- package/src/viewer.js +188 -0
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,47 @@ follows [semantic versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
7
7
|
From 1.0.0 on, a breaking change to the element's API waits for a major
|
|
8
8
|
version. Before that, `0.x` releases changed it freely.
|
|
9
9
|
|
|
10
|
+
## [1.1.0] - 2026-08-31
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- Touch: one finger pans the layout, two pinch to zoom about the point between
|
|
15
|
+
them and drag the view with it. Lifting one finger of a pinch hands the
|
|
16
|
+
gesture to the other rather than ending it, so a pinch that relaxes into a
|
|
17
|
+
drag does not jump.
|
|
18
|
+
|
|
19
|
+
### Changed
|
|
20
|
+
|
|
21
|
+
- On a touch-only device (a coarse pointer *and* no hover, so a touchscreen
|
|
22
|
+
laptop is not one) the control panel starts collapsed to its title bar, and
|
|
23
|
+
both it and the cell hierarchy are capped at 85% of the viewport width. A
|
|
24
|
+
panel sized for a window is most of a phone screen, and the layout is what
|
|
25
|
+
someone opening the viewer came for.
|
|
26
|
+
- Measure mode is offered greyed out where there is no hovering pointer. It is
|
|
27
|
+
placed by clicking two points, and without hover the snap indicator only
|
|
28
|
+
appears after the tap that already used it, under a fingertip.
|
|
29
|
+
- The demo page drops its tagline, its file button, its status line and its
|
|
30
|
+
what-this-is-built-from footer line below 620px. Two of those cannot work on
|
|
31
|
+
touch anyway: there is no drag-and-drop, and the file picker's extension
|
|
32
|
+
filter greys out `.gds`/`.oas` in the iOS Files app.
|
|
33
|
+
|
|
34
|
+
### Fixed
|
|
35
|
+
|
|
36
|
+
- Touch input did not work at all on iOS. The gestures were handled in the
|
|
37
|
+
renderer, through `emscripten_set_touchstart_callback`, and that callback
|
|
38
|
+
never runs on iOS Safari: the events reach the canvas and the handler behind
|
|
39
|
+
them does not fire. Emscripten resolves mouse and touch targets through the
|
|
40
|
+
same code path, so the mouse handlers next to them were fine, which is what
|
|
41
|
+
made this invisible from a desktop. The gestures are now ordinary listeners
|
|
42
|
+
on the element, driving the camera through the exported `getCamera` /
|
|
43
|
+
`setCamera` -- the same arithmetic, with nothing between the DOM event and
|
|
44
|
+
the state it changes. Covered by a test that spells out the touch lists.
|
|
45
|
+
- A two-finger pinch over the viewer zoomed the page on iOS instead of the
|
|
46
|
+
layout. Safari answers a pinch with its own page zoom, delivered as a
|
|
47
|
+
proprietary `GestureEvent`, and it does that over an element that has already
|
|
48
|
+
claimed the gesture with `touch-action: none`. Refused on the canvas alone,
|
|
49
|
+
so a pinch anywhere else in an embedding page still zooms it.
|
|
50
|
+
|
|
10
51
|
## [1.0.3] - 2026-08-31
|
|
11
52
|
|
|
12
53
|
### Fixed
|
|
@@ -348,7 +389,9 @@ web page rather than for a webview.
|
|
|
348
389
|
worker-loading route and shipped unsubstituted. The `createWorker` host hook
|
|
349
390
|
replaces it.
|
|
350
391
|
|
|
351
|
-
[Unreleased]: https://github.com/EthanLowenthal/GDS-Lens/compare/v1.0
|
|
392
|
+
[Unreleased]: https://github.com/EthanLowenthal/GDS-Lens/compare/v1.1.0...HEAD
|
|
393
|
+
[1.1.0]: https://github.com/EthanLowenthal/GDS-Lens/compare/v1.0.3...v1.1.0
|
|
394
|
+
[1.0.3]: https://github.com/EthanLowenthal/GDS-Lens/compare/v1.0.2...v1.0.3
|
|
352
395
|
[1.0.2]: https://github.com/EthanLowenthal/GDS-Lens/compare/v1.0.1...v1.0.2
|
|
353
396
|
[1.0.1]: https://github.com/EthanLowenthal/GDS-Lens/compare/v1.0.0...v1.0.1
|
|
354
397
|
[1.0.0]: https://github.com/EthanLowenthal/GDS-Lens/compare/v0.1.1...v1.0.0
|
package/README.md
CHANGED
|
@@ -32,10 +32,10 @@ import "gds-lens"; // registers <gds-lens>
|
|
|
32
32
|
<gds-lens src="chip.gds" style="width: 100%; height: 600px"></gds-lens>
|
|
33
33
|
```
|
|
34
34
|
|
|
35
|
-
That's the whole thing. Pan with the mouse, zoom with the wheel
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
else to serve.
|
|
35
|
+
That's the whole thing. Pan with the mouse, zoom with the wheel; on a touch
|
|
36
|
+
screen, drag to pan and pinch to zoom. The import pulls in one self-contained
|
|
37
|
+
module — the parser, the renderer, the WebAssembly binary, and the control
|
|
38
|
+
panel — so there is nothing to copy and nothing else to serve.
|
|
39
39
|
|
|
40
40
|
Or drive it from JavaScript:
|
|
41
41
|
|
package/dist/esm/gds-lens.js
CHANGED
|
@@ -895,7 +895,12 @@ var init_viewer = __esm({
|
|
|
895
895
|
against it rather than against the page. */
|
|
896
896
|
:host { display: block; position: relative; width: 100%; height: 100%; overflow: hidden;
|
|
897
897
|
background: var(--bg); user-select: none; color: var(--fg); }
|
|
898
|
-
|
|
898
|
+
/* touch-action: none hands every gesture over the canvas to the touch
|
|
899
|
+
handlers in renderer.cpp: without it the browser claims a drag for
|
|
900
|
+
page scrolling and a pinch for its own page zoom before the handlers
|
|
901
|
+
see the second event, and it is also what turns off double-tap zoom
|
|
902
|
+
over the layout. */
|
|
903
|
+
canvas { width: 100%; height: 100%; display: block; touch-action: none; }
|
|
899
904
|
/* The engine readout (polygon/label counts + the per-frame #renderStats
|
|
900
905
|
span, both written from renderer.cpp). It lives inside #debugPanel
|
|
901
906
|
rather than floating over the canvas on its own: it's debug-only
|
|
@@ -980,6 +985,15 @@ var init_viewer = __esm({
|
|
|
980
985
|
overlay (30) and debug panel (40) rather than lil-gui's 1001. */
|
|
981
986
|
#guiHost { position: absolute; top: 0; right: 15px; max-height: 100%; z-index: 20; display: flex; }
|
|
982
987
|
#guiHost > .lil-gui.lil-root { max-height: 100%; }
|
|
988
|
+
/* Both panels are sized for a window, and a phone is narrower than
|
|
989
|
+
either of them plus anything worth seeing. Capped rather than
|
|
990
|
+
reflowed: they still open over the layout, but never across all of
|
|
991
|
+
it. (The control panel also starts collapsed here -- see touchOnly
|
|
992
|
+
in viewer.js.) */
|
|
993
|
+
@media (pointer: coarse) and (hover: none) {
|
|
994
|
+
#guiHost > .lil-gui.lil-root { max-width: 85vw; }
|
|
995
|
+
#hierarchyPanel { width: min(260px, 85vw); }
|
|
996
|
+
}
|
|
983
997
|
/* Checkbox rows (layer list + top-level toggles): lil-gui gives the
|
|
984
998
|
label a 45% min-width and no overflow handling, so a long name like
|
|
985
999
|
"250/74 \u2013 SIN_WG_S" would push into the checkbox column. Widen the
|
|
@@ -1021,6 +1035,10 @@ var init_viewer = __esm({
|
|
|
1021
1035
|
buttons a readable width. */
|
|
1022
1036
|
.lil-gui .lil-controller.mode-row .lil-name { min-width: 30%; }
|
|
1023
1037
|
.lil-gui .lil-controller.mode-row .mode-widget { gap: 4px; }
|
|
1038
|
+
/* Greyed out where the mode is not offered (see touchOnly in
|
|
1039
|
+
viewer.js). lil-gui dims a whole disabled controller, but has no rule
|
|
1040
|
+
for one disabled button inside a live row. */
|
|
1041
|
+
.lil-gui .lil-controller.mode-row .mode-widget button:disabled { opacity: 0.5; cursor: default; }
|
|
1024
1042
|
.lil-gui .lil-controller.mode-row .mode-widget button { flex: 1; }
|
|
1025
1043
|
/* :hover/:active spelled out too -- lil-gui's own button:hover rule
|
|
1026
1044
|
outranks a bare .mode-active, which would grey out the selected
|
|
@@ -3126,6 +3144,7 @@ function createViewer(mountTarget) {
|
|
|
3126
3144
|
const hostCall = (name, ...args) => hostCan(name) ? host[name](...args) : void 0;
|
|
3127
3145
|
trace("[GDS] host ready; Worker:", typeof Worker, "Blob:", typeof Blob, "bundled worker:", !!workerBundle);
|
|
3128
3146
|
const gui = new GUI({ width: 260, container: viewerRoot.getElementById("guiHost"), injectStyles: false });
|
|
3147
|
+
const touchOnly = typeof window.matchMedia === "function" && window.matchMedia("(pointer: coarse) and (hover: none)").matches;
|
|
3129
3148
|
const actions = {
|
|
3130
3149
|
// Clicking the row always opens the file dialog (load, or replace the
|
|
3131
3150
|
// current file); the injected ✕ (see setFileChip) handles unloading.
|
|
@@ -3154,6 +3173,7 @@ function createViewer(mountTarget) {
|
|
|
3154
3173
|
const lypController = displayFolder.add(actions, "loadLypFile").name("Load .lyp File");
|
|
3155
3174
|
const markerController = displayFolder.add(actions, "loadMarkerFile").name("Load Marker File (.lyrdb / DRC)");
|
|
3156
3175
|
displayFolder.add(actions, "resetView").name("Reset View");
|
|
3176
|
+
if (touchOnly) gui.close();
|
|
3157
3177
|
const MODES = [
|
|
3158
3178
|
{ id: "pan", label: "Pan", title: "Drag to pan the view, wheel to zoom" },
|
|
3159
3179
|
{
|
|
@@ -3176,6 +3196,10 @@ function createViewer(mountTarget) {
|
|
|
3176
3196
|
btn.type = "button";
|
|
3177
3197
|
btn.textContent = mode.label;
|
|
3178
3198
|
btn.title = mode.title;
|
|
3199
|
+
if (mode.id === "measure" && touchOnly) {
|
|
3200
|
+
btn.disabled = true;
|
|
3201
|
+
btn.title = "Measuring needs a pointer that hovers: use a mouse or a trackpad.";
|
|
3202
|
+
}
|
|
3179
3203
|
btn.addEventListener("click", () => setMode(mode.id));
|
|
3180
3204
|
modeWidget.appendChild(btn);
|
|
3181
3205
|
modeButtons.set(mode.id, btn);
|
|
@@ -3184,6 +3208,7 @@ function createViewer(mountTarget) {
|
|
|
3184
3208
|
modeRow.appendChild(modeWidget);
|
|
3185
3209
|
gui.$children.prepend(modeRow);
|
|
3186
3210
|
function setMode(id) {
|
|
3211
|
+
if (id === "measure" && touchOnly) return;
|
|
3187
3212
|
if (currentMode === id) return;
|
|
3188
3213
|
currentMode = id;
|
|
3189
3214
|
for (const [modeId, btn] of modeButtons) {
|
|
@@ -3222,10 +3247,110 @@ function createViewer(mountTarget) {
|
|
|
3222
3247
|
}
|
|
3223
3248
|
const glCanvas = els.glCanvas;
|
|
3224
3249
|
if (glCanvas) {
|
|
3250
|
+
let zoomAbout = function(camera, newZoom, pixelX, pixelY) {
|
|
3251
|
+
const dx = pixelX - glCanvas.clientWidth / 2;
|
|
3252
|
+
const dy = glCanvas.clientHeight / 2 - pixelY;
|
|
3253
|
+
const worldX = camera.panX + dx / camera.zoom;
|
|
3254
|
+
const worldY = camera.panY + dy / camera.zoom;
|
|
3255
|
+
return { zoom: newZoom, panX: worldX - dx / newZoom, panY: worldY - dy / newZoom };
|
|
3256
|
+
};
|
|
3225
3257
|
glCanvas.addEventListener("mousedown", () => {
|
|
3226
3258
|
if (currentMode !== "measure") return;
|
|
3227
3259
|
setTimeout(() => modulePromise.then(refreshRulerRow), 0);
|
|
3228
3260
|
});
|
|
3261
|
+
let panId = null;
|
|
3262
|
+
let pinch = null;
|
|
3263
|
+
let lastX = 0, lastY = 0;
|
|
3264
|
+
const MIN_PINCH_PX = 8;
|
|
3265
|
+
const touchById = (list, id) => {
|
|
3266
|
+
for (const touch of list) if (touch.identifier === id) return touch;
|
|
3267
|
+
return null;
|
|
3268
|
+
};
|
|
3269
|
+
const spread = (a, b) => Math.hypot(a.clientX - b.clientX, a.clientY - b.clientY);
|
|
3270
|
+
const onCanvas = (event) => event.composedPath().includes(glCanvas);
|
|
3271
|
+
const touchIdle = () => currentMode === "measure" || !resolvedModule;
|
|
3272
|
+
hostElement.addEventListener("touchstart", (event) => {
|
|
3273
|
+
if (touchIdle() || !onCanvas(event)) return;
|
|
3274
|
+
const touches = event.touches;
|
|
3275
|
+
if (touches.length >= 2) {
|
|
3276
|
+
if (!pinch) {
|
|
3277
|
+
const [a, b] = touches;
|
|
3278
|
+
pinch = { a: a.identifier, b: b.identifier, dist: spread(a, b) };
|
|
3279
|
+
panId = null;
|
|
3280
|
+
lastX = (a.clientX + b.clientX) / 2;
|
|
3281
|
+
lastY = (a.clientY + b.clientY) / 2;
|
|
3282
|
+
}
|
|
3283
|
+
} else if (touches.length === 1) {
|
|
3284
|
+
panId = touches[0].identifier;
|
|
3285
|
+
lastX = touches[0].clientX;
|
|
3286
|
+
lastY = touches[0].clientY;
|
|
3287
|
+
}
|
|
3288
|
+
event.preventDefault();
|
|
3289
|
+
}, { passive: false });
|
|
3290
|
+
hostElement.addEventListener("touchmove", (event) => {
|
|
3291
|
+
if (touchIdle() || !onCanvas(event)) return;
|
|
3292
|
+
let camera = resolvedModule.getCamera();
|
|
3293
|
+
if (pinch) {
|
|
3294
|
+
const a = touchById(event.touches, pinch.a);
|
|
3295
|
+
const b = touchById(event.touches, pinch.b);
|
|
3296
|
+
if (a && b) {
|
|
3297
|
+
const midX = (a.clientX + b.clientX) / 2;
|
|
3298
|
+
const midY = (a.clientY + b.clientY) / 2;
|
|
3299
|
+
camera = {
|
|
3300
|
+
zoom: camera.zoom,
|
|
3301
|
+
panX: camera.panX - (midX - lastX) / camera.zoom,
|
|
3302
|
+
panY: camera.panY + (midY - lastY) / camera.zoom
|
|
3303
|
+
};
|
|
3304
|
+
lastX = midX;
|
|
3305
|
+
lastY = midY;
|
|
3306
|
+
const dist = spread(a, b);
|
|
3307
|
+
if (dist >= MIN_PINCH_PX && pinch.dist >= MIN_PINCH_PX) {
|
|
3308
|
+
const rect = glCanvas.getBoundingClientRect();
|
|
3309
|
+
camera = zoomAbout(
|
|
3310
|
+
camera,
|
|
3311
|
+
camera.zoom * dist / pinch.dist,
|
|
3312
|
+
midX - rect.left,
|
|
3313
|
+
midY - rect.top
|
|
3314
|
+
);
|
|
3315
|
+
pinch.dist = dist;
|
|
3316
|
+
}
|
|
3317
|
+
resolvedModule.setCamera(camera.zoom, camera.panX, camera.panY);
|
|
3318
|
+
event.preventDefault();
|
|
3319
|
+
}
|
|
3320
|
+
return;
|
|
3321
|
+
}
|
|
3322
|
+
const finger = panId === null ? null : touchById(event.touches, panId);
|
|
3323
|
+
if (!finger) return;
|
|
3324
|
+
resolvedModule.setCamera(
|
|
3325
|
+
camera.zoom,
|
|
3326
|
+
camera.panX - (finger.clientX - lastX) / camera.zoom,
|
|
3327
|
+
camera.panY + (finger.clientY - lastY) / camera.zoom
|
|
3328
|
+
);
|
|
3329
|
+
lastX = finger.clientX;
|
|
3330
|
+
lastY = finger.clientY;
|
|
3331
|
+
event.preventDefault();
|
|
3332
|
+
}, { passive: false });
|
|
3333
|
+
const endTouch = (event) => {
|
|
3334
|
+
if (pinch) {
|
|
3335
|
+
const a = touchById(event.touches, pinch.a);
|
|
3336
|
+
const b = touchById(event.touches, pinch.b);
|
|
3337
|
+
if (a && b) return;
|
|
3338
|
+
const survivor = a || b;
|
|
3339
|
+
pinch = null;
|
|
3340
|
+
panId = survivor ? survivor.identifier : null;
|
|
3341
|
+
if (survivor) {
|
|
3342
|
+
lastX = survivor.clientX;
|
|
3343
|
+
lastY = survivor.clientY;
|
|
3344
|
+
}
|
|
3345
|
+
return;
|
|
3346
|
+
}
|
|
3347
|
+
if (panId !== null && !touchById(event.touches, panId)) panId = null;
|
|
3348
|
+
};
|
|
3349
|
+
hostElement.addEventListener("touchend", endTouch);
|
|
3350
|
+
hostElement.addEventListener("touchcancel", endTouch);
|
|
3351
|
+
for (const type of ["gesturestart", "gesturechange", "gestureend"]) {
|
|
3352
|
+
glCanvas.addEventListener(type, (event) => event.preventDefault());
|
|
3353
|
+
}
|
|
3229
3354
|
}
|
|
3230
3355
|
function setFileChip(controller, name, { idleLabel, idleTitle, unloadTitle, onUnload }) {
|
|
3231
3356
|
const existingX = controller.domElement.querySelector(".lyp-unload");
|
|
@@ -871,7 +871,12 @@ Usually a truncated or half-written file -- a copy that was interrupted, or a do
|
|
|
871
871
|
against it rather than against the page. */
|
|
872
872
|
:host { display: block; position: relative; width: 100%; height: 100%; overflow: hidden;
|
|
873
873
|
background: var(--bg); user-select: none; color: var(--fg); }
|
|
874
|
-
|
|
874
|
+
/* touch-action: none hands every gesture over the canvas to the touch
|
|
875
|
+
handlers in renderer.cpp: without it the browser claims a drag for
|
|
876
|
+
page scrolling and a pinch for its own page zoom before the handlers
|
|
877
|
+
see the second event, and it is also what turns off double-tap zoom
|
|
878
|
+
over the layout. */
|
|
879
|
+
canvas { width: 100%; height: 100%; display: block; touch-action: none; }
|
|
875
880
|
/* The engine readout (polygon/label counts + the per-frame #renderStats
|
|
876
881
|
span, both written from renderer.cpp). It lives inside #debugPanel
|
|
877
882
|
rather than floating over the canvas on its own: it's debug-only
|
|
@@ -956,6 +961,15 @@ Usually a truncated or half-written file -- a copy that was interrupted, or a do
|
|
|
956
961
|
overlay (30) and debug panel (40) rather than lil-gui's 1001. */
|
|
957
962
|
#guiHost { position: absolute; top: 0; right: 15px; max-height: 100%; z-index: 20; display: flex; }
|
|
958
963
|
#guiHost > .lil-gui.lil-root { max-height: 100%; }
|
|
964
|
+
/* Both panels are sized for a window, and a phone is narrower than
|
|
965
|
+
either of them plus anything worth seeing. Capped rather than
|
|
966
|
+
reflowed: they still open over the layout, but never across all of
|
|
967
|
+
it. (The control panel also starts collapsed here -- see touchOnly
|
|
968
|
+
in viewer.js.) */
|
|
969
|
+
@media (pointer: coarse) and (hover: none) {
|
|
970
|
+
#guiHost > .lil-gui.lil-root { max-width: 85vw; }
|
|
971
|
+
#hierarchyPanel { width: min(260px, 85vw); }
|
|
972
|
+
}
|
|
959
973
|
/* Checkbox rows (layer list + top-level toggles): lil-gui gives the
|
|
960
974
|
label a 45% min-width and no overflow handling, so a long name like
|
|
961
975
|
"250/74 \u2013 SIN_WG_S" would push into the checkbox column. Widen the
|
|
@@ -997,6 +1011,10 @@ Usually a truncated or half-written file -- a copy that was interrupted, or a do
|
|
|
997
1011
|
buttons a readable width. */
|
|
998
1012
|
.lil-gui .lil-controller.mode-row .lil-name { min-width: 30%; }
|
|
999
1013
|
.lil-gui .lil-controller.mode-row .mode-widget { gap: 4px; }
|
|
1014
|
+
/* Greyed out where the mode is not offered (see touchOnly in
|
|
1015
|
+
viewer.js). lil-gui dims a whole disabled controller, but has no rule
|
|
1016
|
+
for one disabled button inside a live row. */
|
|
1017
|
+
.lil-gui .lil-controller.mode-row .mode-widget button:disabled { opacity: 0.5; cursor: default; }
|
|
1000
1018
|
.lil-gui .lil-controller.mode-row .mode-widget button { flex: 1; }
|
|
1001
1019
|
/* :hover/:active spelled out too -- lil-gui's own button:hover rule
|
|
1002
1020
|
outranks a bare .mode-active, which would grey out the selected
|
|
@@ -3102,6 +3120,7 @@ Usually a truncated or half-written file -- a copy that was interrupted, or a do
|
|
|
3102
3120
|
const hostCall = (name, ...args) => hostCan(name) ? host[name](...args) : void 0;
|
|
3103
3121
|
trace("[GDS] host ready; Worker:", typeof Worker, "Blob:", typeof Blob, "bundled worker:", !!workerBundle);
|
|
3104
3122
|
const gui = new GUI({ width: 260, container: viewerRoot.getElementById("guiHost"), injectStyles: false });
|
|
3123
|
+
const touchOnly = typeof window.matchMedia === "function" && window.matchMedia("(pointer: coarse) and (hover: none)").matches;
|
|
3105
3124
|
const actions = {
|
|
3106
3125
|
// Clicking the row always opens the file dialog (load, or replace the
|
|
3107
3126
|
// current file); the injected ✕ (see setFileChip) handles unloading.
|
|
@@ -3130,6 +3149,7 @@ Usually a truncated or half-written file -- a copy that was interrupted, or a do
|
|
|
3130
3149
|
const lypController = displayFolder.add(actions, "loadLypFile").name("Load .lyp File");
|
|
3131
3150
|
const markerController = displayFolder.add(actions, "loadMarkerFile").name("Load Marker File (.lyrdb / DRC)");
|
|
3132
3151
|
displayFolder.add(actions, "resetView").name("Reset View");
|
|
3152
|
+
if (touchOnly) gui.close();
|
|
3133
3153
|
const MODES = [
|
|
3134
3154
|
{ id: "pan", label: "Pan", title: "Drag to pan the view, wheel to zoom" },
|
|
3135
3155
|
{
|
|
@@ -3152,6 +3172,10 @@ Usually a truncated or half-written file -- a copy that was interrupted, or a do
|
|
|
3152
3172
|
btn.type = "button";
|
|
3153
3173
|
btn.textContent = mode.label;
|
|
3154
3174
|
btn.title = mode.title;
|
|
3175
|
+
if (mode.id === "measure" && touchOnly) {
|
|
3176
|
+
btn.disabled = true;
|
|
3177
|
+
btn.title = "Measuring needs a pointer that hovers: use a mouse or a trackpad.";
|
|
3178
|
+
}
|
|
3155
3179
|
btn.addEventListener("click", () => setMode(mode.id));
|
|
3156
3180
|
modeWidget.appendChild(btn);
|
|
3157
3181
|
modeButtons.set(mode.id, btn);
|
|
@@ -3160,6 +3184,7 @@ Usually a truncated or half-written file -- a copy that was interrupted, or a do
|
|
|
3160
3184
|
modeRow.appendChild(modeWidget);
|
|
3161
3185
|
gui.$children.prepend(modeRow);
|
|
3162
3186
|
function setMode(id) {
|
|
3187
|
+
if (id === "measure" && touchOnly) return;
|
|
3163
3188
|
if (currentMode === id) return;
|
|
3164
3189
|
currentMode = id;
|
|
3165
3190
|
for (const [modeId, btn] of modeButtons) {
|
|
@@ -3198,10 +3223,110 @@ Usually a truncated or half-written file -- a copy that was interrupted, or a do
|
|
|
3198
3223
|
}
|
|
3199
3224
|
const glCanvas = els.glCanvas;
|
|
3200
3225
|
if (glCanvas) {
|
|
3226
|
+
let zoomAbout = function(camera, newZoom, pixelX, pixelY) {
|
|
3227
|
+
const dx = pixelX - glCanvas.clientWidth / 2;
|
|
3228
|
+
const dy = glCanvas.clientHeight / 2 - pixelY;
|
|
3229
|
+
const worldX = camera.panX + dx / camera.zoom;
|
|
3230
|
+
const worldY = camera.panY + dy / camera.zoom;
|
|
3231
|
+
return { zoom: newZoom, panX: worldX - dx / newZoom, panY: worldY - dy / newZoom };
|
|
3232
|
+
};
|
|
3201
3233
|
glCanvas.addEventListener("mousedown", () => {
|
|
3202
3234
|
if (currentMode !== "measure") return;
|
|
3203
3235
|
setTimeout(() => modulePromise.then(refreshRulerRow), 0);
|
|
3204
3236
|
});
|
|
3237
|
+
let panId = null;
|
|
3238
|
+
let pinch = null;
|
|
3239
|
+
let lastX = 0, lastY = 0;
|
|
3240
|
+
const MIN_PINCH_PX = 8;
|
|
3241
|
+
const touchById = (list, id) => {
|
|
3242
|
+
for (const touch of list) if (touch.identifier === id) return touch;
|
|
3243
|
+
return null;
|
|
3244
|
+
};
|
|
3245
|
+
const spread = (a, b) => Math.hypot(a.clientX - b.clientX, a.clientY - b.clientY);
|
|
3246
|
+
const onCanvas = (event) => event.composedPath().includes(glCanvas);
|
|
3247
|
+
const touchIdle = () => currentMode === "measure" || !resolvedModule;
|
|
3248
|
+
hostElement.addEventListener("touchstart", (event) => {
|
|
3249
|
+
if (touchIdle() || !onCanvas(event)) return;
|
|
3250
|
+
const touches = event.touches;
|
|
3251
|
+
if (touches.length >= 2) {
|
|
3252
|
+
if (!pinch) {
|
|
3253
|
+
const [a, b] = touches;
|
|
3254
|
+
pinch = { a: a.identifier, b: b.identifier, dist: spread(a, b) };
|
|
3255
|
+
panId = null;
|
|
3256
|
+
lastX = (a.clientX + b.clientX) / 2;
|
|
3257
|
+
lastY = (a.clientY + b.clientY) / 2;
|
|
3258
|
+
}
|
|
3259
|
+
} else if (touches.length === 1) {
|
|
3260
|
+
panId = touches[0].identifier;
|
|
3261
|
+
lastX = touches[0].clientX;
|
|
3262
|
+
lastY = touches[0].clientY;
|
|
3263
|
+
}
|
|
3264
|
+
event.preventDefault();
|
|
3265
|
+
}, { passive: false });
|
|
3266
|
+
hostElement.addEventListener("touchmove", (event) => {
|
|
3267
|
+
if (touchIdle() || !onCanvas(event)) return;
|
|
3268
|
+
let camera = resolvedModule.getCamera();
|
|
3269
|
+
if (pinch) {
|
|
3270
|
+
const a = touchById(event.touches, pinch.a);
|
|
3271
|
+
const b = touchById(event.touches, pinch.b);
|
|
3272
|
+
if (a && b) {
|
|
3273
|
+
const midX = (a.clientX + b.clientX) / 2;
|
|
3274
|
+
const midY = (a.clientY + b.clientY) / 2;
|
|
3275
|
+
camera = {
|
|
3276
|
+
zoom: camera.zoom,
|
|
3277
|
+
panX: camera.panX - (midX - lastX) / camera.zoom,
|
|
3278
|
+
panY: camera.panY + (midY - lastY) / camera.zoom
|
|
3279
|
+
};
|
|
3280
|
+
lastX = midX;
|
|
3281
|
+
lastY = midY;
|
|
3282
|
+
const dist = spread(a, b);
|
|
3283
|
+
if (dist >= MIN_PINCH_PX && pinch.dist >= MIN_PINCH_PX) {
|
|
3284
|
+
const rect = glCanvas.getBoundingClientRect();
|
|
3285
|
+
camera = zoomAbout(
|
|
3286
|
+
camera,
|
|
3287
|
+
camera.zoom * dist / pinch.dist,
|
|
3288
|
+
midX - rect.left,
|
|
3289
|
+
midY - rect.top
|
|
3290
|
+
);
|
|
3291
|
+
pinch.dist = dist;
|
|
3292
|
+
}
|
|
3293
|
+
resolvedModule.setCamera(camera.zoom, camera.panX, camera.panY);
|
|
3294
|
+
event.preventDefault();
|
|
3295
|
+
}
|
|
3296
|
+
return;
|
|
3297
|
+
}
|
|
3298
|
+
const finger = panId === null ? null : touchById(event.touches, panId);
|
|
3299
|
+
if (!finger) return;
|
|
3300
|
+
resolvedModule.setCamera(
|
|
3301
|
+
camera.zoom,
|
|
3302
|
+
camera.panX - (finger.clientX - lastX) / camera.zoom,
|
|
3303
|
+
camera.panY + (finger.clientY - lastY) / camera.zoom
|
|
3304
|
+
);
|
|
3305
|
+
lastX = finger.clientX;
|
|
3306
|
+
lastY = finger.clientY;
|
|
3307
|
+
event.preventDefault();
|
|
3308
|
+
}, { passive: false });
|
|
3309
|
+
const endTouch = (event) => {
|
|
3310
|
+
if (pinch) {
|
|
3311
|
+
const a = touchById(event.touches, pinch.a);
|
|
3312
|
+
const b = touchById(event.touches, pinch.b);
|
|
3313
|
+
if (a && b) return;
|
|
3314
|
+
const survivor = a || b;
|
|
3315
|
+
pinch = null;
|
|
3316
|
+
panId = survivor ? survivor.identifier : null;
|
|
3317
|
+
if (survivor) {
|
|
3318
|
+
lastX = survivor.clientX;
|
|
3319
|
+
lastY = survivor.clientY;
|
|
3320
|
+
}
|
|
3321
|
+
return;
|
|
3322
|
+
}
|
|
3323
|
+
if (panId !== null && !touchById(event.touches, panId)) panId = null;
|
|
3324
|
+
};
|
|
3325
|
+
hostElement.addEventListener("touchend", endTouch);
|
|
3326
|
+
hostElement.addEventListener("touchcancel", endTouch);
|
|
3327
|
+
for (const type of ["gesturestart", "gesturechange", "gestureend"]) {
|
|
3328
|
+
glCanvas.addEventListener(type, (event) => event.preventDefault());
|
|
3329
|
+
}
|
|
3205
3330
|
}
|
|
3206
3331
|
function setFileChip(controller, name, { idleLabel, idleTitle, unloadTitle, onUnload }) {
|
|
3207
3332
|
const existingX = controller.domElement.querySelector(".lyp-unload");
|
package/dist/web/gds-lens.js
CHANGED
|
@@ -871,7 +871,12 @@ Usually a truncated or half-written file -- a copy that was interrupted, or a do
|
|
|
871
871
|
against it rather than against the page. */
|
|
872
872
|
:host { display: block; position: relative; width: 100%; height: 100%; overflow: hidden;
|
|
873
873
|
background: var(--bg); user-select: none; color: var(--fg); }
|
|
874
|
-
|
|
874
|
+
/* touch-action: none hands every gesture over the canvas to the touch
|
|
875
|
+
handlers in renderer.cpp: without it the browser claims a drag for
|
|
876
|
+
page scrolling and a pinch for its own page zoom before the handlers
|
|
877
|
+
see the second event, and it is also what turns off double-tap zoom
|
|
878
|
+
over the layout. */
|
|
879
|
+
canvas { width: 100%; height: 100%; display: block; touch-action: none; }
|
|
875
880
|
/* The engine readout (polygon/label counts + the per-frame #renderStats
|
|
876
881
|
span, both written from renderer.cpp). It lives inside #debugPanel
|
|
877
882
|
rather than floating over the canvas on its own: it's debug-only
|
|
@@ -956,6 +961,15 @@ Usually a truncated or half-written file -- a copy that was interrupted, or a do
|
|
|
956
961
|
overlay (30) and debug panel (40) rather than lil-gui's 1001. */
|
|
957
962
|
#guiHost { position: absolute; top: 0; right: 15px; max-height: 100%; z-index: 20; display: flex; }
|
|
958
963
|
#guiHost > .lil-gui.lil-root { max-height: 100%; }
|
|
964
|
+
/* Both panels are sized for a window, and a phone is narrower than
|
|
965
|
+
either of them plus anything worth seeing. Capped rather than
|
|
966
|
+
reflowed: they still open over the layout, but never across all of
|
|
967
|
+
it. (The control panel also starts collapsed here -- see touchOnly
|
|
968
|
+
in viewer.js.) */
|
|
969
|
+
@media (pointer: coarse) and (hover: none) {
|
|
970
|
+
#guiHost > .lil-gui.lil-root { max-width: 85vw; }
|
|
971
|
+
#hierarchyPanel { width: min(260px, 85vw); }
|
|
972
|
+
}
|
|
959
973
|
/* Checkbox rows (layer list + top-level toggles): lil-gui gives the
|
|
960
974
|
label a 45% min-width and no overflow handling, so a long name like
|
|
961
975
|
"250/74 \u2013 SIN_WG_S" would push into the checkbox column. Widen the
|
|
@@ -997,6 +1011,10 @@ Usually a truncated or half-written file -- a copy that was interrupted, or a do
|
|
|
997
1011
|
buttons a readable width. */
|
|
998
1012
|
.lil-gui .lil-controller.mode-row .lil-name { min-width: 30%; }
|
|
999
1013
|
.lil-gui .lil-controller.mode-row .mode-widget { gap: 4px; }
|
|
1014
|
+
/* Greyed out where the mode is not offered (see touchOnly in
|
|
1015
|
+
viewer.js). lil-gui dims a whole disabled controller, but has no rule
|
|
1016
|
+
for one disabled button inside a live row. */
|
|
1017
|
+
.lil-gui .lil-controller.mode-row .mode-widget button:disabled { opacity: 0.5; cursor: default; }
|
|
1000
1018
|
.lil-gui .lil-controller.mode-row .mode-widget button { flex: 1; }
|
|
1001
1019
|
/* :hover/:active spelled out too -- lil-gui's own button:hover rule
|
|
1002
1020
|
outranks a bare .mode-active, which would grey out the selected
|
|
@@ -3102,6 +3120,7 @@ Usually a truncated or half-written file -- a copy that was interrupted, or a do
|
|
|
3102
3120
|
const hostCall = (name, ...args) => hostCan(name) ? host[name](...args) : void 0;
|
|
3103
3121
|
trace("[GDS] host ready; Worker:", typeof Worker, "Blob:", typeof Blob, "bundled worker:", !!workerBundle);
|
|
3104
3122
|
const gui = new GUI({ width: 260, container: viewerRoot.getElementById("guiHost"), injectStyles: false });
|
|
3123
|
+
const touchOnly = typeof window.matchMedia === "function" && window.matchMedia("(pointer: coarse) and (hover: none)").matches;
|
|
3105
3124
|
const actions = {
|
|
3106
3125
|
// Clicking the row always opens the file dialog (load, or replace the
|
|
3107
3126
|
// current file); the injected ✕ (see setFileChip) handles unloading.
|
|
@@ -3130,6 +3149,7 @@ Usually a truncated or half-written file -- a copy that was interrupted, or a do
|
|
|
3130
3149
|
const lypController = displayFolder.add(actions, "loadLypFile").name("Load .lyp File");
|
|
3131
3150
|
const markerController = displayFolder.add(actions, "loadMarkerFile").name("Load Marker File (.lyrdb / DRC)");
|
|
3132
3151
|
displayFolder.add(actions, "resetView").name("Reset View");
|
|
3152
|
+
if (touchOnly) gui.close();
|
|
3133
3153
|
const MODES = [
|
|
3134
3154
|
{ id: "pan", label: "Pan", title: "Drag to pan the view, wheel to zoom" },
|
|
3135
3155
|
{
|
|
@@ -3152,6 +3172,10 @@ Usually a truncated or half-written file -- a copy that was interrupted, or a do
|
|
|
3152
3172
|
btn.type = "button";
|
|
3153
3173
|
btn.textContent = mode.label;
|
|
3154
3174
|
btn.title = mode.title;
|
|
3175
|
+
if (mode.id === "measure" && touchOnly) {
|
|
3176
|
+
btn.disabled = true;
|
|
3177
|
+
btn.title = "Measuring needs a pointer that hovers: use a mouse or a trackpad.";
|
|
3178
|
+
}
|
|
3155
3179
|
btn.addEventListener("click", () => setMode(mode.id));
|
|
3156
3180
|
modeWidget.appendChild(btn);
|
|
3157
3181
|
modeButtons.set(mode.id, btn);
|
|
@@ -3160,6 +3184,7 @@ Usually a truncated or half-written file -- a copy that was interrupted, or a do
|
|
|
3160
3184
|
modeRow.appendChild(modeWidget);
|
|
3161
3185
|
gui.$children.prepend(modeRow);
|
|
3162
3186
|
function setMode(id) {
|
|
3187
|
+
if (id === "measure" && touchOnly) return;
|
|
3163
3188
|
if (currentMode === id) return;
|
|
3164
3189
|
currentMode = id;
|
|
3165
3190
|
for (const [modeId, btn] of modeButtons) {
|
|
@@ -3198,10 +3223,110 @@ Usually a truncated or half-written file -- a copy that was interrupted, or a do
|
|
|
3198
3223
|
}
|
|
3199
3224
|
const glCanvas = els.glCanvas;
|
|
3200
3225
|
if (glCanvas) {
|
|
3226
|
+
let zoomAbout = function(camera, newZoom, pixelX, pixelY) {
|
|
3227
|
+
const dx = pixelX - glCanvas.clientWidth / 2;
|
|
3228
|
+
const dy = glCanvas.clientHeight / 2 - pixelY;
|
|
3229
|
+
const worldX = camera.panX + dx / camera.zoom;
|
|
3230
|
+
const worldY = camera.panY + dy / camera.zoom;
|
|
3231
|
+
return { zoom: newZoom, panX: worldX - dx / newZoom, panY: worldY - dy / newZoom };
|
|
3232
|
+
};
|
|
3201
3233
|
glCanvas.addEventListener("mousedown", () => {
|
|
3202
3234
|
if (currentMode !== "measure") return;
|
|
3203
3235
|
setTimeout(() => modulePromise.then(refreshRulerRow), 0);
|
|
3204
3236
|
});
|
|
3237
|
+
let panId = null;
|
|
3238
|
+
let pinch = null;
|
|
3239
|
+
let lastX = 0, lastY = 0;
|
|
3240
|
+
const MIN_PINCH_PX = 8;
|
|
3241
|
+
const touchById = (list, id) => {
|
|
3242
|
+
for (const touch of list) if (touch.identifier === id) return touch;
|
|
3243
|
+
return null;
|
|
3244
|
+
};
|
|
3245
|
+
const spread = (a, b) => Math.hypot(a.clientX - b.clientX, a.clientY - b.clientY);
|
|
3246
|
+
const onCanvas = (event) => event.composedPath().includes(glCanvas);
|
|
3247
|
+
const touchIdle = () => currentMode === "measure" || !resolvedModule;
|
|
3248
|
+
hostElement.addEventListener("touchstart", (event) => {
|
|
3249
|
+
if (touchIdle() || !onCanvas(event)) return;
|
|
3250
|
+
const touches = event.touches;
|
|
3251
|
+
if (touches.length >= 2) {
|
|
3252
|
+
if (!pinch) {
|
|
3253
|
+
const [a, b] = touches;
|
|
3254
|
+
pinch = { a: a.identifier, b: b.identifier, dist: spread(a, b) };
|
|
3255
|
+
panId = null;
|
|
3256
|
+
lastX = (a.clientX + b.clientX) / 2;
|
|
3257
|
+
lastY = (a.clientY + b.clientY) / 2;
|
|
3258
|
+
}
|
|
3259
|
+
} else if (touches.length === 1) {
|
|
3260
|
+
panId = touches[0].identifier;
|
|
3261
|
+
lastX = touches[0].clientX;
|
|
3262
|
+
lastY = touches[0].clientY;
|
|
3263
|
+
}
|
|
3264
|
+
event.preventDefault();
|
|
3265
|
+
}, { passive: false });
|
|
3266
|
+
hostElement.addEventListener("touchmove", (event) => {
|
|
3267
|
+
if (touchIdle() || !onCanvas(event)) return;
|
|
3268
|
+
let camera = resolvedModule.getCamera();
|
|
3269
|
+
if (pinch) {
|
|
3270
|
+
const a = touchById(event.touches, pinch.a);
|
|
3271
|
+
const b = touchById(event.touches, pinch.b);
|
|
3272
|
+
if (a && b) {
|
|
3273
|
+
const midX = (a.clientX + b.clientX) / 2;
|
|
3274
|
+
const midY = (a.clientY + b.clientY) / 2;
|
|
3275
|
+
camera = {
|
|
3276
|
+
zoom: camera.zoom,
|
|
3277
|
+
panX: camera.panX - (midX - lastX) / camera.zoom,
|
|
3278
|
+
panY: camera.panY + (midY - lastY) / camera.zoom
|
|
3279
|
+
};
|
|
3280
|
+
lastX = midX;
|
|
3281
|
+
lastY = midY;
|
|
3282
|
+
const dist = spread(a, b);
|
|
3283
|
+
if (dist >= MIN_PINCH_PX && pinch.dist >= MIN_PINCH_PX) {
|
|
3284
|
+
const rect = glCanvas.getBoundingClientRect();
|
|
3285
|
+
camera = zoomAbout(
|
|
3286
|
+
camera,
|
|
3287
|
+
camera.zoom * dist / pinch.dist,
|
|
3288
|
+
midX - rect.left,
|
|
3289
|
+
midY - rect.top
|
|
3290
|
+
);
|
|
3291
|
+
pinch.dist = dist;
|
|
3292
|
+
}
|
|
3293
|
+
resolvedModule.setCamera(camera.zoom, camera.panX, camera.panY);
|
|
3294
|
+
event.preventDefault();
|
|
3295
|
+
}
|
|
3296
|
+
return;
|
|
3297
|
+
}
|
|
3298
|
+
const finger = panId === null ? null : touchById(event.touches, panId);
|
|
3299
|
+
if (!finger) return;
|
|
3300
|
+
resolvedModule.setCamera(
|
|
3301
|
+
camera.zoom,
|
|
3302
|
+
camera.panX - (finger.clientX - lastX) / camera.zoom,
|
|
3303
|
+
camera.panY + (finger.clientY - lastY) / camera.zoom
|
|
3304
|
+
);
|
|
3305
|
+
lastX = finger.clientX;
|
|
3306
|
+
lastY = finger.clientY;
|
|
3307
|
+
event.preventDefault();
|
|
3308
|
+
}, { passive: false });
|
|
3309
|
+
const endTouch = (event) => {
|
|
3310
|
+
if (pinch) {
|
|
3311
|
+
const a = touchById(event.touches, pinch.a);
|
|
3312
|
+
const b = touchById(event.touches, pinch.b);
|
|
3313
|
+
if (a && b) return;
|
|
3314
|
+
const survivor = a || b;
|
|
3315
|
+
pinch = null;
|
|
3316
|
+
panId = survivor ? survivor.identifier : null;
|
|
3317
|
+
if (survivor) {
|
|
3318
|
+
lastX = survivor.clientX;
|
|
3319
|
+
lastY = survivor.clientY;
|
|
3320
|
+
}
|
|
3321
|
+
return;
|
|
3322
|
+
}
|
|
3323
|
+
if (panId !== null && !touchById(event.touches, panId)) panId = null;
|
|
3324
|
+
};
|
|
3325
|
+
hostElement.addEventListener("touchend", endTouch);
|
|
3326
|
+
hostElement.addEventListener("touchcancel", endTouch);
|
|
3327
|
+
for (const type of ["gesturestart", "gesturechange", "gestureend"]) {
|
|
3328
|
+
glCanvas.addEventListener(type, (event) => event.preventDefault());
|
|
3329
|
+
}
|
|
3205
3330
|
}
|
|
3206
3331
|
function setFileChip(controller, name, { idleLabel, idleTitle, unloadTitle, onUnload }) {
|
|
3207
3332
|
const existingX = controller.domElement.querySelector(".lyp-unload");
|
package/package.json
CHANGED
package/src/viewer.css
CHANGED
|
@@ -110,7 +110,12 @@
|
|
|
110
110
|
against it rather than against the page. */
|
|
111
111
|
:host { display: block; position: relative; width: 100%; height: 100%; overflow: hidden;
|
|
112
112
|
background: var(--bg); user-select: none; color: var(--fg); }
|
|
113
|
-
|
|
113
|
+
/* touch-action: none hands every gesture over the canvas to the touch
|
|
114
|
+
handlers in renderer.cpp: without it the browser claims a drag for
|
|
115
|
+
page scrolling and a pinch for its own page zoom before the handlers
|
|
116
|
+
see the second event, and it is also what turns off double-tap zoom
|
|
117
|
+
over the layout. */
|
|
118
|
+
canvas { width: 100%; height: 100%; display: block; touch-action: none; }
|
|
114
119
|
/* The engine readout (polygon/label counts + the per-frame #renderStats
|
|
115
120
|
span, both written from renderer.cpp). It lives inside #debugPanel
|
|
116
121
|
rather than floating over the canvas on its own: it's debug-only
|
|
@@ -195,6 +200,15 @@
|
|
|
195
200
|
overlay (30) and debug panel (40) rather than lil-gui's 1001. */
|
|
196
201
|
#guiHost { position: absolute; top: 0; right: 15px; max-height: 100%; z-index: 20; display: flex; }
|
|
197
202
|
#guiHost > .lil-gui.lil-root { max-height: 100%; }
|
|
203
|
+
/* Both panels are sized for a window, and a phone is narrower than
|
|
204
|
+
either of them plus anything worth seeing. Capped rather than
|
|
205
|
+
reflowed: they still open over the layout, but never across all of
|
|
206
|
+
it. (The control panel also starts collapsed here -- see touchOnly
|
|
207
|
+
in viewer.js.) */
|
|
208
|
+
@media (pointer: coarse) and (hover: none) {
|
|
209
|
+
#guiHost > .lil-gui.lil-root { max-width: 85vw; }
|
|
210
|
+
#hierarchyPanel { width: min(260px, 85vw); }
|
|
211
|
+
}
|
|
198
212
|
/* Checkbox rows (layer list + top-level toggles): lil-gui gives the
|
|
199
213
|
label a 45% min-width and no overflow handling, so a long name like
|
|
200
214
|
"250/74 – SIN_WG_S" would push into the checkbox column. Widen the
|
|
@@ -236,6 +250,10 @@
|
|
|
236
250
|
buttons a readable width. */
|
|
237
251
|
.lil-gui .lil-controller.mode-row .lil-name { min-width: 30%; }
|
|
238
252
|
.lil-gui .lil-controller.mode-row .mode-widget { gap: 4px; }
|
|
253
|
+
/* Greyed out where the mode is not offered (see touchOnly in
|
|
254
|
+
viewer.js). lil-gui dims a whole disabled controller, but has no rule
|
|
255
|
+
for one disabled button inside a live row. */
|
|
256
|
+
.lil-gui .lil-controller.mode-row .mode-widget button:disabled { opacity: 0.5; cursor: default; }
|
|
239
257
|
.lil-gui .lil-controller.mode-row .mode-widget button { flex: 1; }
|
|
240
258
|
/* :hover/:active spelled out too -- lil-gui's own button:hover rule
|
|
241
259
|
outranks a bare .mode-active, which would grey out the selected
|
package/src/viewer.js
CHANGED
|
@@ -311,6 +311,14 @@ export function createViewer(mountTarget) {
|
|
|
311
311
|
// unstyled. Its CSS is carried in viewer.css instead (see the lil-gui block
|
|
312
312
|
// there), alongside everything else the shadow root owns.
|
|
313
313
|
const gui = new GUI({ width: 260, container: viewerRoot.getElementById("guiHost"), injectStyles: false });
|
|
314
|
+
|
|
315
|
+
// A touch-only device: coarse pointer *and* no hover, so a touchscreen
|
|
316
|
+
// laptop (coarse pointer, but a mouse too) is not one. Two things follow
|
|
317
|
+
// from it -- the panel starts collapsed, and measure mode is withheld --
|
|
318
|
+
// and both are about the same thing: what a fingertip can do, and how much
|
|
319
|
+
// room there is to do it in.
|
|
320
|
+
const touchOnly = typeof window.matchMedia === "function"
|
|
321
|
+
&& window.matchMedia("(pointer: coarse) and (hover: none)").matches;
|
|
314
322
|
const actions = {
|
|
315
323
|
// Clicking the row always opens the file dialog (load, or replace the
|
|
316
324
|
// current file); the injected ✕ (see setFileChip) handles unloading.
|
|
@@ -363,6 +371,13 @@ export function createViewer(mountTarget) {
|
|
|
363
371
|
const markerController = displayFolder.add(actions, "loadMarkerFile").name("Load Marker File (.lyrdb / DRC)");
|
|
364
372
|
displayFolder.add(actions, "resetView").name("Reset View");
|
|
365
373
|
|
|
374
|
+
// On a phone the panel is most of the screen, so it starts collapsed to its
|
|
375
|
+
// title bar rather than opening over the layout: the point of arriving is
|
|
376
|
+
// to see the design, and one tap on the bar brings the controls back. Left
|
|
377
|
+
// open everywhere a pointer hovers, where it costs a corner of a much
|
|
378
|
+
// larger window.
|
|
379
|
+
if (touchOnly) gui.close();
|
|
380
|
+
|
|
366
381
|
// ---- Interaction mode (Pan / Measure) ----
|
|
367
382
|
// The canvas can only do one thing with a click, so the two are exclusive
|
|
368
383
|
// modes rather than an "on top of panning" toggle: in Pan mode a drag moves
|
|
@@ -399,6 +414,16 @@ export function createViewer(mountTarget) {
|
|
|
399
414
|
btn.type = "button";
|
|
400
415
|
btn.textContent = mode.label;
|
|
401
416
|
btn.title = mode.title;
|
|
417
|
+
// Measuring is left to the compatibility mouse events a tap produces
|
|
418
|
+
// (the canvas's touch handlers in renderer.cpp bow out while the mode
|
|
419
|
+
// is on), and on a touch-only device that is a poor tool: with no
|
|
420
|
+
// hover the snap indicator only appears after the tap that already
|
|
421
|
+
// used it, and the point it snapped to is under a fingertip. Offered
|
|
422
|
+
// greyed out rather than half-working.
|
|
423
|
+
if (mode.id === "measure" && touchOnly) {
|
|
424
|
+
btn.disabled = true;
|
|
425
|
+
btn.title = "Measuring needs a pointer that hovers: use a mouse or a trackpad.";
|
|
426
|
+
}
|
|
402
427
|
btn.addEventListener("click", () => setMode(mode.id));
|
|
403
428
|
modeWidget.appendChild(btn);
|
|
404
429
|
modeButtons.set(mode.id, btn);
|
|
@@ -413,6 +438,8 @@ export function createViewer(mountTarget) {
|
|
|
413
438
|
gui.$children.prepend(modeRow);
|
|
414
439
|
|
|
415
440
|
function setMode(id) {
|
|
441
|
+
// Also guards the M key, which calls straight in here.
|
|
442
|
+
if (id === "measure" && touchOnly) return;
|
|
416
443
|
if (currentMode === id) return;
|
|
417
444
|
currentMode = id;
|
|
418
445
|
for (const [modeId, btn] of modeButtons) {
|
|
@@ -470,6 +497,167 @@ export function createViewer(mountTarget) {
|
|
|
470
497
|
if (currentMode !== "measure") return;
|
|
471
498
|
setTimeout(() => modulePromise.then(refreshRulerRow), 0);
|
|
472
499
|
});
|
|
500
|
+
|
|
501
|
+
// ---- Touch: one finger pans, two pinch about their midpoint ----
|
|
502
|
+
//
|
|
503
|
+
// Here rather than in renderer.cpp, where the mouse and wheel handlers
|
|
504
|
+
// live, because Emscripten's touch marshalling never reaches the C++
|
|
505
|
+
// callback on iOS Safari: the events arrive at the canvas, and the
|
|
506
|
+
// handler behind emscripten_set_touchstart_callback does not run.
|
|
507
|
+
// Driving the camera through getCamera/setCamera is the same
|
|
508
|
+
// arithmetic (see zoom_about_canvas_point in renderer.cpp) with
|
|
509
|
+
// nothing between the DOM event and the state it changes.
|
|
510
|
+
//
|
|
511
|
+
// Bound to the host element and filtered by composedPath rather than
|
|
512
|
+
// bound to the canvas: a touch sequence keeps the target it started
|
|
513
|
+
// on, so this sees a whole gesture that began on the layout, and never
|
|
514
|
+
// sees one that began on the control panel -- which is what leaves the
|
|
515
|
+
// layer list scrollable.
|
|
516
|
+
//
|
|
517
|
+
// Fingers are tracked by identifier, never by their index in the touch
|
|
518
|
+
// list: that list is rebuilt on every event, so the slot a gesture
|
|
519
|
+
// started in can hold a different finger a frame later.
|
|
520
|
+
let panId = null;
|
|
521
|
+
let pinch = null; // { a, b, dist } while two fingers are down
|
|
522
|
+
let lastX = 0, lastY = 0; // client px: the pan finger, or the midpoint
|
|
523
|
+
|
|
524
|
+
// Below this the fingers are close enough that the distance ratio is
|
|
525
|
+
// mostly noise, and one jittery frame would multiply the zoom by an
|
|
526
|
+
// arbitrary amount.
|
|
527
|
+
const MIN_PINCH_PX = 8;
|
|
528
|
+
|
|
529
|
+
const touchById = (list, id) => {
|
|
530
|
+
for (const touch of list) if (touch.identifier === id) return touch;
|
|
531
|
+
return null;
|
|
532
|
+
};
|
|
533
|
+
const spread = (a, b) => Math.hypot(a.clientX - b.clientX, a.clientY - b.clientY);
|
|
534
|
+
const onCanvas = (event) => event.composedPath().includes(glCanvas);
|
|
535
|
+
|
|
536
|
+
// Holds the world point under a canvas pixel still across a zoom
|
|
537
|
+
// change: the same inverse the vertex shader applies, solved for the
|
|
538
|
+
// pan that puts that point back under the same pixel.
|
|
539
|
+
function zoomAbout(camera, newZoom, pixelX, pixelY) {
|
|
540
|
+
const dx = pixelX - glCanvas.clientWidth / 2;
|
|
541
|
+
const dy = glCanvas.clientHeight / 2 - pixelY;
|
|
542
|
+
const worldX = camera.panX + dx / camera.zoom;
|
|
543
|
+
const worldY = camera.panY + dy / camera.zoom;
|
|
544
|
+
return { zoom: newZoom, panX: worldX - dx / newZoom, panY: worldY - dy / newZoom };
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
// Measure mode places its ruler by tapping, which arrives as the
|
|
548
|
+
// compatibility mouse events renderer.cpp already handles, so the
|
|
549
|
+
// gesture is left alone while it is on.
|
|
550
|
+
const touchIdle = () => currentMode === "measure" || !resolvedModule;
|
|
551
|
+
|
|
552
|
+
hostElement.addEventListener("touchstart", (event) => {
|
|
553
|
+
if (touchIdle() || !onCanvas(event)) return;
|
|
554
|
+
const touches = event.touches;
|
|
555
|
+
if (touches.length >= 2) {
|
|
556
|
+
// A third finger is ignored rather than reseating the pinch,
|
|
557
|
+
// which would jump the view mid-zoom.
|
|
558
|
+
if (!pinch) {
|
|
559
|
+
const [a, b] = touches;
|
|
560
|
+
pinch = { a: a.identifier, b: b.identifier, dist: spread(a, b) };
|
|
561
|
+
panId = null;
|
|
562
|
+
lastX = (a.clientX + b.clientX) / 2;
|
|
563
|
+
lastY = (a.clientY + b.clientY) / 2;
|
|
564
|
+
}
|
|
565
|
+
} else if (touches.length === 1) {
|
|
566
|
+
panId = touches[0].identifier;
|
|
567
|
+
lastX = touches[0].clientX;
|
|
568
|
+
lastY = touches[0].clientY;
|
|
569
|
+
}
|
|
570
|
+
// Without this the browser takes the gesture for page scrolling or
|
|
571
|
+
// its own pinch zoom, whatever touch-action says.
|
|
572
|
+
event.preventDefault();
|
|
573
|
+
}, { passive: false });
|
|
574
|
+
|
|
575
|
+
hostElement.addEventListener("touchmove", (event) => {
|
|
576
|
+
if (touchIdle() || !onCanvas(event)) return;
|
|
577
|
+
let camera = resolvedModule.getCamera();
|
|
578
|
+
|
|
579
|
+
if (pinch) {
|
|
580
|
+
const a = touchById(event.touches, pinch.a);
|
|
581
|
+
const b = touchById(event.touches, pinch.b);
|
|
582
|
+
if (a && b) {
|
|
583
|
+
// Two things at once, and both are wanted: the midpoint
|
|
584
|
+
// travelling across the glass drags the layout exactly as
|
|
585
|
+
// one finger would, and the fingers' separation changing
|
|
586
|
+
// zooms about that midpoint. Panning first, at the
|
|
587
|
+
// pre-zoom scale, keeps the two independent.
|
|
588
|
+
const midX = (a.clientX + b.clientX) / 2;
|
|
589
|
+
const midY = (a.clientY + b.clientY) / 2;
|
|
590
|
+
camera = {
|
|
591
|
+
zoom: camera.zoom,
|
|
592
|
+
panX: camera.panX - (midX - lastX) / camera.zoom,
|
|
593
|
+
panY: camera.panY + (midY - lastY) / camera.zoom
|
|
594
|
+
};
|
|
595
|
+
lastX = midX;
|
|
596
|
+
lastY = midY;
|
|
597
|
+
|
|
598
|
+
const dist = spread(a, b);
|
|
599
|
+
if (dist >= MIN_PINCH_PX && pinch.dist >= MIN_PINCH_PX) {
|
|
600
|
+
const rect = glCanvas.getBoundingClientRect();
|
|
601
|
+
camera = zoomAbout(camera, camera.zoom * dist / pinch.dist,
|
|
602
|
+
midX - rect.left, midY - rect.top);
|
|
603
|
+
pinch.dist = dist;
|
|
604
|
+
}
|
|
605
|
+
// setCamera clamps both zoom and pan and asks for a redraw,
|
|
606
|
+
// so the next getCamera reads back what was allowed rather
|
|
607
|
+
// than what was asked for, and nothing drifts.
|
|
608
|
+
resolvedModule.setCamera(camera.zoom, camera.panX, camera.panY);
|
|
609
|
+
event.preventDefault();
|
|
610
|
+
}
|
|
611
|
+
return;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
const finger = panId === null ? null : touchById(event.touches, panId);
|
|
615
|
+
if (!finger) return;
|
|
616
|
+
resolvedModule.setCamera(camera.zoom,
|
|
617
|
+
camera.panX - (finger.clientX - lastX) / camera.zoom,
|
|
618
|
+
camera.panY + (finger.clientY - lastY) / camera.zoom);
|
|
619
|
+
lastX = finger.clientX;
|
|
620
|
+
lastY = finger.clientY;
|
|
621
|
+
event.preventDefault();
|
|
622
|
+
}, { passive: false });
|
|
623
|
+
|
|
624
|
+
// touchend and touchcancel want the same thing: forget the fingers that
|
|
625
|
+
// are gone, and keep whatever gesture the ones still down can support.
|
|
626
|
+
// event.touches is already only the fingers still on the glass.
|
|
627
|
+
const endTouch = (event) => {
|
|
628
|
+
if (pinch) {
|
|
629
|
+
const a = touchById(event.touches, pinch.a);
|
|
630
|
+
const b = touchById(event.touches, pinch.b);
|
|
631
|
+
if (a && b) return;
|
|
632
|
+
// Lifting one finger of a pinch hands the gesture to the other
|
|
633
|
+
// rather than ending it. Otherwise the survivor is picked up as
|
|
634
|
+
// a fresh pan only at its next move, and the view jumps by
|
|
635
|
+
// however far it travelled since the lift.
|
|
636
|
+
const survivor = a || b;
|
|
637
|
+
pinch = null;
|
|
638
|
+
panId = survivor ? survivor.identifier : null;
|
|
639
|
+
if (survivor) {
|
|
640
|
+
lastX = survivor.clientX;
|
|
641
|
+
lastY = survivor.clientY;
|
|
642
|
+
}
|
|
643
|
+
return;
|
|
644
|
+
}
|
|
645
|
+
if (panId !== null && !touchById(event.touches, panId)) panId = null;
|
|
646
|
+
};
|
|
647
|
+
hostElement.addEventListener("touchend", endTouch);
|
|
648
|
+
hostElement.addEventListener("touchcancel", endTouch);
|
|
649
|
+
|
|
650
|
+
// iOS Safari answers a two-finger pinch with its own page zoom,
|
|
651
|
+
// delivered as a proprietary GestureEvent rather than through the touch
|
|
652
|
+
// events, and it can do that over an element that has already claimed
|
|
653
|
+
// the gesture with touch-action: none. Refused here so the pinch
|
|
654
|
+
// reaches the touch handlers in renderer.cpp instead -- and refused on
|
|
655
|
+
// the canvas alone, never on the page, so a pinch anywhere else in an
|
|
656
|
+
// embedding page still zooms it. A no-op in every other browser:
|
|
657
|
+
// nothing else fires these.
|
|
658
|
+
for (const type of ["gesturestart", "gesturechange", "gestureend"]) {
|
|
659
|
+
glCanvas.addEventListener(type, (event) => event.preventDefault());
|
|
660
|
+
}
|
|
473
661
|
}
|
|
474
662
|
|
|
475
663
|
// Reflects a loaded-file state in a lil-gui button row (used by both the
|