@rigel-cms/rigel 1.2.2 → 1.4.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.
- package/docs/content-locales.md +8 -0
- package/package.json +1 -1
- package/src/editor/App.svelte +52 -28
- package/src/editor/i18n/de/fields.json +3 -0
- package/src/editor/i18n/de/media.json +9 -1
- package/src/editor/i18n/de/shell.json +4 -0
- package/src/editor/i18n/en/fields.json +3 -0
- package/src/editor/i18n/en/media.json +9 -1
- package/src/editor/i18n/en/shell.json +4 -0
- package/src/editor/i18n/es/fields.json +3 -0
- package/src/editor/i18n/es/media.json +9 -1
- package/src/editor/i18n/es/shell.json +4 -0
- package/src/editor/i18n/fr/fields.json +3 -0
- package/src/editor/i18n/fr/media.json +9 -1
- package/src/editor/i18n/fr/shell.json +4 -0
- package/src/editor/i18n/it/fields.json +3 -0
- package/src/editor/i18n/it/media.json +9 -1
- package/src/editor/i18n/it/shell.json +4 -0
- package/src/editor/i18n/ja/fields.json +3 -0
- package/src/editor/i18n/ja/media.json +9 -1
- package/src/editor/i18n/ja/shell.json +4 -0
- package/src/editor/i18n/nl/fields.json +3 -0
- package/src/editor/i18n/nl/media.json +9 -1
- package/src/editor/i18n/nl/shell.json +4 -0
- package/src/editor/i18n/ru/fields.json +3 -0
- package/src/editor/i18n/ru/media.json +9 -1
- package/src/editor/i18n/ru/shell.json +4 -0
- package/src/editor/i18n/zh/fields.json +3 -0
- package/src/editor/i18n/zh/media.json +9 -1
- package/src/editor/i18n/zh/shell.json +4 -0
- package/src/editor/lib/ActionDialog.svelte +23 -3
- package/src/editor/lib/ImagePicker.svelte +27 -19
- package/src/editor/lib/MarkdownEditor.svelte +7 -5
- package/src/editor/lib/SchemaField.svelte +2 -2
- package/src/editor/lib/SchemaPanel.svelte +76 -4
- package/src/editor/lib/VisualEditor.svelte +38 -3
- package/src/editor/lib/icons.ts +4 -0
- package/src/editor/styles.css +15 -3
- package/src/manifest/content-locales.ts +1 -1
- package/src/manifest/navigation.ts +4 -3
- package/src/manifest/pages.ts +17 -0
- package/src/media-types.ts +17 -0
- package/src/schema/types.ts +3 -1
- package/src/schema/validate.ts +8 -3
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
{
|
|
2
|
+
"shell.coreCategory": "基础",
|
|
3
|
+
"shell.template": "模板",
|
|
4
|
+
"shell.chooseTemplate": "选择模板",
|
|
2
5
|
"shell.search": "搜索",
|
|
3
6
|
"shell.searchShortcut": "⌘/Ctrl + K",
|
|
4
7
|
"shell.closeDialog": "关闭对话框",
|
|
@@ -94,6 +97,7 @@
|
|
|
94
97
|
"shell.eraseData": "清除数据",
|
|
95
98
|
"shell.eraseBrowserDataDescription": "这会清除此浏览器中的 Rigel 设置、API 密钥、GitHub 登录信息和未保存的草稿。已保存的仓库文件不会改变。编辑器将重新加载。",
|
|
96
99
|
"shell.name": "名称",
|
|
100
|
+
"shell.path": "路径",
|
|
97
101
|
"shell.slug": "路径标识",
|
|
98
102
|
"shell.title": "标题",
|
|
99
103
|
"shell.category": "分类",
|
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import { t } from "../i18n";
|
|
3
3
|
import Icon from "./Icon.svelte";
|
|
4
|
-
|
|
4
|
+
import Select from "./Select.svelte";
|
|
5
|
+
import { untrack } from "svelte";
|
|
6
|
+
import type { DocumentDef } from "../../schema/types";
|
|
7
|
+
let { title, description, confirmLabel, fieldLabel, danger = false, templates, templateCategory, onconfirm, onclose }: {
|
|
5
8
|
title: string; description: string; confirmLabel: string; fieldLabel?: string; danger?: boolean;
|
|
6
|
-
|
|
9
|
+
templates?: DocumentDef[]; templateCategory?: string;
|
|
10
|
+
onconfirm: (value: string, type?: string) => void | Promise<void>; onclose: () => void;
|
|
7
11
|
} = $props();
|
|
8
12
|
let dialog: HTMLDialogElement;
|
|
9
13
|
let cancel: HTMLButtonElement;
|
|
@@ -11,6 +15,11 @@
|
|
|
11
15
|
let value = $state("");
|
|
12
16
|
let busy = $state(false);
|
|
13
17
|
let error = $state("");
|
|
18
|
+
let category = $state(untrack(() => templateCategory ?? ""));
|
|
19
|
+
let selectedTemplate = $state("");
|
|
20
|
+
const categories = $derived([...new Set((templates ?? []).map(type => type.category ?? ""))].sort());
|
|
21
|
+
const candidates = $derived((templates ?? []).filter(type => (type.category ?? "") === category));
|
|
22
|
+
const template = $derived(candidates.length === 1 ? candidates[0].name : selectedTemplate);
|
|
14
23
|
$effect(() => {
|
|
15
24
|
dialog.showModal();
|
|
16
25
|
(fieldLabel ? input : cancel).focus();
|
|
@@ -20,7 +29,10 @@
|
|
|
20
29
|
if (busy) return;
|
|
21
30
|
busy = true;
|
|
22
31
|
error = "";
|
|
23
|
-
try {
|
|
32
|
+
try {
|
|
33
|
+
if (templates && !candidates.some(type => type.name === template)) throw new Error($t("shell.chooseTemplate"));
|
|
34
|
+
await onconfirm(value.trim(), template); onclose();
|
|
35
|
+
}
|
|
24
36
|
catch (cause) { error = cause instanceof Error ? cause.message : $t("connection.actionFailed"); }
|
|
25
37
|
finally { busy = false; }
|
|
26
38
|
}
|
|
@@ -31,6 +43,14 @@
|
|
|
31
43
|
<div class="action-head"><h2 id="action-title">{title}</h2><button type="button" class="icon-btn" aria-label={$t("connection.cancel")} disabled={busy} onclick={onclose}><Icon name="x" size={16} /></button></div>
|
|
32
44
|
<p id="action-description">{description}</p>
|
|
33
45
|
{#if fieldLabel}<label><span>{fieldLabel}</span><input type="text" bind:this={input} bind:value required disabled={busy} /></label>{/if}
|
|
46
|
+
{#if templates}
|
|
47
|
+
{#if categories.some(category => category !== "")}
|
|
48
|
+
<Select label={$t("shell.category")} value={category} options={categories.map(value => ({ value, label: value || $t("shell.coreCategory") }))} disabled={busy} onchange={(value) => { if (typeof value === "string") { category = value; selectedTemplate = ""; error = ""; } }} />
|
|
49
|
+
{/if}
|
|
50
|
+
{#if candidates.length > 1}
|
|
51
|
+
<Select label={$t("shell.template")} value={selectedTemplate} options={[{ value: "", label: $t("shell.chooseTemplate"), disabled: true }, ...candidates.map(type => ({ value: type.name, label: type.title }))]} disabled={busy} onchange={(value) => { if (typeof value === "string") selectedTemplate = value; }} />
|
|
52
|
+
{/if}
|
|
53
|
+
{/if}
|
|
34
54
|
{#if error}<p class="error" role="alert">{error}</p>{/if}
|
|
35
55
|
<div class="action-actions">
|
|
36
56
|
<button type="button" bind:this={cancel} disabled={busy} onclick={onclose}>{$t("connection.cancel")}</button>
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
import ActionDialog from "./ActionDialog.svelte";
|
|
6
6
|
import Select from "./Select.svelte";
|
|
7
7
|
import { getSettings, setSettings } from "./settings";
|
|
8
|
-
import { DEFAULT_MAX_FILE_SIZE, MEDIA_CATEGORIES, mediaMetadata, mediaAccept, safeMediaName } from "../../media-types";
|
|
8
|
+
import { DEFAULT_MAX_FILE_SIZE, MEDIA_CATEGORIES, mediaMetadata, mediaAccept, safeMediaName, matchesMediaAccept, safeFileUrl } from "../../media-types";
|
|
9
9
|
import config from "virtual:rigel-config";
|
|
10
10
|
import { parseEmbedUrl, embedWatchUrl, type EmbedProvider } from "./embeds";
|
|
11
11
|
import type { EmbedData } from "../blocks/embed";
|
|
@@ -13,12 +13,13 @@
|
|
|
13
13
|
import { filterMedia, modifiedMonth, mediaSize, mediaIcon, assetFile, type MediaAsset } from "./media-library";
|
|
14
14
|
import { searchImages, importStockImage, importImageUrl, uploadMedia, safeImageUrl, STOCK_PROVIDERS, type StockResult, type StockSource } from "./images";
|
|
15
15
|
|
|
16
|
-
let { value = "", altText = "", triggerLabel, kind = "image", video, onvideo, embed, onembed, onpick, inline = false, toolbar = false, hiddenTrigger = false, onopen, onclose }: {
|
|
16
|
+
let { value = "", altText = "", triggerLabel, kind = "image", accept, video, onvideo, embed, onembed, onpick, inline = false, toolbar = false, hiddenTrigger = false, onopen, onclose }: {
|
|
17
17
|
video?: VideoData;
|
|
18
18
|
onvideo?: (video: VideoData) => void;
|
|
19
19
|
embed?: EmbedData;
|
|
20
20
|
onembed?: (embed: EmbedData) => void;
|
|
21
|
-
kind?: "image" | "video";
|
|
21
|
+
kind?: "image" | "video" | "file";
|
|
22
|
+
accept?: string;
|
|
22
23
|
value?: string; onpick?: (url: string, alt: string, credit?: StockResult) => void;
|
|
23
24
|
altText?: string; triggerLabel?: string;
|
|
24
25
|
inline?: boolean; toolbar?: boolean; hiddenTrigger?: boolean; onopen?: () => void; onclose?: () => void;
|
|
@@ -30,7 +31,7 @@
|
|
|
30
31
|
const providerTab = $derived(tab === "youtube" || tab === "vimeo");
|
|
31
32
|
const editing = $derived(!!embed || !!value);
|
|
32
33
|
const stockKeys = $derived(open ? getSettings().apiKeys : null);
|
|
33
|
-
const tabs = $derived((kind === "video" ? ["library", "upload", "url", "youtube", "vimeo"] : ["library", "upload", "url", "picsum", ...Object.keys(STOCK_PROVIDERS).filter(source => stockKeys?.[source as keyof typeof STOCK_PROVIDERS]?.trim())]) as (typeof tab)[]);
|
|
34
|
+
const tabs = $derived((kind === "file" ? ["library", "upload"] : kind === "video" ? ["library", "upload", "url", "youtube", "vimeo"] : ["library", "upload", "url", "picsum", ...Object.keys(STOCK_PROVIDERS).filter(source => stockKeys?.[source as keyof typeof STOCK_PROVIDERS]?.trim())]) as (typeof tab)[]);
|
|
34
35
|
let videoTitle = $state("");
|
|
35
36
|
let privacy = $state(true);
|
|
36
37
|
let muted = $state(false);
|
|
@@ -73,6 +74,7 @@
|
|
|
73
74
|
const months = $derived([...new Set(assets.map(a => modifiedMonth(a.modified)).filter(Boolean))].sort().reverse());
|
|
74
75
|
function resetFilters() { filter = ""; format = ""; month = ""; }
|
|
75
76
|
function selectAsset(asset: MediaAsset) {
|
|
77
|
+
if (kind === "file" && (!safeFileUrl(asset.url) || !matchesMediaAccept(asset.url, accept))) { error = $t("media.chooseAcceptedFile", { accept: accept ?? mediaAccept() }); return; }
|
|
76
78
|
librarySelection = asset.url; selected = {url: asset.url, asset};
|
|
77
79
|
metaName = asset.name; metaTitle = asset.title ?? ""; metaDescription = asset.description ?? ""; metaAlt = asset.alt ?? "";
|
|
78
80
|
alt = value ? altText : asset.alt ?? "";
|
|
@@ -117,7 +119,7 @@
|
|
|
117
119
|
const response = await fetch("/rigel/media.json", { cache: "no-store", headers: { "Accept-Language": $locale } });
|
|
118
120
|
if (!response.ok) throw new Error($t("media.listingUnavailable"));
|
|
119
121
|
const data = await response.json();
|
|
120
|
-
assets = (data.assets ?? data.images).filter((asset: MediaAsset) => inline || asset.type?.startsWith(kind === "video" ? "video/" : "image/"));
|
|
122
|
+
assets = (data.assets ?? data.images).filter((asset: MediaAsset) => inline || (kind === "file" ? safeFileUrl(asset.url) && matchesMediaAccept(asset.url, accept) : asset.type?.startsWith(kind === "video" ? "video/" : "image/")));
|
|
121
123
|
writable = data.writable === true;
|
|
122
124
|
folder = data.folder;
|
|
123
125
|
}
|
|
@@ -162,11 +164,13 @@
|
|
|
162
164
|
if (!selected || picking) return;
|
|
163
165
|
const request = ++requestId;
|
|
164
166
|
const choice = selected;
|
|
167
|
+
if (kind === "file" && !matchesMediaAccept(choice.file?.name ?? choice.url, accept)) { error = $t("media.chooseAcceptedFile", { accept: accept ?? mediaAccept() }); return; }
|
|
165
168
|
const metadata = metadataImport ? { title: metaTitle.trim(), description: metaDescription.trim(), ...(imageImport ? { alt: alt.trim() } : {}) } : null;
|
|
166
169
|
picking = true; error = "";
|
|
167
170
|
try {
|
|
168
171
|
const url = choice.file ? await uploadMedia(choice.file) : choice.stock ? await importStockImage(choice.stock) : needsImport ? await importImageUrl(choice.url) : choice.url;
|
|
169
172
|
if (!safeImageUrl(url)) throw new Error($t("media.unsafeMediaUrl"));
|
|
173
|
+
if (kind === "file" && (!safeFileUrl(url) || !matchesMediaAccept(url, accept))) throw new Error($t("media.chooseAcceptedFile", { accept: accept ?? mediaAccept() }));
|
|
170
174
|
if (metadata) {
|
|
171
175
|
const saved = await fetch("/rigel/api/local/media", { method: "PUT", headers: { "Content-Type": "application/json", "Accept-Language": $locale }, body: JSON.stringify({ url, ...metadata }) });
|
|
172
176
|
if (!saved.ok) throw new Error($t("media.uploadedMetadataFailed", { status: saved.status }));
|
|
@@ -179,15 +183,16 @@
|
|
|
179
183
|
else onpick?.(url, alt, choice.stock);
|
|
180
184
|
close();
|
|
181
185
|
}
|
|
182
|
-
} catch (e) { if (request === requestId) error = e instanceof Error ? e.message : $t("media.photoImportFailed"); }
|
|
186
|
+
} catch (e) { if (request === requestId) error = e instanceof Error ? e.message : $t(kind === "file" ? "media.uploadFailedGeneric" : "media.photoImportFailed"); }
|
|
183
187
|
finally { if (request === requestId) picking = false; }
|
|
184
188
|
}
|
|
185
189
|
function upload(file?: File) {
|
|
186
190
|
if (!file || picking) return;
|
|
187
191
|
error = "";
|
|
188
192
|
const limit = maxFileSize;
|
|
189
|
-
if (
|
|
190
|
-
|
|
193
|
+
if (kind === "file" && !matchesMediaAccept(file.name, accept)) { error = $t("media.chooseAcceptedFile", { accept: accept ?? mediaAccept() }); return; }
|
|
194
|
+
if (!safeMediaName(file.name) || (!inline && kind !== "file" && mediaMetadata(file.name)?.category !== (kind === "video" ? "Video" : "Images")) || file.size > limit) {
|
|
195
|
+
error = $t(inline || kind === "file" ? "media.chooseSupportedFile" : kind === "video" ? "media.chooseSupportedVideo" : "media.chooseSupportedImage", { limit: limit / 1024 / 1024 }); return;
|
|
191
196
|
}
|
|
192
197
|
const type = mediaMetadata(file.name)!.type;
|
|
193
198
|
releaseFile(); objectURL = URL.createObjectURL(new Blob([file], { type: type === "text/plain" ? "text/plain; charset=utf-8" : type }));
|
|
@@ -225,9 +230,11 @@
|
|
|
225
230
|
<button bind:this={trigger} data-image-trigger={kind === "image" ? "" : undefined} type="button" aria-label={triggerLabel ?? $t("media.insertImage")} data-tooltip={triggerLabel ?? $t("media.insertImage")} hidden={hiddenTrigger} onmousedown={event => event.preventDefault()} onclick={launch}><Icon name="image" size={18} /></button>
|
|
226
231
|
{:else}
|
|
227
232
|
<div class="image-field">
|
|
228
|
-
{#if
|
|
233
|
+
{#if kind === "file"}
|
|
234
|
+
{#if value && safeFileUrl(value)}<a href={value} download>{assetFile(value)}</a>{:else}<span>{$t("media.noFile")}</span>{/if}
|
|
235
|
+
{:else if value}<img src={value} alt="" class="preview" />{:else}<div class="preview empty">{$t("media.noImage")}</div>{/if}
|
|
229
236
|
<button bind:this={trigger} type="button" class="outlined" onclick={launch}>{$t("media.choose")}</button>
|
|
230
|
-
{#if value && onpick}<button type="button" class="outlined" aria-label={$t("media.removeImage")} data-tooltip={$t("media.removeImage")} onclick={() => onpick?.("", "")}><Icon name="x" size={14} /></button>{/if}
|
|
237
|
+
{#if value && onpick}<button type="button" class="outlined icon-only" aria-label={$t(kind === "file" ? "media.removeFile" : "media.removeImage")} data-tooltip={$t(kind === "file" ? "media.removeFile" : "media.removeImage")} onclick={() => onpick?.("", "")}><Icon name="x" size={14} /></button>{/if}
|
|
231
238
|
</div>
|
|
232
239
|
{/if}
|
|
233
240
|
|
|
@@ -235,11 +242,11 @@
|
|
|
235
242
|
<p class="hint">{$t(writable ? "media.repositoryWritable" : "media.repositorySnapshot", { folder: folder || "…" })}</p>
|
|
236
243
|
<div class="media-tools">
|
|
237
244
|
<div class="media-views" role="group" aria-label={$t("media.view")}>
|
|
238
|
-
<button type="button" class="outlined" data-tooltip={$t("media.gridView")} aria-label={$t("media.gridView")} aria-pressed={view === "grid"} onclick={() => setView("grid")}><Icon name="squaresFour" size={18} /></button>
|
|
239
|
-
<button type="button" class="outlined" data-tooltip={$t("media.listView")} aria-label={$t("media.listView")} aria-pressed={view === "list"} onclick={() => setView("list")}><Icon name="listBullets" size={18} /></button>
|
|
245
|
+
<button type="button" class="outlined icon-only" data-tooltip={$t("media.gridView")} aria-label={$t("media.gridView")} aria-pressed={view === "grid"} onclick={() => setView("grid")}><Icon name="squaresFour" size={18} /></button>
|
|
246
|
+
<button type="button" class="outlined icon-only" data-tooltip={$t("media.listView")} aria-label={$t("media.listView")} aria-pressed={view === "list"} onclick={() => setView("list")}><Icon name="listBullets" size={18} /></button>
|
|
240
247
|
</div>
|
|
241
248
|
<div class="media-select">
|
|
242
|
-
<Select ariaLabel={$t(inline ? "media.type" : kind === "video" ? "media.videoFormat" : "media.imageFormat")} value={format} onchange={next => { format = String(next); }} options={inline ? [{ value: "", label: $t("media.allItems") }, ...MEDIA_CATEGORIES.map(category => ({ value: category, label: $t(`media.category.${category}`) }))] : kind === "video" ? [{ value: "", label: $t("media.allVideos") }, { value: "video/mp4", label: "MP4" }, { value: "video/webm", label: "WebM" }, { value: "video/quicktime", label: "MOV" }] : [{ value: "", label: $t("media.allImages") }, ...["jpeg", "png", "webp", "gif", "avif"].map(format => ({ value: `image/${format}`, label: format === "jpeg" ? "JPEG" : format === "webp" ? "WebP" : format.toUpperCase() }))]} />
|
|
249
|
+
<Select ariaLabel={$t(inline || kind === "file" ? "media.type" : kind === "video" ? "media.videoFormat" : "media.imageFormat")} value={format} onchange={next => { format = String(next); }} options={inline || kind === "file" ? [{ value: "", label: $t("media.allItems") }, ...MEDIA_CATEGORIES.map(category => ({ value: category, label: $t(`media.category.${category}`) }))] : kind === "video" ? [{ value: "", label: $t("media.allVideos") }, { value: "video/mp4", label: "MP4" }, { value: "video/webm", label: "WebM" }, { value: "video/quicktime", label: "MOV" }] : [{ value: "", label: $t("media.allImages") }, ...["jpeg", "png", "webp", "gif", "avif"].map(format => ({ value: `image/${format}`, label: format === "jpeg" ? "JPEG" : format === "webp" ? "WebP" : format.toUpperCase() }))]} />
|
|
243
250
|
</div>
|
|
244
251
|
<div class="media-select">
|
|
245
252
|
<Select ariaLabel={$t("media.modifiedMonth")} data-tooltip={$t("media.modifiedMonthHint")} value={month} onchange={next => { month = String(next); }} options={[{ value: "", label: $t("media.allModifiedDates") }, ...months.map(date => ({ value: date, label: new Date(date + "-01T00:00:00Z").toLocaleDateString($locale, { month: "long", year: "numeric", timeZone: "UTC" }) }))]} />
|
|
@@ -261,9 +268,9 @@
|
|
|
261
268
|
{/snippet}
|
|
262
269
|
|
|
263
270
|
{#if open}
|
|
264
|
-
<dialog bind:this={dialog} class="rigel-modal-backdrop rigel image-dialog" class:media-insert={providerTab} aria-label={$t(providerTab ? editing ? "media.editVideo" : "media.addVideoLink" : inline ? "media.uploadMedia" : kind === "video" ? "media.chooseVideo" : "media.chooseImage")} oncancel={e => { e.preventDefault(); if (!picking) close(); }} onclick={e => { if (e.target === dialog && !picking) close(); }}>
|
|
271
|
+
<dialog bind:this={dialog} class="rigel-modal-backdrop rigel image-dialog" class:media-insert={providerTab} aria-label={$t(providerTab ? editing ? "media.editVideo" : "media.addVideoLink" : inline ? "media.uploadMedia" : kind === "file" ? "media.chooseFile" : kind === "video" ? "media.chooseVideo" : "media.chooseImage")} oncancel={e => { e.preventDefault(); if (!picking) close(); }} onclick={e => { if (e.target === dialog && !picking) close(); }}>
|
|
265
272
|
<div class="rigel-modal image-picker">
|
|
266
|
-
<div class="media-head"><h2>{providerTab ? $t(editing ? "media.editProviderVideo" : "media.addProviderVideo", { provider: tab === "youtube" ? "YouTube" : "Vimeo" }) : $t(selected ? "media.previewMedia" : inline ? "media.uploadMedia" : kind === "video" ? "media.chooseVideo" : "media.chooseImage")}</h2><button bind:this={cancel} type="button" class="close" aria-label={$t("media.close")} disabled={picking} onclick={close}><Icon name="x" size={14} weight="bold" /></button></div>
|
|
273
|
+
<div class="media-head"><h2>{providerTab ? $t(editing ? "media.editProviderVideo" : "media.addProviderVideo", { provider: tab === "youtube" ? "YouTube" : "Vimeo" }) : $t(selected ? "media.previewMedia" : inline ? "media.uploadMedia" : kind === "file" ? "media.chooseFile" : kind === "video" ? "media.chooseVideo" : "media.chooseImage")}</h2><button bind:this={cancel} type="button" class="close" aria-label={$t("media.close")} disabled={picking} onclick={close}><Icon name="x" size={14} weight="bold" /></button></div>
|
|
267
274
|
{#if selected}
|
|
268
275
|
<div class="media-selection" class:media-editing={(inline && !!selected.asset) || metadataImport}>
|
|
269
276
|
<div class="media-preview">
|
|
@@ -292,7 +299,7 @@
|
|
|
292
299
|
<label><span>{$t(kind === "video" ? "media.videoUrl" : "media.imageUrl")}</span><input type="text" readonly value={selected.url} /></label>
|
|
293
300
|
{/if}
|
|
294
301
|
{#if imageImport && !selected.file}<label><span>{$t("media.imageUrl")}</span><input type="text" readonly value={selected.url} /></label>{/if}
|
|
295
|
-
{#if !inline && !imageImport}<label><span>{$t(kind === "video" ? "media.captionOptional" : "media.descriptiveAlt")}</span><input aria-label={kind === "video" ? $t("media.videoCaption") : undefined} type="text" bind:value={alt} /></label>{/if}
|
|
302
|
+
{#if !inline && kind !== "file" && !imageImport}<label><span>{$t(kind === "video" ? "media.captionOptional" : "media.descriptiveAlt")}</span><input aria-label={kind === "video" ? $t("media.videoCaption") : undefined} type="text" bind:value={alt} /></label>{/if}
|
|
296
303
|
{#if kind === "video"}
|
|
297
304
|
<label class="inline"><input type="checkbox" role="switch" class="switch" aria-label={$t("media.muted")} bind:checked={muted} /><span>{$t("media.muted")}</span></label>
|
|
298
305
|
<label class="inline"><input type="checkbox" role="switch" class="switch" aria-label={$t("media.showControls")} bind:checked={controls} /><span>{$t("media.showControls")}</span></label>
|
|
@@ -313,12 +320,12 @@
|
|
|
313
320
|
<button type="button" class="primary" disabled={picking || savingMeta} onclick={saveMetadata}>{$t(savingMeta ? "media.saving" : "media.save")}</button>
|
|
314
321
|
{:else}
|
|
315
322
|
<button type="button" class="outlined" disabled={picking} onclick={() => { selected = null; releaseFile(); }}>{$t("media.back")}</button>
|
|
316
|
-
{#if inline && tab === "library"}<button type="button" class="primary" onclick={close}>{$t("media.done")}</button>{:else}<button type="button" class="primary" disabled={picking || (needsImport && !writable)} onclick={confirm}>{$t(picking ? kind === "video" ? "media.uploadingVideo" : "media.importingPhoto" : kind === "video" ? "media.useVideo" : !inline ? "media.insertImage" : selected.file && mediaMetadata(selected.file.name)?.category !== "Images" ? "media.uploadFile" : "media.importImage")}</button>{/if}
|
|
323
|
+
{#if inline && tab === "library"}<button type="button" class="primary" onclick={close}>{$t("media.done")}</button>{:else}<button type="button" class="primary" disabled={picking || (needsImport && !writable)} onclick={confirm}>{$t(picking ? kind === "file" ? "media.uploadingFile" : kind === "video" ? "media.uploadingVideo" : "media.importingPhoto" : kind === "file" ? selected.file ? "media.uploadFile" : "media.useFile" : kind === "video" ? "media.useVideo" : !inline ? "media.insertImage" : selected.file && mediaMetadata(selected.file.name)?.category !== "Images" ? "media.uploadFile" : "media.importImage")}</button>{/if}
|
|
317
324
|
{/if}</div>
|
|
318
325
|
</div>
|
|
319
326
|
</div>
|
|
320
327
|
{:else}
|
|
321
|
-
<div class="tabs" aria-label={$t(kind === "video" ? "media.videoSources" : "media.imageSources")}>
|
|
328
|
+
<div class="tabs" aria-label={$t(kind === "file" ? "media.fileSources" : kind === "video" ? "media.videoSources" : "media.imageSources")}>
|
|
322
329
|
{#each tabs.filter(source => !inline || (source !== "library" && source !== "unsplash")) as source}<button type="button" data-source={source} class:active={tab === source} aria-pressed={tab === source} disabled={picking} onclick={() => changeTab(source)}>{source === "youtube" ? "YouTube" : source === "vimeo" ? "Vimeo" : source === "library" || source === "upload" || source === "url" ? $t(`media.source.${source}`) : source}</button>{/each}
|
|
323
330
|
</div>
|
|
324
331
|
{#if inline}<p class="hint">{$t("media.unsplashUploadsHint")}</p>{/if}
|
|
@@ -335,7 +342,8 @@
|
|
|
335
342
|
{@render libraryBrowser()}
|
|
336
343
|
{:else if tab === "upload"}
|
|
337
344
|
<p class="hint">{$t("media.localFileHint")}</p>
|
|
338
|
-
{#if
|
|
345
|
+
{#if kind === "file" && accept}<p class="hint">{$t("media.chooseAcceptedFile", { accept })}</p>{/if}
|
|
346
|
+
{#if writable}<label><span>{$t(kind === "video" ? "media.uploadVideoLimit" : inline || kind === "file" ? "media.uploadMediaLimit" : "media.uploadImageLimit", { limit: maxFileSize / 1024 / 1024 })}</span><input type="file" accept={kind === "file" ? accept ?? mediaAccept() : kind === "video" ? ".mp4,.webm,.mov" : mediaAccept(!inline)} onchange={e => upload(e.currentTarget.files?.[0])} /></label>{:else}<p role="status">{$t("media.readOnlyUploads")}</p>{/if}
|
|
339
347
|
{:else if tab === "url"}
|
|
340
348
|
<label><span>{$t(kind === "video" ? "media.videoUrl" : "media.imageUrl")}</span><input type="url" bind:value={urlInput} placeholder={$t(kind === "video" ? "media.videoUrlPlaceholder" : "media.imageUrlPlaceholder")} /></label>
|
|
341
349
|
<button type="button" class="primary" onclick={run}>{$t("media.useUrl")}</button>
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
import type { SelectionBookmark } from "prosemirror-state";
|
|
15
15
|
import { parseVisualMarkdown, serializeMarkdown, safeMarkdownUrl, schema } from "./rich-markdown";
|
|
16
16
|
|
|
17
|
-
let { value, label, oninput }: { value: string; label: string; oninput: (value: string) => void } = $props();
|
|
17
|
+
let { value, label, oninput, editableRoot }: { value: string; label: string; oninput: (value: string) => void; editableRoot?: HTMLElement } = $props();
|
|
18
18
|
const id = $props.id();
|
|
19
19
|
let host: HTMLDivElement;
|
|
20
20
|
let mode = $state<"visual" | "source">("visual");
|
|
@@ -66,9 +66,10 @@
|
|
|
66
66
|
}
|
|
67
67
|
|
|
68
68
|
$effect(() => {
|
|
69
|
-
if (!host) return;
|
|
69
|
+
if (!editableRoot && !host) return;
|
|
70
70
|
if (!view) {
|
|
71
|
-
|
|
71
|
+
if (editableRoot) editableRoot.classList.add("rigel-markdown-content");
|
|
72
|
+
view = new EditorView(editableRoot ? { mount: editableRoot } : host, {
|
|
72
73
|
state: EditorState.create({ schema }),
|
|
73
74
|
attributes: { role: "textbox", "aria-label": label, "aria-multiline": "true" },
|
|
74
75
|
dispatchTransaction(transaction) {
|
|
@@ -92,6 +93,7 @@
|
|
|
92
93
|
load(value);
|
|
93
94
|
}
|
|
94
95
|
});
|
|
96
|
+
$effect(() => { const editable = mode === "visual"; view?.setProps({ editable: () => editable }); });
|
|
95
97
|
onDestroy(() => view?.destroy());
|
|
96
98
|
|
|
97
99
|
function activeMark(type: string) {
|
|
@@ -162,7 +164,7 @@
|
|
|
162
164
|
|
|
163
165
|
<svelte:window onpointerdown={event => { if (linkOpen && !document.getElementById(`${id}-link`)?.contains(event.target as Node) && !linkButton?.contains(event.target as Node)) linkOpen = false; }} />
|
|
164
166
|
|
|
165
|
-
<div class="markdown-editor body-editor">
|
|
167
|
+
<div class="markdown-editor body-editor" class:markdown-inline={!!editableRoot}>
|
|
166
168
|
<div class="markdown-heading">
|
|
167
169
|
<div class="field-label" id={`${id}-label`}>{label}</div>
|
|
168
170
|
<div class="markdown-modes" role="group" aria-label={$t("richtext.editingMode", { label })}>
|
|
@@ -199,7 +201,7 @@
|
|
|
199
201
|
{#if linkError}<p id={`${id}-error`} role="alert">{linkError}</p>{/if}
|
|
200
202
|
</div>
|
|
201
203
|
{/if}
|
|
202
|
-
<div class="md-preview rich-text" bind:this={host}></div>
|
|
204
|
+
{#if !editableRoot}<div class="md-preview rich-text" bind:this={host}></div>{/if}
|
|
203
205
|
</div>
|
|
204
206
|
{#if mode === "source"}
|
|
205
207
|
<textarea class="mono" rows="14" aria-label={$t("richtext.markdownSource", { label })} value={value} oninput={(event) => { warning = ""; emit(event.currentTarget.value); }}></textarea>
|
|
@@ -86,10 +86,10 @@
|
|
|
86
86
|
</label>
|
|
87
87
|
{:else if field.type === "markdown"}
|
|
88
88
|
<MarkdownEditor label={fieldLabel} value={typeof value === "string" ? value : ""} oninput={(next) => onvalue(next)} />
|
|
89
|
-
{:else if field.type === "image"}
|
|
89
|
+
{:else if field.type === "image" || field.type === "file"}
|
|
90
90
|
<div class="schema-field">
|
|
91
91
|
<span class="schema-label">{label}</span>
|
|
92
|
-
<ImagePicker value={typeof value === "string" ? value : ""} onpick={(url) => onvalue(url)} />
|
|
92
|
+
<ImagePicker kind={field.type} accept={field.accept} value={typeof value === "string" ? value : ""} onpick={(url) => onvalue(url)} />
|
|
93
93
|
</div>
|
|
94
94
|
{:else if field.type === "boolean"}
|
|
95
95
|
<label class="inline">
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
import PropertiesTabs from "./PropertiesTabs.svelte";
|
|
6
6
|
import SeoGuidance from "./SeoGuidance.svelte";
|
|
7
7
|
import Select from "./Select.svelte";
|
|
8
|
-
import { tick } from "svelte";
|
|
8
|
+
import { onDestroy, tick } from "svelte";
|
|
9
9
|
import {
|
|
10
10
|
blankItem,
|
|
11
11
|
duplicateItem,
|
|
@@ -89,8 +89,79 @@
|
|
|
89
89
|
function removeSection(index: number) {
|
|
90
90
|
setSections(sections.filter((_, position) => position !== index));
|
|
91
91
|
}
|
|
92
|
+
|
|
93
|
+
let dragging = $state<string | null>(null);
|
|
94
|
+
let target = $state<number | null>(null);
|
|
95
|
+
let message = $state("");
|
|
96
|
+
let pointer: { key: string; docId: string; pointerId: number; x: number; y: number; handle: HTMLButtonElement } | null = null;
|
|
97
|
+
|
|
98
|
+
function cancelDrag() {
|
|
99
|
+
const active = pointer;
|
|
100
|
+
pointer = null;
|
|
101
|
+
dragging = null;
|
|
102
|
+
target = null;
|
|
103
|
+
if (active?.handle.hasPointerCapture(active.pointerId)) active.handle.releasePointerCapture(active.pointerId);
|
|
104
|
+
}
|
|
105
|
+
onDestroy(cancelDrag);
|
|
106
|
+
|
|
107
|
+
function startDrag(event: PointerEvent, key: string) {
|
|
108
|
+
if (event.button !== 0 || !event.isPrimary || pointer) return;
|
|
109
|
+
const handle = event.currentTarget as HTMLButtonElement;
|
|
110
|
+
pointer = { key, docId, pointerId: event.pointerId, x: event.clientX, y: event.clientY, handle };
|
|
111
|
+
handle.setPointerCapture(event.pointerId);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function dropAt(x: number, y: number) {
|
|
115
|
+
const list = document.elementFromPoint(x, y)?.closest('.structure-list');
|
|
116
|
+
const source = pointer?.handle.closest('li');
|
|
117
|
+
if (!list || source?.parentElement !== list || pointer?.docId !== docId) return null;
|
|
118
|
+
const rows = [...list.children];
|
|
119
|
+
const before = rows.findIndex(row => { const rect = row.getBoundingClientRect(); return y < rect.top + rect.height / 2; });
|
|
120
|
+
const insertion = before < 0 ? rows.length : before;
|
|
121
|
+
const from = sections.findIndex(section => section._key === pointer?.key);
|
|
122
|
+
return from < 0 || insertion === from || insertion === from + 1 ? null : insertion;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function dragPointer(event: PointerEvent) {
|
|
126
|
+
if (!pointer || event.pointerId !== pointer.pointerId) return;
|
|
127
|
+
if (!dragging && Math.hypot(event.clientX - pointer.x, event.clientY - pointer.y) < 5) return;
|
|
128
|
+
dragging = pointer.key;
|
|
129
|
+
target = dropAt(event.clientX, event.clientY);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function finishDrag(event: PointerEvent) {
|
|
133
|
+
if (!pointer || event.pointerId !== pointer.pointerId) return;
|
|
134
|
+
const captured = pointer.handle.hasPointerCapture(event.pointerId);
|
|
135
|
+
const from = captured ? sections.findIndex(section => section._key === dragging) : -1;
|
|
136
|
+
const insertion = dropAt(event.clientX, event.clientY);
|
|
137
|
+
cancelDrag();
|
|
138
|
+
if (from < 0 || insertion === null) return;
|
|
139
|
+
const next = [...sections];
|
|
140
|
+
const [section] = next.splice(from, 1);
|
|
141
|
+
const index = insertion > from ? insertion - 1 : insertion;
|
|
142
|
+
next.splice(index, 0, section);
|
|
143
|
+
setSections(next);
|
|
144
|
+
message = $t("fields.sectionMoved", { label: sectionLabel(schema, section._type), position: index + 1, count: next.length });
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function moveWithKeyboard(event: KeyboardEvent, index: number) {
|
|
148
|
+
if (event.key !== "ArrowUp" && event.key !== "ArrowDown") return;
|
|
149
|
+
event.preventDefault();
|
|
150
|
+
if (pointer) return;
|
|
151
|
+
const delta = event.key === "ArrowUp" ? -1 : 1;
|
|
152
|
+
const position = index + delta;
|
|
153
|
+
if (position < 0 || position >= sections.length) return;
|
|
154
|
+
const handle = event.currentTarget as HTMLButtonElement;
|
|
155
|
+
const label = sectionLabel(schema, sections[index]._type);
|
|
156
|
+
setSections(moveItem(sections, index, delta) as Record<string, unknown>[]);
|
|
157
|
+
message = $t("fields.sectionMoved", { label, position: position + 1, count: sections.length });
|
|
158
|
+
await tick();
|
|
159
|
+
handle.focus({ preventScroll: true });
|
|
160
|
+
}
|
|
92
161
|
</script>
|
|
93
162
|
|
|
163
|
+
<svelte:window onpointermove={dragPointer} onpointerup={finishDrag} onpointercancel={cancelDrag} onkeydown={event => { if (event.key === 'Escape' && pointer) { event.preventDefault(); cancelDrag(); message = $t('fields.menuMoveCancelled'); } }} />
|
|
164
|
+
|
|
94
165
|
<div class="schema-panel" bind:this={host}>
|
|
95
166
|
{#if docType.shared}
|
|
96
167
|
<p class="shared-badge"><Icon name="info" size={12} /> {$t(docType.demo ? "fields.demoContentNotice" : "fields.sharedContentNotice")}</p>
|
|
@@ -115,16 +186,17 @@
|
|
|
115
186
|
{#if sections.length || values.sections !== undefined}
|
|
116
187
|
<section>
|
|
117
188
|
<h3>{$t("fields.structure")}</h3>
|
|
189
|
+
<p id="section-reorder-hint" class="reorder-status">{$t("fields.sectionReorderHint")}</p>
|
|
190
|
+
<p class="reorder-status" role="status" aria-live="polite">{message}</p>
|
|
118
191
|
<ul class="structure-list">
|
|
119
192
|
{#each sections as section, index (section._key)}
|
|
120
|
-
<li class:active={selected?.sectionKey === section._key}>
|
|
193
|
+
<li class:active={selected?.sectionKey === section._key} class:is-dragging={dragging === section._key} class:drop-before={target === index} class:drop-after={target === sections.length && index === sections.length - 1} data-section-key={section._key}>
|
|
194
|
+
<button type="button" class="outlined icon-only section-drag" aria-label={$t("fields.reorderSection", { label: sectionLabel(schema, section._type) })} aria-describedby="section-reorder-hint" onpointerdown={event => startDrag(event, String(section._key))} onlostpointercapture={cancelDrag} onkeydown={event => moveWithKeyboard(event, index)}><Icon name="dotsSixVertical" /></button>
|
|
121
195
|
<button type="button" class="structure-row" onclick={() => onselect({ docId, path: "", sectionKey: String(section._key) })}>
|
|
122
196
|
<span>{sectionLabel(schema, section._type, $t("fields.section"))}</span>
|
|
123
197
|
{#if devMode}<span class="schema-key">{section._key}</span>{/if}
|
|
124
198
|
</button>
|
|
125
199
|
<div class="structure-actions">
|
|
126
|
-
<button type="button" aria-label={$t("fields.moveSectionUp", { label: sectionLabel(schema, section._type) })} data-tooltip={$t("fields.moveUp")} disabled={index === 0} onclick={() => setSections(moveItem(sections, index, -1) as Record<string, unknown>[])}>↑</button>
|
|
127
|
-
<button type="button" aria-label={$t("fields.moveSectionDown", { label: sectionLabel(schema, section._type) })} data-tooltip={$t("fields.moveDown")} disabled={index === sections.length - 1} onclick={() => setSections(moveItem(sections, index, 1) as Record<string, unknown>[])}>↓</button>
|
|
128
200
|
<button type="button" aria-label={$t("fields.duplicateSection", { label: sectionLabel(schema, section._type) })} data-tooltip={$t("fields.duplicate")} onclick={() => setSections(duplicateItem(sections, index) as Record<string, unknown>[])}>⧉</button>
|
|
129
201
|
<button type="button" aria-label={$t("fields.removeSection", { label: sectionLabel(schema, section._type) })} data-tooltip={$t("fields.remove")} onclick={() => removeSection(index)}>×</button>
|
|
130
202
|
</div>
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { t } from "../i18n";
|
|
3
3
|
import { mount, unmount, onDestroy, tick, type Snippet } from "svelte";
|
|
4
4
|
import BlockEditor from "./BlockEditor.svelte";
|
|
5
|
+
import MarkdownEditor from "./MarkdownEditor.svelte";
|
|
5
6
|
import { tooltipRoot } from "./tooltip";
|
|
6
7
|
import editorStyles from "../styles.css?inline";
|
|
7
8
|
import type { ArticleBody } from "../../article-body";
|
|
@@ -51,7 +52,7 @@
|
|
|
51
52
|
let bodyScope: HTMLElement | null = null;
|
|
52
53
|
let removeTooltips: (() => void) | undefined;
|
|
53
54
|
let mode = $state<"browse" | "edit">("edit");
|
|
54
|
-
let panelOpen = $state(
|
|
55
|
+
let panelOpen = $state(false);
|
|
55
56
|
let editorWidth = $state(0);
|
|
56
57
|
let requestedWidth = $state(340);
|
|
57
58
|
let resizing = $state(false);
|
|
@@ -248,11 +249,42 @@
|
|
|
248
249
|
}
|
|
249
250
|
if (editVideo) mounted.editVideoAt(blockIndex);
|
|
250
251
|
}
|
|
252
|
+
function startMarkdownEdit(element: HTMLElement) {
|
|
253
|
+
const field = fieldPathFrom(element);
|
|
254
|
+
if (!field || field.doc !== docId || !schema || !docType) return false;
|
|
255
|
+
const def = fieldForPath(schema, docType, values, field.path);
|
|
256
|
+
if (def?.type !== "markdown") return false;
|
|
257
|
+
stopBodyEdit();
|
|
258
|
+
const doc = element.ownerDocument;
|
|
259
|
+
let editorStyle = doc.querySelector<HTMLStyleElement>('style[data-rigel-editor-styles]');
|
|
260
|
+
if (!editorStyle) { editorStyle = doc.createElement("style"); editorStyle.dataset.rigelEditorStyles = ""; doc.head.append(editorStyle); }
|
|
261
|
+
if (editorStyle.textContent !== editorStyles) editorStyle.textContent = editorStyles;
|
|
262
|
+
clearHover(); clearHighlight();
|
|
263
|
+
editingBody = element; bodyContents = [...element.childNodes];
|
|
264
|
+
const scope = doc.createElement("div");
|
|
265
|
+
bodyAttributes = ["class", "style", "role", "aria-label", "aria-multiline", "contenteditable", "tabindex", "translate"].map(name => [name, element.getAttribute(name)]);
|
|
266
|
+
const syncTheme = () => { scope.dataset.theme = document.documentElement.dataset.theme; scope.dataset.contrast = document.documentElement.dataset.contrast; };
|
|
267
|
+
syncTheme();
|
|
268
|
+
const host = doc.createElement("div"); host.className = "rigel rigel-inline";
|
|
269
|
+
scope.append(host); doc.body.append(scope); bodyScope = scope;
|
|
270
|
+
removeTooltips = tooltipRoot(host).destroy;
|
|
271
|
+
bodyEditor = mount(MarkdownEditor, { target: host, props: {
|
|
272
|
+
editableRoot: element,
|
|
273
|
+
label: def.labelKey ? $t(def.labelKey) : def.label,
|
|
274
|
+
get value() { return String(anchorValue(values, field.path).value ?? ""); },
|
|
275
|
+
oninput: (next: string) => onchange(replaceAt(values, field.path, next)),
|
|
276
|
+
} });
|
|
277
|
+
bodyThemeObserver = new MutationObserver(syncTheme);
|
|
278
|
+
bodyThemeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme", "data-contrast"] });
|
|
279
|
+
void tick().then(() => (element.isContentEditable ? element : host.querySelector<HTMLTextAreaElement>("textarea"))?.focus());
|
|
280
|
+
return true;
|
|
281
|
+
}
|
|
282
|
+
|
|
251
283
|
function focusBody() {
|
|
252
284
|
const target = docElement()?.querySelector<HTMLElement>('[data-rigel-richtext="$body"]');
|
|
253
285
|
if (inlineBodyEnabled && target) { target.scrollIntoView({ block: "center" }); void startBodyEdit(target); }
|
|
254
286
|
}
|
|
255
|
-
$effect(() => { if (!inlineBodyEnabled || mode !== "edit") stopBodyEdit(); });
|
|
287
|
+
$effect(() => { if ((!schema && !inlineBodyEnabled) || mode !== "edit") stopBodyEdit(); });
|
|
256
288
|
onDestroy(stopBodyEdit);
|
|
257
289
|
|
|
258
290
|
function onClick(event: MouseEvent) {
|
|
@@ -264,6 +296,9 @@
|
|
|
264
296
|
if (!target) return;
|
|
265
297
|
const articleBody = target.closest<HTMLElement>('[data-rigel-richtext="$body"]');
|
|
266
298
|
if (inlineBodyEnabled && articleBody) { void startBodyEdit(articleBody, event); return; }
|
|
299
|
+
const markdown = target.closest<HTMLElement>("[data-rigel-richtext]");
|
|
300
|
+
if (markdown && startMarkdownEdit(markdown)) return;
|
|
301
|
+
stopBodyEdit();
|
|
267
302
|
const element = target.closest<HTMLElement>("[data-rigel-tags],[data-rigel-text],[data-rigel-richtext],[data-rigel-image],[data-rigel-alt],[data-rigel-href],[data-rigel-section]");
|
|
268
303
|
if (element) selectFromElement(element);
|
|
269
304
|
}
|
|
@@ -360,7 +395,7 @@
|
|
|
360
395
|
|
|
361
396
|
function setMode(next: "browse" | "edit") {
|
|
362
397
|
mode = next;
|
|
363
|
-
panelOpen =
|
|
398
|
+
panelOpen = false;
|
|
364
399
|
if (next === "browse") {
|
|
365
400
|
clearHighlight();
|
|
366
401
|
clearHover();
|
package/src/editor/lib/icons.ts
CHANGED
|
@@ -78,6 +78,10 @@ export const ICONS = {
|
|
|
78
78
|
viewBox: "0 0 256 256",
|
|
79
79
|
d: "M128,96a32,32,0,1,0,32,32A32,32,0,0,0,128,96Zm0,48a16,16,0,1,1,16-16A16,16,0,0,1,128,144Zm0-64A32,32,0,1,0,96,48,32,32,0,0,0,128,80Zm0-48a16,16,0,1,1-16,16A16,16,0,0,1,128,32Zm0,144a32,32,0,1,0,32,32A32,32,0,0,0,128,176Zm0,48a16,16,0,1,1,16-16A16,16,0,0,1,128,224Z",
|
|
80
80
|
},
|
|
81
|
+
dotsSixVertical: {
|
|
82
|
+
viewBox: "0 0 256 256",
|
|
83
|
+
d: "M104,60A12,12,0,1,1,92,48,12,12,0,0,1,104,60Zm60,12a12,12,0,1,0-12-12A12,12,0,0,0,164,72ZM92,116a12,12,0,1,0,12,12A12,12,0,0,0,92,116Zm72,0a12,12,0,1,0,12,12A12,12,0,0,0,164,116ZM92,184a12,12,0,1,0,12,12A12,12,0,0,0,92,184Zm72,0a12,12,0,1,0,12,12A12,12,0,0,0,164,184Z",
|
|
84
|
+
},
|
|
81
85
|
keyboard: {
|
|
82
86
|
viewBox: "0 0 256 256",
|
|
83
87
|
d: "M224,48H32A16,16,0,0,0,16,64V192a16,16,0,0,0,16,16H224a16,16,0,0,0,16-16V64A16,16,0,0,0,224,48Zm0,144H32V64H224V192Zm-16-64a8,8,0,0,1-8,8H56a8,8,0,0,1,0-16H200A8,8,0,0,1,208,128Zm0-32a8,8,0,0,1-8,8H56a8,8,0,0,1,0-16H200A8,8,0,0,1,208,96ZM72,160a8,8,0,0,1-8,8H56a8,8,0,0,1,0-16h8A8,8,0,0,1,72,160Zm96,0a8,8,0,0,1-8,8H96a8,8,0,0,1,0-16h64A8,8,0,0,1,168,160Zm40,0a8,8,0,0,1-8,8h-8a8,8,0,0,1,0-16h8A8,8,0,0,1,208,160Z",
|
package/src/editor/styles.css
CHANGED
|
@@ -849,7 +849,8 @@
|
|
|
849
849
|
.rigel .media-head h2 { margin: 0; font-size: 1rem; }
|
|
850
850
|
.rigel .media-tools { align-items: center; flex-wrap: wrap; gap: 0.5rem; padding: 0.75rem; border: 1px solid var(--border-control); background: var(--panel); }
|
|
851
851
|
.rigel .media-views { display: flex; flex-shrink: 0; margin-inline-end: 1rem; }
|
|
852
|
-
.rigel .
|
|
852
|
+
.rigel button.outlined.icon-only, .rigel :is(.structure-actions, .schema-item-actions) button { display: inline-flex; align-items: center; justify-content: center; width: 2.25rem; height: 2.25rem; padding: 0; flex-shrink: 0; }
|
|
853
|
+
@media (pointer: coarse) { .rigel button.outlined.icon-only, .rigel :is(.structure-actions, .schema-item-actions) button { width: 44px; height: 44px; } }
|
|
853
854
|
.rigel .media-views button + button { border-left: 0; }
|
|
854
855
|
.rigel .media-views button[aria-pressed=true] { background: var(--accent-soft); color: var(--on-accent-soft); }
|
|
855
856
|
.rigel .media-tools .media-select { position: relative; max-width: 100%; min-width: 0; }
|
|
@@ -1182,6 +1183,9 @@
|
|
|
1182
1183
|
.rigel.rigel-inline .markdown-link, .rigel .block-editor .markdown-link { position: fixed; inset: auto; width: 340px; max-width: calc(100vw - 16px); z-index: 121; }
|
|
1183
1184
|
.rigel.rigel-inline .block-markdown-view { position: fixed; inset: 12px; max-height: calc(100vh - 24px); overflow: auto; padding: 0.5rem; border: 1px solid var(--border-control); background: var(--panel); }
|
|
1184
1185
|
.rigel-article-content:focus { outline: 0; }
|
|
1186
|
+
.rigel.rigel-inline .markdown-inline { position: fixed; top: 8px; left: 8px; width: max-content; max-width: calc(100vw - 16px); max-height: calc(100vh - 16px); overflow: auto; background: var(--panel); border: 1px solid var(--border-control); box-shadow: var(--shadow-lg); pointer-events: auto; }
|
|
1187
|
+
.rigel .markdown-editor [hidden] { display: none !important; }
|
|
1188
|
+
.rigel-markdown-content { white-space: pre-wrap; overflow-wrap: anywhere; }
|
|
1185
1189
|
:is(.rigel .block-content, .rigel-article-content)::highlight(rigel-link-selection), :is(.rigel .block-content, .rigel-article-content) ::highlight(rigel-link-selection) { background-color: Highlight; color: HighlightText; }
|
|
1186
1190
|
.rigel-article-content:focus-visible > [data-rigel-active-block] { outline: 3px solid Highlight; outline-offset: 3px; }
|
|
1187
1191
|
.rigel-article-content .block-bold { font-weight: 700; }
|
|
@@ -1305,11 +1309,19 @@
|
|
|
1305
1309
|
.rigel .panel-note { margin: 0 0 0.75rem; font-size: 0.75rem; color: var(--text-3); }
|
|
1306
1310
|
.rigel .shared-badge { display: flex; align-items: center; gap: 0.375rem; margin: 0 0 0.75rem; padding: 0.5rem; border: 1px solid var(--accent); color: var(--text-2); font-size: 0.75rem; }
|
|
1307
1311
|
.rigel .structure-list { list-style: none; margin: 0 0 0.75rem; padding: 0; display: flex; flex-direction: column; gap: 0.375rem; }
|
|
1308
|
-
.rigel .structure-list li { display: flex; align-items: stretch; border: 1px solid var(--border); }
|
|
1312
|
+
.rigel .structure-list li { position: relative; display: flex; align-items: stretch; border: 1px solid var(--border); }
|
|
1313
|
+
.rigel .structure-list .section-drag { cursor: grab; touch-action: none; user-select: none; align-self: center; border: 0; background: transparent; }
|
|
1314
|
+
.rigel .structure-list .section-drag:active { cursor: grabbing; }
|
|
1315
|
+
.rigel .structure-list li.is-dragging { border-style: dashed; }
|
|
1316
|
+
.rigel .structure-list li.drop-before::before, .rigel .structure-list li.drop-after::after { content: ""; position: absolute; left: -1px; right: -1px; height: 3px; background: var(--accent); pointer-events: none; }
|
|
1317
|
+
.rigel .structure-list li.drop-before::before { top: -5px; }
|
|
1318
|
+
.rigel .structure-list li.drop-after::after { bottom: -5px; }
|
|
1319
|
+
.rigel .reorder-status { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
|
|
1309
1320
|
.rigel .structure-list li.active { border-color: var(--accent); }
|
|
1310
1321
|
.rigel .structure-row { flex: 1; min-width: 0; display: flex; justify-content: space-between; gap: 0.5rem; align-items: center; border: 0; border-radius: 0; background: transparent; color: var(--text); padding: 0.5rem; text-align: left; cursor: pointer; font-size: 0.8125rem; }
|
|
1322
|
+
.rigel .structure-row > span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
1311
1323
|
.rigel .structure-actions, .rigel .schema-item-actions { display: flex; align-items: center; }
|
|
1312
|
-
.rigel .structure-actions button, .rigel .schema-item-actions button { border: 0; border-left: 1px solid var(--border); border-radius: 0; background: transparent; color: var(--text-2);
|
|
1324
|
+
.rigel .structure-actions button, .rigel .schema-item-actions button { border: 0; border-left: 1px solid var(--border); border-radius: 0; background: transparent; color: var(--text-2); cursor: pointer; }
|
|
1313
1325
|
.rigel .structure-actions button:disabled, .rigel .schema-item-actions button:disabled { opacity: 0.35; cursor: default; }
|
|
1314
1326
|
.rigel .add-section { display: flex; gap: 0.5rem; }
|
|
1315
1327
|
.rigel .add-section select { flex: 1; min-width: 0; }
|
|
@@ -35,7 +35,7 @@ export function selectionInContent(manifest: Manifest, selection: Selection | nu
|
|
|
35
35
|
if (!selection) return true;
|
|
36
36
|
switch (selection.kind) {
|
|
37
37
|
case "page": return manifest.pages.some(page => page.slug === selection.slug);
|
|
38
|
-
case "pages": return !selection.category || manifest.pages.some(page => !page.shared && page.category === selection.category);
|
|
38
|
+
case "pages": return !selection.category || manifest.pages.some(page => !page.shared && page.category === selection.category) || !!manifest.schema?.documents.some(type => !type.shared && type.category === selection.category);
|
|
39
39
|
case "document":
|
|
40
40
|
case "documents": return !!manifest.schema?.documents.some(type => type.name === selection.type);
|
|
41
41
|
case "post": return !!manifest.collections?.some(collection => collection.name === selection.collection);
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Manifest } from "./types";
|
|
2
|
+
import { pageCategories } from "./pages";
|
|
2
3
|
import { systemMessage, type Translator } from "../editor/i18n/format";
|
|
3
4
|
|
|
4
5
|
export type Selection =
|
|
@@ -31,8 +32,7 @@ export function navigationKey(target: Selection): string {
|
|
|
31
32
|
export function buildSidebar(manifest: Manifest, message: Translator = systemMessage): SidebarNode[] {
|
|
32
33
|
const link = (target: SidebarTarget, label: string): SidebarLink => ({ kind: "link", target, label });
|
|
33
34
|
const types = manifest.schema?.documents ?? [];
|
|
34
|
-
const
|
|
35
|
-
const categories = [...new Set(pages.map(page => page.category).filter((category): category is string => !!category))].sort();
|
|
35
|
+
const categories = pageCategories(manifest);
|
|
36
36
|
const defaults: SidebarNode[] = [
|
|
37
37
|
{ kind: "heading", label: message("system.sidebar.website") },
|
|
38
38
|
link({ kind: "pages" }, message("system.sidebar.pages")),
|
|
@@ -72,7 +72,8 @@ export function buildSidebar(manifest: Manifest, message: Translator = systemMes
|
|
|
72
72
|
used.add(key);
|
|
73
73
|
nodes.push({ ...item, label: entry.label ?? item.label, ...(entry.icon ? { icon: entry.icon } : {}) });
|
|
74
74
|
}
|
|
75
|
-
const
|
|
75
|
+
const allPages = used.has(navigationKey({ kind: "pages" }));
|
|
76
|
+
const remaining = defaults.filter((node): node is SidebarLink => node.kind === "link" && !used.has(navigationKey(node.target)) && !(allPages && node.target.kind === "pages" && node.target.category));
|
|
76
77
|
if (remaining.length) nodes.push({ kind: "heading", label: message("system.sidebar.more") }, ...remaining);
|
|
77
78
|
return nodes;
|
|
78
79
|
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { Manifest } from "./types";
|
|
2
|
+
|
|
3
|
+
export function pageCategories(manifest: Manifest): string[] {
|
|
4
|
+
return [...new Set([...manifest.pages, ...(manifest.schema?.documents ?? [])].filter(entry => !entry.shared).map(entry => entry.category).filter((category): category is string => !!category))].sort();
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function pageTemplates(manifest: Manifest) {
|
|
8
|
+
return (manifest.schema?.documents ?? []).filter(type => !type.shared && !type.singleton && type.creatable !== false);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function defaultPageCategory(manifest: Manifest): "" | null {
|
|
12
|
+
return [...manifest.pages, ...(manifest.schema?.documents ?? [])].some(entry => !entry.shared && entry.category === undefined) ? "" : null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function inPageCategory(category: string | undefined, filter: string | null): boolean {
|
|
16
|
+
return filter === null || (category ?? "") === filter;
|
|
17
|
+
}
|