@operato/slot-layout-editor 10.29.52 → 10.29.57

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.
@@ -1,25 +1,62 @@
1
1
  /**
2
2
  * @license Copyright © HatioLab Inc. All rights reserved.
3
3
  *
4
- * 자리 레이아웃 편집기. 홀더의 Tier·Slot 구성을 고치고, 검증하고, 미리 보고, [적용]으로 알린다.
4
+ * 자리 레이아웃 편집기. 홀더의 Tier·Slot 구성을 고치고, 검증하고, 미리 보고, 호스트에 알린다.
5
5
  * Tier 는 홀더가 주는 Anchor 하나에 서고, 그 Anchor 기준 위치·회전과 적재 방식(mount)을 갖는다.
6
6
  *
7
+ * 맨 위 그림은 Anchor 면(호스트가 크기를 주면)과 그 위의 자리, 실린 아이템을 Anchor 기준으로 보인다. 자리를 끌면 Tier 위치가,
8
+ * 끝 자리의 점을 끌면 간격이 바뀐다. 끄는 동안은 그림만 바뀌고, 놓을 때 한 번 편집이 된다. Esc 는 끌기 전으로 돌린다.
9
+ *
7
10
  * 컴포넌트 상태를 직접 바꾸지 않는다. 호스트에 두 가지만 알린다:
8
- * change — 적용한 레이아웃 (this.value)
11
+ * change — 적용한 레이아웃 (this.value). live 호스트에는 맞는 편집마다, 아니면 [적용]으로
9
12
  * slot-layout-use-default — 개별 설정을 지우고 기본값을 쓰겠다는 의도 (값을 비우는 편집과 다르다)
13
+ * 맞지 않는 초안은 알리지 않는다: 칸 옆에 문제를 보이고, 장면에는 마지막으로 알린 값이 남는다.
10
14
  * 기본값을 쓰게 된 뒤 보일 값은 호스트가 options.effective 로 다시 준다.
11
15
  */
12
16
  import { __decorate } from "tslib";
13
17
  import { css, html, nothing, LitElement, svg } from 'lit';
14
18
  import { customElement, property, state } from 'lit/decorators.js';
15
19
  import { i18next, localize } from '@operato/i18n';
16
- import { persistenceIssues, slotSpaceWarnings, validateSlotLayout } from '@hatiolab/things-scene';
17
- import { addCustomSlot, addressImpact, applyPreset, copyTier, previewTier, removeCustomSlot, removeTier, setTierPattern, updatePattern, updateSlotSpec, updateTier } from './draft.js';
18
- import { issueText as defaultIssueText, spaceWarningText, t } from './issue-text.js';
20
+ import { persistenceIssues, rotatedFootprint, slotSpaceWarnings, validateSlotLayout } from '@hatiolab/things-scene';
21
+ import { addCustomSlot, addressImpact, anchorFaceSlots, applyPreset, copyTier, fitTierToFace, heldItemStatuses, pitchToPoint, removeCustomSlot, removeTier, setTierPattern, updatePattern, updateSlotSpec, updateTier } from './draft.js';
22
+ import { fitFaceText, fitText, issueField, issueMessage, issueText as defaultIssueText, ISSUE_TEXT, spaceWarningText, t } from './issue-text.js';
19
23
  const num = (e) => {
20
24
  const n = e.target.valueAsNumber;
21
25
  return Number.isFinite(n) ? n : NaN;
22
26
  };
27
+ /* 보이는 값: 길이는 소수 셋째 자리까지 — 피규어에서 잰 면은 부동소수 잡음을 싣는다(759.9999998556777 → 760) */
28
+ const shown = (v) => (v === undefined || v === null || Number.isNaN(v) ? '' : typeof v === 'number' ? String(Number(v.toFixed(3))) : String(v));
29
+ const snap = (v) => Math.round(v / 10) * 10;
30
+ const ERROR = '#c62828';
31
+ const SLOT = '#1976d2';
32
+ /** 칸 이름 (칸 옆에 오지 못한 문제를 목록에 적을 때) */
33
+ const FIELD_LABEL = {
34
+ anchor: ['face', 'Face'],
35
+ mount: ['mount', 'Mount'],
36
+ 'position-x': ['position-x', 'Position X'],
37
+ 'position-y': ['position-y', 'Position Y (height)'],
38
+ 'position-z': ['position-z', 'Position Z'],
39
+ 'tier-rotation': ['tier-rotation', 'Tier rotation (deg)'],
40
+ pattern: ['pattern', 'Arrangement'],
41
+ count: ['count', 'Slots'],
42
+ pitch: ['pitch', 'Pitch'],
43
+ 'pitch-x': ['pitch-x', 'Pitch X'],
44
+ 'pitch-z': ['pitch-z', 'Pitch Z'],
45
+ axis: ['axis', 'Direction'],
46
+ origin: ['origin', 'Slot 1 at'],
47
+ 'origin-x': ['origin-x', 'Slot 1 at X'],
48
+ 'origin-z': ['origin-z', 'Slot 1 at Z'],
49
+ rows: ['rows', 'Rows (Z)'],
50
+ cols: ['cols', 'Columns (X)'],
51
+ order: ['order', 'Numbering'],
52
+ 'allowed-width': ['allowed-width', 'Width (X)'],
53
+ 'allowed-depth': ['allowed-depth', 'Depth (Z)'],
54
+ 'allowed-height': ['allowed-height', 'Height'],
55
+ 'stack-limit': ['stack-limit', 'Stack limit'],
56
+ rotation: ['rotation', 'Slot rotation (deg)'],
57
+ accepts: ['accepts', 'Accepts']
58
+ };
59
+ const fieldLabel = (field) => (FIELD_LABEL[field] ? t(FIELD_LABEL[field][0], FIELD_LABEL[field][1]) : field);
23
60
  let OxSlotLayoutEditor = class OxSlotLayoutEditor extends localize(i18next)(LitElement) {
24
61
  constructor() {
25
62
  super(...arguments);
@@ -29,6 +66,46 @@ let OxSlotLayoutEditor = class OxSlotLayoutEditor extends localize(i18next)(LitE
29
66
  this.presetKey = '';
30
67
  this.presetSize = {};
31
68
  this.presetParams = {};
69
+ this._onDragKey = (e) => {
70
+ if (e.key !== 'Escape' || !this._drag)
71
+ return;
72
+ e.preventDefault();
73
+ e.stopPropagation();
74
+ this._cancelDrag();
75
+ };
76
+ this._onDragMove = (e) => {
77
+ const d = this._drag;
78
+ const p = d && this._point(e);
79
+ if (!d || !p)
80
+ return;
81
+ const tier = d.before.tiers.find(x => x.tierNo === d.tierNo);
82
+ if (d.kind === 'move') {
83
+ const position = { ...tier.position, x: snap(tier.position.x + p.x - d.from.x), z: snap(tier.position.z + p.z - d.from.z) };
84
+ this.draft = updateTier(d.before, d.tierNo, { position });
85
+ }
86
+ else {
87
+ const pitch = pitchToPoint(d.before, d.tierNo, p);
88
+ if (pitch !== null)
89
+ this.draft = updatePattern(d.before, d.tierNo, { pitch: Math.max(10, snap(pitch)) });
90
+ }
91
+ };
92
+ /** 놓으면 한 번의 편집. 움직이지 않았으면 아무 일도 없다 */
93
+ this._onDragEnd = () => {
94
+ const d = this._endDrag();
95
+ if (!d)
96
+ return;
97
+ if (JSON.stringify(this.draft) === JSON.stringify(d.before)) {
98
+ this.draft = d.before;
99
+ return;
100
+ }
101
+ this._edit(this.draft);
102
+ };
103
+ this._cancelDrag = () => {
104
+ const d = this._endDrag();
105
+ if (!d)
106
+ return;
107
+ this.draft = d.before;
108
+ };
32
109
  }
33
110
  willUpdate(changed) {
34
111
  var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m;
@@ -43,17 +120,36 @@ let OxSlotLayoutEditor = class OxSlotLayoutEditor extends localize(i18next)(LitE
43
120
  // 값도 기본값도 없는 호스트도 프리셋 크기는 준다. 크기가 실제로 바뀔 때만 받고, 편집 중인 초안은 그대로 둔다
44
121
  if (JSON.stringify((_h = before === null || before === void 0 ? void 0 : before.presetSize) !== null && _h !== void 0 ? _h : {}) !== JSON.stringify((_k = (_j = this.options) === null || _j === void 0 ? void 0 : _j.presetSize) !== null && _k !== void 0 ? _k : {}))
45
122
  this.presetSize = { ...((_m = (_l = this.options) === null || _l === void 0 ? void 0 : _l.presetSize) !== null && _m !== void 0 ? _m : {}) };
123
+ this._startFromHost();
46
124
  }
47
- /** 편집 중인 값을 버리고 개별 설정(없으면 호스트 기본값)에서 다시 시작한다 */
125
+ /** 편집 중인 값을 버리고 개별 설정(없으면 호스트 기본값)에서 다시 시작한다. 고른 Tier 는 남아 있으면 그대로 */
48
126
  reset() {
49
- var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o, _p;
127
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o, _p, _q, _r;
50
128
  const start = (_a = this.value) !== null && _a !== void 0 ? _a : (_b = this.options) === null || _b === void 0 ? void 0 : _b.effective;
51
129
  this.draft = start ? structuredClone(start) : undefined;
52
- this.tier = (_f = (_e = (_d = (_c = this.draft) === null || _c === void 0 ? void 0 : _c.tiers) === null || _d === void 0 ? void 0 : _d[0]) === null || _e === void 0 ? void 0 : _e.tierNo) !== null && _f !== void 0 ? _f : 1;
130
+ if (!((_d = (_c = this.draft) === null || _c === void 0 ? void 0 : _c.tiers) === null || _d === void 0 ? void 0 : _d.some(x => (x === null || x === void 0 ? void 0 : x.tierNo) === this.tier)))
131
+ this.tier = (_h = (_g = (_f = (_e = this.draft) === null || _e === void 0 ? void 0 : _e.tiers) === null || _f === void 0 ? void 0 : _f[0]) === null || _g === void 0 ? void 0 : _g.tierNo) !== null && _h !== void 0 ? _h : 1;
53
132
  this.dirty = false;
54
- this.presetSize = { ...((_h = (_g = this.options) === null || _g === void 0 ? void 0 : _g.presetSize) !== null && _h !== void 0 ? _h : {}) };
55
- if (!((_k = (_j = this.options) === null || _j === void 0 ? void 0 : _j.presets) === null || _k === void 0 ? void 0 : _k.some(p => p.key === this.presetKey)))
56
- this.presetKey = (_p = (_o = (_m = (_l = this.options) === null || _l === void 0 ? void 0 : _l.presets) === null || _m === void 0 ? void 0 : _m[0]) === null || _o === void 0 ? void 0 : _o.key) !== null && _p !== void 0 ? _p : '';
133
+ this.fitNote = undefined;
134
+ this._endDrag();
135
+ this.presetSize = { ...((_k = (_j = this.options) === null || _j === void 0 ? void 0 : _j.presetSize) !== null && _k !== void 0 ? _k : {}) };
136
+ if (!((_m = (_l = this.options) === null || _l === void 0 ? void 0 : _l.presets) === null || _m === void 0 ? void 0 : _m.some(p => p.key === this.presetKey)))
137
+ this.presetKey = (_r = (_q = (_p = (_o = this.options) === null || _o === void 0 ? void 0 : _o.presets) === null || _p === void 0 ? void 0 : _p[0]) === null || _q === void 0 ? void 0 : _q.key) !== null && _r !== void 0 ? _r : '';
138
+ this._startFromHost();
139
+ }
140
+ /* 레이아웃이 없으면 호스트가 고른 프리셋(startFrom)으로 시작한 초안을 보인다. 저장은 [적용] 때 — 끼어들어 쓰지 않는다 */
141
+ _startFromHost() {
142
+ var _a, _b, _c, _d, _e, _f, _g, _h;
143
+ const key = (_a = this.options) === null || _a === void 0 ? void 0 : _a.startFrom;
144
+ if (this.draft || this.value || ((_b = this.options) === null || _b === void 0 ? void 0 : _b.effective) || ((_c = this.options) === null || _c === void 0 ? void 0 : _c.live) || !key)
145
+ return;
146
+ const preset = (_e = (_d = this.options) === null || _d === void 0 ? void 0 : _d.presets) === null || _e === void 0 ? void 0 : _e.find(p => p.key === key);
147
+ const built = preset && !((_f = preset.params) === null || _f === void 0 ? void 0 : _f.length) ? applyPreset(preset, this.presetSize, {}) : undefined;
148
+ if (!(built === null || built === void 0 ? void 0 : built.layout))
149
+ return;
150
+ this.draft = structuredClone(built.layout);
151
+ this.dirty = true;
152
+ this.tier = (_h = (_g = this.draft.tiers[0]) === null || _g === void 0 ? void 0 : _g.tierNo) !== null && _h !== void 0 ? _h : 1;
57
153
  }
58
154
  get issues() {
59
155
  var _a, _b, _c, _d, _e;
@@ -61,58 +157,95 @@ let OxSlotLayoutEditor = class OxSlotLayoutEditor extends localize(i18next)(LitE
61
157
  return [];
62
158
  const anchors = (_b = (_a = this.options) === null || _a === void 0 ? void 0 : _a.anchors) === null || _b === void 0 ? void 0 : _b.map(a => a.id);
63
159
  const invalid = [...validateSlotLayout(this.draft, { anchors }), ...((_e = (_d = (_c = this.options) === null || _c === void 0 ? void 0 : _c.validate) === null || _d === void 0 ? void 0 : _d.call(_c, this.draft)) !== null && _e !== void 0 ? _e : [])];
160
+ // 호스트 검증이 같은 문제를 다시 내도 한 번만 적는다
161
+ const seen = new Set();
162
+ const unique = invalid.filter(i => !seen.has(`${i.code}@${i.path}`) && !!seen.add(`${i.code}@${i.path}`));
64
163
  // 저장 불가 검사는 검증을 통과한 값에만 보인다 (같은 문제를 두 번 적지 않는다)
65
- return invalid.length ? invalid : persistenceIssues(this.draft);
164
+ return unique.length ? unique : persistenceIssues(this.draft);
66
165
  }
67
- /** 자리끼리 겹치거나 면 밖으로 나가는 설정 경고. 적용을 막지 않는다. 적재면 크기를 주지 않은 호스트에는 확인 불가를 보이지 않는다 */
166
+ /** 자리끼리 겹치거나 면 밖으로 나가는 설정, 면 크기를 몰라 확인하지 못한 Anchor. 적용을 막지 않는다 */
68
167
  get warnings() {
69
168
  var _a;
70
169
  if (!this.draft || this.issues.length)
71
170
  return [];
72
- const faces = (_a = this.options) === null || _a === void 0 ? void 0 : _a.anchorFaces;
73
- return slotSpaceWarnings(this.draft, { anchorFaces: faces }).filter(w => faces || w.code !== 'ANCHOR_SPACE_UNKNOWN');
171
+ return slotSpaceWarnings(this.draft, { anchorFaces: (_a = this.options) === null || _a === void 0 ? void 0 : _a.anchorFaces });
74
172
  }
75
173
  /** 적용하면 뜻이 바뀌는 주소 (지금 적용된 값 기준) */
76
174
  get impact() {
77
175
  var _a, _b;
78
176
  return this.dirty && !this.issues.length ? addressImpact((_a = this.value) !== null && _a !== void 0 ? _a : (_b = this.options) === null || _b === void 0 ? void 0 : _b.effective, this.draft) : { moved: [], gone: [] };
79
177
  }
178
+ _message(issue) {
179
+ var _a, _b, _c;
180
+ // 편집기가 아는 코드는 칸 옆에 위치 없이 보인다. 호스트 고유 코드는 호스트 문구
181
+ if (issue.code in ISSUE_TEXT)
182
+ return issueMessage(issue);
183
+ return (_c = (_b = (_a = this.options) === null || _a === void 0 ? void 0 : _a.issueText) === null || _b === void 0 ? void 0 : _b.call(_a, issue)) !== null && _c !== void 0 ? _c : issueMessage(issue);
184
+ }
80
185
  _text(issue) {
81
186
  var _a, _b, _c;
82
187
  return (_c = (_b = (_a = this.options) === null || _a === void 0 ? void 0 : _a.issueText) === null || _b === void 0 ? void 0 : _b.call(_a, issue)) !== null && _c !== void 0 ? _c : defaultIssueText(issue);
83
188
  }
84
189
  _edit(next) {
85
- var _a, _b;
190
+ var _a, _b, _c;
86
191
  this.draft = next;
87
192
  this.dirty = true;
193
+ this.fitNote = undefined;
88
194
  if (!next.tiers.some(x => x.tierNo === this.tier))
89
195
  this.tier = (_b = (_a = next.tiers[next.tiers.length - 1]) === null || _a === void 0 ? void 0 : _a.tierNo) !== null && _b !== void 0 ? _b : 1;
196
+ if (((_c = this.options) === null || _c === void 0 ? void 0 : _c.live) && !this.issues.length)
197
+ this._apply();
90
198
  }
91
199
  _apply() {
92
200
  this.value = structuredClone(this.draft);
201
+ this.dirty = false;
93
202
  this.dispatchEvent(new Event('change', { bubbles: true, composed: true }));
94
203
  }
95
204
  _useDefault() {
96
205
  this.dispatchEvent(new CustomEvent('slot-layout-use-default', { bubbles: true, composed: true }));
97
206
  }
98
207
  render() {
99
- var _a, _b, _c, _d;
208
+ var _a, _b, _c;
100
209
  const issues = this.issues;
101
- const canUseDefault = (_b = (_a = this.options) === null || _a === void 0 ? void 0 : _a.canUseDefault) !== null && _b !== void 0 ? _b : this.value !== undefined;
210
+ const live = !!((_a = this.options) === null || _a === void 0 ? void 0 : _a.live);
211
+ const canUseDefault = (_c = (_b = this.options) === null || _b === void 0 ? void 0 : _b.canUseDefault) !== null && _c !== void 0 ? _c : this.value !== undefined;
102
212
  return html `
103
- ${((_d = (_c = this.options) === null || _c === void 0 ? void 0 : _c.notes) === null || _d === void 0 ? void 0 : _d.length)
104
- ? html `<div class="notes">
105
- ${this.options.notes.map(n => html `<div class=${n.level === 'error' ? 'error' : 'note'}>${n.text}</div>`)}
106
- </div>`
107
- : nothing}
108
- ${this._renderPreset()} ${this.draft ? this._renderDraft(issues) : nothing} ${this._renderImpact()}
213
+ ${this._renderNotes()} ${this.draft ? this._renderDraft(issues) : nothing} ${this._renderPreset()} ${live ? nothing : this._renderImpact()}
109
214
  <div class="actions">
110
215
  ${canUseDefault ? html `<button data-action="use-default" @click=${this._useDefault}>${t('use-default', 'Use default')}</button>` : nothing}
111
- <button data-action="discard" ?disabled=${!this.dirty} @click=${() => this.reset()}>${t('discard', 'Discard')}</button>
112
- <button data-action="apply" ?disabled=${!this.dirty || !this.draft || issues.length > 0} @click=${this._apply}>${t('apply', 'Apply')}</button>
216
+ ${live
217
+ ? nothing
218
+ : html `
219
+ <button data-action="discard" ?disabled=${!this.dirty} @click=${() => this.reset()}>${t('discard', 'Discard')}</button>
220
+ <button data-action="apply" ?disabled=${!this.dirty || !this.draft || issues.length > 0} @click=${this._apply}>${t('apply', 'Apply')}</button>
221
+ `}
113
222
  </div>
114
223
  `;
115
224
  }
225
+ _renderNotes() {
226
+ var _a, _b;
227
+ const notes = (_b = (_a = this.options) === null || _a === void 0 ? void 0 : _a.notes) !== null && _b !== void 0 ? _b : [];
228
+ if (!notes.length)
229
+ return nothing;
230
+ const text = (n) => {
231
+ var _a;
232
+ return (_a = n.text) !== null && _a !== void 0 ? _a : (n.code === 'OVERRIDE_INVALID' && n.issue
233
+ ? t('override-invalid', "This placement's layout does not hold and offers no slots: {text}", { text: this._located(n.issue), interpolation: { escapeValue: false } })
234
+ : n.issue
235
+ ? this._located(n.issue)
236
+ : '');
237
+ };
238
+ return html `<div class="notes">${notes.map(n => html `<div class=${n.level === 'error' ? 'error' : 'note'}>${text(n)}</div>`)}</div>`;
239
+ }
240
+ /** 문제를 Tier·칸 이름과 함께. 칸을 모르면 경로 그대로 */
241
+ _located(issue) {
242
+ var _a, _b, _c, _d;
243
+ const { tierIndex, field } = issueField(issue);
244
+ const tierNo = tierIndex === undefined ? undefined : (_d = (_c = (_b = (_a = this.draft) === null || _a === void 0 ? void 0 : _a.tiers) === null || _b === void 0 ? void 0 : _b[tierIndex]) === null || _c === void 0 ? void 0 : _c.tierNo) !== null && _d !== void 0 ? _d : tierIndex + 1;
245
+ if (tierNo === undefined || !field)
246
+ return this._text(issue);
247
+ return `${t('tier-n', 'Tier {n}', { n: tierNo })} · ${fieldLabel(field)}: ${this._message(issue)}`;
248
+ }
116
249
  _renderPreset() {
117
250
  var _a, _b, _c, _d;
118
251
  const presets = (_b = (_a = this.options) === null || _a === void 0 ? void 0 : _a.presets) !== null && _b !== void 0 ? _b : [];
@@ -120,30 +253,58 @@ let OxSlotLayoutEditor = class OxSlotLayoutEditor extends localize(i18next)(LitE
120
253
  return nothing;
121
254
  const preset = (_c = presets.find(p => p.key === this.presetKey)) !== null && _c !== void 0 ? _c : presets[0];
122
255
  const built = applyPreset(preset, this.presetSize, this.presetParams);
256
+ const positive = (v) => typeof v === 'number' && Number.isFinite(v) && v > 0;
257
+ const sized = positive(this.presetSize.width) && positive(this.presetSize.depth) && positive(this.presetSize.slotHeight);
123
258
  const numberRow = (label, value, onChange, key) => html `
124
- <div class="row">
125
- <label>${label}</label>
126
- <input type="number" data-preset=${key} .value=${value === undefined || Number.isNaN(value) ? '' : String(value)} @change=${(e) => onChange(num(e))} />
127
- </div>
259
+ <label class="l">${label}</label>
260
+ <input type="number" data-preset=${key} .value=${shown(value)} @change=${(e) => onChange(num(e))} />
128
261
  `;
262
+ /* 값을 더 묻지 않는 프리셋은 누르면 바로 시작한다. 값을 묻는 프리셋(행 수 등)은 그 칸과 [시작]을 연다 */
263
+ const choose = (p) => {
264
+ var _a;
265
+ this.presetKey = p.key;
266
+ if ((_a = p.params) === null || _a === void 0 ? void 0 : _a.length)
267
+ return;
268
+ const made = applyPreset(p, this.presetSize, {});
269
+ if (made.layout)
270
+ this._startFrom(made.layout);
271
+ };
129
272
  return html `
130
- <h4>${t('preset', 'Preset')}</h4>
131
- <div class="row">
132
- <label>${t('preset-kind', 'Kind')}</label>
133
- <select data-preset="kind" @change=${(e) => (this.presetKey = e.target.value)}>
134
- ${presets.map(p => html `<option value=${p.key} ?selected=${p.key === preset.key}>${t(`preset.${p.key}`, p.key)}</option>`)}
135
- </select>
136
- </div>
137
- ${numberRow(t('width', 'Width (X)'), this.presetSize.width, n => (this.presetSize = { ...this.presetSize, width: n }), 'width')}
138
- ${numberRow(t('depth', 'Depth (Z)'), this.presetSize.depth, n => (this.presetSize = { ...this.presetSize, depth: n }), 'depth')}
139
- ${numberRow(t('slot-height', 'Slot height'), this.presetSize.slotHeight, n => (this.presetSize = { ...this.presetSize, slotHeight: n }), 'slotHeight')}
140
- ${((_d = preset.params) !== null && _d !== void 0 ? _d : []).map(p => numberRow(t(`preset-param.${p.key}`, p.key), this.presetParams[p.key], n => (this.presetParams = { ...this.presetParams, [p.key]: n }), p.key))}
141
- <div class="note">${t('preset-note', 'Pitch and slot size are split evenly as starting values. Confirm them against the real equipment.')}</div>
142
- <div class="actions">
143
- <button data-action="start-preset" ?disabled=${!built.layout} @click=${() => built.layout && this._startFrom(built.layout)}>
144
- ${t('start-from-preset', 'Start from preset')}
145
- </button>
146
- </div>
273
+ <details data-section="preset" ?open=${!this.draft}>
274
+ <summary>${t('preset', 'Start again from a preset')}</summary>
275
+ <div class="tiers" data-preset="kind" role="group">
276
+ ${presets.map(p => {
277
+ var _a;
278
+ return html `<button
279
+ data-preset-key=${p.key}
280
+ aria-pressed=${((_a = p.params) === null || _a === void 0 ? void 0 : _a.length) && p.key === preset.key ? 'true' : 'false'}
281
+ ?disabled=${!sized}
282
+ @click=${() => choose(p)}
283
+ >
284
+ ${t(`preset.${p.key}`, p.key)}
285
+ </button>`;
286
+ })}
287
+ </div>
288
+ ${((_d = preset.params) === null || _d === void 0 ? void 0 : _d.length)
289
+ ? html `<section>
290
+ ${preset.params.map(p => numberRow(t(`preset-param.${p.key}`, p.key), this.presetParams[p.key], n => (this.presetParams = { ...this.presetParams, [p.key]: n }), p.key))}
291
+ </section>
292
+ <div class="actions">
293
+ <button data-action="start-preset" ?disabled=${!built.layout} @click=${() => built.layout && this._startFrom(built.layout)}>
294
+ ${t('start-from-preset', 'Start from preset')}
295
+ </button>
296
+ </div>`
297
+ : nothing}
298
+ <details data-section="preset-size" ?open=${!sized}>
299
+ <summary>${t('preset-size', 'Size (enter by hand)')}</summary>
300
+ <section>
301
+ ${numberRow(t('width', 'Width (X)'), this.presetSize.width, n => (this.presetSize = { ...this.presetSize, width: n }), 'width')}
302
+ ${numberRow(t('depth', 'Depth (Z)'), this.presetSize.depth, n => (this.presetSize = { ...this.presetSize, depth: n }), 'depth')}
303
+ ${numberRow(t('slot-height', 'Slot height'), this.presetSize.slotHeight, n => (this.presetSize = { ...this.presetSize, slotHeight: n }), 'slotHeight')}
304
+ </section>
305
+ </details>
306
+ <div class="hint">${t('preset-note', 'Pitch and slot size are split evenly as starting values. Confirm them against the real equipment.')}</div>
307
+ </details>
147
308
  `;
148
309
  }
149
310
  /** 적용 전에 뜻이 바뀌는 주소를 보인다. 놓인 Carrier 는 주소로 다시 읽혀 다른 자리에 그려지거나 예외가 된다 */
@@ -164,177 +325,435 @@ let OxSlotLayoutEditor = class OxSlotLayoutEditor extends localize(i18next)(LitE
164
325
  this._edit({ ...extra, ...structuredClone(layout) });
165
326
  }
166
327
  _renderDraft(issues) {
167
- var _a, _b, _c, _d, _e, _f;
328
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j;
168
329
  const draft = this.draft;
169
330
  const tier = (_b = (_a = draft.tiers) === null || _a === void 0 ? void 0 : _a.find(x => x.tierNo === this.tier)) !== null && _b !== void 0 ? _b : (_c = draft.tiers) === null || _c === void 0 ? void 0 : _c[0];
170
- const warnings = this.warnings;
171
- const issueList = issues.length
172
- ? html `<div class="error" data-issues>${issues.map(i => html `<div>· ${this._text(i)}</div>`)}</div>`
173
- : warnings.length
174
- ? html `<div class="note" data-warnings>${warnings.map(w => html `<div>· ${spaceWarningText(w)}</div>`)}</div>`
175
- : nothing;
176
331
  // 층이 없는 값(잘못된 저장값)은 편집할 층이 없으므로 문제만 보인다. 프리셋으로 다시 시작한다
177
332
  if (!tier)
178
- return issueList;
333
+ return issues.length ? html `<div class="error" data-issues>${issues.map(i => html `<div>· ${this._text(i)}</div>`)}</div>` : nothing;
334
+ const index = draft.tiers.indexOf(tier);
335
+ // 고른 Tier 의 문제는 칸 옆에, 나머지(다른 Tier, 칸이 없는 경로)는 목록에
336
+ const errors = new Map();
337
+ const listed = [];
338
+ for (const issue of issues) {
339
+ const { tierIndex, field } = issueField(issue);
340
+ if (tierIndex === index && field)
341
+ errors.set(field, [...((_d = errors.get(field)) !== null && _d !== void 0 ? _d : []), this._message(issue)]);
342
+ else
343
+ listed.push(issue);
344
+ }
179
345
  return html `
180
- <h4>${t('tiers', 'Tiers')}</h4>
181
- <div class="chips">
182
- ${draft.tiers.map(x => html `<button data-tier=${x.tierNo} ?active=${x.tierNo === tier.tierNo} @click=${() => (this.tier = x.tierNo)}>${t('tier-n', 'Tier {n}', { n: x.tierNo })}</button>`)}
183
- </div>
184
- <div class="actions">
185
- <button data-action="copy-tier" @click=${() => this._copyTier(draft, tier.tierNo)}>${t('copy-tier', 'Copy tier')}</button>
346
+ ${this._renderPicture(draft, tier.tierNo)}
347
+ <div class="tiers">
348
+ ${draft.tiers.map(x => html `<button data-tier=${x.tierNo} aria-pressed=${x.tierNo === tier.tierNo ? 'true' : 'false'} @click=${() => (this.tier = x.tierNo)}>
349
+ ${t('tier-n', 'Tier {n}', { n: x.tierNo })}
350
+ </button>`)}
351
+ <button class="end" data-action="copy-tier" @click=${() => this._copyTier(draft, tier.tierNo)}>${t('copy-tier', 'Copy tier')}</button>
186
352
  <button data-action="remove-tier" ?disabled=${draft.tiers.length <= 1} @click=${() => this._edit(removeTier(draft, tier.tierNo))}>${t('remove-tier', 'Remove tier')}</button>
187
353
  </div>
188
- ${this._renderTier(draft, tier.tierNo)} ${(_f = (_e = (_d = this.options) === null || _d === void 0 ? void 0 : _d.renderExtra) === null || _e === void 0 ? void 0 : _e.call(_d, draft, next => this._edit(next))) !== null && _f !== void 0 ? _f : nothing}
189
- <h4>${t('preview', 'Preview (top view, tier {n}, in the tier)', { n: tier.tierNo })}</h4>
190
- ${this._renderPreview(draft, tier.tierNo)} ${issueList}
354
+ ${listed.length || (issues.length && ((_e = this.options) === null || _e === void 0 ? void 0 : _e.live))
355
+ ? html `<div class="error" data-issues>
356
+ ${listed.map(i => html `<div>· ${this._located(i)}</div>`)}
357
+ ${((_f = this.options) === null || _f === void 0 ? void 0 : _f.live) && issues.length ? html `<div class="note" data-held>${t('held-back', 'Not applied until fixed: the scene keeps the last sound layout.')}</div>` : nothing}
358
+ </div>`
359
+ : nothing}
360
+ ${this._renderTier(draft, tier.tierNo, errors)} ${(_j = (_h = (_g = this.options) === null || _g === void 0 ? void 0 : _g.renderExtra) === null || _h === void 0 ? void 0 : _h.call(_g, draft, next => this._edit(next))) !== null && _j !== void 0 ? _j : nothing}
191
361
  `;
192
362
  }
193
363
  /** 복사한 Tier 를 골라 둔다. 같은 자리에 겹쳐 생기므로 바로 이어서 배치를 고친다 */
194
364
  _copyTier(draft, tierNo) {
195
365
  const next = copyTier(draft, tierNo);
196
- this._edit(next);
197
366
  this.tier = next.tiers[next.tiers.length - 1].tierNo;
367
+ this._edit(next);
368
+ }
369
+ // ── 그림 ──────────────────────────────────────────────────────────────────
370
+ _anchorName(id) {
371
+ var _a, _b, _c, _d;
372
+ const a = (_b = (_a = this.options) === null || _a === void 0 ? void 0 : _a.anchors) === null || _b === void 0 ? void 0 : _b.find(x => x.id === id);
373
+ if (a === null || a === void 0 ? void 0 : a.label)
374
+ return a.label;
375
+ if (a === null || a === void 0 ? void 0 : a.part)
376
+ return `${id} · ${t(`face-${(_c = a.face) !== null && _c !== void 0 ? _c : 'top'}`, `{part} ${(_d = a.face) !== null && _d !== void 0 ? _d : 'top'}`, { part: a.part })}`;
377
+ return id;
378
+ }
379
+ _unit() {
380
+ var _a;
381
+ return ((_a = this.options) === null || _a === void 0 ? void 0 : _a.unit) ? html ` <span class="unit">${this.options.unit}</span>` : nothing;
198
382
  }
199
- _renderTier(draft, tierNo) {
383
+ /** 고른 Tier 의 Anchor 면 위에서 본 자리 (Anchor 기준). 오른쪽이 +X, 위쪽이 −Z */
384
+ _renderPicture(draft, tierNo) {
200
385
  var _a, _b, _c, _d, _e, _f, _g, _h;
201
386
  const tier = draft.tiers.find(x => x.tierNo === tierNo);
202
- const l = tier.layout;
203
- const patternInput = (k, label, value, parse = num) => html `
204
- <div class="row">
205
- <label>${label}</label>
206
- <input type="number" data-field=${k} .value=${value === undefined || Number.isNaN(value) ? '' : String(value)} @change=${(e) => this._edit(updatePattern(draft, tierNo, { [k]: parse(e) }))} />
207
- </div>
208
- `;
209
- const select = (field, label, value, options, onChange) => html `
210
- <div class="row">
211
- <label>${label}</label>
212
- <select data-field=${field} @change=${(e) => onChange(e.target.value)}>
213
- ${options.map(o => html `<option value=${o} ?selected=${o === value}>${o}</option>`)}
214
- </select>
215
- </div>
216
- `;
387
+ const face = (_b = (_a = this.options) === null || _a === void 0 ? void 0 : _a.anchorFaces) === null || _b === void 0 ? void 0 : _b[tier.anchorId];
388
+ const slots = anchorFaceSlots(draft, tier.anchorId);
389
+ const mine = slots.filter(s => s.tier === tierNo);
390
+ if (!mine.length)
391
+ return html `<div class="note" data-preview="none">${t('preview-none', 'No slots to show with the current values.')}</div>`;
392
+ const boxes = slots.map(s => ({ s, fp: rotatedFootprint({ width: s.width, depth: s.depth, height: 0 }, s.rotation) }));
393
+ const xs = boxes.flatMap(({ s, fp }) => [s.x - fp.width / 2, s.x + fp.width / 2]).concat(face ? [-face.width / 2, face.width / 2] : []);
394
+ const zs = boxes.flatMap(({ s, fp }) => [s.z - fp.depth / 2, s.z + fp.depth / 2]).concat(face ? [-face.depth / 2, face.depth / 2] : []);
395
+ const [minX, maxX, minZ, maxZ] = [Math.min(...xs), Math.max(...xs), Math.min(...zs), Math.max(...zs)];
396
+ const size = Math.max(maxX - minX, maxZ - minZ, 1);
397
+ const pad = size * 0.06;
398
+ const viewBox = (_d = (_c = this._drag) === null || _c === void 0 ? void 0 : _c.viewBox) !== null && _d !== void 0 ? _d : `${minX - pad} ${minZ - pad} ${maxX - minX + pad * 2} ${maxZ - minZ + pad * 2}`;
399
+ const font = size * 0.045;
400
+ const stroke = size * 0.004;
401
+ const flagged = new Map();
402
+ for (const w of this.warnings) {
403
+ const id = `t${(_e = w.params) === null || _e === void 0 ? void 0 : _e.tier}_s${(_f = w.params) === null || _f === void 0 ? void 0 : _f.slot}`;
404
+ if (w.code === 'SLOT_OUTSIDE_ANCHOR')
405
+ flagged.set(id, 'out');
406
+ else if (w.code === 'SLOT_OVERLAP' && !flagged.has(id))
407
+ flagged.set(id, 'overlap');
408
+ }
409
+ const items = (_h = (_g = this.options) === null || _g === void 0 ? void 0 : _g.items) !== null && _h !== void 0 ? _h : [];
410
+ const onSlot = (id) => items.filter(i => i.slot === id).map(i => i.id);
411
+ const tl = tier.layout;
412
+ const last = tl.pattern === 'ROW' && tl.count >= 2 ? mine.find(s => s.slotNo === tl.count) : undefined;
413
+ /* 간격 손잡이: 끝 자리 안, 줄과 직각으로 가장자리 쪽에 둔다(글자를 가리지 않게). 간격은 줄 방향 거리로만 정해진다 */
414
+ const handle = last && (() => {
415
+ const first = mine.find(s => s.slotNo === 1);
416
+ const length = Math.hypot(last.x - first.x, last.z - first.z) || 1;
417
+ const across = { x: -(last.z - first.z) / length, z: (last.x - first.x) / length };
418
+ const room = Math.min(last.width, last.depth) / 2 - font;
419
+ return { x: last.x + across.x * room, z: last.z + across.z * room };
420
+ })();
217
421
  return html `
218
- ${this._renderPlacement(draft, tierNo)}
219
- ${select('pattern', t('pattern', 'Pattern'), l.pattern, ['SINGLE', 'ROW', 'GRID', 'CUSTOM'], v => this._edit(setTierPattern(draft, tierNo, v)))}
220
- ${l.pattern === 'ROW'
221
- ? html `
222
- ${patternInput('count', t('count', 'Slots'), l.count)} ${patternInput('pitch', t('pitch', 'Pitch'), l.pitch)}
223
- ${select('axis', t('axis', 'Axis'), l.axis, ['X', 'Z'], v => this._edit(updatePattern(draft, tierNo, { axis: v })))}
224
- ${select('origin', t('origin', 'Slot 1 at'), (_a = l.origin) !== null && _a !== void 0 ? _a : 'MIN', ['MIN', 'MAX'], v => this._edit(updatePattern(draft, tierNo, { origin: v })))}
225
- `
422
+ <svg
423
+ data-preview
424
+ viewBox=${viewBox}
425
+ preserveAspectRatio="xMidYMid meet"
426
+ tabindex="0"
427
+ @pointermove=${this._onDragMove}
428
+ @pointerup=${this._onDragEnd}
429
+ @pointercancel=${this._cancelDrag}
430
+ >
431
+ ${face
432
+ ? svg `<rect data-face x=${-face.width / 2} y=${-face.depth / 2} width=${face.width} height=${face.depth}
433
+ fill="rgba(0,0,0,0.04)" stroke="rgba(0,0,0,0.45)" stroke-width=${stroke} />`
226
434
  : nothing}
227
- ${l.pattern === 'GRID'
228
- ? html `
229
- ${patternInput('rows', t('rows', 'Rows (Z)'), l.rows)} ${patternInput('cols', t('cols', 'Columns (X)'), l.cols)}
230
- ${patternInput('pitch', t('pitch-x', 'Pitch X'), (_b = l.pitch) === null || _b === void 0 ? void 0 : _b.x, e => ({ ...l.pitch, x: num(e) }))}
231
- ${patternInput('pitch', t('pitch-z', 'Pitch Z'), (_c = l.pitch) === null || _c === void 0 ? void 0 : _c.z, e => ({ ...l.pitch, z: num(e) }))}
232
- ${select('order', t('order', 'Numbering'), (_d = l.order) !== null && _d !== void 0 ? _d : 'ROW_MAJOR', ['ROW_MAJOR', 'COL_MAJOR', 'SERPENTINE_ROW', 'SERPENTINE_COL'], v => this._edit(updatePattern(draft, tierNo, { order: v })))}
233
- ${select('origin-x', t('origin-x', 'Slot 1 at X'), (_f = (_e = l.origin) === null || _e === void 0 ? void 0 : _e.x) !== null && _f !== void 0 ? _f : 'MIN', ['MIN', 'MAX'], v => this._edit(updatePattern(draft, tierNo, { origin: { z: 'MIN', ...l.origin, x: v } })))}
234
- ${select('origin-z', t('origin-z', 'Slot 1 at Z'), (_h = (_g = l.origin) === null || _g === void 0 ? void 0 : _g.z) !== null && _h !== void 0 ? _h : 'MIN', ['MIN', 'MAX'], v => this._edit(updatePattern(draft, tierNo, { origin: { x: 'MIN', ...l.origin, z: v } })))}
235
- `
435
+ ${slots
436
+ .filter(s => s.tier !== tierNo)
437
+ .map(s => svg `<g transform="translate(${s.x} ${s.z}) rotate(${-s.rotation})">
438
+ <rect data-other-slot=${s.id} x=${-s.width / 2} y=${-s.depth / 2} width=${s.width} height=${s.depth}
439
+ fill="none" stroke="rgba(0,0,0,0.35)" stroke-dasharray="${stroke * 4} ${stroke * 3}" stroke-width=${stroke} />
440
+ </g>`)}
441
+ ${mine.map(s => {
442
+ const flag = flagged.get(s.id);
443
+ const colour = flag ? ERROR : SLOT;
444
+ return svg `<g transform="translate(${s.x} ${s.z}) rotate(${-s.rotation})">
445
+ <rect data-slot=${s.slotNo} data-slot-id=${s.id} data-flag=${flag !== null && flag !== void 0 ? flag : ''} x=${-s.width / 2} y=${-s.depth / 2} width=${s.width} height=${s.depth}
446
+ fill=${flag ? 'rgba(198,40,40,0.1)' : 'rgba(25,118,210,0.1)'} stroke=${colour} stroke-width=${stroke}
447
+ stroke-dasharray=${flag ? `${stroke * 4} ${stroke * 3}` : 'none'}
448
+ @pointerdown=${(e) => this._startDrag(e, 'move', tierNo)} />
449
+ </g>`;
450
+ })}
451
+ ${mine.map(s => {
452
+ const held = onSlot(s.id);
453
+ return svg `<text x=${s.x} y=${s.z - (held.length ? font * 0.6 : 0)} font-size=${font} text-anchor="middle" dominant-baseline="central"
454
+ fill=${flagged.has(s.id) ? '#791f1f' : '#0d47a1'} pointer-events="none">${s.id}</text>
455
+ ${held.length
456
+ ? svg `<text data-held-on=${s.id} x=${s.x} y=${s.z + font * 0.7} font-size=${font * 0.85} text-anchor="middle" dominant-baseline="central"
457
+ fill="rgba(0,0,0,0.6)" pointer-events="none">${held.length > 2 ? `${held.slice(0, 2).join(', ')} +${held.length - 2}` : held.join(', ')}</text>`
458
+ : nothing}`;
459
+ })}
460
+ ${handle
461
+ ? svg `<circle data-handle="pitch" cx=${handle.x} cy=${handle.z} r=${font * 0.45} fill=${SLOT} stroke="#fff" stroke-width=${stroke}
462
+ @pointerdown=${(e) => this._startDrag(e, 'pitch', tierNo)} />`
236
463
  : nothing}
237
- ${l.pattern === 'CUSTOM' ? this._renderCustom(draft, tierNo) : this._renderSpec(draft, tierNo, l.slot)}
464
+ </svg>
465
+ <div class="caption">
466
+ <span data-face-caption>
467
+ ${face
468
+ ? html `${this._anchorName(tier.anchorId)} · ${shown(face.width)} × ${shown(face.depth)}${this._unit()}`
469
+ : html `<span class="error">${t('face-unknown', 'Face size unknown: whether slots stay on it is not checked')}</span>`}
470
+ </span>
471
+ <span>${t('preview-axes', 'Right = +X, up = −Z')}</span>
472
+ </div>
473
+ ${this._renderSpaceWarnings(tier.anchorId, !!face)} ${this._renderItems(draft)}
474
+ `;
475
+ }
476
+ /** 면 밖·겹침 경고. 고른 Tier 의 Anchor 면을 모른다는 것은 그림 아래에 이미 적었다 */
477
+ _renderSpaceWarnings(anchorId, faceKnown) {
478
+ const warnings = this.warnings.filter(w => { var _a; return !(w.code === 'ANCHOR_SPACE_UNKNOWN' && !faceKnown && ((_a = w.params) === null || _a === void 0 ? void 0 : _a.anchorId) === anchorId); });
479
+ if (!warnings.length)
480
+ return nothing;
481
+ return html `<div class="error" data-warnings>${warnings.map(w => html `<div>· ${spaceWarningText(w)}</div>`)}</div>`;
482
+ }
483
+ /**
484
+ * 실린 아이템이 편집 중인 레이아웃에서 어떻게 되는지. 자리가 없어지면 미배치로 남고(지우거나 옮기지 않는다), 받는 타입·규격은
485
+ * 공통 판정으로 본다. 치수를 모르는 아이템은 판정하지 않았다고 적는다 — 모양을 자리에 맞춰 그리는 것은 판정이 아니다.
486
+ */
487
+ _renderItems(draft) {
488
+ var _a, _b;
489
+ const items = (_b = (_a = this.options) === null || _a === void 0 ? void 0 : _a.items) !== null && _b !== void 0 ? _b : [];
490
+ if (!items.length)
491
+ return nothing;
492
+ const statuses = heldItemStatuses(draft, items);
493
+ const shownItems = statuses.slice(0, 12);
494
+ return html `<div class="items" data-items>
495
+ ${shownItems.map(s => {
496
+ var _a;
497
+ const line = [];
498
+ if (!s.placed)
499
+ line.push(html `<span class="bad">${fitText('SLOT_NOT_FOUND')}</span>`);
500
+ else {
501
+ if (s.unregistered)
502
+ line.push(html `<span class="warn">${t('unregistered-type', 'Type not registered')}</span>`);
503
+ for (const r of s.reasons)
504
+ line.push(html `<span class=${r === 'SPEC_UNKNOWN' ? 'note' : 'bad'}>${fitText(r)}</span>`);
505
+ if (s.verdict === 'OK')
506
+ line.push(html `<span class="note">OK</span>`);
507
+ }
508
+ return html `<div data-item=${s.item.id} data-verdict=${s.placed ? s.verdict : 'UNPLACED'}>
509
+ <b>${s.item.id}</b> · ${(_a = s.item.slot) !== null && _a !== void 0 ? _a : '—'}${s.item.code ? html ` · ${s.item.code}` : nothing} — ${line.map((x, i) => html `${i ? ', ' : ''}${x}`)}
510
+ </div>`;
511
+ })}
512
+ ${statuses.length > shownItems.length ? html `<div class="note">… (${statuses.length})</div>` : nothing}
513
+ </div>`;
514
+ }
515
+ // ── 끌기 ──────────────────────────────────────────────────────────────────
516
+ _point(e) {
517
+ const svgEl = this.renderRoot.querySelector('svg[data-preview]');
518
+ const m = svgEl === null || svgEl === void 0 ? void 0 : svgEl.getScreenCTM();
519
+ if (!m)
520
+ return null;
521
+ const p = new DOMPoint(e.clientX, e.clientY).matrixTransform(m.inverse());
522
+ return { x: p.x, z: p.y };
523
+ }
524
+ _startDrag(e, kind, tierNo) {
525
+ var _a;
526
+ const from = this._point(e);
527
+ const svgEl = this.renderRoot.querySelector('svg[data-preview]');
528
+ if (!from || !svgEl || !this.draft)
529
+ return;
530
+ e.preventDefault();
531
+ e.stopPropagation();
532
+ try {
533
+ svgEl.setPointerCapture(e.pointerId);
534
+ }
535
+ catch (_b) {
536
+ // 이미 놓인 포인터: 끌기는 svg 안에서만 이어진다
537
+ }
538
+ this._drag = { kind, tierNo, from, before: structuredClone(this.draft), viewBox: (_a = svgEl.getAttribute('viewBox')) !== null && _a !== void 0 ? _a : '' };
539
+ // 끄는 동안의 Esc 는 포커스가 어디 있든 끌기를 거둔다
540
+ window.addEventListener('keydown', this._onDragKey, true);
541
+ }
542
+ _endDrag() {
543
+ const d = this._drag;
544
+ this._drag = undefined;
545
+ window.removeEventListener('keydown', this._onDragKey, true);
546
+ return d;
547
+ }
548
+ disconnectedCallback() {
549
+ super.disconnectedCallback();
550
+ this._endDrag();
551
+ }
552
+ // ── 칸 ────────────────────────────────────────────────────────────────────
553
+ _row(field, label, control, errors) {
554
+ const messages = errors.get(field);
555
+ return html `
556
+ <label class="l">${label}</label>
557
+ ${control} ${messages ? html `<div class="err" data-error=${field}>${messages.join(' · ')}</div>` : nothing}
238
558
  `;
239
559
  }
240
- /** Tier 가 선 Anchor, 적재 방식, Anchor 기준 위치·회전 */
241
- _renderPlacement(draft, tierNo) {
242
- var _a, _b, _c, _d, _e, _f;
560
+ _num(field, value, onChange, errors, extra = {}) {
561
+ var _a, _b;
562
+ return html `<input
563
+ type="number"
564
+ data-field=${field}
565
+ ?invalid=${errors.has(field)}
566
+ min=${(_a = extra.min) !== null && _a !== void 0 ? _a : nothing}
567
+ step=${(_b = extra.step) !== null && _b !== void 0 ? _b : nothing}
568
+ .value=${shown(value)}
569
+ @change=${(e) => onChange(num(e))}
570
+ />`;
571
+ }
572
+ _seg(field, value, options, onChange, errors) {
573
+ return html `<div class="seg" role="radiogroup" data-field=${field} ?invalid=${errors.has(field)}>
574
+ ${options.map(([v, label]) => html `<button type="button" role="radio" data-value=${v} aria-checked=${v === value ? 'true' : 'false'} title=${label} @click=${() => v !== value && onChange(v)}>
575
+ ${label}
576
+ </button>`)}
577
+ </div>`;
578
+ }
579
+ _renderTier(draft, tierNo, errors) {
580
+ var _a, _b, _c, _d, _e;
243
581
  const tier = draft.tiers.find(x => x.tierNo === tierNo);
244
582
  const anchors = (_a = this.options) === null || _a === void 0 ? void 0 : _a.anchors;
245
- const edit = (patch) => this._edit(updateTier(draft, tierNo, patch));
583
+ const editTier = (patch) => this._edit(updateTier(draft, tierNo, patch));
246
584
  const position = (_b = tier.position) !== null && _b !== void 0 ? _b : { x: 0, y: 0, z: 0 };
247
- const axis = (k, label) => {
248
- var _a;
249
- return html `
250
- <div class="row">
251
- <label>${label}</label>
252
- <input type="number" data-field=${`position-${k}`} .value=${String((_a = position[k]) !== null && _a !== void 0 ? _a : '')} @change=${(e) => edit({ position: { ...position, [k]: num(e) } })} />
253
- </div>
585
+ const fine = ['position-x', 'position-y', 'position-z', 'tier-rotation'].some(f => errors.has(f));
586
+ const u = this._unit();
587
+ return html `
588
+ <section data-section="where">
589
+ <h4>${t('where', 'Where')}</h4>
590
+ ${this._row('anchor', t('face', 'Face'), (anchors === null || anchors === void 0 ? void 0 : anchors.length)
591
+ ? html `<select data-field="anchor" ?invalid=${errors.has('anchor')} @change=${(e) => editTier({ anchorId: e.target.value })}>
592
+ ${anchors.some(a => a.id === tier.anchorId) ? nothing : html `<option value=${(_c = tier.anchorId) !== null && _c !== void 0 ? _c : ''} selected>${(_d = tier.anchorId) !== null && _d !== void 0 ? _d : ''}</option>`}
593
+ ${anchors.map(a => html `<option value=${a.id} ?selected=${a.id === tier.anchorId}>${this._anchorName(a.id)}</option>`)}
594
+ </select>`
595
+ : html `<input data-field="anchor" ?invalid=${errors.has('anchor')} .value=${(_e = tier.anchorId) !== null && _e !== void 0 ? _e : ''} @change=${(e) => editTier({ anchorId: e.target.value.trim() })} />`, errors)}
596
+ ${this._row('mount', t('mount', 'Mount'), this._seg('mount', tier.mount, [
597
+ ['ON', t('mount.ON', 'On')],
598
+ ['IN', t('mount.IN', 'In')],
599
+ ['HANG', t('mount.HANG', 'Hang')]
600
+ ], mount => editTier({ mount }), errors), errors)}
601
+ </section>
602
+ ${this._renderArrangement(draft, tierNo, errors)} ${this._renderSlot(draft, tierNo, errors)}
603
+ <details data-section="fine" ?open=${fine}>
604
+ <summary>${t('fine', 'Position and rotation')}</summary>
605
+ <section>
606
+ ${this._row('position-x', html `${t('position-x', 'Position X')}${u}`, this._num('position-x', position.x, n => editTier({ position: { ...position, x: n } }), errors), errors)}
607
+ ${this._row('position-y', html `${t('position-y', 'Position Y (height)')}${u}`, this._num('position-y', position.y, n => editTier({ position: { ...position, y: n } }), errors), errors)}
608
+ ${this._row('position-z', html `${t('position-z', 'Position Z')}${u}`, this._num('position-z', position.z, n => editTier({ position: { ...position, z: n } }), errors), errors)}
609
+ ${this._row('tier-rotation', t('tier-rotation', 'Tier rotation (deg)'), this._num('tier-rotation', tier.rotation, n => editTier({ rotation: n }), errors), errors)}
610
+ </section>
611
+ </details>
254
612
  `;
613
+ }
614
+ /** 자리를 어떻게 늘어놓는가: 패턴, 수, 간격, 방향, 1번 자리. 그리고 [면에 맞춤] */
615
+ _renderArrangement(draft, tierNo, errors) {
616
+ var _a, _b, _c, _d, _e, _f, _g;
617
+ const tier = draft.tiers.find(x => x.tierNo === tierNo);
618
+ const l = tier.layout;
619
+ const edit = (patch) => this._edit(updatePattern(draft, tierNo, patch));
620
+ const u = this._unit();
621
+ const side = (axis) => axis === 'X'
622
+ ? [
623
+ ['MIN', t('side.left', 'Left')],
624
+ ['MAX', t('side.right', 'Right')]
625
+ ]
626
+ : [
627
+ ['MIN', t('side.top', 'Top')],
628
+ ['MAX', t('side.bottom', 'Bottom')]
629
+ ];
630
+ const fit = () => {
631
+ var _a, _b;
632
+ const result = fitTierToFace(draft, tierNo, (_b = (_a = this.options) === null || _a === void 0 ? void 0 : _a.anchorFaces) === null || _b === void 0 ? void 0 : _b[tier.anchorId]);
633
+ if ('layout' in result)
634
+ this._edit(result.layout);
635
+ else
636
+ this.fitNote = fitFaceText(result.reason);
255
637
  };
638
+ const fitButton = html `<button data-action="fit-face" title=${t('fit-face-tip', 'Centre on the face and spread the slots end to end, once')} @click=${fit}>
639
+ ${t('fit-face', 'Fit to face')}
640
+ </button>`;
256
641
  return html `
257
- <div class="row">
258
- <label>${t('anchor', 'Anchor')}</label>
259
- ${(anchors === null || anchors === void 0 ? void 0 : anchors.length)
260
- ? html `<select data-field="anchor" @change=${(e) => edit({ anchorId: e.target.value })}>
261
- ${anchors.some(a => a.id === tier.anchorId) ? nothing : html `<option value=${(_c = tier.anchorId) !== null && _c !== void 0 ? _c : ''} selected>${(_d = tier.anchorId) !== null && _d !== void 0 ? _d : ''}</option>`}
262
- ${anchors.map(a => { var _a; return html `<option value=${a.id} ?selected=${a.id === tier.anchorId}>${(_a = a.label) !== null && _a !== void 0 ? _a : a.id}</option>`; })}
263
- </select>`
264
- : html `<input data-field="anchor" .value=${(_e = tier.anchorId) !== null && _e !== void 0 ? _e : ''} @change=${(e) => edit({ anchorId: e.target.value.trim() })} />`}
265
- </div>
266
- <div class="row">
267
- <label>${t('mount', 'Mount')}</label>
268
- <select data-field="mount" @change=${(e) => edit({ mount: e.target.value })}>
269
- ${['ON', 'IN', 'HANG'].map(m => html `<option value=${m} ?selected=${m === tier.mount}>${m}</option>`)}
270
- </select>
271
- </div>
272
- ${axis('x', t('position-x', 'Position X'))} ${axis('y', t('position-y', 'Position Y (height)'))} ${axis('z', t('position-z', 'Position Z'))}
273
- <div class="row">
274
- <label>${t('tier-rotation', 'Tier rotation (deg)')}</label>
275
- <input type="number" data-field="tier-rotation" .value=${String((_f = tier.rotation) !== null && _f !== void 0 ? _f : '')} @change=${(e) => edit({ rotation: num(e) })} />
276
- </div>
642
+ <section data-section="arrangement">
643
+ <h4>${t('arrangement', 'Arrangement')}</h4>
644
+ ${this._row('pattern', t('pattern', 'Arrangement'), this._seg('pattern', l.pattern, [
645
+ ['SINGLE', t('pattern.SINGLE', 'One')],
646
+ ['ROW', t('pattern.ROW', 'Row')],
647
+ ['GRID', t('pattern.GRID', 'Grid')],
648
+ ['CUSTOM', t('pattern.CUSTOM', 'Listed')]
649
+ ], v => this._edit(setTierPattern(draft, tierNo, v)), errors), errors)}
650
+ ${l.pattern === 'ROW'
651
+ ? html `
652
+ ${this._row('count', t('count', 'Slots'), this._num('count', l.count, n => edit({ count: n }), errors, { min: '1', step: '1' }), errors)}
653
+ ${this._row('pitch', html `${t('pitch', 'Pitch')}${u}`, html `<div class="inline">${this._num('pitch', l.pitch, n => edit({ pitch: n }), errors)} ${fitButton}</div>`, errors)}
654
+ ${this._row('axis', t('axis', 'Direction'), this._seg('axis', l.axis, [
655
+ ['X', t('axis.X', 'Across (X)')],
656
+ ['Z', t('axis.Z', 'Down (Z)')]
657
+ ], axis => edit({ axis }), errors), errors)}
658
+ ${this._row('origin', t('origin', 'Slot 1 at'), this._seg('origin', (_a = l.origin) !== null && _a !== void 0 ? _a : 'MIN', side(l.axis), origin => edit({ origin }), errors), errors)}
659
+ `
660
+ : nothing}
661
+ ${l.pattern === 'GRID'
662
+ ? html `
663
+ ${this._row('rows', t('rows', 'Rows (Z)'), this._num('rows', l.rows, n => edit({ rows: n }), errors, { min: '1', step: '1' }), errors)}
664
+ ${this._row('cols', t('cols', 'Columns (X)'), this._num('cols', l.cols, n => edit({ cols: n }), errors, { min: '1', step: '1' }), errors)}
665
+ ${this._row('pitch-x', html `${t('pitch-x', 'Pitch X')}${u}`, this._num('pitch-x', (_b = l.pitch) === null || _b === void 0 ? void 0 : _b.x, n => edit({ pitch: { ...l.pitch, x: n } }), errors), errors)}
666
+ ${this._row('pitch-z', html `${t('pitch-z', 'Pitch Z')}${u}`, html `<div class="inline">${this._num('pitch-z', (_c = l.pitch) === null || _c === void 0 ? void 0 : _c.z, n => edit({ pitch: { ...l.pitch, z: n } }), errors)} ${fitButton}</div>`, errors)}
667
+ ${this._row('order', t('order', 'Numbering'), html `<select data-field="order" @change=${(e) => edit({ order: e.target.value })}>
668
+ ${['ROW_MAJOR', 'COL_MAJOR', 'SERPENTINE_ROW', 'SERPENTINE_COL'].map(o => { var _a; return html `<option value=${o} ?selected=${o === ((_a = l.order) !== null && _a !== void 0 ? _a : 'ROW_MAJOR')}>${t(`order.${o}`, o)}</option>`; })}
669
+ </select>`, errors)}
670
+ ${this._row('origin-x', t('origin-x', 'Slot 1 at X'), this._seg('origin-x', (_e = (_d = l.origin) === null || _d === void 0 ? void 0 : _d.x) !== null && _e !== void 0 ? _e : 'MIN', side('X'), x => edit({ origin: { z: 'MIN', ...l.origin, x } }), errors), errors)}
671
+ ${this._row('origin-z', t('origin-z', 'Slot 1 at Z'), this._seg('origin-z', (_g = (_f = l.origin) === null || _f === void 0 ? void 0 : _f.z) !== null && _g !== void 0 ? _g : 'MIN', side('Z'), z => edit({ origin: { x: 'MIN', ...l.origin, z } }), errors), errors)}
672
+ `
673
+ : nothing}
674
+ ${l.pattern === 'SINGLE' ? html `<label class="l"></label><div class="inline">${fitButton}</div>` : nothing}
675
+ ${this.fitNote ? html `<div class="err" data-fit-note>${this.fitNote}</div>` : nothing}
676
+ </section>
677
+ ${l.pattern === 'CUSTOM' ? this._renderCustom(draft, tierNo) : nothing}
277
678
  `;
278
679
  }
279
- /** 받는 타입: 모든 타입(accepts 생략) / 지정 타입만(빈 목록이면 아무것도 받지 않음) */
280
- _acceptsEditor(spec, onChange) {
281
- var _a;
282
- const restricted = Array.isArray(spec.accepts);
283
- return html `
284
- <div class="row">
285
- <label>${t('accepts', 'Accepts')}</label>
286
- <select data-field="accepts-mode" @change=${(e) => { var _a; return onChange(e.target.value === 'ALL' ? undefined : (_a = spec.accepts) !== null && _a !== void 0 ? _a : []); }}>
287
- <option value="ALL" ?selected=${!restricted}>${t('accepts-all', 'Any type')}</option>
288
- <option value="LIST" ?selected=${restricted}>${t('accepts-list', 'Listed types only')}</option>
289
- </select>
290
- </div>
291
- ${restricted
292
- ? html `<div class="row">
293
- <label>${t('accepts-types', 'Types (comma)')}</label>
294
- <input
295
- data-field="accepts"
296
- .value=${((_a = spec.accepts) !== null && _a !== void 0 ? _a : []).join(', ')}
297
- placeholder=${t('accepts-none', 'Empty = accepts nothing')}
298
- @change=${(e) => onChange(e.target.value
680
+ /** 받는 타입: 목록에서 고른 코드(목록에 없는 코드는 그대로 두고 표시), 목록이 없으면 직접 적는다. 필수가 아니면 [모든 타입]도 */
681
+ _acceptsEditor(spec, onChange, errors) {
682
+ var _a, _b, _c;
683
+ const required = !!((_a = this.options) === null || _a === void 0 ? void 0 : _a.acceptsRequired);
684
+ const restricted = required || Array.isArray(spec.accepts);
685
+ const types = (_b = this.options) === null || _b === void 0 ? void 0 : _b.itemTypes;
686
+ const accepts = (_c = spec.accepts) !== null && _c !== void 0 ? _c : [];
687
+ const mode = required
688
+ ? nothing
689
+ : this._row('accepts-mode', t('accepts', 'Accepts'), this._seg('accepts-mode', restricted ? 'LIST' : 'ALL', [
690
+ ['ALL', t('accepts-all', 'Any type')],
691
+ ['LIST', t('accepts-list', 'Listed types only')]
692
+ ], v => { var _a; return onChange(v === 'ALL' ? undefined : ((_a = spec.accepts) !== null && _a !== void 0 ? _a : [])); }, errors), errors);
693
+ if (!restricted)
694
+ return mode;
695
+ const label = required ? t('accepts', 'Accepts') : t('accepts-types', 'Types');
696
+ const control = types
697
+ ? html `<div class="chips" data-field="accepts" ?invalid=${errors.has('accepts')}>
698
+ ${accepts.map(code => {
699
+ const known = types.find(x => x.code === code);
700
+ return html `<span class="chip" data-code=${code} ?unregistered=${!known} title=${known ? code : t('unregistered-type', 'Type not registered')}>
701
+ ${(known === null || known === void 0 ? void 0 : known.name) && known.name !== code ? `${known.name} (${code})` : code}${known ? nothing : ' ⚠'}
702
+ <button aria-label=${t('remove', 'Remove')} @click=${() => onChange(accepts.filter(c => c !== code))}>×</button>
703
+ </span>`;
704
+ })}
705
+ <select
706
+ data-field="accepts-add"
707
+ @change=${(e) => {
708
+ const select = e.target;
709
+ const code = select.value;
710
+ select.value = '';
711
+ if (code)
712
+ onChange([...accepts, code]);
713
+ }}
714
+ >
715
+ <option value="">${t('accepts-add', '+ Add type')}</option>
716
+ ${types.filter(x => !accepts.includes(x.code)).map(x => html `<option value=${x.code}>${x.name && x.name !== x.code ? `${x.name} (${x.code})` : x.code}</option>`)}
717
+ </select>
718
+ </div>`
719
+ : html `<input
720
+ data-field="accepts"
721
+ ?invalid=${errors.has('accepts')}
722
+ .value=${accepts.join(', ')}
723
+ placeholder=${t('accepts-none', 'Empty = accepts nothing')}
724
+ @change=${(e) => onChange(e.target.value
299
725
  .split(',')
300
726
  .map(s => s.trim())
301
727
  .filter(Boolean))}
302
- />
303
- </div>`
304
- : nothing}
305
- `;
728
+ />`;
729
+ return html `${mode} ${this._row('accepts', label, control, errors)}`;
306
730
  }
307
- _renderSpec(draft, tierNo, spec) {
308
- var _a, _b;
731
+ /** 자리 하나의 크기·받는 것·쌓기 (이 Tier 의 모든 자리 공통). CUSTOM 은 자리 표에서 */
732
+ _renderSlot(draft, tierNo, errors) {
733
+ var _a, _b, _c;
734
+ const tier = draft.tiers.find(x => x.tierNo === tierNo);
735
+ if (tier.layout.pattern === 'CUSTOM')
736
+ return nothing;
737
+ const spec = tier.layout.slot;
309
738
  const edit = (patch) => this._edit(updateSlotSpec(draft, tierNo, patch));
310
- const allowed = (k, label) => {
311
- var _a, _b;
312
- return html `
313
- <div class="row">
314
- <label>${label}</label>
315
- <input type="number" data-field=${`allowed-${k}`} .value=${String((_b = (_a = spec.allowed) === null || _a === void 0 ? void 0 : _a[k]) !== null && _b !== void 0 ? _b : '')} @change=${(e) => edit({ allowed: { ...spec.allowed, [k]: num(e) } })} />
316
- </div>
317
- `;
318
- };
739
+ const u = this._unit();
740
+ const allowed = (k, field, label) => { var _a; return this._row(field, html `${label}${u}`, this._num(field, (_a = spec.allowed) === null || _a === void 0 ? void 0 : _a[k], n => edit({ allowed: { ...spec.allowed, [k]: n } }), errors), errors); };
319
741
  return html `
320
- <h4>${t('slot-spec', 'Slot (same for every slot in this tier)')}</h4>
321
- ${allowed('width', t('allowed-width', 'Allowed width'))} ${allowed('depth', t('allowed-depth', 'Allowed depth'))}
322
- ${allowed('height', t('allowed-height', 'Allowed height'))}
323
- <div class="row">
324
- <label>${t('stack-limit', 'Stack limit')}</label>
325
- <input type="number" data-field="stack-limit" min="1" step="1" .value=${String((_a = spec.stackLimit) !== null && _a !== void 0 ? _a : 1)} @change=${(e) => edit({ stackLimit: num(e) })} />
326
- </div>
327
- <div class="row">
328
- <label>${t('rotation', 'Rotation (deg)')}</label>
329
- <input type="number" data-field="rotation" .value=${String((_b = spec.rotation) !== null && _b !== void 0 ? _b : 0)} @change=${(e) => edit({ rotation: num(e) || undefined })} />
330
- </div>
331
- ${this._acceptsEditor(spec, accepts => edit({ accepts }))}
742
+ <section data-section="slot">
743
+ <h4>${t('slot-spec', 'Each slot')}</h4>
744
+ ${this._acceptsEditor(spec, accepts => edit({ accepts }), errors)} ${allowed('width', 'allowed-width', t('allowed-width', 'Width (X)'))}
745
+ ${allowed('depth', 'allowed-depth', t('allowed-depth', 'Depth (Z)'))} ${allowed('height', 'allowed-height', t('allowed-height', 'Height'))}
746
+ ${((_a = this.options) === null || _a === void 0 ? void 0 : _a.itemTypes) ? html `<div class="hint">${t('drawn-at-slot', 'An item that gives no size is drawn at this size.')}</div>` : nothing}
747
+ ${this._row('stack-limit', t('stack-limit', 'Stack limit'), this._num('stack-limit', (_b = spec.stackLimit) !== null && _b !== void 0 ? _b : 1, n => edit({ stackLimit: n }), errors, { min: '1', step: '1' }), errors)}
748
+ ${this._row('rotation', t('rotation', 'Slot rotation (deg)'), this._num('rotation', (_c = spec.rotation) !== null && _c !== void 0 ? _c : 0, n => edit({ rotation: n || undefined }), errors), errors)}
749
+ </section>
332
750
  `;
333
751
  }
334
752
  _renderCustom(draft, tierNo) {
753
+ var _a, _b, _c;
335
754
  const tier = draft.tiers.find(x => x.tierNo === tierNo);
336
755
  const slots = [...tier.layout.slots].sort((a, b) => a.slotNo - b.slotNo);
337
- const cell = (slotNo, value, patch) => html `<td><input type="number" .value=${String(value !== null && value !== void 0 ? value : '')} @change=${(e) => this._edit(updateSlotSpec(draft, tierNo, patch(e), slotNo))} /></td>`;
756
+ const cell = (slotNo, value, patch) => html `<td><input type="number" .value=${shown(value)} @change=${(e) => this._edit(updateSlotSpec(draft, tierNo, patch(e), slotNo))} /></td>`;
338
757
  return html `
339
758
  <h4>${t('custom-slots', 'Slots')}</h4>
340
759
  <table>
@@ -366,30 +785,12 @@ let OxSlotLayoutEditor = class OxSlotLayoutEditor extends localize(i18next)(LitE
366
785
  })}
367
786
  </table>
368
787
  <div class="actions"><button data-action="add-slot" @click=${() => this._edit(addCustomSlot(draft, tierNo))}>${t('add-slot', 'Add slot')}</button></div>
369
- `;
370
- }
371
- /** 위에서 본 자리 배치. 오른쪽이 +X, 위쪽이 −Z */
372
- _renderPreview(draft, tierNo) {
373
- const slots = previewTier(draft, tierNo);
374
- if (!slots.length)
375
- return html `<div class="note" data-preview="none">${t('preview-none', 'No slots to show with the current values.')}</div>`;
376
- const minX = Math.min(...slots.map(s => s.x - s.width / 2));
377
- const maxX = Math.max(...slots.map(s => s.x + s.width / 2));
378
- const minZ = Math.min(...slots.map(s => s.z - s.depth / 2));
379
- const maxZ = Math.max(...slots.map(s => s.z + s.depth / 2));
380
- const w = Math.max(maxX - minX, 1);
381
- const h = Math.max(maxZ - minZ, 1);
382
- const pad = Math.max(w, h) * 0.08;
383
- const font = Math.max(w, h) * 0.07;
384
- return html `
385
- <svg data-preview viewBox="${minX - pad} ${minZ - pad} ${w + pad * 2} ${h + pad * 2}" preserveAspectRatio="xMidYMid meet">
386
- ${slots.map(s => svg `
387
- <rect data-slot=${s.slotNo} x=${s.x - s.width / 2} y=${s.z - s.depth / 2} width=${s.width} height=${s.depth}
388
- fill="rgba(25,118,210,0.08)" stroke="rgba(25,118,210,0.8)" stroke-width=${Math.max(w, h) * 0.006} />
389
- <text x=${s.x} y=${s.z} font-size=${font} text-anchor="middle" dominant-baseline="central" fill="#0d47a1">${s.slotNo}</text>
390
- `)}
391
- </svg>
392
- <div class="note">${t('preview-axes', 'Right = +X, up = −Z')}</div>
788
+ ${this._acceptsEditor({ allowed: (_b = (_a = slots[0]) === null || _a === void 0 ? void 0 : _a.allowed) !== null && _b !== void 0 ? _b : { width: 1, depth: 1, height: 1 }, accepts: (_c = slots[0]) === null || _c === void 0 ? void 0 : _c.accepts }, accepts => {
789
+ let next = draft;
790
+ for (const s of slots)
791
+ next = updateSlotSpec(next, tierNo, { accepts }, s.slotNo);
792
+ this._edit(next);
793
+ }, new Map())}
393
794
  `;
394
795
  }
395
796
  };
@@ -397,14 +798,42 @@ OxSlotLayoutEditor.styles = css `
397
798
  :host {
398
799
  display: flex;
399
800
  flex-direction: column;
400
- gap: 6px;
801
+ gap: 8px;
802
+ min-width: 0;
401
803
  font-size: 12px;
804
+ color: var(--property-editor-color, inherit);
402
805
  }
403
- .row {
806
+ section {
404
807
  display: grid;
405
- grid-template-columns: 40% 1fr;
808
+ grid-template-columns: minmax(64px, 34%) minmax(0, 1fr);
809
+ gap: 4px 6px;
406
810
  align-items: center;
407
- gap: 4px;
811
+ }
812
+ h4,
813
+ summary {
814
+ grid-column: 1 / -1;
815
+ margin: 4px 0 0;
816
+ font-size: 12px;
817
+ font-weight: bold;
818
+ }
819
+ summary {
820
+ cursor: pointer;
821
+ }
822
+ details > section {
823
+ margin-top: 4px;
824
+ }
825
+ label.l {
826
+ text-align: right;
827
+ font: var(--property-editor-label-font, inherit);
828
+ color: var(--property-editor-label-color, inherit);
829
+ }
830
+ .unit {
831
+ opacity: 0.6;
832
+ }
833
+ .err {
834
+ grid-column: 2;
835
+ color: #c62828;
836
+ font-size: 11px;
408
837
  }
409
838
  .notes {
410
839
  padding: 4px 6px;
@@ -414,63 +843,176 @@ OxSlotLayoutEditor.styles = css `
414
843
  .error {
415
844
  color: #c62828;
416
845
  }
417
- .note {
846
+ .note,
847
+ .hint {
418
848
  color: rgba(0, 0, 0, 0.55);
419
849
  }
420
- .chips {
850
+ .hint {
851
+ grid-column: 2;
852
+ font-size: 11px;
853
+ }
854
+ input,
855
+ select {
856
+ box-sizing: border-box;
857
+ width: 100%;
858
+ min-width: 0;
859
+ min-height: var(--property-editor-field-height, 24px);
860
+ padding: var(--property-editor-field-padding, 1px 4px);
861
+ border: var(--property-editor-border, 1px solid rgba(0, 0, 0, 0.2));
862
+ border-radius: var(--property-editor-border-radius, 4px);
863
+ background-color: var(--property-editor-background, field);
864
+ color: inherit;
865
+ font: var(--property-editor-field-font, inherit);
866
+ outline: none;
867
+ }
868
+ input[type='number'] {
869
+ font-variant-numeric: tabular-nums;
870
+ }
871
+ input[invalid],
872
+ select[invalid],
873
+ .seg[invalid],
874
+ .chips[invalid] {
875
+ border-color: #c62828;
876
+ }
877
+ .seg {
421
878
  display: flex;
422
- flex-wrap: wrap;
423
- gap: 4px;
879
+ box-sizing: border-box;
880
+ min-height: var(--property-editor-field-height, 24px);
881
+ border: var(--property-editor-border, 1px solid rgba(0, 0, 0, 0.2));
882
+ border-radius: var(--property-editor-border-radius, 4px);
883
+ background-color: var(--property-editor-background, field);
884
+ overflow: hidden;
424
885
  }
425
- .chips button[active] {
426
- border-color: var(--md-sys-color-primary, #1976d2);
427
- color: var(--md-sys-color-primary, #1976d2);
428
- font-weight: bold;
886
+ .seg button {
887
+ flex: 1;
888
+ min-width: 0;
889
+ min-height: 0;
890
+ padding: 0 4px;
891
+ border: 0;
892
+ border-left: 1px solid rgba(0, 0, 0, 0.12);
893
+ border-radius: 0;
894
+ background: transparent;
895
+ white-space: nowrap;
896
+ overflow: hidden;
897
+ text-overflow: ellipsis;
898
+ }
899
+ .seg button:first-child {
900
+ border-left: 0;
901
+ }
902
+ .seg button[aria-checked='true'] {
903
+ background: var(--md-sys-color-primary, #1976d2);
904
+ color: var(--md-sys-color-on-primary, #fff);
429
905
  }
430
906
  button {
431
- font-size: 12px;
432
- padding: 2px 8px;
433
- border: 1px solid rgba(0, 0, 0, 0.2);
434
- border-radius: 4px;
435
- background: #fff;
907
+ box-sizing: border-box;
908
+ min-height: var(--property-editor-field-height, 24px);
909
+ padding: 0 8px;
910
+ border: var(--property-editor-border, 1px solid rgba(0, 0, 0, 0.2));
911
+ border-radius: var(--property-editor-border-radius, 4px);
912
+ background-color: var(--property-editor-background, field);
913
+ color: inherit;
914
+ font: var(--property-editor-field-font, inherit);
436
915
  cursor: pointer;
437
916
  }
438
917
  button[disabled] {
439
918
  opacity: 0.4;
440
919
  cursor: default;
441
920
  }
442
- input,
443
- select {
444
- font-size: 12px;
445
- min-width: 0;
921
+ .tiers,
922
+ .chips,
923
+ .actions,
924
+ .inline {
925
+ display: flex;
926
+ flex-wrap: wrap;
927
+ gap: 4px;
928
+ align-items: center;
446
929
  }
447
- table {
448
- border-collapse: collapse;
449
- width: 100%;
930
+ .actions {
931
+ justify-content: flex-end;
450
932
  }
451
- td,
452
- th {
453
- border-bottom: 1px solid rgba(0, 0, 0, 0.08);
454
- padding: 1px;
933
+ .tiers button[aria-pressed='true'] {
934
+ border-color: var(--md-sys-color-primary, #1976d2);
935
+ color: var(--md-sys-color-primary, #1976d2);
936
+ font-weight: bold;
455
937
  }
456
- td input {
457
- width: 100%;
938
+ .tiers .end {
939
+ margin-left: auto;
940
+ }
941
+ .inline > input {
942
+ flex: 1;
943
+ }
944
+ .chips {
458
945
  box-sizing: border-box;
946
+ padding: 2px;
947
+ border: 1px solid transparent;
948
+ border-radius: var(--property-editor-border-radius, 4px);
459
949
  }
460
- h4 {
461
- margin: 6px 0 2px;
462
- font-size: 12px;
950
+ .chip {
951
+ display: inline-flex;
952
+ align-items: center;
953
+ gap: 2px;
954
+ padding: 1px 2px 1px 6px;
955
+ border-radius: 10px;
956
+ background: rgba(25, 118, 210, 0.1);
957
+ color: #0d47a1;
958
+ }
959
+ .chip[unregistered] {
960
+ background: rgba(239, 159, 39, 0.18);
961
+ color: #633806;
962
+ }
963
+ .chip button {
964
+ min-height: 0;
965
+ padding: 0 4px;
966
+ border: 0;
967
+ background: transparent;
968
+ }
969
+ .chips select {
970
+ flex: 1;
971
+ min-width: 80px;
972
+ width: auto;
463
973
  }
464
974
  svg {
975
+ display: block;
465
976
  width: 100%;
466
- height: 160px;
977
+ height: auto;
978
+ max-height: 240px;
979
+ touch-action: none;
467
980
  background: rgba(0, 0, 0, 0.02);
468
981
  border: 1px solid rgba(0, 0, 0, 0.08);
982
+ border-radius: 4px;
469
983
  }
470
- .actions {
984
+ svg [data-slot] {
985
+ cursor: move;
986
+ }
987
+ svg [data-handle] {
988
+ cursor: ew-resize;
989
+ }
990
+ .caption {
471
991
  display: flex;
472
- gap: 4px;
473
- justify-content: flex-end;
992
+ justify-content: space-between;
993
+ gap: 6px;
994
+ color: rgba(0, 0, 0, 0.55);
995
+ font-size: 11px;
996
+ }
997
+ .items {
998
+ display: flex;
999
+ flex-direction: column;
1000
+ gap: 2px;
1001
+ }
1002
+ .items .bad {
1003
+ color: #c62828;
1004
+ }
1005
+ .items .warn {
1006
+ color: #854f0b;
1007
+ }
1008
+ table {
1009
+ border-collapse: collapse;
1010
+ width: 100%;
1011
+ }
1012
+ td,
1013
+ th {
1014
+ border-bottom: 1px solid rgba(0, 0, 0, 0.08);
1015
+ padding: 1px;
474
1016
  }
475
1017
  `;
476
1018
  __decorate([
@@ -497,6 +1039,9 @@ __decorate([
497
1039
  __decorate([
498
1040
  state()
499
1041
  ], OxSlotLayoutEditor.prototype, "presetParams", void 0);
1042
+ __decorate([
1043
+ state()
1044
+ ], OxSlotLayoutEditor.prototype, "fitNote", void 0);
500
1045
  OxSlotLayoutEditor = __decorate([
501
1046
  customElement('ox-slot-layout-editor')
502
1047
  ], OxSlotLayoutEditor);