@things-factory/figure-ui 10.1.177 → 10.1.179

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 (91) hide show
  1. package/client/figure-released.ts +6 -0
  2. package/client/graphql/index.ts +101 -25
  3. package/client/graphql/store.ts +2 -1
  4. package/client/index.ts +1 -0
  5. package/client/modeller/editor.ts +10 -2
  6. package/client/modeller/figure-first-save.ts +23 -3
  7. package/client/modeller/figure-parts.ts +53 -23
  8. package/client/modeller/figure-properties.ts +41 -7
  9. package/client/modeller/first-save.ts +2 -0
  10. package/client/modeller/product-session.ts +5 -3
  11. package/client/modeller/property-sections.ts +45 -0
  12. package/client/pages/figure-list-page.ts +52 -10
  13. package/client/pages/figure-modeller-page.ts +15 -3
  14. package/client/setting-let/figure-namespace-setting-let.ts +113 -31
  15. package/client/types.ts +8 -0
  16. package/client/viewparts/figure-detail-dock.ts +124 -9
  17. package/client/viewparts/figure-thumb.ts +6 -3
  18. package/dist-client/figure-released.d.ts +3 -0
  19. package/dist-client/figure-released.js +5 -0
  20. package/dist-client/figure-released.js.map +1 -0
  21. package/dist-client/graphql/index.d.ts +25 -13
  22. package/dist-client/graphql/index.js +85 -16
  23. package/dist-client/graphql/index.js.map +1 -1
  24. package/dist-client/graphql/store.js +2 -1
  25. package/dist-client/graphql/store.js.map +1 -1
  26. package/dist-client/index.d.ts +1 -0
  27. package/dist-client/index.js +1 -0
  28. package/dist-client/index.js.map +1 -1
  29. package/dist-client/modeller/editor.d.ts +2 -0
  30. package/dist-client/modeller/editor.js +13 -2
  31. package/dist-client/modeller/editor.js.map +1 -1
  32. package/dist-client/modeller/figure-first-save.d.ts +8 -0
  33. package/dist-client/modeller/figure-first-save.js +28 -3
  34. package/dist-client/modeller/figure-first-save.js.map +1 -1
  35. package/dist-client/modeller/figure-links.d.ts +1 -1
  36. package/dist-client/modeller/figure-parts.d.ts +5 -0
  37. package/dist-client/modeller/figure-parts.js +54 -22
  38. package/dist-client/modeller/figure-parts.js.map +1 -1
  39. package/dist-client/modeller/figure-properties.d.ts +4 -0
  40. package/dist-client/modeller/figure-properties.js +45 -7
  41. package/dist-client/modeller/figure-properties.js.map +1 -1
  42. package/dist-client/modeller/first-save.d.ts +2 -0
  43. package/dist-client/modeller/first-save.js.map +1 -1
  44. package/dist-client/modeller/product-session.d.ts +4 -1
  45. package/dist-client/modeller/product-session.js +3 -3
  46. package/dist-client/modeller/product-session.js.map +1 -1
  47. package/dist-client/modeller/property-sections.d.ts +26 -0
  48. package/dist-client/modeller/property-sections.js +17 -0
  49. package/dist-client/modeller/property-sections.js.map +1 -0
  50. package/dist-client/pages/figure-list-page.d.ts +4 -1
  51. package/dist-client/pages/figure-list-page.js +56 -10
  52. package/dist-client/pages/figure-list-page.js.map +1 -1
  53. package/dist-client/pages/figure-modeller-page.d.ts +2 -0
  54. package/dist-client/pages/figure-modeller-page.js +19 -3
  55. package/dist-client/pages/figure-modeller-page.js.map +1 -1
  56. package/dist-client/route.d.ts +1 -1
  57. package/dist-client/setting-let/figure-namespace-setting-let.d.ts +16 -5
  58. package/dist-client/setting-let/figure-namespace-setting-let.js +129 -31
  59. package/dist-client/setting-let/figure-namespace-setting-let.js.map +1 -1
  60. package/dist-client/tsconfig.tsbuildinfo +1 -1
  61. package/dist-client/types.d.ts +12 -0
  62. package/dist-client/types.js.map +1 -1
  63. package/dist-client/viewparts/figure-detail-dock.d.ts +33 -2
  64. package/dist-client/viewparts/figure-detail-dock.js +171 -11
  65. package/dist-client/viewparts/figure-detail-dock.js.map +1 -1
  66. package/dist-client/viewparts/figure-thumb.d.ts +2 -0
  67. package/dist-client/viewparts/figure-thumb.js +13 -3
  68. package/dist-client/viewparts/figure-thumb.js.map +1 -1
  69. package/package.json +8 -7
  70. package/test/browser/category-page.js +28 -0
  71. package/test/browser/category.check.mjs +54 -0
  72. package/test/browser/mesh-select.check.mjs +1 -1
  73. package/test/browser/namespace-page.js +75 -0
  74. package/test/browser/namespace.check.mjs +104 -0
  75. package/test/browser/parts-page.js +8 -2
  76. package/test/browser/places-mesh.check.mjs +30 -2
  77. package/test/browser/property-section-page.js +45 -0
  78. package/test/browser/property-section.check.mjs +48 -0
  79. package/test/browser/props.check.mjs +13 -2
  80. package/test/browser/public-page.js +39 -0
  81. package/test/browser/public.check.mjs +79 -0
  82. package/test/browser/retire-page.js +37 -0
  83. package/test/browser/retire.check.mjs +96 -0
  84. package/test/figure-released.test.ts +54 -0
  85. package/test/modeller-route.test.ts +2 -1
  86. package/translations/en.json +20 -5
  87. package/translations/es.json +20 -5
  88. package/translations/ja.json +20 -5
  89. package/translations/ko.json +20 -5
  90. package/translations/ms.json +20 -5
  91. package/translations/zh.json +20 -5
@@ -1 +1 @@
1
- {"version":3,"file":"figure-thumb.js","sourceRoot":"","sources":["../../client/viewparts/figure-thumb.ts"],"names":[],"mappings":";AAAA,OAAO,4BAA4B,CAAA;AAEnC,OAAO,EAAE,GAAG,EAAE,IAAI,EAAE,UAAU,EAAE,MAAM,KAAK,CAAA;AAC3C,OAAO,EAAE,aAAa,EAAE,QAAQ,EAAE,MAAM,mBAAmB,CAAA;AAE3D,OAAO,EAAE,OAAO,EAAE,QAAQ,EAAE,MAAM,eAAe,CAAA;AACjD,OAAO,sCAAsC,CAAA;AAC7C,OAAO,gEAAgE,CAAA;AACvE,OAAO,EAAE,WAAW,EAAE,MAAM,gBAAgB,CAAA;AAI5C;;;;;;;;;GASG;AACH,SAAS,YAAY,CAAC,MAAc;IAClC,IAAI,CAAC,MAAM,CAAC,kBAAkB,EAAE,CAAC;QAC/B,OAAO,SAAS,CAAA;IAClB,CAAC;IAED,MAAM,EAAE,WAAW,EAAE,GAAG,WAAW,CAAC,QAAQ,CAAC,QAAQ,CAAC,CAAA;IACtD,MAAM,KAAK,GAAG,IAAI,IAAI,CAAC,MAAM,CAAC,kBAAkB,CAAC,CAAC,OAAO,EAAE,CAAA;IAE3D,OAAO,GAAG,WAAW,qBAAqB,MAAM,CAAC,EAAE,MAAM,KAAK,EAAE,CAAA;AAClE,CAAC;AAED;;;;;;;;;;;;;;;;;;;GAmBG;AAEI,IAAM,WAAW,GAAjB,MAAM,WAAY,SAAQ,QAAQ,CAAC,OAAO,CAAC,CAAC,UAAU,CAAC;aACrD,WAAM,GAAG,GAAG,CAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8FlB,AA9FY,CA8FZ;IAID,MAAM;QACJ,MAAM,MAAM,GAAG,IAAI,CAAC,MAAM,CAAA;QAC1B,IAAI,CAAC,MAAM,EAAE,CAAC;YACZ,OAAO,IAAI,CAAA,EAAE,CAAA;QACf,CAAC;QAED,MAAM,GAAG,GAAG,YAAY,CAAC,MAAM,CAAC,CAAA;QAEhC,OAAO,IAAI,CAAA;;cAED,GAAG,IAAI,EAAE;cACT,MAAM,CAAC,IAAI,IAAI,EAAE;gBACf,MAAM,CAAC,KAAK,KAAK,UAAU,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,oBAAoB,CAAC;;iDAEjC,OAAO,CAAC,CAAC,CAAC,oCAAoC,CAAC;QACxF,MAAM,CAAC,IAAI,EAAE,MAAM;YACnB,CAAC,CAAC,IAAI,CAAA;cACA,MAAM,CAAC,IAAI,CAAC,GAAG,CACf,GAAG,CAAC,EAAE,CAAC,IAAI,CAAA;;0BAEC,OAAO,CAAC,CAAC,CAAC,8BAA8B,CAAC;2BACxC,CAAC,CAAQ,EAAE,EAAE;gBACpB,CAAC,CAAC,eAAe,EAAE,CAAA;gBACnB,IAAI,CAAC,aAAa,CAChB,IAAI,WAAW,CAAC,YAAY,EAAE,EAAE,MAAM,EAAE,EAAE,GAAG,EAAE,EAAE,OAAO,EAAE,IAAI,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAC,CAClF,CAAA;YACH,CAAC;;oBAEC,GAAG;;eAER,CACF;iBACI;YACT,CAAC,CAAC,EAAE;;;;gBAII,OAAO,CAAC,CAAC,CAAC,6BAA6B,CAAC;iBACvC,CAAC,CAAQ,EAAE,EAAE;YACpB,sCAAsC;YACtC,CAAC,CAAC,eAAe,EAAE,CAAA;YACnB,IAAI,CAAC,aAAa,CAAC,IAAI,WAAW,CAAC,QAAQ,CAAC,CAAC,CAAA;QAC/C,CAAC;;;;;KAKJ,CAAA;IACH,CAAC;;AAlD2B;IAA3B,QAAQ,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,CAAC;;2CAAgB;AAjGhC,WAAW;IADvB,aAAa,CAAC,cAAc,CAAC;GACjB,WAAW,CAoJvB","sourcesContent":["import '@material/web/icon/icon.js'\n\nimport { css, html, LitElement } from 'lit'\nimport { customElement, property } from 'lit/decorators.js'\n\nimport { i18next, localize } from '@operato/i18n'\nimport '@operato/data-grist/ox-card-thumb.js'\nimport '@things-factory/figure-viewer/dist-client/figure-thumb-mark.js'\nimport { getPathInfo } from '@operato/utils'\n\nimport type { Figure } from '../types.js'\n\n/**\n * 그림을 부르는 주소.\n *\n * **base64 를 목록 응답에 실어 오지 않는다.** data URL 은 브라우저가 캐시할 수 없어, 카탈로그를\n * 열 때마다 그림 전부가 다시 온다([확인함] 표본 14개 227KB). 주소로 부르면 캐시가 되고, 판을\n * 가리는 `v` 가 바뀐 것만 다시 받는다. 내주는 자리는 `figure-thumbnail-router` 다.\n *\n * 도메인 조각(`/domain/{subdomain}`)은 셸이 쓰는 것과 같은 함수로 읽는다 — 화면이 주소를\n * 손으로 짜맞추면 도메인이 바뀌는 배치에서 조용히 틀린다.\n */\nfunction thumbnailSrc(figure: Figure): string | undefined {\n if (!figure.thumbnailUpdatedAt) {\n return undefined\n }\n\n const { contextPath } = getPathInfo(location.pathname)\n const stamp = new Date(figure.thumbnailUpdatedAt).getTime()\n\n return `${contextPath}/figure-thumbnail/${figure.id}?v=${stamp}`\n}\n\n/**\n * 카드의 그림 칸. 그림 · 그림 없음 · 초안 표시는 모든 카드 카탈로그의 `ox-card-thumb`(@operato/data-grist)가\n * 그리고, 여기서는 그 위에 이 카탈로그의 것(태그 · 삭제)만 얹는다(ADR-0117 개정 2026-10-13).\n *\n * ## 왜 컴포넌트인가\n *\n * 카탈로그는 `ox-grist` 의 CARD 모드로 그린다. 격자에게 그림 칸은 컬럼 하나이고, 그 칸을 그리는\n * 것은 컬럼의 `record.renderer` 다. 그림 위에 얹히는 것들(초안 표시, 삭제)은 **그림 칸 안에**\n * 있어야 하므로 renderer 가 그려야 하는데, renderer 의 결과는 `ox-card-field` 의 shadow root\n * 안으로 들어간다 — 화면의 `static styles` 가 닿지 않고, 인라인 style 로는 `:hover` 를 쓸 수 없다.\n *\n * 그래서 그림 칸만 컴포넌트로 둔다. 자기 스타일을 갖고 있으니 hover 도 테마도 정상으로 돈다.\n * 격자로 옮기기 전 손으로 만들었던 카드의 위쪽 절반이 그대로 이 자리다.\n *\n * ## 삭제가 왜 여기 있나\n *\n * 격자의 gutter 줄에 두면 카드 아래에 단추 줄이 하나 생긴다. 감춰도 자리가 남고, 자리를 없애면\n * 눌릴 곳이 사라진다. 되돌릴 수 없는 조작은 **고르는 눈길에 걸리지 않아야** 하므로 그림 오른쪽\n * 위, 손이 갔을 때만 나오는 자리에 둔다.\n */\n@customElement('figure-thumb')\nexport class FigureThumb extends localize(i18next)(LitElement) {\n static styles = css`\n :host {\n display: block;\n width: 100%;\n height: 100%;\n }\n\n /*\n 태그 — **그림 위에 얹는다.**\n\n 카드 본문에 한 줄로 두었더니 태그가 있는 카드만 그만큼 높아져 격자가 들쭉날쭉해졌다.\n 태그는 도형마다 개수가 다르므로 본문에 두는 한 이 문제가 남는다. 그림 위는 이미 자리가\n 잡혀 있고(초안 표시·삭제), 얹어도 카드 높이가 변하지 않는다.\n\n 아래쪽에 둔다 — 위는 초안 표시와 삭제 단추가 쓴다.\n\n **투명한 알약이다.** 면을 꽉 채우면 그림 위에 흰 띠가 생겨 형상을 가린다 — 카탈로그에서\n 먼저 보아야 하는 것은 형상이다. 그래서 바탕은 거의 비우고, 읽히는 것은 뒤를 흐리는 것과\n 실낱 테두리가 맡는다. 흐림이 안 되는 환경에서도 테두리가 남아 글자가 형상에 묻히지 않는다.\n */\n div[tags] {\n position: absolute;\n right: 6px;\n bottom: 6px;\n left: 6px;\n display: flex;\n flex-wrap: wrap-reverse;\n justify-content: flex-end;\n gap: 3px;\n /* 태그가 많아도 그림을 덮지 않는다 — 넘치는 것은 위로 잘린다 */\n max-height: 44px;\n overflow: hidden;\n /* 알약 사이의 빈 자리는 그림이 받는다 — 거기서 누르면 저작면이 열린다 */\n pointer-events: none;\n }\n /*\n **알약은 누를 수 있다.** 누르면 그 태그로 목록을 거른다.\n\n 태그를 눈에 보이게 한 다음의 물음은 「그래서 이걸로 뭘 하나」다. 같은 태그가 붙은 것을\n 보려고 태그를 눈으로 읽어 검색창에 옮겨 적게 두면, 보여 준 뜻이 절반만 산다.\n\n 카드 자체가 저작면을 여는 자리이므로 알약에서 그 클릭을 멈춘다 — 안 멈추면 태그를 누른\n 사람이 편집기로 끌려간다.\n */\n div[tags] button {\n pointer-events: auto;\n cursor: pointer;\n padding: 0 7px;\n font: var(--label-font, inherit);\n font-size: 0.66rem;\n line-height: 1.7;\n color: var(--md-sys-color-on-surface-variant, var(--md-sys-color-on-surface));\n background-color: color-mix(in srgb, var(--md-sys-color-surface, #fff) 34%, transparent);\n border: 1px solid color-mix(in srgb, var(--md-sys-color-outline, #888) 42%, transparent);\n backdrop-filter: blur(4px);\n border-radius: 999em;\n white-space: nowrap;\n }\n div[tags] button:hover,\n div[tags] button:focus-visible {\n color: var(--md-sys-color-on-surface);\n background-color: color-mix(in srgb, var(--md-sys-color-surface, #fff) 72%, transparent);\n border-color: var(--md-sys-color-primary);\n outline: none;\n }\n\n button[remove] {\n position: absolute;\n top: 5px;\n right: 5px;\n display: flex;\n align-items: center;\n justify-content: center;\n padding: 3px;\n color: var(--md-sys-color-on-surface-variant, var(--md-sys-color-on-surface));\n /* 그림이 밝을 수도 어두울 수도 있다 — 옅은 면을 깔아 아이콘이 어디서나 읽히게 한다 */\n background-color: color-mix(in srgb, var(--md-sys-color-surface, #fff) 82%, transparent);\n border: none;\n border-radius: 4px;\n cursor: pointer;\n opacity: 0;\n transition: opacity 0.12s ease-out;\n }\n button[remove] md-icon {\n --md-icon-size: 18px;\n }\n :host(:hover) button[remove],\n button[remove]:focus-visible {\n opacity: 1;\n }\n button[remove]:hover {\n color: var(--md-sys-color-on-error, #fff);\n background-color: var(--md-sys-color-error);\n }\n `\n\n @property({ type: Object }) figure!: Figure\n\n render() {\n const figure = this.figure\n if (!figure) {\n return html``\n }\n\n const src = thumbnailSrc(figure)\n\n return html`\n <ox-card-thumb\n src=${src || ''}\n alt=${figure.name || ''}\n badge=${figure.state === 'released' ? '' : i18next.t('figure.label.draft')}\n >\n <figure-thumb-mark slot=\"empty\" .label=${i18next.t('figure.text.thumbnail-not-made-yet')}></figure-thumb-mark>\n ${figure.tags?.length\n ? html`<div tags>\n ${figure.tags.map(\n tag => html`\n <button\n title=${i18next.t('figure.text.find-by-this-tag')}\n @click=${(e: Event) => {\n e.stopPropagation()\n this.dispatchEvent(\n new CustomEvent('search-tag', { detail: { tag }, bubbles: true, composed: true })\n )\n }}\n >\n ${tag}\n </button>\n `\n )}\n </div>`\n : ''}\n\n <button\n remove\n title=${i18next.t('figure.button.delete-figure')}\n @click=${(e: Event) => {\n /* 카드 클릭(=저작면 열기)까지 올라가지 않게 여기서 멈춘다 */\n e.stopPropagation()\n this.dispatchEvent(new CustomEvent('remove'))\n }}\n >\n <md-icon>delete</md-icon>\n </button>\n </ox-card-thumb>\n `\n }\n}\n"]}
1
+ {"version":3,"file":"figure-thumb.js","sourceRoot":"","sources":["../../client/viewparts/figure-thumb.ts"],"names":[],"mappings":"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","sourcesContent":["import '@material/web/icon/icon.js'\n\nimport { css, html, LitElement } from 'lit'\nimport { customElement, property } from 'lit/decorators.js'\n\nimport { i18next, localize } from '@operato/i18n'\nimport '@operato/data-grist/ox-card-thumb.js'\nimport '@things-factory/figure-viewer/dist-client/figure-thumb-mark.js'\nimport { getPathInfo } from '@operato/utils'\n\nimport type { Figure } from '../types.js'\nimport { hasBeenReleased } from '../figure-released.js'\n\n/**\n * 그림을 부르는 주소.\n *\n * **base64 를 목록 응답에 실어 오지 않는다.** data URL 은 브라우저가 캐시할 수 없어, 카탈로그를\n * 열 때마다 그림 전부가 다시 온다([확인함] 표본 14개 227KB). 주소로 부르면 캐시가 되고, 판을\n * 가리는 `v` 가 바뀐 것만 다시 받는다. 내주는 자리는 `figure-thumbnail-router` 다.\n *\n * 도메인 조각(`/domain/{subdomain}`)은 셸이 쓰는 것과 같은 함수로 읽는다 — 화면이 주소를\n * 손으로 짜맞추면 도메인이 바뀌는 배치에서 조용히 틀린다.\n */\nfunction thumbnailSrc(figure: Figure): string | undefined {\n if (!figure.thumbnailUpdatedAt) {\n return undefined\n }\n\n const { contextPath } = getPathInfo(location.pathname)\n const stamp = new Date(figure.thumbnailUpdatedAt).getTime()\n\n return `${contextPath}/figure-thumbnail/${figure.id}?v=${stamp}`\n}\n\n/**\n * 카드의 그림 칸. 그림 · 그림 없음 · 초안 표시는 모든 카드 카탈로그의 `ox-card-thumb`(@operato/data-grist)가\n * 그리고, 여기서는 그 위에 이 카탈로그의 것(태그 · 삭제)만 얹는다(ADR-0117 개정 2026-10-13).\n *\n * ## 왜 컴포넌트인가\n *\n * 카탈로그는 `ox-grist` 의 CARD 모드로 그린다. 격자에게 그림 칸은 컬럼 하나이고, 그 칸을 그리는\n * 것은 컬럼의 `record.renderer` 다. 그림 위에 얹히는 것들(초안 표시, 삭제)은 **그림 칸 안에**\n * 있어야 하므로 renderer 가 그려야 하는데, renderer 의 결과는 `ox-card-field` 의 shadow root\n * 안으로 들어간다 — 화면의 `static styles` 가 닿지 않고, 인라인 style 로는 `:hover` 를 쓸 수 없다.\n *\n * 그래서 그림 칸만 컴포넌트로 둔다. 자기 스타일을 갖고 있으니 hover 도 테마도 정상으로 돈다.\n * 격자로 옮기기 전 손으로 만들었던 카드의 위쪽 절반이 그대로 이 자리다.\n *\n * ## 삭제가 왜 여기 있나\n *\n * 격자의 gutter 줄에 두면 카드 아래에 단추 줄이 하나 생긴다. 감춰도 자리가 남고, 자리를 없애면\n * 눌릴 곳이 사라진다. 되돌릴 수 없는 조작은 **고르는 눈길에 걸리지 않아야** 하므로 그림 오른쪽\n * 위, 손이 갔을 때만 나오는 자리에 둔다.\n */\n@customElement('figure-thumb')\nexport class FigureThumb extends localize(i18next)(LitElement) {\n static styles = css`\n :host {\n display: block;\n width: 100%;\n height: 100%;\n }\n\n /*\n 태그 — **그림 위에 얹는다.**\n\n 카드 본문에 한 줄로 두었더니 태그가 있는 카드만 그만큼 높아져 격자가 들쭉날쭉해졌다.\n 태그는 도형마다 개수가 다르므로 본문에 두는 한 이 문제가 남는다. 그림 위는 이미 자리가\n 잡혀 있고(초안 표시·삭제), 얹어도 카드 높이가 변하지 않는다.\n\n 아래쪽에 둔다 — 위는 초안 표시와 삭제 단추가 쓴다.\n\n **투명한 알약이다.** 면을 꽉 채우면 그림 위에 흰 띠가 생겨 형상을 가린다 — 카탈로그에서\n 먼저 보아야 하는 것은 형상이다. 그래서 바탕은 거의 비우고, 읽히는 것은 뒤를 흐리는 것과\n 실낱 테두리가 맡는다. 흐림이 안 되는 환경에서도 테두리가 남아 글자가 형상에 묻히지 않는다.\n */\n div[tags] {\n position: absolute;\n right: 6px;\n bottom: 6px;\n left: 6px;\n display: flex;\n flex-wrap: wrap-reverse;\n justify-content: flex-end;\n gap: 3px;\n /* 태그가 많아도 그림을 덮지 않는다 — 넘치는 것은 위로 잘린다 */\n max-height: 44px;\n overflow: hidden;\n /* 알약 사이의 빈 자리는 그림이 받는다 — 거기서 누르면 저작면이 열린다 */\n pointer-events: none;\n }\n /*\n **알약은 누를 수 있다.** 누르면 그 태그로 목록을 거른다.\n\n 태그를 눈에 보이게 한 다음의 물음은 「그래서 이걸로 뭘 하나」다. 같은 태그가 붙은 것을\n 보려고 태그를 눈으로 읽어 검색창에 옮겨 적게 두면, 보여 준 뜻이 절반만 산다.\n\n 카드 자체가 저작면을 여는 자리이므로 알약에서 그 클릭을 멈춘다 — 안 멈추면 태그를 누른\n 사람이 편집기로 끌려간다.\n */\n div[tags] button {\n pointer-events: auto;\n cursor: pointer;\n padding: 0 7px;\n font: var(--label-font, inherit);\n font-size: 0.66rem;\n line-height: 1.7;\n color: var(--md-sys-color-on-surface-variant, var(--md-sys-color-on-surface));\n background-color: color-mix(in srgb, var(--md-sys-color-surface, #fff) 34%, transparent);\n border: 1px solid color-mix(in srgb, var(--md-sys-color-outline, #888) 42%, transparent);\n backdrop-filter: blur(4px);\n border-radius: 999em;\n white-space: nowrap;\n }\n div[tags] button:hover,\n div[tags] button:focus-visible {\n color: var(--md-sys-color-on-surface);\n background-color: color-mix(in srgb, var(--md-sys-color-surface, #fff) 72%, transparent);\n border-color: var(--md-sys-color-primary);\n outline: none;\n }\n\n button[remove] {\n position: absolute;\n top: 5px;\n right: 5px;\n display: flex;\n align-items: center;\n justify-content: center;\n padding: 3px;\n color: var(--md-sys-color-on-surface-variant, var(--md-sys-color-on-surface));\n /* 그림이 밝을 수도 어두울 수도 있다 — 옅은 면을 깔아 아이콘이 어디서나 읽히게 한다 */\n background-color: color-mix(in srgb, var(--md-sys-color-surface, #fff) 82%, transparent);\n border: none;\n border-radius: 4px;\n cursor: pointer;\n opacity: 0;\n transition: opacity 0.12s ease-out;\n }\n button[remove] md-icon {\n --md-icon-size: 18px;\n }\n :host(:hover) button[remove],\n button[remove]:focus-visible {\n opacity: 1;\n }\n button[remove]:hover {\n color: var(--md-sys-color-on-error, #fff);\n background-color: var(--md-sys-color-error);\n }\n `\n\n @property({ type: Object }) figure!: Figure\n /** Another domain's public figure (G2): its card says whose it is and offers no delete. */\n @property({ type: Boolean }) readonly = false\n\n render() {\n const figure = this.figure\n if (!figure) {\n return html``\n }\n\n const src = thumbnailSrc(figure)\n\n return html`\n <ox-card-thumb\n src=${src || ''}\n alt=${figure.name || ''}\n badge=${this.readonly ? figure.domain?.name ?? '' : figure.deprecated ? i18next.t('figure.label.retired') : figure.state === 'released' ? '' : i18next.t('figure.label.draft')}\n >\n <figure-thumb-mark slot=\"empty\" .label=${i18next.t('figure.text.thumbnail-not-made-yet')}></figure-thumb-mark>\n ${figure.tags?.length\n ? html`<div tags>\n ${figure.tags.map(\n tag => html`\n <button\n title=${i18next.t('figure.text.find-by-this-tag')}\n @click=${(e: Event) => {\n e.stopPropagation()\n this.dispatchEvent(\n new CustomEvent('search-tag', { detail: { tag }, bubbles: true, composed: true })\n )\n }}\n >\n ${tag}\n </button>\n `\n )}\n </div>`\n : ''}\n\n ${this.readonly || hasBeenReleased(figure) ? '' : html`<button\n remove\n title=${i18next.t('figure.button.delete-figure')}\n @click=${(e: Event) => {\n /* 카드 클릭(=저작면 열기)까지 올라가지 않게 여기서 멈춘다 */\n e.stopPropagation()\n this.dispatchEvent(new CustomEvent('remove'))\n }}\n >\n <md-icon>delete</md-icon>\n </button>`}\n </ox-card-thumb>\n `\n }\n}\n"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@things-factory/figure-ui",
3
- "version": "10.1.177",
3
+ "version": "10.1.179",
4
4
  "publishConfig": {
5
5
  "access": "public",
6
6
  "@things-factory:registry": "https://registry.npmjs.org"
@@ -18,7 +18,7 @@
18
18
  "scripts": {
19
19
  "build": "npm run build:server && npm run build:client",
20
20
  "test": "FIGURE_FREEZE_COMPILED=1 node --import ./test/resolve-ts-siblings.mjs --test test/*.test.ts",
21
- "test:browser": "node --test test/browser/build.check.mjs test/browser/modeller.check.mjs test/browser/modeller-revision.check.mjs test/browser/editing.check.mjs test/browser/links.check.mjs test/browser/section.check.mjs test/browser/screen.check.mjs test/browser/product-page.check.mjs test/browser/parts.check.mjs test/browser/places-mesh.check.mjs test/browser/part-ids.check.mjs test/browser/fix-alignment.check.mjs test/browser/mesh-select.check.mjs test/browser/row-preview.check.mjs test/browser/detail-tabs.check.mjs test/browser/props.check.mjs",
21
+ "test:browser": "node --test test/browser/build.check.mjs test/browser/modeller.check.mjs test/browser/modeller-revision.check.mjs test/browser/editing.check.mjs test/browser/links.check.mjs test/browser/section.check.mjs test/browser/screen.check.mjs test/browser/product-page.check.mjs test/browser/parts.check.mjs test/browser/places-mesh.check.mjs test/browser/part-ids.check.mjs test/browser/fix-alignment.check.mjs test/browser/mesh-select.check.mjs test/browser/row-preview.check.mjs test/browser/detail-tabs.check.mjs test/browser/props.check.mjs test/browser/retire.check.mjs test/browser/namespace.check.mjs test/browser/public.check.mjs test/browser/category.check.mjs test/browser/property-section.check.mjs",
22
22
  "copy:files": "copyfiles -e \"./client/**/*.{ts,js,json}\" -u 1 \"./client/**/*\" dist-client",
23
23
  "build:client": "npm run copy:files && npx tsc --p ./client/tsconfig.json",
24
24
  "build:server": "npx tsc --p ./server/tsconfig.json",
@@ -31,8 +31,9 @@
31
31
  "@hatiolab/things-scene": "^10.1.91",
32
32
  "@material/web": "^2.0.0",
33
33
  "@operato/data-grist": "^10.0.0",
34
- "@operato/graphql": "^10.0.0",
34
+ "@operato/graphql": "^10.29.36",
35
35
  "@operato/i18n": "^10.0.0",
36
+ "@operato/input": "^10.29.37",
36
37
  "@operato/layout": "^10.28.0",
37
38
  "@operato/markdown": "^10.23.0",
38
39
  "@operato/p13n": "^10.0.0",
@@ -41,11 +42,11 @@
41
42
  "@operato/slot-layout-editor": "^10.29.25",
42
43
  "@operato/styles": "^10.0.0",
43
44
  "@operato/utils": "^10.0.0",
44
- "@things-factory/ai-assistant": "^10.1.177",
45
- "@things-factory/figure-service": "^10.1.177",
45
+ "@things-factory/ai-assistant": "^10.1.179",
46
+ "@things-factory/figure-service": "^10.1.179",
46
47
  "@things-factory/figure-viewer": "^10.1.176",
47
- "@things-factory/setting-base": "^10.1.176",
48
+ "@things-factory/setting-base": "^10.1.179",
48
49
  "three": "^0.185.1"
49
50
  },
50
- "gitHead": "91da09c945ec5e77ab960074db8f53b9ea8ac534"
51
+ "gitHead": "4c8263b08e06b2b15f11dc0575c61a280a0d9e27"
51
52
  }
@@ -0,0 +1,28 @@
1
+ /*
2
+ * The modeller's properties panel with a server that allows a category vocabulary, or none (LV figure-server design
3
+ * G5). The figure is in memory; what the panel asks to change is kept, not saved.
4
+ */
5
+ import 'figure-ui-client/modeller/figure-properties.js'
6
+ import { createFigureProductSession } from 'figure-ui-client/modeller/product-session.js'
7
+ import { addFigureBox, createFigureAsset, serializeFigureAsset } from '@hatiolab/figure-model'
8
+
9
+ const source = serializeFigureAsset(addFigureBox(createFigureAsset('Mixer'), { id: 'body', size: { x: 100, y: 100, z: 100 }, position: { x: 0, y: 50, z: 0 } }))
10
+ const session = createFigureProductSession({ load: async id => ({ id, source }), save: async request => ({ id: 'f1', source: request.source }) })
11
+ const asked = []
12
+
13
+ const panel = document.createElement('figure-properties')
14
+ panel.addEventListener('catalog', e => asked.push(e.detail))
15
+ document.body.append(panel)
16
+
17
+ window.harness = {
18
+ panel,
19
+ asked,
20
+ async show({ categories, category }) {
21
+ await session.open('f1')
22
+ panel.revision = session.revisionNow()
23
+ panel.figureId = 'f1'
24
+ panel.figureCategories = categories
25
+ panel.figureCategory = category
26
+ await panel.updateComplete
27
+ }
28
+ }
@@ -0,0 +1,54 @@
1
+ /*
2
+ * The category in the modeller's properties panel (LV figure-server design G5): free text when the server has no
3
+ * vocabulary; with one, chosen from it, and fixed once the figure has one.
4
+ *
5
+ * Run: node --test test/browser/category.check.mjs
6
+ */
7
+ import { test, describe, before, after } from 'node:test'
8
+ import assert from 'node:assert/strict'
9
+
10
+ import { buildPage } from './build.mjs'
11
+ import { openPage } from './serve.mjs'
12
+
13
+ let opened
14
+ before(async () => {
15
+ opened = await openPage(await buildPage('category-page.js'), { width: 1200, height: 900 })
16
+ })
17
+ after(async () => {
18
+ await opened?.close()
19
+ })
20
+
21
+ const page = () => opened.page
22
+ const field = () =>
23
+ page().evaluate(() => {
24
+ const root = document.querySelector('figure-properties').shadowRoot
25
+ const select = root.querySelector('select[category]')
26
+ /* The page has no translations: the label reads its key. */
27
+ const label = [...root.querySelectorAll('div[field] label')].find(l => l.textContent.trim() === 'figure.label.category')
28
+ return select
29
+ ? { kind: 'select', disabled: select.disabled, value: select.value, options: [...select.options].map(o => o.value) }
30
+ : { kind: 'input', value: label?.nextElementSibling?.value ?? null }
31
+ })
32
+
33
+ describe('the category field', () => {
34
+ test('free text without a vocabulary', async () => {
35
+ await page().evaluate(() => harness.show({ categories: null, category: 'conveyors' }))
36
+ assert.deepEqual(await field(), { kind: 'input', value: 'conveyors' })
37
+ })
38
+
39
+ test('with a vocabulary and none set: chosen from it, and the choice is asked for', async () => {
40
+ await page().evaluate(() => harness.show({ categories: ['EQUIPMENT', 'CARRIER'], category: undefined }))
41
+ assert.deepEqual(await field(), { kind: 'select', disabled: false, value: '', options: ['', 'EQUIPMENT', 'CARRIER'] })
42
+ await page().evaluate(() => {
43
+ const select = document.querySelector('figure-properties').shadowRoot.querySelector('select[category]')
44
+ select.value = 'CARRIER'
45
+ select.dispatchEvent(new Event('change'))
46
+ })
47
+ assert.deepEqual(await page().evaluate(() => harness.asked.at(-1)), { category: 'CARRIER' })
48
+ })
49
+
50
+ test('with a vocabulary and one set: shown, and not changed here', async () => {
51
+ await page().evaluate(() => harness.show({ categories: ['EQUIPMENT', 'CARRIER'], category: 'EQUIPMENT' }))
52
+ assert.deepEqual(await field(), { kind: 'select', disabled: true, value: 'EQUIPMENT', options: ['EQUIPMENT', 'CARRIER'] })
53
+ })
54
+ })
@@ -79,7 +79,7 @@ describe('a GLB imported on the modeller page: the part made is the one selected
79
79
  const file = join(mkdtempSync(join(tmpdir(), 'figure-glb-')), 'crane.glb')
80
80
  writeFileSync(file, Buffer.from('glTF'))
81
81
  const start = await editor()
82
- const [chooser] = await Promise.all([page().waitForFileChooser({ timeout: 4000 }), clickAt('figure-parts >>> button[shape][title="figure.text.import-mesh-hint"]', 'the import button')])
82
+ const [chooser] = await Promise.all([page().waitForFileChooser({ timeout: 4000 }), clickAt('figure-parts >>> ox-input-file[mesh-file] >>> button', 'the import button')])
83
83
  await chooser.accept([file])
84
84
  await until(() => !!product.deep('figure-mesh-choice')[0], 'the question')
85
85
  await clickAt('figure-mesh-choice >>> li button', 'the whole file')
@@ -0,0 +1,75 @@
1
+ /*
2
+ * The namespace card of the settings and the first save's question, with the server's calls as stand-ins kept in
3
+ * memory (LV figure-server design G1). Nothing is saved.
4
+ */
5
+ import 'figure-ui-client/setting-let/figure-namespace-setting-let.js'
6
+ import 'figure-ui-client/modeller/figure-first-save.js'
7
+ import { html, render } from 'lit'
8
+ import { openPopup } from '@operato/layout'
9
+ import { setOpenPopupImplementation } from '@operato/popup'
10
+ import '@operato/popup/ox-floating-overlay.js'
11
+
12
+ /* Popups, drawn as the layout draws them (as product-page.js does): an overlay in the body, the result given when it leaves. */
13
+ let popups = 0
14
+ setOpenPopupImplementation((template, options = {}) => {
15
+ const name = `$popup-${popups++}`
16
+ const fragment = document.createDocumentFragment()
17
+ 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)
18
+ document.body.appendChild(fragment)
19
+ const handle = {
20
+ name,
21
+ close: () => {
22
+ const popup = document.getElementById(name)
23
+ if (!popup) return false
24
+ popup.close()
25
+ return true
26
+ },
27
+ closed: false
28
+ }
29
+ document.addEventListener('overlay-closed', function listener(e) {
30
+ if (e.detail !== name) return
31
+ document.removeEventListener('overlay-closed', listener)
32
+ handle.closed = true
33
+ handle.onclosed && handle.onclosed()
34
+ })
35
+ return handle
36
+ })
37
+
38
+ let rows = [
39
+ { id: '1', name: 'acme.example', state: 'active', isDefault: true },
40
+ { id: '2', name: 'acme-two.example', state: 'active', isDefault: false },
41
+ { id: '3', name: 'acme-old.example', state: 'suspended', isDefault: false }
42
+ ]
43
+ const calls = []
44
+ let refuse = ''
45
+ const answer = name => rows.find(row => row.name === name)
46
+
47
+ const card = document.createElement('figure-namespace-setting-let')
48
+ /* The server answers the default first. */
49
+ card.list = async () => [...rows].sort((a, b) => Number(b.isDefault) - Number(a.isDefault)).map(row => ({ ...row }))
50
+ card.add = async name => {
51
+ calls.push(['add', name])
52
+ if (refuse) throw new Error(refuse)
53
+ rows = [...rows, { id: String(rows.length + 1), name, state: 'active', isDefault: false }]
54
+ return answer(name)
55
+ }
56
+ card.makeDefault = async name => {
57
+ calls.push(['default', name])
58
+ rows = rows.map(row => ({ ...row, isDefault: row.name === name }))
59
+ return answer(name)
60
+ }
61
+ card.setState = async (name, state) => {
62
+ calls.push(['state', name, state])
63
+ rows = rows.map(row => (row.name === name ? { ...row, state } : row))
64
+ return answer(name)
65
+ }
66
+ document.body.append(card)
67
+
68
+ window.harness = {
69
+ card,
70
+ calls,
71
+ refuseWith: message => (refuse = message),
72
+ /** Opens the first save's question over these namespaces; resolves with what it closed with. */
73
+ ask: namespaces =>
74
+ openPopup(html`<figure-first-save .type=${'mixer'} .name=${'Mixer'} .taken=${[]} .namespaces=${namespaces}></figure-first-save>`, { backdrop: true, size: 'small', title: 'save' }).result
75
+ }
@@ -0,0 +1,104 @@
1
+ /*
2
+ * The namespaces a domain owns (LV figure-server design G1): the settings card lists them, the default first, and adds
3
+ * one, makes one the default, suspends and uses one again; the server's refusal is said on the field. The first save
4
+ * asks the namespace only when there are several, the default chosen until another is.
5
+ *
6
+ * Run: node --test test/browser/namespace.check.mjs
7
+ */
8
+ import { test, describe, before, after } from 'node:test'
9
+ import assert from 'node:assert/strict'
10
+
11
+ import { buildPage } from './build.mjs'
12
+ import { openPage } from './serve.mjs'
13
+
14
+ const sleep = ms => new Promise(done => setTimeout(done, ms))
15
+
16
+ let opened
17
+ before(async () => {
18
+ opened = await openPage(await buildPage('namespace-page.js'), { width: 1200, height: 900 })
19
+ })
20
+ after(async () => {
21
+ await opened?.close()
22
+ })
23
+
24
+ const page = () => opened.page
25
+ const inCard = (fn, ...args) =>
26
+ page().evaluate((source, a) => (0, eval)(`(${source})`)(document.querySelector('figure-namespace-setting-let').shadowRoot, ...a), String(fn), args)
27
+ async function until(fn, what, ms = 15000) {
28
+ const deadline = Date.now() + ms
29
+ for (;;) {
30
+ if (await page().evaluate(`(${fn})()`).catch(() => false)) return
31
+ if (Date.now() > deadline) assert.fail(`waited for ${what}`)
32
+ await sleep(50)
33
+ }
34
+ }
35
+ const rows = () =>
36
+ inCard(root =>
37
+ [...root.querySelectorAll('li')].map(li => [
38
+ li.querySelector('code').textContent,
39
+ li.querySelectorAll('[mark]').length,
40
+ [...li.querySelectorAll('md-text-button')].length
41
+ ])
42
+ )
43
+ const pressInRow = (name, index) =>
44
+ inCard(
45
+ (root, n, i) => [...root.querySelectorAll('li')].find(li => li.querySelector('code').textContent === n).querySelectorAll('md-text-button')[i].click(),
46
+ name,
47
+ index
48
+ )
49
+
50
+ describe('the namespace card', () => {
51
+ test('lists them, the default first: the default has no actions, an active one two, a suspended one one', async () => {
52
+ await until(() => document.querySelector('figure-namespace-setting-let').shadowRoot.querySelectorAll('li').length === 3, 'three namespaces')
53
+ assert.deepEqual(await rows(), [
54
+ ['acme.example', 1, 0],
55
+ ['acme-two.example', 0, 2],
56
+ ['acme-old.example', 1, 1]
57
+ ])
58
+ })
59
+
60
+ test('makes one the default, suspends and uses one again, each one call and the list read again', async () => {
61
+ await pressInRow('acme-two.example', 0)
62
+ await until(() => document.querySelector('figure-namespace-setting-let').shadowRoot.querySelector('li code').textContent === 'acme-two.example', 'the new default first')
63
+ await pressInRow('acme-old.example', 0)
64
+ await until(async () => (await harness.card.list()).find(r => r.name === 'acme-old.example').state === 'active', 'used again')
65
+ assert.deepEqual(await page().evaluate(() => harness.calls), [['default', 'acme-two.example'], ['state', 'acme-old.example', 'active']])
66
+ })
67
+
68
+ test('adds one typed in; a refusal is said on the field and what was typed is kept', async () => {
69
+ await page().evaluate(() => harness.refuseWith('the figure namespace taken.example is already taken on this server.'))
70
+ await inCard(root => root.querySelector('md-filled-text-field').focus())
71
+ await page().keyboard.type('taken.example')
72
+ await page().keyboard.press('Enter')
73
+ await until(() => !!document.querySelector('figure-namespace-setting-let').shadowRoot.querySelector('md-filled-text-field').error, 'the refusal')
74
+ assert.match(await inCard(root => root.querySelector('md-filled-text-field').errorText), /already taken/)
75
+ assert.equal(await inCard(root => root.querySelector('md-filled-text-field').value), 'taken.example')
76
+ await page().evaluate(() => harness.refuseWith(''))
77
+ await inCard(root => [...root.querySelectorAll('[add] md-text-button')][0].click())
78
+ await until(() => [...document.querySelector('figure-namespace-setting-let').shadowRoot.querySelectorAll('li code')].some(c => c.textContent === 'taken.example'), 'added')
79
+ assert.equal(await inCard(root => root.querySelector('md-filled-text-field').value), '')
80
+ })
81
+ })
82
+
83
+ describe('the first save asks the namespace only when there are several', () => {
84
+ test('one namespace: no choice, and none is sent', async () => {
85
+ const result = page().evaluate(() => harness.ask([{ name: 'acme.example', isDefault: true }]).then(r => JSON.stringify(r)))
86
+ await until(() => (function find(root) { for (const e of root.querySelectorAll('*')) { if (e.localName === 'figure-first-save') return !!e.shadowRoot?.querySelector('#name'); if (e.shadowRoot && find(e.shadowRoot)) return true } return false })(document), 'the question')
87
+ const hasSelect = await page().evaluate(() => (function find(root) { for (const e of root.querySelectorAll('*')) { if (e.localName === 'figure-first-save') return !!e.shadowRoot.querySelector('select'); if (e.shadowRoot) { const f = find(e.shadowRoot); if (f !== undefined) return f } } })(document))
88
+ assert.equal(hasSelect, false)
89
+ await page().evaluate(() => (function find(root) { for (const e of root.querySelectorAll('*')) { if (e.localName === 'figure-first-save') return e.shadowRoot.querySelector('md-filled-button[slot=confirm]').click(); if (e.shadowRoot) find(e.shadowRoot) } })(document))
90
+ assert.deepEqual(JSON.parse(await result), { type: 'mixer', name: 'Mixer' })
91
+ })
92
+
93
+ test('several: the default is chosen, another can be, and the one chosen is sent', async () => {
94
+ const result = page().evaluate(() =>
95
+ harness.ask([{ name: 'acme.example', isDefault: true }, { name: 'acme-two.example', isDefault: false }]).then(r => JSON.stringify(r))
96
+ )
97
+ const select = `(function find(root) { for (const e of root.querySelectorAll('*')) { if (e.localName === 'figure-first-save') return e.shadowRoot.querySelector('select'); if (e.shadowRoot) { const f = find(e.shadowRoot); if (f) return f } } })(document)`
98
+ await until(`() => !!${select}`, 'the namespace asked')
99
+ assert.equal(await page().evaluate(`${select}.value`), 'acme.example')
100
+ await page().evaluate(`(() => { const s = ${select}; s.value = 'acme-two.example'; s.dispatchEvent(new Event('change')) })()`)
101
+ await page().evaluate(() => (function find(root) { for (const e of root.querySelectorAll('*')) { if (e.localName === 'figure-first-save') return e.shadowRoot.querySelector('md-filled-button[slot=confirm]').click(); if (e.shadowRoot) find(e.shadowRoot) } })(document))
102
+ assert.deepEqual(JSON.parse(await result), { type: 'mixer', name: 'Mixer', namespace: 'acme-two.example' })
103
+ })
104
+ })
@@ -93,9 +93,15 @@ window.parts = {
93
93
  const b = panel().shadowRoot.querySelector(`li[selected] button[op="${name}"]`)
94
94
  return b ? { ...centreOf(b), disabled: b.disabled } : null
95
95
  },
96
- /** The palette's shape buttons: what each says (keys: no translations are loaded) and whether it can be pressed. */
96
+ /**
97
+ * The palette: its shape buttons, then the file picker for a GLB -- what each says (keys: no translations are loaded)
98
+ * and whether it can be pressed.
99
+ */
97
100
  palette() {
98
- return [...panel().shadowRoot.querySelectorAll('div[palette] button[shape]')].map(b => ({ label: b.querySelector('span').textContent.trim(), disabled: b.disabled, ...centreOf(b) }))
101
+ const shapes = [...panel().shadowRoot.querySelectorAll('div[palette] button[shape]')].map(b => ({ label: b.querySelector('span').textContent.trim(), disabled: b.disabled, ...centreOf(b) }))
102
+ const picker = panel().shadowRoot.querySelector('div[palette] ox-input-file[mesh-file]')
103
+ const button = picker?.shadowRoot.querySelector('button')
104
+ return button ? [...shapes, { label: button.textContent.trim(), disabled: picker.disabled && button.disabled, ...centreOf(button) }] : shapes
99
105
  },
100
106
  /** The detail level choice in the list's head. */
101
107
  levelChoice() {
@@ -122,7 +122,7 @@ describe('a GLB made into a mesh part', () => {
122
122
 
123
123
  /** The palette's import button, the file chooser it opens, the file chosen. */
124
124
  async function importFile() {
125
- const [chooser] = await Promise.all([page().waitForFileChooser({ timeout: 4000 }), click('button[shape]', { has: 'figure.button.import-mesh' }, 'the import button')])
125
+ const [chooser] = await Promise.all([page().waitForFileChooser({ timeout: 4000 }), click('button', { within: 'ox-input-file[mesh-file]', has: 'figure.button.import-mesh' }, 'the import button')])
126
126
  await chooser.accept([file])
127
127
  await settled()
128
128
  }
@@ -133,7 +133,7 @@ describe('a GLB made into a mesh part', () => {
133
133
  })
134
134
 
135
135
  test('the file chooser takes GLB files', async () => {
136
- const input = await handle('input[mesh-file]', {}, 'the palette\'s file input')
136
+ const input = await handle('input[type="file"]', { within: 'ox-input-file[mesh-file]' }, 'the palette\'s file picker')
137
137
  assert.equal(await input.evaluate(i => i.accept), '.glb,model/gltf-binary')
138
138
  })
139
139
 
@@ -188,6 +188,34 @@ describe('a GLB made into a mesh part', () => {
188
188
  assert.equal((await state()).revision, start.revision)
189
189
  })
190
190
 
191
+ /* A drop passes no native filter: the one picker (ox-input-file) takes the GLB and refuses the rest before it goes up. */
192
+ const drop = name =>
193
+ read(n => {
194
+ const shapes = findDeep('section[shapes]', { within: 'figure-parts' })
195
+ const transfer = new DataTransfer()
196
+ transfer.items.add(new File(['glTF'], n))
197
+ shapes.dispatchEvent(new DragEvent('drop', { dataTransfer: transfer, bubbles: true, cancelable: true }))
198
+ }, name)
199
+
200
+ test('a GLB dropped on the shapes: uploaded and added as one step, as one picked is', async () => {
201
+ await read(a => meshServer.answer({ upload: a }), one)
202
+ const start = await state()
203
+ const sent = (await read(() => meshServer.uploads())).length
204
+ await drop('dropped.glb')
205
+ await until(() => probe.ids().includes('dropped'), [], 'the part made of the dropped file')
206
+ assert.equal((await read(() => meshServer.uploads())).length, sent + 1)
207
+ assert.equal((await state()).revision, start.revision + 1, 'one step')
208
+ })
209
+
210
+ test('a file of another kind dropped: refused where it was dropped, nothing sent, nothing added', async () => {
211
+ const start = await state()
212
+ const sent = (await read(() => meshServer.uploads())).length
213
+ await drop('notes.txt')
214
+ await until(() => !!findDeep('div.refusal', { within: 'ox-input-file[mesh-file]', has: 'notes.txt' }), [], 'the refusal by the picker')
215
+ assert.equal((await read(() => meshServer.uploads())).length, sent, 'nothing sent')
216
+ assert.equal((await state()).revision, start.revision)
217
+ })
218
+
191
219
  test('the app fetches a mesh file from its own server, under the domain it is on', async () => {
192
220
  assert.equal(await read(() => meshUrlAt('/domain/acme/figure-modeller/f1')), `/domain/acme/figure-mesh/${HASH}`)
193
221
  assert.equal(await read(() => meshUrlAt('/figure-modeller/f1')), `/figure-mesh/${HASH}`)
@@ -0,0 +1,45 @@
1
+ /*
2
+ * The modeller's properties panel with sections an app added (LV figure-server design G11): one for CARRIER figures
3
+ * that writes an extension by the modeller's command, and one that fails. The figure is in memory; commands are kept.
4
+ */
5
+ import 'figure-ui-client/modeller/figure-properties.js'
6
+ import { html } from 'lit'
7
+ import { createFigureProductSession } from 'figure-ui-client/modeller/product-session.js'
8
+ import { registerFigurePropertySection } from 'figure-ui-client/modeller/property-sections.js'
9
+ import { addFigureBox, createFigureAsset, serializeFigureAsset } from '@hatiolab/figure-model'
10
+
11
+ const source = serializeFigureAsset(addFigureBox(createFigureAsset('Roll'), { id: 'body', size: { x: 100, y: 100, z: 100 }, position: { x: 0, y: 50, z: 0 } }))
12
+ const session = createFigureProductSession({ load: async id => ({ id, source }), save: async request => ({ id: 'f1', source: request.source }) })
13
+ const commands = []
14
+ let failing
15
+
16
+ registerFigurePropertySection({
17
+ id: 'carrier',
18
+ title: () => 'Carrier',
19
+ when: ({ figure }) => figure.category === 'CARRIER',
20
+ render: ({ asset, command }) => html`<button declare @click=${() =>
21
+ command({ kind: 'set-extension', key: 'fmsim/carrier', value: { type: 'ROLL', dimensions: { width: 600, length: 600, height: 800 } } })}>
22
+ ${asset.extensions?.['fmsim/carrier'] ? 'declared' : 'declare'}</button>`
23
+ })
24
+
25
+ const panel = document.createElement('figure-properties')
26
+ panel.addEventListener('command', e => commands.push(e.detail.action))
27
+ document.body.append(panel)
28
+
29
+ window.harness = {
30
+ panel,
31
+ commands,
32
+ async show(category) {
33
+ await session.open('f1')
34
+ panel.revision = session.revisionNow()
35
+ panel.figureId = 'f1'
36
+ panel.figureCategory = category
37
+ await panel.updateComplete
38
+ },
39
+ failOn() {
40
+ failing = registerFigurePropertySection({ id: 'broken', title: () => 'Broken', when: () => true, render: () => { throw new Error('no table') } })
41
+ },
42
+ failOff() {
43
+ failing?.()
44
+ }
45
+ }
@@ -0,0 +1,48 @@
1
+ /*
2
+ * Sections an app adds to the modeller's properties panel (LV figure-server design G11): drawn after the panel's own
3
+ * for the figures they are for, changing the figure only by the modeller's commands; a section that fails says so in
4
+ * its place and the rest of the panel stands.
5
+ *
6
+ * Run: node --test test/browser/property-section.check.mjs
7
+ */
8
+ import { test, describe, before, after } from 'node:test'
9
+ import assert from 'node:assert/strict'
10
+
11
+ import { buildPage } from './build.mjs'
12
+ import { openPage } from './serve.mjs'
13
+
14
+ let opened
15
+ before(async () => {
16
+ opened = await openPage(await buildPage('property-section-page.js'), { width: 1200, height: 900 })
17
+ })
18
+ after(async () => {
19
+ await opened?.close()
20
+ })
21
+
22
+ const page = () => opened.page
23
+ const sections = () =>
24
+ page().evaluate(() => [...document.querySelector('figure-properties').shadowRoot.querySelectorAll('section')].map(s => s.dataset.section ?? 'own'))
25
+
26
+ describe('sections an app adds', () => {
27
+ test('only for the figures they are for, after the panel\'s own', async () => {
28
+ await page().evaluate(() => harness.show('EQUIPMENT'))
29
+ assert.deepEqual(await sections(), ['own', 'own', 'own'])
30
+ await page().evaluate(() => harness.show('CARRIER'))
31
+ assert.deepEqual(await sections(), ['own', 'own', 'own', 'carrier'])
32
+ })
33
+
34
+ test('a section changes the figure by the modeller\'s command', async () => {
35
+ await page().evaluate(() => document.querySelector('figure-properties').shadowRoot.querySelector('section[data-section="carrier"] button[declare]').click())
36
+ assert.deepEqual(await page().evaluate(() => harness.commands), [
37
+ { kind: 'set-extension', key: 'fmsim/carrier', value: { type: 'ROLL', dimensions: { width: 600, length: 600, height: 800 } } }
38
+ ])
39
+ })
40
+
41
+ test('a section that fails says so in its place; the others stand', async () => {
42
+ await page().evaluate(() => harness.failOn())
43
+ await page().evaluate(() => harness.show('CARRIER'))
44
+ assert.deepEqual(await sections(), ['own', 'own', 'own', 'carrier', 'broken'])
45
+ assert.match(await page().evaluate(() => document.querySelector('figure-properties').shadowRoot.querySelector('section[data-section="broken"] [role="alert"]').textContent), /figure\.text\.property-section-failed|no table/)
46
+ await page().evaluate(() => harness.failOff())
47
+ })
48
+ })
@@ -253,8 +253,17 @@ describe('the parts panel stands at the side\'s heights (2026-10-16)', () => {
253
253
  const heights = await read(() => {
254
254
  const parts = document.querySelector('figure-editor').shadowRoot.querySelector('figure-parts').shadowRoot
255
255
  const h = e => Math.round(e.getBoundingClientRect().height)
256
- const tiles = [...parts.querySelectorAll('div[palette] button[shape]:not([import])')].map(h)
257
- return { level: h(parts.querySelector('div[level] select')), tiles: [...new Set(tiles)], count: tiles.length, import: h(parts.querySelector('button[shape][import]')) }
256
+ const tiles = [...parts.querySelectorAll('div[palette] button[shape]')].map(h)
257
+ const importer = parts.querySelector('ox-input-file[mesh-file]').shadowRoot.querySelector('button')
258
+ const words = e => { const c = getComputedStyle(e); return { size: c.fontSize, weight: c.fontWeight, color: c.color } }
259
+ return {
260
+ level: h(parts.querySelector('div[level] select')),
261
+ tiles: [...new Set(tiles)],
262
+ count: tiles.length,
263
+ import: h(importer),
264
+ importWords: words(importer),
265
+ tileWords: words(parts.querySelector('div[palette] button[shape]'))
266
+ }
258
267
  })
259
268
  const side = await read(() => Math.round(document.querySelector('figure-editor').shadowRoot.querySelector('input[name-field]')?.getBoundingClientRect().height ?? 0))
260
269
  assert.ok(side > 0, 'a side field to measure against')
@@ -262,6 +271,8 @@ describe('the parts panel stands at the side\'s heights (2026-10-16)', () => {
262
271
  assert.ok(heights.count >= 9, `the starting shapes: ${heights.count}`)
263
272
  assert.equal(heights.tiles.length, 1, `one tile height: ${heights.tiles}`)
264
273
  assert.equal(heights.import, side, 'the file import on one line, its own row, at the fields\' height')
274
+ /* The picker's own button is 13/600 near-black: louder than the shapes' heading until it wears the tiles' words. */
275
+ assert.deepEqual(heights.importWords, heights.tileWords, 'the file import speaks as the tiles do')
265
276
  })
266
277
 
267
278
  /* A static guard sees the sizes written down; an icon with none stands at md-icon's own 24 (the recipe catalog's did). */
@@ -0,0 +1,39 @@
1
+ /*
2
+ * The catalogue's detail dock with the domain's own released figure, which can be made public and private, and another
3
+ * domain's public figure, shown by its release alone (LV figure-server design G2). The server's calls are stand-ins.
4
+ */
5
+ import 'figure-ui-client/viewparts/figure-detail-dock.js'
6
+ import { addFigureBox, createFigureAsset, serializeFigureAsset } from '@hatiolab/figure-model'
7
+
8
+ const source = serializeFigureAsset(addFigureBox(createFigureAsset('Mixer'), { id: 'body', size: { x: 100, y: 100, z: 100 }, position: { x: 0, y: 50, z: 0 } }))
9
+ const figures = {
10
+ shipped: { id: 'shipped', name: 'Mixer', type: 'mixer', namespace: 'acme.example', state: 'released', version: 1, public: false, source },
11
+ draft: { id: 'draft', name: 'Sketch', type: 'sketch', namespace: 'acme.example', state: 'draft', version: 0, public: false, source }
12
+ }
13
+ const theirs = { id: 'theirs', name: 'Their crane', type: 'crane', namespace: 'other.example', state: 'released', version: 3, public: true, domain: { id: 'd2', name: 'Other works' } }
14
+ const calls = []
15
+ const changed = []
16
+ let refuse = ''
17
+
18
+ const dock = document.createElement('figure-detail-dock')
19
+ dock.load = async id => ({ saved: { ...figures[id] }, released: figures[id].version ? { version: figures[id].version, source } : undefined })
20
+ dock.loadPublic = async row => ({ saved: { ...row }, released: { version: row.version, source }, foreign: true })
21
+ dock.keepThumbnail = async () => {}
22
+ dock.publish = async (id, isPublic) => {
23
+ calls.push({ id, public: isPublic })
24
+ if (refuse) throw new Error(refuse)
25
+ figures[id] = { ...figures[id], public: isPublic }
26
+ const { source: _s, ...row } = figures[id]
27
+ return row
28
+ }
29
+ window.addEventListener('figure-public-changed', e => changed.push(e.detail.id))
30
+ document.body.append(dock)
31
+
32
+ window.harness = {
33
+ dock,
34
+ show: id => dock.show(id),
35
+ showTheirs: () => dock.show(theirs.id, theirs),
36
+ calls,
37
+ changed,
38
+ refuseWith: message => (refuse = message)
39
+ }