rapier-embed 1.2.45 → 1.2.48

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
@@ -163,14 +163,23 @@ function assetURL(value) {
163
163
  return url.href;
164
164
  }
165
165
 
166
+ function pictureCeiling(limit) {
167
+ if (!Number.isSafeInteger(limit) || limit < 1) throw refusal('asset_bytes_invalid');
168
+ return Math.min(limit, LIMITS.pictureBytes);
169
+ }
166
170
  function assetBytes(value, limit = LIMITS.pictureBytes) {
167
171
  if (!(value instanceof Uint8Array) || !value.byteLength) throw refusal('asset_bytes_invalid');
168
- if (value.byteLength > Math.min(limit, LIMITS.pictureBytes)) throw refusal('picture_too_large');
172
+ if (value.byteLength > pictureCeiling(limit)) throw refusal('picture_too_large');
169
173
  return value;
170
174
  }
171
175
  async function bytesHash(bytes) {
172
176
  return Array.from(new Uint8Array(await crypto.subtle.digest('SHA-256', bytes)), n => n.toString(16).padStart(2, '0')).join('');
173
177
  }
178
+ function ownedPicture(bytes) {
179
+ const copy = new Uint8Array(bytes.byteLength);
180
+ copy.set(bytes);
181
+ return copy;
182
+ }
174
183
  function externalImage(raw, asset, url) {
175
184
  if (!url) return raw;
176
185
  const token = '[' + asset.label + ']', at = raw.lastIndexOf(token);
@@ -188,15 +197,19 @@ function createAssetBroker({current, send}) {
188
197
  if (identity !== now.identity) { retained.clear(); retainedBytes = 0; identity = now.identity; }
189
198
  return now;
190
199
  }
200
+ function admitted(now) {
201
+ return {identity: now.identity, generation: now.generation, connected: now.connected,
202
+ granted: now.granted, pictureBytes: now.pictureBytes, baseRevision: now.baseRevision};
203
+ }
191
204
  function cancel(code = 'asset_disconnected') { pending?.finish({code}); }
192
205
  async function request(asset) {
193
- const state = scope(), bytes = assetBytes(asset.bytes, state.pictureBytes);
206
+ const state = admitted(scope()), bytes = assetBytes(asset.bytes, state.pictureBytes);
194
207
  if (!state.granted) return {code: 'asset_not_granted'};
195
208
  if (!state.connected) return {code: 'asset_disconnected'};
196
209
  if (pending) return {code: 'asset_busy'};
197
210
  if (!ASSET_TYPES.includes(asset.codec)) throw refusal('asset_media_type_invalid');
198
211
  if (retained.size >= 1024 || retainedBytes + bytes.byteLength > 25 * 1024 * 1024) return {code: 'asset_cache_full'};
199
- const kept = {...asset, bytes: bytes.slice()};
212
+ const kept = {...asset, bytes: ownedPicture(bytes)};
200
213
  let resolve;
201
214
  const job = new Promise(yes => { resolve = yes; });
202
215
  const row = {state, asset: kept, requestId: crypto.randomUUID(), timer: null, bytesSha256: '',
@@ -258,7 +271,7 @@ function pictureBase(value) {
258
271
  return {FEATURES, LIMITS, ASSET_TYPES, refusal, coded, normalizeSettings, snapshotSettings, pickDeviceFile, createFilePicker, assetURL, assetBytes, bytesHash, externalImage, createAssetBroker, pictureBase};
259
272
  })();
260
273
  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.45';
274
+ const PACKAGE_VERSION = '1.2.48';
262
275
  const OPTIONS = ['build', 'src', 'plugins', 'approvePlugins', 'sessionId', 'documentId', 'agent', 'agentName', 'theme', 'save', 'assets', 'openFile', 'load',
263
276
  'onState', 'compare', 'onClose', 'settings', 'style', 'title', 'onError', 'timeout'];
264
277
  const READER_FEATURES = ['find', 'readAloud', 'share'];
@@ -268,14 +281,15 @@ const identifier = value => typeof value === 'string' && value.length > 0 && val
268
281
  const record = value => value !== null && typeof value === 'object' && !Array.isArray(value);
269
282
  const themeValue = value => ['light', 'dark', 'system'].includes(value);
270
283
  const mint = prefix => prefix + '-' + (globalThis.crypto?.randomUUID?.() || Math.random().toString(36).slice(2));
271
- const autoLive = new Map(), slotLive = new Map();
284
+ const slotLive = new Map();
272
285
  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) {
286
+ function autoIdentity(options, location, fieldId) {
274
287
  const load = options.load;
275
288
  if (!options.save || !load || typeof load !== 'object' || typeof load.content !== 'string') return null;
276
289
  const base = fnv(location.origin + location.pathname) + '-' + fnv(String(load.filename ?? '') + '|' + load.content.length + '|' + load.content.slice(0, 2048));
290
+ if (fieldId) return base + '-field-' + fnv(fieldId);
277
291
  let at = 0;
278
- while (autoLive.get(base + '-' + at)?.()) at++;
292
+ while (slotLive.get(JSON.stringify(['page-' + base + '-' + at, 'doc-' + base + '-' + at]))?.()) at++;
279
293
  return base + '-' + at;
280
294
  }
281
295
  function deferred() {
@@ -361,8 +375,9 @@ function mount(target, options = {}) {
361
375
  source.searchParams.set('plugins', directory.href);
362
376
  }
363
377
  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'));
378
+ const origin = source.origin, auto = options.sessionId === undefined && options.documentId === undefined ? autoIdentity(options, host.location) : null;
379
+ const pageSession = options.documentId !== undefined && options.save ? 'page-' + fnv(host.location.origin + host.location.pathname) : null;
380
+ const askedSession = options.sessionId ?? (auto ? 'page-' + auto : pageSession ?? mint('session')), askedDocument = options.documentId ?? (auto ? 'doc-' + auto : mint('document'));
366
381
  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
382
  const sessionId = askedSession, documentId = askedDocument, pairKey = JSON.stringify([sessionId, documentId]), openElsewhere = !!slotLive.get(pairKey)?.();
368
383
  const agentName = options.agentName;
@@ -377,7 +392,7 @@ function mount(target, options = {}) {
377
392
  const unread = reader ? (settings?.features || []).filter(name => !READER_FEATURES.includes(name)) : [];
378
393
  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
394
  const assetAnswers = new Map();
380
- let assetPending = '', assetAbort = new AbortController();
395
+ let assetPending = null, assetAbort = new AbortController();
381
396
  const chooseFile = options.openFile || (({signal}) => pickDeviceFile(document, signal)), openAnswers = new Map();
382
397
  let openPending = null;
383
398
  const capabilities = [
@@ -388,8 +403,9 @@ function mount(target, options = {}) {
388
403
  if (options.agent && (!capabilities.includes('open') || !capabilities.includes('read')))
389
404
  throw coded('invalid_option', TypeError, 'Agent access needs both load and save');
390
405
  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 = {};
406
+ let watcher = null, port = null, posted = false, connected = false, ended = false, terminal = false, endError = null, sawLoad = false, seq = 0, activeSave = null, activeClose = null;
407
+ let currentTheme = options.theme, lastSaved = null, readOnly = false, connectedWait = deferred(), boot, bootAttempt, bootTimer = 0, loadTimer = 0, timedOut = false, frameFacts = {};
408
+ const initialLoad = record(options.load) ? {...options.load} : options.load;
393
409
  let held = null;
394
410
  const ready = deferred();
395
411
  const emit = (type, value) => { for (const fn of listeners.get(type) || []) fn(value); };
@@ -414,8 +430,9 @@ function mount(target, options = {}) {
414
430
  return pending.promise;
415
431
  }
416
432
  function requestCapture() {
417
- if (activeSave) activeSave.commandId = 'host-' + (seq + 1);
418
- send('save', undefined, undefined, true).catch(error => { activeSave?.reject(error); activeSave = null; });
433
+ const capture = activeSave;
434
+ if (capture) capture.commandId = 'host-' + (seq + 1);
435
+ send('save', undefined, undefined, true).catch(error => { if (activeSave === capture) { capture?.reject(error); activeSave = null; } });
419
436
  }
420
437
  async function answerSave(message, channel) {
421
438
  const {requestId, baseRevision, payload} = message;
@@ -439,6 +456,7 @@ function mount(target, options = {}) {
439
456
  if (!answers.has(requestId)) {
440
457
  const job = Promise.resolve().then(async () => {
441
458
  try {
459
+ if (ended || channel !== port) throw coded('disconnected', Error, 'The ' + noun + ' connection has ended');
442
460
  const result = await options.save({...payload, requestId, baseRevision});
443
461
  if (!revision(result?.revision) || result.revision === baseRevision || typeof result.revision === 'number' && typeof baseRevision === 'number' && result.revision <= baseRevision)
444
462
  throw coded('save_not_advancing', TypeError, 'save must return an advancing durable revision');
@@ -455,7 +473,7 @@ function mount(target, options = {}) {
455
473
  if (ended || channel !== port) return;
456
474
  channel.postMessage(reply);
457
475
  if (reply.type === 'save-ack') {
458
- lastSaved = reply.payload; held = {revision: reply.payload.revision, content: payload.content, filename: payload.filename};
476
+ lastSaved = reply.payload; held = {...held, revision: reply.payload.revision, content: payload.content, filename: payload.filename};
459
477
  if (activeSave) activeSave.acknowledged = true;
460
478
  } else if (activeSave) { activeSave.reject(refused(reply.payload)); activeSave = null; }
461
479
  }
@@ -477,7 +495,7 @@ function mount(target, options = {}) {
477
495
  if (assetPending) throw refusal('asset_busy');
478
496
  if (assetAnswers.size >= 48) assetAnswers.delete(assetAnswers.keys().next().value);
479
497
  const signal = assetAbort.signal;
480
- row = {signature, job: null}; assetAnswers.set(requestId, row); assetPending = requestId;
498
+ row = {signature, job: null}; assetAnswers.set(requestId, row); assetPending = row;
481
499
  row.job = Promise.resolve().then(async () => {
482
500
  try {
483
501
  if (await bytesHash(bytes) !== payload.bytesSha256) throw refusal('asset_hash_mismatch');
@@ -488,7 +506,7 @@ function mount(target, options = {}) {
488
506
  } catch (error) {
489
507
  return {type: 'asset-nack', payload: {code: typeof error?.code === 'string' && error.code.length > 0 && error.code.length <= 64 ? error.code : 'asset_failed',
490
508
  reason: String(error?.message || error).slice(0, 500)}};
491
- } finally { if (assetPending === requestId) assetPending = ''; }
509
+ } finally { if (assetPending === row) assetPending = null; }
492
510
  });
493
511
  } else if (await bytesHash(bytes) !== payload.bytesSha256) throw refusal('asset_hash_mismatch');
494
512
  const answer = await row.job;
@@ -500,6 +518,7 @@ function mount(target, options = {}) {
500
518
  function cancelOpen(code = 'open_cancelled') {
501
519
  const row = openPending;
502
520
  if (!row || row.abort.signal.aborted) return;
521
+ openPending = null;
503
522
  row.answer = {type: 'open-nack', payload: {code, reason: 'File opening was canceled.'}};
504
523
  row.abort.abort();
505
524
  }
@@ -567,7 +586,8 @@ function mount(target, options = {}) {
567
586
  || !(message.requestId === null || identifier(message.requestId)) || !(message.baseRevision == null || revision(message.baseRevision))) return;
568
587
  const {type, payload, requestId} = message, pending = waiting.get(requestId);
569
588
  if (type === 'protocol-error' && !connected) {
570
- const error = refused(payload); connectedWait.reject(error); ready.reject(error); fail(error); return;
589
+ clearTimeout(bootTimer); clearTimeout(loadTimer);
590
+ connectedWait.reject(refused(payload)); return;
571
591
  }
572
592
  if (type === 'connected') {
573
593
  if (connected) return;
@@ -578,8 +598,8 @@ function mount(target, options = {}) {
578
598
  }
579
599
  if (!connected) return;
580
600
  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; }
601
+ if (type === 'asset-request') { answerAsset(message, channel).catch(error => { if (!ended && channel === port) fail(error); }); return; }
602
+ if (type === 'save-request') { answerSave(message, channel).catch(error => { if (!ended && channel === port) fail(error); }); return; }
583
603
  if (type === 'document-state' && capabilities.includes('changes')) {
584
604
  if (!['loaded', 'dirty', 'saving', 'closing', 'readOnly'].every(key => typeof payload[key] === 'boolean')
585
605
  || Object.keys(payload).some(key => !['loaded', 'dirty', 'saving', 'closing', 'readOnly', 'filename', 'docKind'].includes(key))) return;
@@ -595,12 +615,15 @@ function mount(target, options = {}) {
595
615
  return;
596
616
  }
597
617
  if (type === 'close-request' && options.onClose) {
598
- Promise.resolve().then(() => options.onClose({dirty: !!payload.dirty})).then(decision => {
618
+ Promise.resolve().then(() => {
619
+ if (ended || channel !== port) throw coded('disconnected', Error, 'The ' + noun + ' connection has ended');
620
+ return options.onClose({dirty: !!payload.dirty});
621
+ }).then(decision => {
599
622
  if (!['save', 'discard', 'cancel'].includes(decision)) throw coded('invalid_option', TypeError, 'Close needs save, discard or cancel');
600
623
  if (channel !== port || ended) return;
601
624
  channel.postMessage({type: 'close-decision', ...ids, requestId, baseRevision: message.baseRevision, payload: {decision}});
602
625
  if (decision === 'cancel') { activeClose?.resolve({cancelled: true}); activeClose = null; }
603
- }).catch(error => { activeClose?.reject(error); activeClose = null; fail(error); });
626
+ }).catch(error => { if (channel !== port || ended) return; activeClose?.reject(error); activeClose = null; fail(error); });
604
627
  emit('close-request', payload); return;
605
628
  }
606
629
  if (type === 'close-ready' && options.onClose) { activeClose?.resolve(payload); activeClose = null; emit('closed', payload); return; }
@@ -620,17 +643,40 @@ function mount(target, options = {}) {
620
643
  }
621
644
  }
622
645
  function prepareBoot() {
623
- boot = connectedWait.promise.then(async () => {
646
+ bootAttempt?.cancel(coded('disconnected', Error, 'The ' + noun + ' reconnected'));
647
+ const attempt = {...deferred(), stopped: false, recovered: false, timer: 0};
648
+ attempt.cancel = error => { attempt.stopped = true; clearTimeout(attempt.timer); attempt.reject(error); };
649
+ bootAttempt = attempt; boot = attempt.promise;
650
+ const current = () => bootAttempt === attempt && !attempt.stopped && !ended;
651
+ connectedWait.promise.then(async () => {
652
+ if (!current()) return;
653
+ attempt.timer = setTimeout(() => {
654
+ if (!current()) return;
655
+ const error = coded('load_timeout', Error, 'The ' + noun + ' did not finish its first document within ' + loadTimeout / 1000 + ' seconds');
656
+ attempt.cancel(error); rejectPending(error);
657
+ }, loadTimeout);
624
658
  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};
659
+ if (initialLoad !== undefined) {
660
+ const input = typeof initialLoad === 'function' ? await initialLoad() : held || initialLoad;
661
+ if (!current()) return;
662
+ if (!record(input) || typeof input.content !== 'string' || !revision(input.revision)) throw coded('invalid_load', TypeError, 'load needs content and its revision');
663
+ const value = {...input};
664
+ await send('load', value, null, false, loadTimeout);
665
+ if (!current()) return;
666
+ held = value;
630
667
  }
668
+ if (!current()) return;
669
+ clearTimeout(attempt.timer); attempt.resolve();
631
670
  ready.resolve(handle);
632
- });
633
- boot.catch(error => { ready.reject(error); if (!ended) fail(error); });
671
+ }).catch(error => { if (current()) attempt.cancel(error); });
672
+ boot.catch(error => { if (bootAttempt === attempt && !ended) { ready.reject(error); fail(error); } });
673
+ }
674
+ async function whenBooted(retryLoad = false) {
675
+ const attempt = bootAttempt;
676
+ try { await attempt.promise; }
677
+ catch (error) { if ((!retryLoad && !attempt.recovered) || ended || !connected || attempt !== bootAttempt) throw error; }
678
+ if (ended || attempt !== bootAttempt) throw endError || coded('disconnected', Error, 'The ' + noun + ' connection has ended');
679
+ return attempt;
634
680
  }
635
681
  function connect() {
636
682
  if (posted || ended) return;
@@ -639,7 +685,8 @@ function mount(target, options = {}) {
639
685
  port.onmessage = event => onPortMessage(event, channel.port1);
640
686
  port.onmessageerror = () => {
641
687
  if (channel.port1 !== port || ended) return;
642
- cancelOpen('open_disconnected'); assetAbort.abort(); rejectPending(coded('disconnected', Error, 'The ' + noun + ' connection was lost'));
688
+ const error = coded('disconnected', Error, 'The ' + noun + ' connection was lost');
689
+ end(true, error); fail(error);
643
690
  };
644
691
  try { iframe.contentWindow.postMessage({type: 'rapier-connect', ...ids, capabilities, contract: 1, ...(settings ? {settings} : {}), ...(agentName ? {agent: {name: agentName}} : {}), ...(currentTheme ? {theme: currentTheme} : {})}, origin, [channel.port2]); }
645
692
  catch (error) { port.close(); port = null; posted = false; fail(error); }
@@ -652,11 +699,24 @@ function mount(target, options = {}) {
652
699
  }
653
700
  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
701
  function onLoad() {
702
+ if (terminal) return;
703
+ if (ended) {
704
+ if (!iframe.isConnected) return;
705
+ if (slotLive.get(pairKey)?.()) {
706
+ endError = coded('document_open_elsewhere', Error, 'Another editor already has this document open');
707
+ fail(endError); return;
708
+ }
709
+ ended = false; endError = null; connected = false; posted = false; readOnly = false; sawLoad = false; timedOut = false;
710
+ assetAbort = new AbortController(); connectedWait = deferred(); prepareBoot();
711
+ host.addEventListener('message', onWindowMessage);
712
+ slotLive.set(pairKey, live); watch();
713
+ }
655
714
  if (sawLoad && posted) {
656
715
  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();
716
+ const error = coded('disconnected', Error, 'The ' + noun + ' reconnected');
717
+ bootAttempt.cancel(error); rejectPending(error); port?.close(); port = null;
718
+ assetAbort.abort(refusal('asset_disconnected')); assetPending = null; assetAbort = new AbortController();
719
+ connected = false; posted = false; readOnly = false; timedOut = false; connectedWait = deferred(); prepareBoot();
660
720
  }
661
721
  sawLoad = true;
662
722
  clearTimeout(bootTimer); clearTimeout(loadTimer);
@@ -664,27 +724,43 @@ function mount(target, options = {}) {
664
724
  connect();
665
725
  }
666
726
  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);
727
+ const flat = [], around = new Set();
728
+ const parent = node => node.assignedSlot || node.parentNode || node.host;
729
+ const walk = element => {
730
+ flat.push(element);
731
+ const assigned = element.localName === 'slot' ? element.assignedElements({flatten: true}) : [];
732
+ for (const child of assigned.length ? assigned : (element.shadowRoot || element).children) walk(child);
733
+ };
734
+ for (const child of document.children) walk(child);
735
+ for (let node = iframe; node; node = parent(node)) around.add(node);
736
+ const available = element => {
737
+ if (around.has(element) || element.tabIndex < 0 || !element.matches('a[href],button,input,select,textarea,iframe,summary,[tabindex],[contenteditable=""],[contenteditable="true"]') ||
738
+ element.matches(':disabled') || !element.getClientRects().length || ['hidden', 'collapse'].includes(host.getComputedStyle(element).visibility)) return false;
739
+ for (let node = element; node; node = parent(node))
740
+ if (node.nodeType === 1 && (node.matches('[hidden],[inert]') || node.localName.includes('-') && node.hasAttribute('disabled'))) return false;
741
+ return true;
742
+ };
743
+ const at = flat.indexOf(iframe), side = forward ? flat.slice(at + 1) : flat.slice(0, at).reverse();
744
+ const next = at < 0 ? null : side.find(available);
672
745
  if (next) next.focus(); else iframe.blur();
673
746
  }
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();
747
+ function end(removed, error) {
748
+ if (terminal || ended && removed) return;
749
+ if (connected && (!removed || error)) send('disconnect', undefined, undefined, true).catch(() => {});
750
+ terminal = !removed; ended = true; connected = false; clearTimeout(bootTimer); clearTimeout(loadTimer); watcher?.disconnect(); watcher = null;
678
751
  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();
752
+ assetAbort.abort(refusal('asset_disconnected')); assetPending = null; assetAnswers.clear();
753
+ error ??= removed ? coded('frame_removed', Error, 'The ' + noun + ' was removed from the page') : coded('disconnected', Error, 'The ' + noun + ' connection has ended');
754
+ endError = error;
755
+ bootAttempt.cancel(error); rejectPending(error); connectedWait.reject(error); ready.reject(error);
756
+ host.removeEventListener('message', onWindowMessage);
757
+ if (!removed) { iframe.removeEventListener('load', onLoad); listeners.clear(); answers.clear(); }
758
+ port?.close(); port = null;
759
+ if (slotLive.get(pairKey) === live) slotLive.delete(pairKey);
685
760
  if (!removed && iframe !== target) iframe.remove();
686
761
  }
687
762
  function onWindowMessage(event) {
763
+ if (ended) return;
688
764
  if (event.source !== iframe.contentWindow || event.origin !== origin) return;
689
765
  if (event.data?.type === 'rapier-ready') connect();
690
766
  else if (event.data?.type === 'rapier-boot-failed') bootFailed('boot_failed', 'The ' + noun + ' could not start');
@@ -703,19 +779,21 @@ function mount(target, options = {}) {
703
779
  if (typeof content !== 'string') throw Object.assign(new TypeError('load needs the document text as a string'), {code: 'invalid_payload'});
704
780
  let {filename, revision: version, readOnly, title, baseUrl} = options;
705
781
  cancelOpen('open_stale');
706
- await boot;
782
+ const attempt = await whenBooted(true);
707
783
  if (version === undefined) version = typeof held?.revision === 'number' ? held.revision + 1 : 1;
708
784
  const sameDocument = held && (filename === undefined || held.filename === undefined || filename === held.filename);
709
785
  if (sameDocument && typeof version === 'number' && typeof held.revision === 'number' && version < held.revision)
710
786
  throw Object.assign(new Error('Revision ' + version + ' is older than the frame\'s ' + held.revision), {code: 'revision_stale', currentRevision: held.revision});
711
787
  if (sameDocument && version === held.revision && held.content !== null && content !== held.content)
712
788
  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};
789
+ const value = {content, revision: version, ...(filename !== undefined ? {filename} : {}), ...(readOnly !== undefined ? {readOnly} : {}), ...(title !== undefined ? {title} : {}), ...(baseUrl !== undefined ? {baseUrl} : {})};
790
+ const loaded = await send('load', value, null, false, loadTimeout);
791
+ if (attempt !== bootAttempt || ended) throw endError || coded('disconnected', Error, 'The ' + noun + ' reconnected');
792
+ held = value; attempt.recovered = true;
715
793
  return loaded;
716
794
  },
717
795
  async save() {
718
- await boot;
796
+ await whenBooted();
719
797
  if (ended) throw endError || coded('disconnected', Error, 'The ' + noun + ' connection has ended');
720
798
  if (!options.save) throw coded('save_unavailable', Error, 'No save callback was granted');
721
799
  if (readOnly) throw coded('read_only', Error, 'The document is read-only');
@@ -723,24 +801,27 @@ function mount(target, options = {}) {
723
801
  const promise = activeSave.promise;
724
802
  requestCapture(); return promise;
725
803
  },
726
- async compare(content, {filename} = {}) { await boot; return send('compare', {content, ...(filename !== undefined ? {filename} : {})}); },
804
+ async compare(content, {filename} = {}) { await whenBooted(); return send('compare', {content, ...(filename !== undefined ? {filename} : {})}); },
727
805
  async close() {
728
- await boot;
806
+ await whenBooted();
729
807
  if (ended) throw endError || coded('disconnected', Error, 'The ' + noun + ' connection has ended');
730
808
  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; }); }
809
+ if (!activeClose) {
810
+ const closing = activeClose = deferred();
811
+ send('close', undefined, undefined, true).catch(error => { if (activeClose === closing) { closing.reject(error); activeClose = null; } });
812
+ }
732
813
  return activeClose.promise;
733
814
  },
734
815
  async theme(value) {
735
816
  if (!themeValue(value)) throw coded('invalid_theme', TypeError, 'Theme must be light, dark or system');
736
817
  currentTheme = value;
737
- try { await boot; return await send('theme', {theme: value}, undefined, true); } catch (error) { if (error?.code === 'disconnected') return; throw error; }
818
+ try { await whenBooted(); return await send('theme', {theme: value}, undefined, true); } catch (error) { if (error?.code === 'disconnected') return; throw error; }
738
819
  },
739
820
  async style(value) {
740
821
  if (!reader) throw coded('style_unavailable', TypeError, 'style restyles the reader build');
741
822
  if (!capabilities.includes('open')) throw coded('load_required', TypeError, 'Reader styling requires load');
742
823
  currentStyle = readerStyle(value);
743
- try { await boot; return await send('style', currentStyle, undefined, true); } catch (error) { if (error?.code === 'disconnected') return; throw error; }
824
+ try { await whenBooted(); return await send('style', currentStyle, undefined, true); } catch (error) { if (error?.code === 'disconnected') return; throw error; }
744
825
  },
745
826
  disconnect() { end(false); },
746
827
  on(type, fn) {
@@ -749,18 +830,18 @@ function mount(target, options = {}) {
749
830
  return () => listeners.get(type)?.delete(fn);
750
831
  },
751
832
  });
752
- if (auto) autoLive.set(auto, () => iframe.isConnected && !ended);
753
- if (!openElsewhere) slotLive.set(pairKey, () => iframe.isConnected && !ended);
833
+ const live = () => iframe.isConnected && !ended;
834
+ if (!openElsewhere) slotLive.set(pairKey, live);
754
835
  prepareBoot();
836
+ if (openElsewhere) {
837
+ 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.'));
838
+ return handle;
839
+ }
755
840
  if (iframe !== target) { iframe.setAttribute('width', '100%'); iframe.setAttribute('height', '100%'); iframe.setAttribute('frameborder', '0'); }
756
841
  iframe.setAttribute('title', options.title || (reader ? 'Markdown reader' : 'Markdown editor'));
757
842
  iframe.setAttribute('allow', options.agent ? 'clipboard-write; tools' : 'clipboard-write');
758
843
  const ownSandbox = iframe === target && iframe.hasAttribute('sandbox');
759
844
  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
845
  const blocked = framingProblem(host, source, ownSandbox ? iframe.sandbox : null, noun);
765
846
  if (blocked) {
766
847
  if (iframe !== target) target.append(iframe);
@@ -770,13 +851,26 @@ function mount(target, options = {}) {
770
851
  host.addEventListener('message', onWindowMessage); iframe.addEventListener('load', onLoad);
771
852
  iframe.src = source.href;
772
853
  if (iframe !== target) target.append(iframe);
773
- if (host.MutationObserver) {
854
+ function watch() {
855
+ if (!host.MutationObserver) return;
856
+ watcher?.disconnect();
774
857
  let attached = iframe.isConnected;
775
- watcher = new host.MutationObserver(() => { if (iframe.isConnected) attached = true; else if (attached) end(true); });
858
+ watcher = new host.MutationObserver(() => {
859
+ if (!iframe.isConnected) { if (attached) end(true); return; }
860
+ attached = true;
861
+ observeRoots();
862
+ });
863
+ const observeRoots = () => {
864
+ for (let node = iframe; node;) {
865
+ const root = node.getRootNode();
866
+ if (root !== host.document && root.nodeType === 11) watcher.observe(root, {childList: true, subtree: true});
867
+ node = root.host;
868
+ }
869
+ };
776
870
  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});
871
+ observeRoots();
779
872
  }
873
+ watch();
780
874
  loadTimer = setTimeout(() => bootFailed('load_timeout', 'The ' + noun + ' did not finish loading within ' + loadTimeout / 1000 + ' seconds. Check the address and the network.'), loadTimeout);
781
875
  return handle;
782
876
  }
@@ -807,12 +901,19 @@ function joinForm(field, form) {
807
901
  } catch (error) { if (current()) for (const {field} of captured) field._error(error); }
808
902
  finally { group.pending = false; group.releasing = false; }
809
903
  };
810
- group.view = form.ownerDocument.defaultView; group.view.addEventListener('submit', group.submit, true); forms.set(form, group);
904
+ forms.set(form, group);
905
+ }
906
+ const root = form.getRootNode?.();
907
+ const next = !root || root === form.ownerDocument ? form.ownerDocument.defaultView : root;
908
+ if (group.view !== next) {
909
+ group.view?.removeEventListener('submit', group.submit, true);
910
+ group.view = next;
911
+ next?.addEventListener('submit', group.submit, true);
811
912
  }
812
913
  group.fields.add(field);
813
914
  return () => {
814
915
  group.fields.delete(field);
815
- if (!group.fields.size) { group.view.removeEventListener('submit', group.submit, true); forms.delete(form); }
916
+ if (!group.fields.size) { group.view?.removeEventListener('submit', group.submit, true); forms.delete(form); }
816
917
  };
817
918
  }
818
919
 
@@ -846,23 +947,62 @@ export class RapierEditor extends (globalThis.HTMLElement || class {}) {
846
947
  this._initialized = true;
847
948
  }
848
949
  this._disabled = this.matches(':disabled'); this.tabIndex = this._disabled ? -1 : 0;
849
- this._media ||= this.ownerDocument.defaultView.matchMedia('(max-width:600px)');
850
- this._onMedia ||= () => this._layout(); this._media.addEventListener('change', this._onMedia);
950
+ if (!this._onPageHide) {
951
+ this._onPageHide = () => {
952
+ if (this._disabled) return;
953
+ this._setFormValue();
954
+ };
955
+ this._onPageShow = () => { if (!this._disabled) this._updateValue(); };
956
+ }
957
+ const bindPageLifecycle = () => {
958
+ if (!this._onPageHide) return;
959
+ const view = this.isConnected ? this.ownerDocument.defaultView : null;
960
+ if (this._view === view) return;
961
+ this._view?.removeEventListener('pagehide', this._onPageHide);
962
+ this._view?.removeEventListener('pageshow', this._onPageShow);
963
+ this._media?.removeEventListener('change', this._onMedia);
964
+ this._view = view;
965
+ this._media = view ? view.matchMedia('(max-width:600px)') : null;
966
+ view?.addEventListener('pagehide', this._onPageHide);
967
+ view?.addEventListener('pageshow', this._onPageShow);
968
+ if (this._media) {
969
+ this._onMedia ||= () => this._layout();
970
+ this._media.addEventListener('change', this._onMedia);
971
+ }
972
+ };
973
+ bindPageLifecycle();
974
+ queueMicrotask(bindPageLifecycle);
851
975
  this._bindForm(); this._updateValue(); this._layout(); if (this._panel.open) this._start();
852
976
  }
853
977
  disconnectedCallback() {
854
978
  this._formAuthority++;
855
979
  queueMicrotask(() => {
856
980
  if (this.isConnected) return;
857
- this._leaveForm?.(); this._leaveForm = null; this._media?.removeEventListener('change', this._onMedia);
858
- this._editor?.disconnect(); this._editor = null; this._mount.replaceChildren();
981
+ this._leaveForm?.(); this._leaveForm = null;
982
+ this._media?.removeEventListener('change', this._onMedia);
983
+ this._media = null;
984
+ this._view?.removeEventListener('pagehide', this._onPageHide);
985
+ this._view?.removeEventListener('pageshow', this._onPageShow);
986
+ this._view = null;
987
+ this._onPageHide = null; this._onPageShow = null;
988
+ const editor = this._editor;
989
+ this._generation++; this._editor = null; this._sync = Promise.resolve();
990
+ editor?.disconnect(); this._mount.replaceChildren();
859
991
  });
860
992
  }
861
993
  attributeChangedCallback(name) {
862
994
  if (!this._initialized) return;
863
995
  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));
996
+ else if (name === 'theme') {
997
+ const editor = this._editor;
998
+ editor?.theme(this.getAttribute('theme') || 'system').catch(error => { if (this._editor === editor) this._error(error); });
999
+ }
1000
+ else if (name === 'src') {
1001
+ const editor = this._editor, generation = this._generation, src = this.getAttribute('src');
1002
+ const current = () => this._editor === editor && this._generation === generation && this.getAttribute('src') === src;
1003
+ (editor?.connected ? this.save() : Promise.resolve()).then(() => { if (current()) this._remount(); })
1004
+ .catch(error => { if (current()) this._error(error); });
1005
+ }
866
1006
  else this._updateValue();
867
1007
  }
868
1008
  get value() { return this._value; }
@@ -884,7 +1024,7 @@ export class RapierEditor extends (globalThis.HTMLElement || class {}) {
884
1024
  _bindForm() { this._formAuthority++; this._leaveForm?.(); this._leaveForm = joinForm(this, this.form); }
885
1025
  formAssociatedCallback() { if (this._initialized && this.isConnected) this._bindForm(); }
886
1026
  formResetCallback() { this._replace(this._defaultValue); }
887
- formStateRestoreCallback(state) { if (typeof state === 'string') this._replace(state); }
1027
+ formStateRestoreCallback(state, mode) { if (mode === 'restore' && typeof state === 'string') this._replace(state); }
888
1028
  formDisabledCallback(disabled) {
889
1029
  const wasDisabled = this._disabled; this._disabled = disabled;
890
1030
  if (disabled !== wasDisabled) this._formAuthority++;
@@ -901,9 +1041,17 @@ export class RapierEditor extends (globalThis.HTMLElement || class {}) {
901
1041
  }).catch(error => { if (this._editor === editor) this._error(error); });
902
1042
  }
903
1043
  _filename() { return (this.getAttribute('name') || 'document').slice(0, 240) + '.md'; }
1044
+ _submissionFile() {
1045
+ const File = this.ownerDocument.defaultView?.File;
1046
+ return File ? new File([this._value], this._filename(), {type: 'text/markdown'}) : null;
1047
+ }
1048
+ _setFormValue() {
1049
+ const file = this._submissionFile();
1050
+ if (!file) return;
1051
+ this._internals.setFormValue(file, null);
1052
+ }
904
1053
  _updateValue() {
905
- const File = this.ownerDocument.defaultView.File;
906
- this._internals.setFormValue(new File([this._value], this._filename(), {type: 'text/markdown'}), this._value);
1054
+ this._setFormValue();
907
1055
  this._preview.textContent = this._value || 'Edit document'; this._preview.disabled = this._disabled;
908
1056
  if (this._fallback) this._fallback.value = this._value;
909
1057
  const missing = this.required && !this._value && !this._dirty;
@@ -911,34 +1059,52 @@ export class RapierEditor extends (globalThis.HTMLElement || class {}) {
911
1059
  this._internals.setValidity(error ? (this._customValidity ? {customError: true} : {valueMissing: true}) : {}, error, this._preview);
912
1060
  }
913
1061
  _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();
1062
+ this._value = value; this._dirty = false; this._revision = 0;
1063
+ this._ids = {sessionId: mint('field'), documentId: mint('field')};
1064
+ this._remount();
1065
+ }
1066
+ _remount() {
1067
+ const editor = this._editor;
1068
+ this._generation++; this._editor = null; this._sync = Promise.resolve();
1069
+ editor?.disconnect(); this._mount.replaceChildren(); this._updateValue();
916
1070
  if (this._initialized && this.isConnected && this._panel.open) this._start();
917
1071
  }
918
1072
  _start() {
919
1073
  if (this._editor) return;
1074
+ const load = {content: this._value, filename: this._filename(), revision: this._revision, readOnly: this._disabled};
1075
+ if (!this._ids) {
1076
+ const here = this.ownerDocument.defaultView.location;
1077
+ const auto = autoIdentity({load, save: true}, here, this.id);
1078
+ this._ids = {sessionId: 'page-' + auto, documentId: 'doc-' + auto};
1079
+ }
920
1080
  const generation = this._generation;
921
- this._editor = Rapier.mount(this._mount, {
1081
+ let editor;
1082
+ const current = () => this._generation === generation && this._editor === editor;
1083
+ editor = Rapier.mount(this._mount, {...this._ids,
922
1084
  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},
1085
+ load,
924
1086
  save: ({content, baseRevision}) => {
925
- if (this._generation !== generation) throw coded('field_replaced', Error, 'The form field was replaced');
1087
+ if (!current()) throw coded('field_replaced', Error, 'The form field was replaced');
926
1088
  if (baseRevision !== this._revision) throw conflict(this._revision);
927
1089
  this._value = content; this._revision++; this._dirty = false; this._updateValue();
1090
+ const revision = this._revision;
928
1091
  this.dispatchEvent(new Event('change', {bubbles: true, composed: true}));
929
- return {revision: this._revision};
1092
+ return {revision};
930
1093
  },
931
- onState: state => { this._dirty = state.dirty; this._updateValue(); },
932
- onError: error => this._error(error),
1094
+ onState: state => { if (current()) { this._dirty = state.dirty; this._updateValue(); } },
1095
+ onError: error => { if (current()) this._error(error); },
933
1096
  });
1097
+ this._editor = editor;
934
1098
  this._mount.inert = this._disabled;
935
1099
  }
936
1100
  _layout() {
937
- const narrow = this._media.matches;
1101
+ const narrow = this._media?.matches === true;
938
1102
  if (this._narrow === narrow) return;
939
1103
  this._narrow = narrow;
940
1104
  if (this._panel.open) this._panel.close();
941
- if (!narrow) { this._panel.setAttribute('open', ''); this._start(); }
1105
+ if (narrow || !this.ownerDocument.defaultView) return;
1106
+ this._panel.setAttribute('open', '');
1107
+ this._start();
942
1108
  }
943
1109
  focus(options) {
944
1110
  if (this._disabled) return;
@@ -946,13 +1112,18 @@ export class RapierEditor extends (globalThis.HTMLElement || class {}) {
946
1112
  this._start(); this._editor.iframe.focus(options);
947
1113
  }
948
1114
  async save() {
1115
+ const generation = this._generation;
949
1116
  await this._sync;
1117
+ if (generation !== this._generation) throw coded('field_replaced', Error, 'The form field was replaced');
950
1118
  if (this._editor && !this._disabled) await this._editor.save();
1119
+ if (generation !== this._generation) throw coded('field_replaced', Error, 'The form field was replaced');
951
1120
  return this._value;
952
1121
  }
953
1122
  async _finish() {
954
- try { await this.save(); this._panel.close(); this._preview.focus(); }
955
- catch (error) { this._error(error); }
1123
+ const generation = this._generation, authority = this._formAuthority, narrow = this._narrow;
1124
+ const current = () => this._generation === generation && this._formAuthority === authority && this._narrow === narrow && this.isConnected;
1125
+ try { await this.save(); if (current()) { this._panel.close(); if (!this._disabled) this._preview.focus(); } }
1126
+ catch (error) { if (current()) this._error(error); }
956
1127
  }
957
1128
  _error(error) { this.dispatchEvent(new CustomEvent('error', {detail: error, bubbles: true, composed: true})); }
958
1129
  }