@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,355 @@
1
+ import { createBlocklyEditor } from './editor.js';
2
+ import { createNodalBlocklySession } from './session.js';
3
+ /** Loading and mounting are explicit. Disposal also cancels an outstanding asynchronous load. */
4
+ export function mountNodalBlocklySurface(element, options) {
5
+ let disposed = false;
6
+ let activeRequested = true;
7
+ let session = null;
8
+ let resize = null;
9
+ const t = (key, fallback) => options.text?.(key, fallback) ?? fallback;
10
+ const root = element.ownerDocument.createElement('section');
11
+ root.className = 'konitif-nodal-blockly';
12
+ const highlightStyle = element.ownerDocument.createElement('style');
13
+ highlightStyle.textContent = `
14
+ .konitif-nodal-blockly .blocklyDraggable > .blocklyPath {
15
+ stroke: var(--konitif-blockly-concept-accent, var(--konitif-blockly-block-border, #07111b70));
16
+ stroke-width: 1.25px; stroke-linejoin: round; vector-effect: non-scaling-stroke;
17
+ }
18
+ .konitif-nodal-blockly .blocklyHighlighted > .blocklyPath {
19
+ filter:none;
20
+ stroke: var(--konitif-blockly-accent,#22d3ee); stroke-width: 3px;
21
+ }
22
+ .konitif-nodal-blockly .blocklySelected > .blocklyPathSelected {
23
+ stroke: var(--konitif-blockly-accent,#22d3ee); stroke-width: 2.5px;
24
+ vector-effect: non-scaling-stroke;
25
+ }
26
+ .konitif-nodal-blockly .blocklyInsertionMarker > .konitifBlocklySheen,
27
+ .konitif-nodal-blockly .blocklyInsertionMarker > .konitifBlocklyDepth { display:none; }
28
+ .konitif-nodal-blockly .blocklyText { font-weight:600; }
29
+ .konitif-nodal-blockly .konitifBlocklyWarning { display:none; }
30
+ .konitif-nodal-blockly [data-playback-warning] > .konitifBlocklyWarning { display:block; }
31
+ .konitif-nodal-blockly [data-playback-state="queued"] > .blocklyPath { fill-opacity:.48; }
32
+ .konitif-nodal-blockly [data-playback-state="queued"] > .konitifBlocklySheen { opacity:.35; }
33
+ .konitif-nodal-blockly [data-playback-state="running"] > .blocklyPath { stroke:var(--konitif-blockly-accent,#22d3ee); stroke-width:2.5px; }
34
+ .konitif-nodal-blockly [data-playback-state="paused"] > .blocklyPath { stroke:var(--konitif-blockly-warning,#fbbf24); stroke-width:2px; }
35
+ .konitif-nodal-blockly [data-playback-state="completed"] > .blocklyPath { stroke:var(--konitif-blockly-success,#34d399); }
36
+ .konitif-nodal-blockly [data-playback-state="failed"] > .blocklyPath { stroke:var(--konitif-blockly-error,#fb7185); stroke-width:2px; }
37
+ .konitif-nodal-blockly [data-playback-warning] > .blocklyPath { stroke:var(--konitif-blockly-warning,#fbbf24); stroke-width:2px; }
38
+ .konitif-nodal-blockly [data-playback-warning] > .blocklyWarningIcon .blocklyIconShape { fill:var(--konitif-blockly-warning,#fbbf24); stroke:#132435; }
39
+ .konitif-nodal-blockly [data-playback-warning] > .blocklyWarningIcon .blocklyIconSymbol { fill:#132435; }
40
+ .konitif-nodal-blockly .konitifBlocklyPlayback { fill:var(--konitif-blockly-muted,#94a3b8); stroke:none; }
41
+ .konitif-nodal-blockly .konitifBlocklyPlaybackTrack {
42
+ stroke:color-mix(in srgb,var(--konitif-blockly-muted,#94a3b8) 38%,transparent);
43
+ }
44
+ .konitif-nodal-blockly .konitifBlocklyPlaybackValue { stroke:var(--konitif-blockly-accent,#22d3ee); }
45
+ .konitif-nodal-blockly [data-playback-state="completed"] > .konitifBlocklyPlayback .konitifBlocklyPlaybackValue { stroke:var(--konitif-blockly-success,#34d399); }
46
+ .konitif-nodal-blockly [data-playback-state="paused"] > .konitifBlocklyPlayback .konitifBlocklyPlaybackValue,
47
+ .konitif-nodal-blockly [data-playback-warning] > .konitifBlocklyPlayback .konitifBlocklyPlaybackValue { stroke:var(--konitif-blockly-warning,#fbbf24); }
48
+ .konitif-nodal-blockly [data-playback-state="failed"] > .konitifBlocklyPlayback .konitifBlocklyPlaybackValue { stroke:var(--konitif-blockly-error,#fb7185); }
49
+ .konitif-nodal-blockly [data-playback-state="running"] > .konitifBlocklyPlayback { fill:var(--konitif-blockly-accent,#22d3ee); }
50
+ .konitif-nodal-blockly [data-playback-state="completed"] > .konitifBlocklyPlayback { fill:var(--konitif-blockly-success,#34d399); }
51
+ .konitif-nodal-blockly [data-playback-state="paused"] > .konitifBlocklyPlayback,
52
+ .konitif-nodal-blockly [data-playback-warning] > .konitifBlocklyPlayback { fill:var(--konitif-blockly-warning,#fbbf24); }
53
+ .konitif-nodal-blockly .blocklyEditableText > rect {
54
+ stroke: var(--konitif-blockly-field-border, #ffffff42); stroke-width:1px;
55
+ }
56
+ .konitif-nodal-blockly .blocklyFlyoutBackground {
57
+ stroke:var(--konitif-blockly-border,#8884); stroke-width:1px;
58
+ }
59
+ .konitif-nodal-blockly__footer {
60
+ display:flex; align-items:center; gap:10px; flex:0 0 auto;
61
+ min-width:0; padding:8px 14px;
62
+ border-top:1px solid var(--konitif-blockly-border,#8884);
63
+ background:color-mix(in srgb,var(--konitif-blockly-surface,#eef2f6) 72%,transparent);
64
+ }
65
+ .konitif-nodal-blockly__progress {
66
+ flex:1 1 12rem; width:auto; min-width:64px; height:4px;
67
+ accent-color:var(--konitif-blockly-accent,#22d3ee);
68
+ }
69
+ .konitif-nodal-blockly__progress[data-interactive="true"] {
70
+ cursor:ew-resize; touch-action:none;
71
+ }
72
+ .konitif-nodal-blockly__progress[data-interactive="true"]:focus-visible {
73
+ outline:2px solid var(--konitif-blockly-accent,#22d3ee);
74
+ outline-offset:4px;
75
+ }
76
+ .konitif-nodal-blockly__info { position:relative; display:inline-flex; flex:0 0 auto; }
77
+ .konitif-nodal-blockly__info-button {
78
+ display:inline-grid; place-items:center; width:30px; height:30px; padding:0;
79
+ color:var(--konitif-blockly-muted,#60758c);
80
+ background:var(--konitif-blockly-surface,#eef2f6);
81
+ border:1px solid var(--konitif-blockly-border,#8884); border-radius:999px;
82
+ font:700 13px/1 system-ui; cursor:help;
83
+ }
84
+ .konitif-nodal-blockly__info-button:hover,
85
+ .konitif-nodal-blockly__info-button:focus-visible {
86
+ color:var(--konitif-blockly-ink,#213044);
87
+ border-color:var(--konitif-blockly-accent,#22d3ee);
88
+ outline:none;
89
+ }
90
+ .konitif-nodal-blockly__tooltip {
91
+ position:absolute; right:0; bottom:calc(100% + 8px); z-index:20;
92
+ width:max-content; max-width:min(28rem,calc(100vw - 32px)); padding:8px 10px;
93
+ color:var(--konitif-blockly-ink,#213044);
94
+ background:var(--konitif-blockly-surface,#eef2f6);
95
+ border:1px solid var(--konitif-blockly-border,#8884); border-radius:6px;
96
+ box-shadow:0 8px 24px #0005; overflow-wrap:anywhere;
97
+ opacity:0; visibility:hidden; transform:translateY(4px); pointer-events:none;
98
+ transition:opacity 120ms ease,transform 120ms ease,visibility 120ms step-end;
99
+ }
100
+ .konitif-nodal-blockly__info:hover .konitif-nodal-blockly__tooltip,
101
+ .konitif-nodal-blockly__info:focus-within .konitif-nodal-blockly__tooltip {
102
+ opacity:1; visibility:visible; transform:translateY(0);
103
+ transition:opacity 120ms ease,transform 120ms ease,visibility 0ms;
104
+ }
105
+ .konitif-nodal-blockly__status {
106
+ position:absolute; width:1px; height:1px; padding:0; margin:-1px;
107
+ overflow:hidden; clip:rect(0 0 0 0); white-space:nowrap; border:0;
108
+ }
109
+ .konitif-nodal-blockly__command { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
110
+ @media (max-width:520px) {
111
+ .konitif-nodal-blockly__footer { gap:7px; padding-inline:8px; }
112
+ .konitif-nodal-blockly__command { max-width:42%; }
113
+ }
114
+ @media (forced-colors: active) {
115
+ .konitif-nodal-blockly .konitifBlocklySheen,
116
+ .konitif-nodal-blockly .konitifBlocklyDepth { display:none; }
117
+ .konitif-nodal-blockly .blocklyDraggable > .blocklyPath { stroke:CanvasText; }
118
+ .konitif-nodal-blockly .blocklyHighlighted > .blocklyPath { stroke:Highlight; }
119
+ }
120
+ `;
121
+ root.append(highlightStyle);
122
+ root.style.cssText = 'display:flex;flex-direction:column;height:100%;min-height:240px;color:var(--konitif-blockly-ink,#213044);background:var(--konitif-blockly-canvas,#f8fafc);font:14px system-ui';
123
+ const bar = element.ownerDocument.createElement('footer');
124
+ bar.className = 'konitif-nodal-blockly__footer';
125
+ const status = element.ownerDocument.createElement('span');
126
+ status.className = 'konitif-nodal-blockly__status';
127
+ status.setAttribute('role', 'status');
128
+ status.setAttribute('aria-live', 'polite');
129
+ const initialStatus = t('loading', 'Loading Blockly…');
130
+ status.textContent = initialStatus;
131
+ const information = element.ownerDocument.createElement('span');
132
+ information.className = 'konitif-nodal-blockly__info';
133
+ const informationButton = element.ownerDocument.createElement('button');
134
+ informationButton.type = 'button';
135
+ informationButton.className = 'konitif-nodal-blockly__info-button';
136
+ informationButton.textContent = 'i';
137
+ informationButton.setAttribute('aria-label', t('information', 'Program information'));
138
+ const tooltip = element.ownerDocument.createElement('span');
139
+ tooltip.className = 'konitif-nodal-blockly__tooltip';
140
+ tooltip.setAttribute('role', 'tooltip');
141
+ tooltip.id = `konitif-nodal-blockly-status-${crypto.randomUUID()}`;
142
+ tooltip.textContent = initialStatus;
143
+ informationButton.setAttribute('aria-describedby', tooltip.id);
144
+ information.append(informationButton, tooltip);
145
+ const setStatus = (message) => { status.textContent = message; tooltip.textContent = message; };
146
+ const run = element.ownerDocument.createElement('button');
147
+ run.type = 'button';
148
+ run.textContent = t('run', 'Run');
149
+ run.disabled = true;
150
+ run.style.cssText = 'color:inherit;background:var(--konitif-blockly-surface,#eef2f6);border:1px solid var(--konitif-blockly-border,#8884);border-radius:6px;padding:6px 12px;font:inherit';
151
+ const canvas = element.ownerDocument.createElement('div');
152
+ canvas.style.cssText = 'flex:1;min-height:180px;position:relative;overflow:hidden';
153
+ const stop = element.ownerDocument.createElement('button');
154
+ stop.type = 'button';
155
+ stop.textContent = t('stop', 'Stop');
156
+ stop.hidden = !options.host.runtime?.requestStop;
157
+ stop.style.cssText = run.style.cssText;
158
+ const progress = element.ownerDocument.createElement('progress');
159
+ progress.max = 1;
160
+ progress.value = 0;
161
+ progress.hidden = !options.host.runtime?.observe;
162
+ progress.className = 'konitif-nodal-blockly__progress';
163
+ progress.setAttribute('aria-label', t('progress', 'Sequence progress'));
164
+ const canSeek = typeof options.host.runtime?.requestSeek === 'function';
165
+ progress.dataset.interactive = String(canSeek);
166
+ progress.tabIndex = canSeek ? 0 : -1;
167
+ if (canSeek) {
168
+ progress.setAttribute('role', 'slider');
169
+ progress.setAttribute('aria-valuemin', '0');
170
+ progress.setAttribute('aria-valuemax', '100');
171
+ }
172
+ const details = element.ownerDocument.createElement('details');
173
+ details.style.cssText = 'padding:6px 14px';
174
+ const summary = element.ownerDocument.createElement('summary');
175
+ summary.textContent = t('details', 'Details');
176
+ const diagnostic = element.ownerDocument.createElement('div');
177
+ diagnostic.style.cssText = 'overflow-wrap:anywhere;padding:8px 0';
178
+ details.append(summary, diagnostic);
179
+ for (const command of options.commands ?? []) {
180
+ const button = element.ownerDocument.createElement('button');
181
+ button.type = 'button';
182
+ button.textContent = command.label;
183
+ button.className = 'konitif-nodal-blockly__command';
184
+ button.style.cssText = run.style.cssText;
185
+ button.addEventListener('click', () => { const result = command.request(); if (!result.accepted) {
186
+ diagnostic.textContent = result.reason ?? 'command-rejected';
187
+ details.hidden = false;
188
+ } });
189
+ bar.append(button);
190
+ }
191
+ bar.append(progress, information, run, stop, status);
192
+ root.append(canvas, details, bar);
193
+ element.append(root);
194
+ const applySeek = (value) => {
195
+ const normalized = Math.max(0, Math.min(1, Number.isFinite(value) ? value : 0));
196
+ progress.value = normalized;
197
+ progress.setAttribute('aria-valuenow', String(Math.round(normalized * 100)));
198
+ const result = options.host.runtime?.requestSeek?.(normalized);
199
+ if (result && !result.accepted) {
200
+ diagnostic.textContent = result.reason ?? 'seek-rejected';
201
+ details.hidden = false;
202
+ }
203
+ };
204
+ const seekFromPointer = (event) => {
205
+ const bounds = progress.getBoundingClientRect();
206
+ if (!canSeek || bounds.width <= 0)
207
+ return;
208
+ applySeek((event.clientX - bounds.left) / bounds.width);
209
+ };
210
+ let scrubbing = false;
211
+ const progressPointerDown = (event) => {
212
+ if (!canSeek)
213
+ return;
214
+ scrubbing = true;
215
+ if (typeof progress.setPointerCapture === 'function')
216
+ progress.setPointerCapture(event.pointerId);
217
+ seekFromPointer(event);
218
+ event.preventDefault();
219
+ };
220
+ const progressPointerMove = (event) => { if (scrubbing)
221
+ seekFromPointer(event); };
222
+ const progressPointerUp = (event) => { if (!scrubbing)
223
+ return; seekFromPointer(event); scrubbing = false; };
224
+ const progressKeyDown = (event) => {
225
+ if (!canSeek)
226
+ return;
227
+ const next = event.key === 'Home' ? 0 : event.key === 'End' ? 1 : event.key === 'ArrowLeft' || event.key === 'ArrowDown' ? progress.value - .01 : event.key === 'ArrowRight' || event.key === 'ArrowUp' ? progress.value + .01 : null;
228
+ if (next === null)
229
+ return;
230
+ applySeek(next);
231
+ event.preventDefault();
232
+ };
233
+ progress.addEventListener('pointerdown', progressPointerDown);
234
+ progress.addEventListener('pointermove', progressPointerMove);
235
+ progress.addEventListener('pointerup', progressPointerUp);
236
+ progress.addEventListener('pointercancel', progressPointerUp);
237
+ progress.addEventListener('keydown', progressKeyDown);
238
+ function updateRun() {
239
+ const snapshot = options.host.read();
240
+ const availability = snapshot && options.host.runtime?.availability(snapshot);
241
+ if (availability?.reason && options.host.runtime?.observe && !availability.available) {
242
+ const state = options.host.runtime.observe().state;
243
+ if (state !== 'running' && state !== 'preparing') {
244
+ diagnostic.textContent = availability.reason;
245
+ details.hidden = false;
246
+ }
247
+ }
248
+ run.disabled = !session || !activeRequested || !availability?.available;
249
+ run.style.opacity = run.disabled ? '0.5' : '1';
250
+ run.title = availability?.available ? '' : availability?.reason ?? t('runtimeUnavailable', 'No compatible execution provider is available.');
251
+ const observation = options.host.runtime?.observe?.();
252
+ if (observation) {
253
+ root.dataset.executionState = observation.state;
254
+ progress.value = Math.max(0, Math.min(1, observation.progress));
255
+ progress.setAttribute('aria-valuenow', String(Math.round(progress.value * 100)));
256
+ stop.disabled = observation.state !== 'running' && observation.state !== 'preparing';
257
+ session?.highlight(observation.activeModuleId ?? null);
258
+ session?.presentPlayback(observation.blocks ?? {}, state => t(`playback.${state}`, state.toUpperCase()));
259
+ if (observation.state !== 'idle')
260
+ setStatus(observation.message || t(`execution.${observation.state}`, observation.state));
261
+ if (observation.state === 'failed') {
262
+ diagnostic.textContent = observation.message ?? 'execution-failed';
263
+ details.hidden = false;
264
+ }
265
+ }
266
+ }
267
+ const click = () => { const result = session?.run(); if (result && !result.accepted)
268
+ diagnostic.textContent = result.reason; updateRun(); };
269
+ run.addEventListener('click', click);
270
+ const stopClick = () => { options.host.runtime?.requestStop?.(); updateRun(); };
271
+ stop.addEventListener('click', stopClick);
272
+ let releaseRun = null;
273
+ let releaseObservation = null;
274
+ function activateSession() {
275
+ if (!session || disposed || !activeRequested)
276
+ return;
277
+ session.activate();
278
+ if (!releaseRun)
279
+ releaseRun = options.host.subscribe(updateRun);
280
+ if (!releaseObservation)
281
+ releaseObservation = options.host.runtime?.subscribe?.(updateRun) ?? null;
282
+ if (!resize && typeof ResizeObserver !== 'undefined') {
283
+ resize = new ResizeObserver(() => session?.resize());
284
+ resize.observe(canvas);
285
+ }
286
+ session.resize();
287
+ updateRun();
288
+ }
289
+ const ready = Promise.all([import('blockly/core'), import('blockly/msg/en')]).then(([loaded, messages]) => {
290
+ if (disposed)
291
+ return;
292
+ const api = loaded.default ?? loaded;
293
+ const messageSource = messages.default ?? messages;
294
+ const english = Object.fromEntries(Object.entries(messageSource).filter((entry) => typeof entry[1] === 'string'));
295
+ session = createNodalBlocklySession({ host: options.host, catalog: options.catalog, createEditor: () => createBlocklyEditor(api, canvas, english), status(message) {
296
+ const labels = {
297
+ ready: t('ready', 'Ready to edit'), 'empty-catalog': t('emptyCatalog', 'No blocks contributed yet.'),
298
+ 'ready-references': t('readyReferences', 'Ready to edit. Choose source ports in the input menus.'),
299
+ 'ready-statements': t('readyStatements', 'Stack actions under On start. Place clips inside Play animation and values inside parameter sockets.'),
300
+ 'ready-mixed': t('readyMixed', 'Ready to edit. Attach value blocks or choose source ports in the input menus.'),
301
+ 'no-workflow': t('noWorkflow', 'Open or create a workflow in the host.'),
302
+ 'source-changed': t('sourceChanged', 'The workflow changed elsewhere. The current version has been restored.')
303
+ };
304
+ const code = message.split(':')[0];
305
+ const issueLabels = {
306
+ 'missing-projection': t('missingProjection', 'No block presentation is registered for this node type.'),
307
+ 'composite-not-supported': t('unsupportedStructure', 'Composite editing is not yet supported. The workflow is preserved.'),
308
+ 'port-not-representable': t('incompatibleSchema', 'The node port schema cannot be represented by these blocks. The workflow is preserved.'),
309
+ 'schema-changed': t('incompatibleSchema', 'The node port schema cannot be represented by these blocks. The workflow is preserved.'),
310
+ 'connection-not-representable': t('unsupportedConnections', 'These connections cannot be edited with the supported block projections. The workflow is preserved.'),
311
+ 'shared-output-or-occupied-input': t('unsupportedConnections', 'These connections cannot be edited with the supported block projections. The workflow is preserved.'),
312
+ 'cycle-not-supported': t('unsupportedConnections', 'These connections cannot be edited with the supported block projections. The workflow is preserved.')
313
+ };
314
+ setStatus(labels[message] ?? (issueLabels[code] ? `${issueLabels[code]} ${message.slice(code.length + 1)}` : t('unsupported', 'This workflow cannot be edited with the available blocks.')));
315
+ diagnostic.textContent = labels[message] ? '' : message;
316
+ details.hidden = !diagnostic.textContent;
317
+ updateRun();
318
+ } });
319
+ activateSession();
320
+ }).catch(error => {
321
+ if (disposed)
322
+ return;
323
+ resize?.disconnect();
324
+ resize = null;
325
+ releaseRun?.();
326
+ releaseRun = null;
327
+ releaseObservation?.();
328
+ releaseObservation = null;
329
+ session?.dispose();
330
+ session = null;
331
+ setStatus(t('loadFailed', 'Blockly could not be loaded.'));
332
+ diagnostic.textContent = error instanceof Error ? error.message : String(error);
333
+ details.hidden = false;
334
+ throw error;
335
+ });
336
+ return { ready, deactivate() { activeRequested = false; session?.deactivate(); resize?.disconnect(); resize = null; releaseRun?.(); releaseRun = null; releaseObservation?.(); releaseObservation = null; run.disabled = true; }, activate() { if (disposed)
337
+ throw new Error('surface-disposed'); activeRequested = true; activateSession(); }, dispose() {
338
+ if (disposed)
339
+ return;
340
+ disposed = true;
341
+ resize?.disconnect();
342
+ releaseRun?.();
343
+ releaseObservation?.();
344
+ session?.dispose();
345
+ session = null;
346
+ run.removeEventListener('click', click);
347
+ stop.removeEventListener('click', stopClick);
348
+ progress.removeEventListener('pointerdown', progressPointerDown);
349
+ progress.removeEventListener('pointermove', progressPointerMove);
350
+ progress.removeEventListener('pointerup', progressPointerUp);
351
+ progress.removeEventListener('pointercancel', progressPointerUp);
352
+ progress.removeEventListener('keydown', progressKeyDown);
353
+ root.remove();
354
+ } };
355
+ }
@@ -0,0 +1,9 @@
1
+ import type * as Blockly from "blockly/core";
2
+ /** Camera presentation only. Never reads blocks or publishes semantic edits.
3
+ * One world rectangle survives a chain of layout sizes; a user camera change
4
+ * establishes a new rectangle on the next resize. Flyout is not part of it. */
5
+ export declare function createBlocklyViewportResizeController(workspace: Blockly.WorkspaceSvg, resizeSvg: () => void): {
6
+ reset(): void;
7
+ cameraChanged(): void;
8
+ resize(): void;
9
+ };
@@ -0,0 +1,118 @@
1
+ /** Camera presentation only. Never reads blocks or publishes semantic edits.
2
+ * One world rectangle survives a chain of layout sizes; a user camera change
3
+ * establishes a new rectangle on the next resize. Flyout is not part of it. */
4
+ export function createBlocklyViewportResizeController(workspace, resizeSvg) {
5
+ let size = null;
6
+ let anchor = null;
7
+ let applied = null;
8
+ let adjusting = false;
9
+ const minimum = workspace.options.zoomOptions.minScale;
10
+ const valid = (view) => Number.isFinite(view.width) &&
11
+ Number.isFinite(view.height) &&
12
+ view.width > 0 &&
13
+ view.height > 0;
14
+ return {
15
+ reset() {
16
+ anchor = null;
17
+ applied = null;
18
+ },
19
+ cameraChanged() {
20
+ if (adjusting)
21
+ return;
22
+ const current = {
23
+ x: workspace.scrollX,
24
+ y: workspace.scrollY,
25
+ zoom: workspace.scale,
26
+ };
27
+ if (applied &&
28
+ Math.abs(applied.x - current.x) < 0.001 &&
29
+ Math.abs(applied.y - current.y) < 0.001 &&
30
+ Math.abs(applied.zoom - current.zoom) < 0.000001)
31
+ return;
32
+ const view = workspace.getMetricsManager().getViewMetrics();
33
+ if (!valid(view))
34
+ return;
35
+ // User zoom also changes flyout width. Record its settled usable area
36
+ // before the next panel resize, rather than reusing the old flyout width.
37
+ size = { width: view.width, height: view.height };
38
+ anchor = null;
39
+ applied = current;
40
+ },
41
+ resize() {
42
+ adjusting = true;
43
+ try {
44
+ const before = {
45
+ x: workspace.scrollX,
46
+ y: workspace.scrollY,
47
+ zoom: workspace.scale,
48
+ };
49
+ if (size &&
50
+ valid(size) &&
51
+ before.zoom > 0 &&
52
+ (!anchor ||
53
+ !applied ||
54
+ Math.abs(applied.x - before.x) > 0.001 ||
55
+ Math.abs(applied.y - before.y) > 0.001 ||
56
+ Math.abs(applied.zoom - before.zoom) > 0.000001)) {
57
+ anchor = {
58
+ width: size.width / before.zoom,
59
+ height: size.height / before.zoom,
60
+ centerX: (size.width / 2 - before.x) / before.zoom,
61
+ centerY: (size.height / 2 - before.y) / before.zoom,
62
+ };
63
+ }
64
+ resizeSvg();
65
+ let next = workspace.getMetricsManager().getViewMetrics();
66
+ if (!valid(next))
67
+ return; // Hidden/unmounted panels do not destroy the reference.
68
+ if (size &&
69
+ anchor &&
70
+ (size.width !== next.width || size.height !== next.height)) {
71
+ // setScale can reflow the flyout. Refine against the usable canvas, with
72
+ // a bounded calculation, never a polling/frame loop.
73
+ const maximum = workspace.options.zoomOptions.maxScale || Infinity;
74
+ let zoom = Math.min(next.width / anchor.width, next.height / anchor.height, maximum);
75
+ let previous = null;
76
+ for (let iteration = 0; iteration < 6; iteration++) {
77
+ workspace.options.zoomOptions.minScale = Math.min(minimum || zoom, zoom);
78
+ workspace.setScale(zoom);
79
+ const measured = workspace.getMetricsManager().getViewMetrics();
80
+ next = measured;
81
+ const required = Math.min(next.width / anchor.width, next.height / anchor.height, maximum);
82
+ const residual = required - workspace.scale;
83
+ if (Math.abs(residual) < 1e-9)
84
+ break;
85
+ let candidate = required;
86
+ // The flyout usually varies affinely with scale: secant refinement
87
+ // avoids many repeated reflows. Six is a hard bound, not a frame loop.
88
+ if (previous && Math.abs(residual - previous.residual) > 1e-12) {
89
+ const secant = workspace.scale -
90
+ (residual * (workspace.scale - previous.zoom)) /
91
+ (residual - previous.residual);
92
+ if (Number.isFinite(secant) && secant > 0 && secant <= maximum)
93
+ candidate = secant;
94
+ }
95
+ previous = { zoom: workspace.scale, residual };
96
+ zoom = candidate;
97
+ }
98
+ const safeZoom = Math.min(workspace.scale, next.width / anchor.width, next.height / anchor.height);
99
+ if (safeZoom < workspace.scale) {
100
+ workspace.options.zoomOptions.minScale = Math.min(minimum || safeZoom, safeZoom);
101
+ workspace.setScale(safeZoom);
102
+ next = workspace.getMetricsManager().getViewMetrics();
103
+ }
104
+ workspace.scroll(next.width / 2 - anchor.centerX * workspace.scale, next.height / 2 - anchor.centerY * workspace.scale);
105
+ }
106
+ size = { width: next.width, height: next.height };
107
+ applied = {
108
+ x: workspace.scrollX,
109
+ y: workspace.scrollY,
110
+ zoom: workspace.scale,
111
+ };
112
+ }
113
+ finally {
114
+ adjusting = false;
115
+ }
116
+ },
117
+ };
118
+ }
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "@konitif/nodal-blockly",
3
+ "version": "0.1.0",
4
+ "private": false,
5
+ "type": "module",
6
+ "license": "PolyForm-Noncommercial-1.0.0",
7
+ "description": "Hosted Blockly editing projection of canonical KONITIF Workflow Compositions.",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/LeMouf/konitif-nodal-blockly.git"
11
+ },
12
+ "publishConfig": {
13
+ "access": "public",
14
+ "registry": "https://registry.npmjs.org/"
15
+ },
16
+ "main": "./dist/index.js",
17
+ "types": "./dist/index.d.ts",
18
+ "exports": {
19
+ ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" },
20
+ "./surface": { "types": "./dist/surface.d.ts", "import": "./dist/surface.js" }
21
+ },
22
+ "files": ["LICENSE.md", "dist", "README.md", "package.json"],
23
+ "scripts": {
24
+ "build": "tsc -p tsconfig.json",
25
+ "test": "node --test tests/*.test.mjs",
26
+ "verify:package": "node scripts/verify-package.mjs",
27
+ "prepack": "npm run build && npm test",
28
+ "prepublishOnly": "npm run build && npm test && npm run verify:package"
29
+ },
30
+ "engines": { "node": ">=22" },
31
+ "dependencies": {
32
+ "@konitif/composition": "0.284.3",
33
+ "@konitif/nodal": "0.285.1",
34
+ "@konitif/tools": "0.284.4",
35
+ "blockly": "12.1.0"
36
+ },
37
+ "devDependencies": { "typescript": "5.9.3" }
38
+ }