partforge 0.101.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/docs/AUTHORING-PARTS.md +15 -3
- package/package.json +1 -1
- package/src/framework/app.css +95 -0
- package/src/framework/chrome.css +75 -1
- package/src/framework/mobile-tabs.js +88 -8
- package/src/framework/mount.js +29 -2
- package/src/framework/panel/author.js +11 -0
- package/src/framework/panel/declared-source.js +128 -0
- package/src/framework/panel/render.js +1 -0
- package/src/framework/panel/widget-specs.js +6 -6
- package/src/framework/panel/widgets/file-drop.js +45 -16
- package/src/framework/panel/widgets/font.js +10 -2
- package/src/framework/panel/widgets/image.js +93 -15
- package/src/framework/panel/widgets/vector-thumb.js +136 -0
- package/src/framework/panel/widgets/vector.js +48 -5
- package/src/framework/rail.js +78 -16
- package/src/parts/emblem.js +22 -9
- 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/docs/AUTHORING-PARTS.md
CHANGED
|
@@ -731,9 +731,9 @@ Every control accepts `key`, `type`, `label`, `description`, `hidden`, `when` an
|
|
|
731
731
|
| `"checkbox"` | an on/off box: ticked writes `on`, cleared writes `0` | `on` (default `1`) |
|
|
732
732
|
| `"select"` | a dropdown | `options` |
|
|
733
733
|
| `"radio"` | a segmented button row | `options` |
|
|
734
|
-
| `"font"` | a typeface picker
|
|
735
|
-
| `"image"` | an image picker
|
|
736
|
-
| `"vector"` | a
|
|
734
|
+
| `"font"` | a typeface picker with a catalog, else a drop target | `allow`, `preview`, `sourceField` |
|
|
735
|
+
| `"image"` | an image picker with a catalog, else a drop target showing the artwork | `allow`, `sourceField` |
|
|
736
|
+
| `"vector"` | a drop target showing the artwork — no catalog exists | `sourceField` |
|
|
737
737
|
|
|
738
738
|
Numeric controls always show the number box: drag the slider *or* type an exact
|
|
739
739
|
value. Typed values may be finer than `step` and clamp to `[min, max]` on commit.
|
|
@@ -748,6 +748,18 @@ each entry is both value and label — or the long form
|
|
|
748
748
|
types, `12` is not `"12"`). An option's `description` surfaces as a hover tooltip
|
|
749
749
|
on that one option, not as a ⓘ popover.
|
|
750
750
|
|
|
751
|
+
**`sourceField: true`** (font/image/vector) adds a raw source text box to the
|
|
752
|
+
control. It is **off by default**: the drop target already carries the preview,
|
|
753
|
+
the drag target and click-to-choose, and where a catalog is wired there is a
|
|
754
|
+
picker too, so on a 288 px rail a text box is the affordance earning its space
|
|
755
|
+
least. Turn it on when typing a source by hand is something your users will
|
|
756
|
+
actually do — pasting an `https:` URL they already have, or a host `pfc-asset:`
|
|
757
|
+
token. Hiding it changes nothing else: the same values are accepted by the same
|
|
758
|
+
allow list, and a source set in `defaults` or by the host still applies.
|
|
759
|
+
|
|
760
|
+
For a `"font"` control with no `fontCatalog` this is the only text entry there
|
|
761
|
+
is, so a standalone app that expects users to paste font URLs should set it.
|
|
762
|
+
|
|
751
763
|
**`allow` and `preview`** (font) configure the typeface control. `allow` lists the
|
|
752
764
|
source kinds a **param-supplied** value may use — what the picker writes, or what
|
|
753
765
|
arrives in a share link:
|
package/package.json
CHANGED
package/src/framework/app.css
CHANGED
|
@@ -198,6 +198,101 @@ textarea.text-input { min-height: 64px; resize: vertical; }
|
|
|
198
198
|
.text-input:focus { outline: none; border-color: var(--pf-accent);
|
|
199
199
|
box-shadow: 0 0 0 3px color-mix(in oklab, var(--pf-accent) 35%, transparent); }
|
|
200
200
|
|
|
201
|
+
/* ── file drop targets ──────────────────────────────────────────────────────
|
|
202
|
+
Two forms of the same widget. The LABELLED form is a tile the user is meant
|
|
203
|
+
to find: it doubles as the thumbnail once there is something to show, so a
|
|
204
|
+
preview, a drop zone and a click-to-choose occupy one box rather than three.
|
|
205
|
+
The AMBIENT form is for a control that already has a visible way in (the font
|
|
206
|
+
control's catalog button) — it covers the control invisibly and only appears
|
|
207
|
+
while a file is over it, so dropping stays available without spending rail
|
|
208
|
+
height advertising itself. */
|
|
209
|
+
.file-drop {
|
|
210
|
+
position: relative; display: flex; flex-direction: column;
|
|
211
|
+
align-items: center; justify-content: center; gap: 6px;
|
|
212
|
+
min-height: 56px; margin-top: 6px; padding: 8px;
|
|
213
|
+
background: var(--pf-input-bg); color: var(--pf-hint);
|
|
214
|
+
border: 1px dashed var(--pf-border); border-radius: var(--pf-radius-control);
|
|
215
|
+
text-align: center; cursor: pointer;
|
|
216
|
+
}
|
|
217
|
+
.file-drop:focus-visible { outline: none; border-color: var(--pf-accent);
|
|
218
|
+
box-shadow: 0 0 0 3px color-mix(in oklab, var(--pf-accent) 35%, transparent); }
|
|
219
|
+
.file-drop-hint { font: 11px/1.35 var(--pf-sans); }
|
|
220
|
+
|
|
221
|
+
/* `contain` rather than `cover` because artwork and depth maps are content, not
|
|
222
|
+
decoration — cropping one to fill the box would misrepresent what is about to
|
|
223
|
+
be built. The two preview kinds then diverge, because a photograph and a piece
|
|
224
|
+
of line art want opposite things from the box around them. */
|
|
225
|
+
.file-drop > svg, .file-drop > img {
|
|
226
|
+
display: block; max-width: 100%; max-height: 96px; object-fit: contain;
|
|
227
|
+
}
|
|
228
|
+
.file-drop > svg { fill: var(--pf-text-2); }
|
|
229
|
+
/* Once there IS a preview, the frame moves off the tile and onto the artwork.
|
|
230
|
+
The tile stays full width — it is the drop target, and a full-width one is
|
|
231
|
+
easier to hit — but goes invisible, so what the eye sees is a rounded box
|
|
232
|
+
hugging the picture with the hint underneath it, rather than a picture
|
|
233
|
+
floating inside a much larger box. The border stays at 1px and only loses its
|
|
234
|
+
colour, so nothing shifts when a thumbnail arrives. */
|
|
235
|
+
.file-drop.has-thumb {
|
|
236
|
+
border-color: transparent; background: none; padding: 0; gap: 4px;
|
|
237
|
+
}
|
|
238
|
+
.file-drop.has-thumb > img, .file-drop.has-thumb > svg {
|
|
239
|
+
border: 1px solid var(--pf-border);
|
|
240
|
+
border-radius: var(--pf-radius-control);
|
|
241
|
+
background: var(--pf-input-bg);
|
|
242
|
+
}
|
|
243
|
+
/* A RASTER is a filled rectangle, so the border sits directly on the pixels —
|
|
244
|
+
padding around one reads as a mount around a picture. `border-radius` on an
|
|
245
|
+
`<img>` clips the image itself, which is what rounds the corners. Sized by
|
|
246
|
+
`max-height` rather than `height` so a small source is shown at its own size
|
|
247
|
+
instead of being upscaled into a blur.
|
|
248
|
+
`width: auto` is load-bearing: this element also carries `.image-preview`,
|
|
249
|
+
which pins it to `width: 100%` and letterboxes the picture inside a
|
|
250
|
+
rail-width box with `object-fit`. That is the right behaviour for a preview
|
|
251
|
+
that fills its container and the wrong one for a frame meant to hug, and it
|
|
252
|
+
is invisible in the markup — the box just silently stops matching the image. */
|
|
253
|
+
.file-drop.has-thumb > img { max-height: 96px; max-width: 100%; width: auto; margin: 0; }
|
|
254
|
+
/* A VECTOR is line art: strokes running into the border would collide with it
|
|
255
|
+
rather than be framed by it, so it keeps padding inside its own frame. Sizing
|
|
256
|
+
by height with `width: auto` lets the viewBox's aspect supply the width, so
|
|
257
|
+
the frame hugs the drawing instead of the SVG's default 300x150 box. */
|
|
258
|
+
.file-drop.has-thumb > svg { height: 96px; width: auto; max-width: 100%; padding: 8px; }
|
|
259
|
+
|
|
260
|
+
/* The empty-state hint and the replace hint trade places: exactly one is visible
|
|
261
|
+
in either state, so a tile is never without an instruction. */
|
|
262
|
+
.file-drop-hint-replace { display: none; }
|
|
263
|
+
.file-drop.has-thumb .file-drop-hint { display: none; }
|
|
264
|
+
.file-drop.has-thumb .file-drop-hint-replace { display: block; }
|
|
265
|
+
|
|
266
|
+
/* Ambient: no box of its own. Laid over the control, inert to the pointer so
|
|
267
|
+
every click still reaches the button underneath — the one affordance this
|
|
268
|
+
form exists to protect. */
|
|
269
|
+
/* Only the controls that actually carry an ambient overlay become positioned —
|
|
270
|
+
`.slider` is shared by every widget in the rail, and making all of them a
|
|
271
|
+
containing block to serve one would be a wide change for a narrow need. */
|
|
272
|
+
.slider:has(> .file-drop-ambient) { position: relative; }
|
|
273
|
+
|
|
274
|
+
.file-drop-ambient {
|
|
275
|
+
position: absolute; inset: 0; margin: 0; padding: 0;
|
|
276
|
+
min-height: 0; background: none; border: 0; pointer-events: none;
|
|
277
|
+
}
|
|
278
|
+
/* `dragover` is the only thing that reveals it. Pointer events stay off even
|
|
279
|
+
then: the drop event still fires on the element, and turning them on would
|
|
280
|
+
swallow a click that merely passed through mid-drag. */
|
|
281
|
+
.file-drop-ambient.file-drop-over {
|
|
282
|
+
border: 1px dashed var(--pf-accent); border-radius: var(--pf-radius-control);
|
|
283
|
+
background: color-mix(in oklab, var(--pf-accent) 12%, transparent);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/* Shared drag feedback for the labelled form. */
|
|
287
|
+
.file-drop.file-drop-over {
|
|
288
|
+
border-color: var(--pf-accent);
|
|
289
|
+
background: color-mix(in oklab, var(--pf-accent) 12%, transparent);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
.file-drop-error {
|
|
293
|
+
margin-top: 5px; font: 11px/1.35 var(--pf-sans); color: var(--pf-err);
|
|
294
|
+
}
|
|
295
|
+
|
|
201
296
|
/* the `type: "font"` control — a button that shows the current face IN it */
|
|
202
297
|
.font-btn { width: 100%; display: flex; align-items: center; gap: 8px; text-align: left; cursor: pointer;
|
|
203
298
|
background: var(--pf-input-bg); color: var(--pf-text-strong);
|
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
|
@@ -4,6 +4,7 @@ import { createViewer } from "./viewer.js";
|
|
|
4
4
|
import { attachViewerControls } from "./viewer-controls.js";
|
|
5
5
|
import { attachCutawayControls } from "./cutaway-controls.js";
|
|
6
6
|
import { attachRail } from "./rail.js";
|
|
7
|
+
import { declaredSourceLookup } from "./panel/declared-source.js";
|
|
7
8
|
import { attachMobileTabs } from "./mobile-tabs.js";
|
|
8
9
|
import { createTooltipPresenter, attachButtonTooltips } from "./tooltip.js";
|
|
9
10
|
import { loadCamera, loadProjection, saveProjection } from "./view-state.js";
|
|
@@ -61,7 +62,7 @@ const IMPORT_MESH_BROKEN_MESSAGE = "STEP import tessellation failed to satisfy t
|
|
|
61
62
|
// carries the worker's own error text. See the correlated "error" case below.
|
|
62
63
|
const importTessellateFailedMessage = (workerMessage) => `STEP import tessellation failed — ${workerMessage}`;
|
|
63
64
|
|
|
64
|
-
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 }) {
|
|
65
66
|
return {
|
|
66
67
|
ready, dispose, setParams,
|
|
67
68
|
// Part-declared animation playback (spec 2026-08-02): animations are
|
|
@@ -121,6 +122,12 @@ export function makeHandle({ ready, dispose, viewer, setParams, listExportablePa
|
|
|
121
122
|
// (partforge-cloud does, at the window level). Defaulted to a no-op so the
|
|
122
123
|
// handle's shape never depends on whether this mount resolved a rail.
|
|
123
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 ?? (() => {}),
|
|
124
131
|
// Join host-owned chrome buttons to this mount's shared hover tooltip, so
|
|
125
132
|
// a host's own viewbar/rail-foot buttons match the built-in ones. Entries
|
|
126
133
|
// are [{ element, getLabel? }] (label falls back to the button's
|
|
@@ -210,6 +217,11 @@ function createCleanupStack() {
|
|
|
210
217
|
// runtime.setHostPane("rail"); // narrow layout only: show just the controls
|
|
211
218
|
// // rail ('stage' | 'rail'), suppressing the
|
|
212
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.
|
|
213
225
|
// runtime.attachTooltips([{ element: myButton }]); // host chrome buttons join the
|
|
214
226
|
// // mount's shared hover tooltip (the viewbar one).
|
|
215
227
|
// // Label = the button's title (or aria-label), or a
|
|
@@ -378,6 +390,16 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
|
|
|
378
390
|
shell: els.shell ?? els.rail?.parentElement,
|
|
379
391
|
stage: els.viewer,
|
|
380
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?.(),
|
|
381
403
|
});
|
|
382
404
|
cleanup.defer(() => paneTabs.detach());
|
|
383
405
|
const hover = attachHoverLabels(viewer, { part, tooltip }); // always-on hover inspection (no-op on touch-only devices)
|
|
@@ -976,7 +998,11 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
|
|
|
976
998
|
}, onParamsCommit
|
|
977
999
|
? (changed) => onParamsCommit({ changed, params: { ...params } })
|
|
978
1000
|
: undefined,
|
|
979
|
-
|
|
1001
|
+
// What the part is actually using for each asset key, so a control opens
|
|
1002
|
+
// showing the bundled default instead of an empty tile. Rebuilt per panel
|
|
1003
|
+
// build, because the declaration is a function of the current params.
|
|
1004
|
+
{ fontCatalog, imageCatalog, onAssetUpload,
|
|
1005
|
+
declaredSource: declaredSourceLookup(part, params) });
|
|
980
1006
|
cleanup.defer(() => panel.dispose());
|
|
981
1007
|
panelRef = panel;
|
|
982
1008
|
const updateRelevance = () => {
|
|
@@ -1149,6 +1175,7 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
|
|
|
1149
1175
|
ready, dispose, viewer, setParams,
|
|
1150
1176
|
attachTooltips: attachHostTooltips,
|
|
1151
1177
|
setHostPane: paneTabs.setHostPane,
|
|
1178
|
+
setRailLayout: paneTabs.setRailLayout,
|
|
1152
1179
|
getView: view, // () => tabsCtl.current()
|
|
1153
1180
|
setView: (name) => tabsCtl.select(name),
|
|
1154
1181
|
captureView,
|
|
@@ -33,6 +33,17 @@ function authoredControl(c) {
|
|
|
33
33
|
hidden: !!c.hidden,
|
|
34
34
|
when: c.when,
|
|
35
35
|
whenFalse: c.whenFalse,
|
|
36
|
+
// Asset-control fields. This list is an explicit allow-list, so anything
|
|
37
|
+
// missing from it is silently dropped rather than failing loudly — which is
|
|
38
|
+
// how `allow` came to be ignored: an authored `allow: ["asset"]` never
|
|
39
|
+
// reached the widget, so its own typed-URL check fell back to the default
|
|
40
|
+
// list and accepted values the worker then refused and reset underneath the
|
|
41
|
+
// user. The worker-side gate was never affected (imageControlAllows and its
|
|
42
|
+
// siblings walk the raw authored tree, not this node), so the enforcement
|
|
43
|
+
// held throughout — but the panel disagreed with it.
|
|
44
|
+
allow: c.allow,
|
|
45
|
+
preview: c.preview,
|
|
46
|
+
sourceField: c.sourceField,
|
|
36
47
|
preserveOn: false,
|
|
37
48
|
marksCustom: true,
|
|
38
49
|
};
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// What a part is ACTUALLY using for an asset key, when the control's own param
|
|
2
|
+
// is empty.
|
|
3
|
+
//
|
|
4
|
+
// The problem this solves is not cosmetic. A part's bundled default lives in the
|
|
5
|
+
// `images`/`vectors` DECLARATION, never in `defaults` — an author cannot move it
|
|
6
|
+
// there, because the allow list passes only `https` and a bundled asset resolves
|
|
7
|
+
// to a `file:`/dev URL, so `defaults: { relief: new URL(…) }` is refused and
|
|
8
|
+
// reset. The declaration is therefore the only home for it, and the panel could
|
|
9
|
+
// not see the declaration at all: `buildControls` receives `part.parameters`.
|
|
10
|
+
// The result was a control that opened empty while the part was plainly building
|
|
11
|
+
// from an image.
|
|
12
|
+
//
|
|
13
|
+
// Main-thread only, and deliberately import-free beyond what a lookup needs: it
|
|
14
|
+
// runs during panel construction, on every rebuild.
|
|
15
|
+
|
|
16
|
+
const arr = (v) => (Array.isArray(v) ? v : []);
|
|
17
|
+
|
|
18
|
+
// `images`/`vectors`/`fonts` may each be a plain map or a function of params —
|
|
19
|
+
// the function form is what a control drives. Resolving it can throw (it is
|
|
20
|
+
// author code running on every panel build), and a broken declaration must not
|
|
21
|
+
// take the panel down: the control simply shows nothing, which is the same thing
|
|
22
|
+
// it showed before this existed.
|
|
23
|
+
function resolveDecl(decl, params) {
|
|
24
|
+
try {
|
|
25
|
+
return typeof decl === "function" ? decl(params) : decl;
|
|
26
|
+
} catch {
|
|
27
|
+
return undefined;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const FIELD = { image: "images", vector: "vectors", font: "fonts" };
|
|
32
|
+
|
|
33
|
+
// URL-shaped for the same reason lint's probes are: a declaration may parse the
|
|
34
|
+
// value it is handed, and an arbitrary string would make it throw for reasons
|
|
35
|
+
// that have nothing to do with which asset the key feeds.
|
|
36
|
+
const SENTINEL = "pf-panel-sentinel://declared-source";
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Build `(kind, key) => source | undefined` for one part and its current params.
|
|
40
|
+
*
|
|
41
|
+
* Returns `undefined` when the param already holds a value: the param IS the
|
|
42
|
+
* user's choice, and the declaration for that key is derived from it. Only an
|
|
43
|
+
* empty param falls through to whatever the part declared.
|
|
44
|
+
*/
|
|
45
|
+
export function declaredSourceLookup(part, params) {
|
|
46
|
+
return (kind, key) => {
|
|
47
|
+
const own = params?.[key];
|
|
48
|
+
if (own !== undefined && own !== null && own !== "") return undefined;
|
|
49
|
+
|
|
50
|
+
const decl = part?.[FIELD[kind]];
|
|
51
|
+
const resolved = resolveDecl(decl, params ?? {});
|
|
52
|
+
if (!resolved || typeof resolved !== "object") return undefined;
|
|
53
|
+
|
|
54
|
+
// A control's param key is NOT necessarily the asset's name. emblem.js
|
|
55
|
+
// declares `vectors: (p) => ({ emblem: p.art || bundled })` — key `art`,
|
|
56
|
+
// asset `emblem`. relief.js happens to use the same word for both, which is
|
|
57
|
+
// what makes the assumption look safe until it is not.
|
|
58
|
+
//
|
|
59
|
+
// So probe, the way lint's *-control-not-in-* rules already do: resolve the
|
|
60
|
+
// declaration once with a sentinel in this key and see which asset name it
|
|
61
|
+
// came out under. A URL-shaped sentinel, because a declaration is free to
|
|
62
|
+
// parse what it is handed and an arbitrary string would make it throw for
|
|
63
|
+
// reasons unrelated to the mapping.
|
|
64
|
+
let name = key;
|
|
65
|
+
if (typeof decl === "function" && !(key in resolved)) {
|
|
66
|
+
const probe = resolveDecl(decl, { ...(params ?? {}), [key]: SENTINEL });
|
|
67
|
+
const hit = probe && typeof probe === "object"
|
|
68
|
+
&& Object.keys(probe).find((n) => probe[n] === SENTINEL);
|
|
69
|
+
if (!hit) return undefined; // this key feeds nothing — not our asset
|
|
70
|
+
name = hit;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const source = resolved[name];
|
|
74
|
+
return source == null || source === "" ? undefined : source;
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* A declared image source -> something an `<img>` can load, or `undefined`.
|
|
80
|
+
*
|
|
81
|
+
* The allow list gates PARAMS, not author declarations, so a `file:` or dev URL
|
|
82
|
+
* is perfectly fine to display here — the browser is loading it, nothing is
|
|
83
|
+
* being accepted from an untrusted link.
|
|
84
|
+
*/
|
|
85
|
+
export async function declaredImageUrl(source) {
|
|
86
|
+
try {
|
|
87
|
+
let v = typeof source === "function" ? await source() : source;
|
|
88
|
+
// A Vite `() => import("./x.png")` resolves to `{ default: url }`.
|
|
89
|
+
if (v && typeof v === "object" && !(v instanceof URL) && "default" in v) v = v.default;
|
|
90
|
+
if (v instanceof URL) return v.href;
|
|
91
|
+
if (typeof v === "string" && v) return v;
|
|
92
|
+
if (v instanceof ArrayBuffer || ArrayBuffer.isView(v)) {
|
|
93
|
+
return URL.createObjectURL(new Blob([v], { type: "image/png" }));
|
|
94
|
+
}
|
|
95
|
+
return undefined;
|
|
96
|
+
} catch {
|
|
97
|
+
return undefined; // a thunk that rejects shows nothing, it does not propagate
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* A declared vector source -> its parsed document, or `undefined`.
|
|
103
|
+
*
|
|
104
|
+
* Unlike an image there is nothing to point at: the thumbnail is drawn from the
|
|
105
|
+
* document's own contours, so the file has to be fetched and parsed. Results are
|
|
106
|
+
* memoised per source, because a panel rebuild would otherwise refetch on every
|
|
107
|
+
* slider drag.
|
|
108
|
+
*/
|
|
109
|
+
const vectorDocs = new Map();
|
|
110
|
+
export async function declaredVectorDoc(source) {
|
|
111
|
+
if (source == null) return undefined;
|
|
112
|
+
if (typeof source === "object" && !(source instanceof URL) && !("default" in source)) return source;
|
|
113
|
+
if (vectorDocs.has(source)) return vectorDocs.get(source);
|
|
114
|
+
const p = (async () => {
|
|
115
|
+
try {
|
|
116
|
+
let v = typeof source === "function" ? await source() : source;
|
|
117
|
+
if (v && typeof v === "object" && !(v instanceof URL) && "default" in v) v = v.default;
|
|
118
|
+
if (v && typeof v === "object" && !(v instanceof URL)) return v; // already parsed
|
|
119
|
+
const res = await fetch(v instanceof URL ? v.href : v);
|
|
120
|
+
if (!res.ok) return undefined;
|
|
121
|
+
return await res.json();
|
|
122
|
+
} catch {
|
|
123
|
+
return undefined; // offline, 404, CORS, malformed JSON — all show nothing
|
|
124
|
+
}
|
|
125
|
+
})();
|
|
126
|
+
vectorDocs.set(source, p);
|
|
127
|
+
return p;
|
|
128
|
+
}
|
|
@@ -229,6 +229,7 @@ export function buildControls(root, parameters, params, onDirty, onCommit, opts
|
|
|
229
229
|
fontCatalog: opts.fontCatalog,
|
|
230
230
|
imageCatalog: opts.imageCatalog,
|
|
231
231
|
onAssetUpload: opts.onAssetUpload,
|
|
232
|
+
declaredSource: opts.declaredSource,
|
|
232
233
|
});
|
|
233
234
|
nodeEls.set(node.id, widget.el);
|
|
234
235
|
if (node.key && !keyToId.has(node.key)) keyToId.set(node.key, node.id);
|