@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.
@@ -0,0 +1,97 @@
1
+ /** Differential SVG paint only. No workspace serialization or semantic events. */
2
+ export function createBlocklyPlaybackPresentation(workspace) {
3
+ const applied = new Map();
4
+ function present(blocks, label) {
5
+ const nextIds = new Set(Object.keys(blocks));
6
+ for (const id of applied.keys())
7
+ if (!nextIds.has(id)) {
8
+ const block = workspace.getBlockById(id);
9
+ if (block) {
10
+ const root = block.getSvgRoot();
11
+ delete root.dataset.playbackState;
12
+ delete root.dataset.playbackWarning;
13
+ delete root.dataset.playbackWarningMessage;
14
+ root.querySelector(":scope > .konitifBlocklyPlayback")?.remove();
15
+ block.setWarningText(null, "konitif-playback");
16
+ }
17
+ applied.delete(id);
18
+ }
19
+ for (const [id, reading] of Object.entries(blocks)) {
20
+ const block = workspace.getBlockById(id);
21
+ if (!block || block.isInsertionMarker() || block.isInFlyout)
22
+ continue;
23
+ const root = block.getSvgRoot();
24
+ const progress = Math.round(Math.min(1, Math.max(0, Number.isFinite(reading.progress) ? reading.progress : 0)) * 100);
25
+ const caption = label(reading.state);
26
+ const warning = reading.warning?.message ?? "";
27
+ // Blockly's public width includes connected statement children. Status
28
+ // belongs to this block's own header, so anchor it to the childless
29
+ // outline or a wide nested stack pushes the label into empty canvas.
30
+ const size = { width: block.width, headerWidth: block.childlessWidth, height: block.height };
31
+ const signature = JSON.stringify([
32
+ reading.state,
33
+ progress,
34
+ warning,
35
+ caption,
36
+ size.width,
37
+ size.headerWidth,
38
+ size.height,
39
+ ]);
40
+ const previous = applied.get(id);
41
+ if (previous?.root === root && previous.signature === signature)
42
+ continue;
43
+ root.dataset.playbackState = reading.state;
44
+ if (warning)
45
+ root.dataset.playbackWarning = "true";
46
+ else
47
+ delete root.dataset.playbackWarning;
48
+ // The vendor owns warning icon placement and tooltip; a scoped ID avoids
49
+ // clearing unrelated diagnostics. Only a changed warning rerenders it.
50
+ if (previous?.root !== root ||
51
+ root.dataset.playbackWarningMessage !== warning) {
52
+ block.setWarningText(warning || null, "konitif-playback");
53
+ root.dataset.playbackWarningMessage = warning;
54
+ }
55
+ let decoration = root.querySelector(":scope > .konitifBlocklyPlayback");
56
+ if (!decoration) {
57
+ decoration = root.ownerDocument.createElementNS("http://www.w3.org/2000/svg", "g");
58
+ decoration.classList.add("konitifBlocklyPlayback");
59
+ decoration.setAttribute("pointer-events", "none");
60
+ decoration.innerHTML =
61
+ '<title></title><text text-anchor="end" font-size="8" font-weight="700"></text><line class="konitifBlocklyPlaybackTrack" stroke-width="4" stroke-linecap="round" vector-effect="non-scaling-stroke" /><line class="konitifBlocklyPlaybackValue" stroke-width="2.5" stroke-linecap="round" vector-effect="non-scaling-stroke" />';
62
+ root.append(decoration);
63
+ }
64
+ decoration.querySelector("title").textContent = warning
65
+ ? `${caption} — ${warning}`
66
+ : caption;
67
+ const text = decoration.querySelector("text");
68
+ text.textContent = caption;
69
+ text.setAttribute("x", String(Math.max(8, size.headerWidth - 8)));
70
+ text.setAttribute("y", "11");
71
+ // A statement stack advances from top to bottom. Project progress in the
72
+ // same direction, in a quiet gutter outside the block silhouette, rather
73
+ // than drawing a horizontal meter across the block content.
74
+ const railX = -8;
75
+ const railStart = 6;
76
+ const railEnd = Math.max(railStart, size.height - 6);
77
+ const track = decoration.querySelector(".konitifBlocklyPlaybackTrack");
78
+ track.setAttribute("x1", String(railX));
79
+ track.setAttribute("x2", String(railX));
80
+ track.setAttribute("y1", String(railStart));
81
+ track.setAttribute("y2", String(railEnd));
82
+ const value = decoration.querySelector(".konitifBlocklyPlaybackValue");
83
+ value.setAttribute("x1", String(railX));
84
+ value.setAttribute("x2", String(railX));
85
+ value.setAttribute("y1", String(railStart));
86
+ value.setAttribute("y2", String(railStart + ((railEnd - railStart) * progress) / 100));
87
+ value.style.display = progress > 0 ? "" : "none";
88
+ applied.set(id, { root, signature });
89
+ }
90
+ }
91
+ return {
92
+ present,
93
+ reset() {
94
+ applied.clear();
95
+ },
96
+ };
97
+ }
@@ -0,0 +1,24 @@
1
+ import { type Workflow } from '@konitif/composition';
2
+ import { type NodalDialect } from '@konitif/nodal';
3
+ import type { BlocklyBlockReading, BlocklyNodeContribution, BlocklyProjection, BlocklyWorkflowSnapshot } from './contracts.js';
4
+ /** Presentation eligibility only; shared validation remains the connection authority. */
5
+ export declare function canUseBlocklyValueOutput(definition: NodalDialect['nodeRegistry'][number]): boolean;
6
+ /** A presentation query, not admission. Final edits validate the complete graph. */
7
+ export declare function getBlocklyReferenceChoices(snapshot: BlocklyWorkflowSnapshot, contributions: readonly BlocklyNodeContribution[], occurrences: readonly Pick<BlocklyBlockReading, 'id' | 'contributionId'>[], targetId: string, portId: string): {
8
+ label: string;
9
+ reference: {
10
+ moduleId: string;
11
+ portId: string;
12
+ };
13
+ }[];
14
+ /** Tree blocks for simple value dialects; explicit port references for structured dialects. */
15
+ export declare function projectBlocklyWorkflow(snapshot: BlocklyWorkflowSnapshot, contributions: readonly BlocklyNodeContribution[]): BlocklyProjection;
16
+ /** Patch the existing canonical artifact; no whole-workflow roundtrip through a lossy graph encoding. */
17
+ export declare function proposeBlocklyEdit(snapshot: BlocklyWorkflowSnapshot, contributions: readonly BlocklyNodeContribution[], reading: readonly BlocklyBlockReading[]): {
18
+ accepted: true;
19
+ candidate: Workflow;
20
+ changed: boolean;
21
+ } | {
22
+ accepted: false;
23
+ reason: string;
24
+ };
@@ -0,0 +1,275 @@
1
+ import { commitWorkflowComposition } from '@konitif/composition';
2
+ import { createNodeFromDefinition, createNodalGraphDocument, nodalNodeToModule, projectWorkflowToNodalGraph, validateGraphDocument } from '@konitif/nodal';
3
+ const failure = (reason) => ({ accepted: false, reason });
4
+ /** Presentation eligibility only; shared validation remains the connection authority. */
5
+ export function canUseBlocklyValueOutput(definition) {
6
+ return definition.outputs.length === 1 &&
7
+ [...definition.inputs, ...definition.outputs].every(port => port.mode === 'value');
8
+ }
9
+ /** A presentation query, not admission. Final edits validate the complete graph. */
10
+ export function getBlocklyReferenceChoices(snapshot, contributions, occurrences, targetId, portId) {
11
+ const target = occurrences.find(b => b.id === targetId);
12
+ if (!target)
13
+ return [];
14
+ const targetDefinition = resolve(snapshot, contributions, target.contributionId).definition;
15
+ return occurrences.flatMap(source => {
16
+ if (source.id === targetId)
17
+ return [];
18
+ const definition = resolve(snapshot, contributions, source.contributionId).definition;
19
+ return definition.outputs.flatMap(port => {
20
+ const graph = createNodalGraphDocument({ dialect: snapshot.dialect.id });
21
+ graph.nodes = [source, target].map((occurrence, index) => {
22
+ const node = createNodeFromDefinition({ definition: index === 0 ? definition : targetDefinition, position: { x: 0, y: 0 } });
23
+ node.id = occurrence.id;
24
+ for (const p of [...node.inputs, ...node.outputs])
25
+ p.nodeId = node.id;
26
+ return node;
27
+ });
28
+ graph.edges = [{ id: 'projection-query', sourceNodeId: source.id, sourcePortId: port.id, targetNodeId: targetId, targetPortId: portId }];
29
+ if (!validateGraphDocument(graph, snapshot.dialect).valid)
30
+ return [];
31
+ return [{ label: `${port.label} [${port.id}] ← ${definition.title} [${source.id}] (${port.mode}:${port.dataType})`,
32
+ reference: { moduleId: source.id, portId: port.id } }];
33
+ });
34
+ });
35
+ }
36
+ function resolve(snapshot, contributions, id) {
37
+ const contribution = contributions.find(c => c.id === id && c.dialectId === snapshot.dialect.id);
38
+ const definition = snapshot.dialect.nodeRegistry.find(d => d.type === contribution?.nodeType);
39
+ if (!contribution || !definition)
40
+ throw new Error(`missing-definition:${id}`);
41
+ if (contribution.composite)
42
+ throw new Error(`composite-not-supported:${id}`);
43
+ const statement = contribution.statement;
44
+ if (statement) {
45
+ for (const port of [statement.previous, ...(statement.containers ?? []).map(c => c.portId)].filter(Boolean)) {
46
+ if (!definition.inputs.some(p => p.id === port))
47
+ throw new Error(`statement-input-missing:${port}`);
48
+ }
49
+ if (statement.next && !definition.outputs.some(p => p.id === statement.next))
50
+ throw new Error('statement-output-missing');
51
+ }
52
+ for (const field of contribution.fields ?? []) {
53
+ if (!Object.hasOwn(definition.defaultConfig, field.configKey) || typeof definition.defaultConfig[field.configKey] !== (field.editor === 'text' ? 'string' : field.editor)) {
54
+ throw new Error(`field-not-in-shared-schema:${field.configKey}`);
55
+ }
56
+ }
57
+ return { contribution, definition };
58
+ }
59
+ /** Tree blocks for simple value dialects; explicit port references for structured dialects. */
60
+ export function projectBlocklyWorkflow(snapshot, contributions) {
61
+ const issues = [];
62
+ const graph = projectWorkflowToNodalGraph(snapshot.workflow);
63
+ const blocks = [];
64
+ const structured = contributions.some(c => {
65
+ const definition = snapshot.dialect.nodeRegistry.find(d => d.type === c.nodeType && c.dialectId === snapshot.dialect.id);
66
+ return definition && (definition.outputs.length > 1 || [...definition.inputs, ...definition.outputs].some(p => p.mode !== 'value'));
67
+ });
68
+ const hasValues = contributions.some(c => {
69
+ const definition = snapshot.dialect.nodeRegistry.find(d => d.type === c.nodeType && c.dialectId === snapshot.dialect.id);
70
+ return definition && canUseBlocklyValueOutput(definition);
71
+ });
72
+ const layout = contributions.some(c => c.statement) ? 'mixed' : structured ? (hasValues ? 'mixed' : 'references') : 'tree';
73
+ if (graph.dialect !== snapshot.dialect.id)
74
+ issues.push('dialect-mismatch');
75
+ for (const node of graph.nodes) {
76
+ try {
77
+ const contribution = contributions.find(c => c.nodeType === node.type && c.dialectId === snapshot.dialect.id);
78
+ if (!contribution)
79
+ throw new Error(`missing-projection:${node.type}`);
80
+ const { definition } = resolve(snapshot, contributions, contribution.id);
81
+ if (node.mode !== 'always' || node.locked || node.missingDefinition)
82
+ throw new Error(`node-state-not-editable:${node.id}`);
83
+ const module = snapshot.workflow.composition.modules.find(m => m.id === node.id);
84
+ // Keep unrepresentable contracts intact instead of casting their semantics to a value block.
85
+ for (const port of module.ports) {
86
+ const template = (port.direction === 'input' ? definition.inputs : definition.outputs).find(p => p.id === port.id);
87
+ if (!template || template.mode !== port.contract.mode ||
88
+ (port.contract.valueType !== template.dataType && port.contract.valueType !== 'unknown') || port.contract.multiple) {
89
+ throw new Error(`port-not-representable:${node.id}:${port.id}`);
90
+ }
91
+ }
92
+ if (module.ports.length !== definition.inputs.length + definition.outputs.length)
93
+ throw new Error(`schema-changed:${node.id}`);
94
+ const fields = {};
95
+ for (const field of contribution.fields ?? []) {
96
+ const value = node.config[field.configKey] ?? definition.defaultConfig[field.configKey];
97
+ const expected = field.editor === 'text' ? 'string' : field.editor;
98
+ if (typeof value !== expected || (typeof value === 'number' && !Number.isFinite(value)))
99
+ throw new Error(`field-not-representable:${field.configKey}`);
100
+ fields[field.configKey] = value;
101
+ }
102
+ blocks.push({ id: node.id, contributionId: contribution.id, fields, inputs: Object.fromEntries(definition.inputs.map(p => [p.id, null])) });
103
+ }
104
+ catch (error) {
105
+ issues.push(error instanceof Error ? error.message : 'projection-failed');
106
+ }
107
+ }
108
+ const children = new Set();
109
+ for (const connection of snapshot.workflow.composition.connections) {
110
+ const parent = blocks.find(b => b.id === connection.target.moduleId);
111
+ const child = blocks.find(b => b.id === connection.source.moduleId);
112
+ const childModule = snapshot.workflow.composition.modules.find(m => m.id === connection.source.moduleId);
113
+ const outputs = childModule?.ports.filter(p => p.direction === 'output') ?? [];
114
+ if (!parent || !child || !(connection.target.portId in parent.inputs) || !outputs.some(p => p.id === connection.source.portId) ||
115
+ (layout === 'tree' && outputs.length !== 1)) {
116
+ issues.push(`connection-not-representable:${connection.id}`);
117
+ continue;
118
+ }
119
+ if ((layout === 'tree' && children.has(child.id)) || parent.inputs[connection.target.portId] !== null)
120
+ issues.push(`shared-output-or-occupied-input:${connection.id}`);
121
+ children.add(child.id);
122
+ parent.inputs[connection.target.portId] = layout !== 'tree'
123
+ ? { moduleId: child.id, portId: connection.source.portId } : child.id;
124
+ }
125
+ const visiting = new Set(), visited = new Set();
126
+ const visit = (id) => {
127
+ if (visiting.has(id)) {
128
+ issues.push(`cycle-not-supported:${id}`);
129
+ return;
130
+ }
131
+ if (visited.has(id))
132
+ return;
133
+ visiting.add(id);
134
+ for (const child of Object.values(blocks.find(b => b.id === id)?.inputs ?? {}))
135
+ if (child)
136
+ visit(typeof child === 'string' ? child : child.moduleId);
137
+ visiting.delete(id);
138
+ visited.add(id);
139
+ };
140
+ for (const block of blocks)
141
+ visit(block.id);
142
+ const validation = validateGraphDocument(graph, snapshot.dialect);
143
+ for (const block of blocks) {
144
+ const statement = contributions.find(c => c.id === block.contributionId)?.statement;
145
+ if (statement?.next && snapshot.workflow.composition.connections.filter(c => c.source.moduleId === block.id && c.source.portId === statement.next).length > 1) {
146
+ issues.push(`statement-output-shared:${block.id}`);
147
+ }
148
+ if (statement)
149
+ for (const [port, ref] of Object.entries(block.inputs)) {
150
+ if (!ref || typeof ref === 'string')
151
+ continue;
152
+ const source = blocks.find(b => b.id === ref.moduleId);
153
+ const sourceStatement = contributions.find(c => c.id === source?.contributionId)?.statement;
154
+ if (port === statement.previous || statement.containers?.some(c => c.portId === port)) {
155
+ if (sourceStatement?.next !== ref.portId)
156
+ issues.push(`statement-source-not-representable:${block.id}:${port}`);
157
+ const container = statement.containers?.find(c => c.portId === port);
158
+ if (container) {
159
+ let head = source;
160
+ const seen = new Set();
161
+ while (head && !seen.has(head.id)) {
162
+ seen.add(head.id);
163
+ const shape = contributions.find(c => c.id === head.contributionId)?.statement;
164
+ const previous = shape?.previous ? head.inputs[shape.previous] : null;
165
+ if (!previous || typeof previous === 'string')
166
+ break;
167
+ head = blocks.find(b => b.id === previous.moduleId);
168
+ }
169
+ const shape = contributions.find(c => c.id === head?.contributionId)?.statement;
170
+ const check = container.check ?? statement.check, other = shape?.check;
171
+ if (check && other && !(Array.isArray(check) ? check : [check]).some(c => (Array.isArray(other) ? other : [other]).includes(c)))
172
+ issues.push(`statement-container-check-mismatch:${block.id}:${port}`);
173
+ }
174
+ }
175
+ else if (snapshot.workflow.composition.connections.filter(c => c.source.moduleId === ref.moduleId && c.source.portId === ref.portId).length > 1) {
176
+ issues.push(`statement-parameter-shared:${block.id}:${port}`);
177
+ }
178
+ }
179
+ }
180
+ if (!validation.valid)
181
+ issues.push(...validation.issues.filter(i => i.severity === 'error').map(i => `invalid-connection:${i.code}`));
182
+ return { layout, editable: issues.length === 0, issues: [...new Set(issues)], blocks };
183
+ }
184
+ /** Patch the existing canonical artifact; no whole-workflow roundtrip through a lossy graph encoding. */
185
+ export function proposeBlocklyEdit(snapshot, contributions, reading) {
186
+ try {
187
+ const projection = projectBlocklyWorkflow(snapshot, contributions);
188
+ if (!projection.editable)
189
+ return failure(projection.issues.join('; '));
190
+ if (new Set(reading.map(b => b.id)).size !== reading.length)
191
+ return failure('duplicate-occurrence');
192
+ const candidate = structuredClone(snapshot.workflow);
193
+ const previousModules = snapshot.workflow.composition.modules;
194
+ candidate.composition.modules = reading.map(block => {
195
+ if (!/^[a-zA-Z0-9][a-zA-Z0-9._:-]*$/.test(block.id))
196
+ throw new Error('invalid-occurrence-id');
197
+ const { contribution, definition } = resolve(snapshot, contributions, block.contributionId);
198
+ const previous = previousModules.find(m => m.id === block.id);
199
+ if (previous && previous.kind !== definition.type)
200
+ throw new Error('occurrence-definition-changed');
201
+ const module = previous ? structuredClone(previous) : nodalNodeToModule(createNodeFromDefinition({ definition, position: { x: 0, y: 0 } }));
202
+ if (!previous) {
203
+ module.id = block.id;
204
+ for (const port of module.ports) {
205
+ port.moduleId = block.id;
206
+ port.contract.id = `contract.${block.id}.${port.id}`;
207
+ }
208
+ }
209
+ const config = { ...(module.metadata?.config ?? {}) };
210
+ const fields = contribution.fields ?? [];
211
+ if (Object.keys(block.fields).some(key => !fields.some(f => f.configKey === key)))
212
+ throw new Error('unknown-config-field');
213
+ for (const field of fields) {
214
+ const value = block.fields[field.configKey];
215
+ if (typeof value !== (field.editor === 'text' ? 'string' : field.editor) || (typeof value === 'number' && !Number.isFinite(value)))
216
+ throw new Error(`invalid-field:${field.configKey}`);
217
+ config[field.configKey] = value;
218
+ }
219
+ module.metadata = { ...module.metadata, config };
220
+ return module;
221
+ });
222
+ // Preserve authored module ordering, even if Blockly enumerates them in a different order.
223
+ const priorOrder = new Map(previousModules.map((m, i) => [m.id, i]));
224
+ candidate.composition.modules.sort((a, b) => (priorOrder.get(a.id) ?? Number.MAX_SAFE_INTEGER) - (priorOrder.get(b.id) ?? Number.MAX_SAFE_INTEGER));
225
+ candidate.composition.connections = [];
226
+ const usedChildren = new Set();
227
+ for (const block of reading) {
228
+ const { definition } = resolve(snapshot, contributions, block.contributionId);
229
+ if (Object.keys(block.inputs).some(id => !definition.inputs.some(p => p.id === id)))
230
+ throw new Error('unknown-input');
231
+ for (const [portId, reference] of Object.entries(block.inputs)) {
232
+ if (reference === null)
233
+ continue;
234
+ const childId = typeof reference === 'string' ? reference : reference.moduleId;
235
+ const child = candidate.composition.modules.find(m => m.id === childId);
236
+ const outputs = child?.ports.filter(p => p.direction === 'output') ?? [];
237
+ const output = typeof reference === 'string' ? (outputs.length === 1 ? outputs[0] : undefined) : outputs.find(p => p.id === reference.portId);
238
+ if (!child || !output || (projection.layout === 'tree' && usedChildren.has(childId)))
239
+ throw new Error('invalid-or-shared-child');
240
+ usedChildren.add(childId);
241
+ const previous = snapshot.workflow.composition.connections.find(c => c.source.moduleId === childId && c.source.portId === output.id && c.target.moduleId === block.id && c.target.portId === portId);
242
+ candidate.composition.connections.push(previous ? structuredClone(previous) : {
243
+ id: `blockly.connection:${crypto.randomUUID()}`,
244
+ source: { moduleId: childId, portId: output.id }, target: { moduleId: block.id, portId }
245
+ });
246
+ }
247
+ }
248
+ const edgeOrder = new Map(snapshot.workflow.composition.connections.map((c, i) => [c.id, i]));
249
+ candidate.composition.connections.sort((a, b) => (edgeOrder.get(a.id) ?? Number.MAX_SAFE_INTEGER) - (edgeOrder.get(b.id) ?? Number.MAX_SAFE_INTEGER));
250
+ const ids = new Set(candidate.composition.modules.map(m => m.id));
251
+ for (const domain of candidate.composition.domains)
252
+ domain.moduleIds = domain.moduleIds.filter(id => ids.has(id));
253
+ const oldPortContractIds = new Set(previousModules.flatMap(m => m.ports.map(p => p.contract.id)));
254
+ const nextPortContractIds = new Set(candidate.composition.modules.flatMap(m => m.ports.map(p => p.contract.id)));
255
+ candidate.composition.contracts = candidate.composition.contracts.filter(c => !oldPortContractIds.has(c.id) || nextPortContractIds.has(c.id));
256
+ for (const module of candidate.composition.modules.filter(m => !priorOrder.has(m.id))) {
257
+ for (const port of module.ports)
258
+ if (!candidate.composition.contracts.some(c => c.id === port.contract.id))
259
+ candidate.composition.contracts.push(structuredClone(port.contract));
260
+ }
261
+ const check = projectBlocklyWorkflow({ ...snapshot, workflow: candidate }, contributions);
262
+ if (!check.editable)
263
+ return failure(check.issues.join('; '));
264
+ const nodalValidation = validateGraphDocument(projectWorkflowToNodalGraph(candidate), snapshot.dialect);
265
+ if (!nodalValidation.valid)
266
+ return failure(nodalValidation.issues.map(i => i.message).join('; '));
267
+ const commit = commitWorkflowComposition(snapshot.workflow, candidate);
268
+ if (!commit.accepted)
269
+ return failure(commit.validation.issues.map(i => i.message).join('; '));
270
+ return { accepted: true, candidate: commit.workflow, changed: JSON.stringify(commit.workflow) !== JSON.stringify(snapshot.workflow) };
271
+ }
272
+ catch (error) {
273
+ return failure(error instanceof Error ? error.message : 'invalid-projection');
274
+ }
275
+ }
@@ -0,0 +1,17 @@
1
+ import type { BlocklyEditorPort, NodalBlocklyHost, BlocklyPlaybackReading } from './contracts.js';
2
+ import { BlocklyContributionCatalog } from './catalog.js';
3
+ /** A disposable editing session, never the owner of the host or of its catalog. */
4
+ export declare function createNodalBlocklySession(options: {
5
+ host: NodalBlocklyHost;
6
+ catalog: BlocklyContributionCatalog;
7
+ createEditor(): BlocklyEditorPort;
8
+ status?(message: string): void;
9
+ }): {
10
+ activate(): void;
11
+ deactivate: () => void;
12
+ resize(): void;
13
+ highlight(moduleId: string | null): void;
14
+ presentPlayback(blocks: Readonly<Record<string, BlocklyPlaybackReading>>, label: (state: BlocklyPlaybackReading["state"]) => string): void;
15
+ run(): import("./contracts.js").BlocklyCommitResult;
16
+ dispose(): void;
17
+ };
@@ -0,0 +1,106 @@
1
+ import { projectBlocklyWorkflow, proposeBlocklyEdit } from './projection.js';
2
+ /** A disposable editing session, never the owner of the host or of its catalog. */
3
+ export function createNodalBlocklySession(options) {
4
+ let editor = null;
5
+ let base = null;
6
+ let contributions = [];
7
+ let disposed = false, rendering = false;
8
+ let selectedModuleId = null;
9
+ const releases = [];
10
+ function select(moduleId) {
11
+ if (!editor || !base || rendering)
12
+ return;
13
+ selectedModuleId = moduleId;
14
+ options.host.select?.({ workflowId: base.workflow.id, revision: base.revision, moduleId });
15
+ }
16
+ function refresh() {
17
+ if (!editor)
18
+ return;
19
+ base = options.host.read();
20
+ base = base && { ...base, workflow: structuredClone(base.workflow) };
21
+ contributions = options.catalog.list();
22
+ rendering = true;
23
+ try {
24
+ const projection = base ? projectBlocklyWorkflow(base, contributions) : { editable: false, issues: ['no-workflow'], blocks: [] };
25
+ editor.render(projection, contributions, base);
26
+ options.status?.(projection.issues[0] ?? (contributions.length
27
+ ? (contributions.some(contribution => contribution.statement) ? 'ready-statements' :
28
+ 'layout' in projection && projection.layout === 'mixed' ? 'ready-mixed' :
29
+ 'layout' in projection && projection.layout === 'references' ? 'ready-references' : 'ready') : 'empty-catalog'));
30
+ }
31
+ finally {
32
+ rendering = false;
33
+ }
34
+ }
35
+ function commit() {
36
+ if (!editor || !base || rendering)
37
+ return;
38
+ const current = options.host.read();
39
+ if (!current || current.revision !== base.revision || current.workflow.id !== base.workflow.id) {
40
+ refresh();
41
+ options.status?.('source-changed');
42
+ return;
43
+ }
44
+ const proposal = proposeBlocklyEdit(base, contributions, editor.read());
45
+ if (!proposal.accepted) {
46
+ refresh();
47
+ options.status?.(proposal.reason);
48
+ return;
49
+ }
50
+ if (!proposal.changed)
51
+ return;
52
+ const result = options.host.commit({ baseRevision: base.revision, workflowId: base.workflow.id, candidate: proposal.candidate });
53
+ const selected = selectedModuleId;
54
+ refresh();
55
+ // A parameter edit of the selected occurrence can change its host focus.
56
+ // Foreign refreshes and viewport events never claim global selection.
57
+ if (result.accepted && selected && base?.workflow.composition.modules.some(module => module.id === selected))
58
+ select(selected);
59
+ if (!result.accepted)
60
+ options.status?.(result.reason);
61
+ }
62
+ function deactivate() {
63
+ for (const release of releases.splice(0).reverse())
64
+ release();
65
+ editor?.dispose();
66
+ editor = null;
67
+ base = null;
68
+ selectedModuleId = null;
69
+ }
70
+ return {
71
+ activate() {
72
+ if (disposed)
73
+ throw new Error('session-disposed');
74
+ if (editor)
75
+ return;
76
+ try {
77
+ editor = options.createEditor();
78
+ releases.push(options.host.subscribe(refresh));
79
+ releases.push(options.catalog.subscribe(refresh));
80
+ releases.push(editor.onSemanticChange(commit));
81
+ if (editor.onSelectionChange)
82
+ releases.push(editor.onSelectionChange(select));
83
+ refresh();
84
+ }
85
+ catch (error) {
86
+ deactivate();
87
+ throw error;
88
+ }
89
+ },
90
+ deactivate,
91
+ resize() { editor?.resize(); },
92
+ highlight(moduleId) { editor?.highlight?.(moduleId); },
93
+ presentPlayback(blocks, label) { editor?.presentPlayback?.(blocks, label); },
94
+ run() {
95
+ const snapshot = options.host.read(), runtime = options.host.runtime;
96
+ if (!editor || !snapshot || !runtime)
97
+ return { accepted: false, reason: 'runtime-unavailable' };
98
+ const availability = runtime.availability(snapshot);
99
+ if (!availability.available)
100
+ return { accepted: false, reason: availability.reason ?? 'runtime-unavailable' };
101
+ return runtime.requestRun({ workflowId: snapshot.workflow.id, revision: snapshot.revision });
102
+ },
103
+ dispose() { if (disposed)
104
+ return; deactivate(); disposed = true; }
105
+ };
106
+ }
@@ -0,0 +1,22 @@
1
+ import type { NodalBlocklyHost } from './contracts.js';
2
+ import { BlocklyContributionCatalog } from './catalog.js';
3
+ export interface NodalBlocklySurfaceOptions {
4
+ host: NodalBlocklyHost;
5
+ catalog: BlocklyContributionCatalog;
6
+ /** Translator belongs to the host; English defaults are the portable fallback. */
7
+ text?: (key: string, fallback: string) => string;
8
+ commands?: readonly {
9
+ label: string;
10
+ request(): {
11
+ accepted: boolean;
12
+ reason?: string;
13
+ };
14
+ }[];
15
+ }
16
+ /** Loading and mounting are explicit. Disposal also cancels an outstanding asynchronous load. */
17
+ export declare function mountNodalBlocklySurface(element: HTMLElement, options: NodalBlocklySurfaceOptions): {
18
+ ready: Promise<void>;
19
+ deactivate(): void;
20
+ activate(): void;
21
+ dispose(): void;
22
+ };