@rigel-cms/rigel 0.2.0-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 (39) 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/editor/App.svelte +6 -7
  7. package/src/editor/blocks/embed.ts +21 -0
  8. package/src/editor/blocks/index.ts +3 -1
  9. package/src/editor/blocks/video.ts +35 -0
  10. package/src/editor/lib/ActionDialog.svelte +2 -1
  11. package/src/editor/lib/BlockEditor.svelte +84 -15
  12. package/src/editor/lib/CollectionPanel.svelte +4 -3
  13. package/src/editor/lib/ImagePicker.svelte +141 -47
  14. package/src/editor/lib/MediaPicker.svelte +73 -0
  15. package/src/editor/lib/Select.svelte +5 -2
  16. package/src/editor/lib/VisualEditor.svelte +11 -6
  17. package/src/editor/lib/embed-preview.ts +57 -0
  18. package/src/editor/lib/embeds.ts +47 -0
  19. package/src/editor/lib/github-backend.ts +2 -1
  20. package/src/editor/lib/icons.ts +13 -0
  21. package/src/editor/lib/images.ts +20 -8
  22. package/src/editor/lib/media-library.ts +11 -1
  23. package/src/editor/lib/settings.ts +2 -2
  24. package/src/editor/lib/tooltip.ts +23 -4
  25. package/src/editor/styles.css +29 -12
  26. package/src/env.d.ts +1 -1
  27. package/src/index.ts +4 -6
  28. package/src/json-output.ts +5 -0
  29. package/src/manifest/types.ts +1 -0
  30. package/src/media-types.ts +14 -1
  31. package/src/preview.ts +7 -3
  32. package/src/routes/local-documents.ts +1 -1
  33. package/src/routes/local-media.ts +33 -28
  34. package/src/routes/local-menu.ts +2 -1
  35. package/src/routes/local-post.ts +2 -1
  36. package/src/routes/media-files.ts +36 -1
  37. package/src/routes/media-validation.ts +2 -2
  38. package/src/schema/store.ts +3 -1
  39. package/src/vite/manifest-plugin.ts +3 -0
package/README.md CHANGED
@@ -2,8 +2,6 @@
2
2
 
3
3
  A CMS for Astro sites. You edit the text, git keeps the history.
4
4
 
5
- `@rigel-cms/rigel` is a beta package and a work in progress. Expect bugs and breaking changes.
6
-
7
5
  ## Collection validation
8
6
 
9
7
  New collection drafts initialize every configured field, including Rigel's automatic `description`, `metaTitle`, and `ogImage` strings. Existing content is never filled in automatically; unknown properties are preserved and missing optional fields remain allowed.
@@ -12,6 +10,10 @@ Rigel validates configured collection field types and `required: true` on load a
12
10
 
13
11
  Astro content schemas remain the host site's contract: Rigel does not discover or execute them. Align Rigel field types and requiredness with the host schema. For example, Astro's `z.string()` accepts an initialized empty description; `z.string().min(1)` also needs Rigel's `required: true`.
14
12
 
13
+ ## Media uploads
14
+
15
+ Set `maxFileSize` in the `rigel()` options in `astro.config.mjs`. The value is bytes and defaults to `2 * 1024 * 1024` (2 MB). For example, `rigel({ maxFileSize: 50 * 1024 * 1024 })` allows 50 MB per file. The limit applies to uploads and downloaded image imports, not existing files or linked videos. Your host may impose its own limit.
16
+
15
17
  ## License
16
18
 
17
19
  [Apache-2.0](LICENSE). Copyright 2026 [Alex Zappa](https://alex.zappa.dev/) ([FreshJuice](https://freshjuice.dev/)).
package/package.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "@rigel-cms/rigel",
3
- "version": "0.2.0-beta.1",
3
+ "version": "1.0.0-rc.1",
4
4
  "publishConfig": {
5
5
  "access": "public",
6
- "tag": "beta"
6
+ "tag": "rc"
7
7
  },
8
8
  "description": "Rigel CMS — the copy editor for Astro sites. Clients edit text, git keeps the history.",
9
9
  "author": {
@@ -32,8 +32,7 @@
32
32
  "scripts": {
33
33
  "build": "pnpm typecheck",
34
34
  "test": "vitest run",
35
- "typecheck": "tsc --noEmit",
36
- "publish:beta": "npm publish --access public --tag beta"
35
+ "typecheck": "tsc --noEmit"
37
36
  },
38
37
  "exports": {
39
38
  ".": "./src/index.ts",
@@ -1,5 +1,6 @@
1
1
  import type { SerializedEditorState } from "lexical";
2
2
  import { safePreviewUrl } from "./preview-urls";
3
+ import { safeEmbed } from "./editor/lib/embeds";
3
4
 
4
5
  export type ArticleBody = SerializedEditorState;
5
6
  export type ArticleNode = { type: string; version: number; children?: ArticleNode[]; text?: string; format?: number | string; tag?: string; listType?: string; start?: number; url?: string; src?: string; alt?: string; credit?: string; creditURL?: string; [key: string]: unknown };
@@ -12,7 +13,7 @@ export function emptyBody(): ArticleBody {
12
13
  }
13
14
 
14
15
  export function validateBody(value: unknown): asserts value is ArticleBody {
15
- const types = new Set(["root", "paragraph", "heading", "quote", "list", "listitem", "code", "text", "linebreak", "link", "image", "divider", "table", "tablerow", "tablecell"]);
16
+ const types = new Set(["root", "paragraph", "heading", "quote", "list", "listitem", "code", "text", "linebreak", "link", "image", "video", "embed", "divider", "table", "tablerow", "tablecell"]);
16
17
  let count = 0;
17
18
  let textSize = 0;
18
19
  function check(raw: unknown, parent = "", depth = 0) {
@@ -43,9 +44,15 @@ export function validateBody(value: unknown): asserts value is ArticleBody {
43
44
  if (node.type === "image" && !safePreviewUrl(node.src!, true)) throw new Error("Unsafe image URL");
44
45
  if (node.type === "image" && node.url !== undefined && (typeof node.url !== "string" || node.url && !safePreviewUrl(node.url))) throw new Error("Unsafe image link URL");
45
46
  if (node.type === "link" && !safePreviewUrl(node.url!)) throw new Error("Unsafe link URL");
47
+ if (node.type === "video" && (typeof node.src !== "string" || !safePreviewUrl(node.src, true))) throw new Error("Unsafe video URL");
48
+ if (node.type === "video" && node.poster !== undefined && (typeof node.poster !== "string" || !safePreviewUrl(node.poster, true))) throw new Error("Unsafe video poster URL");
49
+ if (node.type === "video" && node.caption !== undefined && typeof node.caption !== "string") throw new Error("Invalid video caption");
50
+ if (node.type === "video") for (const key of ["muted", "controls"]) if (node[key] !== undefined && typeof node[key] !== "boolean") throw new Error("Invalid video playback setting");
51
+ if (node.type === "embed" && (!safeEmbed(node) || typeof node.privacy !== "boolean")) throw new Error("Invalid video embed");
52
+ if (node.type === "embed" && node.title !== undefined && typeof node.title !== "string") throw new Error("Invalid embed title");
46
53
  for (const key of ["credit", "creditURL"]) if (node[key] !== undefined && typeof node[key] !== "string") throw new Error("Invalid image credit");
47
54
  if (node.creditURL && !safePreviewUrl(node.creditURL)) throw new Error("Unsafe image credit URL");
48
- if (!["text", "linebreak", "image", "divider"].includes(node.type)) {
55
+ if (!["text", "linebreak", "image", "video", "embed", "divider"].includes(node.type)) {
49
56
  if (!Array.isArray(node.children)) throw new Error("Article block requires children");
50
57
  for (const child of node.children) check(child, node.type, depth + 1);
51
58
  if (["table", "tablerow", "tablecell"].includes(node.type) && !node.children.length) throw new Error("Table elements cannot be empty");
@@ -1,5 +1,6 @@
1
1
  import { validateBody, type ArticleBody, type ArticleNode } from "./article-body";
2
2
  import { safePreviewUrl } from "./preview-urls";
3
+ import { embedSrc, embedWatchUrl, safeEmbed, embedProviderLabel } from "./editor/lib/embeds";
3
4
 
4
5
  const escape = (text: unknown) => String(text ?? "").replace(/[&<>"']/g, character => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[character]!);
5
6
 
@@ -10,7 +11,7 @@ function renderText(node: ArticleNode): string {
10
11
  return text;
11
12
  }
12
13
 
13
- export function renderArticle(body: ArticleBody): string {
14
+ export function renderArticle(body: ArticleBody, options: { preview?: boolean } = {}): string {
14
15
  validateBody(body);
15
16
  function walk(node: ArticleNode): string {
16
17
  const content = () => (node.children ?? []).map(walk).join("");
@@ -32,6 +33,20 @@ export function renderArticle(body: ArticleBody): string {
32
33
  if (node.type === "tablecell") return Number(node.headerState) ? `<th scope="${Number(node.headerState) & 1 ? "col" : "row"}">${content()}</th>` : `<td>${content()}</td>`;
33
34
  if (node.type === "link") return safePreviewUrl(node.url!) ? `<a href="${escape(node.url)}">${content()}</a>` : content();
34
35
  if (node.type === "divider") return "<hr>";
36
+ if (node.type === "video") {
37
+ if (!safePreviewUrl(node.src!, true)) return "";
38
+ const poster = typeof node.poster === "string" && safePreviewUrl(node.poster, true) ? ` poster="${escape(node.poster)}"` : "";
39
+ const caption = node.caption ? `<figcaption>${escape(node.caption)}</figcaption>` : "";
40
+ return `<figure class="media-video"><video${node.controls !== false ? " controls" : ""}${node.muted === true ? " muted" : ""} preload="metadata" playsinline src="${escape(node.src)}"${poster}></video>${caption}</figure>`;
41
+ }
42
+ if (node.type === "embed") {
43
+ const ref = safeEmbed(node);
44
+ if (!ref) return "";
45
+ const title = escape(typeof node.title === "string" && node.title ? node.title : `${embedProviderLabel(ref)} video`);
46
+ if (options.preview) return `<figure data-rigel-embed="${ref.provider}" data-rigel-video-id="${ref.id}" data-rigel-video-title="${title}"></figure>`;
47
+ const src = escape(embedSrc(ref, node.privacy !== false));
48
+ return `<figure class="media-embed"><iframe src="${src}" title="${title}" loading="lazy" allow="accelerometer; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen referrerpolicy="strict-origin-when-cross-origin"></iframe></figure>`;
49
+ }
35
50
  if (node.type === "image") {
36
51
  if (!safePreviewUrl(node.src!, true)) return "";
37
52
  const credit = node.credit ? (node.creditURL && safePreviewUrl(node.creditURL) ? `<a href="${escape(node.creditURL)}">${escape(node.credit)}</a>` : escape(node.credit)) : "";
@@ -81,6 +96,17 @@ export function articleMarkdown(body: ArticleBody): string {
81
96
  return (node.url ? `[${image}](<${node.url.replace(/[<>]/g, character => encodeURIComponent(character))}>)` : image) + "\n\n";
82
97
  }
83
98
  if (node.type === "divider") return "---\n\n";
99
+ if (node.type === "video") {
100
+ if (!safePreviewUrl(node.src!, true)) return "";
101
+ const label = node.caption ? `Video: ${node.caption}` : "Video";
102
+ return `[${label}](<${node.src!.replace(/[<>]/g, character => encodeURIComponent(character))}>)\n\n`;
103
+ }
104
+ if (node.type === "embed") {
105
+ const ref = safeEmbed(node);
106
+ if (!ref) return "";
107
+ const label = typeof node.title === "string" && node.title ? node.title : `${embedProviderLabel(ref)} video`;
108
+ return `[${escaped(label)}](<${embedWatchUrl(ref)}>)\n\n`;
109
+ }
84
110
  return content();
85
111
  }
86
112
  return walk(body.root as unknown as ArticleNode).trimEnd() + "\n";
package/src/config.ts CHANGED
@@ -4,12 +4,16 @@ import type { Schema } from "./schema/types";
4
4
  import { validateSidebar, type SidebarEntry } from "./manifest/navigation";
5
5
  import { withSeoFields } from "./manifest/seo";
6
6
  import { previewBaseUrl } from "./preview-urls";
7
+ import type { JsonOutput } from "./json-output";
8
+ import { DEFAULT_MAX_FILE_SIZE } from "./media-types";
7
9
 
8
10
  export interface RigelConfig {
9
11
  copyDir: string;
10
12
  mediaDir?: string;
13
+ maxFileSize?: number;
11
14
  preview?: import("./preview").PreviewConfig;
12
15
  backend: "local" | "github";
16
+ jsonOutput?: JsonOutput;
13
17
  demo?: boolean;
14
18
  collections?: CollectionConfig[];
15
19
  menus?: { folder: string; maxDepth?: number };
@@ -40,6 +44,10 @@ const DEFAULTS: Pick<RigelConfig, "copyDir" | "backend"> = {
40
44
 
41
45
  export function parseConfig(options: Partial<RigelConfig> = {}): RigelConfig {
42
46
  const cfg = { ...DEFAULTS, ...options };
47
+ if (cfg.maxFileSize === undefined) cfg.maxFileSize = DEFAULT_MAX_FILE_SIZE;
48
+ if (!Number.isSafeInteger(cfg.maxFileSize) || cfg.maxFileSize <= 0) throw new Error("rigel: maxFileSize must be a positive integer in bytes");
49
+ if (cfg.jsonOutput === undefined) cfg.jsonOutput = "pretty";
50
+ if (cfg.jsonOutput !== "pretty" && cfg.jsonOutput !== "minified") throw new Error("rigel: jsonOutput must be pretty or minified");
43
51
  if (cfg.demo === undefined) cfg.demo = true;
44
52
  if (typeof cfg.demo !== "boolean") throw new Error("rigel: demo must be boolean");
45
53
  if (cfg.preview) {
@@ -815,11 +815,10 @@
815
815
  </div>
816
816
  {:else}
817
817
  <div class="rigel" class:sidebar-collapsed={settings.userPref.sidebarCollapsed}>
818
- <aside id="rigel-sidebar">
819
- <a class="brand" href="/rigel/" role="button" aria-label="Welcome" aria-describedby="rigel-beta" onclick={(event) => { event.preventDefault(); goWelcome(); }}>
818
+ <aside id="rigel-sidebar" data-tooltip-position="right">
819
+ <a class="brand" href="/rigel/" role="button" aria-label="Welcome" onclick={(event) => { event.preventDefault(); goWelcome(); }}>
820
820
  <img src={logo} alt="" width="32" height="32" />
821
821
  <span>Rigel <span class="brand-suffix">CMS</span></span>
822
- <span id="rigel-beta" class="badge">Beta</span>
823
822
  </a>
824
823
  <TopBar {manifest} {sel} onselect={select} />
825
824
 
@@ -877,11 +876,11 @@
877
876
  Live Site
878
877
  </a>
879
878
  <div class="divider"></div>
880
- <button role="menuitem" onclick={() => { userOpen = false; requestNavigation(() => { settingsOpen = true; }); }}>
879
+ <button role="menuitem" onclick={() => { userOpen = false; settingsOpen = true; }}>
881
880
  <Icon name="gear" size={13} />
882
881
  Settings
883
882
  </button>
884
- <button role="menuitem" onclick={() => { userOpen = false; requestNavigation(() => { shortcutsOpen = true; }); }}>
883
+ <button role="menuitem" onclick={() => { userOpen = false; shortcutsOpen = true; }}>
885
884
  <Icon name="keyboard" size={13} />
886
885
  Keyboard Shortcuts
887
886
  </button>
@@ -1071,8 +1070,8 @@
1071
1070
  devMode={settings.devMode}
1072
1071
  onchange={(next) => { draft = next; touch(); }}
1073
1072
  >
1074
- {#snippet properties(selected, focusToken, bodyInline, oneditbody)}
1075
- <CollectionPanel fields={coll.fields} values={draft} storageSlug={post.slug} documents={manifest.documents ?? []} body={bodyDraft as ArticleBody} {bodyInline} {oneditbody} {selected} {focusToken} devMode={settings.devMode} onchange={(key, value) => { draft[key] = value; touch(); }} onbody={(value) => { bodyDraft = value; touch(); }} />
1073
+ {#snippet properties(selected, focusToken, bodyInline, oneditbody, bodyLoading)}
1074
+ <CollectionPanel fields={coll.fields} values={draft} storageSlug={post.slug} documents={manifest.documents ?? []} body={bodyDraft as ArticleBody} {bodyInline} {bodyLoading} {oneditbody} {selected} {focusToken} devMode={settings.devMode} onchange={(key, value) => { draft[key] = value; touch(); }} onbody={(value) => { bodyDraft = value; touch(); }} />
1076
1075
  {/snippet}
1077
1076
  </VisualEditor>
1078
1077
  {/key}
@@ -0,0 +1,21 @@
1
+ import { DecoratorNode, type NodeKey } from "lexical";
2
+ import type { EmbedProvider } from "../lib/embeds";
3
+ import { createEmbedPreview } from "../lib/embed-preview";
4
+
5
+ export type EmbedData = { provider: EmbedProvider; id: string; title?: string; privacy: boolean };
6
+ export class EmbedNode extends DecoratorNode<null> {
7
+ __embed: EmbedData;
8
+ static getType() { return "embed"; }
9
+ static clone(node: EmbedNode) { return new EmbedNode(node.__embed, node.__key); }
10
+ constructor(embed: EmbedData = { provider: "youtube", id: "", privacy: true }, key?: NodeKey) { super(key); this.__embed = embed; }
11
+ static importJSON(raw: unknown) { const embed = raw as EmbedData; return new EmbedNode({ provider: embed.provider, id: embed.id, title: embed.title, privacy: embed.privacy !== false }); }
12
+ exportJSON() { return { ...this.__embed, type: "embed", version: 1 }; }
13
+ createDOM() {
14
+ return createEmbedPreview(this.__embed, document, true);
15
+ }
16
+ updateDOM() { return true; }
17
+ decorate() { return null; }
18
+ isInline() { return false; }
19
+ getTextContent() { return this.__embed.title ?? ""; }
20
+ }
21
+ export const create = (embed?: EmbedData) => new EmbedNode(embed);
@@ -7,6 +7,8 @@ import { LinkNode } from "@lexical/link";
7
7
  import { CodeNode } from "@lexical/code";
8
8
  import { TableNode, TableRowNode, TableCellNode } from "@lexical/table";
9
9
  import { ImageNode, create as image } from "./image";
10
+ import { VideoNode } from "./video";
11
+ import { EmbedNode } from "./embed";
10
12
  import { DividerNode, create as divider } from "./divider";
11
13
  import { create as quote } from "./quote";
12
14
  import { create as bullet } from "./bulleted-list";
@@ -37,4 +39,4 @@ export const blockTypes: { value: string; label: string; icon: keyof typeof ICON
37
39
  { value: "divider", label: "Divider", icon: "minus", create: divider },
38
40
  { value: "table", label: "Table", icon: "table", create: table },
39
41
  ];
40
- export const blockNodes = [HeadingNode, QuoteNode, ListNode, ListItemNode, LinkNode, CodeNode, ImageNode, DividerNode, TableNode, TableRowNode, TableCellNode];
42
+ export const blockNodes = [HeadingNode, QuoteNode, ListNode, ListItemNode, LinkNode, CodeNode, ImageNode, VideoNode, EmbedNode, DividerNode, TableNode, TableRowNode, TableCellNode];
@@ -0,0 +1,35 @@
1
+ import { DecoratorNode, type NodeKey } from "lexical";
2
+ import { safePreviewUrl } from "../../preview-urls";
3
+ import { createVideoOverlay } from "../lib/embed-preview";
4
+
5
+ export type VideoData = { src: string; poster?: string; caption?: string; muted?: boolean; controls?: boolean };
6
+ export class VideoNode extends DecoratorNode<null> {
7
+ __video: VideoData;
8
+ static getType() { return "video"; }
9
+ static clone(node: VideoNode) { return new VideoNode(node.__video, node.__key); }
10
+ constructor(video: VideoData = { src: "" }, key?: NodeKey) { super(key); this.__video = video; }
11
+ static importJSON(raw: unknown) { const video = raw as VideoData; return new VideoNode({ src: video.src, poster: video.poster, caption: video.caption, muted: video.muted, controls: video.controls }); }
12
+ exportJSON() { return { ...this.__video, type: "video", version: 1 }; }
13
+ createDOM() {
14
+ const figure = document.createElement("figure");
15
+ figure.className = "media-video";
16
+ const video = document.createElement("video");
17
+ if (safePreviewUrl(this.__video.src, true)) video.src = this.__video.src;
18
+ if (this.__video.poster && safePreviewUrl(this.__video.poster, true)) video.poster = this.__video.poster;
19
+ video.controls = this.__video.controls !== false;
20
+ video.muted = video.defaultMuted = this.__video.muted === true;
21
+ video.preload = "metadata";
22
+ video.playsInline = true;
23
+ const preview = document.createElement("div");
24
+ preview.style.cssText = "position:relative;min-height:220px";
25
+ preview.append(video, createVideoOverlay(document, "Video", this.__video.src, true));
26
+ figure.append(preview);
27
+ if (this.__video.caption) { const caption = document.createElement("figcaption"); caption.textContent = this.__video.caption; figure.append(caption); }
28
+ return figure;
29
+ }
30
+ updateDOM() { return true; }
31
+ decorate() { return null; }
32
+ isInline() { return false; }
33
+ getTextContent() { return this.__video.caption ?? ""; }
34
+ }
35
+ export const create = (video?: VideoData) => new VideoNode(video);
@@ -25,7 +25,7 @@
25
25
  }
26
26
  </script>
27
27
 
28
- <dialog bind:this={dialog} class="rigel rigel-modal-backdrop action-dialog" aria-labelledby="action-title" aria-describedby="action-description" oncancel={(event) => { event.preventDefault(); if (!busy) onclose(); }}>
28
+ <dialog bind:this={dialog} class="rigel rigel-modal-backdrop action-dialog" aria-labelledby="action-title" aria-describedby="action-description" oncancel={(event) => { event.preventDefault(); event.stopPropagation(); if (!busy) onclose(); }} onkeydown={(event) => { if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); if (!busy) onclose(); } }}>
29
29
  <form class="rigel-modal" onsubmit={(event) => { event.preventDefault(); void submit(); }}>
30
30
  <div class="action-head"><h2 id="action-title">{title}</h2><button type="button" class="icon-btn" aria-label="Cancel" disabled={busy} onclick={onclose}><Icon name="x" size={16} /></button></div>
31
31
  <p id="action-description">{description}</p>
@@ -45,6 +45,7 @@
45
45
  .action-dialog::backdrop { background: transparent; }
46
46
  .action-head, .action-actions { display: flex; align-items: center; gap: 0.75rem; }
47
47
  .action-head { justify-content: space-between; }
48
+ .action-head h2 { min-width: 0; overflow-wrap: anywhere; }
48
49
  .action-actions { justify-content: flex-end; flex-wrap: wrap; margin-top: 1.5rem; }
49
50
  .action-actions button[type=button] { padding: 0.4375rem 0.75rem; border: 1px solid var(--border-control); border-radius: 0; background: var(--panel); color: var(--text); font: inherit; font-size: 0.8125rem; cursor: pointer; }
50
51
  button:disabled { opacity: 0.5; cursor: wait; }
@@ -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}
@@ -15,12 +15,13 @@
15
15
  import type { ContentDocument } from "../../schema/types";
16
16
  import { slugify } from "../../slugs";
17
17
 
18
- let { fields, values, storageSlug, 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 }: {
19
19
  fields: CollectionField[];
20
20
  values: Record<string, unknown>;
21
21
  storageSlug: string;
22
22
  body: ArticleBody;
23
23
  bodyInline?: boolean;
24
+ bodyLoading?: boolean;
24
25
  oneditbody?: () => void;
25
26
  selected: SelectedField | null;
26
27
  focusToken: number;
@@ -83,8 +84,8 @@
83
84
  </section>
84
85
  <section data-collection-field="$body">
85
86
  {#if bodyInline}
86
- <button class="outlined" onclick={oneditbody}>Edit article body in preview</button>
87
- <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>
88
89
  {:else}<BlockEditor value={body} oninput={onbody} />{/if}
89
90
  </section>
90
91
  </div>