@seamlet/astro 0.7.0 → 0.8.0

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 CHANGED
@@ -162,6 +162,8 @@ import RichText from "@seamlet/astro/components/RichText.astro";
162
162
  </style>
163
163
  ```
164
164
 
165
+ When the marked element holds the component alone, the editor types the text right on the page: a person clicks a paragraph and types, marks the selected words bold or italic (⌘B, ⌘I or the toolbar beside the selection) and puts a link on them. The element is then rendered with the same elements the component puts out, so style them from around it, never by classes of your own on them.
166
+
165
167
  `plainTextOf` gives its words without marks, for a page title or a description.
166
168
 
167
169
  ## Presets
@@ -216,7 +218,7 @@ What was opened stays open while the page is rendered anew after every edit.
216
218
  ## What the integration adds
217
219
 
218
220
  - `/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.
219
- - 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.
221
+ - 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, formatted ones too.
220
222
  - `/seamlet-photos/<address>`: the site's own photo at an address its content gives it, found by `photos` of `defineSeamletSite`, for the CMS's editor.
221
223
  - `/media/<site>/<file>`: the photos uploaded in the CMS, taken from it on request; a static build copies every uploaded photo its built pages refer to next to them, so a static host serves them too.
222
224
 
package/dist/bridge.d.ts CHANGED
@@ -1,6 +1,6 @@
1
- import { F as Fields } from './content-DeBXwSmg.js';
2
- import { P as PageMessage, E as EditorMessage } from './messages-ClePO8N_.js';
3
- import './parse-content-DqToE6I6.js';
1
+ import { F as Fields } from './content-6k-E7NWf.js';
2
+ import { P as PageMessage, E as EditorMessage } from './messages-Dx0jNy3N.js';
3
+ import './parse-content-C9ldnmvj.js';
4
4
 
5
5
  interface BridgeOptions {
6
6
  /** The origin of the CMS whose editor may frame the page, such as `https://cms.example.com`. */
package/dist/bridge.js CHANGED
@@ -2,15 +2,30 @@ import {
2
2
  PROTOCOL_VERSION,
3
3
  carriesFiles,
4
4
  closestMarkedPath,
5
+ formatsAt,
5
6
  imageFieldAt,
7
+ insertPlainText,
8
+ isCollapsed,
9
+ linkSelectionAt,
10
+ rangeOf,
6
11
  readEditorMessage,
7
- shortcutOf
8
- } from "./chunk-4YLS3VPT.js";
12
+ readRichTextFrom,
13
+ readSelectionFrom,
14
+ renderInlineRichText,
15
+ renderRichText,
16
+ selectIn,
17
+ setLink,
18
+ shortcutOf,
19
+ toggleList,
20
+ toggleMark
21
+ } from "./chunk-27XIZEVS.js";
9
22
  import {
10
23
  FIELD_PATH_ATTRIBUTE,
11
24
  REVEALED_ATTRIBUTE,
12
25
  REVEAL_ATTRIBUTE,
13
- REVEAL_EVENT
26
+ REVEAL_EVENT,
27
+ asOneParagraph,
28
+ readRichText
14
29
  } from "./chunk-ZUJEM3P7.js";
15
30
 
16
31
  // src/bridge/page-markers.ts
@@ -28,6 +43,10 @@ function linkAround(target) {
28
43
  const isLink = isHtmlElement(element) && typeof href === "string";
29
44
  return isLink ? { element, href } : void 0;
30
45
  }
46
+ function isInTypedText(target) {
47
+ const editable = asElement(target)?.closest("[contenteditable]");
48
+ return editable !== null && editable !== void 0;
49
+ }
31
50
  function findMarkedElement(document2, path) {
32
51
  const element = document2.querySelector(
33
52
  `[${FIELD_PATH_ATTRIBUTE}="${CSS.escape(path)}"]`
@@ -170,7 +189,7 @@ var DRAG_GONE_AFTER_MS = 150;
170
189
  function listenToPage(document2, listeners) {
171
190
  const view = document2.defaultView;
172
191
  const hover = (event) => {
173
- const link = nearerLink(event.target);
192
+ const link = linkToFollow(event.target);
174
193
  listeners.onHover(
175
194
  link === void 0 ? markedElementAround(event.target)?.path : void 0
176
195
  );
@@ -182,7 +201,7 @@ function listenToPage(document2, listeners) {
182
201
  };
183
202
  const click = (event) => {
184
203
  event.preventDefault();
185
- const link = nearerLink(event.target);
204
+ const link = linkToFollow(event.target);
186
205
  if (link !== void 0) {
187
206
  listeners.onLinkClick(link);
188
207
  return;
@@ -256,6 +275,9 @@ function listenToPage(document2, listeners) {
256
275
  view?.removeEventListener("resize", listeners.onLayoutChange);
257
276
  };
258
277
  }
278
+ function linkToFollow(target) {
279
+ return isInTypedText(target) ? void 0 : nearerLink(target);
280
+ }
259
281
  function nearerLink(target) {
260
282
  const link = linkAround(target);
261
283
  const marked = markedElementAround(target);
@@ -326,7 +348,7 @@ function createLayoutReports(document2, report) {
326
348
  // src/bridge/page-rendering.ts
327
349
  function createPageRenderer(options) {
328
350
  let request;
329
- const render = (content) => {
351
+ const render2 = (content) => {
330
352
  request?.abort();
331
353
  const currentRequest = new AbortController();
332
354
  request = currentRequest;
@@ -344,7 +366,7 @@ function createPageRenderer(options) {
344
366
  });
345
367
  };
346
368
  return {
347
- render,
369
+ render: render2,
348
370
  stop: () => {
349
371
  request?.abort();
350
372
  }
@@ -501,12 +523,202 @@ function caretRangeAt(document2, point) {
501
523
  return lookup.caretRangeFromPoint?.(point.x, point.y) ?? void 0;
502
524
  }
503
525
 
526
+ // src/bridge/rich-text-editing.ts
527
+ var EDITABLE = "true";
528
+ var PASTED_LINE_BREAKS = /\s*\n\s*/gu;
529
+ var SPACE = " ";
530
+ function canEditRichTextInPlace(element, value, isInline) {
531
+ if (hasMarkedDescendants(element)) return false;
532
+ const normalized = readRichText(value) ?? [];
533
+ const expected = isInline ? asOneParagraph(normalized) : normalized;
534
+ const shown = readTextOf(element, isInline);
535
+ return JSON.stringify(shown) === JSON.stringify(expected);
536
+ }
537
+ function startRichTextEditing(element, options) {
538
+ const document2 = element.ownerDocument;
539
+ const view = document2.defaultView;
540
+ let lastSelection;
541
+ let reportFrame;
542
+ let blurTimer;
543
+ const readText = () => readTextOf(element, options.isInline);
544
+ const liveSelection = () => isSelectionIn(element) ? readSelectionFrom(element) : void 0;
545
+ const reportSelection = () => {
546
+ reportFrame = void 0;
547
+ options.onSelect(measureSelection(element, readText(), lastSelection));
548
+ };
549
+ const scheduleReport = () => {
550
+ if (reportFrame !== void 0) return;
551
+ reportFrame = view?.requestAnimationFrame(reportSelection);
552
+ };
553
+ const followSelection = () => {
554
+ const selection = document2.hasFocus() ? liveSelection() : void 0;
555
+ if (selection === void 0) return;
556
+ lastSelection = selection;
557
+ scheduleReport();
558
+ };
559
+ const reportInput = () => {
560
+ options.onInput(readText());
561
+ followSelection();
562
+ };
563
+ const change = (changed, selection) => {
564
+ render(element, changed, options.isInline);
565
+ lastSelection = selection;
566
+ if (document2.hasFocus()) selectIn(element, selection);
567
+ options.onInput(changed);
568
+ scheduleReport();
569
+ };
570
+ const format = (requested) => {
571
+ const selection = liveSelection() ?? lastSelection;
572
+ const isListInLine = requested === "list" && options.isInline;
573
+ if (selection === void 0 || isListInLine) return;
574
+ const current = readText();
575
+ const changed = requested === "list" ? toggleList(current, selection) : toggleMark(current, selection, requested);
576
+ change(changed, selection);
577
+ };
578
+ const linkTo = (href) => {
579
+ const selection = lastSelection;
580
+ if (selection === void 0) return;
581
+ const current = readText();
582
+ const target = isCollapsed(selection) ? linkSelectionAt(current, selection.start) : selection;
583
+ if (target === void 0) return;
584
+ change(setLink(current, target, href), selection);
585
+ };
586
+ const pressKey = (event) => {
587
+ const isEnd = event.key === "Enter" && options.isInline && !event.shiftKey;
588
+ const shortcut = formatShortcutOf(event);
589
+ if (event.key === "Escape") {
590
+ event.preventDefault();
591
+ end("reverted");
592
+ } else if (isEnd) {
593
+ event.preventDefault();
594
+ end("kept");
595
+ } else if (shortcut !== void 0) {
596
+ event.preventDefault();
597
+ if (shortcut !== "none") format(shortcut);
598
+ }
599
+ };
600
+ const pastePlainText = (event) => {
601
+ event.preventDefault();
602
+ const pasted = event.clipboardData?.getData("text/plain") ?? "";
603
+ const words = options.isInline ? pasted.replace(PASTED_LINE_BREAKS, SPACE) : pasted;
604
+ insertPlainText(element, words);
605
+ reportInput();
606
+ };
607
+ const refuseDrop = (event) => {
608
+ event.preventDefault();
609
+ };
610
+ const endOnBlur = () => {
611
+ clearTimeout(blurTimer);
612
+ blurTimer = setTimeout(() => {
613
+ const isFocusOnPage = document2.hasFocus();
614
+ const isFocusInText = document2.activeElement === element;
615
+ if (isFocusOnPage && !isFocusInText) end("kept");
616
+ });
617
+ };
618
+ const stopListening = () => {
619
+ clearTimeout(blurTimer);
620
+ if (reportFrame !== void 0) view?.cancelAnimationFrame(reportFrame);
621
+ element.removeEventListener("input", reportInput);
622
+ element.removeEventListener("keydown", pressKey);
623
+ element.removeEventListener("paste", pastePlainText);
624
+ element.removeEventListener("drop", refuseDrop);
625
+ element.removeEventListener("blur", endOnBlur);
626
+ document2.removeEventListener("selectionchange", followSelection);
627
+ document2.removeEventListener("scroll", scheduleReport, true);
628
+ view?.removeEventListener("resize", scheduleReport);
629
+ element.removeAttribute("contenteditable");
630
+ };
631
+ let hasEnded = false;
632
+ const end = (outcome) => {
633
+ if (hasEnded) return;
634
+ hasEnded = true;
635
+ stopListening();
636
+ options.onSelect(void 0);
637
+ options.onEnd(outcome);
638
+ };
639
+ element.setAttribute("contenteditable", EDITABLE);
640
+ element.addEventListener("input", reportInput);
641
+ element.addEventListener("keydown", pressKey);
642
+ element.addEventListener("paste", pastePlainText);
643
+ element.addEventListener("drop", refuseDrop);
644
+ element.addEventListener("blur", endOnBlur);
645
+ document2.addEventListener("selectionchange", followSelection);
646
+ document2.addEventListener("scroll", scheduleReport, true);
647
+ view?.addEventListener("resize", scheduleReport);
648
+ element.focus();
649
+ return {
650
+ format,
651
+ setLink: linkTo,
652
+ stop: () => {
653
+ end("kept");
654
+ }
655
+ };
656
+ }
657
+ function readTextOf(element, isInline) {
658
+ const read = readRichTextFrom(element);
659
+ return isInline ? asOneParagraph(read) : read;
660
+ }
661
+ function render(element, text, isInline) {
662
+ if (isInline) renderInlineRichText(element, text);
663
+ else renderRichText(element, text);
664
+ }
665
+ function measureSelection(element, text, selection) {
666
+ if (selection === void 0) return void 0;
667
+ const range = isSelectionIn(element) ? element.ownerDocument.getSelection()?.getRangeAt(0) : rangeOf(element, selection);
668
+ const box = (range === void 0 ? void 0 : boxOfRange(range)) ?? boxOf(element);
669
+ if (box === void 0) return void 0;
670
+ return {
671
+ box,
672
+ isCollapsed: isCollapsed(selection),
673
+ formats: formatsAt(text, selection)
674
+ };
675
+ }
676
+ function boxOfRange(range) {
677
+ const whole = range.getBoundingClientRect();
678
+ const rectangle = whole.width > 0 || whole.height > 0 ? whole : range.getClientRects()[0];
679
+ if (rectangle === void 0) return void 0;
680
+ const isShown = rectangle.width > 0 || rectangle.height > 0;
681
+ if (!isShown) return void 0;
682
+ return {
683
+ top: rectangle.top,
684
+ left: rectangle.left,
685
+ width: rectangle.width,
686
+ height: rectangle.height
687
+ };
688
+ }
689
+ function isSelectionIn(element) {
690
+ const selection = element.ownerDocument.getSelection();
691
+ const anchor = selection?.anchorNode ?? null;
692
+ const hasRange = (selection?.rangeCount ?? 0) > 0;
693
+ return hasRange && anchor !== null && element.contains(anchor);
694
+ }
695
+ function formatShortcutOf(event) {
696
+ const hasCommandKey = event.metaKey || event.ctrlKey;
697
+ if (!hasCommandKey || event.altKey || event.shiftKey) return void 0;
698
+ switch (event.code) {
699
+ case "KeyB":
700
+ return "bold";
701
+ case "KeyI":
702
+ return "italic";
703
+ case "KeyU":
704
+ return "none";
705
+ default:
706
+ return void 0;
707
+ }
708
+ }
709
+
504
710
  // src/bridge/page-typing.ts
505
711
  function createPageTyping(options) {
506
712
  let stopEditing;
713
+ let richEditing;
507
714
  const stop = () => {
508
715
  stopEditing?.();
509
716
  };
717
+ const ended = (path) => (outcome) => {
718
+ stopEditing = void 0;
719
+ richEditing = void 0;
720
+ options.send({ type: "typing-ended", path, outcome });
721
+ };
510
722
  const start = (request) => {
511
723
  stop();
512
724
  const { path } = request;
@@ -523,14 +735,43 @@ function createPageTyping(options) {
523
735
  options.send({ type: "typed", path, text });
524
736
  options.onInput();
525
737
  },
526
- onEnd: (outcome) => {
527
- stopEditing = void 0;
528
- options.send({ type: "typing-ended", path, outcome });
529
- }
738
+ onEnd: ended(path)
530
739
  });
531
740
  placeCaretAt(element, request.point);
532
741
  };
533
- return { isTyping: () => stopEditing !== void 0, start, stop };
742
+ const startRich = (request) => {
743
+ stop();
744
+ const { path, isInline } = request;
745
+ const element = findMarkedElement(options.document, path);
746
+ const canType = element !== void 0 && canEditRichTextInPlace(element, request.text, isInline);
747
+ if (!canType) {
748
+ options.send({ type: "typing-refused", path });
749
+ return;
750
+ }
751
+ options.send({ type: "typing-started", path });
752
+ const editing = startRichTextEditing(element, {
753
+ isInline,
754
+ onInput: (text) => {
755
+ options.send({ type: "rich-typed", path, text });
756
+ options.onInput();
757
+ },
758
+ onSelect: (selection) => {
759
+ options.send({ type: "typed-selection", path, selection });
760
+ },
761
+ onEnd: ended(path)
762
+ });
763
+ richEditing = editing;
764
+ stopEditing = editing.stop;
765
+ placeCaretAt(element, request.point);
766
+ };
767
+ return {
768
+ isTyping: () => stopEditing !== void 0,
769
+ start,
770
+ startRich,
771
+ format: (format) => richEditing?.format(format),
772
+ setLink: (href) => richEditing?.setLink(href),
773
+ stop
774
+ };
534
775
  }
535
776
 
536
777
  // src/bridge/connect-to-editor.ts
@@ -624,6 +865,15 @@ function connectPage(document2, schema, send) {
624
865
  case "start-typing":
625
866
  typing.start(message);
626
867
  break;
868
+ case "start-rich-typing":
869
+ typing.startRich(message);
870
+ break;
871
+ case "format-typed":
872
+ typing.format(message.format);
873
+ break;
874
+ case "link-typed":
875
+ typing.setLink(message.href);
876
+ break;
627
877
  case "stop-typing":
628
878
  typing.stop();
629
879
  break;