rapier-embed 1.2.40 → 1.2.43
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 +39 -23
- package/contract.d.mts +2 -0
- package/contract.mjs +1 -0
- package/embed.d.mts +2 -2
- package/embed.mjs +108 -71
- package/package.json +1 -1
- package/rapier-plugins.json +208 -208
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.43';
|
|
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, ...(typeof payload?.reason === 'string' ? {reason: payload.reason} : {})},
|
|
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); };
|
|
@@ -385,8 +398,8 @@ function mount(target, options = {}) {
|
|
|
385
398
|
for (const job of waiting.values()) job.reject(error);
|
|
386
399
|
waiting.clear(); activeSave?.reject(error); activeSave = null; activeClose?.reject(error); activeClose = null;
|
|
387
400
|
};
|
|
388
|
-
function send(type, payload, baseRevision, quiet = false) {
|
|
389
|
-
if (ended || !connected || !port) return Promise.reject(
|
|
401
|
+
function send(type, payload, baseRevision, quiet = false, deadline = 0) {
|
|
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} : {})};
|
|
@@ -394,6 +407,10 @@ function mount(target, options = {}) {
|
|
|
394
407
|
if (!quiet) waiting.set(requestId, pending);
|
|
395
408
|
try { port.postMessage(message); if (quiet) pending.resolve(); }
|
|
396
409
|
catch (error) { waiting.delete(requestId); pending.reject(error); }
|
|
410
|
+
if (deadline > 0 && !quiet) {
|
|
411
|
+
const timer = setTimeout(() => { if (waiting.delete(requestId)) pending.reject(coded(type + '_timeout', Error, 'The ' + noun + ' did not answer within ' + deadline / 1000 + ' seconds')); }, deadline);
|
|
412
|
+
pending.promise.then(() => clearTimeout(timer), () => clearTimeout(timer));
|
|
413
|
+
}
|
|
397
414
|
return pending.promise;
|
|
398
415
|
}
|
|
399
416
|
function requestCapture() {
|
|
@@ -424,7 +441,7 @@ function mount(target, options = {}) {
|
|
|
424
441
|
try {
|
|
425
442
|
const result = await options.save({...payload, requestId, baseRevision});
|
|
426
443
|
if (!revision(result?.revision) || result.revision === baseRevision || typeof result.revision === 'number' && typeof baseRevision === 'number' && result.revision <= baseRevision)
|
|
427
|
-
throw
|
|
444
|
+
throw coded('save_not_advancing', TypeError, 'save must return an advancing durable revision');
|
|
428
445
|
return {type: 'save-ack', ...ids, requestId, baseRevision, payload: {revision: result.revision}};
|
|
429
446
|
} catch (error) {
|
|
430
447
|
return {type: 'save-nack', ...ids, requestId, baseRevision, payload: error?.code === 'conflict' && revision(error.currentRevision)
|
|
@@ -554,7 +571,8 @@ function mount(target, options = {}) {
|
|
|
554
571
|
}
|
|
555
572
|
if (type === 'connected') {
|
|
556
573
|
if (connected) return;
|
|
557
|
-
clearTimeout(bootTimer);
|
|
574
|
+
clearTimeout(bootTimer); clearTimeout(loadTimer);
|
|
575
|
+
if (timedOut) { timedOut = false; connectedWait = deferred(); prepareBoot(); }
|
|
558
576
|
connected = true; frameFacts = {version: typeof payload.appVersion === 'string' ? payload.appVersion : null, contract: payload.contract ?? null};
|
|
559
577
|
connectedWait.resolve(); emit('connected', payload); return;
|
|
560
578
|
}
|
|
@@ -578,7 +596,7 @@ function mount(target, options = {}) {
|
|
|
578
596
|
}
|
|
579
597
|
if (type === 'close-request' && options.onClose) {
|
|
580
598
|
Promise.resolve().then(() => options.onClose({dirty: !!payload.dirty})).then(decision => {
|
|
581
|
-
if (!['save', 'discard', 'cancel'].includes(decision)) throw
|
|
599
|
+
if (!['save', 'discard', 'cancel'].includes(decision)) throw coded('invalid_option', TypeError, 'Close needs save, discard or cancel');
|
|
582
600
|
if (channel !== port || ended) return;
|
|
583
601
|
channel.postMessage({type: 'close-decision', ...ids, requestId, baseRevision: message.baseRevision, payload: {decision}});
|
|
584
602
|
if (decision === 'cancel') { activeClose?.resolve({cancelled: true}); activeClose = null; }
|
|
@@ -606,7 +624,7 @@ function mount(target, options = {}) {
|
|
|
606
624
|
if (currentStyle) await send('style', currentStyle, undefined, true);
|
|
607
625
|
if (options.load !== undefined) {
|
|
608
626
|
const value = typeof options.load === 'function' ? await options.load() : options.load;
|
|
609
|
-
if (!record(value) || typeof value.content !== 'string' || !revision(value.revision)) throw
|
|
627
|
+
if (!record(value) || typeof value.content !== 'string' || !revision(value.revision)) throw coded('invalid_load', TypeError, 'load needs content and its revision');
|
|
610
628
|
await send('load', value, null);
|
|
611
629
|
held = {revision: value.revision, content: value.content, filename: value.filename};
|
|
612
630
|
}
|
|
@@ -621,26 +639,27 @@ function mount(target, options = {}) {
|
|
|
621
639
|
port.onmessage = event => onPortMessage(event, channel.port1);
|
|
622
640
|
port.onmessageerror = () => {
|
|
623
641
|
if (channel.port1 !== port || ended) return;
|
|
624
|
-
cancelOpen('open_disconnected'); assetAbort.abort(); rejectPending(
|
|
642
|
+
cancelOpen('open_disconnected'); assetAbort.abort(); rejectPending(coded('disconnected', Error, 'The ' + noun + ' connection was lost'));
|
|
625
643
|
};
|
|
626
644
|
try { iframe.contentWindow.postMessage({type: 'rapier-connect', ...ids, capabilities, contract: 1, ...(settings ? {settings} : {}), ...(agentName ? {agent: {name: agentName}} : {}), ...(currentTheme ? {theme: currentTheme} : {})}, origin, [channel.port2]); }
|
|
627
645
|
catch (error) { port.close(); port = null; posted = false; fail(error); }
|
|
628
646
|
}
|
|
629
647
|
function bootFailed(code, reason) {
|
|
630
|
-
clearTimeout(bootTimer);
|
|
648
|
+
clearTimeout(bootTimer); clearTimeout(loadTimer);
|
|
631
649
|
if (connected || ended) return;
|
|
650
|
+
timedOut = code === 'connect_timeout' || code === 'load_timeout';
|
|
632
651
|
connectedWait.reject(refusal(code, reason));
|
|
633
652
|
}
|
|
634
|
-
const connectTimeoutReason = () => 'The ' + noun + ' did not
|
|
653
|
+
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
654
|
function onLoad() {
|
|
636
655
|
if (sawLoad && posted) {
|
|
637
656
|
cancelOpen('open_disconnected');
|
|
638
|
-
rejectPending(
|
|
657
|
+
rejectPending(coded('disconnected', Error, 'The ' + noun + ' reconnected')); port?.close();
|
|
639
658
|
assetAbort.abort(refusal('asset_disconnected')); assetAbort = new AbortController();
|
|
640
659
|
connected = false; posted = false; readOnly = false; connectedWait = deferred(); prepareBoot();
|
|
641
660
|
}
|
|
642
661
|
sawLoad = true;
|
|
643
|
-
clearTimeout(bootTimer);
|
|
662
|
+
clearTimeout(bootTimer); clearTimeout(loadTimer);
|
|
644
663
|
if (!connected) bootTimer = setTimeout(() => bootFailed('connect_timeout', connectTimeoutReason()), bootTimeout);
|
|
645
664
|
connect();
|
|
646
665
|
}
|
|
@@ -652,6 +671,19 @@ function mount(target, options = {}) {
|
|
|
652
671
|
const next = forward ? controls[0] : controls.at(-1);
|
|
653
672
|
if (next) next.focus(); else iframe.blur();
|
|
654
673
|
}
|
|
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();
|
|
678
|
+
cancelOpen('open_disconnected'); openAnswers.clear();
|
|
679
|
+
assetAbort.abort(refusal('asset_disconnected')); assetAnswers.clear();
|
|
680
|
+
const error = removed ? coded('frame_removed', Error, 'The ' + noun + ' was removed from the page') : coded('disconnected', Error, 'The ' + noun + ' connection has ended');
|
|
681
|
+
if (removed) endError = error;
|
|
682
|
+
rejectPending(error); connectedWait.reject(error); ready.reject(error);
|
|
683
|
+
host.removeEventListener('message', onWindowMessage); iframe.removeEventListener('load', onLoad);
|
|
684
|
+
port?.close(); port = null; listeners.clear();
|
|
685
|
+
if (!removed && iframe !== target) iframe.remove();
|
|
686
|
+
}
|
|
655
687
|
function onWindowMessage(event) {
|
|
656
688
|
if (event.source !== iframe.contentWindow || event.origin !== origin) return;
|
|
657
689
|
if (event.data?.type === 'rapier-ready') connect();
|
|
@@ -678,15 +710,15 @@ function mount(target, options = {}) {
|
|
|
678
710
|
throw Object.assign(new Error('Revision ' + version + ' is older than the frame\'s ' + held.revision), {code: 'revision_stale', currentRevision: held.revision});
|
|
679
711
|
if (sameDocument && version === held.revision && held.content !== null && content !== held.content)
|
|
680
712
|
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);
|
|
713
|
+
const loaded = await send('load', {content, revision: version, ...(filename !== undefined ? {filename} : {}), ...(readOnly !== undefined ? {readOnly} : {}), ...(title !== undefined ? {title} : {}), ...(baseUrl !== undefined ? {baseUrl} : {})}, null, false, loadTimeout);
|
|
682
714
|
held = {revision: version, content, filename};
|
|
683
715
|
return loaded;
|
|
684
716
|
},
|
|
685
717
|
async save() {
|
|
686
718
|
await boot;
|
|
687
|
-
if (
|
|
688
|
-
if (
|
|
689
|
-
if (readOnly) throw
|
|
719
|
+
if (ended) throw endError || coded('disconnected', Error, 'The ' + noun + ' connection has ended');
|
|
720
|
+
if (!options.save) throw coded('save_unavailable', Error, 'No save callback was granted');
|
|
721
|
+
if (readOnly) throw coded('read_only', Error, 'The document is read-only');
|
|
690
722
|
if (!activeSave) activeSave = {...deferred(), acknowledged: false, commandId: ''};
|
|
691
723
|
const promise = activeSave.promise;
|
|
692
724
|
requestCapture(); return promise;
|
|
@@ -694,43 +726,41 @@ function mount(target, options = {}) {
|
|
|
694
726
|
async compare(content, {filename} = {}) { await boot; return send('compare', {content, ...(filename !== undefined ? {filename} : {})}); },
|
|
695
727
|
async close() {
|
|
696
728
|
await boot;
|
|
697
|
-
if (
|
|
729
|
+
if (ended) throw endError || coded('disconnected', Error, 'The ' + noun + ' connection has ended');
|
|
730
|
+
if (!options.onClose) throw coded('close_unavailable', Error, 'No close callback was granted');
|
|
698
731
|
if (!activeClose) { activeClose = deferred(); send('close', undefined, undefined, true).catch(error => { activeClose?.reject(error); activeClose = null; }); }
|
|
699
732
|
return activeClose.promise;
|
|
700
733
|
},
|
|
701
734
|
async theme(value) {
|
|
702
|
-
if (!themeValue(value)) throw
|
|
703
|
-
currentTheme = value;
|
|
735
|
+
if (!themeValue(value)) throw coded('invalid_theme', TypeError, 'Theme must be light, dark or system');
|
|
736
|
+
currentTheme = value;
|
|
737
|
+
try { await boot; return await send('theme', {theme: value}, undefined, true); } catch (error) { if (error?.code === 'disconnected') return; throw error; }
|
|
704
738
|
},
|
|
705
739
|
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();
|
|
740
|
+
if (!reader) throw coded('style_unavailable', TypeError, 'style restyles the reader build');
|
|
741
|
+
if (!capabilities.includes('open')) throw coded('load_required', TypeError, 'Reader styling requires load');
|
|
742
|
+
currentStyle = readerStyle(value);
|
|
743
|
+
try { await boot; return await send('style', currentStyle, undefined, true); } catch (error) { if (error?.code === 'disconnected') return; throw error; }
|
|
721
744
|
},
|
|
745
|
+
disconnect() { end(false); },
|
|
722
746
|
on(type, fn) {
|
|
723
|
-
if (typeof fn !== 'function') throw
|
|
747
|
+
if (typeof fn !== 'function') throw coded('invalid_option', TypeError, 'An event listener must be a function');
|
|
724
748
|
if (!listeners.has(type)) listeners.set(type, new Set()); listeners.get(type).add(fn);
|
|
725
749
|
return () => listeners.get(type)?.delete(fn);
|
|
726
750
|
},
|
|
727
751
|
});
|
|
728
752
|
if (auto) autoLive.set(auto, () => iframe.isConnected && !ended);
|
|
753
|
+
if (!openElsewhere) slotLive.set(pairKey, () => iframe.isConnected && !ended);
|
|
729
754
|
prepareBoot();
|
|
755
|
+
if (iframe !== target) { iframe.setAttribute('width', '100%'); iframe.setAttribute('height', '100%'); iframe.setAttribute('frameborder', '0'); }
|
|
730
756
|
iframe.setAttribute('title', options.title || (reader ? 'Markdown reader' : 'Markdown editor'));
|
|
731
757
|
iframe.setAttribute('allow', options.agent ? 'clipboard-write; tools' : 'clipboard-write');
|
|
732
758
|
const ownSandbox = iframe === target && iframe.hasAttribute('sandbox');
|
|
733
759
|
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
|
+
}
|
|
734
764
|
const blocked = framingProblem(host, source, ownSandbox ? iframe.sandbox : null, noun);
|
|
735
765
|
if (blocked) {
|
|
736
766
|
if (iframe !== target) target.append(iframe);
|
|
@@ -740,7 +770,14 @@ function mount(target, options = {}) {
|
|
|
740
770
|
host.addEventListener('message', onWindowMessage); iframe.addEventListener('load', onLoad);
|
|
741
771
|
iframe.src = source.href;
|
|
742
772
|
if (iframe !== target) target.append(iframe);
|
|
743
|
-
|
|
773
|
+
if (host.MutationObserver) {
|
|
774
|
+
let attached = iframe.isConnected;
|
|
775
|
+
watcher = new host.MutationObserver(() => { if (iframe.isConnected) attached = true; else if (attached) end(true); });
|
|
776
|
+
watcher.observe(host.document, {childList: true, subtree: true});
|
|
777
|
+
const root = iframe.getRootNode();
|
|
778
|
+
if (root !== host.document && root.nodeType === 11) watcher.observe(root, {childList: true, subtree: true});
|
|
779
|
+
}
|
|
780
|
+
loadTimer = setTimeout(() => bootFailed('load_timeout', 'The ' + noun + ' did not finish loading within ' + loadTimeout / 1000 + ' seconds. Check the address and the network.'), loadTimeout);
|
|
744
781
|
return handle;
|
|
745
782
|
}
|
|
746
783
|
|
|
@@ -885,7 +922,7 @@ export class RapierEditor extends (globalThis.HTMLElement || class {}) {
|
|
|
885
922
|
src: this.getAttribute('src') || DEFAULT_SRC, theme: this.getAttribute('theme') || 'system',
|
|
886
923
|
load: {content: this._value, filename: this._filename(), revision: this._revision, readOnly: this._disabled},
|
|
887
924
|
save: ({content, baseRevision}) => {
|
|
888
|
-
if (this._generation !== generation) throw
|
|
925
|
+
if (this._generation !== generation) throw coded('field_replaced', Error, 'The form field was replaced');
|
|
889
926
|
if (baseRevision !== this._revision) throw conflict(this._revision);
|
|
890
927
|
this._value = content; this._revision++; this._dirty = false; this._updateValue();
|
|
891
928
|
this.dispatchEvent(new Event('change', {bubbles: true, composed: true}));
|
|
@@ -921,7 +958,7 @@ export class RapierEditor extends (globalThis.HTMLElement || class {}) {
|
|
|
921
958
|
}
|
|
922
959
|
|
|
923
960
|
export function defineRapierEditor(registry = globalThis.customElements) {
|
|
924
|
-
if (!registry || !globalThis.ElementInternals) throw
|
|
961
|
+
if (!registry || !globalThis.ElementInternals) throw coded('unsupported_environment', Error, 'rapier-editor needs form-associated custom elements');
|
|
925
962
|
if (!registry.get('rapier-editor')) registry.define('rapier-editor', RapierEditor);
|
|
926
963
|
return RapierEditor;
|
|
927
964
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "rapier-embed",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.43",
|
|
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",
|