partforge 0.102.0 → 0.103.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/README.md +9 -0
- package/package.json +1 -1
- package/src/framework/chrome.css +75 -1
- package/src/framework/mobile-tabs.js +88 -8
- package/src/framework/mount.js +23 -1
- package/src/framework/rail.js +78 -16
- package/types/index.d.ts +20 -0
package/README.md
CHANGED
|
@@ -113,6 +113,15 @@ await runtime.ready; // first successful build (rejects on a first-build error
|
|
|
113
113
|
runtime.setHostPane("rail"); // narrow layout only: show just the controls
|
|
114
114
|
// rail ('stage' | 'rail'), suppressing the
|
|
115
115
|
// built-in tab bar. null hands selection back.
|
|
116
|
+
runtime.setRailLayout({ mode: "dock", inset: 380, railHeight: 316 });
|
|
117
|
+
// where the rail SITS when the host draws chrome over
|
|
118
|
+
// the frame: docked into its bottom sheet (inset = the
|
|
119
|
+
// sheet's height, railHeight = the slice of it the rail
|
|
120
|
+
// may paint into), or { mode: "overlay" } for a
|
|
121
|
+
// right-edge drawer over a full-width stage. null (or
|
|
122
|
+
// any shape partforge can't read) restores partforge's
|
|
123
|
+
// own layout; resize and collapse are suspended while
|
|
124
|
+
// a layout is leased.
|
|
116
125
|
runtime.setActive(false); // park the viewer: stop the render loop, release the
|
|
117
126
|
// drawing buffer. setActive(true) restores both.
|
|
118
127
|
runtime.attachTooltips([{ element: myButton }]); // host chrome buttons join the mount's
|
package/package.json
CHANGED
package/src/framework/chrome.css
CHANGED
|
@@ -454,7 +454,74 @@
|
|
|
454
454
|
.pf-anim-bar { width: calc(100% - 24px); max-width: 520px; bottom: 64px; }
|
|
455
455
|
}
|
|
456
456
|
|
|
457
|
-
|
|
457
|
+
/* ---- host-driven rail layouts (partforge-cloud) ---------------------------
|
|
458
|
+
data-pf-rail-layout is written by mobile-tabs.js from the host's
|
|
459
|
+
setRailLayout. Two modes, both host-only (nothing in partforge's own UI can
|
|
460
|
+
enter them):
|
|
461
|
+
|
|
462
|
+
DOCK — the host draws a bottom sheet over the iframe. --pf-rail-inset is the
|
|
463
|
+
sheet's full height: the shell pads by it so the stage (and its bottom
|
|
464
|
+
chrome) clears the sheet. With a "rail" lease the rail renders INTO the
|
|
465
|
+
sheet region — but only its bottom --pf-rail-dock-h px, because the host's
|
|
466
|
+
opaque chrome strip (handle + tabs) covers the top of the region. The spec
|
|
467
|
+
(2026-08-31-responsive-layout-redesign) has the two-numbers reasoning. Note
|
|
468
|
+
that a dock lease ABOVE 719px does not enter the block below at all: the
|
|
469
|
+
rail stays side by side with the stage at its usual --pf-rail-w (rail.js
|
|
470
|
+
keeps the width there, and only suspends resize/collapse/toggle).
|
|
471
|
+
|
|
472
|
+
OVERLAY — the rail is a right-edge drawer OVER the stage, open while
|
|
473
|
+
data-pf-rail-open is present (rail.js's toggle sets it; mobile-tabs.js's
|
|
474
|
+
stage pointerdown clears it). The stage never resizes. Width is its own
|
|
475
|
+
min(288px, 85%) — NOT --pf-rail-w, which rail.js zeroes under an overlay
|
|
476
|
+
lease at any window width (and under any layout below the breakpoint).
|
|
477
|
+
|
|
478
|
+
These come after the narrow block above because they override its one-pane
|
|
479
|
+
hiding, and before the reduced-motion block below so that block's
|
|
480
|
+
transition:none wins the tie at equal specificity. */
|
|
481
|
+
.pf-shell[data-pf-rail-layout="dock"] { padding-bottom: var(--pf-rail-inset, 0px); }
|
|
482
|
+
@media (max-width: 719px) {
|
|
483
|
+
/* pane=rail on a phone: stage above, rail docked in the sheet region. The
|
|
484
|
+
three rules below override the narrow block's one-pane hiding. */
|
|
485
|
+
.pf-shell[data-pf-rail-layout="dock"][data-pf-pane="rail"] { padding-bottom: 0; }
|
|
486
|
+
.pf-shell[data-pf-rail-layout="dock"][data-pf-pane="rail"] .pf-stage { display: block; }
|
|
487
|
+
.pf-shell[data-pf-rail-layout="dock"][data-pf-pane="rail"] .pf-rail {
|
|
488
|
+
display: flex;
|
|
489
|
+
flex: none;
|
|
490
|
+
height: var(--pf-rail-dock-h, 0px);
|
|
491
|
+
width: auto;
|
|
492
|
+
border-left: 0;
|
|
493
|
+
border-top: 1px solid var(--pf-border);
|
|
494
|
+
box-shadow: none;
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
.pf-shell[data-pf-rail-layout="dock"] .pf-rail-seam { display: none; }
|
|
498
|
+
|
|
499
|
+
/* visibility, not just the transform: collapse is suppressed under a host
|
|
500
|
+
layout (rail.js), so nothing else makes a shut drawer inert — it would sit
|
|
501
|
+
off-canvas as a run of invisible tab stops. The delay is what keeps it on
|
|
502
|
+
screen for the 200ms it takes to slide out; opening flips it back at once,
|
|
503
|
+
which is why the open state restates the transition without it. */
|
|
504
|
+
.pf-shell[data-pf-rail-layout="overlay"] .pf-stage { display: block; }
|
|
505
|
+
.pf-shell[data-pf-rail-layout="overlay"] .pf-rail {
|
|
506
|
+
display: flex;
|
|
507
|
+
position: absolute;
|
|
508
|
+
top: 0; right: 0; bottom: 0;
|
|
509
|
+
flex: none;
|
|
510
|
+
width: min(288px, 85%);
|
|
511
|
+
border-left: 1px solid var(--pf-border);
|
|
512
|
+
box-shadow: var(--pf-shadow-rail), -16px 0 32px rgb(0 0 0 / .35);
|
|
513
|
+
transform: translateX(102%);
|
|
514
|
+
visibility: hidden;
|
|
515
|
+
transition: transform 200ms ease, visibility 0s 200ms;
|
|
516
|
+
z-index: 30;
|
|
517
|
+
}
|
|
518
|
+
.pf-shell[data-pf-rail-layout="overlay"][data-pf-rail-open] .pf-rail {
|
|
519
|
+
transform: translateX(0);
|
|
520
|
+
visibility: visible;
|
|
521
|
+
transition: transform 200ms ease;
|
|
522
|
+
}
|
|
523
|
+
.pf-shell[data-pf-rail-layout="overlay"] .pf-rail-seam,
|
|
524
|
+
.pf-shell[data-pf-rail-layout="overlay"] .pf-tabbar { display: none; }
|
|
458
525
|
|
|
459
526
|
/* ---- reduced motion -----------------------------------------------------
|
|
460
527
|
Collapsing the rail slides 288px of layout across the screen — the first
|
|
@@ -465,6 +532,13 @@
|
|
|
465
532
|
state indicator and is left alone. */
|
|
466
533
|
@media (prefers-reduced-motion: reduce) {
|
|
467
534
|
.pf-rail, .pf-rail-seam > span { transition: none; }
|
|
535
|
+
/* The overlay drawer states its own transition twice, at higher specificity
|
|
536
|
+
than the blanket rule above, so both states have to be named here or the
|
|
537
|
+
drawer would keep sliding. Killing the transition also drops the closing
|
|
538
|
+
visibility delay, which is right: with no slide there is nothing to wait
|
|
539
|
+
for, and the drawer leaves the a11y tree the moment it shuts. */
|
|
540
|
+
.pf-shell[data-pf-rail-layout="overlay"] .pf-rail,
|
|
541
|
+
.pf-shell[data-pf-rail-layout="overlay"][data-pf-rail-open] .pf-rail { transition: none; }
|
|
468
542
|
}
|
|
469
543
|
|
|
470
544
|
/* ---- annotation ink layer: a transparent 2D canvas over the viewer --------
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// this module writes onto .pf-shell; a missing attribute reads as "stage", so
|
|
5
5
|
// the layout is already correct before any of this runs.
|
|
6
6
|
//
|
|
7
|
-
//
|
|
7
|
+
// Three independent reasons the bar can be absent, and they are deliberately
|
|
8
8
|
// handled by different mechanisms:
|
|
9
9
|
// * WIDTH — above the breakpoint the rail sits beside the viewer and no tab is
|
|
10
10
|
// needed. That is pure CSS (.pf-tabbar { display: none }). There is no JS
|
|
@@ -12,6 +12,10 @@
|
|
|
12
12
|
// * A HOST owns pane selection — partforge-cloud draws its own bottom bar at
|
|
13
13
|
// the window level and drives this layout through setHostPane(). Then our
|
|
14
14
|
// bar is `hidden` and only the host's choice writes data-pf-pane.
|
|
15
|
+
// * A HOST owns the rail's LAYOUT — setRailLayout() docks the rail into a
|
|
16
|
+
// bottom sheet or floats it as an overlay drawer, each with its own way in
|
|
17
|
+
// and out, so a pane tab would be a second, competing control. That lease
|
|
18
|
+
// is independent of the pane one: either alone hides the bar.
|
|
15
19
|
//
|
|
16
20
|
// State is in-memory: a fresh load starts on the stage, and nothing persists.
|
|
17
21
|
// Which pane you are looking at right now is not a preference.
|
|
@@ -42,6 +46,26 @@ const ICONS = {
|
|
|
42
46
|
const LABELS = { stage: "3D", rail: "Controls" };
|
|
43
47
|
const PANES = ["stage", "rail"];
|
|
44
48
|
|
|
49
|
+
// A host-driven rail layout (partforge-cloud's bottom sheet / overlay drawer).
|
|
50
|
+
// null = partforge's own default layout. Normalization is strict: a shape this
|
|
51
|
+
// module does not recognize is a null, never a guess — the host is trusted to
|
|
52
|
+
// send well-formed layouts and a garbled one must not wedge the shell.
|
|
53
|
+
const MAX_RAIL_INSET = 4096;
|
|
54
|
+
|
|
55
|
+
function normalizeRailLayout(next) {
|
|
56
|
+
if (!next || typeof next !== "object") return null;
|
|
57
|
+
if (next.mode === "overlay") return { mode: "overlay" };
|
|
58
|
+
if (next.mode !== "dock") return null;
|
|
59
|
+
if (!Number.isInteger(next.inset) || next.inset < 0 || next.inset > MAX_RAIL_INSET) return null;
|
|
60
|
+
// A missing or unusable railHeight is a zero, not a rejection: the inset is
|
|
61
|
+
// what the stage lays out against, and a sheet with no visible collapsed
|
|
62
|
+
// height is a legitimate state.
|
|
63
|
+
const railHeight = Number.isInteger(next.railHeight)
|
|
64
|
+
? Math.max(0, Math.min(next.inset, next.railHeight))
|
|
65
|
+
: 0;
|
|
66
|
+
return { mode: "dock", inset: next.inset, railHeight };
|
|
67
|
+
}
|
|
68
|
+
|
|
45
69
|
function buildIcon(paths) {
|
|
46
70
|
const svg = document.createElementNS(SVG_NS, "svg");
|
|
47
71
|
svg.setAttribute("viewBox", "0 0 24 24");
|
|
@@ -69,15 +93,18 @@ function buildIcon(paths) {
|
|
|
69
93
|
//
|
|
70
94
|
// Everything is optional, like attachRail: with no shell to manage this returns
|
|
71
95
|
// a no-op handle, so a legacy id-only page or a host that lays the framework out
|
|
72
|
-
// itself (embed-test.html) is unaffected — including its setHostPane
|
|
73
|
-
//
|
|
74
|
-
|
|
96
|
+
// itself (embed-test.html) is unaffected — including its setHostPane and
|
|
97
|
+
// setRailLayout, which stay callable no-ops so mount()'s handle shape never
|
|
98
|
+
// varies. `toggle` is rail.js's own rail toggle, passed only so the overlay duck
|
|
99
|
+
// below can recognize it; omitting it costs the drawer its close button.
|
|
100
|
+
export function attachMobileTabs({ shell, stage, rail, toggle, onRailLayout } = {}) {
|
|
75
101
|
if (!shell || !stage || !rail) {
|
|
76
|
-
return { setHostPane: () => {}, detach: () => {} };
|
|
102
|
+
return { setHostPane: () => {}, setRailLayout: () => {}, detach: () => {} };
|
|
77
103
|
}
|
|
78
104
|
|
|
79
105
|
let pane = "stage"; // the standalone user's choice
|
|
80
106
|
let hostPane = null; // a host's lease over that choice, while non-null
|
|
107
|
+
let railLayout = null; // a host's lease over where the rail sits, while non-null
|
|
81
108
|
|
|
82
109
|
const bar = document.createElement("div");
|
|
83
110
|
bar.className = "pf-tabbar";
|
|
@@ -107,9 +134,23 @@ export function attachMobileTabs({ shell, stage, rail } = {}) {
|
|
|
107
134
|
for (const [p, button] of buttons) {
|
|
108
135
|
button.setAttribute("aria-pressed", String(p === active));
|
|
109
136
|
}
|
|
110
|
-
//
|
|
111
|
-
//
|
|
112
|
-
|
|
137
|
+
// chrome.css keys the docked/overlay rail off these; the two lengths are
|
|
138
|
+
// zero in overlay mode, where the drawer floats over a full-width stage.
|
|
139
|
+
if (railLayout) {
|
|
140
|
+
shell.dataset.pfRailLayout = railLayout.mode;
|
|
141
|
+
shell.style.setProperty("--pf-rail-inset", `${railLayout.mode === "dock" ? railLayout.inset : 0}px`);
|
|
142
|
+
shell.style.setProperty("--pf-rail-dock-h", `${railLayout.mode === "dock" ? railLayout.railHeight : 0}px`);
|
|
143
|
+
} else {
|
|
144
|
+
delete shell.dataset.pfRailLayout;
|
|
145
|
+
shell.style.removeProperty("--pf-rail-inset");
|
|
146
|
+
shell.style.removeProperty("--pf-rail-dock-h");
|
|
147
|
+
}
|
|
148
|
+
// The drawer-open flag only means anything in overlay mode; entering any
|
|
149
|
+
// other layout must not leave a stale open state behind.
|
|
150
|
+
if (railLayout?.mode !== "overlay") shell.removeAttribute("data-pf-rail-open");
|
|
151
|
+
// A host that owns EITHER pane selection or the layout draws its own
|
|
152
|
+
// controls; ours would compete.
|
|
153
|
+
bar.hidden = hostPane !== null || railLayout !== null;
|
|
113
154
|
}
|
|
114
155
|
|
|
115
156
|
// Delegated, so the icon <svg>/<span> inside a button resolve to the button.
|
|
@@ -120,6 +161,31 @@ export function attachMobileTabs({ shell, stage, rail } = {}) {
|
|
|
120
161
|
apply();
|
|
121
162
|
};
|
|
122
163
|
bar.addEventListener("click", onClick);
|
|
164
|
+
|
|
165
|
+
// Overlay mode's "duck": any interaction with the stage slides the drawer
|
|
166
|
+
// back. Capture phase, because the canvas consumes pointer events.
|
|
167
|
+
//
|
|
168
|
+
// Two things the plain "remove the attribute" version got wrong, both because
|
|
169
|
+
// the rail toggle is a DESCENDANT of the stage (it floats at the stage's
|
|
170
|
+
// top-right):
|
|
171
|
+
// * its own tap arrives here first, so this would close the drawer a moment
|
|
172
|
+
// before rail.js's click handler reopened it — the toggle could open the
|
|
173
|
+
// drawer but never shut it. Hence the exemption, which needs the element
|
|
174
|
+
// itself: nothing in the DOM marks it out for a class/closest() test.
|
|
175
|
+
// * a real duck has to be REPORTED. The toggle's chevron, aria-expanded and
|
|
176
|
+
// label are derived from the open flag in rail.js's apply(), so a close it
|
|
177
|
+
// was never told about leaves the button announcing an open drawer. The
|
|
178
|
+
// callback is the same one setRailLayout uses (the host wires it to
|
|
179
|
+
// railChrome.layoutChanged), so this is a re-look, not a new channel.
|
|
180
|
+
const onStagePointerDown = (e) => {
|
|
181
|
+
if (toggle && (e.target === toggle || toggle.contains?.(e.target))) return;
|
|
182
|
+
if (shell.dataset.pfRailLayout !== "overlay") return;
|
|
183
|
+
// Nothing to duck: stay silent rather than nudge on every stage pointerdown.
|
|
184
|
+
if (!shell.hasAttribute("data-pf-rail-open")) return;
|
|
185
|
+
shell.removeAttribute("data-pf-rail-open");
|
|
186
|
+
onRailLayout?.(railLayout);
|
|
187
|
+
};
|
|
188
|
+
stage.addEventListener("pointerdown", onStagePointerDown, true);
|
|
123
189
|
apply();
|
|
124
190
|
|
|
125
191
|
return {
|
|
@@ -129,10 +195,24 @@ export function attachMobileTabs({ shell, stage, rail } = {}) {
|
|
|
129
195
|
hostPane = PANES.includes(next) ? next : null;
|
|
130
196
|
apply();
|
|
131
197
|
},
|
|
198
|
+
// A dock/overlay layout takes the lease; anything this module cannot read —
|
|
199
|
+
// null included — releases it and restores partforge's own layout. The
|
|
200
|
+
// callback reports what was actually applied, so a host never has to guess
|
|
201
|
+
// whether its request survived normalization.
|
|
202
|
+
setRailLayout: (next) => {
|
|
203
|
+
railLayout = normalizeRailLayout(next);
|
|
204
|
+
apply();
|
|
205
|
+
onRailLayout?.(railLayout);
|
|
206
|
+
},
|
|
132
207
|
detach: () => {
|
|
133
208
|
bar.removeEventListener("click", onClick);
|
|
209
|
+
stage.removeEventListener("pointerdown", onStagePointerDown, true);
|
|
134
210
|
bar.remove();
|
|
135
211
|
delete shell.dataset.pfPane;
|
|
212
|
+
delete shell.dataset.pfRailLayout;
|
|
213
|
+
shell.removeAttribute("data-pf-rail-open");
|
|
214
|
+
shell.style.removeProperty("--pf-rail-inset");
|
|
215
|
+
shell.style.removeProperty("--pf-rail-dock-h");
|
|
136
216
|
},
|
|
137
217
|
};
|
|
138
218
|
}
|
package/src/framework/mount.js
CHANGED
|
@@ -62,7 +62,7 @@ const IMPORT_MESH_BROKEN_MESSAGE = "STEP import tessellation failed to satisfy t
|
|
|
62
62
|
// carries the worker's own error text. See the correlated "error" case below.
|
|
63
63
|
const importTessellateFailedMessage = (workerMessage) => `STEP import tessellation failed — ${workerMessage}`;
|
|
64
64
|
|
|
65
|
-
export function makeHandle({ ready, dispose, viewer, setParams, listExportableParts, exportParts, warmExportKernel, setHostPane, animation, getView, setView, captureView, attachTooltips, measure, annotate, projection, pickMarker }) {
|
|
65
|
+
export function makeHandle({ ready, dispose, viewer, setParams, listExportableParts, exportParts, warmExportKernel, setHostPane, setRailLayout, animation, getView, setView, captureView, attachTooltips, measure, annotate, projection, pickMarker }) {
|
|
66
66
|
return {
|
|
67
67
|
ready, dispose, setParams,
|
|
68
68
|
// Part-declared animation playback (spec 2026-08-02): animations are
|
|
@@ -122,6 +122,12 @@ export function makeHandle({ ready, dispose, viewer, setParams, listExportablePa
|
|
|
122
122
|
// (partforge-cloud does, at the window level). Defaulted to a no-op so the
|
|
123
123
|
// handle's shape never depends on whether this mount resolved a rail.
|
|
124
124
|
setHostPane: setHostPane ?? (() => {}),
|
|
125
|
+
// Where the rail SITS, for a host that draws its own chrome around the
|
|
126
|
+
// frame: {mode:"dock", inset, railHeight} renders it into the host's bottom
|
|
127
|
+
// sheet, {mode:"overlay"} floats it as a right-edge drawer over the stage,
|
|
128
|
+
// and null hands the layout back to partforge. Same no-op default as
|
|
129
|
+
// setHostPane above, and the same lease semantics.
|
|
130
|
+
setRailLayout: setRailLayout ?? (() => {}),
|
|
125
131
|
// Join host-owned chrome buttons to this mount's shared hover tooltip, so
|
|
126
132
|
// a host's own viewbar/rail-foot buttons match the built-in ones. Entries
|
|
127
133
|
// are [{ element, getLabel? }] (label falls back to the button's
|
|
@@ -211,6 +217,11 @@ function createCleanupStack() {
|
|
|
211
217
|
// runtime.setHostPane("rail"); // narrow layout only: show just the controls
|
|
212
218
|
// // rail ('stage' | 'rail'), suppressing the
|
|
213
219
|
// // built-in tab bar. null hands selection back.
|
|
220
|
+
// runtime.setRailLayout({mode:"dock", inset, railHeight} | {mode:"overlay"} | null)
|
|
221
|
+
// // where the rail sits when the HOST draws chrome
|
|
222
|
+
// // over the frame: into its bottom sheet, or as a
|
|
223
|
+
// // right-edge drawer over the stage. null restores
|
|
224
|
+
// // partforge's own layout.
|
|
214
225
|
// runtime.attachTooltips([{ element: myButton }]); // host chrome buttons join the
|
|
215
226
|
// // mount's shared hover tooltip (the viewbar one).
|
|
216
227
|
// // Label = the button's title (or aria-label), or a
|
|
@@ -379,6 +390,16 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
|
|
|
379
390
|
shell: els.shell ?? els.rail?.parentElement,
|
|
380
391
|
stage: els.viewer,
|
|
381
392
|
rail: els.rail,
|
|
393
|
+
// The rail toggle floats INSIDE the stage, so the overlay duck has to know
|
|
394
|
+
// it by identity or the drawer's own close button would duck it shut and
|
|
395
|
+
// rail.js would reopen it on the same tap.
|
|
396
|
+
toggle: els.chrome.railToggle,
|
|
397
|
+
// rail.js reads the layout lazily in its handlers, but its toggle
|
|
398
|
+
// visibility (and, in overlay mode, its open/closed reporting) is written
|
|
399
|
+
// in apply() — tell it to re-look when the host changes the layout, or the
|
|
400
|
+
// duck closes the drawer. railChrome is created just above, so the closure
|
|
401
|
+
// is safe.
|
|
402
|
+
onRailLayout: () => railChrome.layoutChanged?.(),
|
|
382
403
|
});
|
|
383
404
|
cleanup.defer(() => paneTabs.detach());
|
|
384
405
|
const hover = attachHoverLabels(viewer, { part, tooltip }); // always-on hover inspection (no-op on touch-only devices)
|
|
@@ -1154,6 +1175,7 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
|
|
|
1154
1175
|
ready, dispose, viewer, setParams,
|
|
1155
1176
|
attachTooltips: attachHostTooltips,
|
|
1156
1177
|
setHostPane: paneTabs.setHostPane,
|
|
1178
|
+
setRailLayout: paneTabs.setRailLayout,
|
|
1157
1179
|
getView: view, // () => tabsCtl.current()
|
|
1158
1180
|
setView: (name) => tabsCtl.select(name),
|
|
1159
1181
|
captureView,
|
package/src/framework/rail.js
CHANGED
|
@@ -90,12 +90,17 @@ export function attachRail({ rail, toggle, shell = rail?.parentElement, storage
|
|
|
90
90
|
// ?pickserver with no #panel/elements.rail hits this path.
|
|
91
91
|
const root = document.documentElement;
|
|
92
92
|
root.style.setProperty("--pf-rail-w", "0px");
|
|
93
|
-
return { detach: () => root.style.removeProperty("--pf-rail-w") };
|
|
93
|
+
return { layoutChanged: () => {}, detach: () => root.style.removeProperty("--pf-rail-w") };
|
|
94
94
|
}
|
|
95
95
|
|
|
96
96
|
const root = document.documentElement;
|
|
97
97
|
const shellBox = () => shell.getBoundingClientRect();
|
|
98
98
|
const shellWidth = () => shellBox().width;
|
|
99
|
+
// A host that leases the rail's placement through attachMobileTabs'
|
|
100
|
+
// setRailLayout writes this ("dock" | "overlay"); absent means partforge's own
|
|
101
|
+
// layout. Read live rather than cached: the lease can move at any moment and
|
|
102
|
+
// layoutChanged() (below) is only a nudge to re-apply, not the source of truth.
|
|
103
|
+
const hostLayout = () => shell.dataset.pfRailLayout ?? null;
|
|
99
104
|
let state = readRailPref(storage, shellWidth());
|
|
100
105
|
// Captured before the first apply() mutates the toggle, so detach() can
|
|
101
106
|
// hand back a plain, unwired button rather than a dead "Show controls" one.
|
|
@@ -142,11 +147,34 @@ export function attachRail({ rail, toggle, shell = rail?.parentElement, storage
|
|
|
142
147
|
// inert, invisible rail. `state` is deliberately NOT rewritten — the stored
|
|
143
148
|
// preference applies again the moment the window widens.
|
|
144
149
|
const narrow = window.innerWidth < RAIL_NARROW_BREAKPOINT;
|
|
145
|
-
|
|
146
|
-
//
|
|
147
|
-
//
|
|
148
|
-
//
|
|
149
|
-
|
|
150
|
+
// A host layout suspends collapse (and every resize gesture — see
|
|
151
|
+
// resizeRefused) for the same reason narrow does: the rail's placement is
|
|
152
|
+
// the host's to decide, so a collapse flag would only make it inert and
|
|
153
|
+
// invisible where the host is showing it. `state` is left untouched, so
|
|
154
|
+
// the preference returns intact when the lease ends.
|
|
155
|
+
const layout = hostLayout();
|
|
156
|
+
const overlay = layout === "overlay";
|
|
157
|
+
const suppressed = narrow || layout !== null;
|
|
158
|
+
const collapsed = state.collapsed && !suppressed;
|
|
159
|
+
// WIDTH is suppressed on a NARROWER rule than collapse, because only two of
|
|
160
|
+
// the three layouts stop needing --pf-rail-w:
|
|
161
|
+
// - narrow (any layout): chrome.css stacks the panes and shows exactly
|
|
162
|
+
// one, so the rail is the whole surface or absent — it reserves no
|
|
163
|
+
// width beside the viewer;
|
|
164
|
+
// - overlay (any width): the drawer sizes itself (min(288px, 85%)) and
|
|
165
|
+
// floats over the stage, reserving nothing;
|
|
166
|
+
// - dock at >= RAIL_NARROW_BREAKPOINT: the rail is STILL side by side with
|
|
167
|
+
// the stage — the host only leases the bottom inset — so zeroing here
|
|
168
|
+
// would leave the card with no controls at all. Keep the width.
|
|
169
|
+
// Anything centring itself against the rail (app.css's #pf-pick-banner)
|
|
170
|
+
// reads this token, so a stale 288px in the first two cases would offset it.
|
|
171
|
+
// A wide dock therefore renders as: rail visible at its remembered width,
|
|
172
|
+
// no resize, no collapse, and no toggle (hidden just below — a visible one
|
|
173
|
+
// would be a dead control while collapse is suppressed). That is the
|
|
174
|
+
// sheet-owns-layout stance: the host decides placement, we only supply the
|
|
175
|
+
// width it laid the rail out against.
|
|
176
|
+
const widthSuppressed = narrow || overlay;
|
|
177
|
+
const width = collapsed || widthSuppressed ? 0 : clampRailWidth(state.width, sw);
|
|
150
178
|
// Written on :root, not the rail/shell, so body-appended overlays (the
|
|
151
179
|
// pick banner, the ?debug overlay) inherit it — see spec §4.4. This
|
|
152
180
|
// assumes ONE rail per document: attachRail is written for a single
|
|
@@ -162,16 +190,26 @@ export function attachRail({ rail, toggle, shell = rail?.parentElement, storage
|
|
|
162
190
|
seam.setAttribute("aria-valuemax", String(railMaxWidth(sw)));
|
|
163
191
|
if (toggle) {
|
|
164
192
|
// The tab bar owns pane selection below the breakpoint, so a second
|
|
165
|
-
// collapse affordance in the viewbar would be a competing control.
|
|
166
|
-
toggle
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
193
|
+
// collapse affordance in the viewbar would be a competing control. In
|
|
194
|
+
// overlay mode the toggle IS the drawer's open/close control, so it must
|
|
195
|
+
// stay visible even though the width says "narrow" — and the host hides
|
|
196
|
+
// its own tab bar under a layout lease, making this the only way back to
|
|
197
|
+
// the controls. Under dock it stays hidden at EVERY width, including the
|
|
198
|
+
// wide band that keeps its --pf-rail-w above: collapse is suppressed
|
|
199
|
+
// there, so a visible toggle would be a control that does nothing.
|
|
200
|
+
toggle.hidden = suppressed && !overlay;
|
|
201
|
+
// One button, two meanings: the drawer's open state in overlay mode, the
|
|
202
|
+
// collapse everywhere else. `shut` is whichever of the two this layout
|
|
203
|
+
// asks the label, chevron and aria-expanded to report.
|
|
204
|
+
const shut = overlay ? !shell.hasAttribute("data-pf-rail-open") : collapsed;
|
|
205
|
+
toggleChevron?.setAttribute("d", shut ? CHEVRON_RAIL_COLLAPSED : CHEVRON_RAIL_OPEN);
|
|
206
|
+
const label = shut ? "Show controls" : "Hide controls";
|
|
207
|
+
toggle.setAttribute("aria-expanded", String(!shut));
|
|
170
208
|
toggle.setAttribute("aria-label", label);
|
|
171
209
|
// The shared tooltip reads the aria-label at show time, so a native
|
|
172
210
|
// title would double up as a second, competing tooltip.
|
|
173
211
|
if (!tooltipBinding) toggle.title = label;
|
|
174
|
-
toggle.classList.toggle("on",
|
|
212
|
+
toggle.classList.toggle("on", shut);
|
|
175
213
|
tooltipBinding?.sync();
|
|
176
214
|
}
|
|
177
215
|
if (persist) writeRailPref(state, storage);
|
|
@@ -199,10 +237,18 @@ export function attachRail({ rail, toggle, shell = rail?.parentElement, storage
|
|
|
199
237
|
}
|
|
200
238
|
const toggleCollapsed = () => commit({ collapsed: !state.collapsed, width: state.width });
|
|
201
239
|
|
|
240
|
+
// Neither gesture that sizes the rail may run when the seam is not what
|
|
241
|
+
// sizes it: chrome.css hides the vertical seam in the stacked layout (the
|
|
242
|
+
// rail is under the viewer, not beside it), and a host layout sizes the rail
|
|
243
|
+
// itself, where a width or a collapse we wrote would be invisible at best
|
|
244
|
+
// and an inert docked rail at worst. The toggle still works in both.
|
|
245
|
+
const resizeRefused = () => window.innerWidth < RAIL_NARROW_BREAKPOINT || hostLayout() !== null;
|
|
246
|
+
|
|
202
247
|
// --- keyboard: move the SEPARATOR (standard role="separator" semantics), so
|
|
203
248
|
// ArrowLeft widens a right-hand rail. Arrows clamp at the minimum and never
|
|
204
249
|
// collapse; Enter/Space is the collapse gesture.
|
|
205
250
|
function onKeyDown(e) {
|
|
251
|
+
if (resizeRefused()) return;
|
|
206
252
|
// Cmd/Alt/Ctrl+Arrow are browser/OS reserved (back, tab switch, ...); don't
|
|
207
253
|
// eat them just because the seam happens to hold focus.
|
|
208
254
|
if (e.metaKey || e.altKey || e.ctrlKey) return;
|
|
@@ -247,15 +293,26 @@ export function attachRail({ rail, toggle, shell = rail?.parentElement, storage
|
|
|
247
293
|
}
|
|
248
294
|
|
|
249
295
|
const onDoubleClick = () => commit({ collapsed: false, width: RAIL_DEFAULT_WIDTH });
|
|
250
|
-
const onToggleClick = () =>
|
|
296
|
+
const onToggleClick = () => {
|
|
297
|
+
// Overlay: the rail is a drawer over the stage, so the toggle slides it in
|
|
298
|
+
// and out. It must not touch `state` — the collapse preference belongs to
|
|
299
|
+
// the wide layout the host may hand back at any moment, and persisting a
|
|
300
|
+
// drawer gesture into it would collapse the rail on a desktop next visit.
|
|
301
|
+
// The host clears this flag itself when the lease ends or the stage is
|
|
302
|
+
// touched (mobile-tabs.js), so nothing here has to unwind it.
|
|
303
|
+
if (hostLayout() === "overlay") {
|
|
304
|
+
shell.toggleAttribute("data-pf-rail-open");
|
|
305
|
+
apply();
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
toggleCollapsed();
|
|
309
|
+
};
|
|
251
310
|
|
|
252
311
|
// --- drag ---
|
|
253
312
|
let grabOffset = 0;
|
|
254
313
|
function onPointerDown(e) {
|
|
255
314
|
if (e.button !== 0) return;
|
|
256
|
-
|
|
257
|
-
// to drag (chrome.css hides it). The toggle still works.
|
|
258
|
-
if (window.innerWidth < RAIL_NARROW_BREAKPOINT) return;
|
|
315
|
+
if (resizeRefused()) return;
|
|
259
316
|
e.preventDefault();
|
|
260
317
|
// setPointerCapture is load-bearing: without it the pointer crosses into the
|
|
261
318
|
// viewer (an iframe, in the cloud editor) whose document eats the move
|
|
@@ -321,6 +378,11 @@ export function attachRail({ rail, toggle, shell = rail?.parentElement, storage
|
|
|
321
378
|
apply();
|
|
322
379
|
|
|
323
380
|
return {
|
|
381
|
+
// The host's data-pf-rail-layout / data-pf-rail-open attributes are read
|
|
382
|
+
// live by apply(), so re-running it is the whole of "the layout moved".
|
|
383
|
+
// Deliberately unconditional: attachMobileTabs reports every setRailLayout
|
|
384
|
+
// call, no-change ones included, and a bare re-apply is idempotent.
|
|
385
|
+
layoutChanged: () => apply(),
|
|
324
386
|
detach: () => {
|
|
325
387
|
settleKeys();
|
|
326
388
|
seam.removeEventListener("pointerdown", onPointerDown);
|
package/types/index.d.ts
CHANGED
|
@@ -13,6 +13,18 @@ export * from "./part.js";
|
|
|
13
13
|
/** Which pane a narrow layout shows. `null` hands selection back to partforge. */
|
|
14
14
|
export type HostPane = "stage" | "rail" | null;
|
|
15
15
|
|
|
16
|
+
/**
|
|
17
|
+
* Where a HOST wants the controls rail: docked into a bottom sheet the host
|
|
18
|
+
* draws over the frame (`inset` = the sheet's full height, so the stage clears
|
|
19
|
+
* it; `railHeight` = the bottom slice of that region the rail renders into,
|
|
20
|
+
* under the host's own chrome strip), or floated as a right-edge drawer over a
|
|
21
|
+
* full-width stage. `null` hands the layout back to partforge.
|
|
22
|
+
*/
|
|
23
|
+
export type HostRailLayout =
|
|
24
|
+
| { mode: "dock"; inset: number; railHeight?: number }
|
|
25
|
+
| { mode: "overlay" }
|
|
26
|
+
| null;
|
|
27
|
+
|
|
16
28
|
/** An export file format. STEP is routed to OCCT automatically. */
|
|
17
29
|
export type ExportFormat = "stl" | "step" | "3mf";
|
|
18
30
|
|
|
@@ -494,6 +506,14 @@ export interface PartRuntime {
|
|
|
494
506
|
* `null` hands selection back to partforge's built-in bar.
|
|
495
507
|
*/
|
|
496
508
|
setHostPane(pane: HostPane): void;
|
|
509
|
+
/**
|
|
510
|
+
* Where the rail sits, for a host that draws its own chrome over the frame:
|
|
511
|
+
* docked into the host's bottom sheet (`inset` is the sheet's full height,
|
|
512
|
+
* `railHeight` the part of it the rail may paint into), or floated as a
|
|
513
|
+
* right-edge drawer over an unresized stage. `null` — or any shape partforge
|
|
514
|
+
* cannot read — restores partforge's own layout.
|
|
515
|
+
*/
|
|
516
|
+
setRailLayout(layout: HostRailLayout): void;
|
|
497
517
|
/**
|
|
498
518
|
* Part-declared animation playback, or `null` when NO view declares an
|
|
499
519
|
* `animations` block. Non-null while any view does — including while the
|