rapier-embed 1.2.40 → 1.2.42
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 +36 -22
- package/contract.d.mts +2 -0
- package/contract.mjs +1 -0
- package/embed.d.mts +2 -2
- package/embed.mjs +102 -69
- package/package.json +1 -1
- package/rapier-plugins.json +205 -205
package/embed.mjs
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
// SPDX-License-Identifier: MIT
|
|
2
|
-
const {FEATURES, LIMITS, ASSET_TYPES, refusal, normalizeSettings, snapshotSettings, pickDeviceFile, createFilePicker, assetURL, assetBytes, bytesHash, externalImage, createAssetBroker, pictureBase} = (() => {
|
|
2
|
+
const {FEATURES, LIMITS, ASSET_TYPES, refusal, coded, 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']);
|
|
6
6
|
const ACCENTS = Object.freeze(['Teal', 'Blue', 'Amber', 'Green', 'Red', 'Purple', 'Pink', 'Gray']);
|
|
7
7
|
const refusal = (code, reason = code) => Object.assign(new Error(reason), {code});
|
|
8
|
+
const coded = (code, Type, message) => Object.assign(new Type(message), {code});
|
|
8
9
|
const record = value => value !== null && typeof value === 'object' && !Array.isArray(value) &&
|
|
9
10
|
(Object.getPrototypeOf(value) === null || Object.getPrototypeOf(Object.getPrototypeOf(value)) === null);
|
|
10
11
|
const only = (value, keys) => Object.keys(value).every(key => keys.includes(key));
|
|
@@ -254,20 +255,20 @@ function pictureBase(value) {
|
|
|
254
255
|
try { const href = new URL(text, base).href; vouched.add(href); return href; } catch (_) { return null; }
|
|
255
256
|
}, {vouched});
|
|
256
257
|
}
|
|
257
|
-
return {FEATURES, LIMITS, ASSET_TYPES, refusal, normalizeSettings, snapshotSettings, pickDeviceFile, createFilePicker, assetURL, assetBytes, bytesHash, externalImage, createAssetBroker, pictureBase};
|
|
258
|
+
return {FEATURES, LIMITS, ASSET_TYPES, refusal, coded, normalizeSettings, snapshotSettings, pickDeviceFile, createFilePicker, assetURL, assetBytes, bytesHash, externalImage, createAssetBroker, pictureBase};
|
|
258
259
|
})();
|
|
259
260
|
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.
|
|
261
|
+
const PACKAGE_VERSION = '1.2.42';
|
|
261
262
|
const OPTIONS = ['build', 'src', 'plugins', 'approvePlugins', 'sessionId', 'documentId', 'agent', 'agentName', 'theme', 'save', 'assets', 'openFile', 'load',
|
|
262
263
|
'onState', 'compare', 'onClose', 'settings', 'style', 'title', 'onError', 'timeout'];
|
|
263
264
|
const READER_FEATURES = ['find', 'readAloud', 'share'];
|
|
264
265
|
const DEFAULT_SRC = BUILDS.full;
|
|
265
266
|
const revision = value => Number.isSafeInteger(value) && value >= 0 || typeof value === 'string' && value.length > 0 && value.length <= 256;
|
|
266
|
-
const identifier = value => typeof value === 'string' && value.length > 0 && value.length <= 256;
|
|
267
|
+
const identifier = value => typeof value === 'string' && value.length > 0 && value.length <= 256 && value.isWellFormed();
|
|
267
268
|
const record = value => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
268
269
|
const themeValue = value => ['light', 'dark', 'system'].includes(value);
|
|
269
270
|
const mint = prefix => prefix + '-' + (globalThis.crypto?.randomUUID?.() || Math.random().toString(36).slice(2));
|
|
270
|
-
const autoLive = new Map();
|
|
271
|
+
const autoLive = new Map(), slotLive = new Map();
|
|
271
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); };
|
|
272
273
|
function autoIdentity(options) {
|
|
273
274
|
const load = options.load;
|
|
@@ -283,21 +284,21 @@ function deferred() {
|
|
|
283
284
|
promise.catch(() => {});
|
|
284
285
|
return {promise, resolve, reject};
|
|
285
286
|
}
|
|
286
|
-
const refused = payload => Object.assign(new Error(payload?.reason || payload?.code || 'The editor refused the request'), {code: payload?.code, payload},
|
|
287
|
+
const refused = payload => Object.assign(new Error(payload?.reason || payload?.code || 'The editor refused the request'), {code: payload?.code ?? 'refused', payload},
|
|
287
288
|
revision(payload?.currentRevision) ? {currentRevision: payload.currentRevision} : {});
|
|
288
289
|
|
|
289
290
|
function conflict(currentRevision) {
|
|
290
|
-
if (!revision(currentRevision)) throw
|
|
291
|
+
if (!revision(currentRevision)) throw coded('invalid_revision', TypeError, 'A conflict needs the current revision');
|
|
291
292
|
return Object.assign(new Error('The stored document has changed'), {code: 'conflict', currentRevision});
|
|
292
293
|
}
|
|
293
294
|
|
|
294
295
|
function readerStyle(value) {
|
|
295
|
-
if (!record(value) || Object.keys(value).some(key => !['css', 'fonts'].includes(key))) throw
|
|
296
|
-
if (value.css !== undefined && typeof value.css !== 'string') throw
|
|
296
|
+
if (!record(value) || Object.keys(value).some(key => !['css', 'fonts'].includes(key))) throw coded('invalid_style', TypeError, 'style takes css and fonts');
|
|
297
|
+
if (value.css !== undefined && typeof value.css !== 'string') throw coded('invalid_style', TypeError, 'style.css must be a stylesheet');
|
|
297
298
|
const face = row => record(row) && typeof row.family === 'string' && row.family.length > 0 &&
|
|
298
299
|
(typeof row.source === 'string' || row.source instanceof ArrayBuffer || ArrayBuffer.isView(row.source)) &&
|
|
299
300
|
(row.descriptors === undefined || record(row.descriptors)) && Object.keys(row).every(key => ['family', 'source', 'descriptors'].includes(key));
|
|
300
|
-
if (value.fonts !== undefined && (!Array.isArray(value.fonts) || !value.fonts.every(face))) throw
|
|
301
|
+
if (value.fonts !== undefined && (!Array.isArray(value.fonts) || !value.fonts.every(face))) throw coded('invalid_style', TypeError, 'style.fonts takes {family, source, descriptors?} faces');
|
|
301
302
|
return {...(value.css !== undefined ? {css: value.css} : {}), ...(value.fonts !== undefined ? {fonts: value.fonts.map(row => ({...row}))} : {})};
|
|
302
303
|
}
|
|
303
304
|
|
|
@@ -308,6 +309,12 @@ function framingProblem(host, source, sandbox, noun) {
|
|
|
308
309
|
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
310
|
if (source.origin === HOSTED_ORIGIN && here.protocol === 'http:' && !local)
|
|
310
311
|
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.'};
|
|
312
|
+
if (source.origin === HOSTED_ORIGIN && host.origin === 'null')
|
|
313
|
+
return {code: 'host_sandboxed', reason: 'This page has an opaque origin (a sandbox without allow-same-origin), which the hosted ' + noun + ' refuses. Give the page an origin, or serve a copy of the ' + noun + ' yourself and set src.'};
|
|
314
|
+
if (source.origin === HOSTED_ORIGIN && [...(here.ancestorOrigins || [])].some(origin => /^http:\/\//.test(origin) && !/^http:\/\/(?:localhost|127\.0\.0\.1|\[::1\])(?::|$)/.test(origin)))
|
|
315
|
+
return {code: 'host_not_secure', reason: 'A page around this one is served over http, which the hosted ' + noun + ' refuses. Serve the whole page over https, or serve a copy of the ' + noun + ' yourself and set src.'};
|
|
316
|
+
if (source.origin === HOSTED_ORIGIN && host.navigator?.onLine === false)
|
|
317
|
+
return {code: 'offline', reason: 'The browser is offline, so the hosted ' + noun + ' cannot be fetched. Mount again when it is back, or serve a copy of the ' + noun + ' yourself and set src.'};
|
|
311
318
|
if (source.origin === HOSTED_ORIGIN && host.crossOriginIsolated === true)
|
|
312
319
|
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
320
|
if (sandbox) for (const token of ['allow-scripts', 'allow-same-origin']) if (!sandbox.contains(token))
|
|
@@ -317,49 +324,55 @@ function framingProblem(host, source, sandbox, noun) {
|
|
|
317
324
|
|
|
318
325
|
function mount(target, options = {}) {
|
|
319
326
|
const document = target?.ownerDocument;
|
|
320
|
-
if (!document) throw
|
|
327
|
+
if (!document || target.nodeType !== 1) throw coded('invalid_target', TypeError, 'Mount needs an iframe or a container element');
|
|
328
|
+
if (!record(options)) throw coded('invalid_options', TypeError, 'Mount options must be an object');
|
|
321
329
|
const unknown = Object.keys(options).filter(key => !OPTIONS.includes(key));
|
|
322
330
|
if (unknown.length) throw Object.assign(new TypeError('Unknown option: ' + unknown.join(', ') + '. Mount takes ' + OPTIONS.join(', ')), {code: 'option_unknown'});
|
|
323
331
|
const build = options.build === undefined || options.build === 'document' ? 'full' : options.build;
|
|
324
|
-
if (!Object.hasOwn(BUILDS, build)) throw
|
|
332
|
+
if (!Object.hasOwn(BUILDS, build)) throw coded('invalid_build', TypeError, 'build must be full or reader');
|
|
325
333
|
const reader = build === 'reader', noun = reader ? 'reader' : 'editor';
|
|
326
334
|
if (reader && ['save', 'compare', 'onClose', 'agent', 'agentName', 'assets'].some(key => options[key] !== undefined && options[key] !== false))
|
|
327
|
-
throw
|
|
328
|
-
if (!reader && options.style !== undefined) throw
|
|
329
|
-
if (options.style !== undefined && options.load === undefined) throw
|
|
335
|
+
throw coded('reader_option_unsupported', TypeError, 'The reader takes load, onState, theme, settings, style and plugins');
|
|
336
|
+
if (!reader && options.style !== undefined) throw coded('style_unavailable', TypeError, 'style restyles the reader build');
|
|
337
|
+
if (options.style !== undefined && options.load === undefined) throw coded('load_required', TypeError, 'Reader styling requires load');
|
|
330
338
|
if (options.plugins !== undefined && (typeof options.plugins !== 'string' || !options.plugins || options.plugins.length > 2048 || /[\u0000-\u001f\u007f]/.test(options.plugins)))
|
|
331
|
-
throw
|
|
332
|
-
if (options.approvePlugins !== undefined && typeof options.approvePlugins !== 'boolean') throw
|
|
339
|
+
throw coded('invalid_plugins', TypeError, 'plugins is the address of the directory that holds the plug-in files');
|
|
340
|
+
if (options.approvePlugins !== undefined && typeof options.approvePlugins !== 'boolean') throw coded('invalid_option', TypeError, 'approvePlugins is true or false');
|
|
333
341
|
let currentStyle = options.style === undefined ? undefined : readerStyle(options.style);
|
|
334
342
|
const host = document.defaultView, iframe = target.tagName === 'IFRAME' ? target : document.createElement('iframe');
|
|
335
|
-
|
|
343
|
+
let source;
|
|
344
|
+
try {
|
|
345
|
+
if (options.src !== undefined && typeof options.src !== 'string') throw new TypeError();
|
|
346
|
+
source = new URL(options.src || BUILDS[build], host.location.href);
|
|
347
|
+
} catch { throw coded('invalid_src', TypeError, 'src must be a page address'); }
|
|
336
348
|
if (source.username || source.password || !(source.protocol === 'https:' || source.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(source.hostname)))
|
|
337
|
-
throw
|
|
349
|
+
throw coded('host_not_secure', TypeError, 'The editor needs HTTPS, or HTTP on localhost');
|
|
338
350
|
const page = source.pathname.split('/').pop();
|
|
339
351
|
if (reader ? page === 'rapier.html' : page === 'rapier-reader.html')
|
|
340
352
|
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'});
|
|
341
353
|
source.searchParams.set('embed', '1');
|
|
342
354
|
if (options.plugins !== undefined) {
|
|
343
355
|
let directory;
|
|
344
|
-
try { directory = new URL(options.plugins, host.location.href); } catch { throw
|
|
356
|
+
try { directory = new URL(options.plugins, host.location.href); } catch { throw coded('invalid_plugins', TypeError, 'plugins is not an address'); }
|
|
345
357
|
if (!(directory.protocol === 'https:' || directory.protocol === 'http:') || directory.username || directory.password || directory.search || directory.hash)
|
|
346
|
-
throw
|
|
358
|
+
throw coded('invalid_plugins', TypeError, 'plugins is an http or https address of a directory, with no query or fragment');
|
|
347
359
|
if (directory.origin !== source.origin)
|
|
348
|
-
throw
|
|
360
|
+
throw coded('invalid_plugins', 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');
|
|
349
361
|
source.searchParams.set('plugins', directory.href);
|
|
350
362
|
}
|
|
351
363
|
if (options.approvePlugins === true) source.searchParams.set('approvePlugins', '1');
|
|
352
364
|
const origin = source.origin, auto = options.sessionId === undefined && options.documentId === undefined ? autoIdentity(options) : null;
|
|
353
|
-
const
|
|
354
|
-
if (!identifier(
|
|
365
|
+
const askedSession = options.sessionId ?? (auto ? 'page-' + auto : mint('session')), askedDocument = options.documentId ?? (auto ? 'doc-' + auto : mint('document'));
|
|
366
|
+
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
|
+
const sessionId = askedSession, documentId = askedDocument, pairKey = JSON.stringify([sessionId, documentId]), openElsewhere = !!slotLive.get(pairKey)?.();
|
|
355
368
|
const agentName = options.agentName;
|
|
356
|
-
if (agentName !== undefined && (typeof agentName !== 'string' || !agentName.trim() || agentName.length > 96 || /[\u0000-\u001f\u007f]/.test(agentName))) throw
|
|
357
|
-
if (options.theme !== undefined && !themeValue(options.theme)) throw
|
|
358
|
-
if (options.save !== undefined && typeof options.save !== 'function') throw
|
|
359
|
-
if (options.assets !== undefined && typeof options.assets !== 'function') throw
|
|
360
|
-
if (options.openFile !== undefined && typeof options.openFile !== 'function') throw
|
|
361
|
-
if (options.timeout !== undefined && !(Number.isSafeInteger(options.timeout) && options.timeout > 0)) throw
|
|
362
|
-
const bootTimeout = options.timeout ?? 15000;
|
|
369
|
+
if (agentName !== undefined && (typeof agentName !== 'string' || !agentName.trim() || agentName.length > 96 || /[\u0000-\u001f\u007f]/.test(agentName))) throw coded('invalid_option', TypeError, 'agentName must be a host-given name of at most 96 characters');
|
|
370
|
+
if (options.theme !== undefined && !themeValue(options.theme)) throw coded('invalid_theme', TypeError, 'Theme must be light, dark or system');
|
|
371
|
+
if (options.save !== undefined && typeof options.save !== 'function') throw coded('invalid_option', TypeError, 'save must be a durable storage callback');
|
|
372
|
+
if (options.assets !== undefined && typeof options.assets !== 'function') throw coded('invalid_option', TypeError, 'assets must be a picture storage callback');
|
|
373
|
+
if (options.openFile !== undefined && typeof options.openFile !== 'function') throw coded('invalid_option', TypeError, 'openFile must choose a File or null');
|
|
374
|
+
if (options.timeout !== undefined && !(Number.isSafeInteger(options.timeout) && options.timeout > 0)) throw coded('invalid_option', TypeError, 'timeout is a whole number of milliseconds');
|
|
375
|
+
const bootTimeout = options.timeout ?? 15000, loadTimeout = Math.max(bootTimeout, 120000);
|
|
363
376
|
const settings = snapshotSettings(options.settings), policy = normalizeSettings(settings), storeAsset = options.assets;
|
|
364
377
|
const unread = reader ? (settings?.features || []).filter(name => !READER_FEATURES.includes(name)) : [];
|
|
365
378
|
if (unread.length) throw Object.assign(new TypeError('The reader has no ' + unread.join(', ') + '; its features are ' + READER_FEATURES.join(', ')), {code: 'settings_feature_unavailable'});
|
|
@@ -373,10 +386,10 @@ function mount(target, options = {}) {
|
|
|
373
386
|
...(options.onClose ? ['close'] : []), ...(options.agent ? ['agent'] : []), ...(storeAsset ? ['assets'] : []),
|
|
374
387
|
];
|
|
375
388
|
if (options.agent && (!capabilities.includes('open') || !capabilities.includes('read')))
|
|
376
|
-
throw
|
|
389
|
+
throw coded('invalid_option', TypeError, 'Agent access needs both load and save');
|
|
377
390
|
const ids = {sessionId, documentId}, answers = new Map(), waiting = new Map(), listeners = new Map();
|
|
378
|
-
let port = null, posted = false, connected = false, ended = false, sawLoad = false, seq = 0, activeSave = null, activeClose = null;
|
|
379
|
-
let currentTheme = options.theme, lastSaved = null, readOnly = false, connectedWait = deferred(), boot, bootTimer = 0, frameFacts = {};
|
|
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 = {};
|
|
380
393
|
let held = null;
|
|
381
394
|
const ready = deferred();
|
|
382
395
|
const emit = (type, value) => { for (const fn of listeners.get(type) || []) fn(value); };
|
|
@@ -386,7 +399,7 @@ function mount(target, options = {}) {
|
|
|
386
399
|
waiting.clear(); activeSave?.reject(error); activeSave = null; activeClose?.reject(error); activeClose = null;
|
|
387
400
|
};
|
|
388
401
|
function send(type, payload, baseRevision, quiet = false) {
|
|
389
|
-
if (ended || !connected || !port) return Promise.reject(
|
|
402
|
+
if (ended || !connected || !port) return Promise.reject(endError || coded('disconnected', Error, 'The ' + noun + ' connection has ended or is not connected'));
|
|
390
403
|
if (type === 'load') cancelOpen('open_stale');
|
|
391
404
|
const requestId = 'host-' + ++seq;
|
|
392
405
|
const message = {type, ...ids, requestId, ...(baseRevision !== undefined ? {baseRevision} : {}), ...(payload !== undefined ? {payload} : {})};
|
|
@@ -424,7 +437,7 @@ function mount(target, options = {}) {
|
|
|
424
437
|
try {
|
|
425
438
|
const result = await options.save({...payload, requestId, baseRevision});
|
|
426
439
|
if (!revision(result?.revision) || result.revision === baseRevision || typeof result.revision === 'number' && typeof baseRevision === 'number' && result.revision <= baseRevision)
|
|
427
|
-
throw
|
|
440
|
+
throw coded('save_not_advancing', TypeError, 'save must return an advancing durable revision');
|
|
428
441
|
return {type: 'save-ack', ...ids, requestId, baseRevision, payload: {revision: result.revision}};
|
|
429
442
|
} catch (error) {
|
|
430
443
|
return {type: 'save-nack', ...ids, requestId, baseRevision, payload: error?.code === 'conflict' && revision(error.currentRevision)
|
|
@@ -554,7 +567,8 @@ function mount(target, options = {}) {
|
|
|
554
567
|
}
|
|
555
568
|
if (type === 'connected') {
|
|
556
569
|
if (connected) return;
|
|
557
|
-
clearTimeout(bootTimer);
|
|
570
|
+
clearTimeout(bootTimer); clearTimeout(loadTimer);
|
|
571
|
+
if (timedOut) { timedOut = false; connectedWait = deferred(); prepareBoot(); }
|
|
558
572
|
connected = true; frameFacts = {version: typeof payload.appVersion === 'string' ? payload.appVersion : null, contract: payload.contract ?? null};
|
|
559
573
|
connectedWait.resolve(); emit('connected', payload); return;
|
|
560
574
|
}
|
|
@@ -578,7 +592,7 @@ function mount(target, options = {}) {
|
|
|
578
592
|
}
|
|
579
593
|
if (type === 'close-request' && options.onClose) {
|
|
580
594
|
Promise.resolve().then(() => options.onClose({dirty: !!payload.dirty})).then(decision => {
|
|
581
|
-
if (!['save', 'discard', 'cancel'].includes(decision)) throw
|
|
595
|
+
if (!['save', 'discard', 'cancel'].includes(decision)) throw coded('invalid_option', TypeError, 'Close needs save, discard or cancel');
|
|
582
596
|
if (channel !== port || ended) return;
|
|
583
597
|
channel.postMessage({type: 'close-decision', ...ids, requestId, baseRevision: message.baseRevision, payload: {decision}});
|
|
584
598
|
if (decision === 'cancel') { activeClose?.resolve({cancelled: true}); activeClose = null; }
|
|
@@ -606,7 +620,7 @@ function mount(target, options = {}) {
|
|
|
606
620
|
if (currentStyle) await send('style', currentStyle, undefined, true);
|
|
607
621
|
if (options.load !== undefined) {
|
|
608
622
|
const value = typeof options.load === 'function' ? await options.load() : options.load;
|
|
609
|
-
if (!record(value) || typeof value.content !== 'string' || !revision(value.revision)) throw
|
|
623
|
+
if (!record(value) || typeof value.content !== 'string' || !revision(value.revision)) throw coded('invalid_load', TypeError, 'load needs content and its revision');
|
|
610
624
|
await send('load', value, null);
|
|
611
625
|
held = {revision: value.revision, content: value.content, filename: value.filename};
|
|
612
626
|
}
|
|
@@ -621,26 +635,27 @@ function mount(target, options = {}) {
|
|
|
621
635
|
port.onmessage = event => onPortMessage(event, channel.port1);
|
|
622
636
|
port.onmessageerror = () => {
|
|
623
637
|
if (channel.port1 !== port || ended) return;
|
|
624
|
-
cancelOpen('open_disconnected'); assetAbort.abort(); rejectPending(
|
|
638
|
+
cancelOpen('open_disconnected'); assetAbort.abort(); rejectPending(coded('disconnected', Error, 'The ' + noun + ' connection was lost'));
|
|
625
639
|
};
|
|
626
640
|
try { iframe.contentWindow.postMessage({type: 'rapier-connect', ...ids, capabilities, contract: 1, ...(settings ? {settings} : {}), ...(agentName ? {agent: {name: agentName}} : {}), ...(currentTheme ? {theme: currentTheme} : {})}, origin, [channel.port2]); }
|
|
627
641
|
catch (error) { port.close(); port = null; posted = false; fail(error); }
|
|
628
642
|
}
|
|
629
643
|
function bootFailed(code, reason) {
|
|
630
|
-
clearTimeout(bootTimer);
|
|
644
|
+
clearTimeout(bootTimer); clearTimeout(loadTimer);
|
|
631
645
|
if (connected || ended) return;
|
|
646
|
+
timedOut = code === 'connect_timeout' || code === 'load_timeout';
|
|
632
647
|
connectedWait.reject(refusal(code, reason));
|
|
633
648
|
}
|
|
634
|
-
const connectTimeoutReason = () => 'The ' + noun + ' did not
|
|
649
|
+
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.';
|
|
635
650
|
function onLoad() {
|
|
636
651
|
if (sawLoad && posted) {
|
|
637
652
|
cancelOpen('open_disconnected');
|
|
638
|
-
rejectPending(
|
|
653
|
+
rejectPending(coded('disconnected', Error, 'The ' + noun + ' reconnected')); port?.close();
|
|
639
654
|
assetAbort.abort(refusal('asset_disconnected')); assetAbort = new AbortController();
|
|
640
655
|
connected = false; posted = false; readOnly = false; connectedWait = deferred(); prepareBoot();
|
|
641
656
|
}
|
|
642
657
|
sawLoad = true;
|
|
643
|
-
clearTimeout(bootTimer);
|
|
658
|
+
clearTimeout(bootTimer); clearTimeout(loadTimer);
|
|
644
659
|
if (!connected) bootTimer = setTimeout(() => bootFailed('connect_timeout', connectTimeoutReason()), bootTimeout);
|
|
645
660
|
connect();
|
|
646
661
|
}
|
|
@@ -652,6 +667,19 @@ function mount(target, options = {}) {
|
|
|
652
667
|
const next = forward ? controls[0] : controls.at(-1);
|
|
653
668
|
if (next) next.focus(); else iframe.blur();
|
|
654
669
|
}
|
|
670
|
+
function end(removed) {
|
|
671
|
+
if (ended) return;
|
|
672
|
+
if (connected && !removed) send('disconnect', undefined, undefined, true).catch(() => {});
|
|
673
|
+
ended = true; connected = false; clearTimeout(bootTimer); clearTimeout(loadTimer); watcher?.disconnect();
|
|
674
|
+
cancelOpen('open_disconnected'); openAnswers.clear();
|
|
675
|
+
assetAbort.abort(refusal('asset_disconnected')); assetAnswers.clear();
|
|
676
|
+
const error = removed ? coded('frame_removed', Error, 'The ' + noun + ' was removed from the page') : coded('disconnected', Error, 'The ' + noun + ' connection has ended');
|
|
677
|
+
if (removed) endError = error;
|
|
678
|
+
rejectPending(error); connectedWait.reject(error); ready.reject(error);
|
|
679
|
+
host.removeEventListener('message', onWindowMessage); iframe.removeEventListener('load', onLoad);
|
|
680
|
+
port?.close(); port = null; listeners.clear();
|
|
681
|
+
if (!removed && iframe !== target) iframe.remove();
|
|
682
|
+
}
|
|
655
683
|
function onWindowMessage(event) {
|
|
656
684
|
if (event.source !== iframe.contentWindow || event.origin !== origin) return;
|
|
657
685
|
if (event.data?.type === 'rapier-ready') connect();
|
|
@@ -684,9 +712,9 @@ function mount(target, options = {}) {
|
|
|
684
712
|
},
|
|
685
713
|
async save() {
|
|
686
714
|
await boot;
|
|
687
|
-
if (
|
|
688
|
-
if (
|
|
689
|
-
if (readOnly) throw
|
|
715
|
+
if (ended) throw endError || coded('disconnected', Error, 'The ' + noun + ' connection has ended');
|
|
716
|
+
if (!options.save) throw coded('save_unavailable', Error, 'No save callback was granted');
|
|
717
|
+
if (readOnly) throw coded('read_only', Error, 'The document is read-only');
|
|
690
718
|
if (!activeSave) activeSave = {...deferred(), acknowledged: false, commandId: ''};
|
|
691
719
|
const promise = activeSave.promise;
|
|
692
720
|
requestCapture(); return promise;
|
|
@@ -694,43 +722,41 @@ function mount(target, options = {}) {
|
|
|
694
722
|
async compare(content, {filename} = {}) { await boot; return send('compare', {content, ...(filename !== undefined ? {filename} : {})}); },
|
|
695
723
|
async close() {
|
|
696
724
|
await boot;
|
|
697
|
-
if (
|
|
725
|
+
if (ended) throw endError || coded('disconnected', Error, 'The ' + noun + ' connection has ended');
|
|
726
|
+
if (!options.onClose) throw coded('close_unavailable', Error, 'No close callback was granted');
|
|
698
727
|
if (!activeClose) { activeClose = deferred(); send('close', undefined, undefined, true).catch(error => { activeClose?.reject(error); activeClose = null; }); }
|
|
699
728
|
return activeClose.promise;
|
|
700
729
|
},
|
|
701
730
|
async theme(value) {
|
|
702
|
-
if (!themeValue(value)) throw
|
|
703
|
-
currentTheme = value;
|
|
731
|
+
if (!themeValue(value)) throw coded('invalid_theme', TypeError, 'Theme must be light, dark or system');
|
|
732
|
+
currentTheme = value;
|
|
733
|
+
try { await boot; return await send('theme', {theme: value}, undefined, true); } catch (error) { if (error?.code === 'disconnected') return; throw error; }
|
|
704
734
|
},
|
|
705
735
|
async style(value) {
|
|
706
|
-
if (!reader) throw
|
|
707
|
-
if (!capabilities.includes('open')) throw
|
|
708
|
-
currentStyle = readerStyle(value);
|
|
709
|
-
|
|
710
|
-
disconnect() {
|
|
711
|
-
if (ended) return;
|
|
712
|
-
if (connected) send('disconnect', undefined, undefined, true).catch(() => {});
|
|
713
|
-
ended = true; connected = false; clearTimeout(bootTimer);
|
|
714
|
-
cancelOpen('open_disconnected'); openAnswers.clear();
|
|
715
|
-
assetAbort.abort(refusal('asset_disconnected')); assetAnswers.clear();
|
|
716
|
-
const error = new Error('The ' + noun + ' connection has ended');
|
|
717
|
-
rejectPending(error); connectedWait.reject(error); ready.reject(error);
|
|
718
|
-
host.removeEventListener('message', onWindowMessage); iframe.removeEventListener('load', onLoad);
|
|
719
|
-
port?.close(); port = null; listeners.clear();
|
|
720
|
-
if (iframe !== target) iframe.remove();
|
|
736
|
+
if (!reader) throw coded('style_unavailable', TypeError, 'style restyles the reader build');
|
|
737
|
+
if (!capabilities.includes('open')) throw coded('load_required', TypeError, 'Reader styling requires load');
|
|
738
|
+
currentStyle = readerStyle(value);
|
|
739
|
+
try { await boot; return await send('style', currentStyle, undefined, true); } catch (error) { if (error?.code === 'disconnected') return; throw error; }
|
|
721
740
|
},
|
|
741
|
+
disconnect() { end(false); },
|
|
722
742
|
on(type, fn) {
|
|
723
|
-
if (typeof fn !== 'function') throw
|
|
743
|
+
if (typeof fn !== 'function') throw coded('invalid_option', TypeError, 'An event listener must be a function');
|
|
724
744
|
if (!listeners.has(type)) listeners.set(type, new Set()); listeners.get(type).add(fn);
|
|
725
745
|
return () => listeners.get(type)?.delete(fn);
|
|
726
746
|
},
|
|
727
747
|
});
|
|
728
748
|
if (auto) autoLive.set(auto, () => iframe.isConnected && !ended);
|
|
749
|
+
if (!openElsewhere) slotLive.set(pairKey, () => iframe.isConnected && !ended);
|
|
729
750
|
prepareBoot();
|
|
751
|
+
if (iframe !== target) { iframe.setAttribute('width', '100%'); iframe.setAttribute('height', '100%'); iframe.setAttribute('frameborder', '0'); }
|
|
730
752
|
iframe.setAttribute('title', options.title || (reader ? 'Markdown reader' : 'Markdown editor'));
|
|
731
753
|
iframe.setAttribute('allow', options.agent ? 'clipboard-write; tools' : 'clipboard-write');
|
|
732
754
|
const ownSandbox = iframe === target && iframe.hasAttribute('sandbox');
|
|
733
755
|
if (!ownSandbox) iframe.setAttribute('sandbox', 'allow-scripts allow-same-origin allow-downloads');
|
|
756
|
+
if (openElsewhere) {
|
|
757
|
+
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.'));
|
|
758
|
+
return handle;
|
|
759
|
+
}
|
|
734
760
|
const blocked = framingProblem(host, source, ownSandbox ? iframe.sandbox : null, noun);
|
|
735
761
|
if (blocked) {
|
|
736
762
|
if (iframe !== target) target.append(iframe);
|
|
@@ -740,7 +766,14 @@ function mount(target, options = {}) {
|
|
|
740
766
|
host.addEventListener('message', onWindowMessage); iframe.addEventListener('load', onLoad);
|
|
741
767
|
iframe.src = source.href;
|
|
742
768
|
if (iframe !== target) target.append(iframe);
|
|
743
|
-
|
|
769
|
+
if (host.MutationObserver) {
|
|
770
|
+
let attached = iframe.isConnected;
|
|
771
|
+
watcher = new host.MutationObserver(() => { if (iframe.isConnected) attached = true; else if (attached) end(true); });
|
|
772
|
+
watcher.observe(host.document, {childList: true, subtree: true});
|
|
773
|
+
const root = iframe.getRootNode();
|
|
774
|
+
if (root !== host.document && root.nodeType === 11) watcher.observe(root, {childList: true, subtree: true});
|
|
775
|
+
}
|
|
776
|
+
loadTimer = setTimeout(() => bootFailed('load_timeout', 'The ' + noun + ' did not finish loading within ' + loadTimeout / 1000 + ' seconds. Check the address and the network.'), loadTimeout);
|
|
744
777
|
return handle;
|
|
745
778
|
}
|
|
746
779
|
|
|
@@ -885,7 +918,7 @@ export class RapierEditor extends (globalThis.HTMLElement || class {}) {
|
|
|
885
918
|
src: this.getAttribute('src') || DEFAULT_SRC, theme: this.getAttribute('theme') || 'system',
|
|
886
919
|
load: {content: this._value, filename: this._filename(), revision: this._revision, readOnly: this._disabled},
|
|
887
920
|
save: ({content, baseRevision}) => {
|
|
888
|
-
if (this._generation !== generation) throw
|
|
921
|
+
if (this._generation !== generation) throw coded('field_replaced', Error, 'The form field was replaced');
|
|
889
922
|
if (baseRevision !== this._revision) throw conflict(this._revision);
|
|
890
923
|
this._value = content; this._revision++; this._dirty = false; this._updateValue();
|
|
891
924
|
this.dispatchEvent(new Event('change', {bubbles: true, composed: true}));
|
|
@@ -921,7 +954,7 @@ export class RapierEditor extends (globalThis.HTMLElement || class {}) {
|
|
|
921
954
|
}
|
|
922
955
|
|
|
923
956
|
export function defineRapierEditor(registry = globalThis.customElements) {
|
|
924
|
-
if (!registry || !globalThis.ElementInternals) throw
|
|
957
|
+
if (!registry || !globalThis.ElementInternals) throw coded('unsupported_environment', Error, 'rapier-editor needs form-associated custom elements');
|
|
925
958
|
if (!registry.get('rapier-editor')) registry.define('rapier-editor', RapierEditor);
|
|
926
959
|
return RapierEditor;
|
|
927
960
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "rapier-embed",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.42",
|
|
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",
|