@kubohiroya/turbowarp-extension-data-editor 0.2.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,12 @@
1
1
  import * as React from 'react';
2
2
  import * as ReactDOM from 'react-dom';
3
+ import { LayoutHost, ObjectLayout, useCommonLayout } from './layout-view.js';
3
4
  import { createSourceCandidate, patchDecodedSource, readSourceDocument } from './source-editor.js';
4
5
  import { cloneJson } from './data.js';
6
+ import { FormHelp, propertyLabel } from './form-presentation.js';
7
+ import { jsonTypes, presentationInitial, presentationTypes, presentationCondition } from './schema-presentation.js';
8
+ let nextFieldId = 0;
9
+ const SourcePaths = React.createContext([]);
5
10
  const record = (value) => !!value && typeof value === 'object' && !Array.isArray(value);
6
11
  const pointer = (path, key) => `${path}/${key.replace(/~/g, '~0').replace(/\//g, '~1')}`;
7
12
  const typeOf = (v) => v === null ? 'null' : Array.isArray(v) ? 'array' : typeof v;
@@ -17,6 +22,8 @@ const initial = (type) => {
17
22
  };
18
23
  function mergeHints(left, right) {
19
24
  const result = { ...left, ...right };
25
+ if (Array.isArray(left.required) && Array.isArray(right.required))
26
+ result.required = [...new Set([...left.required, ...right.required])];
20
27
  for (const field of ['properties', 'patternProperties']) {
21
28
  if (!record(left[field]) && !record(right[field]))
22
29
  continue;
@@ -48,33 +55,40 @@ function propertyHint(hints, key) {
48
55
  }
49
56
  return matches.length > 1 ? { allOf: matches } : matches.length ? matches[0] : hints.additionalProperties;
50
57
  }
58
+ function sourceReference(variant, ref) {
59
+ const hash = ref.indexOf('#');
60
+ const uri = hash < 0 ? ref : ref.slice(0, hash);
61
+ const resources = [variant.schemaBundle.root, ...variant.schemaBundle.resources];
62
+ const resource = uri ? resources.find(r => r.retrievalUri === uri || (record(r.document) && r.document.$id === uri)) : variant.schemaBundle.root;
63
+ let target = resource === null || resource === void 0 ? void 0 : resource.document;
64
+ const fragment = hash < 0 ? '' : decodeURIComponent(ref.slice(hash + 1));
65
+ if (fragment.startsWith('/'))
66
+ for (const part of fragment.slice(1).split('/')) {
67
+ target = record(target) || Array.isArray(target) ? target[part.replace(/~1/g, '/').replace(/~0/g, '~')] : undefined;
68
+ }
69
+ return !fragment || fragment.startsWith('/') ? target : undefined;
70
+ }
51
71
  /** Presentation hints never replace canonical validation or remove schema constraints. */
52
- function resolveHints(variant, schema, data, seen = new Set()) {
72
+ function resolveHints(variant, schema, data, seen = new Set(), conditional = false) {
53
73
  var _a;
54
74
  if (!record(schema) || seen.has(schema))
55
75
  return {};
56
76
  seen.add(schema);
57
77
  let result = { ...schema };
58
78
  if (typeof schema.$ref === 'string') {
59
- const ref = schema.$ref;
60
- const hash = ref.indexOf('#');
61
- const uri = hash < 0 ? ref : ref.slice(0, hash);
62
- const resources = [variant.schemaBundle.root, ...variant.schemaBundle.resources];
63
- const resource = uri ? resources.find(r => r.retrievalUri === uri || (record(r.document) && r.document.$id === uri)) : variant.schemaBundle.root;
64
- let target = resource === null || resource === void 0 ? void 0 : resource.document;
65
- const fragment = hash < 0 ? '' : decodeURIComponent(ref.slice(hash + 1));
66
- if (fragment.startsWith('/'))
67
- for (const part of fragment.slice(1).split('/')) {
68
- target = record(target) || Array.isArray(target) ? target[part.replace(/~1/g, '/').replace(/~0/g, '~')] : undefined;
69
- }
70
- if (!fragment || fragment.startsWith('/'))
71
- result = mergeHints(resolveHints(variant, target, data, seen), result);
79
+ result = mergeHints(resolveHints(variant, sourceReference(variant, schema.$ref), data, seen, conditional), result);
72
80
  }
73
81
  if (Array.isArray(schema.allOf))
74
82
  for (const part of schema.allOf) {
75
- const next = resolveHints(variant, part, data, seen);
83
+ const next = resolveHints(variant, part, data, seen, conditional);
76
84
  result = mergeHints(result, next);
77
85
  }
86
+ if (conditional && Object.hasOwn(schema, 'if')) {
87
+ const matches = presentationCondition(schema.if, data, ref => sourceReference(variant, ref));
88
+ const branch = matches === true ? schema.then : matches === false ? schema.else : undefined;
89
+ if (branch !== undefined)
90
+ result = mergeHints(result, resolveHints(variant, branch, data, seen, conditional));
91
+ }
78
92
  const alternatives = (_a = schema.oneOf) !== null && _a !== void 0 ? _a : schema.anyOf;
79
93
  if (Array.isArray(alternatives)) {
80
94
  const choices = alternatives.filter(part => {
@@ -90,13 +104,13 @@ function resolveHints(variant, schema, data, seen = new Set()) {
90
104
  return true;
91
105
  });
92
106
  if (choices.length === 1)
93
- result = mergeHints(result, resolveHints(variant, choices[0], data, seen));
107
+ result = mergeHints(result, resolveHints(variant, choices[0], data, seen, conditional));
94
108
  }
95
109
  if (!Array.isArray(result.enum)) {
96
110
  if (Object.hasOwn(result, 'const'))
97
111
  result.enum = [result.const];
98
112
  else if (Array.isArray(alternatives)) {
99
- const enums = alternatives.map(part => resolveHints(variant, part, data, new Set(seen)));
113
+ const enums = alternatives.map(part => resolveHints(variant, part, data, new Set(seen), conditional));
100
114
  if (enums.length && enums.every(part => Array.isArray(part.enum))) {
101
115
  const values = enums.flatMap(part => part.enum);
102
116
  result.enum = [...new Map(values.map(value => [JSON.stringify(value), value])).values()];
@@ -105,26 +119,38 @@ function resolveHints(variant, schema, data, seen = new Set()) {
105
119
  }
106
120
  return result;
107
121
  }
108
- function Tree({ value, path, schema, ui, variant, invalidNumbers, change: onChange }) {
122
+ function Tree({ value, path, schema, ui, variant, invalidNumbers, required, change: onChange }) {
109
123
  var _a;
110
- const hints = resolveHints(variant, schema, value);
124
+ const errors = React.useContext(SourcePaths);
125
+ const commonLayout = useCommonLayout();
126
+ const hints = resolveHints(variant, schema, value, new Set(), commonLayout);
111
127
  const uiHints = record(ui) ? ui : {};
112
128
  const [newKey, setNewKey] = React.useState('');
129
+ const [id] = React.useState(() => `source-field-${++nextFieldId}`);
113
130
  const change = (next, edit = { path }) => onChange(next, edit);
114
131
  const choices = Array.isArray(hints.enum) ? hints.enum : Array.isArray(uiHints['ui:enum']) ? uiHints['ui:enum'] : undefined;
115
- const label = typeof hints.title === 'string' ? hints.title : path || '/';
116
- const props = { 'aria-label': label };
132
+ const key = path.slice(path.lastIndexOf('/') + 1).replace(/~1/g, '/').replace(/~0/g, '~');
133
+ const label = commonLayout && typeof uiHints['ui:title'] === 'string' ? uiHints['ui:title'] : typeof hints.title === 'string' ? hints.title : commonLayout ? propertyLabel(key) : path || '/';
117
134
  const type = typeOf(value);
135
+ const allowed = presentationTypes(schema, ref => sourceReference(variant, ref));
136
+ const fixedType = allowed.size === 1 && allowed.has(type);
137
+ const readonly = commonLayout && (hints.readOnly === true || uiHints['ui:readonly'] === true || uiHints['ui:disabled'] === true);
138
+ const props = { 'aria-label': label, ...(commonLayout ? { id, disabled: readonly, 'aria-describedby': typeof hints.description === 'string' ? `${id}-help` : undefined } : {}) };
139
+ const typeControl = !commonLayout || !fixedType ? React.createElement("select", { "data-source-type-control": "", "aria-label": `Type ${path || '/'}`, disabled: readonly, value: type, onChange: e => change(initial(e.target.value)) }, (commonLayout && allowed.size ? [...new Set([type, ...allowed])] : jsonTypes).map(t => React.createElement("option", { key: t }, t))) : null;
140
+ const seed = (hint) => cloneJson(presentationInitial(hint));
118
141
  let field;
119
142
  if (choices) {
120
143
  const serialized = JSON.stringify(value);
121
144
  const options = choices.map(item => JSON.stringify(item));
122
145
  if (!options.includes(serialized))
123
146
  options.unshift(serialized);
124
- field = React.createElement("select", { ...props, value: serialized, onChange: e => change(JSON.parse(e.target.value)) }, options.map(item => React.createElement("option", { key: item, value: item }, item)));
147
+ field = commonLayout && options.length === 1 ? React.createElement("input", { ...props, readOnly: true, value: typeof value === 'string' ? value : serialized })
148
+ : React.createElement("select", { ...props, value: serialized, onChange: e => change(JSON.parse(e.target.value)) }, options.map(item => React.createElement("option", { key: item, value: item }, commonLayout && typeof JSON.parse(item) === 'string' ? JSON.parse(item) : item)));
125
149
  }
126
150
  else if (typeof value === 'string') {
127
- field = React.createElement("textarea", { ...props, value: value, onChange: e => change(e.target.value) });
151
+ field = commonLayout && uiHints['ui:widget'] !== 'textarea' && !value.includes('\n')
152
+ ? React.createElement("input", { ...props, type: "text", value: value, onChange: e => change(e.target.value) })
153
+ : React.createElement("textarea", { ...props, value: value, onChange: e => change(e.target.value) });
128
154
  }
129
155
  else if (typeof value === 'number') {
130
156
  field = React.createElement("input", { ...props, type: "text", inputMode: "decimal", value: (_a = invalidNumbers[path]) !== null && _a !== void 0 ? _a : String(value), onChange: e => {
@@ -137,84 +163,137 @@ function Tree({ value, path, schema, ui, variant, invalidNumbers, change: onChan
137
163
  field = React.createElement("input", { ...props, type: "checkbox", checked: value, onChange: e => change(e.target.checked) });
138
164
  }
139
165
  else if (Array.isArray(value)) {
166
+ const itemHint = (index) => { var _a, _b; return Array.isArray(hints.prefixItems) ? (_a = hints.prefixItems[index]) !== null && _a !== void 0 ? _a : hints.items : Array.isArray(hints.items) ? (_b = hints.items[index]) !== null && _b !== void 0 ? _b : hints.additionalItems : hints.items; };
167
+ const tuple = Array.isArray(hints.prefixItems) || Array.isArray(hints.items);
168
+ const canRemove = !commonLayout || value.length > (typeof hints.minItems === 'number' ? hints.minItems : 0);
169
+ const canAdd = !commonLayout || (itemHint(value.length) !== false && (typeof hints.maxItems !== 'number' || value.length < hints.maxItems));
140
170
  field = React.createElement("div", null,
141
- value.map((child, index) => {
142
- var _a;
143
- return React.createElement("div", { key: index },
144
- React.createElement(Tree, { invalidNumbers: invalidNumbers, value: child, path: pointer(path, String(index)), variant: variant, schema: Array.isArray(hints.prefixItems) ? (_a = hints.prefixItems[index]) !== null && _a !== void 0 ? _a : hints.items : Array.isArray(hints.items) ? hints.items[index] : hints.items, ui: uiHints.items, change: (next, edit) => { const copy = [...value]; copy[index] = next; change(copy, edit); } }),
145
- React.createElement("button", { type: "button", "aria-label": `Remove ${pointer(path, String(index))}`, onClick: () => change(value.filter((_, i) => i !== index), { path, removeIndex: index }) }, "Remove"),
146
- React.createElement("button", { type: "button", disabled: index === 0, "aria-label": `Move up ${pointer(path, String(index))}`, onClick: () => {
147
- const copy = [...value];
148
- [copy[index - 1], copy[index]] = [copy[index], copy[index - 1]];
149
- change(copy, { path, swap: [index - 1, index] });
150
- } }, "\u2191"));
151
- }),
152
- React.createElement("button", { type: "button", "aria-label": `Add item ${path || '/'}`, onClick: () => change([...value, null], { path: pointer(path, String(value.length)) }) }, "Add item"));
171
+ commonLayout && value.length === 0 && React.createElement("p", { className: "editor-form-hint" }, "\u9805\u76EE\u306A\u3057"),
172
+ value.map((child, index) => React.createElement("div", { key: index, className: commonLayout ? 'editor-form-array-item' : undefined },
173
+ React.createElement(Tree, { invalidNumbers: invalidNumbers, value: child, path: pointer(path, String(index)), variant: variant, schema: itemHint(index), ui: uiHints.items, change: (next, edit) => { const copy = [...value]; copy[index] = next; change(copy, edit); } }),
174
+ canRemove && React.createElement("button", { type: "button", "aria-label": `Remove ${pointer(path, String(index))}`, onClick: () => change(value.filter((_, i) => i !== index), { path, removeIndex: index }) }, "Remove"),
175
+ (!commonLayout || !tuple && value.length > 1) && React.createElement("button", { type: "button", disabled: index === 0, "aria-label": `Move up ${pointer(path, String(index))}`, onClick: () => {
176
+ const copy = [...value];
177
+ [copy[index - 1], copy[index]] = [copy[index], copy[index - 1]];
178
+ change(copy, { path, swap: [index - 1, index] });
179
+ } }, "\u2191"))),
180
+ canAdd && React.createElement("button", { type: "button", "aria-label": `Add item ${path || '/'}`, onClick: () => change([...value, commonLayout ? seed(resolveHints(variant, itemHint(value.length), null)) : null], { path: pointer(path, String(value.length)) }) }, "Add item"));
153
181
  }
154
182
  else if (value !== null && typeof value === 'object') {
155
183
  const properties = record(hints.properties) ? hints.properties : {};
156
- field = React.createElement("div", null,
157
- Object.entries(value).map(([key, child]) => React.createElement("div", { key: key },
158
- React.createElement(Tree, { invalidNumbers: invalidNumbers, value: child, path: pointer(path, key), variant: variant, schema: propertyHint(hints, key), ui: uiHints[key], change: (next, edit) => {
184
+ const requiredKeys = new Set(Array.isArray(hints.required) ? hints.required : []);
185
+ const optional = Object.keys(properties).filter(name => !requiredKeys.has(name));
186
+ const missing = Object.keys(properties).filter(name => !Object.hasOwn(value, name));
187
+ const remove = (name) => { const copy = cloneJson(value); delete copy[name]; change(copy, { path: pointer(path, name) }); };
188
+ const add = (name) => {
189
+ const hint = resolveHints(variant, propertyHint(hints, name), null);
190
+ const copy = cloneJson(value);
191
+ Object.defineProperty(copy, name, { value: seed(hint), enumerable: true, writable: true, configurable: true });
192
+ change(copy, { path: pointer(path, name) });
193
+ setNewKey('');
194
+ };
195
+ const fields = Object.entries(value).map(([name, child]) => ({ name,
196
+ errors: errors.filter(error => error === pointer(path, name) || error.startsWith(pointer(path, name) + '/')).length,
197
+ content: React.createElement("div", { key: name },
198
+ React.createElement(Tree, { invalidNumbers: invalidNumbers, value: child, path: pointer(path, name), variant: variant, schema: propertyHint(hints, name), required: requiredKeys.has(name), ui: uiHints[name], change: (next, edit) => {
159
199
  const copy = cloneJson(value);
160
- Object.defineProperty(copy, key, { value: next, enumerable: true, writable: true, configurable: true });
200
+ Object.defineProperty(copy, name, { value: next, enumerable: true, writable: true, configurable: true });
161
201
  change(copy, edit);
162
202
  } }),
163
- React.createElement("button", { type: "button", "aria-label": `Remove ${pointer(path, key)}`, onClick: () => {
203
+ (!commonLayout || !Object.hasOwn(properties, name) && !requiredKeys.has(name)) && React.createElement("button", { type: "button", "aria-label": `Remove ${pointer(path, name)}`, onClick: () => remove(name) }, "Remove")) }));
204
+ const dynamic = hints.additionalProperties !== false || record(hints.patternProperties);
205
+ field = React.createElement("div", null,
206
+ React.createElement(ObjectLayout, { ui: uiHints, knownFields: [...new Set([...Object.keys(properties), ...Object.keys(value)])], fields: fields, fallback: fields.map(item => item.content) }),
207
+ commonLayout ? React.createElement(React.Fragment, null,
208
+ (missing.length > 0 || optional.some(name => Object.hasOwn(value, name))) && React.createElement("details", { className: "editor-form-tools" },
209
+ React.createElement("summary", null, "\u9805\u76EE\u3092\u7BA1\u7406"),
210
+ missing.map(name => React.createElement("button", { type: "button", key: name, onClick: () => add(name) },
211
+ propertyLabel(name),
212
+ "\u3092\u8FFD\u52A0")),
213
+ optional.filter(name => Object.hasOwn(value, name)).map(name => React.createElement("label", { key: name },
214
+ propertyLabel(name),
215
+ React.createElement("button", { type: "button", "aria-label": `Remove ${pointer(path, name)}`, onClick: () => remove(name) }, "\u5024\u3092\u89E3\u9664")))),
216
+ dynamic && React.createElement("details", { className: "editor-form-tools" },
217
+ React.createElement("summary", null, "\u8FFD\u52A0\u306E\u30D7\u30ED\u30D1\u30C6\u30A3"),
218
+ React.createElement("label", { htmlFor: `${id}-key` }, "\u9805\u76EE\u540D"),
219
+ React.createElement("input", { id: `${id}-key`, "aria-label": `New property ${path || '/'}`, value: newKey, onChange: e => setNewKey(e.target.value) }),
220
+ React.createElement("button", { type: "button", disabled: !newKey || Object.hasOwn(value, newKey) || propertyHint(hints, newKey) === false, onClick: () => add(newKey) }, "Add property"))) : React.createElement(React.Fragment, null,
221
+ React.createElement("label", null,
222
+ "Property ",
223
+ React.createElement("input", { "aria-label": `New property ${path || '/'}`, value: newKey, onChange: e => setNewKey(e.target.value) })),
224
+ React.createElement("select", { "aria-label": `Known property ${path || '/'}`, value: "", onChange: e => setNewKey(e.target.value) },
225
+ React.createElement("option", { value: "" }, "Choose property"),
226
+ missing.map(name => React.createElement("option", { key: name }, name))),
227
+ React.createElement("button", { type: "button", disabled: !newKey || Object.hasOwn(value, newKey), onClick: () => {
164
228
  const copy = cloneJson(value);
165
- delete copy[key];
166
- change(copy, { path: pointer(path, key) });
167
- } }, "Remove"))),
168
- React.createElement("label", null,
169
- "Property ",
170
- React.createElement("input", { "aria-label": `New property ${path || '/'}`, value: newKey, onChange: e => setNewKey(e.target.value) })),
171
- React.createElement("select", { "aria-label": `Known property ${path || '/'}`, value: "", onChange: e => setNewKey(e.target.value) },
172
- React.createElement("option", { value: "" }, "Choose property"),
173
- Object.keys(properties).filter(key => !Object.hasOwn(value, key)).map(key => React.createElement("option", { key: key }, key))),
174
- React.createElement("button", { type: "button", disabled: !newKey || Object.hasOwn(value, newKey), onClick: () => {
175
- const copy = cloneJson(value);
176
- Object.defineProperty(copy, newKey, { value: null, enumerable: true, writable: true, configurable: true });
177
- change(copy, { path: pointer(path, newKey) });
178
- setNewKey('');
179
- } }, "Add property"));
229
+ Object.defineProperty(copy, newKey, { value: null, enumerable: true, writable: true, configurable: true });
230
+ change(copy, { path: pointer(path, newKey) });
231
+ setNewKey('');
232
+ } }, "Add property")));
180
233
  }
181
234
  else
182
235
  field = React.createElement("span", null, "null");
183
- return React.createElement("fieldset", { "data-source-path": path },
236
+ const containerProps = { 'data-source-path': path, 'data-source-error': commonLayout && errors.includes(path) };
237
+ if (commonLayout && !Array.isArray(value) && !record(value))
238
+ return React.createElement("div", { ...containerProps, className: "editor-form-field" },
239
+ typeof value === 'boolean' && !choices ? React.createElement("label", { className: "editor-form-toggle", htmlFor: id },
240
+ field,
241
+ React.createElement("span", null, label)) : React.createElement(React.Fragment, null,
242
+ React.createElement("label", { htmlFor: id },
243
+ label,
244
+ required && React.createElement("span", { className: "required" }, " *")),
245
+ field),
246
+ typeControl,
247
+ React.createElement(FormHelp, { description: hints.description, id: `${id}-help` }),
248
+ invalidNumbers[path] !== undefined && React.createElement("p", { className: "editor-form-invalid", role: "alert" }, "\u6709\u52B9\u306A\u6570\u5024\u3092\u5165\u529B\u3057\u3066\u304F\u3060\u3055\u3044\u3002"));
249
+ return React.createElement("fieldset", { ...containerProps, className: commonLayout ? 'editor-form-section' : undefined, disabled: readonly },
184
250
  React.createElement("legend", null, label),
185
- React.createElement("select", { "aria-label": `Type ${path || '/'}`, value: type, onChange: e => change(initial(e.target.value)) }, ['object', 'array', 'string', 'number', 'boolean', 'null'].map(t => React.createElement("option", { key: t }, t))),
251
+ commonLayout && React.createElement(FormHelp, { description: hints.description }),
252
+ typeControl,
186
253
  field);
187
254
  }
188
- function liveIssues(draft, bindings) {
255
+ function liveIssues(draft, bindings, commonLayout = false) {
256
+ var _a;
189
257
  const issues = [];
190
258
  const sources = draft.sources;
191
259
  for (const binding of bindings) {
192
260
  const entry = sources[binding.argument];
193
261
  try {
194
- if (entry.invalidForm === true)
195
- throw new TypeError();
262
+ if (entry.invalidForm === true) {
263
+ if (!commonLayout || !record(entry.invalidNumbers) || !Object.keys(entry.invalidNumbers).length)
264
+ throw new TypeError();
265
+ for (const path of Object.keys(((_a = entry.invalidNumbers) !== null && _a !== void 0 ? _a : {})))
266
+ issues.push({ message: 'Enter a valid JSON number.', path: pointer('', binding.argument) + path });
267
+ }
196
268
  const doc = record(entry.creating)
197
269
  ? { ...entry.creating, data: entry.data }
198
270
  : readSourceDocument(binding, entry.source);
199
271
  const validator = binding.validators.get(JSON.stringify([doc.format, doc.variantId]));
200
- if (validator && validator.validate((entry.mode === 'form' ? entry.data : doc.data)).kind !== 'valid') {
201
- issues.push({ message: 'Value does not satisfy the schema.', path: `/${binding.argument}` });
272
+ if (validator) {
273
+ const result = validator.validate((entry.mode === 'form' ? entry.data : doc.data));
274
+ if (result.kind !== 'valid') {
275
+ if (commonLayout)
276
+ for (const diagnostic of result.diagnostics)
277
+ issues.push({ message: 'Value does not satisfy the schema.', path: pointer('', binding.argument) + ('dataPath' in diagnostic ? diagnostic.dataPath : '') });
278
+ else
279
+ issues.push({ message: 'Value does not satisfy the schema.', path: `/${binding.argument}` });
280
+ }
202
281
  }
203
282
  }
204
- catch (_a) {
283
+ catch (_b) {
205
284
  issues.push({ message: 'Repair the invalid source or form value.', path: `/${binding.argument}` });
206
285
  }
207
286
  }
208
287
  return issues;
209
288
  }
210
- function SourceView({ context, bindings }) {
289
+ function SourceView({ context, bindings, commonLayout }) {
211
290
  const [, redraw] = React.useState(0);
212
291
  const [creating, selectCreateVariant] = React.useState({});
213
- React.useEffect(() => {
292
+ React.useLayoutEffect(() => {
214
293
  const stop = context.subscribe(() => redraw(n => n + 1));
215
- context.setValidationIssues(liveIssues(context.getDraft(), bindings));
294
+ context.setValidationIssues(liveIssues(context.getDraft(), bindings, commonLayout));
216
295
  return stop;
217
- }, [context, bindings]);
296
+ }, [context, bindings, commonLayout]);
218
297
  const state = context.getState();
219
298
  if (state.status !== 'editing' && state.status !== 'saving')
220
299
  return React.createElement(React.Fragment, null);
@@ -226,10 +305,10 @@ function SourceView({ context, bindings }) {
226
305
  const nextSources = next.sources;
227
306
  Object.defineProperty(nextSources, argument, { value: entry, enumerable: true, configurable: true, writable: true });
228
307
  context.setDraft(next);
229
- context.setValidationIssues(liveIssues(next, bindings));
308
+ context.setValidationIssues(liveIssues(next, bindings, commonLayout));
230
309
  };
231
310
  return React.createElement("div", { className: "block-editor-source-forms" }, bindings.map(binding => {
232
- var _a, _b;
311
+ var _a, _b, _c;
233
312
  const entry = sources[binding.argument];
234
313
  const selectedCreation = Object.hasOwn(creating, binding.argument) ? creating[binding.argument] : '';
235
314
  let document;
@@ -238,29 +317,38 @@ function SourceView({ context, bindings }) {
238
317
  document = readSourceDocument(binding, entry.source);
239
318
  variant = binding.presentationVariants.find(v => v.format === document.format && v.variantId === document.variantId);
240
319
  }
241
- catch ( /* The Source textarea remains available for repair. */_c) { /* The Source textarea remains available for repair. */ }
320
+ catch ( /* The Source textarea remains available for repair. */_d) { /* The Source textarea remains available for repair. */ }
242
321
  if (record(entry.creating)) {
243
322
  const creation = entry.creating;
244
323
  variant = binding.presentationVariants.find(v => v.format === creation.format && v.variantId === creation.variantId);
245
324
  }
246
- return React.createElement("fieldset", { key: binding.argument, disabled: disabled },
247
- React.createElement("legend", null, (_a = binding.label) !== null && _a !== void 0 ? _a : binding.argument),
248
- React.createElement("button", { type: "button", "aria-pressed": entry.mode === 'source', onClick: () => {
249
- try {
250
- if (entry.invalidForm === true)
251
- throw new TypeError();
252
- const source = record(entry.creating)
253
- ? createSourceCandidate(entry.creating.format, entry.data)
254
- : entry.mode === 'form' && document && Object.hasOwn(entry, 'data')
255
- ? patchDecodedSource(document, entry.data, source => readSourceDocument(binding, source)) : entry.source;
256
- const next = { ...entry, mode: 'source', source, invalidForm: false, invalidNumbers: {} };
257
- delete next.creating;
258
- update(binding.argument, next);
259
- }
260
- catch (_a) {
261
- context.setValidationIssues([{ message: 'This edit cannot preserve the source. Repair the Source explicitly.' }]);
262
- }
263
- } }, "Source"),
325
+ const argumentPath = pointer('', binding.argument);
326
+ const errorPaths = [...new Set(state.issues.flatMap(issue => {
327
+ var _a;
328
+ return ((_a = issue.path) === null || _a === void 0 ? void 0 : _a.startsWith(argumentPath + '/'))
329
+ ? [issue.path.slice(argumentPath.length)] : [];
330
+ }).concat(Object.keys(((_a = entry.invalidNumbers) !== null && _a !== void 0 ? _a : {}))))];
331
+ const sourceButton = React.createElement("button", { type: "button", "aria-pressed": entry.mode === 'source', onClick: () => {
332
+ try {
333
+ if (entry.invalidForm === true)
334
+ throw new TypeError();
335
+ const source = record(entry.creating)
336
+ ? createSourceCandidate(entry.creating.format, entry.data)
337
+ : entry.mode === 'form' && document && Object.hasOwn(entry, 'data')
338
+ ? patchDecodedSource(document, entry.data, source => readSourceDocument(binding, source)) : entry.source;
339
+ const next = { ...entry, mode: 'source', source, invalidForm: false, invalidNumbers: {} };
340
+ delete next.creating;
341
+ update(binding.argument, next);
342
+ }
343
+ catch (_a) {
344
+ context.setValidationIssues([{ message: 'This edit cannot preserve the source. Repair the Source explicitly.' }]);
345
+ }
346
+ } }, "Source");
347
+ const formButton = React.createElement("button", { type: "button", disabled: !variant, "aria-pressed": entry.mode === 'form', onClick: () => {
348
+ if (document || entry.creating)
349
+ update(binding.argument, { ...entry, mode: 'form', data: Object.hasOwn(entry, 'data') ? entry.data : document.data });
350
+ } }, "Form");
351
+ const creationTools = React.createElement(React.Fragment, null,
264
352
  React.createElement("select", { "aria-label": `${binding.argument} New format and variant`, value: selectedCreation, onChange: e => selectCreateVariant({ ...creating, [binding.argument]: e.target.value }) },
265
353
  React.createElement("option", { value: "" }, "Choose new format and variant"),
266
354
  binding.presentationVariants.map(v => React.createElement("option", { key: JSON.stringify([v.format, v.variantId]), value: JSON.stringify([v.format, v.variantId]) },
@@ -273,46 +361,58 @@ function SourceView({ context, bindings }) {
273
361
  const root = chosen.schemaBundle.root.document;
274
362
  const type = record(root) && typeof root.type === 'string' ? root.type : 'null';
275
363
  update(binding.argument, { mode: 'form', source: entry.source, sourceEdited: true, creating: { format, variantId }, data: initial(type) });
276
- } }, "Create document (replace source)"),
277
- React.createElement("button", { type: "button", disabled: !variant, "aria-pressed": entry.mode === 'form', onClick: () => {
278
- if (document || entry.creating)
279
- update(binding.argument, { ...entry, mode: 'form', data: Object.hasOwn(entry, 'data') ? entry.data : document.data });
280
- } }, "Form"),
281
- entry.mode === 'form' && React.createElement("button", { type: "button", onClick: () => { var _a; return update(binding.argument, { mode: 'source', source: entry.source, sourceEdited: (_a = entry.sourceEdited) !== null && _a !== void 0 ? _a : false }); } }, "Discard form edits"),
364
+ } }, "Create document (replace source)"));
365
+ const discard = entry.mode === 'form' && React.createElement("button", { type: "button", onClick: () => { var _a; return update(binding.argument, { mode: 'source', source: entry.source, sourceEdited: (_a = entry.sourceEdited) !== null && _a !== void 0 ? _a : false }); } }, "Discard form edits");
366
+ return React.createElement("fieldset", { key: binding.argument, disabled: disabled, className: commonLayout ? 'editor-form-binding' : undefined },
367
+ (!commonLayout || binding.label || bindings.length > 1) && React.createElement("legend", null, (_b = binding.label) !== null && _b !== void 0 ? _b : binding.argument),
368
+ commonLayout ? React.createElement("div", { className: "editor-form-toolbar" },
369
+ React.createElement("div", { className: "editor-form-modes" },
370
+ sourceButton,
371
+ formButton),
372
+ React.createElement("details", { className: "editor-form-tools" },
373
+ React.createElement("summary", null, "\u6587\u66F8\u306E\u64CD\u4F5C"),
374
+ creationTools,
375
+ discard)) : React.createElement(React.Fragment, null,
376
+ sourceButton,
377
+ creationTools,
378
+ formButton,
379
+ discard),
282
380
  entry.mode === 'form' && variant && Object.hasOwn(entry, 'data')
283
- ? React.createElement(Tree, { invalidNumbers: ((_b = entry.invalidNumbers) !== null && _b !== void 0 ? _b : {}), value: entry.data, path: "", schema: variant.schemaBundle.root.document, ui: variant.uiSchema, variant: variant, change: (data, edit = { path: '' }) => {
284
- var _a;
285
- const pending = ((_a = entry.invalidNumbers) !== null && _a !== void 0 ? _a : {});
286
- const invalidNumbers = {};
287
- const prefix = `${edit.path}/`;
288
- for (const [path, text] of Object.entries(pending)) {
289
- let target = path;
290
- if (edit.removeIndex !== undefined || edit.swap) {
291
- if (path.startsWith(prefix)) {
292
- const [head, ...tail] = path.slice(prefix.length).split('/');
293
- let index = Number(head);
294
- if (index === edit.removeIndex)
295
- continue;
296
- if (edit.removeIndex !== undefined && index > edit.removeIndex)
297
- index--;
298
- if (edit.swap)
299
- index = index === edit.swap[0] ? edit.swap[1] : index === edit.swap[1] ? edit.swap[0] : index;
300
- target = `${prefix}${index}${tail.length ? '/' + tail.join('/') : ''}`;
381
+ ? React.createElement(SourcePaths.Provider, { value: errorPaths },
382
+ React.createElement(Tree, { invalidNumbers: ((_c = entry.invalidNumbers) !== null && _c !== void 0 ? _c : {}), value: entry.data, path: "", schema: variant.schemaBundle.root.document, ui: variant.uiSchema, variant: variant, change: (data, edit = { path: '' }) => {
383
+ var _a;
384
+ const pending = ((_a = entry.invalidNumbers) !== null && _a !== void 0 ? _a : {});
385
+ const invalidNumbers = {};
386
+ const prefix = `${edit.path}/`;
387
+ for (const [path, text] of Object.entries(pending)) {
388
+ let target = path;
389
+ if (edit.removeIndex !== undefined || edit.swap) {
390
+ if (path.startsWith(prefix)) {
391
+ const [head, ...tail] = path.slice(prefix.length).split('/');
392
+ let index = Number(head);
393
+ if (index === edit.removeIndex)
394
+ continue;
395
+ if (edit.removeIndex !== undefined && index > edit.removeIndex)
396
+ index--;
397
+ if (edit.swap)
398
+ index = index === edit.swap[0] ? edit.swap[1] : index === edit.swap[1] ? edit.swap[0] : index;
399
+ target = `${prefix}${index}${tail.length ? '/' + tail.join('/') : ''}`;
400
+ }
301
401
  }
402
+ else if (path === edit.path || path.startsWith(prefix))
403
+ continue;
404
+ Object.defineProperty(invalidNumbers, target, { value: text, enumerable: true });
302
405
  }
303
- else if (path === edit.path || path.startsWith(prefix))
304
- continue;
305
- Object.defineProperty(invalidNumbers, target, { value: text, enumerable: true });
306
- }
307
- if (edit.invalidText !== undefined)
308
- Object.defineProperty(invalidNumbers, edit.path, { value: edit.invalidText, enumerable: true });
309
- update(binding.argument, { ...entry, data, invalidNumbers, invalidForm: Object.keys(invalidNumbers).length > 0 });
310
- } })
406
+ if (edit.invalidText !== undefined)
407
+ Object.defineProperty(invalidNumbers, edit.path, { value: edit.invalidText, enumerable: true });
408
+ update(binding.argument, { ...entry, data, invalidNumbers, invalidForm: Object.keys(invalidNumbers).length > 0 });
409
+ } }))
311
410
  : React.createElement("textarea", { "aria-label": `${binding.argument} Source`, value: entry.source, onChange: e => update(binding.argument, { mode: 'source', source: e.target.value, sourceEdited: true }) }),
312
411
  entry.invalidForm === true && React.createElement("p", { role: "alert" }, "Enter a valid JSON number."));
313
412
  }));
314
413
  }
315
- export function mountSourceView(container, context, bindings) {
316
- ReactDOM.render(React.createElement(SourceView, { context: context, bindings: bindings }), container);
414
+ export function mountSourceView(container, context, bindings, commonLayout = false) {
415
+ ReactDOM.render(React.createElement(LayoutHost, { enabled: commonLayout, context: context },
416
+ React.createElement(SourceView, { context: context, bindings: bindings, commonLayout: commonLayout })), container);
317
417
  return () => { ReactDOM.unmountComponentAtNode(container); };
318
418
  }
@@ -1,5 +1,5 @@
1
1
  export default readBlockEditorAppearance;
2
- declare function readBlockEditorAppearance(block: any, ScratchBlocks: any): {
2
+ declare function readBlockEditorAppearance(block: any, ScratchBlocks: any, editableArguments: any): {
3
3
  name: any;
4
4
  colour: string;
5
5
  tint: string;
@@ -1,14 +1,32 @@
1
1
  // Presentation metadata stays outside snapshots, drafts and saved project data.
2
- const readBlockEditorAppearance = (block, ScratchBlocks) => {
2
+ const readBlockEditorAppearance = (block, ScratchBlocks, editableArguments) => {
3
3
  const appearance = {};
4
4
  if (!block)
5
5
  return appearance;
6
6
  try {
7
- // Use the block's own labels, without reading JSON or other values from connected inputs.
7
+ // Registered editor inputs contain the document; other literal inputs identify the block.
8
8
  const name = block.inputList.filter(input => input.type !== ScratchBlocks.NEXT_STATEMENT)
9
- .map(input => input.fieldRow.filter(field => !(field instanceof ScratchBlocks.FieldImage))
10
- .map(field => field.getText())
11
- .join(' '))
9
+ .map(input => {
10
+ const label = input.fieldRow.filter(field => !(field instanceof ScratchBlocks.FieldImage))
11
+ .map(field => field.getText()).join(' ');
12
+ if (!editableArguments || editableArguments.includes(input.name) ||
13
+ input.type !== ScratchBlocks.INPUT_VALUE)
14
+ return label;
15
+ try {
16
+ const child = input.connection && input.connection.targetBlock();
17
+ // Read only literal shadows, never evaluate or traverse reporter expressions.
18
+ if (!child || !child.isShadow() || child.inputList.some(part => part.connection))
19
+ return label;
20
+ const value = child.inputList.flatMap(part => part.fieldRow)
21
+ .filter(field => !(field instanceof ScratchBlocks.FieldImage))
22
+ .map(field => field.getText()).join(' ').replace(/\s+/g, ' ').trim();
23
+ return value ? `${label} (${value})` : label;
24
+ }
25
+ catch (error) {
26
+ // An unavailable identifier must not discard the block's own labels.
27
+ return label;
28
+ }
29
+ })
12
30
  .join(' ')
13
31
  .replace(/\s+/g, ' ')
14
32
  .trim();
@@ -80,6 +80,7 @@ const createBlockEditorBridge = ({ ScratchBlocks, workspace, vm, service, label,
80
80
  return snapshot && Object.values(snapshot.arguments).every(slot => slot.kind === 'literal');
81
81
  };
82
82
  const open = block => {
83
+ var _a, _b;
83
84
  if (activeSession && ['editing', 'saving'].includes(activeSession.getState().status))
84
85
  return;
85
86
  const reference = { targetId: vm.editingTarget.id, blockId: block.id };
@@ -95,13 +96,13 @@ const createBlockEditorBridge = ({ ScratchBlocks, workspace, vm, service, label,
95
96
  openingFixedDeclaration = fixedDeclaration(service.registry, openingSnapshot) || null;
96
97
  const labels = {};
97
98
  const appearances = Object.create(null);
98
- const rootAppearance = readBlockEditorAppearance(block, ScratchBlocks);
99
+ const rootAppearance = readBlockEditorAppearance(block, ScratchBlocks, (_a = service.registry.resolve(block.type)) === null || _a === void 0 ? void 0 : _a.editableArguments);
99
100
  appearances[reference.blockId] = rootAppearance;
100
101
  labels[reference.blockId] = rootAppearance.name || block.type;
101
102
  if (typeof session.getNodes === 'function') {
102
103
  for (const node of session.getNodes()) {
103
104
  const source = workspace.getBlockById(node.reference.blockId);
104
- const appearance = readBlockEditorAppearance(source, ScratchBlocks);
105
+ const appearance = readBlockEditorAppearance(source, ScratchBlocks, (_b = service.registry.resolve(node.opcode)) === null || _b === void 0 ? void 0 : _b.editableArguments);
105
106
  appearances[node.reference.blockId] = appearance;
106
107
  labels[node.reference.blockId] = appearance.name || node.opcode;
107
108
  }
@@ -60,7 +60,8 @@ export declare function bindBlockEditorIcon(options: {
60
60
  readonly canOpen: () => boolean;
61
61
  readonly onOpen: () => void;
62
62
  }): () => void;
63
- export declare function readBlockEditorAppearance(block: object | null, ScratchBlocks: object): TurboWarpNodeAppearance;
63
+ /** Supply editor arguments to include other literal inputs as parenthesized identifiers. */
64
+ export declare function readBlockEditorAppearance(block: object | null, ScratchBlocks: object, editableArguments?: readonly string[]): TurboWarpNodeAppearance;
64
65
  export declare function createBlockEditorErrorSession(reference: BlockReference, error: unknown): BlockEditorFailureSession;
65
66
  export declare function openBlockEditorSession(options: {
66
67
  readonly registry: EditorRegistry;
package/dist/turbowarp.js CHANGED
@@ -29,8 +29,9 @@ export function createTurboWarpBlockEditorHost(options) {
29
29
  export function bindBlockEditorIcon(options) {
30
30
  return bindIcon(options);
31
31
  }
32
- export function readBlockEditorAppearance(block, ScratchBlocks) {
33
- return readAppearance(block, ScratchBlocks);
32
+ /** Supply editor arguments to include other literal inputs as parenthesized identifiers. */
33
+ export function readBlockEditorAppearance(block, ScratchBlocks, editableArguments) {
34
+ return readAppearance(block, ScratchBlocks, editableArguments);
34
35
  }
35
36
  export function createBlockEditorErrorSession(reference, error) {
36
37
  return createErrorSession(reference, error);