rapier-embed 1.2.32 → 1.2.40
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 +97 -29
- package/cli.mjs +19 -6
- package/contract.d.mts +3 -0
- package/contract.mjs +16 -0
- package/embed.d.mts +34 -7
- package/embed.mjs +132 -24
- package/package.json +1 -1
- package/rapier-plugins.json +188 -188
package/embed.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// SPDX-License-Identifier: MIT
|
|
2
|
-
const {FEATURES, LIMITS, ASSET_TYPES, refusal, normalizeSettings, snapshotSettings, pickDeviceFile, createFilePicker, assetURL, assetBytes, bytesHash, externalImage, createAssetBroker} = (() => {
|
|
2
|
+
const {FEATURES, LIMITS, ASSET_TYPES, refusal, normalizeSettings, snapshotSettings, pickDeviceFile, createFilePicker, assetURL, assetBytes, bytesHash, externalImage, createAssetBroker, pictureBase} = (() => {
|
|
3
3
|
const FEATURES = Object.freeze(['draw', 'paint', 'notes', 'readAloud', 'share', 'find']);
|
|
4
4
|
const LIMITS = Object.freeze({documentBytes: 64 * 1024 * 1024, pictureBytes: 16 * 1024 * 1024});
|
|
5
5
|
const ASSET_TYPES = Object.freeze(['image/png', 'image/jpeg', 'image/webp', 'image/gif', 'image/jxl', 'image/svg+xml']);
|
|
@@ -238,22 +238,53 @@ function createAssetBroker({current, send}) {
|
|
|
238
238
|
function lookup(url) { scope(); return retained.get(url)?.asset || null; }
|
|
239
239
|
return Object.freeze({request, answer, cancel, lookup});
|
|
240
240
|
}
|
|
241
|
-
|
|
241
|
+
|
|
242
|
+
function pictureBase(value) {
|
|
243
|
+
if (value === undefined || value === null) return null;
|
|
244
|
+
let base;
|
|
245
|
+
try {
|
|
246
|
+
if (typeof value !== 'string' || !value || value.length > 4096 || /[\s\u0000-\u001f\u007f<>\\]/u.test(value)) throw 0;
|
|
247
|
+
base = new URL(value);
|
|
248
|
+
if (base.username || base.password || !(base.protocol === 'https:' || base.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(base.hostname))) throw 0;
|
|
249
|
+
} catch (_) { throw refusal('invalid_base_url', 'baseUrl is an absolute https address, or http on localhost.'); }
|
|
250
|
+
const vouched = new Set();
|
|
251
|
+
return Object.assign(src => {
|
|
252
|
+
const text = String(src || '').trim();
|
|
253
|
+
if (!text || /^(?:[a-z][a-z0-9+.-]*:|\/\/|#)/i.test(text)) return null;
|
|
254
|
+
try { const href = new URL(text, base).href; vouched.add(href); return href; } catch (_) { return null; }
|
|
255
|
+
}, {vouched});
|
|
256
|
+
}
|
|
257
|
+
return {FEATURES, LIMITS, ASSET_TYPES, refusal, normalizeSettings, snapshotSettings, pickDeviceFile, createFilePicker, assetURL, assetBytes, bytesHash, externalImage, createAssetBroker, pictureBase};
|
|
242
258
|
})();
|
|
243
259
|
const BUILDS = Object.freeze({full: 'https://rapier.website/embed/rapier.html', reader: 'https://rapier.website/embed/rapier-reader.html'});
|
|
260
|
+
const PACKAGE_VERSION = '1.2.40';
|
|
261
|
+
const OPTIONS = ['build', 'src', 'plugins', 'approvePlugins', 'sessionId', 'documentId', 'agent', 'agentName', 'theme', 'save', 'assets', 'openFile', 'load',
|
|
262
|
+
'onState', 'compare', 'onClose', 'settings', 'style', 'title', 'onError', 'timeout'];
|
|
263
|
+
const READER_FEATURES = ['find', 'readAloud', 'share'];
|
|
244
264
|
const DEFAULT_SRC = BUILDS.full;
|
|
245
265
|
const revision = value => Number.isSafeInteger(value) && value >= 0 || typeof value === 'string' && value.length > 0 && value.length <= 256;
|
|
246
266
|
const identifier = value => typeof value === 'string' && value.length > 0 && value.length <= 256;
|
|
247
267
|
const record = value => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
248
268
|
const themeValue = value => ['light', 'dark', 'system'].includes(value);
|
|
249
269
|
const mint = prefix => prefix + '-' + (globalThis.crypto?.randomUUID?.() || Math.random().toString(36).slice(2));
|
|
270
|
+
const autoLive = new Map();
|
|
271
|
+
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); };
|
|
272
|
+
function autoIdentity(options) {
|
|
273
|
+
const load = options.load;
|
|
274
|
+
if (!options.save || !load || typeof load !== 'object' || typeof load.content !== 'string') return null;
|
|
275
|
+
const base = fnv(location.origin + location.pathname) + '-' + fnv(String(load.filename ?? '') + '|' + load.content.length + '|' + load.content.slice(0, 2048));
|
|
276
|
+
let at = 0;
|
|
277
|
+
while (autoLive.get(base + '-' + at)?.()) at++;
|
|
278
|
+
return base + '-' + at;
|
|
279
|
+
}
|
|
250
280
|
function deferred() {
|
|
251
281
|
let resolve, reject;
|
|
252
282
|
const promise = new Promise((yes, no) => { resolve = yes; reject = no; });
|
|
253
283
|
promise.catch(() => {});
|
|
254
284
|
return {promise, resolve, reject};
|
|
255
285
|
}
|
|
256
|
-
const refused = payload => Object.assign(new Error(payload?.reason || payload?.code || 'The editor refused the request'), {code: payload?.code, payload}
|
|
286
|
+
const refused = payload => Object.assign(new Error(payload?.reason || payload?.code || 'The editor refused the request'), {code: payload?.code, payload},
|
|
287
|
+
revision(payload?.currentRevision) ? {currentRevision: payload.currentRevision} : {});
|
|
257
288
|
|
|
258
289
|
function conflict(currentRevision) {
|
|
259
290
|
if (!revision(currentRevision)) throw new TypeError('A conflict needs the current revision');
|
|
@@ -270,23 +301,43 @@ function readerStyle(value) {
|
|
|
270
301
|
return {...(value.css !== undefined ? {css: value.css} : {}), ...(value.fonts !== undefined ? {fonts: value.fonts.map(row => ({...row}))} : {})};
|
|
271
302
|
}
|
|
272
303
|
|
|
304
|
+
const HOSTED_ORIGIN = new URL(BUILDS.full).origin;
|
|
305
|
+
function framingProblem(host, source, sandbox, noun) {
|
|
306
|
+
const here = host.location, local = ['localhost', '127.0.0.1', '[::1]'].includes(here.hostname);
|
|
307
|
+
if (here.protocol !== 'https:' && here.protocol !== 'http:')
|
|
308
|
+
return {code: 'host_scheme_unsupported', reason: 'The ' + noun + ' cannot be framed by a ' + here.protocol + ' page: it needs an https page, or http on localhost. Serve your app over one of those.'};
|
|
309
|
+
if (source.origin === HOSTED_ORIGIN && here.protocol === 'http:' && !local)
|
|
310
|
+
return {code: 'host_not_secure', reason: 'The hosted ' + noun + ' can be framed by https pages and by http on localhost only. Serve your app over https, or serve a copy of the page yourself and set src.'};
|
|
311
|
+
if (source.origin === HOSTED_ORIGIN && host.crossOriginIsolated === true)
|
|
312
|
+
return {code: 'host_cross_origin_isolated', reason: 'This page is cross-origin isolated (COEP), which blocks the hosted ' + noun + '. Serve a copy of the page yourself with the Cross-Origin-Embedder-Policy and Cross-Origin-Resource-Policy headers and set src, or drop COEP.'};
|
|
313
|
+
if (sandbox) for (const token of ['allow-scripts', 'allow-same-origin']) if (!sandbox.contains(token))
|
|
314
|
+
return {code: 'sandbox_token_missing', reason: 'The iframe you passed has a sandbox without ' + token + ', which the ' + noun + ' needs. Add the token, or pass a container and let mount create the iframe.'};
|
|
315
|
+
return null;
|
|
316
|
+
}
|
|
317
|
+
|
|
273
318
|
function mount(target, options = {}) {
|
|
274
319
|
const document = target?.ownerDocument;
|
|
275
320
|
if (!document) throw new TypeError('Mount needs an iframe or a container element');
|
|
321
|
+
const unknown = Object.keys(options).filter(key => !OPTIONS.includes(key));
|
|
322
|
+
if (unknown.length) throw Object.assign(new TypeError('Unknown option: ' + unknown.join(', ') + '. Mount takes ' + OPTIONS.join(', ')), {code: 'option_unknown'});
|
|
276
323
|
const build = options.build === undefined || options.build === 'document' ? 'full' : options.build;
|
|
277
324
|
if (!Object.hasOwn(BUILDS, build)) throw new TypeError('build must be full or reader');
|
|
278
|
-
const reader = build === 'reader';
|
|
325
|
+
const reader = build === 'reader', noun = reader ? 'reader' : 'editor';
|
|
279
326
|
if (reader && ['save', 'compare', 'onClose', 'agent', 'agentName', 'assets'].some(key => options[key] !== undefined && options[key] !== false))
|
|
280
327
|
throw new TypeError('The reader takes load, onState, theme, settings, style and plugins');
|
|
281
328
|
if (!reader && options.style !== undefined) throw new TypeError('style restyles the reader build');
|
|
282
329
|
if (options.style !== undefined && options.load === undefined) throw new TypeError('Reader styling requires load');
|
|
283
330
|
if (options.plugins !== undefined && (typeof options.plugins !== 'string' || !options.plugins || options.plugins.length > 2048 || /[\u0000-\u001f\u007f]/.test(options.plugins)))
|
|
284
331
|
throw new TypeError('plugins is the address of the directory that holds the plug-in files');
|
|
332
|
+
if (options.approvePlugins !== undefined && typeof options.approvePlugins !== 'boolean') throw new TypeError('approvePlugins is true or false');
|
|
285
333
|
let currentStyle = options.style === undefined ? undefined : readerStyle(options.style);
|
|
286
334
|
const host = document.defaultView, iframe = target.tagName === 'IFRAME' ? target : document.createElement('iframe');
|
|
287
335
|
const source = new URL(options.src || BUILDS[build], host.location.href);
|
|
288
336
|
if (source.username || source.password || !(source.protocol === 'https:' || source.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(source.hostname)))
|
|
289
337
|
throw new TypeError('The editor needs HTTPS, or HTTP on localhost');
|
|
338
|
+
const page = source.pathname.split('/').pop();
|
|
339
|
+
if (reader ? page === 'rapier.html' : page === 'rapier-reader.html')
|
|
340
|
+
throw Object.assign(new TypeError('build ' + build + ' cannot frame ' + page + '; set build: \'' + (reader ? 'full' : 'reader') + '\' or src to ' + (reader ? 'rapier-reader.html' : 'rapier.html')), {code: 'build_mismatch'});
|
|
290
341
|
source.searchParams.set('embed', '1');
|
|
291
342
|
if (options.plugins !== undefined) {
|
|
292
343
|
let directory;
|
|
@@ -297,7 +348,9 @@ function mount(target, options = {}) {
|
|
|
297
348
|
throw new TypeError('plugins must be on ' + source.origin + ', the origin of the page it configures: the page reads plug-ins only from its own origin. Serve the page and its plug-ins together (set src), or leave plugins out');
|
|
298
349
|
source.searchParams.set('plugins', directory.href);
|
|
299
350
|
}
|
|
300
|
-
|
|
351
|
+
if (options.approvePlugins === true) source.searchParams.set('approvePlugins', '1');
|
|
352
|
+
const origin = source.origin, auto = options.sessionId === undefined && options.documentId === undefined ? autoIdentity(options) : null;
|
|
353
|
+
const sessionId = options.sessionId ?? (auto ? 'page-' + auto : mint('session')), documentId = options.documentId ?? (auto ? 'doc-' + auto : mint('document'));
|
|
301
354
|
if (!identifier(sessionId) || !identifier(documentId)) throw new TypeError('Session and document IDs must be nonempty strings of at most 256 code units');
|
|
302
355
|
const agentName = options.agentName;
|
|
303
356
|
if (agentName !== undefined && (typeof agentName !== 'string' || !agentName.trim() || agentName.length > 96 || /[\u0000-\u001f\u007f]/.test(agentName))) throw new TypeError('agentName must be a host-given name of at most 96 characters');
|
|
@@ -305,7 +358,11 @@ function mount(target, options = {}) {
|
|
|
305
358
|
if (options.save !== undefined && typeof options.save !== 'function') throw new TypeError('save must be a durable storage callback');
|
|
306
359
|
if (options.assets !== undefined && typeof options.assets !== 'function') throw new TypeError('assets must be a picture storage callback');
|
|
307
360
|
if (options.openFile !== undefined && typeof options.openFile !== 'function') throw new TypeError('openFile must choose a File or null');
|
|
361
|
+
if (options.timeout !== undefined && !(Number.isSafeInteger(options.timeout) && options.timeout > 0)) throw new TypeError('timeout is a whole number of milliseconds');
|
|
362
|
+
const bootTimeout = options.timeout ?? 15000;
|
|
308
363
|
const settings = snapshotSettings(options.settings), policy = normalizeSettings(settings), storeAsset = options.assets;
|
|
364
|
+
const unread = reader ? (settings?.features || []).filter(name => !READER_FEATURES.includes(name)) : [];
|
|
365
|
+
if (unread.length) throw Object.assign(new TypeError('The reader has no ' + unread.join(', ') + '; its features are ' + READER_FEATURES.join(', ')), {code: 'settings_feature_unavailable'});
|
|
309
366
|
const assetAnswers = new Map();
|
|
310
367
|
let assetPending = '', assetAbort = new AbortController();
|
|
311
368
|
const chooseFile = options.openFile || (({signal}) => pickDeviceFile(document, signal)), openAnswers = new Map();
|
|
@@ -319,7 +376,8 @@ function mount(target, options = {}) {
|
|
|
319
376
|
throw new TypeError('Agent access needs both load and save');
|
|
320
377
|
const ids = {sessionId, documentId}, answers = new Map(), waiting = new Map(), listeners = new Map();
|
|
321
378
|
let port = null, posted = false, connected = false, ended = false, sawLoad = false, seq = 0, activeSave = null, activeClose = null;
|
|
322
|
-
let currentTheme = options.theme, lastSaved = null, readOnly = false, connectedWait = deferred(), boot;
|
|
379
|
+
let currentTheme = options.theme, lastSaved = null, readOnly = false, connectedWait = deferred(), boot, bootTimer = 0, frameFacts = {};
|
|
380
|
+
let held = null;
|
|
323
381
|
const ready = deferred();
|
|
324
382
|
const emit = (type, value) => { for (const fn of listeners.get(type) || []) fn(value); };
|
|
325
383
|
const fail = error => { emit('error', error); options.onError?.(error); };
|
|
@@ -328,7 +386,7 @@ function mount(target, options = {}) {
|
|
|
328
386
|
waiting.clear(); activeSave?.reject(error); activeSave = null; activeClose?.reject(error); activeClose = null;
|
|
329
387
|
};
|
|
330
388
|
function send(type, payload, baseRevision, quiet = false) {
|
|
331
|
-
if (ended || !connected || !port) return Promise.reject(new Error('The
|
|
389
|
+
if (ended || !connected || !port) return Promise.reject(new Error('The ' + noun + ' connection has ended or is not connected'));
|
|
332
390
|
if (type === 'load') cancelOpen('open_stale');
|
|
333
391
|
const requestId = 'host-' + ++seq;
|
|
334
392
|
const message = {type, ...ids, requestId, ...(baseRevision !== undefined ? {baseRevision} : {}), ...(payload !== undefined ? {payload} : {})};
|
|
@@ -339,6 +397,7 @@ function mount(target, options = {}) {
|
|
|
339
397
|
return pending.promise;
|
|
340
398
|
}
|
|
341
399
|
function requestCapture() {
|
|
400
|
+
if (activeSave) activeSave.commandId = 'host-' + (seq + 1);
|
|
342
401
|
send('save', undefined, undefined, true).catch(error => { activeSave?.reject(error); activeSave = null; });
|
|
343
402
|
}
|
|
344
403
|
async function answerSave(message, channel) {
|
|
@@ -379,7 +438,7 @@ function mount(target, options = {}) {
|
|
|
379
438
|
if (ended || channel !== port) return;
|
|
380
439
|
channel.postMessage(reply);
|
|
381
440
|
if (reply.type === 'save-ack') {
|
|
382
|
-
lastSaved = reply.payload;
|
|
441
|
+
lastSaved = reply.payload; held = {revision: reply.payload.revision, content: payload.content, filename: payload.filename};
|
|
383
442
|
if (activeSave) activeSave.acknowledged = true;
|
|
384
443
|
} else if (activeSave) { activeSave.reject(refused(reply.payload)); activeSave = null; }
|
|
385
444
|
}
|
|
@@ -495,7 +554,9 @@ function mount(target, options = {}) {
|
|
|
495
554
|
}
|
|
496
555
|
if (type === 'connected') {
|
|
497
556
|
if (connected) return;
|
|
498
|
-
|
|
557
|
+
clearTimeout(bootTimer);
|
|
558
|
+
connected = true; frameFacts = {version: typeof payload.appVersion === 'string' ? payload.appVersion : null, contract: payload.contract ?? null};
|
|
559
|
+
connectedWait.resolve(); emit('connected', payload); return;
|
|
499
560
|
}
|
|
500
561
|
if (!connected) return;
|
|
501
562
|
if (type === 'open-request' || type === 'open-cancel') { answerOpen(message, channel); return; }
|
|
@@ -505,6 +566,10 @@ function mount(target, options = {}) {
|
|
|
505
566
|
if (!['loaded', 'dirty', 'saving', 'closing', 'readOnly'].every(key => typeof payload[key] === 'boolean')
|
|
506
567
|
|| Object.keys(payload).some(key => !['loaded', 'dirty', 'saving', 'closing', 'readOnly', 'filename', 'docKind'].includes(key))) return;
|
|
507
568
|
readOnly = payload.readOnly; emit('state', payload); options.onState?.(payload);
|
|
569
|
+
if (activeSave && !activeSave.acknowledged && activeSave.commandId && message.requestId === activeSave.commandId && payload.loaded && !payload.dirty && !payload.saving && revision(message.baseRevision)) {
|
|
570
|
+
activeSave.resolve({revision: message.baseRevision}); activeSave = null;
|
|
571
|
+
return;
|
|
572
|
+
}
|
|
508
573
|
if (activeSave?.acknowledged && !payload.saving && message.baseRevision === lastSaved?.revision) {
|
|
509
574
|
if (payload.dirty) { activeSave.acknowledged = false; requestCapture(); }
|
|
510
575
|
else { activeSave.resolve(lastSaved); activeSave = null; }
|
|
@@ -530,7 +595,9 @@ function mount(target, options = {}) {
|
|
|
530
595
|
}
|
|
531
596
|
if (pending && ['load-ack', 'load-nack', 'compare-ack', 'compare-nack'].includes(type)) {
|
|
532
597
|
waiting.delete(requestId);
|
|
533
|
-
if (type === 'load-ack')
|
|
598
|
+
if (type === 'load-ack') {
|
|
599
|
+
readOnly = payload.readOnly === true;
|
|
600
|
+
}
|
|
534
601
|
if (type.endsWith('-nack')) pending.reject(refused(payload)); else pending.resolve(payload);
|
|
535
602
|
}
|
|
536
603
|
}
|
|
@@ -541,6 +608,7 @@ function mount(target, options = {}) {
|
|
|
541
608
|
const value = typeof options.load === 'function' ? await options.load() : options.load;
|
|
542
609
|
if (!record(value) || typeof value.content !== 'string' || !revision(value.revision)) throw new TypeError('load needs content and its revision');
|
|
543
610
|
await send('load', value, null);
|
|
611
|
+
held = {revision: value.revision, content: value.content, filename: value.filename};
|
|
544
612
|
}
|
|
545
613
|
ready.resolve(handle);
|
|
546
614
|
});
|
|
@@ -553,24 +621,42 @@ function mount(target, options = {}) {
|
|
|
553
621
|
port.onmessage = event => onPortMessage(event, channel.port1);
|
|
554
622
|
port.onmessageerror = () => {
|
|
555
623
|
if (channel.port1 !== port || ended) return;
|
|
556
|
-
cancelOpen('open_disconnected'); assetAbort.abort(); rejectPending(new Error('The
|
|
624
|
+
cancelOpen('open_disconnected'); assetAbort.abort(); rejectPending(new Error('The ' + noun + ' connection was lost'));
|
|
557
625
|
};
|
|
558
626
|
try { iframe.contentWindow.postMessage({type: 'rapier-connect', ...ids, capabilities, contract: 1, ...(settings ? {settings} : {}), ...(agentName ? {agent: {name: agentName}} : {}), ...(currentTheme ? {theme: currentTheme} : {})}, origin, [channel.port2]); }
|
|
559
627
|
catch (error) { port.close(); port = null; posted = false; fail(error); }
|
|
560
628
|
}
|
|
629
|
+
function bootFailed(code, reason) {
|
|
630
|
+
clearTimeout(bootTimer);
|
|
631
|
+
if (connected || ended) return;
|
|
632
|
+
connectedWait.reject(refusal(code, reason));
|
|
633
|
+
}
|
|
634
|
+
const connectTimeoutReason = () => 'The ' + noun + ' did not connect within ' + bootTimeout / 1000 + ' seconds. A page that does not allow this parent (frame-ancestors) is blocked and never connects; the hosted pages allow https, localhost and 127.0.0.1 parents.';
|
|
561
635
|
function onLoad() {
|
|
562
636
|
if (sawLoad && posted) {
|
|
563
637
|
cancelOpen('open_disconnected');
|
|
564
|
-
rejectPending(new Error('The
|
|
638
|
+
rejectPending(new Error('The ' + noun + ' reconnected')); port?.close();
|
|
565
639
|
assetAbort.abort(refusal('asset_disconnected')); assetAbort = new AbortController();
|
|
566
640
|
connected = false; posted = false; readOnly = false; connectedWait = deferred(); prepareBoot();
|
|
567
641
|
}
|
|
568
642
|
sawLoad = true;
|
|
643
|
+
clearTimeout(bootTimer);
|
|
644
|
+
if (!connected) bootTimer = setTimeout(() => bootFailed('connect_timeout', connectTimeoutReason()), bootTimeout);
|
|
569
645
|
connect();
|
|
570
646
|
}
|
|
647
|
+
function focusBeside(forward) {
|
|
648
|
+
const order = forward ? host.Node.DOCUMENT_POSITION_FOLLOWING : host.Node.DOCUMENT_POSITION_PRECEDING;
|
|
649
|
+
const controls = [...document.querySelectorAll('a[href],button,input,select,textarea,iframe,summary,[tabindex],[contenteditable=""],[contenteditable="true"]')]
|
|
650
|
+
.filter(element => element !== iframe && element.tabIndex >= 0 && !element.disabled && !element.closest('[inert]') && element.getClientRects().length &&
|
|
651
|
+
iframe.compareDocumentPosition(element) & order);
|
|
652
|
+
const next = forward ? controls[0] : controls.at(-1);
|
|
653
|
+
if (next) next.focus(); else iframe.blur();
|
|
654
|
+
}
|
|
571
655
|
function onWindowMessage(event) {
|
|
572
656
|
if (event.source !== iframe.contentWindow || event.origin !== origin) return;
|
|
573
657
|
if (event.data?.type === 'rapier-ready') connect();
|
|
658
|
+
else if (event.data?.type === 'rapier-boot-failed') bootFailed('boot_failed', 'The ' + noun + ' could not start');
|
|
659
|
+
else if (event.data?.type === 'rapier-focus-leave' && connected) focusBeside(event.data.backward !== true);
|
|
574
660
|
else if (event.data?.type === 'close-ready' && event.data.sessionId === sessionId && event.data.documentId === documentId && options.onClose) {
|
|
575
661
|
activeClose?.resolve(event.data.payload); activeClose = null; emit('closed', event.data.payload);
|
|
576
662
|
}
|
|
@@ -578,17 +664,30 @@ function mount(target, options = {}) {
|
|
|
578
664
|
const handle = Object.freeze({
|
|
579
665
|
iframe, ready: ready.promise,
|
|
580
666
|
get connected() { return connected && !ended; },
|
|
581
|
-
|
|
667
|
+
get version() { return frameFacts.version ?? null; },
|
|
668
|
+
get contract() { return frameFacts.contract ?? null; },
|
|
669
|
+
async load(content, options = {}) {
|
|
670
|
+
if (record(content)) { options = content; content = options.content; }
|
|
671
|
+
if (typeof content !== 'string') throw Object.assign(new TypeError('load needs the document text as a string'), {code: 'invalid_payload'});
|
|
672
|
+
let {filename, revision: version, readOnly, title, baseUrl} = options;
|
|
582
673
|
cancelOpen('open_stale');
|
|
583
674
|
await boot;
|
|
584
|
-
|
|
675
|
+
if (version === undefined) version = typeof held?.revision === 'number' ? held.revision + 1 : 1;
|
|
676
|
+
const sameDocument = held && (filename === undefined || held.filename === undefined || filename === held.filename);
|
|
677
|
+
if (sameDocument && typeof version === 'number' && typeof held.revision === 'number' && version < held.revision)
|
|
678
|
+
throw Object.assign(new Error('Revision ' + version + ' is older than the frame\'s ' + held.revision), {code: 'revision_stale', currentRevision: held.revision});
|
|
679
|
+
if (sameDocument && version === held.revision && held.content !== null && content !== held.content)
|
|
680
|
+
throw Object.assign(new Error('Revision ' + version + ' already names other content'), {code: 'revision_conflict', currentRevision: held.revision});
|
|
681
|
+
const loaded = await send('load', {content, revision: version, ...(filename !== undefined ? {filename} : {}), ...(readOnly !== undefined ? {readOnly} : {}), ...(title !== undefined ? {title} : {}), ...(baseUrl !== undefined ? {baseUrl} : {})}, null);
|
|
682
|
+
held = {revision: version, content, filename};
|
|
683
|
+
return loaded;
|
|
585
684
|
},
|
|
586
685
|
async save() {
|
|
587
686
|
await boot;
|
|
588
687
|
if (!options.save) throw new Error('No save callback was granted');
|
|
589
|
-
if (ended) throw new Error('The
|
|
688
|
+
if (ended) throw new Error('The ' + noun + ' connection has ended');
|
|
590
689
|
if (readOnly) throw new Error('The document is read-only');
|
|
591
|
-
if (!activeSave) activeSave = {...deferred(), acknowledged: false};
|
|
690
|
+
if (!activeSave) activeSave = {...deferred(), acknowledged: false, commandId: ''};
|
|
592
691
|
const promise = activeSave.promise;
|
|
593
692
|
requestCapture(); return promise;
|
|
594
693
|
},
|
|
@@ -611,10 +710,10 @@ function mount(target, options = {}) {
|
|
|
611
710
|
disconnect() {
|
|
612
711
|
if (ended) return;
|
|
613
712
|
if (connected) send('disconnect', undefined, undefined, true).catch(() => {});
|
|
614
|
-
ended = true; connected = false;
|
|
713
|
+
ended = true; connected = false; clearTimeout(bootTimer);
|
|
615
714
|
cancelOpen('open_disconnected'); openAnswers.clear();
|
|
616
715
|
assetAbort.abort(refusal('asset_disconnected')); assetAnswers.clear();
|
|
617
|
-
const error = new Error('The
|
|
716
|
+
const error = new Error('The ' + noun + ' connection has ended');
|
|
618
717
|
rejectPending(error); connectedWait.reject(error); ready.reject(error);
|
|
619
718
|
host.removeEventListener('message', onWindowMessage); iframe.removeEventListener('load', onLoad);
|
|
620
719
|
port?.close(); port = null; listeners.clear();
|
|
@@ -626,17 +725,26 @@ function mount(target, options = {}) {
|
|
|
626
725
|
return () => listeners.get(type)?.delete(fn);
|
|
627
726
|
},
|
|
628
727
|
});
|
|
728
|
+
if (auto) autoLive.set(auto, () => iframe.isConnected && !ended);
|
|
629
729
|
prepareBoot();
|
|
630
730
|
iframe.setAttribute('title', options.title || (reader ? 'Markdown reader' : 'Markdown editor'));
|
|
631
731
|
iframe.setAttribute('allow', options.agent ? 'clipboard-write; tools' : 'clipboard-write');
|
|
632
|
-
iframe.
|
|
732
|
+
const ownSandbox = iframe === target && iframe.hasAttribute('sandbox');
|
|
733
|
+
if (!ownSandbox) iframe.setAttribute('sandbox', 'allow-scripts allow-same-origin allow-downloads');
|
|
734
|
+
const blocked = framingProblem(host, source, ownSandbox ? iframe.sandbox : null, noun);
|
|
735
|
+
if (blocked) {
|
|
736
|
+
if (iframe !== target) target.append(iframe);
|
|
737
|
+
queueMicrotask(() => bootFailed(blocked.code, blocked.reason));
|
|
738
|
+
return handle;
|
|
739
|
+
}
|
|
633
740
|
host.addEventListener('message', onWindowMessage); iframe.addEventListener('load', onLoad);
|
|
634
741
|
iframe.src = source.href;
|
|
635
742
|
if (iframe !== target) target.append(iframe);
|
|
743
|
+
bootTimer = setTimeout(() => bootFailed('connect_timeout', connectTimeoutReason()), bootTimeout);
|
|
636
744
|
return handle;
|
|
637
745
|
}
|
|
638
746
|
|
|
639
|
-
export const Rapier = Object.freeze({mount, conflict});
|
|
747
|
+
export const Rapier = Object.freeze({mount, conflict, version: PACKAGE_VERSION, contract: 1});
|
|
640
748
|
|
|
641
749
|
const forms = new WeakMap();
|
|
642
750
|
function joinForm(field, form) {
|
|
@@ -645,8 +753,8 @@ function joinForm(field, form) {
|
|
|
645
753
|
if (!group) {
|
|
646
754
|
group = {fields: new Set(), pending: false, releasing: false};
|
|
647
755
|
group.submit = async event => {
|
|
648
|
-
if (group.releasing) return;
|
|
649
|
-
event.preventDefault();
|
|
756
|
+
if (event.target !== form || group.releasing) return;
|
|
757
|
+
event.preventDefault(); event.stopImmediatePropagation();
|
|
650
758
|
if (group.pending) return;
|
|
651
759
|
group.pending = true;
|
|
652
760
|
const submitter = event.submitter;
|
|
@@ -662,12 +770,12 @@ function joinForm(field, form) {
|
|
|
662
770
|
} catch (error) { if (current()) for (const {field} of captured) field._error(error); }
|
|
663
771
|
finally { group.pending = false; group.releasing = false; }
|
|
664
772
|
};
|
|
665
|
-
form.addEventListener('submit', group.submit, true); forms.set(form, group);
|
|
773
|
+
group.view = form.ownerDocument.defaultView; group.view.addEventListener('submit', group.submit, true); forms.set(form, group);
|
|
666
774
|
}
|
|
667
775
|
group.fields.add(field);
|
|
668
776
|
return () => {
|
|
669
777
|
group.fields.delete(field);
|
|
670
|
-
if (!group.fields.size) {
|
|
778
|
+
if (!group.fields.size) { group.view.removeEventListener('submit', group.submit, true); forms.delete(form); }
|
|
671
779
|
};
|
|
672
780
|
}
|
|
673
781
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "rapier-embed",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.40",
|
|
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",
|