@seamlet/astro 0.0.0-stage → 0.1.1

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Seamlet
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,87 @@
1
- # Temporary Holding Version
1
+ # @seamlet/astro
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Connects an [Astro](https://astro.build) site to the Seamlet CMS ([source](https://github.com/seamlet/sdk)): the owner of the site edits its texts, photos and records right on its pages, and the site keeps its own design, components and code.
4
+
5
+ ## How a site connects
6
+
7
+ 1. Install the package. The site needs an adapter, because the preview is rendered on request; its own pages may stay static.
8
+
9
+ ```bash
10
+ npm install @seamlet/astro
11
+ ```
12
+
13
+ 2. Describe the content with a schema, and mark the elements that show it:
14
+
15
+ ```ts
16
+ // src/schema.ts
17
+ import {
18
+ collection,
19
+ defineSchema,
20
+ number,
21
+ text,
22
+ type ContentOf,
23
+ } from "@seamlet/astro/schema";
24
+
25
+ export const schema = defineSchema({
26
+ title: text({ label: "Заголовок", maxLength: 60 }),
27
+ menu: collection({
28
+ label: "Меню",
29
+ itemTitle: "name",
30
+ item: {
31
+ name: text({ label: "Название" }),
32
+ price: number({ label: "Цена, ₽", min: 0 }),
33
+ },
34
+ }),
35
+ });
36
+
37
+ export type Content = ContentOf<typeof schema>;
38
+ ```
39
+
40
+ ```astro
41
+ ---
42
+ import { editable } from "@seamlet/astro/schema";
43
+ const edit = editable<Content>(); // a path the content does not have does not compile
44
+ ---
45
+
46
+ <h1 {...edit("title")}>{content.title}</h1>
47
+ ```
48
+
49
+ 3. Name the site's address in the CMS and its pages: components that take the content as their `content` prop.
50
+
51
+ ```ts
52
+ // src/seamlet.ts
53
+ import { defineSeamletSite } from "@seamlet/astro/site";
54
+ import HomePage from "./components/HomePage.astro";
55
+ import { schema } from "./schema";
56
+
57
+ export default defineSeamletSite({
58
+ id: "zerno",
59
+ schema,
60
+ pages: { "/": HomePage },
61
+ });
62
+ ```
63
+
64
+ 4. Add the integration:
65
+
66
+ ```js
67
+ // astro.config.mjs
68
+ import seamlet from "@seamlet/astro";
69
+
70
+ export default defineConfig({
71
+ integrations: [
72
+ seamlet({ cms: "https://cms.example.com", site: "./src/seamlet.ts" }),
73
+ ],
74
+ });
75
+ ```
76
+
77
+ The site's pages take their published content with `site.loadPublishedContent()`: at build time for a static site, on request otherwise.
78
+
79
+ ## What the integration adds
80
+
81
+ - `/preview` and `/preview/<page>`: the preview the CMS's editor frames. `GET` shows the published content; `POST` with the content as JSON shows that content without saving it, which is how the editor shows every edit at once. Only the CMS may frame it (`Content-Security-Policy: frame-ancestors`), and it is never indexed or cached.
82
+ - The bridge on the preview: it tells the editor where the marked elements are and what a person does on the page, and types texts in place.
83
+ - `/media/<site>/<file>`: the photos uploaded in the CMS, taken from it.
84
+
85
+ A site never renders content as raw HTML (`set:html`): the editor acts on what its preview says.
86
+
87
+ The preview's address goes on the site's settings page in the CMS: `https://<site>/preview`.
@@ -0,0 +1,18 @@
1
+ import { F as Fields } from './content-DKVM1CUQ.js';
2
+ import { P as PageMessage, E as EditorMessage } from './messages-DIMwt3Kn.js';
3
+ import './parse-content-DF2dL2FG.js';
4
+
5
+ interface BridgeOptions {
6
+ /** The origin of the CMS whose editor may frame the page, such as `https://cms.example.com`. */
7
+ readonly editorOrigin: string;
8
+ }
9
+ /** Waits for the editor when the page is in its frame; does nothing elsewhere. Returns a stop. */
10
+ declare function connectToEditor(options: BridgeOptions): () => void;
11
+ interface EditorConnection {
12
+ readonly receive: (message: EditorMessage) => void;
13
+ readonly stop: () => void;
14
+ }
15
+ /** What the page does for the editor once it connected. */
16
+ declare function connectPage(document: Document, schema: Fields, send: (message: PageMessage) => void): EditorConnection;
17
+
18
+ export { type BridgeOptions, connectPage, connectToEditor };
package/dist/bridge.js ADDED
@@ -0,0 +1,537 @@
1
+ import {
2
+ PROTOCOL_VERSION,
3
+ carriesFiles,
4
+ closestMarkedPath,
5
+ imageFieldAt,
6
+ readEditorMessage,
7
+ shortcutOf
8
+ } from "./chunk-HRAL4X6W.js";
9
+ import {
10
+ FIELD_PATH_ATTRIBUTE
11
+ } from "./chunk-EC42AIQC.js";
12
+
13
+ // src/bridge/page-markers.ts
14
+ var MARKER_SELECTOR = `[${FIELD_PATH_ATTRIBUTE}]`;
15
+ var HTML_NAMESPACE = "http://www.w3.org/1999/xhtml";
16
+ function markedElementAround(target) {
17
+ const element = asElement(target)?.closest(MARKER_SELECTOR);
18
+ const path = element?.getAttribute(FIELD_PATH_ATTRIBUTE);
19
+ const isMarked = isHtmlElement(element) && typeof path === "string";
20
+ return isMarked ? { element, path } : void 0;
21
+ }
22
+ function linkAround(target) {
23
+ const element = asElement(target)?.closest("a[href]");
24
+ const href = element?.getAttribute("href");
25
+ const isLink = isHtmlElement(element) && typeof href === "string";
26
+ return isLink ? { element, href } : void 0;
27
+ }
28
+ function findMarkedElement(document2, path) {
29
+ const element = document2.querySelector(
30
+ `[${FIELD_PATH_ATTRIBUTE}="${CSS.escape(path)}"]`
31
+ );
32
+ return isHtmlElement(element) ? element : void 0;
33
+ }
34
+ function allMarkedElements(document2) {
35
+ const elements = [...document2.querySelectorAll(MARKER_SELECTOR)];
36
+ return elements.flatMap((element) => {
37
+ const marked = markedElementAround(element);
38
+ return marked === void 0 ? [] : [marked];
39
+ });
40
+ }
41
+ function hasMarkedDescendants(element) {
42
+ return element.querySelector(MARKER_SELECTOR) !== null;
43
+ }
44
+ function findClosestMarkedElement(document2, path) {
45
+ const closestPath = closestMarkedPath(
46
+ path,
47
+ (marked) => findMarkedElement(document2, marked) !== void 0
48
+ );
49
+ return closestPath === void 0 ? void 0 : findMarkedElement(document2, closestPath);
50
+ }
51
+ function isHtmlElement(value) {
52
+ return asElement(value)?.namespaceURI === HTML_NAMESPACE;
53
+ }
54
+ function asElement(value) {
55
+ const isElement = typeof value === "object" && value !== null && "nodeType" in value && value.nodeType === Node.ELEMENT_NODE;
56
+ return isElement ? value : void 0;
57
+ }
58
+
59
+ // src/bridge/page-events.ts
60
+ var DRAG_GONE_AFTER_MS = 150;
61
+ function listenToPage(document2, listeners) {
62
+ const view = document2.defaultView;
63
+ const hover = (event) => {
64
+ const link = nearerLink(event.target);
65
+ listeners.onHover(
66
+ link === void 0 ? markedElementAround(event.target)?.path : void 0
67
+ );
68
+ listeners.onLinkHover(link);
69
+ };
70
+ const leave = () => {
71
+ listeners.onHover(void 0);
72
+ listeners.onLinkHover(void 0);
73
+ };
74
+ const click = (event) => {
75
+ event.preventDefault();
76
+ const link = nearerLink(event.target);
77
+ if (link !== void 0) {
78
+ listeners.onLinkClick(link);
79
+ return;
80
+ }
81
+ const marked = markedElementAround(event.target);
82
+ listeners.onClick(
83
+ marked === void 0 ? void 0 : { ...marked, point: { x: event.clientX, y: event.clientY } }
84
+ );
85
+ };
86
+ const preventDefault = (event) => {
87
+ event.preventDefault();
88
+ };
89
+ let dragGoneTimer;
90
+ const dragFile = (event) => {
91
+ if (!carriesFiles(event)) return;
92
+ event.preventDefault();
93
+ clearTimeout(dragGoneTimer);
94
+ const path = markedElementAround(event.target)?.path;
95
+ const isTaken = listeners.onFileDragOver(path);
96
+ if (event.dataTransfer !== null) {
97
+ event.dataTransfer.dropEffect = isTaken ? "copy" : "none";
98
+ }
99
+ };
100
+ const dropFile = (event) => {
101
+ if (!carriesFiles(event)) return;
102
+ event.preventDefault();
103
+ clearTimeout(dragGoneTimer);
104
+ listeners.onFileDragEnd();
105
+ const path = markedElementAround(event.target)?.path;
106
+ const file = event.dataTransfer?.files[0];
107
+ if (path !== void 0 && file !== void 0)
108
+ listeners.onFileDrop(path, file);
109
+ };
110
+ const leaveWithFile = (event) => {
111
+ if (!carriesFiles(event)) return;
112
+ clearTimeout(dragGoneTimer);
113
+ dragGoneTimer = setTimeout(listeners.onFileDragEnd, DRAG_GONE_AFTER_MS);
114
+ };
115
+ const documentListeners = [
116
+ ["mouseover", hover],
117
+ ["click", click],
118
+ ["auxclick", preventDefault],
119
+ ["submit", preventDefault],
120
+ ["dragstart", preventDefault],
121
+ ["keydown", listeners.onKeyDown],
122
+ ["dragover", dragFile],
123
+ ["drop", dropFile],
124
+ ["dragleave", leaveWithFile],
125
+ ["scroll", listeners.onLayoutChange],
126
+ // Images load after the page is shown and move what is below them; load does not bubble.
127
+ ["load", listeners.onLayoutChange]
128
+ ];
129
+ for (const [type, listener] of documentListeners) {
130
+ document2.addEventListener(type, listener, true);
131
+ }
132
+ document2.documentElement.addEventListener("mouseleave", leave);
133
+ view?.addEventListener("resize", listeners.onLayoutChange);
134
+ return () => {
135
+ clearTimeout(dragGoneTimer);
136
+ for (const [type, listener] of documentListeners) {
137
+ document2.removeEventListener(type, listener, true);
138
+ }
139
+ document2.documentElement.removeEventListener("mouseleave", leave);
140
+ view?.removeEventListener("resize", listeners.onLayoutChange);
141
+ };
142
+ }
143
+ function nearerLink(target) {
144
+ const link = linkAround(target);
145
+ const marked = markedElementAround(target);
146
+ const isFieldInsideLink = marked !== void 0 && link !== void 0 && !marked.element.contains(link.element);
147
+ const isLinkTheField = marked !== void 0 && marked.element === link?.element;
148
+ return isFieldInsideLink || isLinkTheField ? void 0 : link;
149
+ }
150
+
151
+ // src/bridge/boxes.ts
152
+ function boxOf(element) {
153
+ const rectangle = element.getBoundingClientRect();
154
+ const isShown = rectangle.width > 0 || rectangle.height > 0;
155
+ if (!isShown) {
156
+ return void 0;
157
+ }
158
+ return {
159
+ top: rectangle.top,
160
+ left: rectangle.left,
161
+ width: rectangle.width,
162
+ height: rectangle.height
163
+ };
164
+ }
165
+
166
+ // src/bridge/page-layout.ts
167
+ function measureLayout(document2, link) {
168
+ const markers = allMarkedElements(document2).flatMap(
169
+ ({ element, path }) => {
170
+ const box = boxOf(element);
171
+ return box === void 0 ? [] : [{ path, box }];
172
+ }
173
+ );
174
+ const root = document2.documentElement;
175
+ const linkBox = link === void 0 ? void 0 : boxOf(link.element);
176
+ return {
177
+ markers,
178
+ viewport: { width: root.clientWidth, height: root.clientHeight },
179
+ link: link === void 0 || linkBox === void 0 ? void 0 : { href: link.href, box: linkBox }
180
+ };
181
+ }
182
+ function createLayoutReports(document2, report) {
183
+ const view = document2.defaultView;
184
+ let link;
185
+ let frame;
186
+ const cancel = () => {
187
+ if (frame !== void 0) view?.cancelAnimationFrame(frame);
188
+ frame = void 0;
189
+ };
190
+ const reportNow = () => {
191
+ cancel();
192
+ report(measureLayout(document2, link));
193
+ };
194
+ const schedule = () => {
195
+ if (frame !== void 0) return;
196
+ frame = view?.requestAnimationFrame(reportNow);
197
+ };
198
+ return {
199
+ schedule,
200
+ reportNow,
201
+ hoverLink: (hovered) => {
202
+ if (hovered?.element === link?.element) return;
203
+ link = hovered;
204
+ schedule();
205
+ },
206
+ stop: cancel
207
+ };
208
+ }
209
+
210
+ // src/bridge/page-rendering.ts
211
+ function createPageRenderer(options) {
212
+ let request;
213
+ const render = (content) => {
214
+ request?.abort();
215
+ const currentRequest = new AbortController();
216
+ request = currentRequest;
217
+ fetchRenderedPage(
218
+ pageAddress(options.document),
219
+ content,
220
+ currentRequest.signal
221
+ ).then((html) => {
222
+ if (!options.canReplacePage()) return;
223
+ replacePageBody(options.document, html);
224
+ options.onRendered();
225
+ }).catch((error) => {
226
+ if (currentRequest.signal.aborted) return;
227
+ options.onFailed(error);
228
+ });
229
+ };
230
+ return {
231
+ render,
232
+ stop: () => {
233
+ request?.abort();
234
+ }
235
+ };
236
+ }
237
+ function pageAddress(document2) {
238
+ const url = new URL(document2.URL);
239
+ url.hash = "";
240
+ return url.href;
241
+ }
242
+ async function fetchRenderedPage(url, content, signal) {
243
+ const response = await fetch(url, {
244
+ method: "POST",
245
+ headers: { "Content-Type": "application/json" },
246
+ body: JSON.stringify(content),
247
+ signal
248
+ });
249
+ if (!response.ok || response.redirected) {
250
+ throw new Error(
251
+ `Rendering the draft at ${url} failed with status ${String(response.status)}`
252
+ );
253
+ }
254
+ return response.text();
255
+ }
256
+ function replacePageBody(document2, html) {
257
+ const rendered = new DOMParser().parseFromString(html, "text/html");
258
+ document2.title = rendered.title;
259
+ document2.body.replaceWith(document2.importNode(rendered.body, true));
260
+ }
261
+
262
+ // src/bridge/element-text.ts
263
+ var LINE_BREAK = "\n";
264
+ var NO_BREAK_SPACE = /\u00a0/gu;
265
+ var SPACES_BUT_LINE_BREAKS = /[^\S\n]+/gu;
266
+ var COLLAPSIBLE_WHITESPACE = /[\t\n\f\r ]+/gu;
267
+ var COLLAPSIBLE_SPACES = /[\t\f\r ]+/gu;
268
+ var SPACES_AROUND_LINE_BREAK = / *\n */gu;
269
+ var EDGE_WHITESPACE = /^[\n ]+|[\n ]+$/gu;
270
+ var KEEPS_LINE_BREAKS = /* @__PURE__ */ new Set([
271
+ "pre",
272
+ "pre-wrap",
273
+ "pre-line",
274
+ "break-spaces"
275
+ ]);
276
+ var KEEPS_SPACES = /* @__PURE__ */ new Set(["pre", "pre-wrap", "break-spaces"]);
277
+ function readElementText(element) {
278
+ const whiteSpace = whiteSpaceOf(element);
279
+ const keepsLineBreaks = KEEPS_LINE_BREAKS.has(whiteSpace);
280
+ const keepsSpaces = KEEPS_SPACES.has(whiteSpace);
281
+ const collapsible = keepsLineBreaks ? COLLAPSIBLE_SPACES : COLLAPSIBLE_WHITESPACE;
282
+ const pieces = textPieces(element).map(
283
+ (piece) => piece.isLineBreak || keepsSpaces ? piece.text : piece.text.replace(collapsible, " ")
284
+ );
285
+ const joined = pieces.join("");
286
+ const tidied = keepsSpaces ? joined : joined.replace(COLLAPSIBLE_SPACES, " ").replace(SPACES_AROUND_LINE_BREAK, LINE_BREAK).replace(EDGE_WHITESPACE, "");
287
+ return tidied.replace(NO_BREAK_SPACE, " ");
288
+ }
289
+ function normalizeSpaces(text) {
290
+ return text.replace(SPACES_BUT_LINE_BREAKS, " ").replace(SPACES_AROUND_LINE_BREAK, LINE_BREAK).trim();
291
+ }
292
+ function canEditTextInPlace(element, value) {
293
+ const shownText = normalizeSpaces(readElementText(element));
294
+ const showsValueAsItIs = shownText === normalizeSpaces(value);
295
+ return showsValueAsItIs && !hasMarkedDescendants(element);
296
+ }
297
+ function textPieces(element) {
298
+ const document2 = element.ownerDocument;
299
+ const walker = document2.createTreeWalker(
300
+ element,
301
+ NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT
302
+ );
303
+ const pieces = [];
304
+ for (let node = walker.nextNode(); node !== null; node = walker.nextNode()) {
305
+ if (node.nodeType === Node.TEXT_NODE) {
306
+ pieces.push({ text: node.nodeValue ?? "", isLineBreak: false });
307
+ } else if (node.nodeName === "BR") {
308
+ pieces.push({ text: LINE_BREAK, isLineBreak: true });
309
+ }
310
+ }
311
+ return pieces;
312
+ }
313
+ function whiteSpaceOf(element) {
314
+ const view = element.ownerDocument.defaultView;
315
+ return view === null ? "normal" : view.getComputedStyle(element).whiteSpace;
316
+ }
317
+
318
+ // src/bridge/inline-text-editing.ts
319
+ var EDITABLE_AS_PLAIN_TEXT = "plaintext-only";
320
+ function startInlineTextEditing(element, options) {
321
+ const reportInput = () => {
322
+ options.onInput(readElementText(element));
323
+ };
324
+ const endOnKey = (event) => {
325
+ const isEnd = event.key === "Enter" && options.isSingleLine;
326
+ if (event.key === "Escape") {
327
+ event.preventDefault();
328
+ end("reverted");
329
+ } else if (isEnd) {
330
+ event.preventDefault();
331
+ end("kept");
332
+ }
333
+ };
334
+ const endOnBlur = () => {
335
+ end("kept");
336
+ };
337
+ const stop = () => {
338
+ element.removeEventListener("input", reportInput);
339
+ element.removeEventListener("keydown", endOnKey);
340
+ element.removeEventListener("blur", endOnBlur);
341
+ element.removeAttribute("contenteditable");
342
+ };
343
+ let hasEnded = false;
344
+ const end = (outcome) => {
345
+ if (hasEnded) return;
346
+ hasEnded = true;
347
+ stop();
348
+ options.onEnd(outcome);
349
+ };
350
+ element.setAttribute("contenteditable", EDITABLE_AS_PLAIN_TEXT);
351
+ element.addEventListener("input", reportInput);
352
+ element.addEventListener("keydown", endOnKey);
353
+ element.addEventListener("blur", endOnBlur);
354
+ element.focus();
355
+ return () => {
356
+ end("kept");
357
+ };
358
+ }
359
+ function placeCaretAt(element, point) {
360
+ const document2 = element.ownerDocument;
361
+ const selection = document2.getSelection();
362
+ const range = caretRangeAt(document2, point);
363
+ if (selection === null || range === void 0) {
364
+ return;
365
+ }
366
+ const isInside = element.contains(range.startContainer);
367
+ if (!isInside) {
368
+ range.selectNodeContents(element);
369
+ range.collapse(false);
370
+ }
371
+ selection.removeAllRanges();
372
+ selection.addRange(range);
373
+ }
374
+ function caretRangeAt(document2, point) {
375
+ const lookup = document2;
376
+ if (lookup.caretPositionFromPoint !== void 0) {
377
+ const position = lookup.caretPositionFromPoint(point.x, point.y);
378
+ if (position === null) return void 0;
379
+ const range = document2.createRange();
380
+ range.setStart(position.offsetNode, position.offset);
381
+ return range;
382
+ }
383
+ return lookup.caretRangeFromPoint?.(point.x, point.y) ?? void 0;
384
+ }
385
+
386
+ // src/bridge/page-typing.ts
387
+ function createPageTyping(options) {
388
+ let stopEditing;
389
+ const stop = () => {
390
+ stopEditing?.();
391
+ };
392
+ const start = (request) => {
393
+ stop();
394
+ const { path } = request;
395
+ const element = findMarkedElement(options.document, path);
396
+ const canType = element !== void 0 && canEditTextInPlace(element, request.text);
397
+ if (!canType) {
398
+ options.send({ type: "typing-refused", path });
399
+ return;
400
+ }
401
+ options.send({ type: "typing-started", path });
402
+ stopEditing = startInlineTextEditing(element, {
403
+ isSingleLine: request.isSingleLine,
404
+ onInput: (text) => {
405
+ options.send({ type: "typed", path, text });
406
+ options.onInput();
407
+ },
408
+ onEnd: (outcome) => {
409
+ stopEditing = void 0;
410
+ options.send({ type: "typing-ended", path, outcome });
411
+ }
412
+ });
413
+ placeCaretAt(element, request.point);
414
+ };
415
+ return { isTyping: () => stopEditing !== void 0, start, stop };
416
+ }
417
+
418
+ // src/bridge/connect-to-editor.ts
419
+ function connectToEditor(options) {
420
+ const editor = window.parent;
421
+ const isFramed = editor !== window;
422
+ if (!isFramed) return () => void 0;
423
+ const send = (message) => {
424
+ editor.postMessage(message, options.editorOrigin);
425
+ };
426
+ let connection;
427
+ const receive = (event) => {
428
+ const isFromEditor = event.source === editor && event.origin === options.editorOrigin;
429
+ const message = isFromEditor ? readEditorMessage(event.data) : void 0;
430
+ if (message === void 0) return;
431
+ if (message.type === "connect") {
432
+ connection?.stop();
433
+ connection = connectPage(document, message.schema, send);
434
+ return;
435
+ }
436
+ connection?.receive(message);
437
+ };
438
+ window.addEventListener("message", receive);
439
+ send({ type: "ready", protocol: PROTOCOL_VERSION });
440
+ return () => {
441
+ window.removeEventListener("message", receive);
442
+ connection?.stop();
443
+ };
444
+ }
445
+ function connectPage(document2, schema, send) {
446
+ const layout = createLayoutReports(document2, (measured) => {
447
+ send({ type: "layout", layout: measured });
448
+ });
449
+ const typing = createPageTyping({ document: document2, send, onInput: layout.schedule });
450
+ const renderer = createPageRenderer({
451
+ document: document2,
452
+ // Replacing the page under a text being typed would throw the typing away.
453
+ canReplacePage: () => !typing.isTyping(),
454
+ onRendered: () => {
455
+ layout.reportNow();
456
+ send({ type: "rendered" });
457
+ },
458
+ onFailed: (error) => {
459
+ console.error(error);
460
+ send({ type: "render-failed" });
461
+ }
462
+ });
463
+ const pressKey = (event) => {
464
+ const key = editorKeyOf(event);
465
+ const isTypingEscape = key === "escape" && typing.isTyping();
466
+ if (key === void 0 || isTypingEscape) return;
467
+ event.preventDefault();
468
+ typing.stop();
469
+ send({ type: "key", key });
470
+ };
471
+ const stopListening = listenToPage(document2, {
472
+ onHover: (path) => {
473
+ send({ type: "hover", path });
474
+ },
475
+ onClick: (pointer) => {
476
+ const target = pointer === void 0 ? void 0 : { path: pointer.path, point: pointer.point };
477
+ send({ type: "click", target });
478
+ },
479
+ onLinkHover: layout.hoverLink,
480
+ onLinkClick: (link) => {
481
+ send({ type: "link-click", href: link.href });
482
+ },
483
+ onKeyDown: pressKey,
484
+ onFileDragOver: (path) => {
485
+ const isTaken = imageFieldAt(schema, path) !== void 0;
486
+ send({ type: "file-drag", path: isTaken ? path : void 0 });
487
+ return isTaken;
488
+ },
489
+ onFileDragEnd: () => {
490
+ send({ type: "file-drag-end" });
491
+ },
492
+ onFileDrop: (path, file) => {
493
+ const isTaken = imageFieldAt(schema, path) !== void 0;
494
+ if (isTaken) send({ type: "file-drop", path, file });
495
+ },
496
+ onLayoutChange: layout.schedule
497
+ });
498
+ layout.reportNow();
499
+ const receive = (message) => {
500
+ switch (message.type) {
501
+ case "connect":
502
+ break;
503
+ case "render":
504
+ renderer.render(message.content);
505
+ break;
506
+ case "start-typing":
507
+ typing.start(message);
508
+ break;
509
+ case "stop-typing":
510
+ typing.stop();
511
+ break;
512
+ case "scroll-to":
513
+ findClosestMarkedElement(document2, message.path)?.scrollIntoView({
514
+ block: "nearest",
515
+ behavior: "smooth"
516
+ });
517
+ break;
518
+ }
519
+ };
520
+ return {
521
+ receive,
522
+ stop: () => {
523
+ typing.stop();
524
+ renderer.stop();
525
+ layout.stop();
526
+ stopListening();
527
+ }
528
+ };
529
+ }
530
+ function editorKeyOf(event) {
531
+ if (event.key === "Escape") return "escape";
532
+ return shortcutOf(event);
533
+ }
534
+ export {
535
+ connectPage,
536
+ connectToEditor
537
+ };