jssm 5.151.2 → 5.152.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/README.md +7 -7
- package/custom-elements.json +510 -40
- package/dist/cdn/instance.js +292 -24
- package/dist/cdn/viz.js +1 -1
- package/dist/cli/fsl-export-system-prompt.cjs +1 -1
- package/dist/cli/fsl-render.cjs +1 -1
- package/dist/cli/fsl.cjs +1 -1
- package/dist/cli/lib.cjs +1 -1
- package/dist/cli/lib.mjs +1 -1
- package/dist/deno/README.md +7 -7
- package/dist/deno/jssm.js +1 -1
- package/dist/jssm.es5.cjs +1 -1
- package/dist/jssm.es5.iife.js +1 -1
- package/dist/jssm.es6.mjs +1 -1
- package/dist/jssm_viz.cjs +1 -1
- package/dist/jssm_viz.iife.cjs +1 -1
- package/dist/jssm_viz.mjs +1 -1
- package/dist/wc/instance.js +291 -23
- package/dist/wc/widgets.js +111 -43
- package/package.json +1 -1
package/dist/wc/instance.js
CHANGED
|
@@ -461,6 +461,242 @@ function build_hook_descriptor(spec, wrapped) {
|
|
|
461
461
|
return base;
|
|
462
462
|
}
|
|
463
463
|
|
|
464
|
+
/**
|
|
465
|
+
* Compressed, URL-safe permalink wire format for FSL machines, shared by the
|
|
466
|
+
* toolbar's Export→Permalink and the per-instance URL sync controller.
|
|
467
|
+
*
|
|
468
|
+
* A machine is encoded to a `<scheme><payload>` segment: `<payload>` is URL-safe
|
|
469
|
+
* base64, `<scheme>` is `1` when DEFLATE shrank the source and `0` for the raw
|
|
470
|
+
* bytes when it did not (so a short machine's link never grows). Segments live in
|
|
471
|
+
* a URL fragment as `#<key>=<segment>` joined by `&`, so several machines can
|
|
472
|
+
* share one URL.
|
|
473
|
+
*/
|
|
474
|
+
/**
|
|
475
|
+
* Default fragment key for the single-machine case (back-compat with 5.150).
|
|
476
|
+
*
|
|
477
|
+
* @example
|
|
478
|
+
* DEFAULT_PERMALINK_KEY; // 'm'
|
|
479
|
+
*/
|
|
480
|
+
/**
|
|
481
|
+
* URL-safe base64 (RFC 4648 §5) of raw bytes: standard base64 with `+`→`-`,
|
|
482
|
+
* `/`→`_`, and trailing `=` padding stripped.
|
|
483
|
+
*
|
|
484
|
+
* @example
|
|
485
|
+
* bytes_to_base64url(new TextEncoder().encode("a")); // "YQ"
|
|
486
|
+
*/
|
|
487
|
+
function bytes_to_base64url(bytes) {
|
|
488
|
+
let binary = '';
|
|
489
|
+
for (const byte of bytes) {
|
|
490
|
+
binary += String.fromCharCode(byte);
|
|
491
|
+
}
|
|
492
|
+
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
493
|
+
}
|
|
494
|
+
/**
|
|
495
|
+
* Inverse of {@link bytes_to_base64url}.
|
|
496
|
+
*
|
|
497
|
+
* @example
|
|
498
|
+
* new TextDecoder().decode(base64url_to_bytes("YQ")); // "a"
|
|
499
|
+
*/
|
|
500
|
+
function base64url_to_bytes(text) {
|
|
501
|
+
const standard = text.replace(/-/g, '+').replace(/_/g, '/');
|
|
502
|
+
const padded = standard + '='.repeat((4 - (standard.length % 4)) % 4); // atob wants 4-aligned input
|
|
503
|
+
const binary = atob(padded);
|
|
504
|
+
const bytes = new Uint8Array(binary.length);
|
|
505
|
+
for (let i = 0; i < binary.length; i++) {
|
|
506
|
+
bytes[i] = binary.charCodeAt(i);
|
|
507
|
+
}
|
|
508
|
+
return bytes;
|
|
509
|
+
}
|
|
510
|
+
/**
|
|
511
|
+
* DEFLATE `bytes` (raw, headerless) via the platform `CompressionStream`.
|
|
512
|
+
*
|
|
513
|
+
* @example
|
|
514
|
+
* await deflate_raw(new TextEncoder().encode("aaaaaaaa")); // shorter Uint8Array of raw DEFLATE bytes
|
|
515
|
+
*/
|
|
516
|
+
async function deflate_raw(bytes) {
|
|
517
|
+
const stream = new CompressionStream('deflate-raw');
|
|
518
|
+
const writer = stream.writable.getWriter();
|
|
519
|
+
void writer.write(bytes);
|
|
520
|
+
void writer.close();
|
|
521
|
+
return new Uint8Array(await new Response(stream.readable).arrayBuffer());
|
|
522
|
+
}
|
|
523
|
+
/**
|
|
524
|
+
* Inverse of {@link deflate_raw}.
|
|
525
|
+
*
|
|
526
|
+
* @example
|
|
527
|
+
* new TextDecoder().decode(await inflate_raw(await deflate_raw(new TextEncoder().encode("hi")))); // "hi"
|
|
528
|
+
*/
|
|
529
|
+
async function inflate_raw(bytes) {
|
|
530
|
+
const stream = new DecompressionStream('deflate-raw');
|
|
531
|
+
const writer = stream.writable.getWriter();
|
|
532
|
+
void writer.write(bytes);
|
|
533
|
+
void writer.close();
|
|
534
|
+
return new Uint8Array(await new Response(stream.readable).arrayBuffer());
|
|
535
|
+
}
|
|
536
|
+
/**
|
|
537
|
+
* Encode FSL to a `<scheme><payload>` segment value (the part after `key=`).
|
|
538
|
+
* DEFLATE is used (scheme `1`) only when it is strictly shorter than the raw
|
|
539
|
+
* bytes (scheme `0`).
|
|
540
|
+
*
|
|
541
|
+
* @example
|
|
542
|
+
* await encode_machine("a -> b;"); // "0YSAtPiBiOw"
|
|
543
|
+
*/
|
|
544
|
+
async function encode_machine(fsl) {
|
|
545
|
+
const utf8 = new TextEncoder().encode(fsl);
|
|
546
|
+
const raw = bytes_to_base64url(utf8);
|
|
547
|
+
const deflated = bytes_to_base64url(await deflate_raw(utf8));
|
|
548
|
+
return deflated.length < raw.length ? `1${deflated}` : `0${raw}`;
|
|
549
|
+
}
|
|
550
|
+
/**
|
|
551
|
+
* Inverse of {@link encode_machine}: decode a `<scheme><payload>` segment back
|
|
552
|
+
* to FSL. Async because inflate is async.
|
|
553
|
+
*
|
|
554
|
+
* @example
|
|
555
|
+
* await decode_machine("0YSAtPiBiOw"); // "a -> b;"
|
|
556
|
+
*/
|
|
557
|
+
async function decode_machine(segment) {
|
|
558
|
+
const scheme = segment[0];
|
|
559
|
+
const bytes = base64url_to_bytes(segment.slice(1));
|
|
560
|
+
const plain = scheme === '1' ? await inflate_raw(bytes) : bytes;
|
|
561
|
+
return new TextDecoder().decode(plain);
|
|
562
|
+
}
|
|
563
|
+
/** Split a fragment (leading `#` optional) into `[key, value]` pairs, dropping empties. */
|
|
564
|
+
function fragment_pairs(hash) {
|
|
565
|
+
const body = hash.startsWith('#') ? hash.slice(1) : hash;
|
|
566
|
+
return body.split('&').filter(Boolean).map(seg => {
|
|
567
|
+
const eq = seg.indexOf('=');
|
|
568
|
+
return eq === -1
|
|
569
|
+
? [seg, '']
|
|
570
|
+
: [seg.slice(0, eq), seg.slice(eq + 1)];
|
|
571
|
+
});
|
|
572
|
+
}
|
|
573
|
+
/**
|
|
574
|
+
* Read one segment's value out of a `#a=…&b=…` fragment.
|
|
575
|
+
*
|
|
576
|
+
* @returns The value, or `null` if `key` is absent.
|
|
577
|
+
*
|
|
578
|
+
* @example
|
|
579
|
+
* read_fragment_param('#a=0AAA&b=1BBB', 'b'); // "1BBB"
|
|
580
|
+
*/
|
|
581
|
+
function read_fragment_param(hash, key) {
|
|
582
|
+
const found = fragment_pairs(hash).find(([k]) => k === key);
|
|
583
|
+
return found ? found[1] : null;
|
|
584
|
+
}
|
|
585
|
+
/**
|
|
586
|
+
* Return a new fragment body (no leading `#`) with `key`'s segment set to
|
|
587
|
+
* `value`, preserving every other segment and its order; appends if absent.
|
|
588
|
+
*
|
|
589
|
+
* @example
|
|
590
|
+
* set_fragment_param('#a=0AAA', 'b', '1BBB'); // "a=0AAA&b=1BBB"
|
|
591
|
+
*/
|
|
592
|
+
function set_fragment_param(hash, key, value) {
|
|
593
|
+
const pairs = fragment_pairs(hash);
|
|
594
|
+
const at = pairs.findIndex(([k]) => k === key);
|
|
595
|
+
if (at === -1) {
|
|
596
|
+
pairs.push([key, value]);
|
|
597
|
+
}
|
|
598
|
+
else {
|
|
599
|
+
pairs[at] = [key, value];
|
|
600
|
+
}
|
|
601
|
+
return pairs.map(([k, v]) => `${k}=${v}`).join('&');
|
|
602
|
+
}
|
|
603
|
+
/**
|
|
604
|
+
* The fragment key an element owns: its `uhash` attribute if set, else its
|
|
605
|
+
* `id`, else `null` (does not participate in URL sync). The single source of
|
|
606
|
+
* this rule, shared by the toolbar export and the sync controller.
|
|
607
|
+
*
|
|
608
|
+
* @example
|
|
609
|
+
* permalink_key_for(el); // "myId" (when <el id="myId">, no uhash)
|
|
610
|
+
*/
|
|
611
|
+
function permalink_key_for(host) {
|
|
612
|
+
var _a, _b;
|
|
613
|
+
return (_b = (_a = host.getAttribute('uhash')) !== null && _a !== void 0 ? _a : host.getAttribute('id')) !== null && _b !== void 0 ? _b : null;
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
/** Debounce before a live edit is written to the URL fragment. */
|
|
617
|
+
const PERMALINK_WRITE_DEBOUNCE_MS = 300;
|
|
618
|
+
/**
|
|
619
|
+
* Binds an `<fsl-instance>` to a segment of the URL fragment: restores from it
|
|
620
|
+
* on connect and writes back (debounced, via `history.replaceState`) whenever
|
|
621
|
+
* the machine is rebuilt. Inert when the host has no key ({@link permalink_key_for}
|
|
622
|
+
* returns `null`), so an `fsl-instance` without an `id`/`uhash` never touches
|
|
623
|
+
* `location`.
|
|
624
|
+
*
|
|
625
|
+
* Echo guard: `_last` holds the segment most recently read or written, so a
|
|
626
|
+
* restore→rebuild→write cycle and a self-induced `hashchange` are both no-ops.
|
|
627
|
+
*
|
|
628
|
+
* @example
|
|
629
|
+
* // In an element's constructor:
|
|
630
|
+
* new FslPermalinkSync(this); // reads <el id="k">'s #k=… on connect, writes it on edit
|
|
631
|
+
*/
|
|
632
|
+
class FslPermalinkSync {
|
|
633
|
+
constructor(host) {
|
|
634
|
+
this.key = null;
|
|
635
|
+
this._last = null;
|
|
636
|
+
this._onRebuilt = () => { this._scheduleWrite(); };
|
|
637
|
+
this._onHashChange = () => { void this._restore(); };
|
|
638
|
+
this.host = host;
|
|
639
|
+
host.addController(this);
|
|
640
|
+
}
|
|
641
|
+
hostConnected() {
|
|
642
|
+
this.key = permalink_key_for(this.host);
|
|
643
|
+
if (this.key === null) {
|
|
644
|
+
return;
|
|
645
|
+
}
|
|
646
|
+
void this._restore();
|
|
647
|
+
this.host.addEventListener('fsl-machine-rebuilt', this._onRebuilt);
|
|
648
|
+
window.addEventListener('hashchange', this._onHashChange);
|
|
649
|
+
}
|
|
650
|
+
hostDisconnected() {
|
|
651
|
+
if (this.key === null) {
|
|
652
|
+
return;
|
|
653
|
+
}
|
|
654
|
+
this.host.removeEventListener('fsl-machine-rebuilt', this._onRebuilt);
|
|
655
|
+
window.removeEventListener('hashchange', this._onHashChange);
|
|
656
|
+
if (this._timer !== undefined) {
|
|
657
|
+
clearTimeout(this._timer);
|
|
658
|
+
this._timer = undefined;
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
/** Read our segment and, if new, push it into the host (overriding declared source). */
|
|
662
|
+
async _restore() {
|
|
663
|
+
const segment = read_fragment_param(location.hash, this.key);
|
|
664
|
+
if (segment === null || segment === this._last) {
|
|
665
|
+
return;
|
|
666
|
+
}
|
|
667
|
+
try {
|
|
668
|
+
const fsl = await decode_machine(segment);
|
|
669
|
+
this._last = segment;
|
|
670
|
+
this.host.fsl = fsl;
|
|
671
|
+
}
|
|
672
|
+
catch (_a) {
|
|
673
|
+
// Malformed/truncated segment, or no compression support: leave the
|
|
674
|
+
// declared source intact. A bad URL never bricks the page.
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
_scheduleWrite() {
|
|
678
|
+
if (this._timer !== undefined) {
|
|
679
|
+
clearTimeout(this._timer);
|
|
680
|
+
}
|
|
681
|
+
this._timer = setTimeout(() => { void this._write(); }, PERMALINK_WRITE_DEBOUNCE_MS);
|
|
682
|
+
}
|
|
683
|
+
/** Encode the current source and merge it into the fragment, history-silently. */
|
|
684
|
+
async _write() {
|
|
685
|
+
try {
|
|
686
|
+
const segment = await encode_machine(this.host.fsl);
|
|
687
|
+
if (segment === this._last) {
|
|
688
|
+
return;
|
|
689
|
+
}
|
|
690
|
+
this._last = segment;
|
|
691
|
+
const fragment = set_fragment_param(location.hash, this.key, segment);
|
|
692
|
+
history.replaceState(history.state, '', `#${fragment}`);
|
|
693
|
+
}
|
|
694
|
+
catch (_a) {
|
|
695
|
+
// No compression support: skip the write rather than throw.
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
|
|
464
700
|
/**
|
|
465
701
|
* Theme registry for the fsl-* suite. A **theme** is a named pair of light/dark
|
|
466
702
|
* palettes; a **mode** (`system` | `light` | `dark`) picks which variant applies,
|
|
@@ -858,8 +1094,10 @@ function split_ratio(coord, start, size) {
|
|
|
858
1094
|
* @slot footer - Footer slot.
|
|
859
1095
|
*/
|
|
860
1096
|
class FslInstance extends LitElement {
|
|
1097
|
+
/** Bind this instance to a URL-fragment segment keyed by its `uhash`/`id`
|
|
1098
|
+
* (inert if it has neither): restore on connect, write debounced on edit. */
|
|
861
1099
|
constructor() {
|
|
862
|
-
super(
|
|
1100
|
+
super();
|
|
863
1101
|
/**
|
|
864
1102
|
* FSL source attribute. When non-empty, this is the sole channel
|
|
865
1103
|
* supplying the machine's source. Setting both this and a child
|
|
@@ -1011,6 +1249,7 @@ class FslInstance extends LitElement {
|
|
|
1011
1249
|
this._split = 50;
|
|
1012
1250
|
this.requestUpdate();
|
|
1013
1251
|
};
|
|
1252
|
+
new FslPermalinkSync(this);
|
|
1014
1253
|
}
|
|
1015
1254
|
/**
|
|
1016
1255
|
* Raw machine accessor. Returns the owned {@link Machine} instance.
|
|
@@ -1214,30 +1453,43 @@ class FslInstance extends LitElement {
|
|
|
1214
1453
|
super.connectedCallback();
|
|
1215
1454
|
// Step 1: resolve FSL source.
|
|
1216
1455
|
const resolved = resolve_fsl_source(this, this.fsl);
|
|
1217
|
-
|
|
1456
|
+
// A permalink-only instance has no declared source, but its own URL segment
|
|
1457
|
+
// will supply one asynchronously: FslPermalinkSync (attached in the
|
|
1458
|
+
// constructor) restores `fsl` just after connect, which rebuilds the machine
|
|
1459
|
+
// via willUpdate -> _rebuild_machine. Defer to that instead of throwing;
|
|
1460
|
+
// render() shows the placeholder until the restore lands.
|
|
1461
|
+
const deferToPermalink = resolved.provided_count === 0 && this._permalinkSegmentPresent();
|
|
1462
|
+
if (resolved.error !== undefined && !deferToPermalink) {
|
|
1218
1463
|
throw new Error(`fsl-instance: ${resolved.error}`);
|
|
1219
1464
|
}
|
|
1220
|
-
//
|
|
1221
|
-
//
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1465
|
+
// Steps 2-4 + machine-scoped wiring: only when a source is available now. In
|
|
1466
|
+
// the deferred case these run later, from _rebuild_machine, once the restore
|
|
1467
|
+
// sets `fsl`.
|
|
1468
|
+
if (!deferToPermalink) {
|
|
1469
|
+
// Step 2: construct the machine.
|
|
1470
|
+
// (The resolver guarantees `fsl` is a non-empty string when error is undefined.)
|
|
1471
|
+
const fsl_source = resolved.fsl;
|
|
1472
|
+
this._machine = this._build_machine(fsl_source);
|
|
1473
|
+
this._applyEditorConfig();
|
|
1474
|
+
// Step 3: paint initial host attributes + CSS custom properties.
|
|
1475
|
+
this._paint_state_reflection();
|
|
1476
|
+
// Step 4: shadow DOM render is automatic via Lit; requesting an update
|
|
1477
|
+
// here ensures the first paint sees the freshly painted attributes.
|
|
1478
|
+
this.requestUpdate();
|
|
1479
|
+
// #639 mechanism 4: subscribe to library events and re-emit them as
|
|
1480
|
+
// DOM CustomEvents from this host (#638 supplies the event API).
|
|
1481
|
+
this._install_event_reemission();
|
|
1482
|
+
// #641: <jssm-hook> declarative discovery.
|
|
1483
|
+
this._install_declarative_hooks();
|
|
1484
|
+
// #643: <jssm-on> declarative event observation.
|
|
1485
|
+
this._install_jssm_on_children();
|
|
1486
|
+
// #645: discover <jssm-bind> tags and `data-jssm-bind` descendants,
|
|
1487
|
+
// install live machine-to-DOM projections.
|
|
1488
|
+
this._unsubs.push(...install_bindings(this, this._machine));
|
|
1489
|
+
}
|
|
1490
|
+
// #640: <jssm-action> DOM event -> machine action wiring. The listeners read
|
|
1491
|
+
// `this.machine` live on event, so discovery is correct even before a
|
|
1492
|
+
// deferred build completes.
|
|
1241
1493
|
this._discover_jssm_actions();
|
|
1242
1494
|
// Theme: register the palette tokens as animatable colors (once, globally, so
|
|
1243
1495
|
// switches can ease), follow the OS while in `system` mode, then apply the
|
|
@@ -1249,6 +1501,16 @@ class FslInstance extends LitElement {
|
|
|
1249
1501
|
}
|
|
1250
1502
|
this._applyTheme();
|
|
1251
1503
|
}
|
|
1504
|
+
/**
|
|
1505
|
+
* True when this instance owns a URL permalink segment (keyed by its
|
|
1506
|
+
* `uhash`/`id`) that a pending {@link FslPermalinkSync} restore will turn into
|
|
1507
|
+
* its FSL source — so `connectedCallback` can defer the machine build to that
|
|
1508
|
+
* restore instead of throwing on an otherwise-absent source.
|
|
1509
|
+
*/
|
|
1510
|
+
_permalinkSegmentPresent() {
|
|
1511
|
+
const key = permalink_key_for(this);
|
|
1512
|
+
return key !== null && read_fragment_param(location.hash, key) !== null;
|
|
1513
|
+
}
|
|
1252
1514
|
/**
|
|
1253
1515
|
* Discover direct-child `<jssm-on>` elements and install their
|
|
1254
1516
|
* subscriptions on the owned machine. Per #643:
|
|
@@ -1575,6 +1837,12 @@ class FslInstance extends LitElement {
|
|
|
1575
1837
|
*/
|
|
1576
1838
|
_install_action_listener(config) {
|
|
1577
1839
|
const handler = (e) => {
|
|
1840
|
+
// A permalink-only instance wires its actions at connect but builds its
|
|
1841
|
+
// machine asynchronously (deferred restore). An event in that window is a
|
|
1842
|
+
// no-op rather than a throw via the `machine` getter.
|
|
1843
|
+
if (this._machine === undefined) {
|
|
1844
|
+
return;
|
|
1845
|
+
}
|
|
1578
1846
|
if (config.prevent_default) {
|
|
1579
1847
|
e.preventDefault();
|
|
1580
1848
|
}
|
package/dist/wc/widgets.js
CHANGED
|
@@ -77,33 +77,26 @@ function closest_wc(el, suffix) {
|
|
|
77
77
|
return el.closest(`fsl-${suffix}, jssm-${suffix}`);
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
{ value: 'svg', label: 'SVG' },
|
|
98
|
-
{ value: 'permalink', label: 'Permalink (URL)' },
|
|
99
|
-
{ value: 'embed', label: 'Embed snippet' },
|
|
100
|
-
];
|
|
101
|
-
/** Hash parameter that carries a permalink's encoded machine: `#m=<scheme><payload>`. */
|
|
102
|
-
const PERMALINK_HASH_KEY = 'm';
|
|
80
|
+
/**
|
|
81
|
+
* Compressed, URL-safe permalink wire format for FSL machines, shared by the
|
|
82
|
+
* toolbar's Export→Permalink and the per-instance URL sync controller.
|
|
83
|
+
*
|
|
84
|
+
* A machine is encoded to a `<scheme><payload>` segment: `<payload>` is URL-safe
|
|
85
|
+
* base64, `<scheme>` is `1` when DEFLATE shrank the source and `0` for the raw
|
|
86
|
+
* bytes when it did not (so a short machine's link never grows). Segments live in
|
|
87
|
+
* a URL fragment as `#<key>=<segment>` joined by `&`, so several machines can
|
|
88
|
+
* share one URL.
|
|
89
|
+
*/
|
|
90
|
+
/**
|
|
91
|
+
* Default fragment key for the single-machine case (back-compat with 5.150).
|
|
92
|
+
*
|
|
93
|
+
* @example
|
|
94
|
+
* DEFAULT_PERMALINK_KEY; // 'm'
|
|
95
|
+
*/
|
|
96
|
+
const DEFAULT_PERMALINK_KEY = 'm';
|
|
103
97
|
/**
|
|
104
98
|
* URL-safe base64 (RFC 4648 §5) of raw bytes: standard base64 with `+`→`-`,
|
|
105
|
-
* `/`→`_`, and trailing `=` padding stripped
|
|
106
|
-
* fragment with no further percent-encoding.
|
|
99
|
+
* `/`→`_`, and trailing `=` padding stripped.
|
|
107
100
|
*
|
|
108
101
|
* @example
|
|
109
102
|
* bytes_to_base64url(new TextEncoder().encode("a")); // "YQ"
|
|
@@ -115,7 +108,12 @@ function bytes_to_base64url(bytes) {
|
|
|
115
108
|
}
|
|
116
109
|
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
117
110
|
}
|
|
118
|
-
/**
|
|
111
|
+
/**
|
|
112
|
+
* DEFLATE `bytes` (raw, headerless) via the platform `CompressionStream`.
|
|
113
|
+
*
|
|
114
|
+
* @example
|
|
115
|
+
* await deflate_raw(new TextEncoder().encode("aaaaaaaa")); // shorter Uint8Array of raw DEFLATE bytes
|
|
116
|
+
*/
|
|
119
117
|
async function deflate_raw(bytes) {
|
|
120
118
|
const stream = new CompressionStream('deflate-raw');
|
|
121
119
|
const writer = stream.writable.getWriter();
|
|
@@ -124,29 +122,98 @@ async function deflate_raw(bytes) {
|
|
|
124
122
|
return new Uint8Array(await new Response(stream.readable).arrayBuffer());
|
|
125
123
|
}
|
|
126
124
|
/**
|
|
127
|
-
*
|
|
128
|
-
*
|
|
129
|
-
*
|
|
130
|
-
* — `1` when DEFLATE made the source smaller, `0` for the raw bytes when it did
|
|
131
|
-
* not — so a short machine's link is never longer than its uncompressed form.
|
|
132
|
-
* Decode with {@link fsl_from_permalink}. Browser-only (uses `location` and the
|
|
133
|
-
* platform compression streams), like the rest of the toolbar.
|
|
134
|
-
*
|
|
135
|
-
* @returns The absolute page URL carrying the encoded machine in its fragment.
|
|
125
|
+
* Encode FSL to a `<scheme><payload>` segment value (the part after `key=`).
|
|
126
|
+
* DEFLATE is used (scheme `1`) only when it is strictly shorter than the raw
|
|
127
|
+
* bytes (scheme `0`).
|
|
136
128
|
*
|
|
137
129
|
* @example
|
|
138
|
-
* await
|
|
139
|
-
* await permalink_for("Off -> On -> Off; On -> Idle -> Off;"); // "https://host/path#m=1809LU9C1U_DPA5NpadZQpmdKTipMCAA"
|
|
140
|
-
*
|
|
141
|
-
* @see fsl_from_permalink
|
|
130
|
+
* await encode_machine("a -> b;"); // "0YSAtPiBiOw"
|
|
142
131
|
*/
|
|
143
|
-
async function
|
|
132
|
+
async function encode_machine(fsl) {
|
|
144
133
|
const utf8 = new TextEncoder().encode(fsl);
|
|
145
134
|
const raw = bytes_to_base64url(utf8);
|
|
146
135
|
const deflated = bytes_to_base64url(await deflate_raw(utf8));
|
|
147
|
-
|
|
148
|
-
|
|
136
|
+
return deflated.length < raw.length ? `1${deflated}` : `0${raw}`;
|
|
137
|
+
}
|
|
138
|
+
/** Split a fragment (leading `#` optional) into `[key, value]` pairs, dropping empties. */
|
|
139
|
+
function fragment_pairs(hash) {
|
|
140
|
+
const body = hash.startsWith('#') ? hash.slice(1) : hash;
|
|
141
|
+
return body.split('&').filter(Boolean).map(seg => {
|
|
142
|
+
const eq = seg.indexOf('=');
|
|
143
|
+
return eq === -1
|
|
144
|
+
? [seg, '']
|
|
145
|
+
: [seg.slice(0, eq), seg.slice(eq + 1)];
|
|
146
|
+
});
|
|
149
147
|
}
|
|
148
|
+
/**
|
|
149
|
+
* Return a new fragment body (no leading `#`) with `key`'s segment set to
|
|
150
|
+
* `value`, preserving every other segment and its order; appends if absent.
|
|
151
|
+
*
|
|
152
|
+
* @example
|
|
153
|
+
* set_fragment_param('#a=0AAA', 'b', '1BBB'); // "a=0AAA&b=1BBB"
|
|
154
|
+
*/
|
|
155
|
+
function set_fragment_param(hash, key, value) {
|
|
156
|
+
const pairs = fragment_pairs(hash);
|
|
157
|
+
const at = pairs.findIndex(([k]) => k === key);
|
|
158
|
+
if (at === -1) {
|
|
159
|
+
pairs.push([key, value]);
|
|
160
|
+
}
|
|
161
|
+
else {
|
|
162
|
+
pairs[at] = [key, value];
|
|
163
|
+
}
|
|
164
|
+
return pairs.map(([k, v]) => `${k}=${v}`).join('&');
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* The fragment key an element owns: its `uhash` attribute if set, else its
|
|
168
|
+
* `id`, else `null` (does not participate in URL sync). The single source of
|
|
169
|
+
* this rule, shared by the toolbar export and the sync controller.
|
|
170
|
+
*
|
|
171
|
+
* @example
|
|
172
|
+
* permalink_key_for(el); // "myId" (when <el id="myId">, no uhash)
|
|
173
|
+
*/
|
|
174
|
+
function permalink_key_for(host) {
|
|
175
|
+
var _a, _b;
|
|
176
|
+
return (_b = (_a = host.getAttribute('uhash')) !== null && _a !== void 0 ? _a : host.getAttribute('id')) !== null && _b !== void 0 ? _b : null;
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* A shareable URL for `fsl` under `key`, merging into `currentHash` so sibling
|
|
180
|
+
* machines' segments survive. Browser-defaulted (`location`) but injectable for
|
|
181
|
+
* tests.
|
|
182
|
+
*
|
|
183
|
+
* @returns The absolute URL carrying the merged fragment.
|
|
184
|
+
*
|
|
185
|
+
* @example
|
|
186
|
+
* await permalink_for('a -> b;', 'm', 'https://h/p', ''); // "https://h/p#m=0YSAtPiBiOw"
|
|
187
|
+
*
|
|
188
|
+
* @see fsl_from_permalink
|
|
189
|
+
*/
|
|
190
|
+
async function permalink_for(fsl, key = DEFAULT_PERMALINK_KEY, href = location.href, currentHash = location.hash) {
|
|
191
|
+
const segment = await encode_machine(fsl);
|
|
192
|
+
const fragment = set_fragment_param(currentHash, key, segment);
|
|
193
|
+
return `${href.split('#')[0]}#${fragment}`;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
var __decorate$7 = (undefined && undefined.__decorate) || function (decorators, target, key, desc) {
|
|
197
|
+
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
198
|
+
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
199
|
+
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
|
200
|
+
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
201
|
+
};
|
|
202
|
+
/** Theme modes offered by the Theme pulldown. */
|
|
203
|
+
const THEME_MODES = [
|
|
204
|
+
{ value: 'system', label: 'System' },
|
|
205
|
+
{ value: 'light', label: 'Light' },
|
|
206
|
+
{ value: 'dark', label: 'Dark' },
|
|
207
|
+
];
|
|
208
|
+
/** Export formats offered by the Export pulldown. */
|
|
209
|
+
const EXPORT_FORMATS = [
|
|
210
|
+
{ value: 'dot', label: 'Graphviz DOT' },
|
|
211
|
+
{ value: 'json', label: 'JSON (serialized)' },
|
|
212
|
+
{ value: 'fsl', label: 'FSL source' },
|
|
213
|
+
{ value: 'svg', label: 'SVG' },
|
|
214
|
+
{ value: 'permalink', label: 'Permalink (URL)' },
|
|
215
|
+
{ value: 'embed', label: 'Embed snippet' },
|
|
216
|
+
];
|
|
150
217
|
/**
|
|
151
218
|
* A paste-able HTML snippet that renders the given FSL from the CDN builds: an
|
|
152
219
|
* `<fsl-instance>` reading its source from a `<script type="text/fsl">` child,
|
|
@@ -283,6 +350,7 @@ class FslToolbar extends LitElement {
|
|
|
283
350
|
/** Emit `fsl-export` with the chosen format's content + the active destination.
|
|
284
351
|
* The embedder performs the actual clipboard / file save. */
|
|
285
352
|
async _export(format) {
|
|
353
|
+
var _a;
|
|
286
354
|
const host = this._host;
|
|
287
355
|
const destination = this._dest;
|
|
288
356
|
this._openMenu = '';
|
|
@@ -300,7 +368,7 @@ class FslToolbar extends LitElement {
|
|
|
300
368
|
content = await machine_to_svg_string(host.machine);
|
|
301
369
|
}
|
|
302
370
|
else if (format === 'permalink') {
|
|
303
|
-
content = await permalink_for(host.fsl);
|
|
371
|
+
content = await permalink_for(host.fsl, (_a = permalink_key_for(host)) !== null && _a !== void 0 ? _a : undefined);
|
|
304
372
|
}
|
|
305
373
|
else if (format === 'embed') {
|
|
306
374
|
content = embed_snippet_for(host.fsl);
|