@things-factory/figure-ui 10.1.181 → 10.1.183

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 (54) 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-fasten-dialog.ts +442 -0
  6. package/client/modeller/figure-fasten-drawing.ts +32 -11
  7. package/client/modeller/figure-links.ts +56 -300
  8. package/client/modeller/modeller-keys.ts +4 -4
  9. package/dist-client/modeller/editor-styles.js +28 -9
  10. package/dist-client/modeller/editor-styles.js.map +1 -1
  11. package/dist-client/modeller/editor.d.ts +23 -2
  12. package/dist-client/modeller/editor.js +121 -12
  13. package/dist-client/modeller/editor.js.map +1 -1
  14. package/dist-client/modeller/fasten-form.d.ts +69 -0
  15. package/dist-client/modeller/fasten-form.js +131 -0
  16. package/dist-client/modeller/fasten-form.js.map +1 -0
  17. package/dist-client/modeller/fasten-geometry.d.ts +3 -1
  18. package/dist-client/modeller/fasten-geometry.js +6 -1
  19. package/dist-client/modeller/fasten-geometry.js.map +1 -1
  20. package/dist-client/modeller/figure-fasten-dialog.d.ts +64 -0
  21. package/dist-client/modeller/figure-fasten-dialog.js +418 -0
  22. package/dist-client/modeller/figure-fasten-dialog.js.map +1 -0
  23. package/dist-client/modeller/figure-fasten-drawing.d.ts +6 -3
  24. package/dist-client/modeller/figure-fasten-drawing.js +36 -10
  25. package/dist-client/modeller/figure-fasten-drawing.js.map +1 -1
  26. package/dist-client/modeller/figure-links.d.ts +15 -39
  27. package/dist-client/modeller/figure-links.js +58 -301
  28. package/dist-client/modeller/figure-links.js.map +1 -1
  29. package/dist-client/modeller/modeller-keys.js +4 -4
  30. package/dist-client/modeller/modeller-keys.js.map +1 -1
  31. package/dist-client/route.d.ts +1 -1
  32. package/dist-client/tsconfig.tsbuildinfo +1 -1
  33. package/package.json +5 -5
  34. package/test/browser/links-page.js +4 -4
  35. package/test/browser/links.check.mjs +184 -46
  36. package/test/browser/modeller-page.js +23 -0
  37. package/test/browser/modeller.check.mjs +19 -20
  38. package/test/browser/namespace-page.js +2 -29
  39. package/test/browser/popup-stand-in.js +48 -0
  40. package/test/browser/product-page.check.mjs +4 -3
  41. package/test/browser/product-page.js +19 -32
  42. package/test/browser/props.check.mjs +28 -7
  43. package/test/browser/screen.check.mjs +28 -23
  44. package/test/browser/section-page.js +13 -41
  45. package/test/browser/section.check.mjs +39 -30
  46. package/test/fasten-form.test.ts +77 -0
  47. package/test/fasten-geometry.test.ts +2 -0
  48. package/test/first-minute.test.ts +7 -4
  49. package/translations/en.json +4 -3
  50. package/translations/es.json +4 -3
  51. package/translations/ja.json +4 -3
  52. package/translations/ko.json +4 -3
  53. package/translations/ms.json +4 -3
  54. package/translations/zh.json +4 -3
@@ -17,8 +17,8 @@ import { thumbnailHashOf } from 'figure-ui-client/modeller/thumbnail-hash.js'
17
17
  import { client } from '@operato/graphql'
18
18
  import { i18next } from '@operato/i18n'
19
19
  import { store } from '@operato/shell'
20
- import { setOpenPopupImplementation } from '@operato/popup'
21
- import '@operato/popup/ox-floating-overlay.js'
20
+ import { addressController } from '@operato/utils/address.js'
21
+ import './popup-stand-in.js'
22
22
  import { html, render } from 'lit'
23
23
  import { addFigureBox, applyFigureAuthoring, createFigureAsset, editFigurePart, serializeFigureAsset } from '@hatiolab/figure-model'
24
24
 
@@ -129,32 +129,6 @@ function everywhere(selector, root) {
129
129
  return found
130
130
  }
131
131
 
132
- /* Popups, drawn as the layout draws them: an overlay of their own in the body, the result given when it leaves. */
133
- let popups = 0
134
- setOpenPopupImplementation((template, options = {}) => {
135
- const name = `$popup-${popups++}`
136
- const fragment = document.createDocumentFragment()
137
- render(html`<ox-floating-overlay id=${name} name=${name} .backdrop=${!!options.backdrop} .hovering=${options.hovering} .title=${options.title || ''} .size=${options.size || 'small'} .historical=${false}>${template}</ox-floating-overlay>`, fragment)
138
- document.body.appendChild(fragment)
139
- const handle = {
140
- name,
141
- close: () => {
142
- const popup = document.getElementById(name)
143
- if (!popup) return false
144
- popup.close()
145
- return true
146
- },
147
- closed: false
148
- }
149
- document.addEventListener('overlay-closed', function listener(e) {
150
- if (e.detail !== name) return
151
- document.removeEventListener('overlay-closed', listener)
152
- handle.closed = true
153
- handle.onclosed && handle.onclosed()
154
- })
155
- return handle
156
- })
157
-
158
132
  /* Notices the product raised (`notify`), as the snack bar would show them. */
159
133
  const notices = []
160
134
  document.addEventListener('notify', e => notices.push({ level: e.detail.level, message: e.detail.message }))
@@ -162,8 +136,20 @@ document.addEventListener('notify', e => notices.push({ level: e.detail.level, m
162
136
  /*
163
137
  The shell's router, for what the product navigates to: the store's route follows the address, and the modeller page
164
138
  is told the figure the address names. Each navigation is kept, with whether the detail preview was still shown then.
139
+ Like the shell's own router (@operato/shell router.js), it follows the entries the address controller commits;
140
+ `navigate` sends no `popstate`. The addresses the shell itself moves to below (`goTo`, `open`) are not the product's.
165
141
  */
166
142
  const navigations = []
143
+ let shellMoving = false
144
+ /** The shell's own move to an address: routed by the caller, not kept as the product's navigation. */
145
+ function shellMoves(move) {
146
+ shellMoving = true
147
+ try {
148
+ move()
149
+ } finally {
150
+ shellMoving = false
151
+ }
152
+ }
167
153
  const detailShown = () => !!store.getState().layout?.viewparts?.['figure-detail']?.show
168
154
  function route() {
169
155
  const [, page = '', resourceId] = location.pathname.match(/^\/([^/]*)\/?([^/]*)/) ?? []
@@ -171,10 +157,11 @@ function route() {
171
157
  const element = page === 'figure-modeller' ? document.querySelector('figure-modeller-page') : null
172
158
  if (element) element.lifecycle = { ...(element.lifecycle ?? {}), resourceId: resourceId || undefined }
173
159
  }
174
- window.addEventListener('popstate', () => {
160
+ addressController().subscribe(() => {
161
+ if (shellMoving) return
175
162
  navigations.push({ path: location.pathname, entries: history.length, detailShown: detailShown() })
176
163
  route()
177
- })
164
+ }, false)
178
165
 
179
166
  /* The aside bar's part: the detail viewpart drawn while the store shows it. */
180
167
  const aside = document.createElement('div')
@@ -227,7 +214,7 @@ window.product = {
227
214
  route: () => ({ page: store.getState().route?.page, resourceId: store.getState().route?.resourceId }),
228
215
  /** The shell moved to a page (an address the product did not navigate to itself). */
229
216
  goTo(path) {
230
- history.pushState({}, '', path)
217
+ shellMoves(() => history.pushState({}, '', path))
231
218
  route()
232
219
  },
233
220
  /** A row on the server, as a figure saved earlier. */
@@ -250,7 +237,7 @@ window.product = {
250
237
  shell.replaceChildren(element)
251
238
  document.body.replaceChildren(shell)
252
239
  }
253
- history.replaceState({}, '', resourceId ? `/figure-modeller/${resourceId}` : '/figure-modeller')
240
+ shellMoves(() => history.replaceState({}, '', resourceId ? `/figure-modeller/${resourceId}` : '/figure-modeller'))
254
241
  store.dispatch({ type: 'UPDATE_PAGE', page: 'figure-modeller', resourceId, params: {} })
255
242
  element.lifecycle = { ...(element.lifecycle ?? {}), resourceId }
256
243
  element.active = true
@@ -99,7 +99,7 @@ async function tab(name) {
99
99
  await read(() => document.querySelector('figure-editor').updateComplete)
100
100
  }
101
101
 
102
- describe('the side: six tabs, the view and the history from the bar', () => {
102
+ describe('the side: six tabs, the view and the releases from the bar', () => {
103
103
  before(() => mount('rollers'))
104
104
 
105
105
  test('properties, responsive, motion, capability, data and the verdict -- the six tabs, in that order (user 2026-10-17)', async () => {
@@ -119,13 +119,31 @@ describe('the side: six tabs, the view and the history from the bar', () => {
119
119
  await until(() => document.querySelector('figure-editor').shadowRoot.querySelector('div[bar-overlay-panel=view]').getBoundingClientRect().height === 0, [], 'closed by Esc')
120
120
  })
121
121
 
122
- test('the history opens from the bar beside save, and the view closes for it', async () => {
122
+ test('the releases open from 「발행」 beside save, the one way to them, and the view closes for them', async () => {
123
+ assert.equal(await read(() => document.querySelectorAll('figure-editor')[0].shadowRoot.querySelectorAll('button[bar-overlay=history]').length), 0, 'no history button beside it')
123
124
  await press('ox-canvas-bar button[bar-overlay=view]', 'the view button')
124
- await press('ox-canvas-bar button[bar-overlay=history]', 'the history button')
125
+ await press('ox-canvas-bar button[publish]', 'the release button')
125
126
  const open = await read(() => ['view', 'history'].map(w => document.querySelector('figure-editor').shadowRoot.querySelector(`div[bar-overlay-panel=${w}]`).getBoundingClientRect().height > 0))
126
127
  assert.deepEqual(open, [false, true], 'one at a time')
127
128
  assert.equal(await read(() => !!document.querySelector('figure-editor').shadowRoot.querySelector('div[bar-overlay-panel=history] slot[name=history]')), true, 'the page\'s history goes in it')
128
- await press('ox-canvas-bar button[bar-overlay=history]', 'the history button')
129
+ await press('ox-canvas-bar button[publish]', 'the release button')
130
+ assert.equal(await read(() => document.querySelector('figure-editor').shadowRoot.querySelector('div[bar-overlay-panel=history]').getBoundingClientRect().height > 0), false, 'a second press closes it')
131
+ })
132
+
133
+ /* User 2026-10-18: 「발행버튼도 저장버튼도 라운드 버튼으로 해주고, 버튼 안에 적절한 아이콘을 추가해주면 혼동되지 않을 것」. */
134
+ test('save and release: one round shape, one height, an icon inside each', async () => {
135
+ const look = await read(() => {
136
+ const bar = document.querySelector('figure-editor').shadowRoot
137
+ return ['save', 'publish'].map(k => {
138
+ const b = bar.querySelector(`ox-canvas-bar button[${k}]`)
139
+ const r = b.getBoundingClientRect()
140
+ return { height: Math.round(r.height), round: parseFloat(getComputedStyle(b).borderTopLeftRadius) >= r.height / 2, icon: b.querySelector('md-icon')?.textContent.trim() }
141
+ })
142
+ })
143
+ assert.deepEqual(look, [
144
+ { height: 32, round: true, icon: 'save' },
145
+ { height: 32, round: true, icon: 'publish' }
146
+ ])
129
147
  })
130
148
  })
131
149
 
@@ -150,7 +168,7 @@ describe('a part\'s groups in one order, whatever its state', () => {
150
168
  await tab('properties')
151
169
  })
152
170
 
153
- test('a fastened part\'s place is one line and 「바꾸기」: no locked cells, no drawing, until it is changed', async () => {
171
+ test('a fastened part\'s place is one line and 「바꾸기」: no locked cells, no drawing; 「바꾸기」 opens the window, not the panel', async () => {
154
172
  await pick('mark')
155
173
  await tab('properties')
156
174
  const place = () =>
@@ -166,8 +184,11 @@ describe('a part\'s groups in one order, whatever its state', () => {
166
184
  })
167
185
  assert.deepEqual(await place(), { sentence: true, change: true, drawing: false, cells: false })
168
186
  await press('button[change-fastening]', '「바꾸기」')
169
- await until(() => !!document.querySelector('figure-editor').shadowRoot.querySelector('div[panel]:not([off]) figure-links').shadowRoot.querySelector('figure-fasten-drawing'), [], 'the drawing')
170
- /* Another part chosen and back: one line again. */
187
+ await until(() => !!document.querySelector('figure-fasten-dialog'), [], 'the window')
188
+ assert.deepEqual(await place(), { sentence: true, change: true, drawing: false, cells: false }, 'the panel still says the result')
189
+ /* Closed with nothing written; another part chosen and back: one line. */
190
+ await page().keyboard.press('Escape')
191
+ await until(() => !document.querySelector('figure-fasten-dialog'), [], 'the window closed')
171
192
  await pick('bed')
172
193
  await pick('mark')
173
194
  assert.equal((await place()).change, true, 'said in one line again')
@@ -82,10 +82,14 @@ const tabOn = () => read(() => screenCheck.editor().shadowRoot.querySelector('bu
82
82
  const pressBar = action => press(action, a => screenCheck.editor().shadowRoot.querySelector('ox-canvas-bar').shadowRoot.querySelector(`button[data-action=${a}]`), action)
83
83
  const pressTab = name => press(name, name => screenCheck.editor().shadowRoot.querySelector(`button[tab][aria-label="${name}"]`), name)
84
84
  const TAB = name => `figure.label.tab-short-${name}`
85
- /** The view settings or the release history, opened from the bar over the work (they were tabs once); left open if open. */
85
+ /*
86
+ The view settings or the releases, opened from the bar over the work (they were tabs once); left open if open. The
87
+ releases open from 「발행」, the bar's one way to them (user 2026-10-18).
88
+ */
89
+ const OVERLAY_BUTTON = { view: 'button[bar-overlay=view]', history: 'button[publish]' }
86
90
  async function openOverlay(which) {
87
- if (await read(w => screenCheck.editor().shadowRoot.querySelector(`ox-canvas-bar button[bar-overlay=${w}]`)?.getAttribute('aria-pressed') === 'true', which)) return
88
- await press(`the ${which} button`, w => screenCheck.editor().shadowRoot.querySelector(`ox-canvas-bar button[bar-overlay=${w}]`), which)
91
+ if (await read(b => screenCheck.editor().shadowRoot.querySelector(`ox-canvas-bar ${b}`)?.getAttribute('aria-pressed') === 'true', OVERLAY_BUTTON[which])) return
92
+ await press(`the ${which} button`, b => screenCheck.editor().shadowRoot.querySelector(`ox-canvas-bar ${b}`), OVERLAY_BUTTON[which])
89
93
  await until(`${which} open`, w => screenCheck.editor().shadowRoot.querySelector(`div[bar-overlay-panel=${w}]`)?.getBoundingClientRect().height > 0, which)
90
94
  }
91
95
  /** A row of the parts list, by the part's name. */
@@ -870,39 +874,40 @@ describe('the side panel is found as it was left, per part', () => {
870
874
  })
871
875
  })
872
876
 
873
- describe('the fastening form says whether it changes the fastening the part has or fastens it anew', () => {
874
- const preview = () => read(() => screenCheck.deep('[preview-words]').length)
875
- const fastenButton = () => read(() => screenCheck.deep('button[act]').filter(b => b.textContent.trim().endsWith('figure.button.fasten')).length)
876
- const target = () => element('the target select', () => screenCheck.deep('select').find(s => s.previousElementSibling?.textContent?.trim() === 'figure.label.target-part'))
877
+ describe('the fastening window says whether it changes the fastening the part has or fastens it anew', () => {
878
+ const applyWords = () => read(() => screenCheck.deep('[apply]').map(b => b.textContent.trim()))
879
+ /* A chip of the window's list, by its part. */
880
+ const pressTarget = part => press(`${part} in the list`, part => screenCheck.deep('md-filter-chip').find(o => o.getAttribute('data-value') === part), part)
877
881
 
878
- test('a part fastened to nothing: 「새로 붙이기」 said, the sentence and what 붙이기 would do shown once a target is chosen, and nothing done until 붙이기', async () => {
882
+ test('a part fastened to nothing: 「다른 부품에 붙이기」 opens the window; the sentence and what 붙이기 would do once a target is chosen; nothing done until 붙이기', async () => {
879
883
  await mount('door')
880
884
  await pressRow('leaf')
881
885
  await until('leaf chosen', () => screenCheck.editor().selected === 'leaf')
882
886
  await press('start fastening', () => screenCheck.deep('button').find(b => b.textContent.trim().endsWith('figure.button.fasten-start')))
883
- await until('the form', () => screenCheck.deep('p[fasten-mode]').length === 1)
887
+ await until('the window', () => screenCheck.deep('figure-fasten-dialog').length === 1)
884
888
  const before = await revision()
885
- await (await target()).select('frame')
886
- await until('the sentence', () => screenCheck.deep('p[fasten-sentence]').length === 1)
889
+ await pressTarget('frame')
890
+ await until('the sentence', () => screenCheck.deep('p[fasten-sentence]').some(p => !p.hasAttribute('hint')))
887
891
  await until('what 붙이기 would do', () => screenCheck.deep('[preview-words]').length === 1)
888
- assert.equal(await fastenButton(), 1, '붙이기 for a new one')
892
+ assert.deepEqual((await applyWords()).map(w => w.endsWith('figure.button.fasten')), [true], '붙이기 for a new one')
889
893
  assert.equal(await revision(), before, 'nothing done until it is pressed')
890
894
  })
891
895
 
892
- test('a part already fastened: said in one line with 「바꾸기」; opened, no 「새로 붙이기」, no 붙이기 -- another target is taken at once, in one step', async () => {
896
+ test('a part already fastened: one line with 「바꾸기」; opened, its button says 적용 -- another target tried, nothing written until 적용, then one step', async () => {
893
897
  await mount('arm')
894
898
  await pressRow('forearm')
895
899
  await until('forearm chosen', () => screenCheck.editor().selected === 'forearm')
896
900
  await until('the sentence', () => screenCheck.deep('p[fasten-sentence]').length === 1)
897
901
  assert.equal(await read(() => screenCheck.deep('figure-fasten-drawing').length + screenCheck.deep('select').filter(s => s.closest('section[fastening]')).length), 0, 'one line: no drawing, no target to choose')
898
902
  await press('change the fastening', () => screenCheck.deep('button[change-fastening]')[0])
899
- await until('the drawing', () => screenCheck.deep('figure-fasten-drawing').length === 1)
900
- assert.equal(await read(() => screenCheck.deep('p[fasten-mode]').length), 0, 'the one it has is not said to be new')
901
- assert.equal(await fastenButton(), 0, 'no 붙이기')
903
+ await until('the window and its drawings', () => screenCheck.deep('figure-fasten-drawing').length === 2)
904
+ assert.deepEqual((await applyWords()).map(w => w.endsWith('figure.button.apply')), [true], '적용, not 붙이기')
902
905
  await until('the fastening seen before it is done: its face lit on the canvas', () => !!screenCheck.deep('figure-canvas')[0].highlight)
903
- assert.equal(await preview(), 0, 'no words for a command no button sends')
904
906
  const before = await revision()
905
- await (await target()).select('upper-arm')
907
+ await pressTarget('upper-arm')
908
+ await until('what 적용 would do', () => screenCheck.deep('[preview-words]').length === 1)
909
+ assert.equal(await revision(), before, 'nothing written while trying')
910
+ await press('apply', () => screenCheck.deep('[apply]')[0])
906
911
  await until('one step', was => screenCheck.editor().session.read().revision === was + 1, before)
907
912
  assert.equal(await read(() => JSON.parse(screenCheck.editor().session.source()).joints.find(j => j.body1.part === 'forearm').body0.part), 'upper-arm')
908
913
  })
@@ -982,11 +987,11 @@ describe('the modeller page: the editor with the page\'s figure panel, verdict a
982
987
  /* First 15 minutes (2026-10-16): release was refused for a volume never declared, said only at the foot of the verdict tab. */
983
988
  test('what blocks release is said beside 「발행」, with the way out at hand there', async () => {
984
989
  const answer = blocked => read(blocked => product.answer('inspectFigureDraft', () => ({ __typename: 'FigureInspection', blocked, findings: blocked ? [alone.finding('occupancy-undeclared')] : [] })), blocked)
985
- const bar = () => read(() => [...screenCheck.editor().shadowRoot.querySelectorAll('ox-canvas-bar > button[slot=commit]')].map(b => (b.hasAttribute('bar-overlay') ? 'history' : b.hasAttribute('release-blocked') ? 'blocked' : b.hasAttribute('publish') ? 'publish' : 'save')))
990
+ const bar = () => read(() => [...screenCheck.editor().shadowRoot.querySelectorAll('ox-canvas-bar > button[slot=commit]')].map(b => (b.hasAttribute('bar-overlay') ? 'overlay' : b.hasAttribute('release-blocked') ? 'blocked' : b.hasAttribute('publish') ? 'publish' : 'save')))
986
991
  await answer(true)
987
992
  await open('fig-block', 'two', 'probe-block')
988
993
  await until('said beside 발행', () => !!screenCheck.editor().shadowRoot.querySelector('ox-canvas-bar button[release-blocked]'))
989
- assert.deepEqual(await bar(), ['history', 'blocked', 'publish', 'save'])
994
+ assert.deepEqual(await bar(), ['blocked', 'publish', 'save'])
990
995
  await press('what blocks release', () => screenCheck.editor().shadowRoot.querySelector('ox-canvas-bar button[release-blocked]'))
991
996
  await until('the list under the bar', () => !!screenCheck.editor().shadowRoot.querySelector('div[release-blockers] li[finding]'))
992
997
  assert.equal(await read(() => !!screenCheck.editor().session.revisionNow().asset.occupancy), false)
@@ -994,7 +999,7 @@ describe('the modeller page: the editor with the page\'s figure panel, verdict a
994
999
  await press('fit to the parts', () => screenCheck.editor().shadowRoot.querySelector('div[release-blockers] button[act]'))
995
1000
  await until('the space declared, in one step', () => !!screenCheck.editor().session.revisionNow().asset.occupancy)
996
1001
  await until('nothing blocks: the word is gone', () => !screenCheck.editor().shadowRoot.querySelector('ox-canvas-bar button[release-blocked]'))
997
- assert.deepEqual(await bar(), ['history', 'publish', 'save'])
1002
+ assert.deepEqual(await bar(), ['publish', 'save'])
998
1003
  await read(() => product.answer('inspectFigureDraft'))
999
1004
  await saved()
1000
1005
  await open('fig-a')
@@ -1055,7 +1060,7 @@ describe('the modeller page: the editor with the page\'s figure panel, verdict a
1055
1060
  assert.deepEqual(first, { heading: 'figure.label.group-figure', assigned: true, order: [true, true] })
1056
1061
  })
1057
1062
 
1058
- test('the verdict tab shows the page\'s verdict, and the bar\'s history button its release history', async () => {
1063
+ test('the verdict tab shows the page\'s verdict, and the bar\'s 「발행」 the releases', async () => {
1059
1064
  const assigned = within =>
1060
1065
  read(w => (w ? screenCheck.editor().shadowRoot.querySelector(w) : screenCheck.shownPanel()).querySelector('slot')?.assignedElements().map(e => ({ tag: e.localName, height: Math.round(e.getBoundingClientRect().height) })), within)
1061
1066
  await pressTab(TAB('verdict'))
@@ -1069,7 +1074,7 @@ describe('the modeller page: the editor with the page\'s figure panel, verdict a
1069
1074
  assert.equal(shown[0].tag, 'figure-history-panel')
1070
1075
  assert.ok(shown[0].height > 0, 'figure-history-panel is shown')
1071
1076
  /* Closed again: it lies over the side, and the checks after this press there. */
1072
- await press('the history button', () => screenCheck.editor().shadowRoot.querySelector('ox-canvas-bar button[bar-overlay=history]'))
1077
+ await press('the release button', () => screenCheck.editor().shadowRoot.querySelector('ox-canvas-bar button[publish]'))
1073
1078
  })
1074
1079
 
1075
1080
  test('the verdict tab\'s count is the report\'s, measured by the verdict', async () => {
@@ -5,39 +5,8 @@
5
5
  */
6
6
  import './more-open.js'
7
7
  import './modeller-page.js'
8
- import { html, render } from 'lit'
9
- import { setOpenPopupImplementation } from '@operato/popup'
10
8
  import { addFigureBox, addFigurePrimitive, applyFigureAuthoring, createFigureAsset, serializeFigureAsset } from '@hatiolab/figure-model'
11
9
 
12
- /*
13
- * Popups drawn as the layout draws them -- an overlay of their own in the body, the result given when it leaves -- for
14
- * the section editor's close question: the page has no layout to append it to.
15
- */
16
- let popups = 0
17
- setOpenPopupImplementation((template, options = {}) => {
18
- const name = `$popup-${popups++}`
19
- const fragment = document.createDocumentFragment()
20
- render(html`<ox-floating-overlay id=${name} name=${name} .backdrop=${!!options.backdrop} .title=${options.title || ''} .size=${options.size || 'small'} .historical=${false}>${template}</ox-floating-overlay>`, fragment)
21
- document.body.appendChild(fragment)
22
- const handle = {
23
- name,
24
- close: () => {
25
- const popup = document.getElementById(name)
26
- if (!popup) return false
27
- popup.close()
28
- return true
29
- },
30
- closed: false
31
- }
32
- document.addEventListener('overlay-closed', function listener(e) {
33
- if (e.detail !== name) return
34
- document.removeEventListener('overlay-closed', listener)
35
- handle.closed = true
36
- handle.onclosed && handle.onclosed()
37
- })
38
- return handle
39
- })
40
-
41
10
  const PALETTE = { 'palette.dark': '#2b2b2b', 'palette.neutral': '#9a9a9a', 'palette.primary': '#477b9e', 'palette.accent': '#f79c40', 'palette.light': '#dddddd' }
42
11
 
43
12
  function figure(name) {
@@ -203,21 +172,24 @@ Object.assign(window.probe, {
203
172
  ends: Object.fromEntries([...root.querySelectorAll('g[end]')].map(g => [g.getAttribute('end'), { text: words(g.querySelector('text')), typed: g.querySelector('text').hasAttribute('typed') }]))
204
173
  }
205
174
  },
206
- /** The fastening drawing as a person sees it: the ways offered and the one on, the faces of the target. */
175
+ /** The fastening window's drawings as a person sees them: the ways offered and the one on (none drawn when there is one way). */
207
176
  fastenDrawing() {
208
- const host = deep('figure-fasten-drawing')[0]
209
- if (!host) return null
210
- const root = host.shadowRoot
177
+ const hosts = deep('figure-fasten-drawing')
178
+ if (!hosts.length) return null
179
+ const how = hosts.find(h => h.show === 'how')?.shadowRoot
211
180
  return {
212
- modes: [...root.querySelectorAll('button[mode]')].map(b => b.getAttribute('mode')),
213
- on: root.querySelector('button[mode][on]')?.getAttribute('mode') ?? null
181
+ modes: how ? [...how.querySelectorAll('button[mode]')].map(b => b.getAttribute('mode')) : [],
182
+ on: how?.querySelector('button[mode][on]')?.getAttribute('mode') ?? null
214
183
  }
215
184
  },
216
- /** The labels before the fastening form's lists, and what each holds. */
185
+ /** The labels before the fastening window's lists, and the questions it asks. */
217
186
  fastenForm() {
218
- return deep('select')
219
- .filter(s => s.previousElementSibling)
220
- .map(s => ({ label: words(s.previousElementSibling), value: s.value, options: [...s.options].map(o => ({ value: o.value, text: words(o) })) }))
187
+ const dialog = deep('figure-fasten-dialog')[0]
188
+ if (!dialog) return []
189
+ return [
190
+ ...[...dialog.shadowRoot.querySelectorAll('h4')].map(h => ({ label: words(h) })),
191
+ ...deep('select', dialog.shadowRoot).filter(s => s.previousElementSibling).map(s => ({ label: words(s.previousElementSibling), value: s.value }))
192
+ ]
221
193
  },
222
194
  /** The page point of one of a part's buttons in the parts list (`remove-part`, ...). */
223
195
  partOp(id, op) {
@@ -521,40 +521,32 @@ describe('the inside\'s drawing in the 단면 group', () => {
521
521
  })
522
522
  })
523
523
 
524
- describe('the fastening drawing in the fastening form', () => {
524
+ describe('the fastening drawing in the fastening window', () => {
525
525
  const labels = () => read(() => probe.fastenForm().map(s => s.label))
526
- const target = () => read(() => probe.fastenForm().find(s => s.label === 'figure.label.target-part'))
527
- /* A fastening the part has is said in one line; 「바꾸기」 opens the drawing (chief architect 2026-10-16). */
528
- const change = () => press('button[change-fastening]')
526
+ const targets = () => read(() => probe.fastenTargets())
527
+ /* A fastening the part has is said in one line; 「바꾸기」 opens the window on it (user 2026-10-17). */
528
+ const change = async () => {
529
+ await press('button[change-fastening]')
530
+ await until(() => !!probe.fastenTargets(), 'the fastening window')
531
+ }
529
532
 
530
- test('the part\'s own face and the facing are not asked: the target, its face and how are drawn', async () => {
533
+ test('the part\'s own face and the facing are not asked: what, which face and how, the face and the way drawn', async () => {
531
534
  await mount('arm')
532
535
  await pick('forearm')
533
536
  await change()
534
537
  const asked = await labels()
535
- assert.ok(asked.includes('figure.label.target-part'))
538
+ assert.deepEqual(asked.slice(0, 3), ['figure.label.fasten-what', 'figure.label.fasten-face-on-part', 'figure.label.fasten-how'])
536
539
  assert.ok(!asked.includes('figure.label.my-face') && !asked.includes('figure.label.facing'), `asked: ${asked}`)
537
540
  assert.ok(await read(() => !!probe.fastenDrawing()), 'the drawing in their place')
538
541
  })
539
542
 
540
- test('a fastened part: a face of the target pressed is one step', async () => {
543
+ test('a fastened part: a face of the target pressed, flush pressed, the footprint dragged -- tried, nothing written', async () => {
541
544
  const start = await state()
542
545
  const was = await read(() => probe.fastening('forearm'))
543
546
  const face = was.body0.face === 'front' ? 'right' : 'front'
544
547
  await press(`[face="${face}"]`, { within: 'figure-fasten-drawing' })
545
- await until(r => probe.state().revision === r + 1, 'one step', start.revision)
546
- assert.equal((await read(() => probe.fastening('forearm'))).body0.face, face)
547
- })
548
-
549
- test('a fastened part: flush pressed is one step, and the drawing shows it', async () => {
550
- const start = await state()
551
548
  await press('button[mode="flush"]', { within: 'figure-fasten-drawing' })
552
- await until(r => probe.state().revision === r + 1, 'one step', start.revision)
553
- assert.equal(await read(() => probe.fastenMode()), 'flush')
554
- })
555
-
556
- test('a fastened part: the footprint dragged on the face and let go is one step', async () => {
557
- const start = await state()
549
+ await until(() => probe.fastenMode() === 'flush', 'the drawing shows flush')
558
550
  const foot = await element('g[footprint] rect', { within: 'figure-fasten-drawing' })
559
551
  const at = await foot.evaluate(e => {
560
552
  e.scrollIntoView({ block: 'center' })
@@ -565,25 +557,46 @@ describe('the fastening drawing in the fastening form', () => {
565
557
  await page().mouse.down()
566
558
  for (let i = 1; i <= 5; i++) await page().mouse.move(at.x + 6 * i, at.y)
567
559
  await page().mouse.up()
560
+ await sleep(300)
561
+ assert.equal((await state()).revision, start.revision, 'nothing written while trying')
562
+ assert.deepEqual(await read(() => probe.fastening('forearm')), was)
563
+ })
564
+
565
+ test('a fastened part: 적용 writes all of it as one step', async () => {
566
+ const start = await state()
567
+ const was = await read(() => probe.fastening('forearm'))
568
+ const face = was.body0.face === 'front' ? 'right' : 'front'
569
+ await press('[apply]', { within: 'figure-fasten-dialog' })
568
570
  await until(r => probe.state().revision === r + 1, 'one step', start.revision)
569
571
  await sleep(300)
570
572
  assert.equal((await state()).revision, start.revision + 1, 'and only one')
573
+ const now = await read(() => probe.fastening('forearm'))
574
+ assert.deepEqual([now.body0.face, now.origin?.facing], [face, 'flush'])
571
575
  })
572
576
 
573
577
  test('fastened to the plane: the plane is named by the placement, chosen, and set on only', async () => {
574
578
  await mount('rollers')
575
579
  await pick('bed')
576
580
  await change()
577
- const list = await target()
578
- assert.equal(list.value, '__plane')
579
- assert.equal(list.options.find(o => o.value === '__plane').text, 'figure.label.resize-place-floor')
581
+ const list = await targets()
582
+ const plane = list.find(t => t.target === '__plane')
583
+ assert.deepEqual([plane.selected, plane.text], [true, 'figure.label.resize-place-floor'])
584
+ assert.equal((await labels())[1], 'figure.label.fasten-face-on-plane', 'the face asked on the floor, not of a part')
580
585
  assert.deepEqual((await read(() => probe.fastenDrawing())).modes, [], 'one way only: no choice offered')
581
586
  })
582
587
 
588
+ /* The window is a popup over the work: closed, nothing written, before another part is picked. */
589
+ const cancel = async () => {
590
+ await press('[cancel]', { within: 'figure-fasten-dialog' })
591
+ await until(() => !probe.fastenTargets(), 'the window closed')
592
+ }
593
+
583
594
  test('a part that turns in place may have its centre on the face; a part that does not, not', async () => {
595
+ await cancel()
584
596
  await pick('roller')
585
597
  await change()
586
598
  assert.deepEqual((await read(() => probe.fastenDrawing())).modes, ['meet', 'flush', 'centre'])
599
+ await cancel()
587
600
  await pick('mark')
588
601
  await change()
589
602
  assert.deepEqual((await read(() => probe.fastenDrawing())).modes, ['meet', 'flush'])
@@ -592,19 +605,15 @@ describe('the fastening drawing in the fastening form', () => {
592
605
  test('a new fastening waits for 붙이기; the part\'s own face follows the target\'s face and how', async () => {
593
606
  await mount('door')
594
607
  await pick('leaf')
595
- await press('button[act]', { has: 'figure.button.fasten-start' })
596
- await until(() => probe.fastenForm().some(s => s.label === 'figure.label.target-part'), 'the target asked')
597
- const targetSelect = await page().evaluateHandle(() => {
598
- const deep = (root = document) => [...root.querySelectorAll('select')].concat(...[...root.querySelectorAll('*')].filter(e => e.shadowRoot).map(e => deep(e.shadowRoot)))
599
- return deep().find(s => s.previousElementSibling?.textContent?.trim() === 'figure.label.target-part') ?? null
600
- })
601
- await targetSelect.select('frame')
608
+ await press('button[fasten-start]')
609
+ await until(() => !!probe.fastenTargets(), 'the window')
610
+ await press('md-filter-chip[data-value="frame"]', { within: 'figure-fasten-dialog' })
602
611
  await until(() => !!probe.fastenDrawing(), 'the fastening drawing')
603
612
  const start = await state()
604
613
  await press('[face="front"]', { within: 'figure-fasten-drawing' })
605
614
  await sleep(300)
606
615
  assert.equal((await state()).revision, start.revision, 'nothing sent before 붙이기')
607
- await press('button[act]', { text: 'linkfigure.button.fasten' })
616
+ await press('[apply]', { within: 'figure-fasten-dialog', has: 'figure.button.fasten' })
608
617
  await until(r => probe.state().revision === r + 1, 'one step', start.revision)
609
618
  const joint = await read(() => probe.fastening('leaf'))
610
619
  assert.deepEqual(joint.body0, { part: 'frame', face: 'front' })
@@ -0,0 +1,77 @@
1
+ /*
2
+ * The fastening as it is chosen in the window (`fasten-form.ts`): opened on what the part has, aimed, changed by the
3
+ * drawing, and the one attach command worked out from it -- the command the window applies, its preview shows and the
4
+ * panel's 「정렬 고치기」 sends.
5
+ */
6
+ import { test } from 'node:test'
7
+ import assert from 'node:assert/strict'
8
+
9
+ import { addFigureBox, applyFigureAuthoring, createFigureAsset } from '@hatiolab/figure-model'
10
+ import type { FigureAsset } from '@hatiolab/figure-model'
11
+
12
+ import { aimFasten, fastenChanged, fastenDetailWordsOf, fastenFormOf, fastenPlanOf, fastenSentenceOf, fastenTargetsOf, freshFasten } from '../client/modeller/fasten-form.ts'
13
+ import { revisionOf } from './revision-of.ts'
14
+
15
+ const box = (asset: FigureAsset, id: string, [x, y, z]: number[], at = [0, 0, 0]) => addFigureBox(asset, { id, size: { x, y, z }, position: { x: at[0]!, y: at[1]!, z: at[2]! } })
16
+ const fasten = (asset: FigureAsset, part: string, to: string, more = {}) =>
17
+ applyFigureAuthoring(asset, { kind: 'attach', part, face: 'bottom', to: to === 'plane' ? { plane: 'mounting-plane' } : { part: to, face: 'top' }, ...more } as never)
18
+
19
+ /* A frame on the floor, a roller on the frame 300 mm off its centre, a loose lid beside them. */
20
+ function conveyor() {
21
+ let a = fasten(box(createFigureAsset('Conveyor'), 'frame', [2000, 100, 600], [0, 50, 0]), 'frame', 'plane')
22
+ a = fasten(box(a, 'roller', [60, 60, 560], [300, 130, 0]), 'roller', 'frame', { align: { x: { mm: 300 } } })
23
+ return revisionOf(box(a, 'lid', [100, 20, 100], [1500, 10, 0]))
24
+ }
25
+ const say = (key: string, params: Record<string, string> = {}) => `${key}(${Object.entries(params).map(([k, v]) => `${k}=${v}`).join(',')})`
26
+
27
+ test('opened on what the part has; a loose part opens on nothing chosen', () => {
28
+ const r = conveyor()
29
+ const own = fastenFormOf(r, 'roller')
30
+ assert.deepEqual([own.target, own.targetFace, own.toPlane, own.align.x, own.alignMm.x], ['frame', 'top', false, 'mm', 300])
31
+ assert.deepEqual(fastenFormOf(r, 'lid'), freshFasten())
32
+ assert.equal(fastenPlanOf(r, 'lid', fastenFormOf(r, 'lid')).action, undefined, 'nothing to apply until something is chosen')
33
+ })
34
+
35
+ test('the one it has, unchanged, comes to the attach that replaces it with the same place across the face', () => {
36
+ const r = conveyor()
37
+ const plan = fastenPlanOf(r, 'roller', fastenFormOf(r, 'roller'))
38
+ assert.deepEqual(plan.action, { kind: 'attach', part: 'roller', face: 'bottom', to: { part: 'frame', face: 'top' }, facing: 'meet', twist: 0, align: { x: { mm: 300 } }, replace: true })
39
+ assert.deepEqual(plan.face, { part: 'frame', face: 'top' }, 'the face it meets, lit while tried')
40
+ assert.equal(fastenSentenceOf('roller', fastenFormOf(r, 'roller'), plan, '바닥', say), 'figure.text.fasten-sentence-meet(part=roller,face=figure.label.face-bottom(),to=frame의 figure.label.face-top())')
41
+ })
42
+
43
+ test('aimed at a part, the loose lid keeps its place across the face; aimed at the plane, nothing is said of a part face', () => {
44
+ const r = conveyor()
45
+ const onFrame = aimFasten(r, 'lid', fastenFormOf(r, 'lid'), { part: 'frame' })
46
+ assert.deepEqual([onFrame.target, onFrame.toPlane, onFrame.align.x, onFrame.alignMm.x], ['frame', false, 'mm', 1500])
47
+ const plan = fastenPlanOf(r, 'lid', onFrame)
48
+ assert.equal((plan.action as { replace: boolean }).replace, false, 'a new fastening')
49
+ const onPlane = fastenPlanOf(r, 'lid', aimFasten(r, 'lid', onFrame, { plane: true }))
50
+ assert.deepEqual((onPlane.action as { to: unknown }).to, { plane: 'mounting-plane' })
51
+ assert.equal(onPlane.face, undefined)
52
+ })
53
+
54
+ test('the drawing\'s changes: another face aims again, the way, the gap, a place across the face', () => {
55
+ const r = conveyor()
56
+ let f = fastenFormOf(r, 'roller')
57
+ f = fastenChanged(r, 'roller', f, { mode: 'flush' })
58
+ assert.equal(f.facing, 'flush')
59
+ f = fastenChanged(r, 'roller', f, { gap: 40 })
60
+ assert.deepEqual([f.gap, f.gapKept], [40, undefined])
61
+ f = fastenChanged(r, 'roller', f, { axis: 'x', how: 'max' })
62
+ assert.equal(f.align.x, 'max')
63
+ assert.deepEqual(fastenDetailWordsOf(f, say), ['figure.text.fasten-summary-gap(mm=40)'])
64
+ const action = fastenPlanOf(r, 'roller', f).action as { facing: string; gap: number; align: unknown }
65
+ assert.deepEqual([action.facing, action.gap, action.align], ['flush', 40, { x: 'max' }])
66
+ f = fastenChanged(r, 'roller', f, { targetFace: 'front' })
67
+ assert.equal(fastenPlanOf(r, 'roller', f).targetFace, 'front')
68
+ })
69
+
70
+ test('the target list: every other part, the one hanging from this part listed as a loop', () => {
71
+ const r = conveyor()
72
+ assert.deepEqual(fastenTargetsOf(r, 'frame'), [
73
+ { part: 'roller', loops: true },
74
+ { part: 'lid', loops: false }
75
+ ])
76
+ assert.deepEqual(fastenTargetsOf(r, 'lid').map(t => t.part), ['frame', 'roller'])
77
+ })
@@ -87,6 +87,8 @@ test('the drawing\'s two names do not lie over each other (user 2026-10-16: 「r
87
87
  assert.deepEqual(thin.face, { x: 20, y: 55 })
88
88
  assert.equal(thin.part.inside, false)
89
89
  assert.ok(thin.part.y > 63 + 84 || thin.part.y < 63, 'outside the part')
90
+ /* As tall as the face, almost: under the part is the face's edge, and the name goes under that line (2026-10-18). */
91
+ assert.ok(thin.part.y - 11 >= face.y + face.h + 2, `clear of the face's edge: ${thin.part.y}`)
90
92
  /* A wide block: its name in it. */
91
93
  const wide = fastenLabelsOf(face, { x: 120, y: 80, w: 100, h: 40 }, 'lid', 180)
92
94
  assert.deepEqual(wide.part, { x: 170, y: 104, inside: true })
@@ -52,11 +52,14 @@ test('the start card and the figure panel choose the placement as the figure\'s
52
52
  })
53
53
 
54
54
  /* B.1 and the designer's items (2026-09-27): each target's panel is found as it was left; the fastening form says which it is. */
55
- test('the fastening form says whether it edits the current relation or fastens anew', () => {
56
- /* Anew is said, the one it has changed at once, 붙이기 only for a new one: the browser check's (test/browser/screen.check.mjs). Here, the words. */
55
+ test('the fastening window says by its button whether it changes the one the part has or fastens anew', () => {
56
+ /* 붙이기 for a new one, 적용 for the one it has, nothing more said: the browser check's (test/browser/screen.check.mjs). Here, the words. */
57
57
  const ko = JSON.parse(read('../translations/ko.json'))
58
- assert.equal(ko['figure.text.fasten-new'], '새로 붙이기')
59
- assert.equal(ko['figure.button.fasten-update'], undefined)
58
+ assert.equal(ko['figure.button.fasten'], '붙이기')
59
+ assert.equal(ko['figure.button.apply'], '적용')
60
+ assert.equal(ko['figure.text.fasten-new'], undefined)
61
+ /* The questions point at the result sentence's pieces: which part, which of its faces, how it is set there (user 2026-10-18). */
62
+ assert.deepEqual(['figure.label.fasten-what', 'figure.label.fasten-face-on-part', 'figure.label.fasten-face-on-plane', 'figure.label.fasten-how'].map(k => ko[k]), ['어느 부품에', '{part}의 어느 면에', '{plane}의 어디에', '어떻게 놓을지'])
60
63
  })
61
64
 
62
65
  test('a measure that follows something says what, where the row once said 「다른 규칙에 연결됨」 (user 2026-10-16)', () => {
@@ -530,7 +530,6 @@
530
530
  "figure.label.fastened-to": "fastened to",
531
531
  "figure.label.not-fastened": "not fastened",
532
532
  "figure.label.my-face": "my face",
533
- "figure.label.target-part": "target part",
534
533
  "figure.label.target-face": "target face",
535
534
  "figure.label.stands-on-plane": "stand on the mounting plane",
536
535
  "figure.label.facing-meet": "faces meet",
@@ -560,6 +559,10 @@
560
559
  "figure.text.preview-face-unknown": "This face cannot be shown on the canvas.",
561
560
  "figure.text.target-would-loop": "{part} — attached to {mine}, cannot be chosen",
562
561
  "figure.label.fasten-detail": "Details",
562
+ "figure.label.fasten-what": "Which part",
563
+ "figure.label.fasten-face-on-part": "Which face of {part}",
564
+ "figure.label.fasten-face-on-plane": "Where on the {plane}",
565
+ "figure.label.fasten-how": "How it sits",
563
566
  "figure.label.facing": "How faces meet",
564
567
  "figure.text.fasten-summary-gap": "gap {mm} mm",
565
568
  "figure.text.fasten-summary-gap-worked-out": "gap worked out by a formula",
@@ -877,10 +880,8 @@
877
880
  "figure.button.show-model-settings": "Show model settings",
878
881
  "figure.text.new-figure-name": "New figure",
879
882
  "figure.text.fasten-editing": "Editing the current attachment: its {face} on {target}",
880
- "figure.text.fasten-new": "Attach anew",
881
883
  "figure.text.fasten-start": "Attach this part to a face on another part so they keep their relationship when either changes.",
882
884
  "figure.button.fasten-start": "Attach to another part",
883
- "figure.label.target-part-placeholder": "Choose a part",
884
885
  "figure.text.fasten-target-first": "Choose the part to attach to first.",
885
886
  "figure.text.fasten-edit-on-plane": "This part is connected to the model's placement reference. Detach it before attaching it to another part.",
886
887
  "figure.button.align-to-floor": "set on the floor",