@things-factory/figure-ui 10.1.181 → 10.1.185

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.
Files changed (67) hide show
  1. package/client/modeller/editor-styles.ts +28 -9
  2. package/client/modeller/editor.ts +105 -12
  3. package/client/modeller/fasten-form.ts +207 -0
  4. package/client/modeller/fasten-geometry.ts +5 -1
  5. package/client/modeller/figure-facts.ts +4 -35
  6. package/client/modeller/figure-fasten-dialog.ts +457 -0
  7. package/client/modeller/figure-fasten-drawing.ts +32 -11
  8. package/client/modeller/figure-links.ts +56 -300
  9. package/client/modeller/figure-properties.ts +23 -70
  10. package/client/modeller/modeller-keys.ts +4 -4
  11. package/dist-client/modeller/clips.d.ts +2 -2
  12. package/dist-client/modeller/editor-styles.js +28 -9
  13. package/dist-client/modeller/editor-styles.js.map +1 -1
  14. package/dist-client/modeller/editor.d.ts +23 -2
  15. package/dist-client/modeller/editor.js +121 -12
  16. package/dist-client/modeller/editor.js.map +1 -1
  17. package/dist-client/modeller/fasten-form.d.ts +69 -0
  18. package/dist-client/modeller/fasten-form.js +131 -0
  19. package/dist-client/modeller/fasten-form.js.map +1 -0
  20. package/dist-client/modeller/fasten-geometry.d.ts +3 -1
  21. package/dist-client/modeller/fasten-geometry.js +6 -1
  22. package/dist-client/modeller/fasten-geometry.js.map +1 -1
  23. package/dist-client/modeller/figure-facts.d.ts +2 -13
  24. package/dist-client/modeller/figure-facts.js +2 -29
  25. package/dist-client/modeller/figure-facts.js.map +1 -1
  26. package/dist-client/modeller/figure-fasten-dialog.d.ts +69 -0
  27. package/dist-client/modeller/figure-fasten-dialog.js +433 -0
  28. package/dist-client/modeller/figure-fasten-dialog.js.map +1 -0
  29. package/dist-client/modeller/figure-fasten-drawing.d.ts +6 -3
  30. package/dist-client/modeller/figure-fasten-drawing.js +36 -10
  31. package/dist-client/modeller/figure-fasten-drawing.js.map +1 -1
  32. package/dist-client/modeller/figure-links.d.ts +14 -38
  33. package/dist-client/modeller/figure-links.js +58 -301
  34. package/dist-client/modeller/figure-links.js.map +1 -1
  35. package/dist-client/modeller/figure-properties.d.ts +12 -12
  36. package/dist-client/modeller/figure-properties.js +23 -72
  37. package/dist-client/modeller/figure-properties.js.map +1 -1
  38. package/dist-client/modeller/modeller-keys.js +4 -4
  39. package/dist-client/modeller/modeller-keys.js.map +1 -1
  40. package/dist-client/route.d.ts +1 -1
  41. package/dist-client/tsconfig.tsbuildinfo +1 -1
  42. package/package.json +8 -8
  43. package/test/browser/links-page.js +4 -4
  44. package/test/browser/links.check.mjs +196 -46
  45. package/test/browser/modeller-page.js +23 -0
  46. package/test/browser/modeller.check.mjs +19 -20
  47. package/test/browser/namespace-page.js +2 -29
  48. package/test/browser/popup-stand-in.js +48 -0
  49. package/test/browser/product-page.check.mjs +60 -9
  50. package/test/browser/product-page.js +30 -33
  51. package/test/browser/property-section.check.mjs +3 -3
  52. package/test/browser/props.check.mjs +28 -7
  53. package/test/browser/screen-page.js +7 -2
  54. package/test/browser/screen.check.mjs +28 -23
  55. package/test/browser/section-page.js +13 -41
  56. package/test/browser/section.check.mjs +39 -30
  57. package/test/fasten-form.test.ts +77 -0
  58. package/test/fasten-geometry.test.ts +2 -0
  59. package/test/first-minute.test.ts +7 -4
  60. package/test/properties.test.ts +11 -19
  61. package/translations/en.json +5 -12
  62. package/translations/es.json +5 -12
  63. package/translations/ja.json +5 -12
  64. package/translations/ko.json +5 -12
  65. package/translations/ms.json +5 -12
  66. package/translations/zh.json +5 -12
  67. package/client/modeller/mounting-part.ts +0 -15
@@ -0,0 +1,457 @@
1
+ import '@material/web/button/filled-button.js'
2
+ import '@material/web/button/text-button.js'
3
+
4
+ import { css, html, LitElement, nothing } from 'lit'
5
+ import { customElement, property, state } from 'lit/decorators.js'
6
+
7
+ import { i18next, localize } from '@operato/i18n'
8
+ import '@operato/input/ox-input-select-buttons.js'
9
+ import '@operato/popup/ox-popup-actions.js'
10
+ import { CommonPopupStyles } from '@operato/styles'
11
+ import { SidePanelStyles } from '@things-factory/figure-viewer/dist-client/side-panel-styles.js'
12
+
13
+ import './figure-fasten-drawing.js'
14
+ import type { FastenChange } from './figure-fasten-drawing.js'
15
+ import { turnsInPlace, type FastenMode } from './fasten-geometry.js'
16
+ import {
17
+ aimFasten,
18
+ fastenChanged,
19
+ fastenDetailWordsOf,
20
+ fastenFormOf,
21
+ fastenPlanOf,
22
+ fastenSentenceOf,
23
+ fastenTargetsOf,
24
+ type FastenForm
25
+ } from './fasten-form.js'
26
+ import { fieldWordsOf, hasInstanceSize, heldBy, type Alignment, type Axis } from './links.js'
27
+ import type { FigureRevision } from './revision.js'
28
+
29
+ /** The target list's entry for the plane the figure stands by. */
30
+ const PLANE = '__plane'
31
+
32
+ /**
33
+ * 붙이기 창: what the selected part is fastened to, on which face, how -- chosen with room to see it, in the standard
34
+ * popup the editor opens (`openPopup`: its title, its close, moved by its head) (user 2026-10-17: 「속성에는 심플하고
35
+ * 직관적인 결과 위주로, 편집은 팝업에서 넓은 공간을 써서」; 2026-10-18: the standard popup, not a window of its own).
36
+ *
37
+ * Everything chosen here is tried, not done: the command it comes to is told to the editor as it changes
38
+ * (`preview-command`), and the canvas draws it. 「적용」 sends it once (`fasten-apply`), one step of the history;
39
+ * 「취소」 and Esc close it with nothing written (`fasten-cancel`). A row of the target list under the pointer or the
40
+ * arrows asks the canvas to light that part (`fasten-hover`). The window is the only one telling the preview while it
41
+ * is open; the editor keeps the panel quiet.
42
+ */
43
+ @customElement('figure-fasten-dialog')
44
+ export class FigureFastenDialog extends localize(i18next)(LitElement) {
45
+ static styles = [
46
+ SidePanelStyles,
47
+ CommonPopupStyles,
48
+ css`
49
+ :host {
50
+ display: grid;
51
+ gap: 16px;
52
+ padding: var(--popup-padding, 16px);
53
+ overflow: auto;
54
+ }
55
+ /* The result in one line, at the top: it changes with every choice below it. */
56
+ p[fasten-sentence] {
57
+ margin: 0;
58
+ font-size: var(--ui-t-body, 13px);
59
+ font-weight: 600;
60
+ }
61
+ div[question] {
62
+ display: grid;
63
+ align-content: start;
64
+ gap: 6px;
65
+ }
66
+ h4 {
67
+ margin: 0;
68
+ font-size: var(--ui-t-small, 11.5px);
69
+ font-weight: 600;
70
+ color: var(--md-sys-color-on-surface-variant);
71
+ }
72
+ /* The chips are part names, identifiers: shown as they are written, not capitalized as filter words are. */
73
+ ox-input-select-buttons {
74
+ --ox-input-select-buttons-label-text-transform: none;
75
+ }
76
+ p[loops] {
77
+ margin: 0;
78
+ }
79
+ details[fasten-detail] {
80
+ margin: 0;
81
+ }
82
+ details[fasten-detail] > div[form] {
83
+ margin-top: 8px;
84
+ max-width: 320px;
85
+ }
86
+ span[fasten-summary] {
87
+ opacity: 0.8;
88
+ }
89
+ ul[preview-words] {
90
+ margin: 0;
91
+ padding: 8px 12px 8px 26px;
92
+ border-radius: 6px;
93
+ background: var(--md-sys-color-surface-container, #8881);
94
+ display: grid;
95
+ gap: 4px;
96
+ }
97
+ p[preview-words] {
98
+ margin: 0;
99
+ padding: 8px 12px;
100
+ border-radius: 6px;
101
+ background: var(--md-sys-color-surface-container, #8881);
102
+ }
103
+ p[error] {
104
+ margin: 0;
105
+ color: var(--md-sys-color-error);
106
+ }
107
+ `
108
+ ]
109
+
110
+ /** The figure at the version on screen. */
111
+ @property({ attribute: false }) revision?: FigureRevision
112
+ /** The part being fastened. */
113
+ @property() partId = ''
114
+ @property({ type: Boolean }) locked = false
115
+ /** What the command would do, in words (the editor works it out: `command-preview.ts`). */
116
+ @property({ attribute: false }) previewWords: string[] = []
117
+ /** Why the last 「적용」 was refused, in the editor's words. */
118
+ @property() error = ''
119
+
120
+ @state() private form?: FastenForm
121
+ /** The command the part's own fastening comes to, as opened: tried unchanged, nothing new is said of it. */
122
+ private opened?: string
123
+ private told = ''
124
+ private loadedFor?: string
125
+ private hovered: string | null = null
126
+
127
+ willUpdate() {
128
+ /* Opened on what the part has now; another part is another window's. */
129
+ if (this.revision && this.partId && this.partId !== this.loadedFor) {
130
+ this.loadedFor = this.partId
131
+ this.form = fastenFormOf(this.revision, this.partId)
132
+ this.opened = JSON.stringify(fastenPlanOf(this.revision, this.partId, this.form).action ?? null)
133
+ }
134
+ }
135
+
136
+ async firstUpdated() {
137
+ /* Into the list, on what is chosen, so the arrows go from there. */
138
+ const list = this.renderRoot.querySelector<HTMLElement & { updateComplete?: Promise<unknown> }>(
139
+ 'ox-input-select-buttons'
140
+ )
141
+ await list?.updateComplete
142
+ const chips = list?.shadowRoot?.querySelectorAll<HTMLElement>('md-filter-chip')
143
+ ;([...(chips ?? [])].find(c => c.hasAttribute('selected')) ?? chips?.[0])?.focus()
144
+ }
145
+
146
+ updated() {
147
+ const plan = this.revision && this.form ? fastenPlanOf(this.revision, this.partId, this.form) : undefined
148
+ const candidate = plan?.action ? { action: plan.action, ...(plan.face ? { face: plan.face } : {}) } : null
149
+ const said = JSON.stringify(candidate)
150
+ if (said === this.told) return
151
+ this.told = said
152
+ this.dispatchEvent(new CustomEvent('preview-command', { detail: candidate, bubbles: true, composed: true }))
153
+ }
154
+
155
+ disconnectedCallback() {
156
+ super.disconnectedCallback()
157
+ /* Closed: what it showed goes with it. */
158
+ if (this.told && this.told !== 'null')
159
+ this.dispatchEvent(new CustomEvent('preview-command', { detail: null, bubbles: true, composed: true }))
160
+ this.told = ''
161
+ this.hover(null)
162
+ }
163
+
164
+ render() {
165
+ const revision = this.revision
166
+ const f = this.form
167
+ if (!revision || !f) return nothing
168
+ const asset = revision.asset
169
+ const held = !!heldBy(asset, this.partId)
170
+ const plan = fastenPlanOf(revision, this.partId, f)
171
+ const planeWord = i18next.t('figure.label.resize-place-' + (asset.placement ?? 'floor'))
172
+ const modes: FastenMode[] = f.toPlane
173
+ ? ['meet']
174
+ : ['meet', 'flush', ...(turnsInPlace(asset, this.partId) ? (['centre'] as FastenMode[]) : [])]
175
+ const detail = fastenDetailWordsOf(f, (key, params) => i18next.t(key, params))
176
+ const drawing = (show: 'face' | 'how') =>
177
+ html`<figure-fasten-drawing
178
+ show=${show}
179
+ .target=${f.toPlane ? '' : f.target}
180
+ .planeWord=${planeWord}
181
+ .faces=${plan.theirFaces}
182
+ .face=${plan.targetFace}
183
+ .modes=${modes}
184
+ .mode=${plan.mode}
185
+ .view=${plan.view}
186
+ .align=${f.align}
187
+ .alignMm=${f.alignMm}
188
+ .gap=${f.gap}
189
+ .partName=${this.partId}
190
+ ?locked=${this.locked}
191
+ @fasten-change=${(e: CustomEvent<FastenChange>) => (this.form = fastenChanged(revision, this.partId, this.form!, e.detail))}
192
+ ></figure-fasten-drawing>`
193
+ return html`
194
+ ${
195
+ plan.aim
196
+ ? html`<p fasten-sentence role="status">
197
+ ${fastenSentenceOf(this.partId, f, plan, planeWord, (key, params) => i18next.t(key, params))}
198
+ </p>`
199
+ : html`<p hint fasten-sentence>${i18next.t('figure.text.fasten-target-first')}</p>`
200
+ }
201
+ <div question="what">
202
+ <h4 id="what">${i18next.t('figure.label.fasten-what')}</h4>
203
+ ${this.renderTargets(revision, f, planeWord)}
204
+ </div>
205
+ ${
206
+ plan.aim
207
+ ? html`<div question="face">
208
+ <h4>
209
+ ${
210
+ /* The face asked is the target's, named: the part's own face follows from it. */ f.toPlane
211
+ ? i18next.t('figure.label.fasten-face-on-plane', { plane: planeWord })
212
+ : i18next.t('figure.label.fasten-face-on-part', { part: f.target })
213
+ }
214
+ </h4>
215
+ ${drawing('face')}
216
+ </div>
217
+ ${
218
+ modes.length > 1
219
+ ? html`<div question="how">
220
+ <h4>${i18next.t('figure.label.fasten-how')}</h4>
221
+ ${drawing('how')}
222
+ </div>`
223
+ : nothing
224
+ }
225
+ <div question="detail">
226
+ <details fasten-detail>
227
+ <summary>
228
+ ${i18next.t('figure.label.fasten-detail')}${detail.length ? html` · <span fasten-summary>${detail.join(' · ')}</span>` : nothing}
229
+ </summary>
230
+ ${plan.alignRows.length ? this.renderAlignment(plan.alignRows) : nothing}
231
+ <div form>
232
+ <span>${i18next.t('figure.label.gap')}</span>
233
+ <input
234
+ gap
235
+ type="number"
236
+ step="any"
237
+ .value=${f.gapKept !== undefined ? '' : String(f.gap)}
238
+ placeholder=${f.gapKept !== undefined ? i18next.t('figure.label.follows-expression') : ''}
239
+ @change=${(e: Event) => this.set({ gap: Number((e.target as HTMLInputElement).value) || 0, gapKept: undefined })}
240
+ />
241
+ <span>${i18next.t('figure.label.twist')}</span>
242
+ <input
243
+ twist
244
+ type="number"
245
+ step="any"
246
+ .value=${String(f.twist)}
247
+ @change=${(e: Event) => this.set({ twist: Number((e.target as HTMLInputElement).value) || 0 })}
248
+ />
249
+ </div>
250
+ </details>
251
+ </div>
252
+ ${/* The one it has, tried unchanged: that it stays where it is goes without saying. */ JSON.stringify(plan.action ?? null) === this.opened ? nothing : this.renderPreviewWords()}`
253
+ : nothing
254
+ }
255
+ ${this.error ? html`<p error role="alert">${this.error}</p>` : nothing}
256
+ <ox-popup-actions>
257
+ <md-text-button slot="cancel" cancel @click=${this.cancel}>${i18next.t('figure.button.cancel')}</md-text-button>
258
+ <md-filled-button slot="confirm" apply ?disabled=${this.locked || !plan.action} @click=${this.apply}>
259
+ ${i18next.t(held ? 'figure.button.apply' : 'figure.button.fasten')}
260
+ </md-filled-button>
261
+ </ox-popup-actions>
262
+ `
263
+ }
264
+
265
+ /**
266
+ * What to fasten to: the plane and the other parts, as the standard choice chips. The arrows move among them and
267
+ * the canvas lights the part under the focus or the pointer; a part hanging from this one is not offered, and why is
268
+ * said under them.
269
+ */
270
+ private renderTargets(revision: FigureRevision, f: FastenForm, planeWord: string) {
271
+ const chosen = f.toPlane ? PLANE : f.target
272
+ const targets = fastenTargetsOf(revision, this.partId)
273
+ const chipOf = (e: Event) =>
274
+ (e.composedPath().find(n => (n as Element).localName === 'md-filter-chip') as Element | undefined)?.getAttribute(
275
+ 'data-value'
276
+ ) ?? null
277
+ return html`<ox-input-select-buttons
278
+ targets
279
+ aria-labelledby="what"
280
+ .options=${[{ value: PLANE, display: planeWord }, ...targets.filter(t => !t.loops).map(t => ({ value: t.part, display: t.part }))]}
281
+ .value=${chosen || undefined}
282
+ ?disabled=${this.locked}
283
+ @change=${(e: CustomEvent) => {
284
+ /* A chip pressed again lets go of it; what to fasten to is always one thing once chosen. */
285
+ if (e.detail) this.aim(e.detail)
286
+ else (e.target as HTMLElement & { value?: string }).value = chosen || undefined
287
+ }}
288
+ @pointerover=${(e: Event) => this.hover(chipOf(e))}
289
+ @pointerleave=${() => this.hover(null)}
290
+ @focusin=${(e: Event) => this.hover(chipOf(e))}
291
+ @keyup=${(e: KeyboardEvent) => {
292
+ /* The arrows move the focus among the chips inside the list, which tells nothing outside it: read where it is. */
293
+ const at = (e.currentTarget as Element).shadowRoot?.activeElement
294
+ if (at?.localName === 'md-filter-chip') this.hover(at.getAttribute('data-value'))
295
+ }}
296
+ ></ox-input-select-buttons>
297
+ ${this.renderLoops(targets.filter(t => t.loops).map(t => t.part))}`
298
+ }
299
+
300
+ /**
301
+ * The parts that hang from this one, in one line: a line each made a list longer than the chips (2026-10-18, :3300,
302
+ * the robot arm's forearm and the six parts it carries).
303
+ */
304
+ private renderLoops(parts: string[]) {
305
+ if (!parts.length) return nothing
306
+ return html`<p hint loops targets=${parts.join(',')}>
307
+ ${i18next.t('figure.text.target-would-loop', { part: parts.join(', '), mine: this.partId })}
308
+ </p>`
309
+ }
310
+
311
+ private aim(part: string) {
312
+ if (this.locked || !this.revision || !this.form) return
313
+ const chosen = this.form.toPlane ? PLANE : this.form.target
314
+ if (part === chosen) return
315
+ this.form = aimFasten(this.revision, this.partId, this.form, part === PLANE ? { plane: true } : { part })
316
+ }
317
+
318
+ /** The part under the pointer or the focus in the list, for the canvas to light; the plane lights nothing. */
319
+ private hover(part: string | null) {
320
+ const lit = part && part !== PLANE ? part : null
321
+ if (lit === this.hovered) return
322
+ this.hovered = lit
323
+ const face =
324
+ lit && this.revision && this.form
325
+ ? fastenPlanOf(this.revision, this.partId, aimFasten(this.revision, this.partId, this.form, { part: lit }))
326
+ .targetFace
327
+ : undefined
328
+ this.dispatchEvent(
329
+ new CustomEvent('fasten-hover', { detail: lit ? { part: lit, face } : null, bubbles: true, composed: true })
330
+ )
331
+ }
332
+
333
+ private set(change: Partial<FastenForm>) {
334
+ this.form = { ...this.form!, ...change }
335
+ }
336
+
337
+ /**
338
+ * Where on the target face the part sits, on the axes the drawing cannot set: centred, level with the target's edge on
339
+ * either side, a distance off centre, a share of the figure's size, or the position field it follows.
340
+ */
341
+ private renderAlignment(axes: { axis: Axis; edges: boolean }[]) {
342
+ const f = this.form!
343
+ const set = (axis: Axis, how: Alignment) => this.set({ align: { ...f.align, [axis]: how } })
344
+ const setMm = (axis: Axis, mm: number) => this.set({ alignMm: { ...f.alignMm, [axis]: mm } })
345
+ return html`<div form>
346
+ ${axes.map(({ axis, edges }) => {
347
+ const how =
348
+ f.align[axis] === 'min' || f.align[axis] === 'max'
349
+ ? edges
350
+ ? f.align[axis]!
351
+ : 'centre'
352
+ : (f.align[axis] ?? 'centre')
353
+ return html`<span>${i18next.t('figure.label.align-' + axis)}</span>
354
+ <select @change=${(e: Event) => set(axis, (e.target as HTMLSelectElement).value as Alignment)}>
355
+ <option value="centre" ?selected=${how === 'centre'}>${i18next.t('figure.label.align-centre')}</option>
356
+ ${
357
+ edges
358
+ ? html`<option value="min" ?selected=${how === 'min'}>
359
+ ${i18next.t(`figure.label.align-${axis}-min`)}
360
+ </option>
361
+ <option value="max" ?selected=${how === 'max'}>
362
+ ${i18next.t(`figure.label.align-${axis}-max`)}
363
+ </option>`
364
+ : nothing
365
+ }
366
+ ${
367
+ f.alignInput?.[axis]
368
+ ? html`<option value="input" ?selected=${how === 'input'}>
369
+ ${i18next.t('figure.label.align-input', { field: fieldWordsOf(f.alignInput[axis]!, key => i18next.t(key)) })}
370
+ </option>`
371
+ : nothing
372
+ }
373
+ <option value="mm" ?selected=${how === 'mm'}>${i18next.t('figure.label.align-mm')}</option>
374
+ ${
375
+ this.revision && hasInstanceSize(this.revision.asset, axis)
376
+ ? html`<option value="share" ?selected=${how === 'share'}>
377
+ ${i18next.t('figure.label.align-share')}
378
+ </option>`
379
+ : nothing
380
+ }
381
+ </select>
382
+ ${
383
+ how === 'mm' || how === 'share'
384
+ ? html`<span></span
385
+ ><input
386
+ type="number"
387
+ step="any"
388
+ aria-label=${i18next.t('figure.label.align-mm')}
389
+ .value=${String(f.alignMm[axis] ?? 0)}
390
+ @change=${(e: Event) => setMm(axis, Number((e.target as HTMLInputElement).value) || 0)}
391
+ />`
392
+ : nothing
393
+ }`
394
+ })}
395
+ </div>`
396
+ }
397
+
398
+ private renderPreviewWords() {
399
+ if (this.previewWords.length === 1) return html`<p preview-words role="status">${this.previewWords[0]}</p>`
400
+ return this.previewWords.length
401
+ ? html`<ul preview-words role="status">
402
+ ${this.previewWords.map(line => html`<li>${line}</li>`)}
403
+ </ul>`
404
+ : nothing
405
+ }
406
+
407
+ /** Enter applies what is chosen, Esc closes with nothing written; a key the window does not take goes on. */
408
+ connectedCallback() {
409
+ super.connectedCallback()
410
+ this.setAttribute('role', 'dialog')
411
+ this.setAttribute('aria-label', i18next.t('figure.label.fastening'))
412
+ this.addEventListener('keydown', this.onKey)
413
+ /* The popup's fitting width is the overlay's own setting; the window asks it for room for the face and the way side by side. */
414
+ this.closest<HTMLElement>('ox-floating-overlay')?.style.setProperty('--overlay-center-fit-width', '640px')
415
+ }
416
+
417
+ private onKey = async (e: KeyboardEvent) => {
418
+ if (e.key === 'Escape') {
419
+ e.stopPropagation()
420
+ e.preventDefault()
421
+ this.cancel()
422
+ return
423
+ }
424
+ if (e.key !== 'Enter' || e.isComposing) return
425
+ const path = e.composedPath() as Element[]
426
+ const target = path[0] as HTMLElement
427
+ /* On a chip of the list Enter applies, as it does anywhere in the window; a chip is chosen by Space or a press. */
428
+ const onChip = path.some(n => n.localName === 'ox-input-select-buttons')
429
+ /* A button, a fold or a list of options does what Enter does to it. */
430
+ if (
431
+ !onChip &&
432
+ (target.localName === 'button' ||
433
+ target.localName === 'summary' ||
434
+ target.localName === 'select' ||
435
+ /^md-.*button$/.test(target.localName))
436
+ )
437
+ return
438
+ e.preventDefault()
439
+ e.stopPropagation()
440
+ /* A number typed and Enter: the number first, as leaving the field would take it. */
441
+ if (target.localName === 'input') target.dispatchEvent(new Event('change', { bubbles: true, composed: true }))
442
+ await this.updateComplete
443
+ this.apply()
444
+ }
445
+
446
+ private apply = () => {
447
+ const plan = this.revision && this.form ? fastenPlanOf(this.revision, this.partId, this.form) : undefined
448
+ if (this.locked || !plan?.action) return
449
+ this.dispatchEvent(
450
+ new CustomEvent('fasten-apply', { detail: { action: plan.action }, bubbles: true, composed: true })
451
+ )
452
+ }
453
+
454
+ private cancel = () => {
455
+ this.dispatchEvent(new CustomEvent('fasten-cancel', { bubbles: true, composed: true }))
456
+ }
457
+ }
@@ -29,6 +29,9 @@ const HIDDEN: Array<[FigureFace, number, number]> = [
29
29
  ['back', 236, 14]
30
30
  ]
31
31
  const PANEL_W = 300
32
+ /* The face seen from above, beside the box in the fastening window: taller than the panel's was, for a long face's parts. */
33
+ const WHERE_W = 360
34
+ const WHERE_H = 200
32
35
 
33
36
 
34
37
  /**
@@ -41,7 +44,8 @@ const PANEL_W = 300
41
44
  * footprint on it: dragged, caught at the centre and at the edges from inside and outside, a guide drawn where it is
42
45
  * caught and a dimension where it is not, which is pressed to type the number. It may go past the edges: the part that
43
46
  * does is shaded, and a part off the face altogether is said so. Nothing is decided here: every change goes to the form
44
- * (`fasten-change`), and a press or a drag let go says so (`fasten-release`), for the form to apply a fastening it holds.
47
+ * (`fasten-change`), and a press or a drag let go says so (`fasten-release`). The window draws it twice, under its own
48
+ * questions: the face (`show="face"`: the box and the face seen from above, side by side) and the way (`show="how"`).
45
49
  */
46
50
  @customElement('figure-fasten-drawing')
47
51
  export class FigureFastenDrawing extends localize(i18next)(LitElement) {
@@ -62,31 +66,45 @@ export class FigureFastenDrawing extends localize(i18next)(LitElement) {
62
66
  }
63
67
  div[modes] {
64
68
  display: grid;
65
- grid-template-columns: repeat(auto-fit, minmax(84px, 1fr));
69
+ grid-template-columns: repeat(auto-fit, minmax(72px, 1fr));
66
70
  gap: 6px;
67
71
  }
68
72
  div[modes] svg {
69
- width: 64px;
70
- height: 44px;
73
+ width: 48px;
74
+ height: 33px;
71
75
  }
76
+ /* A choice drawn, not a box to press (the side's rule: no boxed buttons): the chosen one on the primary's ground. */
72
77
  div[modes] button {
73
78
  display: grid;
74
79
  justify-items: center;
75
80
  align-content: center;
76
81
  gap: 2px;
77
- padding: 2px 4px 4px;
82
+ padding: 4px 4px 6px;
78
83
  font: inherit;
79
84
  color: var(--md-sys-color-on-surface);
80
85
  background: none;
81
- border: 1px solid var(--md-sys-color-outline-variant);
82
- border-radius: 8px;
86
+ border: none;
87
+ border-radius: 6px;
83
88
  cursor: pointer;
84
89
  }
90
+ div[modes] button:hover:not([on]):not(:disabled) {
91
+ background: var(--md-sys-color-surface-container);
92
+ }
85
93
  div[modes] button[on] {
86
- border: 1.5px solid var(--md-sys-color-primary);
87
94
  background: var(--md-sys-color-primary-container);
88
95
  color: var(--md-sys-color-on-primary-container);
89
96
  }
97
+ /* The box and the face seen from above side by side, one under the other where the window is narrow. */
98
+ div[face-row] {
99
+ display: grid;
100
+ grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
101
+ gap: 12px;
102
+ align-items: start;
103
+ }
104
+ div[face-row] > div {
105
+ display: grid;
106
+ gap: 8px;
107
+ }
90
108
  div[where] {
91
109
  position: relative;
92
110
  }
@@ -120,6 +138,8 @@ export class FigureFastenDrawing extends localize(i18next)(LitElement) {
120
138
  @property({ type: Boolean }) locked = false
121
139
  /** The form already says how the fastening it has lies on the face: the drawing says it only while dragging. */
122
140
  @property({ type: Boolean }) said = false
141
+ /** Which of its questions it draws: the face, or the way. */
142
+ @property() show: 'face' | 'how' = 'face'
123
143
 
124
144
  /** Where the footprint is while dragging, and what it is caught by; undefined when nothing is dragged. */
125
145
  @state() private dragged?: { at: Record<Axis, number>; caught: Partial<Record<Axis, string>> }
@@ -135,7 +155,8 @@ export class FigureFastenDrawing extends localize(i18next)(LitElement) {
135
155
  }
136
156
 
137
157
  render() {
138
- return html`${this.renderTarget()}${this.renderModes()}${this.renderWhere()}`
158
+ if (this.show === 'how') return this.renderModes()
159
+ return html`<div face-row>${this.target ? html`<div>${this.renderTarget()}</div>` : nothing}<div>${this.renderWhere()}</div></div>`
139
160
  }
140
161
 
141
162
  /** The target box, its faces pressed; or the plane. */
@@ -248,8 +269,8 @@ export class FigureFastenDrawing extends localize(i18next)(LitElement) {
248
269
  const view = this.view
249
270
  if (!view) return nothing
250
271
  const { h, v, sign } = screenOf(view.axes)
251
- const W = PANEL_W,
252
- H = 180
272
+ const W = WHERE_W,
273
+ H = WHERE_H
253
274
  /* Room around the face for the part to go past its edges by about half itself; a plane with no size, three parts wide. */
254
275
  const span = (a: Axis) => (view.face[a] !== undefined ? view.face[a]! + view.part[a] : view.part[a] * 3)
255
276
  const k = Math.min((W - 40) / span(h), (H - 40) / span(v))