rapier-embed 1.2.45 → 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/README.md +104 -40
- package/embed.d.mts +14 -6
- package/embed.mjs +190 -78
- package/package.json +1 -1
- package/rapier-plugins.json +195 -195
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.
|
|
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
|
|
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 (
|
|
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
|
|
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 =
|
|
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
|
-
|
|
418
|
-
|
|
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 =
|
|
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 ===
|
|
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
|
-
|
|
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(() =>
|
|
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
|
-
|
|
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 (
|
|
626
|
-
const
|
|
627
|
-
if (!
|
|
628
|
-
|
|
629
|
-
|
|
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);
|
|
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
|
-
|
|
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
|
-
|
|
658
|
-
|
|
659
|
-
|
|
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
|
|
668
|
-
const
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
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
|
-
|
|
681
|
-
|
|
682
|
-
rejectPending(error); connectedWait.reject(error); ready.reject(error);
|
|
683
|
-
host.removeEventListener('message', onWindowMessage);
|
|
684
|
-
|
|
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
|
|
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
|
|
714
|
-
|
|
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
|
|
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
|
|
791
|
+
async compare(content, {filename} = {}) { await whenBooted(); return send('compare', {content, ...(filename !== undefined ? {filename} : {})}); },
|
|
727
792
|
async close() {
|
|
728
|
-
await
|
|
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) {
|
|
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
|
|
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
|
|
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
|
-
|
|
753
|
-
if (!openElsewhere) slotLive.set(pairKey,
|
|
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
|
-
|
|
841
|
+
function watch() {
|
|
842
|
+
if (!host.MutationObserver) return;
|
|
843
|
+
watcher?.disconnect();
|
|
774
844
|
let attached = iframe.isConnected;
|
|
775
|
-
watcher = new host.MutationObserver(() => {
|
|
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
|
-
|
|
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
|
-
|
|
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')
|
|
865
|
-
|
|
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;
|
|
915
|
-
this.
|
|
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
|
-
|
|
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
|
|
1028
|
+
load,
|
|
924
1029
|
save: ({content, baseRevision}) => {
|
|
925
|
-
if (
|
|
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
|
|
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
|
-
|
|
955
|
-
|
|
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.
|
|
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",
|