@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,510 @@
|
|
|
1
|
+
import type { Editor } from '@tiptap/core';
|
|
2
|
+
import { useEditorState } from '@tiptap/react';
|
|
3
|
+
import {
|
|
4
|
+
deepEqual,
|
|
5
|
+
editJsxTag,
|
|
6
|
+
editMarkdocTag,
|
|
7
|
+
markdocTagLayout,
|
|
8
|
+
markdocTags,
|
|
9
|
+
jsxTagLayout,
|
|
10
|
+
type DataObject,
|
|
11
|
+
type DataValue,
|
|
12
|
+
type FieldModel,
|
|
13
|
+
} from '@vespercms/core';
|
|
14
|
+
import { Button, Dialog, Select, TextArea, TextField } from '@vespercms/ui';
|
|
15
|
+
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
|
|
16
|
+
import { EditorContext, useEditor, type EditorState } from '../editor/context.tsx';
|
|
17
|
+
import { Field } from '../editor/Field.tsx';
|
|
18
|
+
import { pathKey, setIn, type ValuePath } from '../editor/values.ts';
|
|
19
|
+
import { fallbackLabel, type BlockKind } from './catalog.ts';
|
|
20
|
+
import { chipInfo, tagValues } from './chips.ts';
|
|
21
|
+
import { sourceLabel } from './document.ts';
|
|
22
|
+
import { useBlockIssues, useBodyUi } from './ui.ts';
|
|
23
|
+
|
|
24
|
+
/*
|
|
25
|
+
* A block's settings (spec §6.5.1, "Settings form"): its attributes as
|
|
26
|
+
* fields, rendered with the same widgets as frontmatter. Changes apply as
|
|
27
|
+
* they're made, splicing only the attribute that changed into the tag's
|
|
28
|
+
* text, so Ctrl+Z undoes them like typing. The same dialog moves and
|
|
29
|
+
* removes the block, so one keystroke (Enter on a selected block) reaches
|
|
30
|
+
* every action.
|
|
31
|
+
*
|
|
32
|
+
* Protected constructs (variables, conditions, unknown tags, raw HTML) get
|
|
33
|
+
* a small text field instead: edited as written, never reformatted.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
/** A node attribute that holds text. */
|
|
37
|
+
const attrText = (value: unknown): string => (typeof value === 'string' ? value : '');
|
|
38
|
+
|
|
39
|
+
type Target = {
|
|
40
|
+
pos: number;
|
|
41
|
+
type: string;
|
|
42
|
+
attrs: Record<string, unknown>;
|
|
43
|
+
inline: boolean;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export function BlockSettings({
|
|
47
|
+
editor,
|
|
48
|
+
pos,
|
|
49
|
+
onClose,
|
|
50
|
+
}: {
|
|
51
|
+
editor: Editor;
|
|
52
|
+
pos: number | null;
|
|
53
|
+
onClose: () => void;
|
|
54
|
+
}) {
|
|
55
|
+
const ui = useBodyUi();
|
|
56
|
+
// Follow the node as the document changes (its attributes, or a move).
|
|
57
|
+
const target = useEditorState({
|
|
58
|
+
editor,
|
|
59
|
+
selector: ({ editor: e }): Target | null => {
|
|
60
|
+
if (pos === null) return null;
|
|
61
|
+
const node = e.state.doc.nodeAt(pos);
|
|
62
|
+
if (!node) return null;
|
|
63
|
+
return { pos, type: node.type.name, attrs: node.attrs, inline: node.isInline };
|
|
64
|
+
},
|
|
65
|
+
equalityFn: (a, b) => JSON.stringify(a) === JSON.stringify(b),
|
|
66
|
+
});
|
|
67
|
+
const view = target ? describe(target, ui) : null;
|
|
68
|
+
return (
|
|
69
|
+
<Dialog
|
|
70
|
+
title={view?.title ?? 'Settings'}
|
|
71
|
+
isOpen={pos !== null && target !== null}
|
|
72
|
+
onOpenChange={(open) => {
|
|
73
|
+
if (!open) onClose();
|
|
74
|
+
}}
|
|
75
|
+
>
|
|
76
|
+
{target && view && (
|
|
77
|
+
<FirstFieldFocus className="flex flex-col gap-6">
|
|
78
|
+
{view.mode === 'tag' && view.kind && (
|
|
79
|
+
<TagForm editor={editor} target={target} kind={view.kind} />
|
|
80
|
+
)}
|
|
81
|
+
{view.mode === 'condition' && <ConditionForm editor={editor} target={target} />}
|
|
82
|
+
{view.mode === 'raw' && <RawForm editor={editor} target={target} label={view.label} />}
|
|
83
|
+
<Actions editor={editor} target={target} onClose={onClose} />
|
|
84
|
+
</FirstFieldFocus>
|
|
85
|
+
)}
|
|
86
|
+
</Dialog>
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Moves focus to the dialog's first field when it opens, so typing goes
|
|
92
|
+
* straight into it rather than to the dialog's Close button.
|
|
93
|
+
*/
|
|
94
|
+
function FirstFieldFocus({ className, children }: { className: string; children: ReactNode }) {
|
|
95
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
96
|
+
useEffect(() => {
|
|
97
|
+
// After the opener (a menu, say) has had its turn at restoring focus.
|
|
98
|
+
const timer = setTimeout(() => {
|
|
99
|
+
const first = ref.current?.querySelector<HTMLInputElement>(
|
|
100
|
+
'input:not([type=hidden]):not([disabled]), textarea:not([disabled]), button:not([disabled])',
|
|
101
|
+
);
|
|
102
|
+
// In a radio group, focus belongs on the chosen option, as Tab would put it.
|
|
103
|
+
const checked =
|
|
104
|
+
first?.type === 'radio'
|
|
105
|
+
? ref.current?.querySelector<HTMLInputElement>(
|
|
106
|
+
`input[type=radio][name="${CSS.escape(first.name)}"]:checked`,
|
|
107
|
+
)
|
|
108
|
+
: null;
|
|
109
|
+
(checked ?? first)?.focus();
|
|
110
|
+
}, 0);
|
|
111
|
+
return () => {
|
|
112
|
+
clearTimeout(timer);
|
|
113
|
+
};
|
|
114
|
+
}, []);
|
|
115
|
+
return (
|
|
116
|
+
<div ref={ref} className={className}>
|
|
117
|
+
{children}
|
|
118
|
+
</div>
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
type View = { mode: 'tag' | 'condition' | 'raw'; title: string; label: string; kind?: BlockKind };
|
|
123
|
+
|
|
124
|
+
function describe(target: Target, ui: ReturnType<typeof useBodyUi>): View {
|
|
125
|
+
if (target.type === 'sourceBlock') {
|
|
126
|
+
const label = sourceLabel(attrText(target.attrs.blockType));
|
|
127
|
+
return { mode: 'raw', title: `Edit ${label.toLowerCase()} as text`, label };
|
|
128
|
+
}
|
|
129
|
+
const container = ui.containers.kindOf(target.type);
|
|
130
|
+
if (container) {
|
|
131
|
+
if (container.builtin === 'if')
|
|
132
|
+
return { mode: 'condition', title: 'Conditional content', label: container.label };
|
|
133
|
+
return {
|
|
134
|
+
mode: 'tag',
|
|
135
|
+
title: `${container.label} settings`,
|
|
136
|
+
label: container.label,
|
|
137
|
+
kind: container,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
if (target.type === 'vesperCard') {
|
|
141
|
+
const syntax = target.attrs.syntax as 'markdoc' | 'mdx';
|
|
142
|
+
const name = attrText(target.attrs.name);
|
|
143
|
+
const kind = ui.catalog.find(syntax, name);
|
|
144
|
+
if (kind && !kind.builtin)
|
|
145
|
+
return { mode: 'tag', title: `${kind.label} settings`, label: kind.label, kind };
|
|
146
|
+
if (kind?.builtin === 'partial')
|
|
147
|
+
return { mode: 'tag', title: 'Partial settings', label: kind.label, kind };
|
|
148
|
+
const label = kind?.label ?? fallbackLabel(syntax, name);
|
|
149
|
+
return { mode: 'raw', title: `Edit ${label} as text`, label };
|
|
150
|
+
}
|
|
151
|
+
const info = chipInfo(attrText(target.attrs.source));
|
|
152
|
+
const kind = info.name ? ui.catalog.find(info.syntax, info.name) : undefined;
|
|
153
|
+
if (kind && !kind.builtin && (info.kind === 'tag' || info.kind === 'component')) {
|
|
154
|
+
return { mode: 'tag', title: `${kind.label} settings`, label: kind.label, kind };
|
|
155
|
+
}
|
|
156
|
+
const label =
|
|
157
|
+
info.kind === 'variable'
|
|
158
|
+
? 'Variable'
|
|
159
|
+
: info.kind === 'annotation'
|
|
160
|
+
? 'Annotation'
|
|
161
|
+
: info.kind === 'expression'
|
|
162
|
+
? 'Expression'
|
|
163
|
+
: info.name
|
|
164
|
+
? fallbackLabel(info.syntax, info.name)
|
|
165
|
+
: 'Tag';
|
|
166
|
+
return { mode: 'raw', title: `Edit ${label.toLowerCase()} as text`, label };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** A node's tag text split as opening, the rest, and how to put a new opening back. */
|
|
170
|
+
function partsOf(target: Target): {
|
|
171
|
+
syntax: 'markdoc' | 'mdx';
|
|
172
|
+
open: string;
|
|
173
|
+
rest: string;
|
|
174
|
+
inner?: string;
|
|
175
|
+
close?: string;
|
|
176
|
+
} {
|
|
177
|
+
if (target.type === 'vesperInline') {
|
|
178
|
+
const source = attrText(target.attrs.source);
|
|
179
|
+
const info = chipInfo(source);
|
|
180
|
+
return {
|
|
181
|
+
syntax: info.syntax,
|
|
182
|
+
open: info.open ?? source,
|
|
183
|
+
rest: source.slice((info.open ?? source).length),
|
|
184
|
+
inner: info.inner ?? '',
|
|
185
|
+
close: info.close ?? '',
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
const syntax = target.attrs.syntax as 'markdoc' | 'mdx';
|
|
189
|
+
if (target.type === 'vesperCard') {
|
|
190
|
+
const source = attrText(target.attrs.source);
|
|
191
|
+
const end =
|
|
192
|
+
syntax === 'markdoc'
|
|
193
|
+
? (markdocTags(source)[0]?.end ?? source.length)
|
|
194
|
+
: (jsxTagLayout(source)?.openEnd ?? source.length);
|
|
195
|
+
return { syntax, open: source.slice(0, end), rest: source.slice(end) };
|
|
196
|
+
}
|
|
197
|
+
return { syntax, open: attrText(target.attrs.open), rest: '' };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function setAttrs(editor: Editor, target: Target, attrs: Record<string, unknown>): void {
|
|
201
|
+
const tr = editor.state.tr.setNodeMarkup(target.pos, undefined, { ...target.attrs, ...attrs });
|
|
202
|
+
editor.view.dispatch(tr);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Writes a new opening back into the node it came from. */
|
|
206
|
+
function applyOpening(editor: Editor, target: Target, open: string): void {
|
|
207
|
+
const parts = partsOf(target);
|
|
208
|
+
if (target.type === 'vesperInline' || target.type === 'vesperCard') {
|
|
209
|
+
setAttrs(editor, target, { source: open + parts.rest });
|
|
210
|
+
} else setAttrs(editor, target, { open });
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function TagForm({ editor, target, kind }: { editor: Editor; target: Target; kind: BlockKind }) {
|
|
214
|
+
const ui = useBodyUi();
|
|
215
|
+
const outer = useEditor();
|
|
216
|
+
const parts = partsOf(target);
|
|
217
|
+
const { values, code } = tagValues(parts.syntax, parts.open);
|
|
218
|
+
const [problem, setProblem] = useState<string | null>(null);
|
|
219
|
+
const fullText =
|
|
220
|
+
target.type === 'vesperInline'
|
|
221
|
+
? attrText(target.attrs.source)
|
|
222
|
+
: target.type === 'vesperCard'
|
|
223
|
+
? attrText(target.attrs.source)
|
|
224
|
+
: parts.syntax === 'mdx'
|
|
225
|
+
? `${parts.open}\n\n${attrText(target.attrs.close)}`
|
|
226
|
+
: `${parts.open}\n${attrText(target.attrs.close)}`;
|
|
227
|
+
const issues = useBlockIssues(fullText);
|
|
228
|
+
|
|
229
|
+
const display = useMemo(() => {
|
|
230
|
+
const out: DataObject = {};
|
|
231
|
+
for (const field of kind.fields) {
|
|
232
|
+
const key = field.path[0] ?? '';
|
|
233
|
+
const value = key in values ? values[key] : field.default;
|
|
234
|
+
if (value !== undefined) out[key] = value as DataValue;
|
|
235
|
+
}
|
|
236
|
+
return out;
|
|
237
|
+
}, [kind.fields, values]);
|
|
238
|
+
|
|
239
|
+
const issueMap = useMemo(() => {
|
|
240
|
+
const map = new Map<string, string[]>();
|
|
241
|
+
for (const issue of issues) {
|
|
242
|
+
if (!issue.attribute || issue.level !== 'error') continue;
|
|
243
|
+
const key = pathKey([issue.attribute]);
|
|
244
|
+
// Under the field, the block's name would only repeat the dialog's title.
|
|
245
|
+
const message = issue.message.replace(/^[^:]+: /, '');
|
|
246
|
+
map.set(key, [...(map.get(key) ?? []), message]);
|
|
247
|
+
}
|
|
248
|
+
return map;
|
|
249
|
+
}, [issues]);
|
|
250
|
+
|
|
251
|
+
const fields =
|
|
252
|
+
kind.builtin === 'partial' ? kind.fields.filter((f) => f.path[0] !== 'file') : kind.fields;
|
|
253
|
+
const state: EditorState = {
|
|
254
|
+
...outer,
|
|
255
|
+
values: display,
|
|
256
|
+
issues: issueMap,
|
|
257
|
+
disabled: ui.disabled,
|
|
258
|
+
idPrefix: `block${String(target.pos)}`,
|
|
259
|
+
setValue: (path: ValuePath, changed: DataValue) => {
|
|
260
|
+
const key = String(path[0]);
|
|
261
|
+
// A field inside a list or object attribute changes its part of the attribute's value.
|
|
262
|
+
const value = path.length > 1 ? (setIn(display, path, changed)[key] ?? null) : changed;
|
|
263
|
+
const field = kind.fields.find((f) => f.path[0] === key);
|
|
264
|
+
// A value set back to its default is left out of the tag (house style).
|
|
265
|
+
const next =
|
|
266
|
+
field?.default !== undefined && deepEqual(value, field.default) ? undefined : value;
|
|
267
|
+
const edit = parts.syntax === 'markdoc' ? editMarkdocTag : editJsxTag;
|
|
268
|
+
const edited = edit(parts.open, [[key, next]], kind.order);
|
|
269
|
+
if (edited === null) {
|
|
270
|
+
setProblem(
|
|
271
|
+
'This tag is written in a way the form can’t change. Edit it as Markdoc instead.',
|
|
272
|
+
);
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
setProblem(null);
|
|
276
|
+
applyOpening(editor, target, edited);
|
|
277
|
+
},
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
return (
|
|
281
|
+
<EditorContext.Provider value={state}>
|
|
282
|
+
{kind.description && <p className="text-small text-content-muted">{kind.description}</p>}
|
|
283
|
+
{problem && (
|
|
284
|
+
<p role="alert" className="text-small text-danger">
|
|
285
|
+
{problem}
|
|
286
|
+
</p>
|
|
287
|
+
)}
|
|
288
|
+
{kind.builtin === 'partial' && (
|
|
289
|
+
<PartialFile
|
|
290
|
+
value={typeof values.file === 'string' ? values.file : ''}
|
|
291
|
+
message={issues.find((i) => i.code === 'partial-missing')?.message}
|
|
292
|
+
onChange={(file) => {
|
|
293
|
+
const edited = editMarkdocTag(parts.open, [['file', file]], kind.order);
|
|
294
|
+
if (edited) applyOpening(editor, target, edited);
|
|
295
|
+
}}
|
|
296
|
+
/>
|
|
297
|
+
)}
|
|
298
|
+
{fields.map((field: FieldModel) => (
|
|
299
|
+
<Field key={field.path.join('.')} field={field} path={field.path} />
|
|
300
|
+
))}
|
|
301
|
+
{target.type === 'vesperInline' && parts.close !== '' && (
|
|
302
|
+
<TextField
|
|
303
|
+
label="Text"
|
|
304
|
+
description="What the reader sees."
|
|
305
|
+
value={parts.inner ?? ''}
|
|
306
|
+
isDisabled={ui.disabled}
|
|
307
|
+
onChange={(inner) => {
|
|
308
|
+
setAttrs(editor, target, { source: parts.open + inner + (parts.close ?? '') });
|
|
309
|
+
}}
|
|
310
|
+
/>
|
|
311
|
+
)}
|
|
312
|
+
{Object.keys(code).length > 0 && (
|
|
313
|
+
<div className="text-small text-content-muted">
|
|
314
|
+
<p>Set in the page’s code, so kept as written:</p>
|
|
315
|
+
<ul className="list-disc pl-6">
|
|
316
|
+
{Object.entries(code).map(([key, text]) => (
|
|
317
|
+
<li key={key}>
|
|
318
|
+
<code className="font-mono">{key}</code>: <code className="font-mono">{text}</code>
|
|
319
|
+
</li>
|
|
320
|
+
))}
|
|
321
|
+
</ul>
|
|
322
|
+
</div>
|
|
323
|
+
)}
|
|
324
|
+
</EditorContext.Provider>
|
|
325
|
+
);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** The partial's file: one of the site's partials, stored relative to this entry. */
|
|
329
|
+
function PartialFile({
|
|
330
|
+
value,
|
|
331
|
+
message,
|
|
332
|
+
onChange,
|
|
333
|
+
}: {
|
|
334
|
+
value: string;
|
|
335
|
+
message: string | undefined;
|
|
336
|
+
onChange: (file: string) => void;
|
|
337
|
+
}) {
|
|
338
|
+
const ui = useBodyUi();
|
|
339
|
+
const options = partialOptions(ui.partials ?? [], ui.entryPath, value);
|
|
340
|
+
return (
|
|
341
|
+
<Select
|
|
342
|
+
label="File"
|
|
343
|
+
description="Text kept in its own file and shown here. Files whose names start with an underscore, or that sit in a partials folder, are listed."
|
|
344
|
+
options={options}
|
|
345
|
+
value={value === '' ? null : value}
|
|
346
|
+
onChange={(next) => {
|
|
347
|
+
if (next !== null) onChange(next);
|
|
348
|
+
}}
|
|
349
|
+
isInvalid={message !== undefined}
|
|
350
|
+
errorMessage={message?.replace(/^[^:]+: /, '')}
|
|
351
|
+
isRequired
|
|
352
|
+
isDisabled={ui.disabled}
|
|
353
|
+
/>
|
|
354
|
+
);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** Partial-like files, as paths relative to the entry (how the tag stores them), labelled by site path. */
|
|
358
|
+
export function partialOptions(
|
|
359
|
+
files: readonly string[],
|
|
360
|
+
entryPath: string,
|
|
361
|
+
current: string,
|
|
362
|
+
): { value: string; label: string }[] {
|
|
363
|
+
const options = partialFiles(files, entryPath).map((path) => ({
|
|
364
|
+
value: relativePath(entryPath, path),
|
|
365
|
+
label: path,
|
|
366
|
+
}));
|
|
367
|
+
if (current !== '' && !options.some((o) => o.value === current)) {
|
|
368
|
+
options.unshift({ value: current, label: `${current} (as written)` });
|
|
369
|
+
}
|
|
370
|
+
return options;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* Markdoc files named like partials (`_name.mdoc`, or in a `partials`
|
|
375
|
+
* folder), nearest the entry first: its own folder, then the folders above
|
|
376
|
+
* it, then everything else.
|
|
377
|
+
*/
|
|
378
|
+
export function partialFiles(files: readonly string[], entryPath: string): string[] {
|
|
379
|
+
// How far a file is from the entry: the folders between them (`_x.mdoc` 0, `../_x.mdoc` 1).
|
|
380
|
+
const distance = (path: string) => relativePath(entryPath, path).split('/').length - 1;
|
|
381
|
+
return files
|
|
382
|
+
.filter((path) => path !== entryPath && path.endsWith('.mdoc'))
|
|
383
|
+
.filter((path) => /(^|\/)_[^/]+$/.test(path) || /(^|\/)_?partials\//.test(path))
|
|
384
|
+
.sort((a, b) => distance(a) - distance(b) || a.localeCompare(b));
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/** `to` relative to the folder holding `from`, both site-relative: `_x.mdoc`, `../_x.mdoc`. */
|
|
388
|
+
export function relativePath(from: string, to: string): string {
|
|
389
|
+
const base = from.split('/').slice(0, -1);
|
|
390
|
+
const target = to.split('/');
|
|
391
|
+
let common = 0;
|
|
392
|
+
while (common < base.length && common < target.length - 1 && base[common] === target[common])
|
|
393
|
+
common++;
|
|
394
|
+
return [...Array<string>(base.length - common).fill('..'), ...target.slice(common)].join('/');
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function ConditionForm({ editor, target }: { editor: Editor; target: Target }) {
|
|
398
|
+
const ui = useBodyUi();
|
|
399
|
+
const open = attrText(target.attrs.open);
|
|
400
|
+
const layout = markdocTagLayout(open);
|
|
401
|
+
const primary = layout?.attributes.find((a) => a.kind === 'primary');
|
|
402
|
+
const [text, setText] = useState(primary ? open.slice(primary.valueStart, primary.valueEnd) : '');
|
|
403
|
+
const [invalid, setInvalid] = useState(false);
|
|
404
|
+
if (!layout || !primary)
|
|
405
|
+
return <RawForm editor={editor} target={target} label="Condition" field="open" />;
|
|
406
|
+
return (
|
|
407
|
+
<TextField
|
|
408
|
+
label="Shown when"
|
|
409
|
+
description='A Markdoc condition, such as $product.beta or equals($tier, "free"). The content inside is shown only when it’s true.'
|
|
410
|
+
value={text}
|
|
411
|
+
isDisabled={ui.disabled}
|
|
412
|
+
isInvalid={invalid}
|
|
413
|
+
errorMessage="That isn’t a condition Markdoc can read, so it wasn’t changed."
|
|
414
|
+
inputClassName="font-mono"
|
|
415
|
+
onChange={(next) => {
|
|
416
|
+
setText(next);
|
|
417
|
+
const edited = open.slice(0, primary.valueStart) + next + open.slice(primary.valueEnd);
|
|
418
|
+
const ok =
|
|
419
|
+
next.trim() !== '' &&
|
|
420
|
+
markdocTagLayout(edited)?.attributes.some((a) => a.kind === 'primary');
|
|
421
|
+
setInvalid(!ok);
|
|
422
|
+
if (ok) setAttrs(editor, target, { open: edited });
|
|
423
|
+
}}
|
|
424
|
+
/>
|
|
425
|
+
);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/** A protected construct's text, edited as written. */
|
|
429
|
+
function RawForm({
|
|
430
|
+
editor,
|
|
431
|
+
target,
|
|
432
|
+
label,
|
|
433
|
+
field,
|
|
434
|
+
}: {
|
|
435
|
+
editor: Editor;
|
|
436
|
+
target: Target;
|
|
437
|
+
label: string;
|
|
438
|
+
field?: string;
|
|
439
|
+
}) {
|
|
440
|
+
const ui = useBodyUi();
|
|
441
|
+
const key = field ?? (target.type === 'sourceBlock' ? 'text' : 'source');
|
|
442
|
+
const value = attrText(target.attrs[key]);
|
|
443
|
+
return (
|
|
444
|
+
<TextArea
|
|
445
|
+
label={label}
|
|
446
|
+
description="Kept exactly as written: Vesper never reformats it. Change it with care; the site reads it as code."
|
|
447
|
+
value={value}
|
|
448
|
+
rows={Math.min(12, value.split('\n').length + 1)}
|
|
449
|
+
isDisabled={ui.disabled}
|
|
450
|
+
inputClassName="font-mono text-small"
|
|
451
|
+
onChange={(next) => {
|
|
452
|
+
setAttrs(editor, target, { [key]: next });
|
|
453
|
+
}}
|
|
454
|
+
/>
|
|
455
|
+
);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function Actions({
|
|
459
|
+
editor,
|
|
460
|
+
target,
|
|
461
|
+
onClose,
|
|
462
|
+
}: {
|
|
463
|
+
editor: Editor;
|
|
464
|
+
target: Target;
|
|
465
|
+
onClose: () => void;
|
|
466
|
+
}) {
|
|
467
|
+
const ui = useBodyUi();
|
|
468
|
+
const $pos = editor.state.doc.resolve(target.pos);
|
|
469
|
+
const index = $pos.index();
|
|
470
|
+
const count = $pos.parent.childCount;
|
|
471
|
+
return (
|
|
472
|
+
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-border pt-4">
|
|
473
|
+
<div className="flex flex-wrap gap-2">
|
|
474
|
+
{!target.inline && (
|
|
475
|
+
<>
|
|
476
|
+
<Button
|
|
477
|
+
isDisabled={ui.disabled || index === 0}
|
|
478
|
+
onPress={() => {
|
|
479
|
+
ui.move(target.pos, -1);
|
|
480
|
+
}}
|
|
481
|
+
>
|
|
482
|
+
Move up
|
|
483
|
+
</Button>
|
|
484
|
+
<Button
|
|
485
|
+
isDisabled={ui.disabled || index >= count - 1}
|
|
486
|
+
onPress={() => {
|
|
487
|
+
ui.move(target.pos, 1);
|
|
488
|
+
}}
|
|
489
|
+
>
|
|
490
|
+
Move down
|
|
491
|
+
</Button>
|
|
492
|
+
</>
|
|
493
|
+
)}
|
|
494
|
+
<Button
|
|
495
|
+
variant="quiet"
|
|
496
|
+
isDisabled={ui.disabled}
|
|
497
|
+
onPress={() => {
|
|
498
|
+
onClose();
|
|
499
|
+
ui.remove(target.pos);
|
|
500
|
+
}}
|
|
501
|
+
>
|
|
502
|
+
Remove
|
|
503
|
+
</Button>
|
|
504
|
+
</div>
|
|
505
|
+
<Button variant="primary" onPress={onClose}>
|
|
506
|
+
Done
|
|
507
|
+
</Button>
|
|
508
|
+
</div>
|
|
509
|
+
);
|
|
510
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A keyboard shortcut as people read it ("Ctrl+B", "⌘B") and as
|
|
3
|
+
* `aria-keyshortcuts` writes it ("Control+B", "Meta+B"). `Mod` is Command on
|
|
4
|
+
* a Mac and Control elsewhere, as in Tiptap's own keymaps.
|
|
5
|
+
*/
|
|
6
|
+
export type Shortcut = { text: string; aria: string };
|
|
7
|
+
|
|
8
|
+
export const isMac =
|
|
9
|
+
typeof navigator !== 'undefined' &&
|
|
10
|
+
/Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent);
|
|
11
|
+
|
|
12
|
+
type Modifier = 'Mod' | 'Alt' | 'Shift';
|
|
13
|
+
|
|
14
|
+
export function shortcut(...keys: [...Modifier[], string]): Shortcut {
|
|
15
|
+
const key = keys.at(-1) ?? '';
|
|
16
|
+
const modifiers = keys.slice(0, -1) as Modifier[];
|
|
17
|
+
if (isMac) {
|
|
18
|
+
const symbols: Record<Modifier, string> = { Alt: '⌥', Shift: '⇧', Mod: '⌘' };
|
|
19
|
+
// Apple's order: Option, Shift, Command, then the key.
|
|
20
|
+
const order = (['Alt', 'Shift', 'Mod'] as const).filter((m) => modifiers.includes(m));
|
|
21
|
+
return {
|
|
22
|
+
text: `${order.map((m) => symbols[m]).join('')}${key}`,
|
|
23
|
+
aria: [...order.map((m) => (m === 'Mod' ? 'Meta' : m)), key].join('+'),
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
const named = modifiers.map((m) => (m === 'Mod' ? 'Ctrl' : m));
|
|
27
|
+
return {
|
|
28
|
+
text: [...named, key].join('+'),
|
|
29
|
+
aria: [...modifiers.map((m) => (m === 'Mod' ? 'Control' : m)), key].join('+'),
|
|
30
|
+
};
|
|
31
|
+
}
|