@vespercms/admin 0.0.0-stage → 0.1.0-next.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/README.md +119 -2
- package/dist/BodyEditor-_mkh0BSQ.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-4fVeej3c.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 +35 -0
- package/dist/screens/EntryEditor.d.ts +13 -0
- package/dist/screens/EntryList.d.ts +16 -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/screens/status.d.ts +36 -0
- package/dist/shortcuts-CarKeDqp.js +4666 -0
- package/dist/studio.css +3 -0
- package/dist/studio.js +6449 -0
- package/dist/types.d.ts +76 -0
- package/dist/widgets/basic.d.ts +25 -0
- package/dist/widgets/blank-date.d.ts +8 -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 +298 -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 +303 -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 +122 -0
- package/src/screens/EntryEditor.tsx +1817 -0
- package/src/screens/EntryList.tsx +272 -0
- package/src/screens/Home.tsx +131 -0
- package/src/screens/ListPane.tsx +477 -0
- package/src/screens/MediaBrowser.tsx +253 -0
- package/src/screens/NotFound.tsx +19 -0
- package/src/screens/PageTree.tsx +738 -0
- package/src/screens/Redirects.tsx +522 -0
- package/src/screens/Unavailable.tsx +27 -0
- package/src/screens/status.tsx +104 -0
- package/src/studio.css +168 -0
- package/src/types.ts +82 -0
- package/src/widgets/basic.tsx +315 -0
- package/src/widgets/blank-date.ts +32 -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 +290 -0
- package/src/widgets/union.tsx +233 -0
|
@@ -0,0 +1,1139 @@
|
|
|
1
|
+
import { Extension, type AnyExtension, type Editor } from '@tiptap/core';
|
|
2
|
+
import Image from '@tiptap/extension-image';
|
|
3
|
+
import { Markdown, type MarkdownManager } from '@tiptap/markdown';
|
|
4
|
+
import {
|
|
5
|
+
NodeSelection,
|
|
6
|
+
Plugin,
|
|
7
|
+
Selection,
|
|
8
|
+
TextSelection,
|
|
9
|
+
type EditorState,
|
|
10
|
+
} from '@tiptap/pm/state';
|
|
11
|
+
import type { EditorView } from '@tiptap/pm/view';
|
|
12
|
+
import { EditorContent, ReactNodeViewRenderer, useEditor, useEditorState } from '@tiptap/react';
|
|
13
|
+
import StarterKit from '@tiptap/starter-kit';
|
|
14
|
+
import {
|
|
15
|
+
assembleBody,
|
|
16
|
+
mediaReference,
|
|
17
|
+
type A11yNote,
|
|
18
|
+
type BodyFormat,
|
|
19
|
+
type BodyIssue,
|
|
20
|
+
type TableValue,
|
|
21
|
+
} from '@vespercms/core';
|
|
22
|
+
import {
|
|
23
|
+
Button,
|
|
24
|
+
Checkbox,
|
|
25
|
+
Chevron,
|
|
26
|
+
Dialog,
|
|
27
|
+
Icon,
|
|
28
|
+
Menu,
|
|
29
|
+
TextArea,
|
|
30
|
+
SuggestField,
|
|
31
|
+
ToggleButton,
|
|
32
|
+
ToolbarSeparator,
|
|
33
|
+
ToolButton,
|
|
34
|
+
Toolbar,
|
|
35
|
+
cx,
|
|
36
|
+
} from '@vespercms/ui';
|
|
37
|
+
import { useEffect, useMemo, useRef, useState, type ReactNode, type RefObject } from 'react';
|
|
38
|
+
import { NoteText } from '../a11y/FieldNotes.tsx';
|
|
39
|
+
import { useStudio } from '../app/context.tsx';
|
|
40
|
+
import { pageSuggestions, usePages } from '../editor/PagePicker.tsx';
|
|
41
|
+
import { MediaPicker } from '../media/MediaPicker.tsx';
|
|
42
|
+
import { inputClass } from '../widgets/basic.tsx';
|
|
43
|
+
import { catalogOf, fallbackLabel } from './catalog.ts';
|
|
44
|
+
import { chipInfo } from './chips.ts';
|
|
45
|
+
import {
|
|
46
|
+
enclosingContainer,
|
|
47
|
+
insertBlock,
|
|
48
|
+
isManaged,
|
|
49
|
+
moveBlock,
|
|
50
|
+
removeBlock,
|
|
51
|
+
selectBlock,
|
|
52
|
+
selectedNode,
|
|
53
|
+
} from './commands.ts';
|
|
54
|
+
import {
|
|
55
|
+
bodyItems,
|
|
56
|
+
buildDocument,
|
|
57
|
+
snapshot,
|
|
58
|
+
sourceLabel,
|
|
59
|
+
tableEdit,
|
|
60
|
+
type BodyDocument,
|
|
61
|
+
} from './document.ts';
|
|
62
|
+
import {
|
|
63
|
+
InsertMenu,
|
|
64
|
+
SlashCommands,
|
|
65
|
+
SlashList,
|
|
66
|
+
choicesAt,
|
|
67
|
+
filterChoices,
|
|
68
|
+
slashAttributes,
|
|
69
|
+
useSlashIds,
|
|
70
|
+
type InsertChoice,
|
|
71
|
+
} from './insert.tsx';
|
|
72
|
+
import {
|
|
73
|
+
BlockIndex,
|
|
74
|
+
SourceBlock,
|
|
75
|
+
VesperCard,
|
|
76
|
+
VesperInline,
|
|
77
|
+
VesperTable,
|
|
78
|
+
containerTypes,
|
|
79
|
+
type ContainerTypes,
|
|
80
|
+
} from './nodes.ts';
|
|
81
|
+
import { revealInEditor, revealInSource } from './reveal.ts';
|
|
82
|
+
import { scrollClearOfToolbar, stuckToolbar } from './scroll.ts';
|
|
83
|
+
import { isMac, shortcut } from './shortcuts.ts';
|
|
84
|
+
import { BlockSettings, partialFiles, relativePath } from './settings.tsx';
|
|
85
|
+
import { useFileUrl } from '../app/file-url.tsx';
|
|
86
|
+
import { bodyChecks } from './checks.ts';
|
|
87
|
+
import { BodyUiContext, type BodyUi } from './ui.ts';
|
|
88
|
+
import { TableView } from './TableView.tsx';
|
|
89
|
+
import { CardView, ContainerView, InlineView, SourceView } from './views.tsx';
|
|
90
|
+
|
|
91
|
+
export type BodyEditorProps = {
|
|
92
|
+
/** The body text, LF line endings, without the line breaks after the frontmatter. */
|
|
93
|
+
value: string;
|
|
94
|
+
onChange: (value: string) => void;
|
|
95
|
+
format: BodyFormat;
|
|
96
|
+
disabled: boolean;
|
|
97
|
+
/** The entry's file, so images are stored relative to it. */
|
|
98
|
+
entryPath: string;
|
|
99
|
+
/** Where images uploaded into the body go. */
|
|
100
|
+
imageFolder: string;
|
|
101
|
+
/** Files a Markdoc partial can point at, once loaded (Markdoc bodies only). */
|
|
102
|
+
partials?: readonly string[] | null;
|
|
103
|
+
/** Problems in the whole body, from the last check: listed under the source view. */
|
|
104
|
+
issues?: readonly BodyIssue[];
|
|
105
|
+
/** Accessibility notes about the body (spec §6.18), listed under it. */
|
|
106
|
+
notes?: readonly A11yNote[];
|
|
107
|
+
/** Filled with the editor's actions, for the entry's notes drawer. */
|
|
108
|
+
apiRef?: RefObject<BodyApi | null>;
|
|
109
|
+
/** The level the body's top headings should have (the collection's `headingStart`); 2 when absent. */
|
|
110
|
+
headingStart?: number;
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
/** What the rest of the entry editor can ask of the body editor. */
|
|
114
|
+
export type BodyApi = {
|
|
115
|
+
/** Puts the cursor on the place a note points at, in whichever view is open, and focuses it. */
|
|
116
|
+
reveal: (note: A11yNote) => void;
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const FORMAT_NAMES: Record<BodyFormat, string> = { md: 'Markdown', mdx: 'MDX', mdoc: 'Markdoc' };
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* The body editor (spec §6.5): a writing view and a source view. The writing
|
|
123
|
+
* view owns only the blocks it can show faithfully, and saves splice changed
|
|
124
|
+
* blocks into the original text, so untouched blocks never change. Markdoc
|
|
125
|
+
* tags and declared MDX components are blocks with settings (spec §6.5.1,
|
|
126
|
+
* §6.5.2); everything else it can't show is a protected source block.
|
|
127
|
+
*/
|
|
128
|
+
export function BodyEditor(props: BodyEditorProps) {
|
|
129
|
+
const [mode, setMode] = useState<'writing' | 'source'>('writing');
|
|
130
|
+
// Each switch back to the writing view rebuilds it from the current text.
|
|
131
|
+
const [generation, setGeneration] = useState(0);
|
|
132
|
+
const formatName = FORMAT_NAMES[props.format];
|
|
133
|
+
const blocking = (props.issues ?? []).filter((issue) => issue.level === 'error');
|
|
134
|
+
const notes = props.notes ?? [];
|
|
135
|
+
// The writing view registers how it reveals a place; the source view is a plain text area.
|
|
136
|
+
const writingReveal = useRef<((offset: number) => void) | null>(null);
|
|
137
|
+
const modeRef = useRef(mode);
|
|
138
|
+
useEffect(() => {
|
|
139
|
+
modeRef.current = mode;
|
|
140
|
+
}, [mode]);
|
|
141
|
+
const reveal = (note: A11yNote) => {
|
|
142
|
+
if (note.target.kind !== 'body') return;
|
|
143
|
+
const textarea = document.getElementById('entry-body');
|
|
144
|
+
if (modeRef.current === 'source' && textarea instanceof HTMLTextAreaElement)
|
|
145
|
+
revealInSource(textarea, note.target.start, note.target.end);
|
|
146
|
+
else writingReveal.current?.(note.target.start);
|
|
147
|
+
};
|
|
148
|
+
const revealRef = useRef(reveal);
|
|
149
|
+
useEffect(() => {
|
|
150
|
+
revealRef.current = reveal;
|
|
151
|
+
});
|
|
152
|
+
const { apiRef } = props;
|
|
153
|
+
useEffect(() => {
|
|
154
|
+
if (!apiRef) return;
|
|
155
|
+
apiRef.current = {
|
|
156
|
+
reveal: (note) => {
|
|
157
|
+
revealRef.current(note);
|
|
158
|
+
},
|
|
159
|
+
};
|
|
160
|
+
return () => {
|
|
161
|
+
apiRef.current = null;
|
|
162
|
+
};
|
|
163
|
+
}, [apiRef]);
|
|
164
|
+
|
|
165
|
+
return (
|
|
166
|
+
// A region named "Body": the text's own headings stay real headings, so
|
|
167
|
+
// people can move through them while editing, and screen readers say
|
|
168
|
+
// they're in the body when a heading list or quick navigation lands there.
|
|
169
|
+
<section aria-labelledby="entry-body-label" className="flex flex-col gap-2">
|
|
170
|
+
<div className="flex flex-wrap items-center justify-between gap-2">
|
|
171
|
+
<span id="entry-body-label" className="text-label text-content">
|
|
172
|
+
Body
|
|
173
|
+
</span>
|
|
174
|
+
<Button
|
|
175
|
+
variant="quiet"
|
|
176
|
+
onPress={() => {
|
|
177
|
+
if (mode === 'source') setGeneration((n) => n + 1);
|
|
178
|
+
setMode(mode === 'writing' ? 'source' : 'writing');
|
|
179
|
+
}}
|
|
180
|
+
>
|
|
181
|
+
{mode === 'writing' ? `Edit as ${formatName}` : 'Back to the writing view'}
|
|
182
|
+
</Button>
|
|
183
|
+
</div>
|
|
184
|
+
{mode === 'writing' ? (
|
|
185
|
+
<WritingView key={generation} {...props} revealRef={writingReveal} />
|
|
186
|
+
) : (
|
|
187
|
+
<>
|
|
188
|
+
<textarea
|
|
189
|
+
id="entry-body"
|
|
190
|
+
aria-labelledby="entry-body-label"
|
|
191
|
+
aria-describedby={cx(
|
|
192
|
+
'entry-body-source-help',
|
|
193
|
+
blocking.length > 0 && 'entry-body-issues',
|
|
194
|
+
)}
|
|
195
|
+
aria-invalid={blocking.length > 0 || undefined}
|
|
196
|
+
value={props.value}
|
|
197
|
+
onChange={(event) => {
|
|
198
|
+
props.onChange(event.target.value);
|
|
199
|
+
}}
|
|
200
|
+
rows={20}
|
|
201
|
+
spellCheck={false}
|
|
202
|
+
disabled={props.disabled}
|
|
203
|
+
className={cx(inputClass, 'resize-y font-mono text-small leading-relaxed')}
|
|
204
|
+
/>
|
|
205
|
+
<p id="entry-body-source-help" className="text-small text-content-muted">
|
|
206
|
+
The whole body as {formatName}, exactly as it’s saved.
|
|
207
|
+
</p>
|
|
208
|
+
{blocking.length > 0 && (
|
|
209
|
+
<ul id="entry-body-issues" className="flex flex-col gap-1 text-small text-danger">
|
|
210
|
+
{blocking.map((issue) => (
|
|
211
|
+
<li key={`${String(issue.line)}${issue.message}`}>
|
|
212
|
+
Line {issue.line}: {issue.message}
|
|
213
|
+
</li>
|
|
214
|
+
))}
|
|
215
|
+
</ul>
|
|
216
|
+
)}
|
|
217
|
+
</>
|
|
218
|
+
)}
|
|
219
|
+
<BodyNotes notes={notes} lines={mode === 'source'} onShow={reveal} />
|
|
220
|
+
</section>
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* The body's accessibility notes (spec §6.18), under the body: each names
|
|
226
|
+
* what it's about and has a Show button that takes the cursor there.
|
|
227
|
+
*/
|
|
228
|
+
function BodyNotes({
|
|
229
|
+
notes,
|
|
230
|
+
lines,
|
|
231
|
+
onShow,
|
|
232
|
+
}: {
|
|
233
|
+
notes: readonly A11yNote[];
|
|
234
|
+
/** In the source view, each note says its line. */
|
|
235
|
+
lines: boolean;
|
|
236
|
+
onShow: (note: A11yNote) => void;
|
|
237
|
+
}) {
|
|
238
|
+
if (notes.length === 0) return null;
|
|
239
|
+
return (
|
|
240
|
+
<div className="flex flex-col gap-2">
|
|
241
|
+
<p id="entry-body-a11y-label" className="text-label text-content">
|
|
242
|
+
Accessibility notes in the body
|
|
243
|
+
</p>
|
|
244
|
+
<ul aria-labelledby="entry-body-a11y-label" className="m-0 flex list-none flex-col gap-2 p-0">
|
|
245
|
+
{notes.map((note, index) => {
|
|
246
|
+
const id = `entry-body-a11y-${String(index)}`;
|
|
247
|
+
return (
|
|
248
|
+
<li
|
|
249
|
+
key={id}
|
|
250
|
+
className="flex flex-wrap items-start justify-between gap-2 text-small text-content"
|
|
251
|
+
>
|
|
252
|
+
<p id={id} className="m-0 flex min-w-0 flex-1 gap-2">
|
|
253
|
+
<Icon name="warning" size={16} className="mt-0.5 text-warning" />
|
|
254
|
+
<span>
|
|
255
|
+
{lines && note.target.kind === 'body' ? `Line ${String(note.target.line)}. ` : ''}
|
|
256
|
+
<NoteText note={note} />
|
|
257
|
+
</span>
|
|
258
|
+
</p>
|
|
259
|
+
<Button
|
|
260
|
+
variant="quiet"
|
|
261
|
+
aria-describedby={id}
|
|
262
|
+
onPress={() => {
|
|
263
|
+
onShow(note);
|
|
264
|
+
}}
|
|
265
|
+
>
|
|
266
|
+
Show
|
|
267
|
+
</Button>
|
|
268
|
+
</li>
|
|
269
|
+
);
|
|
270
|
+
})}
|
|
271
|
+
</ul>
|
|
272
|
+
</div>
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const ReactSourceBlock = SourceBlock.extend({
|
|
277
|
+
addNodeView() {
|
|
278
|
+
return ReactNodeViewRenderer(SourceView);
|
|
279
|
+
},
|
|
280
|
+
});
|
|
281
|
+
const ReactCard = VesperCard.extend({
|
|
282
|
+
addNodeView() {
|
|
283
|
+
return ReactNodeViewRenderer(CardView);
|
|
284
|
+
},
|
|
285
|
+
});
|
|
286
|
+
const ReactInline = VesperInline.extend({
|
|
287
|
+
addNodeView() {
|
|
288
|
+
return ReactNodeViewRenderer(InlineView, { as: 'span' });
|
|
289
|
+
},
|
|
290
|
+
});
|
|
291
|
+
const ReactTable = VesperTable.extend({
|
|
292
|
+
addNodeView() {
|
|
293
|
+
return ReactNodeViewRenderer(TableView, {
|
|
294
|
+
// The grid and its actions own their events: a click on a cell focuses
|
|
295
|
+
// the cell, rather than selecting the whole table in the text.
|
|
296
|
+
stopEvent: ({ event }) =>
|
|
297
|
+
event.target instanceof Element && event.target.closest('[data-vesper-table-ui]') !== null,
|
|
298
|
+
});
|
|
299
|
+
},
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Holds what the editor's extensions need from the latest render (they are
|
|
304
|
+
* made once, with the editor).
|
|
305
|
+
*/
|
|
306
|
+
class Bridge {
|
|
307
|
+
actions: EditorActions = { open: () => undefined, insert: () => undefined, disabled: true };
|
|
308
|
+
update(actions: EditorActions): void {
|
|
309
|
+
this.actions = actions;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
type EditorActions = {
|
|
314
|
+
open: (pos: number) => void;
|
|
315
|
+
insert: (choice: InsertChoice, range?: { from: number; to: number }) => void;
|
|
316
|
+
disabled: boolean;
|
|
317
|
+
};
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* The body's keys: Escape inside a frame selects the frame, and on a
|
|
321
|
+
* selected block (or in plain text) moves to the toolbar, so leaving the
|
|
322
|
+
* editor never depends on Tab, which indents list items. Enter on a
|
|
323
|
+
* selected block opens its settings; Alt+↑ and Alt+↓ move it. Ctrl+Home
|
|
324
|
+
* and Ctrl+End (⌘↑ and ⌘↓) go to the text's ends. On a Mac, ⇧⌘← and ⇧⌘→
|
|
325
|
+
* beside a chip are left to the browser.
|
|
326
|
+
*/
|
|
327
|
+
function bodyShortcuts(options: {
|
|
328
|
+
containers: ContainerTypes;
|
|
329
|
+
actions: () => EditorActions;
|
|
330
|
+
onLink: () => void;
|
|
331
|
+
}) {
|
|
332
|
+
const { containers, actions } = options;
|
|
333
|
+
const move = (editor: Editor, direction: -1 | 1): boolean => {
|
|
334
|
+
if (actions().disabled) return false;
|
|
335
|
+
const selected = selectedNode(editor, containers);
|
|
336
|
+
const pos =
|
|
337
|
+
selected && selected.type !== 'vesperInline'
|
|
338
|
+
? selected.pos
|
|
339
|
+
: enclosingContainer(editor, containers);
|
|
340
|
+
if (pos === null) return false;
|
|
341
|
+
moveBlock(editor, pos, direction);
|
|
342
|
+
return true;
|
|
343
|
+
};
|
|
344
|
+
return Extension.create({
|
|
345
|
+
name: 'vesperShortcuts',
|
|
346
|
+
addKeyboardShortcuts() {
|
|
347
|
+
const editor = this.editor;
|
|
348
|
+
return {
|
|
349
|
+
'Mod-k': () => {
|
|
350
|
+
options.onLink();
|
|
351
|
+
return true;
|
|
352
|
+
},
|
|
353
|
+
Escape: () => {
|
|
354
|
+
if (!selectedNode(editor, containers)) {
|
|
355
|
+
const container = enclosingContainer(editor, containers);
|
|
356
|
+
if (container !== null) {
|
|
357
|
+
selectBlock(editor, container);
|
|
358
|
+
return true;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
// The formatting toolbar sits just before the text, in the same body editor.
|
|
362
|
+
editor.view.dom
|
|
363
|
+
.closest('[data-vesper-body]')
|
|
364
|
+
?.querySelector<HTMLElement>('[role="toolbar"] button')
|
|
365
|
+
?.focus();
|
|
366
|
+
return true;
|
|
367
|
+
},
|
|
368
|
+
Enter: () => {
|
|
369
|
+
const selected = selectedNode(editor, containers);
|
|
370
|
+
if (!selected || actions().disabled) return false;
|
|
371
|
+
actions().open(selected.pos);
|
|
372
|
+
return true;
|
|
373
|
+
},
|
|
374
|
+
'Alt-ArrowUp': () => move(editor, -1),
|
|
375
|
+
'Alt-ArrowDown': () => move(editor, 1),
|
|
376
|
+
// The text's ends, a block there included: the browser's own move can't land on a
|
|
377
|
+
// table or a card that starts or ends the body, and leaves the caret where it was.
|
|
378
|
+
[isMac ? 'Mod-ArrowUp' : 'Mod-Home']: () => toEdge(editor, 'start'),
|
|
379
|
+
[isMac ? 'Mod-ArrowDown' : 'Mod-End']: () => toEdge(editor, 'end'),
|
|
380
|
+
};
|
|
381
|
+
},
|
|
382
|
+
addProseMirrorPlugins() {
|
|
383
|
+
return [
|
|
384
|
+
new Plugin({
|
|
385
|
+
props: {
|
|
386
|
+
handleDOMEvents: {
|
|
387
|
+
// True here leaves the key to the browser: ProseMirror neither
|
|
388
|
+
// handles it nor prevents its default.
|
|
389
|
+
keydown: (view, event) => selectsLineOverLeaf(view, event),
|
|
390
|
+
},
|
|
391
|
+
},
|
|
392
|
+
}),
|
|
393
|
+
];
|
|
394
|
+
},
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/** The text's start or end: a text position, or the block there, as the arrow keys select it. */
|
|
399
|
+
function toEdge(editor: Editor, edge: 'start' | 'end'): boolean {
|
|
400
|
+
const { doc } = editor.state;
|
|
401
|
+
const selection = edge === 'start' ? Selection.atStart(doc) : Selection.atEnd(doc);
|
|
402
|
+
editor.view.dispatch(editor.state.tr.setSelection(selection).scrollIntoView());
|
|
403
|
+
return true;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* Whether the key is ⇧⌘← or ⇧⌘→ on a Mac with a chip, or another leaf such
|
|
408
|
+
* as a line break, beside the caret on the side the key extends to. There
|
|
409
|
+
* ProseMirror extends the selection over that one node, whatever else is
|
|
410
|
+
* held, and stops the browser's own move to the visual line's end, which
|
|
411
|
+
* is the one that knows where a wrapped line ends (DECISIONS.md, "⇧⌘← and
|
|
412
|
+
* ⇧⌘→ beside a chip are the browser's").
|
|
413
|
+
*/
|
|
414
|
+
function selectsLineOverLeaf(view: EditorView, event: KeyboardEvent): boolean {
|
|
415
|
+
if (!isMac || !event.shiftKey || !event.metaKey || event.altKey || event.ctrlKey) return false;
|
|
416
|
+
if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return false;
|
|
417
|
+
const { selection } = view.state;
|
|
418
|
+
if (!(selection instanceof TextSelection)) return false;
|
|
419
|
+
// The side ProseMirror reads the key as, the way it does in Chrome.
|
|
420
|
+
const backward = (event.key === 'ArrowLeft') !== (getComputedStyle(view.dom).direction === 'rtl');
|
|
421
|
+
const node = backward ? selection.$head.nodeBefore : selection.$head.nodeAfter;
|
|
422
|
+
return node !== null && !node.isText && node.isLeaf;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/** Moves focus into a table's grid, onto the cell last used. */
|
|
426
|
+
function focusGrid(dom: HTMLElement): void {
|
|
427
|
+
const cell =
|
|
428
|
+
dom.querySelector<HTMLElement>('[data-vesper-active]') ??
|
|
429
|
+
dom.querySelector<HTMLElement>('[role="grid"] [aria-colindex]');
|
|
430
|
+
cell?.focus();
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/** A name for the selected block, read out when the arrow keys land on it. */
|
|
434
|
+
function selectionAnnouncement(editor: Editor, ui: Pick<BodyUi, 'catalog' | 'containers'>): string {
|
|
435
|
+
// Only for someone in the text: a body with no text at all opens with its last block selected.
|
|
436
|
+
if (!editor.isFocused) return '';
|
|
437
|
+
const selected = selectedNode(editor, ui.containers);
|
|
438
|
+
if (!selected) return '';
|
|
439
|
+
const node = editor.state.doc.nodeAt(selected.pos);
|
|
440
|
+
if (!node) return '';
|
|
441
|
+
if (node.type.name === 'vesperTable') {
|
|
442
|
+
const value = node.attrs.value as TableValue;
|
|
443
|
+
const columns = value.columns.length;
|
|
444
|
+
const rows = value.rows.length;
|
|
445
|
+
return `Table, ${String(columns)} ${columns === 1 ? 'column' : 'columns'} and ${String(rows)} ${rows === 1 ? 'row' : 'rows'}, selected. Press Enter to edit its cells.`;
|
|
446
|
+
}
|
|
447
|
+
let label: string;
|
|
448
|
+
if (node.type.name === 'sourceBlock') label = sourceLabel(String(node.attrs.blockType ?? ''));
|
|
449
|
+
else if (node.type.name === 'vesperInline') {
|
|
450
|
+
const info = chipInfo(String(node.attrs.source));
|
|
451
|
+
const kind = info.name ? ui.catalog.find(info.syntax, info.name) : undefined;
|
|
452
|
+
label =
|
|
453
|
+
kind?.label ??
|
|
454
|
+
(info.kind === 'variable' ? 'Variable' : info.kind === 'annotation' ? 'Annotation' : 'Tag');
|
|
455
|
+
} else {
|
|
456
|
+
const syntax = node.attrs.syntax as 'markdoc' | 'mdx';
|
|
457
|
+
const name = String(node.attrs.name);
|
|
458
|
+
label = ui.catalog.find(syntax, name)?.label ?? fallbackLabel(syntax, name);
|
|
459
|
+
}
|
|
460
|
+
return `${label} selected. Press Enter for its settings.`;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function WritingView({
|
|
464
|
+
value,
|
|
465
|
+
onChange,
|
|
466
|
+
format,
|
|
467
|
+
disabled,
|
|
468
|
+
entryPath,
|
|
469
|
+
imageFolder,
|
|
470
|
+
partials = null,
|
|
471
|
+
headingStart = 2,
|
|
472
|
+
revealRef,
|
|
473
|
+
}: BodyEditorProps & { revealRef: RefObject<((offset: number) => void) | null> }) {
|
|
474
|
+
const { manifest, loadMdxProps } = useStudio();
|
|
475
|
+
const model = useRef<{ built: BodyDocument; initial: Map<string, string> } | null>(null);
|
|
476
|
+
const onChangeRef = useRef(onChange);
|
|
477
|
+
useEffect(() => {
|
|
478
|
+
onChangeRef.current = onChange;
|
|
479
|
+
});
|
|
480
|
+
const [linkOpen, setLinkOpen] = useState(false);
|
|
481
|
+
const toolbarRef = useRef<HTMLDivElement>(null);
|
|
482
|
+
const [picking, setPicking] = useState(false);
|
|
483
|
+
const [imagePath, setImagePath] = useState<string | null>(null);
|
|
484
|
+
const previewUrl = useFileUrl(imagePath);
|
|
485
|
+
const [settingsAt, setSettingsAt] = useState<number | null>(null);
|
|
486
|
+
const slashIds = useSlashIds();
|
|
487
|
+
// What a table says about its changes ("Row added below row 3.").
|
|
488
|
+
const [said, setSaid] = useState('');
|
|
489
|
+
const newTable = useRef(false);
|
|
490
|
+
|
|
491
|
+
// The schema is made once per editor: the catalog decides its node types.
|
|
492
|
+
const [setup] = useState(() => {
|
|
493
|
+
const catalog = catalogOf(manifest, format);
|
|
494
|
+
const containers = containerTypes(catalog);
|
|
495
|
+
return { catalog, containers };
|
|
496
|
+
});
|
|
497
|
+
const { catalog, containers } = setup;
|
|
498
|
+
// The editor's extensions are made once, so they reach the latest props through this.
|
|
499
|
+
const [bridge] = useState(() => new Bridge());
|
|
500
|
+
|
|
501
|
+
const [extensions] = useState<AnyExtension[]>(() => {
|
|
502
|
+
const choices = (state: EditorState) => choicesAt(state, catalog, containers);
|
|
503
|
+
return [
|
|
504
|
+
StarterKit.configure({
|
|
505
|
+
// Underline has no Markdown; a trailing empty line would add text nobody typed.
|
|
506
|
+
underline: false,
|
|
507
|
+
trailingNode: false,
|
|
508
|
+
link: { openOnClick: false, autolink: true, defaultProtocol: 'https' },
|
|
509
|
+
}),
|
|
510
|
+
Image.configure({ inline: true }),
|
|
511
|
+
Markdown,
|
|
512
|
+
BlockIndex,
|
|
513
|
+
ReactSourceBlock,
|
|
514
|
+
ReactCard,
|
|
515
|
+
ReactInline,
|
|
516
|
+
ReactTable,
|
|
517
|
+
...containers.extensions.map((extension) =>
|
|
518
|
+
(extension as ReturnType<typeof VesperCard.extend>).extend({
|
|
519
|
+
addNodeView() {
|
|
520
|
+
return ReactNodeViewRenderer(ContainerView);
|
|
521
|
+
},
|
|
522
|
+
}),
|
|
523
|
+
),
|
|
524
|
+
SlashCommands({
|
|
525
|
+
...slashIds,
|
|
526
|
+
choices,
|
|
527
|
+
choose: (choice, range) => {
|
|
528
|
+
bridge.actions.insert(choice, range);
|
|
529
|
+
},
|
|
530
|
+
disabled: () => bridge.actions.disabled,
|
|
531
|
+
}),
|
|
532
|
+
bodyShortcuts({
|
|
533
|
+
containers,
|
|
534
|
+
actions: () => bridge.actions,
|
|
535
|
+
onLink: () => {
|
|
536
|
+
setLinkOpen(true);
|
|
537
|
+
},
|
|
538
|
+
}),
|
|
539
|
+
];
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
const editor = useEditor({
|
|
543
|
+
immediatelyRender: true,
|
|
544
|
+
shouldRerenderOnTransaction: false,
|
|
545
|
+
editable: !disabled,
|
|
546
|
+
extensions,
|
|
547
|
+
editorProps: {
|
|
548
|
+
handleScrollToSelection: (view) => scrollClearOfToolbar(view, toolbarRef.current),
|
|
549
|
+
attributes: (state) => ({
|
|
550
|
+
id: 'entry-body',
|
|
551
|
+
'aria-labelledby': 'entry-body-label',
|
|
552
|
+
'aria-describedby': 'entry-body-help',
|
|
553
|
+
// The frame around the toolbar and text draws the boundary and the focus ring.
|
|
554
|
+
class: 'vesper-body min-h-72 resize-y overflow-y-auto px-4 py-3 text-body outline-none',
|
|
555
|
+
// A textbox, or a combobox while the "/" list is open.
|
|
556
|
+
...slashAttributes(state, {
|
|
557
|
+
...slashIds,
|
|
558
|
+
count: (query) => filterChoices(choicesAt(state, catalog, containers), query).length,
|
|
559
|
+
}),
|
|
560
|
+
}),
|
|
561
|
+
},
|
|
562
|
+
onCreate: ({ editor: created }) => {
|
|
563
|
+
const built = buildDocument(value, {
|
|
564
|
+
format,
|
|
565
|
+
markdown: created.markdown as MarkdownManager,
|
|
566
|
+
catalog,
|
|
567
|
+
containers,
|
|
568
|
+
...(manifest.markdoc ? { markdoc: manifest.markdoc.options } : {}),
|
|
569
|
+
});
|
|
570
|
+
created.commands.setContent(built.doc, { emitUpdate: false });
|
|
571
|
+
// setContent maps the empty document's caret to the end, which selects a last block
|
|
572
|
+
// that's a tag, so it was outlined and nothing fit the toolbar's Insert until a click.
|
|
573
|
+
// The caret starts in the first text instead, as when an editor is given its content.
|
|
574
|
+
const start = Selection.findFrom(created.state.doc.resolve(0), 1, true);
|
|
575
|
+
if (start) created.view.dispatch(created.state.tr.setSelection(start));
|
|
576
|
+
model.current = { built, initial: snapshot(created.getJSON(), containers) };
|
|
577
|
+
},
|
|
578
|
+
onUpdate: ({ editor: updated }) => {
|
|
579
|
+
if (!model.current) return;
|
|
580
|
+
const items = bodyItems(updated.getJSON(), model.current.initial, {
|
|
581
|
+
markdown: updated.markdown as MarkdownManager,
|
|
582
|
+
containers,
|
|
583
|
+
parsed: model.current.built.parsed,
|
|
584
|
+
format,
|
|
585
|
+
...(manifest.markdoc ? { markdoc: manifest.markdoc.options } : {}),
|
|
586
|
+
});
|
|
587
|
+
onChangeRef.current(assembleBody(model.current.built.parsed, items));
|
|
588
|
+
},
|
|
589
|
+
});
|
|
590
|
+
|
|
591
|
+
function insertChoice(choice: InsertChoice, range?: { from: number; to: number }) {
|
|
592
|
+
// A new table opens with its first cell being edited.
|
|
593
|
+
newTable.current = choice.kind.builtin === 'table';
|
|
594
|
+
const pos = insertBlock(
|
|
595
|
+
editor,
|
|
596
|
+
choice.kind,
|
|
597
|
+
choice.shape,
|
|
598
|
+
containers,
|
|
599
|
+
range,
|
|
600
|
+
defaultsFor(choice),
|
|
601
|
+
);
|
|
602
|
+
// A new tag with attributes opens its settings straight away (spec §6.5.1).
|
|
603
|
+
if (pos !== null && (choice.kind.fields.length > 0 || choice.shape === 'inline'))
|
|
604
|
+
setSettingsAt(pos);
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
/** A new partial starts out pointing at the first partial file, so it's valid from the start. */
|
|
608
|
+
function defaultsFor(choice: InsertChoice): Record<string, unknown> {
|
|
609
|
+
if (choice.kind.builtin !== 'partial') return {};
|
|
610
|
+
const [first] = partialFiles(partials ?? [], entryPath);
|
|
611
|
+
return first ? { file: relativePath(entryPath, first) } : {};
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
useEffect(() => {
|
|
615
|
+
bridge.update({
|
|
616
|
+
open: (pos) => {
|
|
617
|
+
// A table has no settings: Enter moves into its cells, onto the one last used.
|
|
618
|
+
if (editor.state.doc.nodeAt(pos)?.type.name === 'vesperTable') {
|
|
619
|
+
const dom = editor.view.nodeDOM(pos);
|
|
620
|
+
if (dom instanceof HTMLElement) focusGrid(dom);
|
|
621
|
+
return;
|
|
622
|
+
}
|
|
623
|
+
setSettingsAt(pos);
|
|
624
|
+
},
|
|
625
|
+
insert: insertChoice,
|
|
626
|
+
disabled,
|
|
627
|
+
});
|
|
628
|
+
});
|
|
629
|
+
|
|
630
|
+
const checks = useMemo(
|
|
631
|
+
() => bodyChecks({ manifest, format, loadMdxProps, partials, entryPath }),
|
|
632
|
+
[manifest, format, loadMdxProps, partials, entryPath],
|
|
633
|
+
);
|
|
634
|
+
const ui = useMemo<BodyUi>(
|
|
635
|
+
() => ({
|
|
636
|
+
catalog,
|
|
637
|
+
containers,
|
|
638
|
+
disabled,
|
|
639
|
+
checks,
|
|
640
|
+
partials,
|
|
641
|
+
entryPath,
|
|
642
|
+
openSettings: (pos) => {
|
|
643
|
+
setSettingsAt(pos);
|
|
644
|
+
},
|
|
645
|
+
move: (pos, direction) => {
|
|
646
|
+
const moved = moveBlock(editor, pos, direction);
|
|
647
|
+
if (moved !== null) setSettingsAt((open) => (open === null ? null : moved));
|
|
648
|
+
},
|
|
649
|
+
remove: (pos) => {
|
|
650
|
+
removeBlock(editor, pos);
|
|
651
|
+
},
|
|
652
|
+
announce: (message) => {
|
|
653
|
+
// A different string each time, so the same words are said again.
|
|
654
|
+
setSaid((previous) => (previous === message ? `${message}\u00a0` : message));
|
|
655
|
+
},
|
|
656
|
+
checkTable: (path, value) =>
|
|
657
|
+
tableEdit(path, value, {
|
|
658
|
+
markdown: editor.markdown as MarkdownManager,
|
|
659
|
+
containers,
|
|
660
|
+
format,
|
|
661
|
+
...(model.current ? { parsed: model.current.built.parsed } : {}),
|
|
662
|
+
...(manifest.markdoc ? { markdoc: manifest.markdoc.options } : {}),
|
|
663
|
+
}),
|
|
664
|
+
takeNewTable: () => {
|
|
665
|
+
const fresh = newTable.current;
|
|
666
|
+
newTable.current = false;
|
|
667
|
+
return fresh;
|
|
668
|
+
},
|
|
669
|
+
}),
|
|
670
|
+
[catalog, containers, disabled, checks, partials, entryPath, editor, format, manifest.markdoc],
|
|
671
|
+
);
|
|
672
|
+
|
|
673
|
+
// How a note's Show finds its block: by the current text, parsed again.
|
|
674
|
+
const valueRef = useRef(value);
|
|
675
|
+
useEffect(() => {
|
|
676
|
+
valueRef.current = value;
|
|
677
|
+
});
|
|
678
|
+
useEffect(() => {
|
|
679
|
+
revealRef.current = (offset) => {
|
|
680
|
+
revealInEditor(editor, valueRef.current, offset, {
|
|
681
|
+
format,
|
|
682
|
+
containers,
|
|
683
|
+
...(manifest.markdoc ? { markdoc: manifest.markdoc.options } : {}),
|
|
684
|
+
});
|
|
685
|
+
};
|
|
686
|
+
return () => {
|
|
687
|
+
revealRef.current = null;
|
|
688
|
+
};
|
|
689
|
+
}, [editor, format, containers, manifest.markdoc, revealRef]);
|
|
690
|
+
|
|
691
|
+
const announcement = useEditorState({
|
|
692
|
+
editor,
|
|
693
|
+
selector: ({ editor: e }) => selectionAnnouncement(e, { catalog, containers }),
|
|
694
|
+
});
|
|
695
|
+
const hasBlocks = catalog.kinds.size > 0;
|
|
696
|
+
|
|
697
|
+
return (
|
|
698
|
+
<BodyUiContext.Provider value={ui}>
|
|
699
|
+
<div data-vesper-body="" className="flex flex-col gap-2">
|
|
700
|
+
{/*
|
|
701
|
+
* One hairline frame holds the toolbar and the text. Focus in the text
|
|
702
|
+
* rings the frame; the toolbar's buttons ring themselves.
|
|
703
|
+
*/}
|
|
704
|
+
<div
|
|
705
|
+
className={cx(
|
|
706
|
+
'rounded-md border border-border-strong bg-surface-raised',
|
|
707
|
+
'has-[#entry-body:focus-visible]:outline-2 has-[#entry-body:focus-visible]:outline-solid has-[#entry-body:focus-visible]:outline-offset-2 has-[#entry-body:focus-visible]:outline-focus',
|
|
708
|
+
'has-[#entry-body:focus-visible]:ring-6 has-[#entry-body:focus-visible]:ring-(color:--glow-focus)',
|
|
709
|
+
)}
|
|
710
|
+
>
|
|
711
|
+
{/*
|
|
712
|
+
* Stuck below the bar while the text scrolls, so the tools stay at
|
|
713
|
+
* hand in a long body. Opaque, not glass: it covers text being edited.
|
|
714
|
+
*/}
|
|
715
|
+
<div ref={toolbarRef} className={stuckToolbar}>
|
|
716
|
+
<FormatToolbar
|
|
717
|
+
editor={editor}
|
|
718
|
+
disabled={disabled}
|
|
719
|
+
headingStart={headingStart}
|
|
720
|
+
onLink={() => {
|
|
721
|
+
setLinkOpen(true);
|
|
722
|
+
}}
|
|
723
|
+
onImage={() => {
|
|
724
|
+
setPicking(true);
|
|
725
|
+
}}
|
|
726
|
+
insert={
|
|
727
|
+
<InsertMenu
|
|
728
|
+
editor={editor}
|
|
729
|
+
choices={(state) => choicesAt(state, catalog, containers)}
|
|
730
|
+
onChoose={(choice) => {
|
|
731
|
+
insertChoice(choice);
|
|
732
|
+
}}
|
|
733
|
+
disabled={disabled}
|
|
734
|
+
/>
|
|
735
|
+
}
|
|
736
|
+
/>
|
|
737
|
+
</div>
|
|
738
|
+
<EditorContent editor={editor} />
|
|
739
|
+
</div>
|
|
740
|
+
<SlashList
|
|
741
|
+
editor={editor}
|
|
742
|
+
{...slashIds}
|
|
743
|
+
choices={(state) => choicesAt(state, catalog, containers)}
|
|
744
|
+
onChoose={(choice, range) => {
|
|
745
|
+
insertChoice(choice, range);
|
|
746
|
+
}}
|
|
747
|
+
/>
|
|
748
|
+
<p className="sr-only" role="status">
|
|
749
|
+
{announcement}
|
|
750
|
+
</p>
|
|
751
|
+
{/*
|
|
752
|
+
* Apart from the selection's: a change made in a table's grid also
|
|
753
|
+
* changes what the text's selection says, and neither may hide the other.
|
|
754
|
+
*/}
|
|
755
|
+
<div className="sr-only" role="status">
|
|
756
|
+
{said}
|
|
757
|
+
</div>
|
|
758
|
+
<p id="entry-body-help" className="text-small text-content-muted">
|
|
759
|
+
Ctrl+B bold, Ctrl+I italic, Ctrl+K link.
|
|
760
|
+
{hasBlocks
|
|
761
|
+
? ' Type / to insert a block or a table. Arrow onto a block and press Enter for its settings, or onto a table for its cells; Alt+↑ and Alt+↓ move it.'
|
|
762
|
+
: ' Type / to insert a table. Arrow onto a table and press Enter for its cells; Alt+↑ and Alt+↓ move it.'}{' '}
|
|
763
|
+
Escape leaves a block, then the text. Dashed boxes are kept exactly as written.
|
|
764
|
+
</p>
|
|
765
|
+
|
|
766
|
+
<BlockSettings
|
|
767
|
+
editor={editor}
|
|
768
|
+
pos={settingsAt}
|
|
769
|
+
onClose={() => {
|
|
770
|
+
const pos = settingsAt;
|
|
771
|
+
setSettingsAt(null);
|
|
772
|
+
// Back to the text with the block still selected, so Enter reopens
|
|
773
|
+
// its settings and Alt+↑/↓ or cut moves it. Only once the closing
|
|
774
|
+
// dialog has put focus back in the editor (after its exit), since
|
|
775
|
+
// the caret that focus brings would replace the selection.
|
|
776
|
+
const dom = editor.view.dom;
|
|
777
|
+
let done = false;
|
|
778
|
+
const reselect = () => {
|
|
779
|
+
// Someone may have left the entry in the meantime.
|
|
780
|
+
if (done || editor.isDestroyed) return;
|
|
781
|
+
done = true;
|
|
782
|
+
dom.removeEventListener('focusin', onFocus);
|
|
783
|
+
editor.view.focus();
|
|
784
|
+
const node = pos === null ? null : editor.state.doc.nodeAt(pos);
|
|
785
|
+
if (pos !== null && node && isManaged(node.type.name, containers)) {
|
|
786
|
+
editor.view.dispatch(
|
|
787
|
+
editor.state.tr.setSelection(NodeSelection.create(editor.state.doc, pos)),
|
|
788
|
+
);
|
|
789
|
+
}
|
|
790
|
+
};
|
|
791
|
+
const onFocus = () => setTimeout(reselect, 0);
|
|
792
|
+
dom.addEventListener('focusin', onFocus);
|
|
793
|
+
// In case focus was meant to go somewhere else entirely.
|
|
794
|
+
setTimeout(reselect, 500);
|
|
795
|
+
}}
|
|
796
|
+
/>
|
|
797
|
+
<LinkDialog editor={editor} isOpen={linkOpen} onOpenChange={setLinkOpen} />
|
|
798
|
+
<MediaPicker
|
|
799
|
+
isOpen={picking}
|
|
800
|
+
onOpenChange={setPicking}
|
|
801
|
+
kind="image"
|
|
802
|
+
folder={imageFolder}
|
|
803
|
+
onChoose={(path) => {
|
|
804
|
+
setImagePath(path);
|
|
805
|
+
}}
|
|
806
|
+
/>
|
|
807
|
+
<ImageAltDialog
|
|
808
|
+
path={imagePath}
|
|
809
|
+
previewUrl={previewUrl}
|
|
810
|
+
onCancel={() => {
|
|
811
|
+
setImagePath(null);
|
|
812
|
+
editor.commands.focus();
|
|
813
|
+
}}
|
|
814
|
+
onInsert={(alt) => {
|
|
815
|
+
if (!imagePath) return;
|
|
816
|
+
editor
|
|
817
|
+
.chain()
|
|
818
|
+
.focus()
|
|
819
|
+
.setImage({ src: mediaReference(imagePath, entryPath), alt })
|
|
820
|
+
.run();
|
|
821
|
+
setImagePath(null);
|
|
822
|
+
}}
|
|
823
|
+
/>
|
|
824
|
+
</div>
|
|
825
|
+
</BodyUiContext.Provider>
|
|
826
|
+
);
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
function FormatToolbar({
|
|
830
|
+
editor,
|
|
831
|
+
disabled,
|
|
832
|
+
headingStart,
|
|
833
|
+
onLink,
|
|
834
|
+
onImage,
|
|
835
|
+
insert,
|
|
836
|
+
}: {
|
|
837
|
+
editor: Editor;
|
|
838
|
+
disabled: boolean;
|
|
839
|
+
headingStart: number;
|
|
840
|
+
onLink: () => void;
|
|
841
|
+
onImage: () => void;
|
|
842
|
+
/** The Insert menu, for bodies whose site has tags or components. */
|
|
843
|
+
insert: ReactNode;
|
|
844
|
+
}) {
|
|
845
|
+
const state = useEditorState({
|
|
846
|
+
editor,
|
|
847
|
+
selector: ({ editor: e }) => ({
|
|
848
|
+
bold: e.isActive('bold'),
|
|
849
|
+
italic: e.isActive('italic'),
|
|
850
|
+
code: e.isActive('code'),
|
|
851
|
+
level: e.isActive('heading') ? Number(e.getAttributes('heading').level) : 0,
|
|
852
|
+
bullets: e.isActive('bulletList'),
|
|
853
|
+
numbers: e.isActive('orderedList'),
|
|
854
|
+
quote: e.isActive('blockquote'),
|
|
855
|
+
link: e.isActive('link'),
|
|
856
|
+
}),
|
|
857
|
+
});
|
|
858
|
+
const chain = () => editor.chain().focus();
|
|
859
|
+
return (
|
|
860
|
+
<Toolbar label="Formatting">
|
|
861
|
+
<ToggleButton
|
|
862
|
+
icon="bold"
|
|
863
|
+
label="Bold"
|
|
864
|
+
shortcut={shortcut('Mod', 'B')}
|
|
865
|
+
isDisabled={disabled}
|
|
866
|
+
isSelected={state.bold}
|
|
867
|
+
onChange={() => chain().toggleBold().run()}
|
|
868
|
+
/>
|
|
869
|
+
<ToggleButton
|
|
870
|
+
icon="italic"
|
|
871
|
+
label="Italic"
|
|
872
|
+
shortcut={shortcut('Mod', 'I')}
|
|
873
|
+
isDisabled={disabled}
|
|
874
|
+
isSelected={state.italic}
|
|
875
|
+
onChange={() => chain().toggleItalic().run()}
|
|
876
|
+
/>
|
|
877
|
+
<ToolbarSeparator />
|
|
878
|
+
<TextStyleMenu editor={editor} level={state.level} start={headingStart} disabled={disabled} />
|
|
879
|
+
<ToolbarSeparator />
|
|
880
|
+
<ToggleButton
|
|
881
|
+
icon="bullets"
|
|
882
|
+
label="Bullets"
|
|
883
|
+
shortcut={shortcut('Mod', 'Shift', '8')}
|
|
884
|
+
isDisabled={disabled}
|
|
885
|
+
isSelected={state.bullets}
|
|
886
|
+
onChange={() => chain().toggleBulletList().run()}
|
|
887
|
+
/>
|
|
888
|
+
<ToggleButton
|
|
889
|
+
icon="numbers"
|
|
890
|
+
label="Numbers"
|
|
891
|
+
shortcut={shortcut('Mod', 'Shift', '7')}
|
|
892
|
+
isDisabled={disabled}
|
|
893
|
+
isSelected={state.numbers}
|
|
894
|
+
onChange={() => chain().toggleOrderedList().run()}
|
|
895
|
+
/>
|
|
896
|
+
<ToggleButton
|
|
897
|
+
icon="quote"
|
|
898
|
+
label="Quote"
|
|
899
|
+
shortcut={shortcut('Mod', 'Shift', 'B')}
|
|
900
|
+
isDisabled={disabled}
|
|
901
|
+
isSelected={state.quote}
|
|
902
|
+
onChange={() => chain().toggleBlockquote().run()}
|
|
903
|
+
/>
|
|
904
|
+
<ToggleButton
|
|
905
|
+
icon="code"
|
|
906
|
+
label="Code"
|
|
907
|
+
shortcut={shortcut('Mod', 'E')}
|
|
908
|
+
isDisabled={disabled}
|
|
909
|
+
isSelected={state.code}
|
|
910
|
+
onChange={() => chain().toggleCode().run()}
|
|
911
|
+
/>
|
|
912
|
+
<ToolbarSeparator />
|
|
913
|
+
<ToolButton
|
|
914
|
+
icon="link"
|
|
915
|
+
label={state.link ? 'Edit link' : 'Link'}
|
|
916
|
+
shortcut={shortcut('Mod', 'K')}
|
|
917
|
+
isDisabled={disabled}
|
|
918
|
+
onPress={onLink}
|
|
919
|
+
/>
|
|
920
|
+
<ToolButton icon="image" label="Image" isDisabled={disabled} onPress={onImage} />
|
|
921
|
+
{insert}
|
|
922
|
+
</Toolbar>
|
|
923
|
+
);
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
/**
|
|
927
|
+
* The block under the caret as a paragraph or a heading, in one menu: six
|
|
928
|
+
* toggles would crowd the toolbar. The headings start where the
|
|
929
|
+
* collection's do (`headingStart`, 2 when the layout's title is the `h1`),
|
|
930
|
+
* as the accessibility checks expect; Ctrl+Alt+1 to 6 still make any level.
|
|
931
|
+
* Its button names the style where the caret is, at one width, so the tools
|
|
932
|
+
* after it don't shift as the caret moves. Choosing sends focus back to the
|
|
933
|
+
* text, as a tool does.
|
|
934
|
+
*/
|
|
935
|
+
function TextStyleMenu({
|
|
936
|
+
editor,
|
|
937
|
+
level,
|
|
938
|
+
start,
|
|
939
|
+
disabled,
|
|
940
|
+
}: {
|
|
941
|
+
editor: Editor;
|
|
942
|
+
/** The heading's level where the caret is, or 0 in a paragraph. */
|
|
943
|
+
level: number;
|
|
944
|
+
/** The first heading level offered. */
|
|
945
|
+
start: number;
|
|
946
|
+
disabled: boolean;
|
|
947
|
+
}) {
|
|
948
|
+
const current = level === 0 ? 'Paragraph' : `Heading ${String(level)}`;
|
|
949
|
+
const levels = [1, 2, 3, 4, 5, 6].filter((n) => n >= Math.min(start, 6));
|
|
950
|
+
const options = [
|
|
951
|
+
{ id: '0', label: 'Paragraph' },
|
|
952
|
+
...levels.map((n) => ({ id: String(n), label: `Heading ${String(n)}` })),
|
|
953
|
+
];
|
|
954
|
+
return (
|
|
955
|
+
<Menu
|
|
956
|
+
label="Text style"
|
|
957
|
+
items={[]}
|
|
958
|
+
onAction={() => undefined}
|
|
959
|
+
trigger={
|
|
960
|
+
<ToolButton
|
|
961
|
+
aria-label={`Text style: ${current}`}
|
|
962
|
+
isDisabled={disabled}
|
|
963
|
+
className="w-28 gap-1"
|
|
964
|
+
>
|
|
965
|
+
<span className="min-w-0 flex-1 truncate text-left">{current}</span>
|
|
966
|
+
<Chevron />
|
|
967
|
+
</ToolButton>
|
|
968
|
+
}
|
|
969
|
+
choice={{
|
|
970
|
+
title: 'Text style',
|
|
971
|
+
options,
|
|
972
|
+
selected: String(level),
|
|
973
|
+
onChange: (id) => {
|
|
974
|
+
const next = Number(id);
|
|
975
|
+
if (next === 0) editor.chain().setParagraph().run();
|
|
976
|
+
else
|
|
977
|
+
editor
|
|
978
|
+
.chain()
|
|
979
|
+
.setHeading({ level: next as 1 | 2 | 3 | 4 | 5 | 6 })
|
|
980
|
+
.run();
|
|
981
|
+
},
|
|
982
|
+
onClosed: () => {
|
|
983
|
+
editor.commands.focus();
|
|
984
|
+
},
|
|
985
|
+
}}
|
|
986
|
+
/>
|
|
987
|
+
);
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
export function LinkDialog({
|
|
991
|
+
editor,
|
|
992
|
+
isOpen,
|
|
993
|
+
onOpenChange,
|
|
994
|
+
}: {
|
|
995
|
+
editor: Editor;
|
|
996
|
+
isOpen: boolean;
|
|
997
|
+
onOpenChange: (open: boolean) => void;
|
|
998
|
+
}) {
|
|
999
|
+
const current = editor.getAttributes('link').href as string | undefined;
|
|
1000
|
+
const close = () => {
|
|
1001
|
+
onOpenChange(false);
|
|
1002
|
+
editor.commands.focus();
|
|
1003
|
+
};
|
|
1004
|
+
return (
|
|
1005
|
+
<Dialog
|
|
1006
|
+
title={current ? 'Edit link' : 'Add a link'}
|
|
1007
|
+
isOpen={isOpen}
|
|
1008
|
+
onOpenChange={(open) => {
|
|
1009
|
+
if (open) onOpenChange(true);
|
|
1010
|
+
else close();
|
|
1011
|
+
}}
|
|
1012
|
+
>
|
|
1013
|
+
{isOpen && <LinkForm editor={editor} current={current} onDone={close} />}
|
|
1014
|
+
</Dialog>
|
|
1015
|
+
);
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
function LinkForm({
|
|
1019
|
+
editor,
|
|
1020
|
+
current,
|
|
1021
|
+
onDone,
|
|
1022
|
+
}: {
|
|
1023
|
+
editor: Editor;
|
|
1024
|
+
current: string | undefined;
|
|
1025
|
+
onDone: () => void;
|
|
1026
|
+
}) {
|
|
1027
|
+
const [href, setHref] = useState(current ?? '');
|
|
1028
|
+
// The link picker (spec §6.15.6): pages by title, written as their address.
|
|
1029
|
+
const pages = usePages();
|
|
1030
|
+
const suggestions = useMemo(() => pageSuggestions(pages), [pages]);
|
|
1031
|
+
return (
|
|
1032
|
+
<form
|
|
1033
|
+
className="flex flex-col gap-4"
|
|
1034
|
+
onSubmit={(event) => {
|
|
1035
|
+
event.preventDefault();
|
|
1036
|
+
const url = href.trim();
|
|
1037
|
+
if (url === '') editor.chain().focus().extendMarkRange('link').unsetLink().run();
|
|
1038
|
+
else editor.chain().focus().extendMarkRange('link').setLink({ href: url }).run();
|
|
1039
|
+
onDone();
|
|
1040
|
+
}}
|
|
1041
|
+
>
|
|
1042
|
+
<SuggestField
|
|
1043
|
+
label="Page or web address"
|
|
1044
|
+
description="Search pages by title, or type an address such as https://example.com."
|
|
1045
|
+
value={href}
|
|
1046
|
+
onChange={setHref}
|
|
1047
|
+
suggestions={suggestions}
|
|
1048
|
+
inputMode="url"
|
|
1049
|
+
/>
|
|
1050
|
+
<div className="flex flex-wrap justify-end gap-3">
|
|
1051
|
+
{current && (
|
|
1052
|
+
<Button
|
|
1053
|
+
variant="quiet"
|
|
1054
|
+
onPress={() => {
|
|
1055
|
+
editor.chain().focus().extendMarkRange('link').unsetLink().run();
|
|
1056
|
+
onDone();
|
|
1057
|
+
}}
|
|
1058
|
+
>
|
|
1059
|
+
Remove link
|
|
1060
|
+
</Button>
|
|
1061
|
+
)}
|
|
1062
|
+
<Button type="submit" variant="primary">
|
|
1063
|
+
{current ? 'Update link' : 'Add link'}
|
|
1064
|
+
</Button>
|
|
1065
|
+
</div>
|
|
1066
|
+
</form>
|
|
1067
|
+
);
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
/** Alt text before an image goes into the body: required, unless it's decorative. */
|
|
1071
|
+
function ImageAltDialog({
|
|
1072
|
+
path,
|
|
1073
|
+
previewUrl,
|
|
1074
|
+
onCancel,
|
|
1075
|
+
onInsert,
|
|
1076
|
+
}: {
|
|
1077
|
+
path: string | null;
|
|
1078
|
+
previewUrl: string;
|
|
1079
|
+
onCancel: () => void;
|
|
1080
|
+
onInsert: (alt: string) => void;
|
|
1081
|
+
}) {
|
|
1082
|
+
const [alt, setAlt] = useState('');
|
|
1083
|
+
const [decorative, setDecorative] = useState(false);
|
|
1084
|
+
const [missing, setMissing] = useState(false);
|
|
1085
|
+
return (
|
|
1086
|
+
<Dialog
|
|
1087
|
+
title="Describe the image"
|
|
1088
|
+
isOpen={path !== null}
|
|
1089
|
+
onOpenChange={(open) => {
|
|
1090
|
+
if (!open) onCancel();
|
|
1091
|
+
}}
|
|
1092
|
+
>
|
|
1093
|
+
<form
|
|
1094
|
+
className="flex flex-col gap-4"
|
|
1095
|
+
onSubmit={(event) => {
|
|
1096
|
+
event.preventDefault();
|
|
1097
|
+
if (!decorative && alt.trim() === '') {
|
|
1098
|
+
setMissing(true);
|
|
1099
|
+
return;
|
|
1100
|
+
}
|
|
1101
|
+
onInsert(decorative ? '' : alt.trim());
|
|
1102
|
+
setAlt('');
|
|
1103
|
+
setDecorative(false);
|
|
1104
|
+
setMissing(false);
|
|
1105
|
+
}}
|
|
1106
|
+
>
|
|
1107
|
+
{previewUrl && (
|
|
1108
|
+
<img src={previewUrl} alt="" className="max-h-40 self-start rounded-md object-contain" />
|
|
1109
|
+
)}
|
|
1110
|
+
{path && (
|
|
1111
|
+
<p className="text-small text-content-muted">{path.slice(path.lastIndexOf('/') + 1)}</p>
|
|
1112
|
+
)}
|
|
1113
|
+
{!decorative && (
|
|
1114
|
+
<TextArea
|
|
1115
|
+
label="Alt text"
|
|
1116
|
+
isRequired
|
|
1117
|
+
description="Describe what this image communicates here."
|
|
1118
|
+
value={alt}
|
|
1119
|
+
onChange={(next) => {
|
|
1120
|
+
setAlt(next);
|
|
1121
|
+
setMissing(false);
|
|
1122
|
+
}}
|
|
1123
|
+
isInvalid={missing}
|
|
1124
|
+
errorMessage="Describe the image, or mark it decorative."
|
|
1125
|
+
rows={2}
|
|
1126
|
+
/>
|
|
1127
|
+
)}
|
|
1128
|
+
<Checkbox isSelected={decorative} onChange={setDecorative}>
|
|
1129
|
+
Decorative image (it adds nothing a reader needs)
|
|
1130
|
+
</Checkbox>
|
|
1131
|
+
<div className="flex justify-end">
|
|
1132
|
+
<Button type="submit" variant="primary">
|
|
1133
|
+
Insert image
|
|
1134
|
+
</Button>
|
|
1135
|
+
</div>
|
|
1136
|
+
</form>
|
|
1137
|
+
</Dialog>
|
|
1138
|
+
);
|
|
1139
|
+
}
|