rapier-embed 1.2.44 → 1.2.46

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/embed.mjs CHANGED
@@ -258,7 +258,7 @@ function pictureBase(value) {
258
258
  return {FEATURES, LIMITS, ASSET_TYPES, refusal, coded, normalizeSettings, snapshotSettings, pickDeviceFile, createFilePicker, assetURL, assetBytes, bytesHash, externalImage, createAssetBroker, pictureBase};
259
259
  })();
260
260
  const BUILDS = Object.freeze({full: 'https://rapier.website/embed/rapier.html', reader: 'https://rapier.website/embed/rapier-reader.html'});
261
- const PACKAGE_VERSION = '1.2.44';
261
+ const PACKAGE_VERSION = '1.2.46';
262
262
  const OPTIONS = ['build', 'src', 'plugins', 'approvePlugins', 'sessionId', 'documentId', 'agent', 'agentName', 'theme', 'save', 'assets', 'openFile', 'load',
263
263
  'onState', 'compare', 'onClose', 'settings', 'style', 'title', 'onError', 'timeout'];
264
264
  const READER_FEATURES = ['find', 'readAloud', 'share'];
@@ -268,14 +268,15 @@ const identifier = value => typeof value === 'string' && value.length > 0 && val
268
268
  const record = value => value !== null && typeof value === 'object' && !Array.isArray(value);
269
269
  const themeValue = value => ['light', 'dark', 'system'].includes(value);
270
270
  const mint = prefix => prefix + '-' + (globalThis.crypto?.randomUUID?.() || Math.random().toString(36).slice(2));
271
- const autoLive = new Map(), slotLive = new Map();
271
+ const slotLive = new Map();
272
272
  const fnv = text => { let h = 0x811c9dc5; for (let i = 0; i < text.length; i++) { h ^= text.charCodeAt(i); h = Math.imul(h, 0x01000193); } return (h >>> 0).toString(36); };
273
- function autoIdentity(options) {
273
+ function autoIdentity(options, location, fieldId) {
274
274
  const load = options.load;
275
275
  if (!options.save || !load || typeof load !== 'object' || typeof load.content !== 'string') return null;
276
276
  const base = fnv(location.origin + location.pathname) + '-' + fnv(String(load.filename ?? '') + '|' + load.content.length + '|' + load.content.slice(0, 2048));
277
+ if (fieldId) return base + '-field-' + fnv(fieldId);
277
278
  let at = 0;
278
- while (autoLive.get(base + '-' + at)?.()) at++;
279
+ while (slotLive.get(JSON.stringify(['page-' + base + '-' + at, 'doc-' + base + '-' + at]))?.()) at++;
279
280
  return base + '-' + at;
280
281
  }
281
282
  function deferred() {
@@ -361,8 +362,9 @@ function mount(target, options = {}) {
361
362
  source.searchParams.set('plugins', directory.href);
362
363
  }
363
364
  if (options.approvePlugins === true) source.searchParams.set('approvePlugins', '1');
364
- const origin = source.origin, auto = options.sessionId === undefined && options.documentId === undefined ? autoIdentity(options) : null;
365
- const askedSession = options.sessionId ?? (auto ? 'page-' + auto : mint('session')), askedDocument = options.documentId ?? (auto ? 'doc-' + auto : mint('document'));
365
+ const origin = source.origin, auto = options.sessionId === undefined && options.documentId === undefined ? autoIdentity(options, host.location) : null;
366
+ const pageSession = options.documentId !== undefined && options.save ? 'page-' + fnv(host.location.origin + host.location.pathname) : null;
367
+ const askedSession = options.sessionId ?? (auto ? 'page-' + auto : pageSession ?? mint('session')), askedDocument = options.documentId ?? (auto ? 'doc-' + auto : mint('document'));
366
368
  if (!identifier(askedSession) || !identifier(askedDocument)) throw coded('invalid_id', TypeError, 'Session and document IDs must be nonempty strings of at most 256 code units');
367
369
  const sessionId = askedSession, documentId = askedDocument, pairKey = JSON.stringify([sessionId, documentId]), openElsewhere = !!slotLive.get(pairKey)?.();
368
370
  const agentName = options.agentName;
@@ -377,7 +379,7 @@ function mount(target, options = {}) {
377
379
  const unread = reader ? (settings?.features || []).filter(name => !READER_FEATURES.includes(name)) : [];
378
380
  if (unread.length) throw Object.assign(new TypeError('The reader has no ' + unread.join(', ') + '; its features are ' + READER_FEATURES.join(', ')), {code: 'settings_feature_unavailable'});
379
381
  const assetAnswers = new Map();
380
- let assetPending = '', assetAbort = new AbortController();
382
+ let assetPending = null, assetAbort = new AbortController();
381
383
  const chooseFile = options.openFile || (({signal}) => pickDeviceFile(document, signal)), openAnswers = new Map();
382
384
  let openPending = null;
383
385
  const capabilities = [
@@ -388,8 +390,9 @@ function mount(target, options = {}) {
388
390
  if (options.agent && (!capabilities.includes('open') || !capabilities.includes('read')))
389
391
  throw coded('invalid_option', TypeError, 'Agent access needs both load and save');
390
392
  const ids = {sessionId, documentId}, answers = new Map(), waiting = new Map(), listeners = new Map();
391
- let watcher = null, port = null, posted = false, connected = false, ended = false, endError = null, sawLoad = false, seq = 0, activeSave = null, activeClose = null;
392
- let currentTheme = options.theme, lastSaved = null, readOnly = false, connectedWait = deferred(), boot, bootTimer = 0, loadTimer = 0, timedOut = false, frameFacts = {};
393
+ let watcher = null, port = null, posted = false, connected = false, ended = false, terminal = false, endError = null, sawLoad = false, seq = 0, activeSave = null, activeClose = null;
394
+ let currentTheme = options.theme, lastSaved = null, readOnly = false, connectedWait = deferred(), boot, bootAttempt, bootTimer = 0, loadTimer = 0, timedOut = false, frameFacts = {};
395
+ const initialLoad = record(options.load) ? {...options.load} : options.load;
393
396
  let held = null;
394
397
  const ready = deferred();
395
398
  const emit = (type, value) => { for (const fn of listeners.get(type) || []) fn(value); };
@@ -414,8 +417,9 @@ function mount(target, options = {}) {
414
417
  return pending.promise;
415
418
  }
416
419
  function requestCapture() {
417
- if (activeSave) activeSave.commandId = 'host-' + (seq + 1);
418
- send('save', undefined, undefined, true).catch(error => { activeSave?.reject(error); activeSave = null; });
420
+ const capture = activeSave;
421
+ if (capture) capture.commandId = 'host-' + (seq + 1);
422
+ send('save', undefined, undefined, true).catch(error => { if (activeSave === capture) { capture?.reject(error); activeSave = null; } });
419
423
  }
420
424
  async function answerSave(message, channel) {
421
425
  const {requestId, baseRevision, payload} = message;
@@ -439,6 +443,7 @@ function mount(target, options = {}) {
439
443
  if (!answers.has(requestId)) {
440
444
  const job = Promise.resolve().then(async () => {
441
445
  try {
446
+ if (ended || channel !== port) throw coded('disconnected', Error, 'The ' + noun + ' connection has ended');
442
447
  const result = await options.save({...payload, requestId, baseRevision});
443
448
  if (!revision(result?.revision) || result.revision === baseRevision || typeof result.revision === 'number' && typeof baseRevision === 'number' && result.revision <= baseRevision)
444
449
  throw coded('save_not_advancing', TypeError, 'save must return an advancing durable revision');
@@ -455,7 +460,7 @@ function mount(target, options = {}) {
455
460
  if (ended || channel !== port) return;
456
461
  channel.postMessage(reply);
457
462
  if (reply.type === 'save-ack') {
458
- lastSaved = reply.payload; held = {revision: reply.payload.revision, content: payload.content, filename: payload.filename};
463
+ lastSaved = reply.payload; held = {...held, revision: reply.payload.revision, content: payload.content, filename: payload.filename};
459
464
  if (activeSave) activeSave.acknowledged = true;
460
465
  } else if (activeSave) { activeSave.reject(refused(reply.payload)); activeSave = null; }
461
466
  }
@@ -477,7 +482,7 @@ function mount(target, options = {}) {
477
482
  if (assetPending) throw refusal('asset_busy');
478
483
  if (assetAnswers.size >= 48) assetAnswers.delete(assetAnswers.keys().next().value);
479
484
  const signal = assetAbort.signal;
480
- row = {signature, job: null}; assetAnswers.set(requestId, row); assetPending = requestId;
485
+ row = {signature, job: null}; assetAnswers.set(requestId, row); assetPending = row;
481
486
  row.job = Promise.resolve().then(async () => {
482
487
  try {
483
488
  if (await bytesHash(bytes) !== payload.bytesSha256) throw refusal('asset_hash_mismatch');
@@ -488,7 +493,7 @@ function mount(target, options = {}) {
488
493
  } catch (error) {
489
494
  return {type: 'asset-nack', payload: {code: typeof error?.code === 'string' && error.code.length > 0 && error.code.length <= 64 ? error.code : 'asset_failed',
490
495
  reason: String(error?.message || error).slice(0, 500)}};
491
- } finally { if (assetPending === requestId) assetPending = ''; }
496
+ } finally { if (assetPending === row) assetPending = null; }
492
497
  });
493
498
  } else if (await bytesHash(bytes) !== payload.bytesSha256) throw refusal('asset_hash_mismatch');
494
499
  const answer = await row.job;
@@ -500,6 +505,7 @@ function mount(target, options = {}) {
500
505
  function cancelOpen(code = 'open_cancelled') {
501
506
  const row = openPending;
502
507
  if (!row || row.abort.signal.aborted) return;
508
+ openPending = null;
503
509
  row.answer = {type: 'open-nack', payload: {code, reason: 'File opening was canceled.'}};
504
510
  row.abort.abort();
505
511
  }
@@ -567,7 +573,8 @@ function mount(target, options = {}) {
567
573
  || !(message.requestId === null || identifier(message.requestId)) || !(message.baseRevision == null || revision(message.baseRevision))) return;
568
574
  const {type, payload, requestId} = message, pending = waiting.get(requestId);
569
575
  if (type === 'protocol-error' && !connected) {
570
- const error = refused(payload); connectedWait.reject(error); ready.reject(error); fail(error); return;
576
+ clearTimeout(bootTimer); clearTimeout(loadTimer);
577
+ connectedWait.reject(refused(payload)); return;
571
578
  }
572
579
  if (type === 'connected') {
573
580
  if (connected) return;
@@ -578,8 +585,8 @@ function mount(target, options = {}) {
578
585
  }
579
586
  if (!connected) return;
580
587
  if (type === 'open-request' || type === 'open-cancel') { answerOpen(message, channel); return; }
581
- if (type === 'asset-request') { answerAsset(message, channel).catch(fail); return; }
582
- if (type === 'save-request') { answerSave(message, channel).catch(fail); return; }
588
+ if (type === 'asset-request') { answerAsset(message, channel).catch(error => { if (!ended && channel === port) fail(error); }); return; }
589
+ if (type === 'save-request') { answerSave(message, channel).catch(error => { if (!ended && channel === port) fail(error); }); return; }
583
590
  if (type === 'document-state' && capabilities.includes('changes')) {
584
591
  if (!['loaded', 'dirty', 'saving', 'closing', 'readOnly'].every(key => typeof payload[key] === 'boolean')
585
592
  || Object.keys(payload).some(key => !['loaded', 'dirty', 'saving', 'closing', 'readOnly', 'filename', 'docKind'].includes(key))) return;
@@ -595,12 +602,15 @@ function mount(target, options = {}) {
595
602
  return;
596
603
  }
597
604
  if (type === 'close-request' && options.onClose) {
598
- Promise.resolve().then(() => options.onClose({dirty: !!payload.dirty})).then(decision => {
605
+ Promise.resolve().then(() => {
606
+ if (ended || channel !== port) throw coded('disconnected', Error, 'The ' + noun + ' connection has ended');
607
+ return options.onClose({dirty: !!payload.dirty});
608
+ }).then(decision => {
599
609
  if (!['save', 'discard', 'cancel'].includes(decision)) throw coded('invalid_option', TypeError, 'Close needs save, discard or cancel');
600
610
  if (channel !== port || ended) return;
601
611
  channel.postMessage({type: 'close-decision', ...ids, requestId, baseRevision: message.baseRevision, payload: {decision}});
602
612
  if (decision === 'cancel') { activeClose?.resolve({cancelled: true}); activeClose = null; }
603
- }).catch(error => { activeClose?.reject(error); activeClose = null; fail(error); });
613
+ }).catch(error => { if (channel !== port || ended) return; activeClose?.reject(error); activeClose = null; fail(error); });
604
614
  emit('close-request', payload); return;
605
615
  }
606
616
  if (type === 'close-ready' && options.onClose) { activeClose?.resolve(payload); activeClose = null; emit('closed', payload); return; }
@@ -620,17 +630,40 @@ function mount(target, options = {}) {
620
630
  }
621
631
  }
622
632
  function prepareBoot() {
623
- boot = connectedWait.promise.then(async () => {
633
+ bootAttempt?.cancel(coded('disconnected', Error, 'The ' + noun + ' reconnected'));
634
+ const attempt = {...deferred(), stopped: false, recovered: false, timer: 0};
635
+ attempt.cancel = error => { attempt.stopped = true; clearTimeout(attempt.timer); attempt.reject(error); };
636
+ bootAttempt = attempt; boot = attempt.promise;
637
+ const current = () => bootAttempt === attempt && !attempt.stopped && !ended;
638
+ connectedWait.promise.then(async () => {
639
+ if (!current()) return;
640
+ attempt.timer = setTimeout(() => {
641
+ if (!current()) return;
642
+ const error = coded('load_timeout', Error, 'The ' + noun + ' did not finish its first document within ' + loadTimeout / 1000 + ' seconds');
643
+ attempt.cancel(error); rejectPending(error);
644
+ }, loadTimeout);
624
645
  if (currentStyle) await send('style', currentStyle, undefined, true);
625
- if (options.load !== undefined) {
626
- const value = typeof options.load === 'function' ? await options.load() : options.load;
627
- if (!record(value) || typeof value.content !== 'string' || !revision(value.revision)) throw coded('invalid_load', TypeError, 'load needs content and its revision');
628
- await send('load', value, null);
629
- held = {revision: value.revision, content: value.content, filename: value.filename};
646
+ if (initialLoad !== undefined) {
647
+ const input = typeof initialLoad === 'function' ? await initialLoad() : held || initialLoad;
648
+ if (!current()) return;
649
+ if (!record(input) || typeof input.content !== 'string' || !revision(input.revision)) throw coded('invalid_load', TypeError, 'load needs content and its revision');
650
+ const value = {...input};
651
+ await send('load', value, null, false, loadTimeout);
652
+ if (!current()) return;
653
+ held = value;
630
654
  }
655
+ if (!current()) return;
656
+ clearTimeout(attempt.timer); attempt.resolve();
631
657
  ready.resolve(handle);
632
- });
633
- boot.catch(error => { ready.reject(error); if (!ended) fail(error); });
658
+ }).catch(error => { if (current()) attempt.cancel(error); });
659
+ boot.catch(error => { if (bootAttempt === attempt && !ended) { ready.reject(error); fail(error); } });
660
+ }
661
+ async function whenBooted(retryLoad = false) {
662
+ const attempt = bootAttempt;
663
+ try { await attempt.promise; }
664
+ catch (error) { if ((!retryLoad && !attempt.recovered) || ended || !connected || attempt !== bootAttempt) throw error; }
665
+ if (ended || attempt !== bootAttempt) throw endError || coded('disconnected', Error, 'The ' + noun + ' connection has ended');
666
+ return attempt;
634
667
  }
635
668
  function connect() {
636
669
  if (posted || ended) return;
@@ -639,7 +672,8 @@ function mount(target, options = {}) {
639
672
  port.onmessage = event => onPortMessage(event, channel.port1);
640
673
  port.onmessageerror = () => {
641
674
  if (channel.port1 !== port || ended) return;
642
- cancelOpen('open_disconnected'); assetAbort.abort(); rejectPending(coded('disconnected', Error, 'The ' + noun + ' connection was lost'));
675
+ const error = coded('disconnected', Error, 'The ' + noun + ' connection was lost');
676
+ end(true, error); fail(error);
643
677
  };
644
678
  try { iframe.contentWindow.postMessage({type: 'rapier-connect', ...ids, capabilities, contract: 1, ...(settings ? {settings} : {}), ...(agentName ? {agent: {name: agentName}} : {}), ...(currentTheme ? {theme: currentTheme} : {})}, origin, [channel.port2]); }
645
679
  catch (error) { port.close(); port = null; posted = false; fail(error); }
@@ -652,11 +686,24 @@ function mount(target, options = {}) {
652
686
  }
653
687
  const connectTimeoutReason = () => 'The ' + noun + ' page loaded but did not answer within ' + bootTimeout / 1000 + ' seconds. The address may not be a Rapier page (a missing file loads an error page), the page may not allow this parent (frame-ancestors or X-Frame-Options), or the device may be too slow; a slow device that answers later still connects.';
654
688
  function onLoad() {
689
+ if (terminal) return;
690
+ if (ended) {
691
+ if (!iframe.isConnected) return;
692
+ if (slotLive.get(pairKey)?.()) {
693
+ endError = coded('document_open_elsewhere', Error, 'Another editor already has this document open');
694
+ fail(endError); return;
695
+ }
696
+ ended = false; endError = null; connected = false; posted = false; readOnly = false; sawLoad = false; timedOut = false;
697
+ assetAbort = new AbortController(); connectedWait = deferred(); prepareBoot();
698
+ host.addEventListener('message', onWindowMessage);
699
+ slotLive.set(pairKey, live); watch();
700
+ }
655
701
  if (sawLoad && posted) {
656
702
  cancelOpen('open_disconnected');
657
- rejectPending(coded('disconnected', Error, 'The ' + noun + ' reconnected')); port?.close();
658
- assetAbort.abort(refusal('asset_disconnected')); assetAbort = new AbortController();
659
- connected = false; posted = false; readOnly = false; connectedWait = deferred(); prepareBoot();
703
+ const error = coded('disconnected', Error, 'The ' + noun + ' reconnected');
704
+ bootAttempt.cancel(error); rejectPending(error); port?.close(); port = null;
705
+ assetAbort.abort(refusal('asset_disconnected')); assetPending = null; assetAbort = new AbortController();
706
+ connected = false; posted = false; readOnly = false; timedOut = false; connectedWait = deferred(); prepareBoot();
660
707
  }
661
708
  sawLoad = true;
662
709
  clearTimeout(bootTimer); clearTimeout(loadTimer);
@@ -664,27 +711,43 @@ function mount(target, options = {}) {
664
711
  connect();
665
712
  }
666
713
  function focusBeside(forward) {
667
- const order = forward ? host.Node.DOCUMENT_POSITION_FOLLOWING : host.Node.DOCUMENT_POSITION_PRECEDING;
668
- const controls = [...document.querySelectorAll('a[href],button,input,select,textarea,iframe,summary,[tabindex],[contenteditable=""],[contenteditable="true"]')]
669
- .filter(element => element !== iframe && element.tabIndex >= 0 && !element.disabled && !element.closest('[inert]') && element.getClientRects().length &&
670
- iframe.compareDocumentPosition(element) & order);
671
- const next = forward ? controls[0] : controls.at(-1);
714
+ const flat = [], around = new Set();
715
+ const parent = node => node.assignedSlot || node.parentNode || node.host;
716
+ const walk = element => {
717
+ flat.push(element);
718
+ const assigned = element.localName === 'slot' ? element.assignedElements({flatten: true}) : [];
719
+ for (const child of assigned.length ? assigned : (element.shadowRoot || element).children) walk(child);
720
+ };
721
+ for (const child of document.children) walk(child);
722
+ for (let node = iframe; node; node = parent(node)) around.add(node);
723
+ const available = element => {
724
+ if (around.has(element) || element.tabIndex < 0 || !element.matches('a[href],button,input,select,textarea,iframe,summary,[tabindex],[contenteditable=""],[contenteditable="true"]') ||
725
+ element.matches(':disabled') || !element.getClientRects().length || ['hidden', 'collapse'].includes(host.getComputedStyle(element).visibility)) return false;
726
+ for (let node = element; node; node = parent(node))
727
+ if (node.nodeType === 1 && (node.matches('[hidden],[inert]') || node.localName.includes('-') && node.hasAttribute('disabled'))) return false;
728
+ return true;
729
+ };
730
+ const at = flat.indexOf(iframe), side = forward ? flat.slice(at + 1) : flat.slice(0, at).reverse();
731
+ const next = at < 0 ? null : side.find(available);
672
732
  if (next) next.focus(); else iframe.blur();
673
733
  }
674
- function end(removed) {
675
- if (ended) return;
676
- if (connected && !removed) send('disconnect', undefined, undefined, true).catch(() => {});
677
- ended = true; connected = false; clearTimeout(bootTimer); clearTimeout(loadTimer); watcher?.disconnect();
734
+ function end(removed, error) {
735
+ if (terminal || ended && removed) return;
736
+ if (connected && (!removed || error)) send('disconnect', undefined, undefined, true).catch(() => {});
737
+ terminal = !removed; ended = true; connected = false; clearTimeout(bootTimer); clearTimeout(loadTimer); watcher?.disconnect(); watcher = null;
678
738
  cancelOpen('open_disconnected'); openAnswers.clear();
679
- assetAbort.abort(refusal('asset_disconnected')); assetAnswers.clear();
680
- const error = removed ? coded('frame_removed', Error, 'The ' + noun + ' was removed from the page') : coded('disconnected', Error, 'The ' + noun + ' connection has ended');
681
- if (removed) endError = error;
682
- rejectPending(error); connectedWait.reject(error); ready.reject(error);
683
- host.removeEventListener('message', onWindowMessage); iframe.removeEventListener('load', onLoad);
684
- port?.close(); port = null; listeners.clear();
739
+ assetAbort.abort(refusal('asset_disconnected')); assetPending = null; assetAnswers.clear();
740
+ error ??= removed ? coded('frame_removed', Error, 'The ' + noun + ' was removed from the page') : coded('disconnected', Error, 'The ' + noun + ' connection has ended');
741
+ endError = error;
742
+ bootAttempt.cancel(error); rejectPending(error); connectedWait.reject(error); ready.reject(error);
743
+ host.removeEventListener('message', onWindowMessage);
744
+ if (!removed) { iframe.removeEventListener('load', onLoad); listeners.clear(); answers.clear(); }
745
+ port?.close(); port = null;
746
+ if (slotLive.get(pairKey) === live) slotLive.delete(pairKey);
685
747
  if (!removed && iframe !== target) iframe.remove();
686
748
  }
687
749
  function onWindowMessage(event) {
750
+ if (ended) return;
688
751
  if (event.source !== iframe.contentWindow || event.origin !== origin) return;
689
752
  if (event.data?.type === 'rapier-ready') connect();
690
753
  else if (event.data?.type === 'rapier-boot-failed') bootFailed('boot_failed', 'The ' + noun + ' could not start');
@@ -703,19 +766,21 @@ function mount(target, options = {}) {
703
766
  if (typeof content !== 'string') throw Object.assign(new TypeError('load needs the document text as a string'), {code: 'invalid_payload'});
704
767
  let {filename, revision: version, readOnly, title, baseUrl} = options;
705
768
  cancelOpen('open_stale');
706
- await boot;
769
+ const attempt = await whenBooted(true);
707
770
  if (version === undefined) version = typeof held?.revision === 'number' ? held.revision + 1 : 1;
708
771
  const sameDocument = held && (filename === undefined || held.filename === undefined || filename === held.filename);
709
772
  if (sameDocument && typeof version === 'number' && typeof held.revision === 'number' && version < held.revision)
710
773
  throw Object.assign(new Error('Revision ' + version + ' is older than the frame\'s ' + held.revision), {code: 'revision_stale', currentRevision: held.revision});
711
774
  if (sameDocument && version === held.revision && held.content !== null && content !== held.content)
712
775
  throw Object.assign(new Error('Revision ' + version + ' already names other content'), {code: 'revision_conflict', currentRevision: held.revision});
713
- const loaded = await send('load', {content, revision: version, ...(filename !== undefined ? {filename} : {}), ...(readOnly !== undefined ? {readOnly} : {}), ...(title !== undefined ? {title} : {}), ...(baseUrl !== undefined ? {baseUrl} : {})}, null, false, loadTimeout);
714
- held = {revision: version, content, filename};
776
+ const value = {content, revision: version, ...(filename !== undefined ? {filename} : {}), ...(readOnly !== undefined ? {readOnly} : {}), ...(title !== undefined ? {title} : {}), ...(baseUrl !== undefined ? {baseUrl} : {})};
777
+ const loaded = await send('load', value, null, false, loadTimeout);
778
+ if (attempt !== bootAttempt || ended) throw endError || coded('disconnected', Error, 'The ' + noun + ' reconnected');
779
+ held = value; attempt.recovered = true;
715
780
  return loaded;
716
781
  },
717
782
  async save() {
718
- await boot;
783
+ await whenBooted();
719
784
  if (ended) throw endError || coded('disconnected', Error, 'The ' + noun + ' connection has ended');
720
785
  if (!options.save) throw coded('save_unavailable', Error, 'No save callback was granted');
721
786
  if (readOnly) throw coded('read_only', Error, 'The document is read-only');
@@ -723,24 +788,27 @@ function mount(target, options = {}) {
723
788
  const promise = activeSave.promise;
724
789
  requestCapture(); return promise;
725
790
  },
726
- async compare(content, {filename} = {}) { await boot; return send('compare', {content, ...(filename !== undefined ? {filename} : {})}); },
791
+ async compare(content, {filename} = {}) { await whenBooted(); return send('compare', {content, ...(filename !== undefined ? {filename} : {})}); },
727
792
  async close() {
728
- await boot;
793
+ await whenBooted();
729
794
  if (ended) throw endError || coded('disconnected', Error, 'The ' + noun + ' connection has ended');
730
795
  if (!options.onClose) throw coded('close_unavailable', Error, 'No close callback was granted');
731
- if (!activeClose) { activeClose = deferred(); send('close', undefined, undefined, true).catch(error => { activeClose?.reject(error); activeClose = null; }); }
796
+ if (!activeClose) {
797
+ const closing = activeClose = deferred();
798
+ send('close', undefined, undefined, true).catch(error => { if (activeClose === closing) { closing.reject(error); activeClose = null; } });
799
+ }
732
800
  return activeClose.promise;
733
801
  },
734
802
  async theme(value) {
735
803
  if (!themeValue(value)) throw coded('invalid_theme', TypeError, 'Theme must be light, dark or system');
736
804
  currentTheme = value;
737
- try { await boot; return await send('theme', {theme: value}, undefined, true); } catch (error) { if (error?.code === 'disconnected') return; throw error; }
805
+ try { await whenBooted(); return await send('theme', {theme: value}, undefined, true); } catch (error) { if (error?.code === 'disconnected') return; throw error; }
738
806
  },
739
807
  async style(value) {
740
808
  if (!reader) throw coded('style_unavailable', TypeError, 'style restyles the reader build');
741
809
  if (!capabilities.includes('open')) throw coded('load_required', TypeError, 'Reader styling requires load');
742
810
  currentStyle = readerStyle(value);
743
- try { await boot; return await send('style', currentStyle, undefined, true); } catch (error) { if (error?.code === 'disconnected') return; throw error; }
811
+ try { await whenBooted(); return await send('style', currentStyle, undefined, true); } catch (error) { if (error?.code === 'disconnected') return; throw error; }
744
812
  },
745
813
  disconnect() { end(false); },
746
814
  on(type, fn) {
@@ -749,18 +817,18 @@ function mount(target, options = {}) {
749
817
  return () => listeners.get(type)?.delete(fn);
750
818
  },
751
819
  });
752
- if (auto) autoLive.set(auto, () => iframe.isConnected && !ended);
753
- if (!openElsewhere) slotLive.set(pairKey, () => iframe.isConnected && !ended);
820
+ const live = () => iframe.isConnected && !ended;
821
+ if (!openElsewhere) slotLive.set(pairKey, live);
754
822
  prepareBoot();
823
+ if (openElsewhere) {
824
+ queueMicrotask(() => bootFailed('document_open_elsewhere', 'Another editor on this page already has this document open (the same sessionId and documentId). Give each editor its own documentId.'));
825
+ return handle;
826
+ }
755
827
  if (iframe !== target) { iframe.setAttribute('width', '100%'); iframe.setAttribute('height', '100%'); iframe.setAttribute('frameborder', '0'); }
756
828
  iframe.setAttribute('title', options.title || (reader ? 'Markdown reader' : 'Markdown editor'));
757
829
  iframe.setAttribute('allow', options.agent ? 'clipboard-write; tools' : 'clipboard-write');
758
830
  const ownSandbox = iframe === target && iframe.hasAttribute('sandbox');
759
831
  if (!ownSandbox) iframe.setAttribute('sandbox', 'allow-scripts allow-same-origin allow-downloads');
760
- if (openElsewhere) {
761
- queueMicrotask(() => bootFailed('document_open_elsewhere', 'Another editor on this page already has this document open (the same sessionId and documentId). Give each editor its own documentId.'));
762
- return handle;
763
- }
764
832
  const blocked = framingProblem(host, source, ownSandbox ? iframe.sandbox : null, noun);
765
833
  if (blocked) {
766
834
  if (iframe !== target) target.append(iframe);
@@ -770,13 +838,26 @@ function mount(target, options = {}) {
770
838
  host.addEventListener('message', onWindowMessage); iframe.addEventListener('load', onLoad);
771
839
  iframe.src = source.href;
772
840
  if (iframe !== target) target.append(iframe);
773
- if (host.MutationObserver) {
841
+ function watch() {
842
+ if (!host.MutationObserver) return;
843
+ watcher?.disconnect();
774
844
  let attached = iframe.isConnected;
775
- watcher = new host.MutationObserver(() => { if (iframe.isConnected) attached = true; else if (attached) end(true); });
845
+ watcher = new host.MutationObserver(() => {
846
+ if (!iframe.isConnected) { if (attached) end(true); return; }
847
+ attached = true;
848
+ observeRoots();
849
+ });
850
+ const observeRoots = () => {
851
+ for (let node = iframe; node;) {
852
+ const root = node.getRootNode();
853
+ if (root !== host.document && root.nodeType === 11) watcher.observe(root, {childList: true, subtree: true});
854
+ node = root.host;
855
+ }
856
+ };
776
857
  watcher.observe(host.document, {childList: true, subtree: true});
777
- const root = iframe.getRootNode();
778
- if (root !== host.document && root.nodeType === 11) watcher.observe(root, {childList: true, subtree: true});
858
+ observeRoots();
779
859
  }
860
+ watch();
780
861
  loadTimer = setTimeout(() => bootFailed('load_timeout', 'The ' + noun + ' did not finish loading within ' + loadTimeout / 1000 + ' seconds. Check the address and the network.'), loadTimeout);
781
862
  return handle;
782
863
  }
@@ -855,14 +936,24 @@ export class RapierEditor extends (globalThis.HTMLElement || class {}) {
855
936
  queueMicrotask(() => {
856
937
  if (this.isConnected) return;
857
938
  this._leaveForm?.(); this._leaveForm = null; this._media?.removeEventListener('change', this._onMedia);
858
- this._editor?.disconnect(); this._editor = null; this._mount.replaceChildren();
939
+ const editor = this._editor;
940
+ this._generation++; this._editor = null; this._sync = Promise.resolve();
941
+ editor?.disconnect(); this._mount.replaceChildren();
859
942
  });
860
943
  }
861
944
  attributeChangedCallback(name) {
862
945
  if (!this._initialized) return;
863
946
  if (name === 'disabled') this.formDisabledCallback(this.matches(':disabled'));
864
- else if (name === 'theme') this._editor?.theme(this.getAttribute('theme') || 'system').catch(error => this._error(error));
865
- else if (name === 'src') (this._editor?.connected ? this.save() : Promise.resolve()).then(() => this._replace(this._value)).catch(error => this._error(error));
947
+ else if (name === 'theme') {
948
+ const editor = this._editor;
949
+ editor?.theme(this.getAttribute('theme') || 'system').catch(error => { if (this._editor === editor) this._error(error); });
950
+ }
951
+ else if (name === 'src') {
952
+ const editor = this._editor, generation = this._generation, src = this.getAttribute('src');
953
+ const current = () => this._editor === editor && this._generation === generation && this.getAttribute('src') === src;
954
+ (editor?.connected ? this.save() : Promise.resolve()).then(() => { if (current()) this._remount(); })
955
+ .catch(error => { if (current()) this._error(error); });
956
+ }
866
957
  else this._updateValue();
867
958
  }
868
959
  get value() { return this._value; }
@@ -911,26 +1002,42 @@ export class RapierEditor extends (globalThis.HTMLElement || class {}) {
911
1002
  this._internals.setValidity(error ? (this._customValidity ? {customError: true} : {valueMissing: true}) : {}, error, this._preview);
912
1003
  }
913
1004
  _replace(value) {
914
- this._value = value; this._dirty = false; this._revision = 0; this._generation++;
915
- this._editor?.disconnect(); this._editor = null; this._mount.replaceChildren(); this._updateValue();
1005
+ this._value = value; this._dirty = false; this._revision = 0;
1006
+ this._ids = {sessionId: mint('field'), documentId: mint('field')};
1007
+ this._remount();
1008
+ }
1009
+ _remount() {
1010
+ const editor = this._editor;
1011
+ this._generation++; this._editor = null; this._sync = Promise.resolve();
1012
+ editor?.disconnect(); this._mount.replaceChildren(); this._updateValue();
916
1013
  if (this._initialized && this.isConnected && this._panel.open) this._start();
917
1014
  }
918
1015
  _start() {
919
1016
  if (this._editor) return;
1017
+ const load = {content: this._value, filename: this._filename(), revision: this._revision, readOnly: this._disabled};
1018
+ if (!this._ids) {
1019
+ const here = this.ownerDocument.defaultView.location;
1020
+ const auto = autoIdentity({load, save: true}, here, this.id);
1021
+ this._ids = {sessionId: 'page-' + auto, documentId: 'doc-' + auto};
1022
+ }
920
1023
  const generation = this._generation;
921
- this._editor = Rapier.mount(this._mount, {
1024
+ let editor;
1025
+ const current = () => this._generation === generation && this._editor === editor;
1026
+ editor = Rapier.mount(this._mount, {...this._ids,
922
1027
  src: this.getAttribute('src') || DEFAULT_SRC, theme: this.getAttribute('theme') || 'system',
923
- load: {content: this._value, filename: this._filename(), revision: this._revision, readOnly: this._disabled},
1028
+ load,
924
1029
  save: ({content, baseRevision}) => {
925
- if (this._generation !== generation) throw coded('field_replaced', Error, 'The form field was replaced');
1030
+ if (!current()) throw coded('field_replaced', Error, 'The form field was replaced');
926
1031
  if (baseRevision !== this._revision) throw conflict(this._revision);
927
1032
  this._value = content; this._revision++; this._dirty = false; this._updateValue();
1033
+ const revision = this._revision;
928
1034
  this.dispatchEvent(new Event('change', {bubbles: true, composed: true}));
929
- return {revision: this._revision};
1035
+ return {revision};
930
1036
  },
931
- onState: state => { this._dirty = state.dirty; this._updateValue(); },
932
- onError: error => this._error(error),
1037
+ onState: state => { if (current()) { this._dirty = state.dirty; this._updateValue(); } },
1038
+ onError: error => { if (current()) this._error(error); },
933
1039
  });
1040
+ this._editor = editor;
934
1041
  this._mount.inert = this._disabled;
935
1042
  }
936
1043
  _layout() {
@@ -946,13 +1053,18 @@ export class RapierEditor extends (globalThis.HTMLElement || class {}) {
946
1053
  this._start(); this._editor.iframe.focus(options);
947
1054
  }
948
1055
  async save() {
1056
+ const generation = this._generation;
949
1057
  await this._sync;
1058
+ if (generation !== this._generation) throw coded('field_replaced', Error, 'The form field was replaced');
950
1059
  if (this._editor && !this._disabled) await this._editor.save();
1060
+ if (generation !== this._generation) throw coded('field_replaced', Error, 'The form field was replaced');
951
1061
  return this._value;
952
1062
  }
953
1063
  async _finish() {
954
- try { await this.save(); this._panel.close(); this._preview.focus(); }
955
- catch (error) { this._error(error); }
1064
+ const generation = this._generation, authority = this._formAuthority, narrow = this._narrow;
1065
+ const current = () => this._generation === generation && this._formAuthority === authority && this._narrow === narrow && this.isConnected;
1066
+ try { await this.save(); if (current()) { this._panel.close(); if (!this._disabled) this._preview.focus(); } }
1067
+ catch (error) { if (current()) this._error(error); }
956
1068
  }
957
1069
  _error(error) { this.dispatchEvent(new CustomEvent('error', {detail: error, bubbles: true, composed: true})); }
958
1070
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rapier-embed",
3
- "version": "1.2.44",
3
+ "version": "1.2.46",
4
4
  "description": "Embed the Rapier Markdown editor or its lightweight reader (about 360 kB) in your web app. Runs offline from your own copy, saves to your storage, and takes your CSS and fonts. Your app's AI agent edits directly over WebMCP under the Will. MIT, no dependencies.",
5
5
  "keywords": [
6
6
  "markdown",