@rigel-cms/rigel 0.1.4-beta.1 → 1.0.0-rc.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.
Files changed (46) hide show
  1. package/README.md +4 -2
  2. package/package.json +3 -4
  3. package/src/article-body.ts +9 -2
  4. package/src/article-render.ts +27 -1
  5. package/src/config.ts +8 -0
  6. package/src/content.ts +3 -1
  7. package/src/editor/App.svelte +114 -25
  8. package/src/editor/blocks/embed.ts +21 -0
  9. package/src/editor/blocks/index.ts +3 -1
  10. package/src/editor/blocks/video.ts +35 -0
  11. package/src/editor/lib/ActionDialog.svelte +2 -1
  12. package/src/editor/lib/BlockEditor.svelte +84 -15
  13. package/src/editor/lib/CollectionPanel.svelte +9 -4
  14. package/src/editor/lib/ImagePicker.svelte +141 -47
  15. package/src/editor/lib/MediaPicker.svelte +73 -0
  16. package/src/editor/lib/PreviewFrame.svelte +64 -22
  17. package/src/editor/lib/PublishDialog.svelte +65 -0
  18. package/src/editor/lib/SchemaPanel.svelte +6 -1
  19. package/src/editor/lib/Select.svelte +5 -2
  20. package/src/editor/lib/VisualEditor.svelte +11 -6
  21. package/src/editor/lib/embed-preview.ts +57 -0
  22. package/src/editor/lib/embeds.ts +47 -0
  23. package/src/editor/lib/github-backend.ts +220 -20
  24. package/src/editor/lib/icons.ts +13 -0
  25. package/src/editor/lib/images.ts +20 -8
  26. package/src/editor/lib/media-library.ts +11 -1
  27. package/src/editor/lib/settings.ts +2 -2
  28. package/src/editor/lib/slugify.ts +1 -15
  29. package/src/editor/lib/tooltip.ts +23 -4
  30. package/src/editor/styles.css +44 -13
  31. package/src/env.d.ts +1 -1
  32. package/src/index.ts +4 -6
  33. package/src/json-output.ts +5 -0
  34. package/src/manifest/types.ts +1 -0
  35. package/src/media-types.ts +14 -1
  36. package/src/preview.ts +14 -3
  37. package/src/routes/local-documents.ts +4 -4
  38. package/src/routes/local-media.ts +33 -28
  39. package/src/routes/local-menu.ts +2 -1
  40. package/src/routes/local-post.ts +18 -5
  41. package/src/routes/media-files.ts +36 -1
  42. package/src/routes/media-validation.ts +2 -2
  43. package/src/schema/paths.ts +3 -1
  44. package/src/schema/store.ts +24 -3
  45. package/src/slugs.ts +46 -0
  46. package/src/vite/manifest-plugin.ts +9 -2
@@ -7,7 +7,10 @@
7
7
  import * as Links from "@lexical/link";
8
8
  import * as Tables from "@lexical/table";
9
9
  import { ImageNode, type ImageData } from "../blocks/image";
10
+ import { VideoNode, type VideoData } from "../blocks/video";
11
+ import { EmbedNode, type EmbedData } from "../blocks/embed";
10
12
  import ImagePicker from "./ImagePicker.svelte";
13
+ import MediaPicker from "./MediaPicker.svelte";
11
14
  import type { StockResult } from "./images";
12
15
  import { safePreviewUrl } from "../../preview-urls";
13
16
  import { blockTypes, blockNodes } from "../blocks";
@@ -58,7 +61,12 @@
58
61
  let linkPosition = $state("");
59
62
  let editorHost: HTMLDivElement;
60
63
  let imageJob: { key: string; replace: boolean } | null = null;
64
+ let mediaJob: { key: string; replace: boolean } | null = null;
65
+ let mediaPicker: MediaPicker;
61
66
  let imageData = $state<ImageData | null>(null);
67
+ let embedData = $state<EmbedData | null>(null);
68
+ let videoData = $state<VideoData | null>(null);
69
+ const selectedVideo = $derived(embedData ?? videoData);
62
70
  let altOpen = $state(false);
63
71
  let altText = $state("");
64
72
  let altKey = "";
@@ -81,7 +89,8 @@
81
89
  let tableRows = $state(0);
82
90
  let tableColumns = $state(0);
83
91
  let tableHeader = $state(false);
84
- const choices = $derived(blockTypes.filter(block => block.label.toLowerCase().includes((slash ?? "").toLowerCase())));
92
+ const mediaTypes = [{ value: "video", label: "Video" }, { value: "youtube", label: "YouTube" }, { value: "vimeo", label: "Vimeo" }] as const;
93
+ const choices = $derived([...blockTypes, ...mediaTypes].filter(block => block.label.toLowerCase().includes((slash ?? "").toLowerCase())));
85
94
  const textBlocks = blockTypes.filter(block => !["image", "table", "bullet", "number"].includes(block.value));
86
95
  const activeBlock = $derived(blockTypes.find(block => block.value === blockType));
87
96
 
@@ -106,6 +115,8 @@
106
115
  }
107
116
  if (node) {
108
117
  imageData = node instanceof ImageNode ? { ...node.__image } : null;
118
+ embedData = node instanceof EmbedNode ? { ...node.__embed } : null;
119
+ videoData = node instanceof VideoNode ? { ...node.__video } : null;
109
120
  activeKey = node.getKey();
110
121
  blockType = Rich.$isHeadingNode(node) ? node.getTag() : Lists.$isListNode(node) ? node.getListType() : node.getType();
111
122
  activeMarks = L.$isRangeSelection(selection) ? marks.filter(mark => selection.hasFormat(mark.type)).map(mark => mark.type) : [];
@@ -125,16 +136,17 @@
125
136
  positionInsertion();
126
137
  }
127
138
  function positionFloating() {
128
- if (imageJob) return;
139
+ if (imageJob || mediaJob) return;
129
140
  const selection = view.getSelection();
130
141
  if (!root.ownerDocument.hasFocus()) { floating = ""; return; }
131
142
  if (editorHost.contains(root.ownerDocument.activeElement) && !root.contains(root.ownerDocument.activeElement)) return;
132
- if (inline || imageData) {
133
- if (imageData ? root.ownerDocument.activeElement !== root : !selection?.rangeCount || !root.contains(selection.anchorNode) || !root.contains(selection.focusNode)) { floating = ""; return; }
143
+ if (inline || imageData || selectedVideo) {
144
+ if (imageData || selectedVideo ? root.ownerDocument.activeElement !== root : !selection?.rangeCount || !root.contains(selection.anchorNode) || !root.contains(selection.focusNode)) { floating = ""; return; }
134
145
  const block = editor.getElementByKey(activeKey);
135
146
  if (!block) { floating = ""; return; }
136
- const rect = imageData || selection!.isCollapsed ? block.getBoundingClientRect() : selection!.getRangeAt(0).getBoundingClientRect();
147
+ const rect = imageData || selectedVideo || selection!.isCollapsed ? block.getBoundingClientRect() : selection!.getRangeAt(0).getBoundingClientRect();
137
148
  if (rect.bottom < 0 || rect.top > view.innerHeight) { floating = ""; return; }
149
+ if (selectedVideo) { floating = ""; return; }
138
150
  const toolbarRect = editorHost.querySelector(imageData ? '.image-toolbar' : '.body-toolbar')?.getBoundingClientRect();
139
151
  const toolbarHeight = toolbarRect?.height || 42, toolbarWidth = toolbarRect?.width || 330;
140
152
  const top = rect.top >= toolbarHeight + 12 ? rect.top - toolbarHeight - 8 : rect.bottom + 8;
@@ -192,7 +204,7 @@
192
204
  }, L.COMMAND_PRIORITY_CRITICAL));
193
205
  cleanups.push(editor.registerCommand(L.DROP_COMMAND, event => { event.preventDefault(); return true; }, L.COMMAND_PRIORITY_CRITICAL));
194
206
  cleanups.push(editor.registerCommand(L.KEY_DOWN_COMMAND, event => {
195
- if ((inline || imageData) && event.altKey && event.key === "F10") { event.preventDefault(); editorHost.querySelector<HTMLButtonElement>(imageData ? '.image-toolbar button' : '.body-toolbar button')?.focus(); return true; }
207
+ if ((inline || imageData || selectedVideo) && event.altKey && event.key === "F10") { event.preventDefault(); (selectedVideo ? editor.getElementByKey(activeKey)?.querySelector<HTMLButtonElement>('[data-rigel-edit-video]') : editorHost.querySelector<HTMLButtonElement>(imageData ? '.image-toolbar button' : '.body-toolbar button'))?.focus(); return true; }
196
208
  return slashKeydown(event);
197
209
  }, L.COMMAND_PRIORITY_HIGH));
198
210
  const updates = editor.registerUpdateListener(({ editorState, dirtyElements, dirtyLeaves }) => {
@@ -209,7 +221,7 @@
209
221
  toolbarObserver.observe(root);
210
222
  const outside = (event: PointerEvent) => {
211
223
  if (linkOpen && !editorHost.querySelector('.markdown-link')?.contains(event.target as Node)) linkOpen = false;
212
- if ((inline || imageData) && !root.contains(event.target as Node) && !editorHost.contains(event.target as Node)) { floating = ""; linkOpen = false; altOpen = false; closeMenu(); }
224
+ if ((inline || imageData || selectedVideo) && !root.contains(event.target as Node) && !editorHost.contains(event.target as Node)) { floating = ""; linkOpen = false; altOpen = false; closeMenu(); }
213
225
  if (slash !== null && !slashMenu?.contains(event.target as Node) && !root.contains(event.target as Node) && !(event.target as Element).closest('.block-add')) dismissSlash();
214
226
  };
215
227
  const escape = (event: KeyboardEvent) => { if (event.key === "Escape") { endDrag(true); closeMenu(); dismissSlash(); if (inline) { floating = ""; editor.focus(); } } };
@@ -271,6 +283,12 @@
271
283
  return node.getChildren().flatMap(child => L.$isElementNode(child) && !child.isInline() ? [...inlineNodes(child), L.$createLineBreakNode()] : [child]);
272
284
  }
273
285
  function changeBlock(type: string, fromSlash = false) {
286
+ if (type === "video" || type === "youtube" || type === "vimeo") {
287
+ mediaJob = { key: fromSlash ? slashKey : activeKey, replace: fromSlash && !adding };
288
+ dismissSlash(); closeMenu();
289
+ mediaPicker.pick(type);
290
+ return;
291
+ }
274
292
  const definition = blockTypes.find(block => block.value === type);
275
293
  if (!definition) return;
276
294
  if (!fromSlash && type === blockType) { dismissSlash(); closeMenu(); return; }
@@ -390,6 +408,45 @@
390
408
  });
391
409
  imageJob = null; editor.focus();
392
410
  }
411
+ function editMedia(key: string) {
412
+ editor.getEditorState().read(() => {
413
+ const node = L.$getNodeByKey(key);
414
+ if (!(node instanceof EmbedNode || node instanceof VideoNode)) return;
415
+ mediaJob = { key, replace: true };
416
+ mediaPicker.edit(node instanceof EmbedNode ? node.__embed : node.__video);
417
+ });
418
+ }
419
+ export function editVideoAt(index: number) {
420
+ const key = editor.getEditorState().read(() => L.$getRoot().getChildren()[index]?.getKey());
421
+ if (key) editMedia(key);
422
+ }
423
+ function insertMedia(create: () => L.LexicalNode) {
424
+ const job = mediaJob ?? { key: activeKey, replace: false };
425
+ editor.update(() => {
426
+ const node = create(), previous = L.$getNodeByKey(job.key), paragraph = L.$createParagraphNode();
427
+ if (job.replace && (previous instanceof EmbedNode || previous instanceof VideoNode)) {
428
+ if (previous instanceof VideoNode && node instanceof VideoNode) node.__video = { ...previous.__video, ...node.__video };
429
+ previous.replace(node);
430
+ const selection = L.$createNodeSelection(); selection.add(node.getKey()); L.$setSelection(selection); hoverKey = "";
431
+ return;
432
+ }
433
+ if (previous && job.replace) previous.replace(node);
434
+ else if (previous && L.$isRootNode(previous.getParent())) previous.insertAfter(node);
435
+ else L.$getRoot().append(node);
436
+ node.insertAfter(paragraph);
437
+ paragraph.select();
438
+ hoverKey = "";
439
+ });
440
+ mediaJob = null;
441
+ refocus();
442
+ }
443
+ function pickVideo(video: VideoData) {
444
+ if (!safePreviewUrl(video.src, true)) return;
445
+ insertMedia(() => new VideoNode(video));
446
+ }
447
+ function pickEmbed(embed: EmbedData) {
448
+ insertMedia(() => new EmbedNode({ ...embed, privacy: embed.privacy !== false }));
449
+ }
393
450
  function closeMenu() { for (const popup of [menu, tableMenu, formatMenu]) if (popup?.matches(":popover-open")) popup.hidePopover(); }
394
451
  function openMenu(event?: MouseEvent, popup = menu) {
395
452
  if (suppressClick) { suppressClick = false; return; }
@@ -497,6 +554,11 @@
497
554
  }
498
555
  function contentClick(event: MouseEvent) {
499
556
  const element = (event.target as Element).closest<HTMLElement>('figure[data-block-key],hr[data-block-key]');
557
+ if (element && (event.target as Element).closest('[data-rigel-edit-video]')) {
558
+ event.preventDefault();
559
+ if (event.type === "click") editMedia(element.dataset.blockKey!);
560
+ return;
561
+ }
500
562
  if (event.type === "pointerdown" && !element) {
501
563
  editor.update(() => { if (L.$isNodeSelection(L.$getSelection())) L.$setSelection(null); }, { discrete: true });
502
564
  return;
@@ -520,23 +582,28 @@
520
582
  <button type="button" disabled={!tableCell || tableColumns >= MAX_TABLE_COLUMNS} onpointerdown={event => event.preventDefault()} onclick={() => tableAction("column")}><Icon name="plus" />Add column right</button>
521
583
  <button type="button" disabled={!tableCell || tableRows <= 1} onpointerdown={event => event.preventDefault()} onclick={() => tableAction("delete-row")}><Icon name="minus" />Remove row</button>
522
584
  <button type="button" disabled={!tableCell || tableColumns <= 1} onpointerdown={event => event.preventDefault()} onclick={() => tableAction("delete-column")}><Icon name="minus" />Remove column</button>
523
- <label class="inline"><Icon name="table" /><span>Header row</span><input type="checkbox" role="switch" class="switch" checked={tableHeader} disabled={!tableCell} onchange={event => tableAction("header", event.currentTarget.checked)} /></label>
585
+ <label class="inline"><input type="checkbox" role="switch" class="switch" checked={tableHeader} disabled={!tableCell} onchange={event => tableAction("header", event.currentTarget.checked)} /><Icon name="table" /><span>Header row</span></label>
524
586
  </div>
525
587
  {/snippet}
526
588
  {#snippet toolbar(image: boolean)}
527
- <div class="body-toolbar formatting-toolbar" class:image-toolbar={image} role="group" aria-label={image ? "Image editing" : "Body formatting"} style={inline || image ? floating : undefined} hidden={markdownView || ((inline || image) && !floating) || (image && !imageData)}>
589
+ <div class="body-toolbar formatting-toolbar" class:image-toolbar={image} role="group" aria-label={image ? "Image editing" : "Body formatting"} style={inline || image ? floating : undefined} hidden={markdownView || ((inline || image) && !floating) || (image && !imageData) || (inline && !!selectedVideo)}>
528
590
  {#if image}
529
591
  <button type="button" aria-label="Edit alt text" data-tooltip="Edit alt text" onpointerdown={event => event.preventDefault()} onclick={openAlt}><Icon name="textAa" size={18} /></button>
530
592
  <button type="button" aria-label={imageData?.url ? "Edit image link" : "Add image link"} data-tooltip={imageData?.url ? "Edit image link" : "Add image link"} onpointerdown={event => event.preventDefault()} onclick={openLink}><Icon name="link" size={18} /></button>
531
593
  {:else}
532
- <Select ariaLabel="Block type" data-tooltip={activeBlock?.label ?? "Block type"} icon={activeBlock?.icon ?? "paragraph"} value={imageData ? "" : blockType} options={textBlocks} disabled={!!imageData} onchange={next => { hoverKey = ""; changeBlock(String(next)); }} />
533
- {#if !inline || !["image", "divider"].includes(blockType)}<span class="toolbar-separator" aria-hidden="true"></span>{@render formatButtons()}<span class="toolbar-separator" aria-hidden="true"></span>{/if}
594
+ <Select ariaLabel="Block type" data-tooltip={activeBlock?.label ?? "Block type"} icon={activeBlock?.icon ?? "paragraph"} value={imageData || embedData ? "" : blockType} options={textBlocks} disabled={!!imageData} onchange={next => { hoverKey = ""; changeBlock(String(next)); }} />
595
+ {#if !inline || !["image", "video", "embed", "divider"].includes(blockType)}<span class="toolbar-separator" aria-hidden="true"></span>{@render formatButtons()}<span class="toolbar-separator" aria-hidden="true"></span>{/if}
596
+ {/if}
597
+ {#if image}
598
+ <ImagePicker toolbar value={imageData?.src ?? ""} altText={imageData?.alt ?? ""} triggerLabel="Replace image" onopen={() => { if (!imageJob) imageJob = { key: activeKey, replace: true }; linkOpen = false; altOpen = false; }} onpick={pickImage} onclose={() => { imageJob = null; refocus(); }} />
599
+ {:else}
600
+ <button type="button" data-media-trigger aria-label="Insert media" data-tooltip="Insert media" aria-haspopup="menu" onpointerdown={event => event.preventDefault()} onclick={event => mediaPicker.toggleMenu(event.currentTarget)}><Icon name="image" size={18} /></button>
601
+ <ImagePicker toolbar hiddenTrigger value="" onopen={() => { if (!imageJob) imageJob = { key: activeKey, replace: false }; linkOpen = false; altOpen = false; }} onpick={pickImage} onclose={() => { imageJob = null; refocus(); }} />
534
602
  {/if}
535
- <ImagePicker toolbar value={image ? imageData?.src ?? "" : ""} altText={image ? imageData?.alt ?? "" : ""} triggerLabel={image ? "Replace image" : "Insert image"} onopen={() => { if (!imageJob) imageJob = { key: activeKey, replace: image }; linkOpen = false; altOpen = false; }} onpick={pickImage} onclose={() => { imageJob = null; refocus(); }} />
536
603
  {#if !image}
537
604
  <button type="button" aria-label="Table" data-tooltip="Table" aria-pressed={blockType === "table"} popovertarget={`${id}-table`} onpointerdown={event => event.preventDefault()} onclick={event => { event.preventDefault(); tableMenu.matches(':popover-open') ? closeMenu() : openMenu(event, tableMenu); }}><Icon name="table" size={18} /></button>
538
- <button type="button" aria-label="Bulleted list" data-tooltip="Bulleted list" aria-pressed={listType === "bullet"} disabled={["image", "divider"].includes(blockType)} onpointerdown={event => event.preventDefault()} onclick={() => toggleList("bullet")}><Icon name="listBullets" size={18} /></button>
539
- <button type="button" aria-label="Numbered list" data-tooltip="Numbered list" aria-pressed={listType === "number"} disabled={["image", "divider"].includes(blockType)} onpointerdown={event => event.preventDefault()} onclick={() => toggleList("number")}><Icon name="listNumbers" size={18} /></button>
605
+ <button type="button" aria-label="Bulleted list" data-tooltip="Bulleted list" aria-pressed={listType === "bullet"} disabled={["image", "video", "embed", "divider"].includes(blockType)} onpointerdown={event => event.preventDefault()} onclick={() => toggleList("bullet")}><Icon name="listBullets" size={18} /></button>
606
+ <button type="button" aria-label="Numbered list" data-tooltip="Numbered list" aria-pressed={listType === "number"} disabled={["image", "video", "embed", "divider"].includes(blockType)} onpointerdown={event => event.preventDefault()} onclick={() => toggleList("number")}><Icon name="listNumbers" size={18} /></button>
540
607
  {#if !inline}
541
608
  <button type="button" class="block-history" disabled={!canUndo} onclick={() => editor.dispatchCommand(L.UNDO_COMMAND, undefined)}>Undo</button>
542
609
  <button type="button" class="block-history" disabled={!canRedo} onclick={() => editor.dispatchCommand(L.REDO_COMMAND, undefined)}>Redo</button>
@@ -547,9 +614,11 @@
547
614
  </div>
548
615
  {/snippet}
549
616
  <div class="block-editor" bind:this={editorHost}>
617
+ <MediaPicker bind:this={mediaPicker} hiddenTrigger onopen={() => editor.blur()} onimage={() => { if (!imageJob) imageJob = { key: activeKey, replace: false }; editorHost.querySelector<HTMLButtonElement>('.body-toolbar:not(.image-toolbar) [data-image-trigger]')?.click(); }} onvideo={pickVideo} onembed={pickEmbed} onclose={() => { mediaJob = null; refocus(); }} />
550
618
  {#if !inline}<div class="markdown-heading"><div class="field-label">Body</div><div class="markdown-modes"><button type="button" aria-pressed={markdownView} onclick={() => { markdownView = !markdownView; linkOpen = false; closeMenu(); dismissSlash(); }}>View as Markdown</button></div></div>{/if}
551
619
  {@render toolbar(inline && !!imageData)}
552
620
  {#if !inline}{@render toolbar(true)}{/if}
621
+
553
622
  {#if error}<p role="alert">{error}</p>{/if}
554
623
  {#if linkOpen}
555
624
  <div class="markdown-link" class:image-form={!!imageData} style={linkPosition} id={`${id}-link`} role="group" aria-label="Edit link" onkeydown={event => { if (event.key === "Escape") { event.stopPropagation(); linkOpen = false; refocus(); } if (event.key === "Enter") { event.preventDefault(); applyLink(); } }}>
@@ -573,7 +642,7 @@
573
642
  </div>
574
643
  </div>
575
644
  {/if}
576
- {#if !inline && !imageData && floating && !markdownView}<div class="block-float" style={floating} role="group" aria-label="Selected text formatting">{@render formatButtons()}</div>{/if}
645
+ {#if !inline && !imageData && !selectedVideo && floating && !markdownView}<div class="block-float" style={floating} role="group" aria-label="Selected text formatting">{@render formatButtons()}</div>{/if}
577
646
  {#if markdownView}
578
647
  <div class="block-markdown-view">{#if inline}<button type="button" class="outlined" onclick={async () => { markdownView = false; await tick(); editor.focus(); }}>Close Markdown view</button>{/if}<pre class="block-markdown" aria-label="Body as Markdown" tabindex="0">{articleMarkdown(value)}</pre></div>
579
648
  {/if}
@@ -13,12 +13,15 @@
13
13
  import { referenceOptions } from "../../manifest/references";
14
14
  import { isSeoField } from "../../manifest/seo";
15
15
  import type { ContentDocument } from "../../schema/types";
16
+ import { slugify } from "../../slugs";
16
17
 
17
- let { fields, values, body, selected, focusToken, devMode, documents = [], bodyInline = false, oneditbody = () => {}, onchange, onbody }: {
18
+ let { fields, values, storageSlug, body, selected, focusToken, devMode, documents = [], bodyInline = false, bodyLoading = false, oneditbody = () => {}, onchange, onbody }: {
18
19
  fields: CollectionField[];
19
20
  values: Record<string, unknown>;
21
+ storageSlug: string;
20
22
  body: ArticleBody;
21
23
  bodyInline?: boolean;
24
+ bodyLoading?: boolean;
22
25
  oneditbody?: () => void;
23
26
  selected: SelectedField | null;
24
27
  focusToken: number;
@@ -49,7 +52,9 @@
49
52
  <div id="properties-content" role="tabpanel" aria-labelledby="properties-tab-content" hidden={tab !== "content"}>
50
53
  <section>
51
54
  <h3>Article details</h3>
52
- {#each fields.filter(field => !isSeoField(field.key)) as field (field.key)}
55
+ <label><span>URL slug</span><input type="text" value={String(values.slug ?? storageSlug)} oninput={(event) => onchange("slug", event.currentTarget.value)} onblur={() => { if (Object.hasOwn(values, "slug")) onchange("slug", slugify(String(values.slug))); }} /></label>
56
+ <p class="hint">Changes the URL, not the content filename. The website route must use the slug metadata.</p>
57
+ {#each fields.filter(field => field.key !== "slug" && !isSeoField(field.key)) as field (field.key)}
53
58
  <div data-collection-field={field.key}>
54
59
  {#if field.type === "reference"}
55
60
  <ReferenceSelect label={field.label} value={values[field.key]} options={referenceOptions(field, documents)} multiple={field.multiple} onchange={(value) => onchange(field.key, value)} />
@@ -79,8 +84,8 @@
79
84
  </section>
80
85
  <section data-collection-field="$body">
81
86
  {#if bodyInline}
82
- <button class="outlined" onclick={oneditbody}>Edit article body in preview</button>
83
- <p class="hint">Click the article body in the preview to edit. Content changes require Save.</p>
87
+ <button class="outlined" disabled={bodyLoading} onclick={oneditbody}>Edit article body in preview</button>
88
+ <p class="hint">{bodyLoading ? "Loading preview…" : "Click the article body in the preview to edit. Content changes require Save."}</p>
84
89
  {:else}<BlockEditor value={body} oninput={onbody} />{/if}
85
90
  </section>
86
91
  </div>