@konitif/nodal-blockly 0.1.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.
package/dist/editor.js ADDED
@@ -0,0 +1,588 @@
1
+ import { acquireBlocklyMessages } from './messages.js';
2
+ import { canUseBlocklyValueOutput, getBlocklyReferenceChoices } from './projection.js';
3
+ import { createBlocklyViewportResizeController } from './viewport.js';
4
+ import { createBlocklyPlaybackPresentation } from './playback.js';
5
+ /** Paint-only Zelos specialization. The vendor retains all geometry, hit
6
+ * testing and connections. No observers, frame loops, filters or semantic events. */
7
+ function leaseBlocklyGlassRenderer(api, namespace) {
8
+ const name = `${namespace}glass`;
9
+ const sheenId = `${namespace}sheen`, edgeId = `${namespace}edge`;
10
+ let definitions = null;
11
+ let disposed = false;
12
+ class GlassPath extends api.zelos.PathObject {
13
+ shadow;
14
+ sheen;
15
+ warning;
16
+ constructor(root, style, constants) {
17
+ super(root, style, constants);
18
+ this.shadow = api.utils.dom.createSvgElement(api.utils.Svg.PATH, {
19
+ class: 'konitifBlocklyDepth', 'aria-hidden': 'true', 'pointer-events': 'none',
20
+ fill: 'var(--konitif-blockly-depth, #0005)', transform: 'translate(0, 2)'
21
+ });
22
+ root.insertBefore(this.shadow, this.svgPath);
23
+ this.sheen = api.utils.dom.createSvgElement(api.utils.Svg.PATH, {
24
+ class: 'konitifBlocklySheen', 'aria-hidden': 'true', 'pointer-events': 'none',
25
+ fill: `url(#${sheenId})`, stroke: `url(#${edgeId})`, 'stroke-width': 1.15,
26
+ 'vector-effect': 'non-scaling-stroke', 'stroke-linejoin': 'round'
27
+ });
28
+ this.svgPath.after(this.sheen);
29
+ this.warning = api.utils.dom.createSvgElement(api.utils.Svg.PATH, {
30
+ class: 'konitifBlocklyWarning', display: 'none', 'aria-hidden': 'true', 'pointer-events': 'none',
31
+ fill: `url(#${namespace}warning)`
32
+ });
33
+ this.sheen.after(this.warning);
34
+ }
35
+ setPath(path) {
36
+ super.setPath(path);
37
+ this.shadow.setAttribute('d', path);
38
+ this.sheen.setAttribute('d', path);
39
+ this.warning.setAttribute('d', path);
40
+ }
41
+ flipRTL() {
42
+ super.flipRTL();
43
+ this.sheen.setAttribute('transform', 'scale(-1 1)');
44
+ this.warning.setAttribute('transform', 'scale(-1 1)');
45
+ this.shadow.setAttribute('transform', 'translate(0, 2) scale(-1 1)');
46
+ }
47
+ applyColour(block) {
48
+ super.applyColour(block);
49
+ // Reserve contrast for the white labels before adding a restrained sheen.
50
+ // Keep vendor shadow, disabled-pattern and insertion-marker paint intact.
51
+ if (!block.isShadow() && block.isEnabled() && !block.isInsertionMarker()) {
52
+ const primary = api.utils.colour.parse(this.style.colourPrimary);
53
+ const shaded = primary && api.utils.colour.blend('#000000', primary, .55);
54
+ if (shaded)
55
+ this.svgPath.setAttribute('fill', shaded);
56
+ if (primary)
57
+ block.getSvgRoot().style.setProperty('--konitif-blockly-concept-accent', primary);
58
+ }
59
+ }
60
+ updateInsertionMarker(enabled) {
61
+ super.updateInsertionMarker(enabled);
62
+ this.shadow.style.display = this.sheen.style.display = enabled ? 'none' : '';
63
+ }
64
+ }
65
+ class GlassRenderer extends api.zelos.Renderer {
66
+ makePathObject(root, style) {
67
+ return new GlassPath(root, style, this.getConstants());
68
+ }
69
+ }
70
+ api.blockRendering.register(name, GlassRenderer);
71
+ return {
72
+ name,
73
+ attach(svg) {
74
+ if (disposed || definitions)
75
+ return;
76
+ const doc = svg.ownerDocument, ns = 'http://www.w3.org/2000/svg';
77
+ definitions = doc.createElementNS(ns, 'defs');
78
+ definitions.setAttribute('data-konitif-blockly-paint', name);
79
+ for (const [id, stops] of [
80
+ [sheenId, [['0%', '#fff', 'var(--konitif-blockly-sheen, .08)'], ['32%', '#fff', '0.015'], ['62%', '#000', '0.04'], ['100%', '#000', '.2']]],
81
+ [edgeId, [['0%', '#fff', '.48'], ['42%', '#fff', '.16'], ['100%', '#000', '.4']]]
82
+ ]) {
83
+ const gradient = doc.createElementNS(ns, 'linearGradient');
84
+ gradient.id = id;
85
+ gradient.setAttribute('x1', '0');
86
+ gradient.setAttribute('y1', '0');
87
+ gradient.setAttribute('x2', '0');
88
+ gradient.setAttribute('y2', '1');
89
+ for (const [offset, color, opacity] of stops) {
90
+ const stop = doc.createElementNS(ns, 'stop');
91
+ stop.setAttribute('offset', offset);
92
+ stop.style.stopColor = color;
93
+ stop.style.stopOpacity = opacity;
94
+ gradient.append(stop);
95
+ }
96
+ definitions.append(gradient);
97
+ }
98
+ svg.prepend(definitions);
99
+ const pattern = doc.createElementNS(ns, 'pattern');
100
+ pattern.id = `${namespace}warning`;
101
+ pattern.setAttribute('width', '12');
102
+ pattern.setAttribute('height', '12');
103
+ pattern.setAttribute('patternUnits', 'userSpaceOnUse');
104
+ pattern.setAttribute('patternTransform', 'rotate(45)');
105
+ const stripe = doc.createElementNS(ns, 'rect');
106
+ stripe.setAttribute('width', '5');
107
+ stripe.setAttribute('height', '12');
108
+ stripe.setAttribute('fill', 'var(--konitif-blockly-warning, #fbbf24)');
109
+ stripe.setAttribute('fill-opacity', '.2');
110
+ pattern.append(stripe);
111
+ definitions.append(pattern);
112
+ },
113
+ dispose() {
114
+ if (disposed)
115
+ return;
116
+ disposed = true;
117
+ definitions?.remove();
118
+ definitions = null;
119
+ api.blockRendering.unregister(name);
120
+ }
121
+ };
122
+ }
123
+ const blockly12VariableReadLeases = new WeakMap();
124
+ /**
125
+ * Blockly 12 still routes some of its own inject, serialization and flyout
126
+ * paths through deprecated Workspace#getAllVariables. Lease a compatibility
127
+ * bridge only while a KONITIF editor is mounted, then restore Blockly's exact
128
+ * prototype. Blockly 13 has no legacy descriptor and therefore needs no shim.
129
+ */
130
+ function leaseBlockly12VariableReads(api) {
131
+ const prototype = api.Workspace.prototype;
132
+ let lease = blockly12VariableReadLeases.get(prototype);
133
+ if (!lease) {
134
+ const descriptor = Object.getOwnPropertyDescriptor(prototype, 'getAllVariables');
135
+ if (!descriptor || typeof descriptor.value !== 'function')
136
+ return () => undefined;
137
+ lease = { count: 0, descriptor };
138
+ blockly12VariableReadLeases.set(prototype, lease);
139
+ Object.defineProperty(prototype, 'getAllVariables', {
140
+ ...descriptor,
141
+ value() {
142
+ return this.getVariableMap().getAllVariables();
143
+ }
144
+ });
145
+ }
146
+ lease.count += 1;
147
+ let released = false;
148
+ return () => {
149
+ if (released)
150
+ return;
151
+ released = true;
152
+ lease.count -= 1;
153
+ if (lease.count > 0)
154
+ return;
155
+ Object.defineProperty(prototype, 'getAllVariables', lease.descriptor);
156
+ blockly12VariableReadLeases.delete(prototype);
157
+ };
158
+ }
159
+ /** Vendor adapter. All registered Blockly types are namespaced to this mount and released on disposal. */
160
+ export function createBlocklyEditor(api, element, messages = {}) {
161
+ const namespace = `konitif_${crypto.randomUUID().replaceAll('-', '')}_`;
162
+ const releaseMessages = acquireBlocklyMessages(api, messages);
163
+ const renderer = leaseBlocklyGlassRenderer(api, namespace);
164
+ const releaseVariableReads = leaseBlockly12VariableReads(api);
165
+ // Blockly parses these two component colours during gestures/highlighting;
166
+ // unlike background styles, they cannot receive a raw CSS variable.
167
+ const accent = () => api.utils.colour.parse(getComputedStyle(element).getPropertyValue('--konitif-blockly-accent').trim()) ?? '#4979a8';
168
+ // Host-scoped variables react to theme changes without recreating the semantic workspace.
169
+ const theme = new api.Theme(namespace, {}, {}, {
170
+ workspaceBackgroundColour: 'var(--konitif-blockly-canvas, #f8fafc)',
171
+ toolboxBackgroundColour: 'var(--konitif-blockly-surface, #eef2f6)',
172
+ toolboxForegroundColour: 'var(--konitif-blockly-ink, #213044)',
173
+ flyoutBackgroundColour: 'var(--konitif-blockly-surface, #eef2f6)',
174
+ flyoutForegroundColour: 'var(--konitif-blockly-ink, #213044)',
175
+ flyoutOpacity: 1,
176
+ scrollbarColour: 'var(--konitif-blockly-muted, #60758c)',
177
+ scrollbarOpacity: 0.6,
178
+ insertionMarkerColour: accent(),
179
+ selectedGlowColour: accent()
180
+ });
181
+ let workspace;
182
+ try {
183
+ workspace = api.inject(element, {
184
+ toolbox: { kind: 'flyoutToolbox', contents: [] }, theme,
185
+ trashcan: true, sounds: false, collapse: false, comments: false, disable: false,
186
+ zoom: { controls: true, wheel: true, startScale: 0.9 },
187
+ move: { scrollbars: true, drag: true, wheel: true }, renderer: renderer.name,
188
+ // Leave enough room between a statement label (for example "Clips") and
189
+ // the nested stack, including its external playback rail.
190
+ rendererOverrides: { STATEMENT_INPUT_PADDING_LEFT: 28 }
191
+ });
192
+ renderer.attach(workspace.getParentSvg());
193
+ }
194
+ catch (error) {
195
+ releaseVariableReads();
196
+ renderer.dispose();
197
+ releaseMessages();
198
+ throw error;
199
+ }
200
+ let disposed = false, rendering = false, editable = false;
201
+ const viewportResize = createBlocklyViewportResizeController(workspace, () => api.svgResize(workspace));
202
+ const listeners = new Set();
203
+ const definitions = new Map();
204
+ const occurrences = new Map();
205
+ let pending = false;
206
+ let renderGeneration = 0;
207
+ let expectedReading = '';
208
+ let renderedSubject = '';
209
+ let contributionSignature = '';
210
+ let referenceLayout = false;
211
+ let highlightedModule = null;
212
+ const playbackPresentation = createBlocklyPlaybackPresentation(workspace);
213
+ const blockOccurrence = (block) => {
214
+ let id = occurrences.get(block.id);
215
+ if (!id) {
216
+ id = `blockly.module:${crypto.randomUUID()}`;
217
+ occurrences.set(block.id, id);
218
+ }
219
+ return id;
220
+ };
221
+ function read() {
222
+ return workspace.getAllBlocks(false).filter(b => !b.isInsertionMarker() && definitions.has(b.type)).map(block => {
223
+ const entry = definitions.get(block.type);
224
+ const fields = {};
225
+ for (const field of entry.contribution.fields ?? []) {
226
+ const raw = block.getFieldValue(entry.fields.get(field.configKey));
227
+ fields[field.configKey] = field.editor === 'boolean' ? raw === 'TRUE' : field.editor === 'number' ? Number(raw) : String(raw);
228
+ }
229
+ return { id: blockOccurrence(block), contributionId: entry.contribution.id, fields,
230
+ inputs: Object.fromEntries([...entry.inputs].map(([key, name]) => {
231
+ const statement = entry.contribution.statement;
232
+ if (statement?.previous === key) {
233
+ const previous = block.getPreviousBlock();
234
+ const predecessor = previous?.getNextBlock() === block ? previous : null;
235
+ return [key, predecessor ? { moduleId: blockOccurrence(predecessor), portId: definitions.get(predecessor.type).contribution.statement.next } : null];
236
+ }
237
+ if (statement?.containers?.some(c => c.portId === key)) {
238
+ let tail = block.getInputTargetBlock(name);
239
+ while (tail?.getNextBlock())
240
+ tail = tail.getNextBlock();
241
+ return [key, tail ? { moduleId: blockOccurrence(tail), portId: definitions.get(tail.type).contribution.statement.next } : null];
242
+ }
243
+ if (referenceLayout) {
244
+ const child = entry.valueInputs.has(name) ? block.getInputTargetBlock(name) : null;
245
+ if (child)
246
+ return [key, { moduleId: blockOccurrence(child), portId: definitions.get(child.type).outputPortId }];
247
+ const raw = block.getFieldValue(name);
248
+ return [key, raw ? JSON.parse(String(raw)) : null];
249
+ }
250
+ const child = block.getInputTargetBlock(name);
251
+ return [key, child ? blockOccurrence(child) : null];
252
+ })) };
253
+ });
254
+ }
255
+ const fingerprint = () => JSON.stringify(read().sort((a, b) => a.id.localeCompare(b.id)));
256
+ const selectionListeners = new Set();
257
+ function onChange(event) {
258
+ if (disposed)
259
+ return;
260
+ if (event.type === api.Events.VIEWPORT_CHANGE && event.workspaceId === workspace.id) {
261
+ viewportResize.cameraChanged();
262
+ return;
263
+ }
264
+ if (rendering || !editable)
265
+ return;
266
+ if (event.type === api.Events.SELECTED && event.workspaceId === workspace.id) {
267
+ const id = event.newElementId;
268
+ const block = id ? workspace.getBlockById(id) : null;
269
+ if (id && (!block || block.isInsertionMarker() || !definitions.has(block.type)))
270
+ return;
271
+ const moduleId = block ? blockOccurrence(block) : null;
272
+ for (const listener of selectionListeners)
273
+ listener(moduleId);
274
+ return;
275
+ }
276
+ if (event.isUiEvent || workspace.isDragging())
277
+ return;
278
+ // Selecting a reference explicitly replaces an attached value, not a second connection.
279
+ const change = event;
280
+ if (referenceLayout && change.element === 'field' && change.blockId && change.name) {
281
+ const block = workspace.getBlockById(change.blockId);
282
+ const entry = block && definitions.get(block.type);
283
+ if (block && entry?.valueInputs.has(change.name) && block.getFieldValue(change.name)) {
284
+ const connection = block.getInput(change.name)?.connection;
285
+ if (connection?.targetConnection)
286
+ connection.disconnect();
287
+ }
288
+ }
289
+ if (pending)
290
+ return;
291
+ pending = true;
292
+ const generation = renderGeneration;
293
+ // Blockly may dispatch a create/connect gesture as several events; observe the settled workspace.
294
+ queueMicrotask(() => {
295
+ pending = false;
296
+ if (disposed || rendering || generation !== renderGeneration || !editable || workspace.isDragging())
297
+ return;
298
+ if (referenceLayout) {
299
+ const blocks = workspace.getAllBlocks(false).filter(b => !b.isInsertionMarker() && definitions.has(b.type));
300
+ const ids = new Set(blocks.map(blockOccurrence));
301
+ // Deleting a source occurrence disconnects only its incoming references.
302
+ for (const block of blocks)
303
+ for (const name of definitions.get(block.type).inputs.values()) {
304
+ const entry = definitions.get(block.type);
305
+ const port = [...entry.inputs].find(([, n]) => n === name)[0];
306
+ if (entry.contribution.statement?.previous === port || entry.contribution.statement?.containers?.some(c => c.portId === port))
307
+ continue;
308
+ if (definitions.get(block.type).valueInputs.has(name) && block.getInputTargetBlock(name)) {
309
+ if (block.getFieldValue(name))
310
+ block.setFieldValue('', name);
311
+ continue;
312
+ }
313
+ const raw = block.getFieldValue(name);
314
+ if (raw && !ids.has(JSON.parse(String(raw)).moduleId))
315
+ block.setFieldValue('', name);
316
+ }
317
+ }
318
+ const next = fingerprint();
319
+ if (next === expectedReading)
320
+ return; // Includes position-only moves and delayed render events.
321
+ expectedReading = next;
322
+ for (const listener of [...listeners])
323
+ listener();
324
+ });
325
+ }
326
+ workspace.addChangeListener(onChange);
327
+ const releaseDefinitions = () => {
328
+ for (const key of definitions.keys())
329
+ delete api.Blocks[key];
330
+ definitions.clear();
331
+ };
332
+ function render(projection, contributions, snapshot) {
333
+ if (disposed)
334
+ return;
335
+ const schema = snapshot?.dialect.nodeRegistry.map(({ type, title, description, inputs, outputs, defaultConfig }) => ({ type, title, description, inputs, outputs, defaultConfig }));
336
+ const nextContributions = JSON.stringify([projection.layout ?? 'tree', contributions, schema]);
337
+ const nextReading = JSON.stringify([...projection.blocks].sort((a, b) => a.id.localeCompare(b.id)));
338
+ // An admitted field edit already exists in this workspace. Keep its editor/focus;
339
+ // other surfaces and rejected/divergent proposals still take the normal refresh path.
340
+ if (snapshot && editable && projection.editable && renderedSubject === snapshot.workflow.id &&
341
+ contributionSignature === nextContributions && fingerprint() === nextReading) {
342
+ expectedReading = nextReading;
343
+ return;
344
+ }
345
+ const sameSubject = renderedSubject === snapshot?.workflow.id;
346
+ if (!sameSubject)
347
+ viewportResize.reset();
348
+ renderedSubject = snapshot?.workflow.id ?? '';
349
+ contributionSignature = nextContributions;
350
+ rendering = true;
351
+ renderGeneration++;
352
+ const positions = new Map(sameSubject ? workspace.getAllBlocks(false).map(block => [blockOccurrence(block), block.getRelativeToSurfaceXY()]) : []);
353
+ try {
354
+ highlightedModule = null;
355
+ playbackPresentation.reset();
356
+ workspace.clear();
357
+ occurrences.clear();
358
+ releaseDefinitions();
359
+ editable = projection.editable && snapshot !== null;
360
+ referenceLayout = projection.layout === 'references' || projection.layout === 'mixed';
361
+ const mixedLayout = projection.layout === 'mixed';
362
+ const toolbox = [];
363
+ if (snapshot)
364
+ for (const [index, contribution] of contributions.entries()) {
365
+ if (contribution.dialectId !== snapshot.dialect.id || contribution.composite)
366
+ continue;
367
+ const definition = snapshot.dialect.nodeRegistry.find(d => d.type === contribution.nodeType);
368
+ if (!definition || (!referenceLayout && (definition.outputs.length > 1 || [...definition.inputs, ...definition.outputs].some(p => p.mode !== 'value'))))
369
+ continue;
370
+ const key = `${namespace}${index}`;
371
+ const fields = new Map((contribution.fields ?? []).map((field, i) => [field.configKey, `field_${i}`]));
372
+ const inputs = new Map(definition.inputs.map((port, i) => [port.id, `input_${i}`]));
373
+ const statement = contribution.statement;
374
+ const valueInputs = new Set(mixedLayout ? definition.inputs.filter(p => p.mode === 'value' && p.id !== statement?.previous && !statement?.containers?.some(c => c.portId === p.id)).map(p => inputs.get(p.id)) : []);
375
+ const outputPortId = mixedLayout && !statement && canUseBlocklyValueOutput(definition) ? definition.outputs[0].id : undefined;
376
+ definitions.set(key, { contribution, fields, inputs, valueInputs, outputPortId });
377
+ api.Blocks[key] = { init() {
378
+ this.appendDummyInput().appendField(contribution.label ?? definition.title);
379
+ if (statement?.previous)
380
+ this.setPreviousStatement(true, statement.check);
381
+ if (statement?.next)
382
+ this.setNextStatement(true, statement.check);
383
+ const compactPalette = referenceLayout && this.workspace.isFlyout;
384
+ if (compactPalette && outputPortId)
385
+ this.setOutput(true, definition.outputs[0].dataType);
386
+ for (const field of compactPalette ? [] : contribution.fields ?? []) {
387
+ const value = definition.defaultConfig[field.configKey];
388
+ const editor = field.editor === 'number' ? new api.FieldNumber(Number(value ?? 0))
389
+ : field.editor === 'boolean' ? new api.FieldCheckbox(value ? 'TRUE' : 'FALSE')
390
+ : field.options?.length ? new api.FieldDropdown(field.options.map(o => [o.label, o.value])) : new api.FieldTextInput(String(value ?? ''));
391
+ if (editor instanceof api.FieldDropdown)
392
+ editor.maxDisplayLength = 32;
393
+ this.appendDummyInput().appendField(field.label).appendField(editor, fields.get(field.configKey));
394
+ }
395
+ if (referenceLayout && !compactPalette) {
396
+ for (const port of definition.inputs) {
397
+ if (statement?.previous === port.id)
398
+ continue;
399
+ const container = statement?.containers?.find(c => c.portId === port.id);
400
+ if (container) {
401
+ this.appendStatementInput(inputs.get(port.id)).appendField(container.label).setCheck(container.check ?? statement?.check ?? null);
402
+ continue;
403
+ }
404
+ if (statement) {
405
+ this.appendValueInput(inputs.get(port.id)).appendField(port.label).setCheck(port.dataType);
406
+ continue;
407
+ }
408
+ const target = this;
409
+ const dropdown = new api.FieldDropdown(() => {
410
+ const readings = workspace.getAllBlocks(false).filter(b => !b.isInsertionMarker() && definitions.has(b.type)).map(b => ({
411
+ id: blockOccurrence(b), contributionId: definitions.get(b.type).contribution.id
412
+ }));
413
+ const choices = getBlocklyReferenceChoices(snapshot, contributions, readings, blockOccurrence(target), port.id);
414
+ return [['—', ''], ...choices.map(choice => [choice.label, JSON.stringify(choice.reference)])];
415
+ });
416
+ dropdown.maxDisplayLength = 26;
417
+ const name = inputs.get(port.id);
418
+ // Canonical compatibility (including dialect wildcards) is validated by the host.
419
+ const input = valueInputs.has(name) ? this.appendValueInput(name) : this.appendDummyInput();
420
+ input.appendField(`← ${port.label} (${port.mode}:${port.dataType})`).appendField(dropdown, name);
421
+ }
422
+ if (outputPortId)
423
+ this.setOutput(true, definition.outputs[0].dataType);
424
+ else if (!statement)
425
+ for (const port of definition.outputs)
426
+ this.appendDummyInput().appendField(`→ ${port.label} [${port.id}] (${port.mode}:${port.dataType})`);
427
+ }
428
+ else if (!compactPalette) {
429
+ for (const port of definition.inputs)
430
+ this.appendValueInput(inputs.get(port.id)).appendField(port.label).setCheck(port.dataType);
431
+ if (definition.outputs[0])
432
+ this.setOutput(true, definition.outputs[0].dataType);
433
+ }
434
+ this.setColour(contribution.colour ?? '#4979a8');
435
+ this.setTooltip(definition.description);
436
+ } };
437
+ if (editable)
438
+ toolbox.push({ kind: 'block', type: key });
439
+ }
440
+ workspace.updateToolbox({ kind: 'flyoutToolbox', contents: toolbox });
441
+ if (editable) {
442
+ const blocks = new Map();
443
+ const sourceUses = new Map();
444
+ for (const reading of projection.blocks)
445
+ for (const ref of Object.values(reading.inputs)) {
446
+ if (ref) {
447
+ const id = typeof ref === 'string' ? ref : ref.moduleId;
448
+ sourceUses.set(id, (sourceUses.get(id) ?? 0) + 1);
449
+ }
450
+ }
451
+ let nextY = 40;
452
+ for (const [index, reading] of projection.blocks.entries()) {
453
+ const key = [...definitions].find(([, entry]) => entry.contribution.id === reading.contributionId)?.[0];
454
+ if (!key)
455
+ throw new Error('missing-render-definition');
456
+ const block = workspace.newBlock(key, reading.id);
457
+ occurrences.set(block.id, reading.id);
458
+ const entry = definitions.get(key);
459
+ for (const [name, value] of Object.entries(reading.fields))
460
+ block.setFieldValue(typeof value === 'boolean' ? (value ? 'TRUE' : 'FALSE') : String(value), entry.fields.get(name));
461
+ block.initSvg();
462
+ block.render();
463
+ const position = positions.get(reading.id) ?? (referenceLayout ? { x: 40, y: nextY }
464
+ : { x: 40 + (index % 3) * 220, y: 40 + Math.floor(index / 3) * 180 });
465
+ block.moveBy(position.x, position.y);
466
+ nextY = Math.max(nextY, position.y + block.getHeightWidth().height + 32);
467
+ blocks.set(reading.id, block);
468
+ }
469
+ // Dynamic options must see all occurrences, including sources created after their target.
470
+ if (referenceLayout)
471
+ for (const block of blocks.values()) {
472
+ for (const name of definitions.get(block.type).inputs.values()) {
473
+ const field = block.getField(name);
474
+ if (field instanceof api.FieldDropdown)
475
+ field.getOptions(false);
476
+ }
477
+ }
478
+ for (const reading of projection.blocks) {
479
+ const parent = blocks.get(reading.id);
480
+ const entry = definitions.get(parent.type);
481
+ for (const [port, childId] of Object.entries(reading.inputs)) {
482
+ if (!childId)
483
+ continue;
484
+ const statement = entry.contribution.statement;
485
+ if (statement && typeof childId !== 'string') {
486
+ const source = blocks.get(childId.moduleId);
487
+ if (port === statement.previous) {
488
+ if (definitions.get(source.type).contribution.statement?.next !== childId.portId)
489
+ throw new Error('statement-predecessor-not-representable');
490
+ source.nextConnection.connect(parent.previousConnection);
491
+ continue;
492
+ }
493
+ if (statement.containers?.some(c => c.portId === port)) {
494
+ let head = source;
495
+ const seen = new Set();
496
+ while (true) {
497
+ if (seen.has(head.id))
498
+ throw new Error('statement-cycle');
499
+ seen.add(head.id);
500
+ const headEntry = definitions.get(head.type);
501
+ const previous = projection.blocks.find(b => b.id === blockOccurrence(head)).inputs[headEntry.contribution.statement.previous];
502
+ if (!previous || typeof previous === 'string')
503
+ break;
504
+ head = blocks.get(previous.moduleId);
505
+ }
506
+ parent.getInput(entry.inputs.get(port)).connection.connect(head.previousConnection);
507
+ continue;
508
+ }
509
+ parent.getInput(entry.inputs.get(port)).connection.connect(source.outputConnection);
510
+ continue;
511
+ }
512
+ if (referenceLayout) {
513
+ if (typeof childId !== 'string') {
514
+ const child = blocks.get(childId.moduleId);
515
+ const childEntry = definitions.get(child.type);
516
+ if (entry.valueInputs.has(entry.inputs.get(port)) && childEntry.outputPortId === childId.portId &&
517
+ sourceUses.get(childId.moduleId) === 1) {
518
+ parent.getInput(entry.inputs.get(port)).connection.connect(child.outputConnection);
519
+ continue;
520
+ }
521
+ }
522
+ parent.setFieldValue(JSON.stringify(childId), entry.inputs.get(port));
523
+ continue;
524
+ }
525
+ if (typeof childId !== 'string')
526
+ throw new Error('reference-in-tree-layout');
527
+ parent.getInput(entry.inputs.get(port)).connection.connect(blocks.get(childId).outputConnection);
528
+ }
529
+ }
530
+ // Nesting expands value cards: arrange new roots only after their final geometry exists.
531
+ if (referenceLayout) {
532
+ const generation = renderGeneration;
533
+ void api.renderManagement.finishQueuedRenders().then(() => {
534
+ if (disposed || generation !== renderGeneration)
535
+ return;
536
+ let y = 40;
537
+ for (const block of blocks.values()) {
538
+ if (block.getParent())
539
+ continue;
540
+ const position = block.getRelativeToSurfaceXY();
541
+ if (!positions.has(blockOccurrence(block)))
542
+ block.moveBy(40 - position.x, y - position.y);
543
+ y = Math.max(y, block.getRelativeToSurfaceXY().y + block.getHeightWidth().height + 32);
544
+ }
545
+ });
546
+ }
547
+ }
548
+ workspace.clearUndo();
549
+ expectedReading = fingerprint();
550
+ }
551
+ finally {
552
+ rendering = false;
553
+ }
554
+ }
555
+ return {
556
+ render, read,
557
+ presentPlayback(blocks, label) { if (!disposed)
558
+ playbackPresentation.present(blocks, label); },
559
+ highlight(moduleId) { if (!disposed && highlightedModule !== moduleId) {
560
+ theme.setComponentStyle('selectedGlowColour', accent());
561
+ workspace.highlightBlock(moduleId ?? null);
562
+ highlightedModule = moduleId;
563
+ } },
564
+ onSemanticChange(listener) { listeners.add(listener); return () => { listeners.delete(listener); }; },
565
+ onSelectionChange(listener) { selectionListeners.add(listener); return () => { selectionListeners.delete(listener); }; },
566
+ resize() { if (!disposed && element.clientWidth > 0 && element.clientHeight > 0)
567
+ viewportResize.resize(); },
568
+ dispose() {
569
+ if (disposed)
570
+ return;
571
+ disposed = true;
572
+ renderGeneration++;
573
+ workspace.removeChangeListener(onChange);
574
+ listeners.clear();
575
+ selectionListeners.clear();
576
+ try {
577
+ workspace.dispose();
578
+ }
579
+ finally {
580
+ releaseVariableReads();
581
+ renderer.dispose();
582
+ releaseDefinitions();
583
+ occurrences.clear();
584
+ releaseMessages();
585
+ }
586
+ }
587
+ };
588
+ }
@@ -0,0 +1,6 @@
1
+ export * from './contracts.js';
2
+ export * from './catalog.js';
3
+ export * from './projection.js';
4
+ export * from './session.js';
5
+ /** Declaration only. No catalog, workspace, contribution or subscription is activated here. */
6
+ export declare const nodalBlocklyToolModule: import("@konitif/tools").KonitifToolModule<unknown>;
package/dist/index.js ADDED
@@ -0,0 +1,21 @@
1
+ import { defineKonitifToolModule } from '@konitif/tools';
2
+ export * from './contracts.js';
3
+ export * from './catalog.js';
4
+ export * from './projection.js';
5
+ export * from './session.js';
6
+ /** Declaration only. No catalog, workspace, contribution or subscription is activated here. */
7
+ export const nodalBlocklyToolModule = defineKonitifToolModule({
8
+ id: 'konitif.nodal-blockly', name: 'Blockly', version: '0.1.0',
9
+ scope: 'generic', capability: 'block-workflow-authoring',
10
+ implementationBindingKey: 'konitif.nodal-blockly',
11
+ description: 'Blockly projection over the Nodal and Composition contracts, hosted through explicit ports.',
12
+ capabilities: {
13
+ provides: [{ id: 'konitif.nodal-blockly.edit', version: '1.0.0' }],
14
+ consumes: [
15
+ { id: 'konitif.workflow.authoring', versionRange: '^1.0.0', mode: 'required', purpose: 'Read, validate and commit the canonical Workflow through the host port.' },
16
+ { id: 'konitif.nodal.dialect', versionRange: '^1.0.0', mode: 'required', purpose: 'Resolve the shared definitions; blocks never redefine their semantics.' },
17
+ { id: 'konitif.workflow.run', versionRange: '^1.0.0', mode: 'optional', purpose: 'Request the existing host runtime, if available.' }
18
+ ]
19
+ },
20
+ contributions: [{ id: 'konitif.nodal-blockly.surface', kind: 'surface' }]
21
+ });
@@ -0,0 +1,3 @@
1
+ import type * as Blockly from 'blockly/core';
2
+ /** Blockly owns a shared message table. Fill only missing defaults on mount and restore our additions on last release. */
3
+ export declare function acquireBlocklyMessages(api: typeof Blockly, defaults: Record<string, string>): () => void;
@@ -0,0 +1,28 @@
1
+ const leases = new WeakMap();
2
+ /** Blockly owns a shared message table. Fill only missing defaults on mount and restore our additions on last release. */
3
+ export function acquireBlocklyMessages(api, defaults) {
4
+ let lease = leases.get(api.Msg);
5
+ if (!lease) {
6
+ const added = {};
7
+ for (const [key, value] of Object.entries(defaults))
8
+ if (typeof value === 'string' && !(key in api.Msg)) {
9
+ api.Msg[key] = value;
10
+ added[key] = value;
11
+ }
12
+ lease = { count: 0, added };
13
+ leases.set(api.Msg, lease);
14
+ }
15
+ lease.count++;
16
+ let released = false;
17
+ return () => {
18
+ if (released)
19
+ return;
20
+ released = true;
21
+ if (--lease.count !== 0)
22
+ return;
23
+ for (const [key, value] of Object.entries(lease.added))
24
+ if (api.Msg[key] === value)
25
+ delete api.Msg[key];
26
+ leases.delete(api.Msg);
27
+ };
28
+ }
@@ -0,0 +1,7 @@
1
+ import type * as Blockly from "blockly/core";
2
+ import type { BlocklyPlaybackReading } from "./contracts.js";
3
+ /** Differential SVG paint only. No workspace serialization or semantic events. */
4
+ export declare function createBlocklyPlaybackPresentation(workspace: Blockly.WorkspaceSvg): {
5
+ present: (blocks: Readonly<Record<string, BlocklyPlaybackReading>>, label: (state: BlocklyPlaybackReading["state"]) => string) => void;
6
+ reset(): void;
7
+ };