@rigel-cms/rigel 0.2.0-beta.1 → 1.0.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 +16 -6
- package/package.json +3 -9
- package/src/article-body.ts +9 -2
- package/src/article-render.ts +27 -1
- package/src/config.ts +14 -1
- package/src/editor/App.svelte +9 -10
- package/src/editor/blocks/embed.ts +21 -0
- package/src/editor/blocks/index.ts +3 -1
- package/src/editor/blocks/video.ts +35 -0
- package/src/editor/lib/ActionDialog.svelte +2 -1
- package/src/editor/lib/BlockEditor.svelte +84 -15
- package/src/editor/lib/CollectionPanel.svelte +4 -3
- package/src/editor/lib/ImagePicker.svelte +141 -47
- package/src/editor/lib/MediaPicker.svelte +73 -0
- package/src/editor/lib/Select.svelte +5 -2
- package/src/editor/lib/VisualEditor.svelte +11 -6
- package/src/editor/lib/embed-preview.ts +57 -0
- package/src/editor/lib/embeds.ts +47 -0
- package/src/editor/lib/github-backend.ts +2 -1
- package/src/editor/lib/icons.ts +17 -0
- package/src/editor/lib/images.ts +20 -8
- package/src/editor/lib/media-library.ts +11 -1
- package/src/editor/lib/settings.ts +2 -2
- package/src/editor/lib/tooltip.ts +23 -4
- package/src/editor/styles.css +29 -12
- package/src/env.d.ts +1 -1
- package/src/index.ts +4 -6
- package/src/json-output.ts +5 -0
- package/src/manifest/types.ts +1 -0
- package/src/media-types.ts +14 -1
- package/src/preview-urls.ts +6 -0
- package/src/preview.ts +23 -8
- package/src/routes/local-documents.ts +1 -1
- package/src/routes/local-media.ts +33 -28
- package/src/routes/local-menu.ts +2 -1
- package/src/routes/local-post.ts +2 -1
- package/src/routes/media-files.ts +36 -1
- package/src/routes/media-validation.ts +2 -2
- package/src/schema/store.ts +3 -1
- package/src/slugs.ts +1 -1
- package/src/vite/manifest-plugin.ts +3 -0
package/README.md
CHANGED
|
@@ -1,16 +1,26 @@
|
|
|
1
1
|
# Rigel CMS
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://www.npmjs.com/package/@rigel-cms/rigel)
|
|
4
|
+
[](https://astro.build/)
|
|
5
|
+
[](package.json)
|
|
6
|
+
[](LICENSE)
|
|
4
7
|
|
|
5
|
-
|
|
8
|
+
A CMS for Astro sites. You edit the text, Git keeps the history.
|
|
6
9
|
|
|
7
|
-
|
|
10
|
+
Edit page copy, structured content and articles at `/rigel/`. Your content stays in your repository, and your Astro site controls how it looks.
|
|
8
11
|
|
|
9
|
-
|
|
12
|
+
- Visual editing on prepared page templates, with unsaved changes shown in the preview.
|
|
13
|
+
- A block editor for articles and a media library with local uploads.
|
|
14
|
+
- Explicit Save, with warnings before leaving unsaved changes.
|
|
15
|
+
- Local file editing or GitHub drafts with pull-request publishing.
|
|
10
16
|
|
|
11
|
-
Rigel
|
|
17
|
+
Rigel edits content, not `.astro` components. Developers define the editable fields and add preview bindings to their templates. Saving content and publishing the website are separate steps.
|
|
12
18
|
|
|
13
|
-
|
|
19
|
+
## Get started
|
|
20
|
+
|
|
21
|
+
Use the [Astro starter](https://github.com/rigel-cms/rigel-starter), or follow the [installation guide](https://rigelcms.dev/docs/getting-started/) to add Rigel to an existing site. Requires Node.js 24 and Astro 7.3 or later within Astro 7.
|
|
22
|
+
|
|
23
|
+
[Documentation](https://rigelcms.dev/docs/) · [Content model](https://rigelcms.dev/docs/content-model/) · [Previews](https://rigelcms.dev/docs/preview/) · [GitHub setup](https://rigelcms.dev/docs/github-publishing/)
|
|
14
24
|
|
|
15
25
|
## License
|
|
16
26
|
|
package/package.json
CHANGED
|
@@ -1,11 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rigel-cms/rigel",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"access": "public",
|
|
6
|
-
"tag": "beta"
|
|
7
|
-
},
|
|
8
|
-
"description": "Rigel CMS — the copy editor for Astro sites. Clients edit text, git keeps the history.",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "A CMS for Astro sites. You edit the text, Git keeps the history.",
|
|
9
5
|
"author": {
|
|
10
6
|
"name": "FreshJuice",
|
|
11
7
|
"email": "contact@freshjuice.dev",
|
|
@@ -32,8 +28,7 @@
|
|
|
32
28
|
"scripts": {
|
|
33
29
|
"build": "pnpm typecheck",
|
|
34
30
|
"test": "vitest run",
|
|
35
|
-
"typecheck": "tsc --noEmit"
|
|
36
|
-
"publish:beta": "npm publish --access public --tag beta"
|
|
31
|
+
"typecheck": "tsc --noEmit"
|
|
37
32
|
},
|
|
38
33
|
"exports": {
|
|
39
34
|
".": "./src/index.ts",
|
|
@@ -54,7 +49,6 @@
|
|
|
54
49
|
"vite": "^8.0.0"
|
|
55
50
|
},
|
|
56
51
|
"devDependencies": {
|
|
57
|
-
|
|
58
52
|
"@types/markdown-it": "^14.2.0",
|
|
59
53
|
"@types/node": "^24.0.0",
|
|
60
54
|
"astro": "^7.3.2",
|
package/src/article-body.ts
CHANGED
|
@@ -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");
|
package/src/article-render.ts
CHANGED
|
@@ -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 => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[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
|
@@ -3,13 +3,17 @@ import { validateSchema } from "./schema/validate";
|
|
|
3
3
|
import type { Schema } from "./schema/types";
|
|
4
4
|
import { validateSidebar, type SidebarEntry } from "./manifest/navigation";
|
|
5
5
|
import { withSeoFields } from "./manifest/seo";
|
|
6
|
-
import { previewBaseUrl } from "./preview-urls";
|
|
6
|
+
import { previewBaseUrl, previewRoute } 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,11 +44,20 @@ 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) {
|
|
46
54
|
cfg.preview = { ...cfg.preview };
|
|
47
55
|
for (const key of ["liveUrl", "draftUrl"] as const) if (cfg.preview[key] !== undefined) cfg.preview[key] = previewBaseUrl(cfg.preview[key]);
|
|
56
|
+
for (const mappings of [cfg.preview.pages, cfg.preview.documents, cfg.preview.collections]) {
|
|
57
|
+
for (const mapping of Object.values(mappings ?? {})) {
|
|
58
|
+
if (mapping && typeof mapping === "object" && mapping.template !== undefined && !previewRoute(mapping.template, true)) throw new Error("rigel: preview template must be a static root-relative path; only /rigel/preview/ is allowed inside the CMS");
|
|
59
|
+
}
|
|
60
|
+
}
|
|
48
61
|
}
|
|
49
62
|
if (cfg.mediaDir !== undefined && (typeof cfg.mediaDir !== "string" || !cfg.mediaDir.split("/").every(part => /^[a-zA-Z0-9_-][a-zA-Z0-9_. -]*$/.test(part)))) throw new Error("rigel: mediaDir must be a relative folder inside publicDir");
|
|
50
63
|
if (cfg.backend === "github") {
|
package/src/editor/App.svelte
CHANGED
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
import PublishDialog from "./lib/PublishDialog.svelte";
|
|
34
34
|
import VisualEditor from "./lib/VisualEditor.svelte";
|
|
35
35
|
import { newDocumentFromTemplate } from "../schema/paths";
|
|
36
|
-
import { previewUrl } from "../preview";
|
|
36
|
+
import { previewUrl, previewPath } from "../preview";
|
|
37
37
|
import type { ContentDocument, Schema } from "../schema/types";
|
|
38
38
|
import { buildSidebar, navigationKey, type Selection } from "../manifest/navigation";
|
|
39
39
|
import { startGitHubLogin, checkGitHubAccess, type GitHubAccess } from "./lib/github-auth";
|
|
@@ -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"
|
|
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
|
|
|
@@ -852,7 +851,7 @@
|
|
|
852
851
|
|
|
853
852
|
{#if githubMode && manifest.github?.publishBranch}
|
|
854
853
|
<button id="publish-button" class="sidebar-publish" aria-label="Publish" data-tooltip={dirty ? "Save your edits before publishing" : "Publish saved branch changes"} disabled={publishBusy || publishLoading || saveState === "saving" || connectionBusy || tokenExpired || githubAccess?.status !== "ready"} onclick={reviewPublish}>
|
|
855
|
-
<Icon name="
|
|
854
|
+
<Icon name="rocketLaunch" size={18} /><span class="sidebar-label">Publish</span>
|
|
856
855
|
</button>
|
|
857
856
|
{/if}
|
|
858
857
|
<button class="sidebar-toggle" aria-expanded={!settings.userPref.sidebarCollapsed} aria-controls="rigel-sidebar-navigation" aria-label={settings.userPref.sidebarCollapsed ? "Expand sidebar" : "Collapse sidebar"} data-tooltip={settings.userPref.sidebarCollapsed ? "Expand sidebar" : "Collapse sidebar"} onclick={() => { settings.userPref.sidebarCollapsed = !settings.userPref.sidebarCollapsed; }}>
|
|
@@ -877,11 +876,11 @@
|
|
|
877
876
|
Live Site
|
|
878
877
|
</a>
|
|
879
878
|
<div class="divider"></div>
|
|
880
|
-
<button role="menuitem" onclick={() => { userOpen = false;
|
|
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;
|
|
883
|
+
<button role="menuitem" onclick={() => { userOpen = false; shortcutsOpen = true; }}>
|
|
885
884
|
<Icon name="keyboard" size={13} />
|
|
886
885
|
Keyboard Shortcuts
|
|
887
886
|
</button>
|
|
@@ -1036,7 +1035,7 @@
|
|
|
1036
1035
|
|
|
1037
1036
|
{:else if sel?.kind === "page" && page}
|
|
1038
1037
|
{#if !page.shared}
|
|
1039
|
-
<section><label><span>Website route</span><input type="text" value={manifest.preview
|
|
1038
|
+
<section><label><span>Website route</span><input type="text" value={previewPath(manifest.preview ?? {}, { kind: "page", slug: page.slug }) ?? "Not configured"} readonly /></label><p class="hint">This copy page uses a fixed Astro route. Its URL can only be changed in the website code; slug metadata cannot move it.</p></section>
|
|
1040
1039
|
{/if}
|
|
1041
1040
|
{#each page.groups as group (group.id)}
|
|
1042
1041
|
<section>
|
|
@@ -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
|
|
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"><
|
|
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
|
|
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>
|