@vespercms/admin 0.0.0-stage → 0.1.0-next.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 +119 -2
- package/dist/BodyEditor-CAGNfofv.js +3047 -0
- package/dist/a11y/FieldNotes.d.ts +23 -0
- package/dist/a11y/NotesDrawer.d.ts +23 -0
- package/dist/a11y/pdf.d.ts +3 -0
- package/dist/a11y/run.d.ts +3 -0
- package/dist/a11y/useNotes.d.ts +28 -0
- package/dist/app/App.d.ts +2 -0
- package/dist/app/Screen.d.ts +88 -0
- package/dist/app/UserMenu.d.ts +7 -0
- package/dist/app/api.d.ts +75 -0
- package/dist/app/appearance.d.ts +10 -0
- package/dist/app/browser-api.d.ts +17 -0
- package/dist/app/collection-icon.d.ts +12 -0
- package/dist/app/context.d.ts +23 -0
- package/dist/app/entries-changed.d.ts +11 -0
- package/dist/app/file-url.d.ts +13 -0
- package/dist/app/format.d.ts +4 -0
- package/dist/app/idb.d.ts +7 -0
- package/dist/app/load.d.ts +16 -0
- package/dist/app/media.d.ts +5 -0
- package/dist/app/navigation.d.ts +4 -0
- package/dist/app/pane-context.d.ts +19 -0
- package/dist/app/panes.d.ts +14 -0
- package/dist/app/router.d.ts +62 -0
- package/dist/body/BodyEditor.d.ts +42 -0
- package/dist/body/TableView.d.ts +2 -0
- package/dist/body/catalog.d.ts +47 -0
- package/dist/body/checks.d.ts +15 -0
- package/dist/body/chips.d.ts +31 -0
- package/dist/body/commands.d.ts +31 -0
- package/dist/body/document.d.ts +42 -0
- package/dist/body/insert.d.ts +69 -0
- package/dist/body/nodes.d.ts +38 -0
- package/dist/body/reveal.d.ts +13 -0
- package/dist/body/scroll.d.ts +16 -0
- package/dist/body/settings.d.ts +19 -0
- package/dist/body/shortcuts.d.ts +13 -0
- package/dist/body/table.d.ts +31 -0
- package/dist/body/ui.d.ts +30 -0
- package/dist/body/views.d.ts +9 -0
- package/dist/checks-C-0xDu-I.js +57 -0
- package/dist/cloud/CloudGate.d.ts +14 -0
- package/dist/cloud/access.d.ts +14 -0
- package/dist/cloud/changes.d.ts +26 -0
- package/dist/cloud/connection.d.ts +100 -0
- package/dist/cloud/editor.d.ts +44 -0
- package/dist/cloud/session.d.ts +62 -0
- package/dist/cloud/storage.d.ts +45 -0
- package/dist/editor/Field.d.ts +24 -0
- package/dist/editor/PagePicker.d.ts +20 -0
- package/dist/editor/arrange.d.ts +13 -0
- package/dist/editor/context.d.ts +23 -0
- package/dist/editor/copy.d.ts +9 -0
- package/dist/editor/reveal.d.ts +7 -0
- package/dist/editor/target.d.ts +21 -0
- package/dist/editor/values.d.ts +18 -0
- package/dist/geo-map-CT3OGD7O.js +110 -0
- package/dist/github/GitHubGate.d.ts +15 -0
- package/dist/github/api.d.ts +20 -0
- package/dist/github/cache.d.ts +2 -0
- package/dist/github/session.d.ts +35 -0
- package/dist/index.d.ts +3 -0
- package/dist/markdown-KUkv11EB.js +211 -0
- package/dist/media/MediaPicker.d.ts +28 -0
- package/dist/media/file-icon.d.ts +3 -0
- package/dist/media/formats.d.ts +23 -0
- package/dist/media/hygiene.d.ts +37 -0
- package/dist/media/upload.d.ts +31 -0
- package/dist/mount.d.ts +3 -0
- package/dist/pdf-m5s_uXJ3.js +24 -0
- package/dist/run-y4XRkg17.js +7 -0
- package/dist/schedule/ScheduleDialog.d.ts +28 -0
- package/dist/schedule/ScheduleSummary.d.ts +12 -0
- package/dist/schedule/ScheduledView.d.ts +8 -0
- package/dist/schedule/cloud.d.ts +21 -0
- package/dist/schedule/now.d.ts +5 -0
- package/dist/schedule/times.d.ts +37 -0
- package/dist/screens/EntryEditor.d.ts +13 -0
- package/dist/screens/EntryList.d.ts +18 -0
- package/dist/screens/Home.d.ts +6 -0
- package/dist/screens/ListPane.d.ts +6 -0
- package/dist/screens/MediaBrowser.d.ts +7 -0
- package/dist/screens/NotFound.d.ts +3 -0
- package/dist/screens/PageTree.d.ts +10 -0
- package/dist/screens/Redirects.d.ts +1 -0
- package/dist/screens/Unavailable.d.ts +8 -0
- package/dist/shortcuts-B_JMAvF9.js +4630 -0
- package/dist/studio.css +3 -0
- package/dist/studio.js +6312 -0
- package/dist/types.d.ts +76 -0
- package/dist/widgets/basic.d.ts +25 -0
- package/dist/widgets/crops.d.ts +43 -0
- package/dist/widgets/focal-point.d.ts +15 -0
- package/dist/widgets/geo-map.d.ts +10 -0
- package/dist/widgets/geo.d.ts +16 -0
- package/dist/widgets/group.d.ts +41 -0
- package/dist/widgets/icon.d.ts +9 -0
- package/dist/widgets/image.d.ts +16 -0
- package/dist/widgets/markdown-lazy.d.ts +2 -0
- package/dist/widgets/markdown.d.ts +2 -0
- package/dist/widgets/reference.d.ts +12 -0
- package/dist/widgets/registry.d.ts +32 -0
- package/dist/widgets/special.d.ts +20 -0
- package/dist/widgets/union.d.ts +36 -0
- package/package.json +63 -4
- package/src/a11y/FieldNotes.tsx +56 -0
- package/src/a11y/NotesDrawer.tsx +81 -0
- package/src/a11y/pdf.ts +45 -0
- package/src/a11y/run.ts +12 -0
- package/src/a11y/useNotes.ts +139 -0
- package/src/app/App.tsx +156 -0
- package/src/app/Screen.tsx +416 -0
- package/src/app/UserMenu.tsx +95 -0
- package/src/app/api.ts +176 -0
- package/src/app/appearance.ts +54 -0
- package/src/app/browser-api.ts +173 -0
- package/src/app/collection-icon.ts +15 -0
- package/src/app/context.tsx +32 -0
- package/src/app/entries-changed.ts +48 -0
- package/src/app/file-url.tsx +44 -0
- package/src/app/format.ts +39 -0
- package/src/app/idb.ts +82 -0
- package/src/app/load.ts +40 -0
- package/src/app/media.ts +20 -0
- package/src/app/navigation.ts +4 -0
- package/src/app/pane-context.ts +25 -0
- package/src/app/panes.tsx +513 -0
- package/src/app/router.ts +158 -0
- package/src/body/BodyEditor.tsx +1139 -0
- package/src/body/TableView.tsx +700 -0
- package/src/body/catalog.ts +198 -0
- package/src/body/checks.ts +83 -0
- package/src/body/chips.ts +150 -0
- package/src/body/commands.ts +191 -0
- package/src/body/document.ts +388 -0
- package/src/body/insert.tsx +301 -0
- package/src/body/nodes.ts +287 -0
- package/src/body/reveal.ts +93 -0
- package/src/body/scroll.ts +53 -0
- package/src/body/settings.tsx +510 -0
- package/src/body/shortcuts.ts +31 -0
- package/src/body/table.ts +192 -0
- package/src/body/ui.ts +77 -0
- package/src/body/views.tsx +268 -0
- package/src/build.ts +6 -0
- package/src/cloud/CloudGate.tsx +245 -0
- package/src/cloud/access.ts +52 -0
- package/src/cloud/changes.tsx +474 -0
- package/src/cloud/connection.ts +281 -0
- package/src/cloud/editor.tsx +250 -0
- package/src/cloud/session.ts +289 -0
- package/src/cloud/storage.ts +212 -0
- package/src/editor/Field.tsx +182 -0
- package/src/editor/PagePicker.tsx +102 -0
- package/src/editor/arrange.ts +98 -0
- package/src/editor/context.tsx +31 -0
- package/src/editor/copy.ts +30 -0
- package/src/editor/reveal.ts +38 -0
- package/src/editor/target.ts +104 -0
- package/src/editor/values.ts +71 -0
- package/src/github/GitHubGate.tsx +261 -0
- package/src/github/api.ts +81 -0
- package/src/github/cache.ts +64 -0
- package/src/github/session.ts +146 -0
- package/src/index.ts +10 -0
- package/src/media/MediaPicker.tsx +302 -0
- package/src/media/file-icon.ts +17 -0
- package/src/media/formats.ts +262 -0
- package/src/media/hygiene.ts +220 -0
- package/src/media/upload.ts +47 -0
- package/src/mount.tsx +20 -0
- package/src/schedule/ScheduleDialog.tsx +300 -0
- package/src/schedule/ScheduleSummary.tsx +59 -0
- package/src/schedule/ScheduledView.tsx +294 -0
- package/src/schedule/cloud.ts +66 -0
- package/src/schedule/now.ts +18 -0
- package/src/schedule/times.ts +133 -0
- package/src/screens/EntryEditor.tsx +1803 -0
- package/src/screens/EntryList.tsx +274 -0
- package/src/screens/Home.tsx +131 -0
- package/src/screens/ListPane.tsx +459 -0
- package/src/screens/MediaBrowser.tsx +253 -0
- package/src/screens/NotFound.tsx +19 -0
- package/src/screens/PageTree.tsx +718 -0
- package/src/screens/Redirects.tsx +522 -0
- package/src/screens/Unavailable.tsx +27 -0
- package/src/studio.css +163 -0
- package/src/types.ts +82 -0
- package/src/widgets/basic.tsx +311 -0
- package/src/widgets/crops.tsx +399 -0
- package/src/widgets/focal-point.tsx +240 -0
- package/src/widgets/geo-map.tsx +160 -0
- package/src/widgets/geo.tsx +170 -0
- package/src/widgets/group.tsx +827 -0
- package/src/widgets/icon.tsx +47 -0
- package/src/widgets/image.tsx +407 -0
- package/src/widgets/markdown-lazy.tsx +22 -0
- package/src/widgets/markdown.tsx +267 -0
- package/src/widgets/reference.tsx +196 -0
- package/src/widgets/registry.ts +104 -0
- package/src/widgets/special.tsx +286 -0
- package/src/widgets/union.tsx +233 -0
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
import { Extension, type JSONContent, type MarkdownToken } from '@tiptap/core';
|
|
2
|
+
import { Markdown, type MarkdownManager } from '@tiptap/markdown';
|
|
3
|
+
import { EditorContent, useEditor, useEditorState } from '@tiptap/react';
|
|
4
|
+
import StarterKit from '@tiptap/starter-kit';
|
|
5
|
+
import { ToggleButton, ToolButton, Toolbar, cx } from '@vespercms/ui';
|
|
6
|
+
import { useEffect, useRef, useState } from 'react';
|
|
7
|
+
import { LinkDialog } from '../body/BodyEditor.tsx';
|
|
8
|
+
import { scrollClearOfToolbar, stuckToolbar } from '../body/scroll.ts';
|
|
9
|
+
import { shortcut } from '../body/shortcuts.ts';
|
|
10
|
+
import type { WidgetProps } from '../types.ts';
|
|
11
|
+
import { inputClass } from './basic.tsx';
|
|
12
|
+
|
|
13
|
+
/*
|
|
14
|
+
* Markdown in frontmatter (spec §6.3.2): a compact editor for short rich
|
|
15
|
+
* text, with bold, italic, code, links and (unless the field is inline)
|
|
16
|
+
* paragraphs and lists. Nothing is rewritten until someone edits it. A
|
|
17
|
+
* value that uses Markdown this editor can't show faithfully is edited as
|
|
18
|
+
* plain text, and the field says so.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const ALLOWED = new Set([
|
|
22
|
+
'doc',
|
|
23
|
+
'paragraph',
|
|
24
|
+
'text',
|
|
25
|
+
'hardBreak',
|
|
26
|
+
'bulletList',
|
|
27
|
+
'orderedList',
|
|
28
|
+
'listItem',
|
|
29
|
+
]);
|
|
30
|
+
const INLINE_ALLOWED = new Set(['doc', 'paragraph', 'text', 'hardBreak']);
|
|
31
|
+
|
|
32
|
+
function nodeTypes(node: JSONContent, found = new Set<string>()): Set<string> {
|
|
33
|
+
if (node.type) found.add(node.type);
|
|
34
|
+
for (const child of node.content ?? []) nodeTypes(child, found);
|
|
35
|
+
return found;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/*
|
|
39
|
+
* The tokens of marked, the lexer Tiptap parses with, that the editor holds.
|
|
40
|
+
* Tiptap's parser drops what no extension claims (a strikethrough's tildes,
|
|
41
|
+
* an image, a quote, a rule, a table, a link definition, a list in a
|
|
42
|
+
* one-line field), and the round trip can't see that: it's missing from both
|
|
43
|
+
* sides. HTML is a token too, so a code span holding `<` is still code.
|
|
44
|
+
*/
|
|
45
|
+
const INLINE_TOKENS = new Set([
|
|
46
|
+
'space',
|
|
47
|
+
'paragraph',
|
|
48
|
+
'text',
|
|
49
|
+
'escape',
|
|
50
|
+
'strong',
|
|
51
|
+
'em',
|
|
52
|
+
'codespan',
|
|
53
|
+
'link',
|
|
54
|
+
'br',
|
|
55
|
+
]);
|
|
56
|
+
const TOKENS = new Set([...INLINE_TOKENS, 'list', 'list_item']);
|
|
57
|
+
|
|
58
|
+
/*
|
|
59
|
+
* Text marked keeps as text that the editor would change: a GFM footnote,
|
|
60
|
+
* which it writes back with the brackets escaped, and a named character
|
|
61
|
+
* reference Tiptap doesn't decode (any but `<`, `>`, `"` and
|
|
62
|
+
* `&`), which it shows as written and saves as `&nbsp;`. Escaped
|
|
63
|
+
* characters are tokens of their own, so `\[^1]` and `\ ` don't match.
|
|
64
|
+
*/
|
|
65
|
+
const LOSSY_TEXT = /\[\^|&(?!(?:lt|gt|quot|amp);)[A-Za-z][A-Za-z0-9]*;/;
|
|
66
|
+
|
|
67
|
+
function holds(tokens: MarkdownToken[], allowed: Set<string>): boolean {
|
|
68
|
+
return tokens.every(
|
|
69
|
+
(token) =>
|
|
70
|
+
allowed.has(token.type ?? '') &&
|
|
71
|
+
// A list item's text token carries its line's raw source, escapes and all.
|
|
72
|
+
!(token.type === 'text' && !token.tokens && LOSSY_TEXT.test(token.raw ?? '')) &&
|
|
73
|
+
holds(token.tokens ?? [], allowed) &&
|
|
74
|
+
holds(token.items ?? [], allowed),
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Whether the compact editor can own this value: only tokens and nodes it holds, and it reads back the same. */
|
|
79
|
+
function fits(markdown: MarkdownManager, value: string, inline: boolean): boolean {
|
|
80
|
+
try {
|
|
81
|
+
const tokens = markdown.instance.lexer(value) as MarkdownToken[];
|
|
82
|
+
if (!holds(tokens, inline ? INLINE_TOKENS : TOKENS)) return false;
|
|
83
|
+
const doc = markdown.parse(value);
|
|
84
|
+
const allowed = inline ? INLINE_ALLOWED : ALLOWED;
|
|
85
|
+
if (![...nodeTypes(doc)].every((type) => allowed.has(type))) return false;
|
|
86
|
+
if (inline && (doc.content?.length ?? 0) > 1) return false;
|
|
87
|
+
const again = markdown.parse(markdown.serialize(doc));
|
|
88
|
+
return JSON.stringify(again) === JSON.stringify(doc);
|
|
89
|
+
} catch {
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function MarkdownWidget(props: WidgetProps<string>) {
|
|
95
|
+
const { value, onChange, field, id, describedBy, invalid, disabled } = props;
|
|
96
|
+
const inline = field.widgetOptions?.inline === true;
|
|
97
|
+
const [plain, setPlain] = useState(false);
|
|
98
|
+
const [linkOpen, setLinkOpen] = useState(false);
|
|
99
|
+
const onChangeRef = useRef(onChange);
|
|
100
|
+
useEffect(() => {
|
|
101
|
+
onChangeRef.current = onChange;
|
|
102
|
+
});
|
|
103
|
+
const toolbar = useRef<HTMLDivElement>(null);
|
|
104
|
+
const [extras] = useState(() =>
|
|
105
|
+
Extension.create({
|
|
106
|
+
name: 'vesperCompactKeys',
|
|
107
|
+
addKeyboardShortcuts() {
|
|
108
|
+
return {
|
|
109
|
+
'Mod-k': () => {
|
|
110
|
+
setLinkOpen(true);
|
|
111
|
+
return true;
|
|
112
|
+
},
|
|
113
|
+
Escape: () => {
|
|
114
|
+
toolbar.current?.querySelector<HTMLElement>('button')?.focus();
|
|
115
|
+
return true;
|
|
116
|
+
},
|
|
117
|
+
// An inline field is one paragraph.
|
|
118
|
+
...(inline ? { Enter: () => true, 'Shift-Enter': () => true } : {}),
|
|
119
|
+
};
|
|
120
|
+
},
|
|
121
|
+
}),
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
const editor = useEditor({
|
|
125
|
+
immediatelyRender: true,
|
|
126
|
+
shouldRerenderOnTransaction: false,
|
|
127
|
+
editable: !disabled,
|
|
128
|
+
extensions: [
|
|
129
|
+
StarterKit.configure({
|
|
130
|
+
heading: false,
|
|
131
|
+
blockquote: false,
|
|
132
|
+
codeBlock: false,
|
|
133
|
+
horizontalRule: false,
|
|
134
|
+
strike: false,
|
|
135
|
+
underline: false,
|
|
136
|
+
trailingNode: false,
|
|
137
|
+
...(inline
|
|
138
|
+
? { bulletList: false, orderedList: false, listItem: false, listKeymap: false }
|
|
139
|
+
: {}),
|
|
140
|
+
link: { openOnClick: false, autolink: true, defaultProtocol: 'https' },
|
|
141
|
+
}),
|
|
142
|
+
Markdown,
|
|
143
|
+
extras,
|
|
144
|
+
],
|
|
145
|
+
editorProps: {
|
|
146
|
+
handleScrollToSelection: (view) => scrollClearOfToolbar(view, toolbar.current),
|
|
147
|
+
attributes: {
|
|
148
|
+
id,
|
|
149
|
+
role: 'textbox',
|
|
150
|
+
'aria-multiline': inline ? 'false' : 'true',
|
|
151
|
+
'aria-labelledby': `${id}-label`,
|
|
152
|
+
'aria-describedby': [describedBy, `${id}-md`].filter(Boolean).join(' '),
|
|
153
|
+
...(invalid ? { 'aria-invalid': 'true' } : {}),
|
|
154
|
+
...(field.required ? { 'aria-required': 'true' } : {}),
|
|
155
|
+
class: cx(
|
|
156
|
+
'vesper-body w-full px-3 py-2 text-body text-content outline-none',
|
|
157
|
+
inline ? 'min-h-10' : 'min-h-24',
|
|
158
|
+
),
|
|
159
|
+
},
|
|
160
|
+
},
|
|
161
|
+
onCreate: ({ editor: created }) => {
|
|
162
|
+
const text = typeof value === 'string' ? value : '';
|
|
163
|
+
const markdown = created.markdown as MarkdownManager;
|
|
164
|
+
if (text !== '' && !fits(markdown, text, inline)) {
|
|
165
|
+
setPlain(true);
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
created.commands.setContent(markdown.parse(text), { emitUpdate: false });
|
|
169
|
+
},
|
|
170
|
+
onUpdate: ({ editor: updated }) => {
|
|
171
|
+
const text = updated.getMarkdown().trim();
|
|
172
|
+
onChangeRef.current(text === '' && !field.required ? undefined : text);
|
|
173
|
+
},
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
const state = useEditorState({
|
|
177
|
+
editor,
|
|
178
|
+
selector: ({ editor: e }) => ({
|
|
179
|
+
bold: e.isActive('bold'),
|
|
180
|
+
italic: e.isActive('italic'),
|
|
181
|
+
code: e.isActive('code'),
|
|
182
|
+
link: e.isActive('link'),
|
|
183
|
+
}),
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
if (plain) {
|
|
187
|
+
return (
|
|
188
|
+
<>
|
|
189
|
+
<textarea
|
|
190
|
+
id={id}
|
|
191
|
+
rows={inline ? 2 : 5}
|
|
192
|
+
value={typeof value === 'string' ? value : ''}
|
|
193
|
+
onChange={(event) => {
|
|
194
|
+
onChange(event.target.value === '' && !field.required ? undefined : event.target.value);
|
|
195
|
+
}}
|
|
196
|
+
aria-describedby={[describedBy, `${id}-md`].filter(Boolean).join(' ')}
|
|
197
|
+
aria-invalid={invalid || undefined}
|
|
198
|
+
aria-required={field.required || undefined}
|
|
199
|
+
disabled={disabled}
|
|
200
|
+
className={cx(inputClass, 'resize-y font-mono text-small')}
|
|
201
|
+
/>
|
|
202
|
+
<p id={`${id}-md`} className="text-small text-content-muted">
|
|
203
|
+
This uses Markdown the compact editor can’t show, so it’s edited as text.
|
|
204
|
+
</p>
|
|
205
|
+
</>
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return (
|
|
210
|
+
<div className="flex flex-col gap-2">
|
|
211
|
+
{/* Framed like the body: the toolbar and the text in one hairline box. */}
|
|
212
|
+
<div
|
|
213
|
+
className={cx(
|
|
214
|
+
'rounded-md border border-border-strong bg-surface-raised',
|
|
215
|
+
invalid && 'border-danger',
|
|
216
|
+
disabled && 'opacity-60',
|
|
217
|
+
'has-[.ProseMirror-focused]:outline-2 has-[.ProseMirror-focused]:outline-solid has-[.ProseMirror-focused]:outline-offset-2 has-[.ProseMirror-focused]:outline-focus',
|
|
218
|
+
'has-[.ProseMirror-focused]:ring-6 has-[.ProseMirror-focused]:ring-(color:--glow-focus)',
|
|
219
|
+
)}
|
|
220
|
+
>
|
|
221
|
+
{/* Stuck below the bar while a long value scrolls, as the body's is. */}
|
|
222
|
+
<div ref={toolbar} className={stuckToolbar}>
|
|
223
|
+
<Toolbar label={`Formatting for ${field.label}`}>
|
|
224
|
+
<ToggleButton
|
|
225
|
+
icon="bold"
|
|
226
|
+
label="Bold"
|
|
227
|
+
shortcut={shortcut('Mod', 'B')}
|
|
228
|
+
isDisabled={disabled}
|
|
229
|
+
isSelected={state.bold}
|
|
230
|
+
onChange={() => editor.chain().focus().toggleBold().run()}
|
|
231
|
+
/>
|
|
232
|
+
<ToggleButton
|
|
233
|
+
icon="italic"
|
|
234
|
+
label="Italic"
|
|
235
|
+
shortcut={shortcut('Mod', 'I')}
|
|
236
|
+
isDisabled={disabled}
|
|
237
|
+
isSelected={state.italic}
|
|
238
|
+
onChange={() => editor.chain().focus().toggleItalic().run()}
|
|
239
|
+
/>
|
|
240
|
+
<ToggleButton
|
|
241
|
+
icon="code"
|
|
242
|
+
label="Code"
|
|
243
|
+
shortcut={shortcut('Mod', 'E')}
|
|
244
|
+
isDisabled={disabled}
|
|
245
|
+
isSelected={state.code}
|
|
246
|
+
onChange={() => editor.chain().focus().toggleCode().run()}
|
|
247
|
+
/>
|
|
248
|
+
<ToolButton
|
|
249
|
+
icon="link"
|
|
250
|
+
label={state.link ? 'Edit link' : 'Link'}
|
|
251
|
+
shortcut={shortcut('Mod', 'K')}
|
|
252
|
+
isDisabled={disabled}
|
|
253
|
+
onPress={() => {
|
|
254
|
+
setLinkOpen(true);
|
|
255
|
+
}}
|
|
256
|
+
/>
|
|
257
|
+
</Toolbar>
|
|
258
|
+
</div>
|
|
259
|
+
<EditorContent editor={editor} />
|
|
260
|
+
</div>
|
|
261
|
+
<p id={`${id}-md`} className="text-small text-content-muted">
|
|
262
|
+
Saved as Markdown. Escape moves to the formatting buttons.
|
|
263
|
+
</p>
|
|
264
|
+
<LinkDialog editor={editor} isOpen={linkOpen} onOpenChange={setLinkOpen} />
|
|
265
|
+
</div>
|
|
266
|
+
);
|
|
267
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { isPlainObject, type DataValue, type EntrySummary, type FieldModel } from '@vespercms/core';
|
|
2
|
+
import { Button, Combobox, type Option } from '@vespercms/ui';
|
|
3
|
+
import { useMemo, useRef } from 'react';
|
|
4
|
+
import { useStudio } from '../app/context.tsx';
|
|
5
|
+
import { useLoad } from '../app/load.ts';
|
|
6
|
+
import { useEditor } from '../editor/context.tsx';
|
|
7
|
+
import type { WidgetProps } from '../types.ts';
|
|
8
|
+
|
|
9
|
+
/*
|
|
10
|
+
* References (spec §6.2): pick entries of another collection by title. The
|
|
11
|
+
* file stores what Astro's reference() reads, an entry ID, and an existing
|
|
12
|
+
* `{ collection, id }` object keeps that shape.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** The ID a stored reference points at. */
|
|
16
|
+
export function referenceId(value: DataValue): string | undefined {
|
|
17
|
+
if (typeof value === 'string') return value;
|
|
18
|
+
if (isPlainObject(value)) {
|
|
19
|
+
const id = value.id ?? value.slug;
|
|
20
|
+
if (typeof id === 'string') return id;
|
|
21
|
+
}
|
|
22
|
+
return undefined;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** A new reference in the same shape as the old one. */
|
|
26
|
+
function withId(previous: DataValue, id: string): DataValue {
|
|
27
|
+
return isPlainObject(previous) ? { ...previous, id } : id;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function useEntryOptions(collection: string | undefined) {
|
|
31
|
+
const { api, collection: find } = useStudio();
|
|
32
|
+
const editor = useEditor();
|
|
33
|
+
const [load] = useLoad<EntrySummary[]>(
|
|
34
|
+
() => (collection ? api.listEntries(collection) : Promise.resolve([])),
|
|
35
|
+
collection ?? '',
|
|
36
|
+
);
|
|
37
|
+
const entries = useMemo(() => (load.state === 'ready' ? load.value : []), [load]);
|
|
38
|
+
const options = useMemo(() => {
|
|
39
|
+
// An entry can't point at itself.
|
|
40
|
+
const others = entries.filter(
|
|
41
|
+
(e) => !(collection === editor.collection.name && e.id === editor.entryId),
|
|
42
|
+
);
|
|
43
|
+
const titles = new Map<string, number>();
|
|
44
|
+
for (const e of others) titles.set(e.title, (titles.get(e.title) ?? 0) + 1);
|
|
45
|
+
return others.map((e): Option => ({
|
|
46
|
+
value: e.id,
|
|
47
|
+
// Two entries with one title are told apart by their IDs.
|
|
48
|
+
label: (titles.get(e.title) ?? 0) > 1 ? `${e.title} (${e.id})` : e.title,
|
|
49
|
+
}));
|
|
50
|
+
}, [entries, collection, editor.collection.name, editor.entryId]);
|
|
51
|
+
const label = (collection ? find(collection)?.label : undefined) ?? collection ?? 'entries';
|
|
52
|
+
return { options, load, label };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function labelFor(options: Option[], id: string): string {
|
|
56
|
+
return options.find((o) => o.value === id)?.label ?? `${id} (not found)`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function targetOf(field: FieldModel): string | undefined {
|
|
60
|
+
return field.reference?.collection ?? field.children?.[0]?.reference?.collection;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** One reference: a searchable list of the other collection's entries. */
|
|
64
|
+
export function ReferenceWidget({
|
|
65
|
+
value,
|
|
66
|
+
onChange,
|
|
67
|
+
field,
|
|
68
|
+
id,
|
|
69
|
+
describedBy,
|
|
70
|
+
invalid,
|
|
71
|
+
disabled,
|
|
72
|
+
}: WidgetProps<DataValue>) {
|
|
73
|
+
const { options, load, label } = useEntryOptions(targetOf(field));
|
|
74
|
+
const current = referenceId(value);
|
|
75
|
+
const all =
|
|
76
|
+
current && !options.some((o) => o.value === current)
|
|
77
|
+
? [...options, { value: current, label: labelFor(options, current) }]
|
|
78
|
+
: options;
|
|
79
|
+
return (
|
|
80
|
+
<Combobox
|
|
81
|
+
id={id}
|
|
82
|
+
aria-labelledby={`${id}-label`}
|
|
83
|
+
aria-describedby={describedBy}
|
|
84
|
+
options={all}
|
|
85
|
+
value={current ?? null}
|
|
86
|
+
onChange={(next) => {
|
|
87
|
+
onChange(next === null ? undefined : withId(value, next));
|
|
88
|
+
}}
|
|
89
|
+
placeholder={load.state === 'loading' ? 'Loading…' : `Search ${label.toLowerCase()}`}
|
|
90
|
+
isInvalid={invalid}
|
|
91
|
+
isDisabled={disabled}
|
|
92
|
+
/>
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Several references, in order. Order usually matters (related posts), so
|
|
98
|
+
* each has Move up, Move down and Remove buttons, and new ones are added
|
|
99
|
+
* from a search box at the end.
|
|
100
|
+
*/
|
|
101
|
+
export function ReferencesWidget({
|
|
102
|
+
value,
|
|
103
|
+
onChange,
|
|
104
|
+
field,
|
|
105
|
+
id,
|
|
106
|
+
describedBy,
|
|
107
|
+
invalid,
|
|
108
|
+
disabled,
|
|
109
|
+
}: WidgetProps<DataValue[]>) {
|
|
110
|
+
const { options, load, label } = useEntryOptions(targetOf(field));
|
|
111
|
+
const items: DataValue[] = Array.isArray(value) ? value : [];
|
|
112
|
+
const ids = items.map(referenceId);
|
|
113
|
+
const status = useRef<HTMLParagraphElement>(null);
|
|
114
|
+
const announce = (text: string) => {
|
|
115
|
+
if (status.current) status.current.textContent = text;
|
|
116
|
+
};
|
|
117
|
+
const update = (next: DataValue[]) => {
|
|
118
|
+
onChange(next.length === 0 && !field.required ? undefined : next);
|
|
119
|
+
};
|
|
120
|
+
const move = (from: number, to: number) => {
|
|
121
|
+
const next = [...items];
|
|
122
|
+
const [moved] = next.splice(from, 1);
|
|
123
|
+
next.splice(to, 0, moved);
|
|
124
|
+
update(next);
|
|
125
|
+
announce(`Moved ${labelFor(options, referenceId(moved) ?? '')} to position ${String(to + 1)}.`);
|
|
126
|
+
};
|
|
127
|
+
const available = options.filter((o) => !ids.includes(o.value));
|
|
128
|
+
|
|
129
|
+
return (
|
|
130
|
+
<div className="flex flex-col gap-3">
|
|
131
|
+
{items.length > 0 && (
|
|
132
|
+
<ol className="flex flex-col divide-y divide-border border-y border-border">
|
|
133
|
+
{items.map((item, index) => {
|
|
134
|
+
const name = labelFor(options, referenceId(item) ?? '');
|
|
135
|
+
return (
|
|
136
|
+
<li
|
|
137
|
+
key={`${String(referenceId(item))}-${String(index)}`}
|
|
138
|
+
className="flex flex-wrap items-center gap-2 py-2"
|
|
139
|
+
>
|
|
140
|
+
<span className="min-w-0 flex-1">{name}</span>
|
|
141
|
+
<Button
|
|
142
|
+
variant="quiet"
|
|
143
|
+
isDisabled={disabled || index === 0}
|
|
144
|
+
onPress={() => {
|
|
145
|
+
move(index, index - 1);
|
|
146
|
+
}}
|
|
147
|
+
aria-label={`Move ${name} up`}
|
|
148
|
+
>
|
|
149
|
+
Up
|
|
150
|
+
</Button>
|
|
151
|
+
<Button
|
|
152
|
+
variant="quiet"
|
|
153
|
+
isDisabled={disabled || index === items.length - 1}
|
|
154
|
+
onPress={() => {
|
|
155
|
+
move(index, index + 1);
|
|
156
|
+
}}
|
|
157
|
+
aria-label={`Move ${name} down`}
|
|
158
|
+
>
|
|
159
|
+
Down
|
|
160
|
+
</Button>
|
|
161
|
+
<Button
|
|
162
|
+
variant="quiet"
|
|
163
|
+
isDisabled={disabled}
|
|
164
|
+
onPress={() => {
|
|
165
|
+
update(items.filter((_, i) => i !== index));
|
|
166
|
+
announce(`Removed ${name}.`);
|
|
167
|
+
document.getElementById(id)?.focus();
|
|
168
|
+
}}
|
|
169
|
+
aria-label={`Remove ${name}`}
|
|
170
|
+
>
|
|
171
|
+
Remove
|
|
172
|
+
</Button>
|
|
173
|
+
</li>
|
|
174
|
+
);
|
|
175
|
+
})}
|
|
176
|
+
</ol>
|
|
177
|
+
)}
|
|
178
|
+
<Combobox
|
|
179
|
+
id={id}
|
|
180
|
+
aria-labelledby={`${id}-label`}
|
|
181
|
+
aria-describedby={describedBy}
|
|
182
|
+
options={available}
|
|
183
|
+
value={null}
|
|
184
|
+
onChange={(next) => {
|
|
185
|
+
if (next === null) return;
|
|
186
|
+
update([...items, next]);
|
|
187
|
+
announce(`Added ${labelFor(options, next)}.`);
|
|
188
|
+
}}
|
|
189
|
+
placeholder={load.state === 'loading' ? 'Loading…' : `Add from ${label.toLowerCase()}`}
|
|
190
|
+
isInvalid={invalid}
|
|
191
|
+
isDisabled={disabled}
|
|
192
|
+
/>
|
|
193
|
+
<p ref={status} role="status" className="sr-only" />
|
|
194
|
+
</div>
|
|
195
|
+
);
|
|
196
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import type { FieldKind, FieldModel } from '@vespercms/core';
|
|
2
|
+
import type { ComponentType } from 'react';
|
|
3
|
+
import type { ValuePath } from '../editor/values.ts';
|
|
4
|
+
import type { WidgetProps } from '../types.ts';
|
|
5
|
+
import {
|
|
6
|
+
DateWidget,
|
|
7
|
+
EmailWidget,
|
|
8
|
+
NumberWidget,
|
|
9
|
+
RadioWidget,
|
|
10
|
+
RawWidget,
|
|
11
|
+
SelectWidget,
|
|
12
|
+
SwitchWidget,
|
|
13
|
+
TagsWidget,
|
|
14
|
+
TextAreaWidget,
|
|
15
|
+
TextWidget,
|
|
16
|
+
} from './basic.tsx';
|
|
17
|
+
import { GeoWidget } from './geo.tsx';
|
|
18
|
+
import { GroupWidget, RepeaterWidget } from './group.tsx';
|
|
19
|
+
import { IconWidget } from './icon.tsx';
|
|
20
|
+
import { FileWidget, ImageWidget } from './image.tsx';
|
|
21
|
+
import { ReferencesWidget, ReferenceWidget } from './reference.tsx';
|
|
22
|
+
import { LazyMarkdownWidget } from './markdown-lazy.tsx';
|
|
23
|
+
import { ColorWidget, DateTimeWidget, SlugWidget, UrlWidget } from './special.tsx';
|
|
24
|
+
import { UnionWidget } from './union.tsx';
|
|
25
|
+
|
|
26
|
+
// Built-ins receive values of every shape; the registry can't be narrower.
|
|
27
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
28
|
+
export type BuiltinWidgetProps = WidgetProps<any> & {
|
|
29
|
+
path: ValuePath;
|
|
30
|
+
/** The field is a list item in its own row, which already names it (a union's kind). */
|
|
31
|
+
inRow?: boolean;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export type BuiltinWidget = {
|
|
35
|
+
component: ComponentType<BuiltinWidgetProps>;
|
|
36
|
+
/**
|
|
37
|
+
* Renders as a fieldset whose legend is the label (objects, lists, radio
|
|
38
|
+
* groups); `'boxed'` also draws a hairline around it (an image, a map).
|
|
39
|
+
*/
|
|
40
|
+
group?: true | 'boxed';
|
|
41
|
+
/** Renders each child (or item) as its own field, so their errors show there. */
|
|
42
|
+
rendersChildren?: true;
|
|
43
|
+
/**
|
|
44
|
+
* At the form's first level it's a section: a hairline above, its own
|
|
45
|
+
* heading, more space (DECISIONS.md, "Top-level groups are sections").
|
|
46
|
+
* Deeper, an object is set off by an indent guide instead of a frame.
|
|
47
|
+
*/
|
|
48
|
+
section?: true;
|
|
49
|
+
/** Ids of controls it renders for some of its children, as suffixes of its own id (`alt` → `-alt`). */
|
|
50
|
+
childControls?: Readonly<Record<string, string>>;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const BUILTIN: Record<string, BuiltinWidget> = {
|
|
54
|
+
text: { component: TextWidget },
|
|
55
|
+
textarea: { component: TextAreaWidget },
|
|
56
|
+
email: { component: EmailWidget },
|
|
57
|
+
url: { component: UrlWidget },
|
|
58
|
+
number: { component: NumberWidget },
|
|
59
|
+
switch: { component: SwitchWidget },
|
|
60
|
+
date: { component: DateWidget },
|
|
61
|
+
datetime: { component: DateTimeWidget },
|
|
62
|
+
select: { component: SelectWidget },
|
|
63
|
+
radio: { component: RadioWidget, group: true },
|
|
64
|
+
icon: { component: IconWidget },
|
|
65
|
+
tags: { component: TagsWidget },
|
|
66
|
+
multiselect: { component: TagsWidget },
|
|
67
|
+
group: { component: GroupWidget, group: true, rendersChildren: true, section: true },
|
|
68
|
+
union: { component: UnionWidget, group: true, rendersChildren: true, section: true },
|
|
69
|
+
repeater: { component: RepeaterWidget, group: true, rendersChildren: true, section: true },
|
|
70
|
+
raw: { component: RawWidget },
|
|
71
|
+
markdown: { component: LazyMarkdownWidget },
|
|
72
|
+
slug: { component: SlugWidget },
|
|
73
|
+
color: { component: ColorWidget },
|
|
74
|
+
geo: { component: GeoWidget, group: 'boxed' },
|
|
75
|
+
reference: { component: ReferenceWidget },
|
|
76
|
+
references: { component: ReferencesWidget, group: true },
|
|
77
|
+
image: { component: ImageWidget, group: 'boxed', childControls: { alt: '-alt' } },
|
|
78
|
+
file: { component: FileWidget, group: true },
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const BY_KIND: Partial<Record<FieldKind, string>> = {
|
|
82
|
+
string: 'text',
|
|
83
|
+
number: 'number',
|
|
84
|
+
boolean: 'switch',
|
|
85
|
+
date: 'date',
|
|
86
|
+
enum: 'select',
|
|
87
|
+
object: 'group',
|
|
88
|
+
union: 'union',
|
|
89
|
+
array: 'repeater',
|
|
90
|
+
reference: 'reference',
|
|
91
|
+
image: 'image',
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The built-in control for a field: its resolved widget, else the one its
|
|
96
|
+
* type implies (spec §6.3 step 3), else the raw YAML field (step 4).
|
|
97
|
+
*/
|
|
98
|
+
export function builtinWidget(field: FieldModel): BuiltinWidget {
|
|
99
|
+
for (const name of [field.widget, field.inferredWidget, BY_KIND[field.kind]]) {
|
|
100
|
+
const widget = name ? BUILTIN[name] : undefined;
|
|
101
|
+
if (widget) return widget;
|
|
102
|
+
}
|
|
103
|
+
return BUILTIN.raw as BuiltinWidget;
|
|
104
|
+
}
|