@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,287 @@
|
|
|
1
|
+
import { Extension, Node, type AnyExtension } from '@tiptap/core';
|
|
2
|
+
import type { BlockKind, Catalog } from './catalog.ts';
|
|
3
|
+
import { chipPlaceholder, decodeHex } from './chips.ts';
|
|
4
|
+
|
|
5
|
+
/*
|
|
6
|
+
* The editor's node types for bodies (DECISIONS.md, "The writing view owns
|
|
7
|
+
* only blocks it can round-trip"). Every block-level node carries the path
|
|
8
|
+
* of the original block it was loaded from (`vesperBlock`: "3", or "3.1" for
|
|
9
|
+
* a container's second child), so a save can keep its bytes when it's
|
|
10
|
+
* unchanged. Paths are never copied when a block is split or pasted.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** Editor node types a Markdown block may become. Anything else stays source. */
|
|
14
|
+
export const WRITABLE_NODES = new Set([
|
|
15
|
+
'paragraph',
|
|
16
|
+
'heading',
|
|
17
|
+
'bulletList',
|
|
18
|
+
'orderedList',
|
|
19
|
+
'blockquote',
|
|
20
|
+
'codeBlock',
|
|
21
|
+
'horizontalRule',
|
|
22
|
+
]);
|
|
23
|
+
|
|
24
|
+
const blockPath = { default: null, keepOnSplit: false, rendered: false } as const;
|
|
25
|
+
|
|
26
|
+
/** Remembers which original block a Markdown node came from. */
|
|
27
|
+
export const BlockIndex = Extension.create({
|
|
28
|
+
name: 'vesperBlockIndex',
|
|
29
|
+
addGlobalAttributes() {
|
|
30
|
+
return [{ types: [...WRITABLE_NODES], attributes: { vesperBlock: blockPath } }];
|
|
31
|
+
},
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
/** A block the writing view can't show: kept exactly, editable as raw text. */
|
|
35
|
+
export const SourceBlock = Node.create({
|
|
36
|
+
name: 'sourceBlock',
|
|
37
|
+
group: 'block',
|
|
38
|
+
atom: true,
|
|
39
|
+
selectable: true,
|
|
40
|
+
draggable: false,
|
|
41
|
+
addAttributes() {
|
|
42
|
+
return {
|
|
43
|
+
vesperBlock: blockPath,
|
|
44
|
+
text: { default: '', rendered: false },
|
|
45
|
+
blockType: { default: '', rendered: false },
|
|
46
|
+
};
|
|
47
|
+
},
|
|
48
|
+
parseHTML() {
|
|
49
|
+
return [
|
|
50
|
+
{
|
|
51
|
+
tag: 'div[data-vesper-source]',
|
|
52
|
+
getAttrs: (element) => ({
|
|
53
|
+
text: element.getAttribute('data-text') ?? '',
|
|
54
|
+
blockType: element.getAttribute('data-block-type') ?? '',
|
|
55
|
+
}),
|
|
56
|
+
},
|
|
57
|
+
];
|
|
58
|
+
},
|
|
59
|
+
renderHTML({ node }) {
|
|
60
|
+
return [
|
|
61
|
+
'div',
|
|
62
|
+
{
|
|
63
|
+
'data-vesper-source': '',
|
|
64
|
+
'data-text': String(node.attrs.text ?? ''),
|
|
65
|
+
'data-block-type': String(node.attrs.blockType ?? ''),
|
|
66
|
+
},
|
|
67
|
+
];
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
/** A tag or component without editable content (spec §6.5.1: "a compact card"). Its whole text is `source`. */
|
|
72
|
+
export const VesperCard = Node.create({
|
|
73
|
+
name: 'vesperCard',
|
|
74
|
+
group: 'block vesperTag',
|
|
75
|
+
atom: true,
|
|
76
|
+
selectable: true,
|
|
77
|
+
draggable: false,
|
|
78
|
+
addAttributes() {
|
|
79
|
+
return {
|
|
80
|
+
vesperBlock: blockPath,
|
|
81
|
+
syntax: { default: 'markdoc', rendered: false },
|
|
82
|
+
name: { default: '', rendered: false },
|
|
83
|
+
source: { default: '', rendered: false },
|
|
84
|
+
};
|
|
85
|
+
},
|
|
86
|
+
parseHTML() {
|
|
87
|
+
return [
|
|
88
|
+
{
|
|
89
|
+
tag: 'div[data-vesper-card]',
|
|
90
|
+
getAttrs: (element) => ({
|
|
91
|
+
syntax: element.getAttribute('data-syntax') ?? 'markdoc',
|
|
92
|
+
name: element.getAttribute('data-name') ?? '',
|
|
93
|
+
source: element.getAttribute('data-source') ?? '',
|
|
94
|
+
}),
|
|
95
|
+
},
|
|
96
|
+
];
|
|
97
|
+
},
|
|
98
|
+
renderHTML({ node }) {
|
|
99
|
+
return [
|
|
100
|
+
'div',
|
|
101
|
+
{
|
|
102
|
+
'data-vesper-card': '',
|
|
103
|
+
'data-syntax': String(node.attrs.syntax),
|
|
104
|
+
'data-name': String(node.attrs.name),
|
|
105
|
+
'data-source': String(node.attrs.source),
|
|
106
|
+
},
|
|
107
|
+
];
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* A pipe table the writing view edits cell by cell (DECISIONS.md, "Tables
|
|
113
|
+
* are edited as a grid, and a save splices cells"). One atom, whose view is
|
|
114
|
+
* a grid that owns its own focus; `value` is the table as `table.ts` holds it.
|
|
115
|
+
*/
|
|
116
|
+
export const VesperTable = Node.create({
|
|
117
|
+
name: 'vesperTable',
|
|
118
|
+
group: 'block',
|
|
119
|
+
atom: true,
|
|
120
|
+
selectable: true,
|
|
121
|
+
draggable: false,
|
|
122
|
+
addAttributes() {
|
|
123
|
+
return {
|
|
124
|
+
vesperBlock: blockPath,
|
|
125
|
+
value: { default: null, rendered: false },
|
|
126
|
+
};
|
|
127
|
+
},
|
|
128
|
+
parseHTML() {
|
|
129
|
+
return [
|
|
130
|
+
{
|
|
131
|
+
tag: 'div[data-vesper-table]',
|
|
132
|
+
getAttrs: (element) => {
|
|
133
|
+
try {
|
|
134
|
+
return { value: JSON.parse(element.getAttribute('data-value') ?? 'null') as unknown };
|
|
135
|
+
} catch {
|
|
136
|
+
return false;
|
|
137
|
+
}
|
|
138
|
+
},
|
|
139
|
+
},
|
|
140
|
+
];
|
|
141
|
+
},
|
|
142
|
+
renderHTML({ node }) {
|
|
143
|
+
return ['div', { 'data-vesper-table': '', 'data-value': JSON.stringify(node.attrs.value) }];
|
|
144
|
+
},
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* An inline chip: an inline tag, a variable, an annotation, inline JSX or an
|
|
149
|
+
* expression. Its text is `source`. To the Markdown parser and serializer it
|
|
150
|
+
* is one placeholder (see chips.ts).
|
|
151
|
+
*/
|
|
152
|
+
export const VesperInline = Node.create({
|
|
153
|
+
name: 'vesperInline',
|
|
154
|
+
group: 'inline',
|
|
155
|
+
inline: true,
|
|
156
|
+
atom: true,
|
|
157
|
+
selectable: true,
|
|
158
|
+
draggable: false,
|
|
159
|
+
addAttributes() {
|
|
160
|
+
return { source: { default: '', rendered: false } };
|
|
161
|
+
},
|
|
162
|
+
parseHTML() {
|
|
163
|
+
return [
|
|
164
|
+
{
|
|
165
|
+
tag: 'span[data-vesper-chip]',
|
|
166
|
+
getAttrs: (element) => ({ source: element.getAttribute('data-source') ?? '' }),
|
|
167
|
+
},
|
|
168
|
+
];
|
|
169
|
+
},
|
|
170
|
+
renderHTML({ node }) {
|
|
171
|
+
return ['span', { 'data-vesper-chip': '', 'data-source': String(node.attrs.source) }];
|
|
172
|
+
},
|
|
173
|
+
markdownTokenizer: {
|
|
174
|
+
name: 'vesperInline',
|
|
175
|
+
level: 'inline',
|
|
176
|
+
start: (src) => src.indexOf(''),
|
|
177
|
+
tokenize: (src) => {
|
|
178
|
+
const match = /^([0-9a-f]*)/.exec(src);
|
|
179
|
+
return match ? { type: 'vesperInline', raw: match[0], hex: match[1] } : undefined;
|
|
180
|
+
},
|
|
181
|
+
},
|
|
182
|
+
parseMarkdown: (token, helpers) =>
|
|
183
|
+
helpers.createNode('vesperInline', { source: decodeHex(String(token.hex ?? '')) }),
|
|
184
|
+
renderMarkdown: (node) => chipPlaceholder(String(node.attrs?.source ?? '')),
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
/** Markdoc child node types, as editor node types. */
|
|
188
|
+
const CHILD_TYPES: Record<string, string[]> = {
|
|
189
|
+
paragraph: ['paragraph'],
|
|
190
|
+
list: ['bulletList', 'orderedList'],
|
|
191
|
+
heading: ['heading'],
|
|
192
|
+
blockquote: ['blockquote'],
|
|
193
|
+
fence: ['codeBlock'],
|
|
194
|
+
hr: ['horizontalRule'],
|
|
195
|
+
image: ['paragraph'],
|
|
196
|
+
table: ['vesperTable'],
|
|
197
|
+
tag: ['vesperTag'],
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
export type ContainerTypes = {
|
|
201
|
+
/** Editor node types for containers, one per kind that has content. */
|
|
202
|
+
extensions: AnyExtension[];
|
|
203
|
+
/** The editor node type for a kind's container. */
|
|
204
|
+
typeOf: (kind: BlockKind) => string | undefined;
|
|
205
|
+
/** The kind behind a container node type. */
|
|
206
|
+
kindOf: (type: string) => BlockKind | undefined;
|
|
207
|
+
/** Whether a node of `type` may sit directly inside a container of `kind`. */
|
|
208
|
+
allows: (kind: BlockKind, type: string) => boolean;
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* One container node type per tag or component with content. Its content
|
|
213
|
+
* expression comes from Markdoc's `children`, so only allowed node types
|
|
214
|
+
* fit inside (spec §6.5.1), and protected blocks always do.
|
|
215
|
+
*/
|
|
216
|
+
export function containerTypes(catalog: Catalog): ContainerTypes {
|
|
217
|
+
const byType = new Map<string, BlockKind>();
|
|
218
|
+
const typeByKey = new Map<string, string>();
|
|
219
|
+
const allowed = new Map<string, Set<string> | null>();
|
|
220
|
+
const extensions: AnyExtension[] = [];
|
|
221
|
+
let n = 0;
|
|
222
|
+
for (const kind of catalog.kinds.values()) {
|
|
223
|
+
if (kind.block !== 'container') continue;
|
|
224
|
+
const type = `vesperTag${String(n++)}_${kind.name.replace(/[^A-Za-z0-9]/g, '')}`;
|
|
225
|
+
byType.set(type, kind);
|
|
226
|
+
typeByKey.set(kind.key, type);
|
|
227
|
+
const children = kind.children?.flatMap((child) => CHILD_TYPES[child] ?? []);
|
|
228
|
+
const set = children ? new Set([...children, 'sourceBlock']) : null;
|
|
229
|
+
allowed.set(kind.key, set);
|
|
230
|
+
const content = set ? `(${[...set].join(' | ')})+` : 'block+';
|
|
231
|
+
extensions.push(containerNode(type, content, kind));
|
|
232
|
+
}
|
|
233
|
+
return {
|
|
234
|
+
extensions,
|
|
235
|
+
typeOf: (kind) => typeByKey.get(kind.key),
|
|
236
|
+
kindOf: (type) => byType.get(type),
|
|
237
|
+
allows: (kind, type) => {
|
|
238
|
+
const set = allowed.get(kind.key);
|
|
239
|
+
if (set === undefined || set === null) return true;
|
|
240
|
+
if (set.has(type)) return true;
|
|
241
|
+
return set.has('vesperTag') && (type === 'vesperCard' || byType.has(type));
|
|
242
|
+
},
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function containerNode(type: string, content: string, kind: BlockKind): AnyExtension {
|
|
247
|
+
return Node.create({
|
|
248
|
+
name: type,
|
|
249
|
+
group: 'block vesperTag',
|
|
250
|
+
content,
|
|
251
|
+
defining: true,
|
|
252
|
+
isolating: true,
|
|
253
|
+
draggable: false,
|
|
254
|
+
selectable: true,
|
|
255
|
+
addAttributes() {
|
|
256
|
+
return {
|
|
257
|
+
vesperBlock: blockPath,
|
|
258
|
+
syntax: { default: kind.syntax, rendered: false },
|
|
259
|
+
name: { default: kind.name, rendered: false },
|
|
260
|
+
open: { default: '', rendered: false },
|
|
261
|
+
close: { default: '', rendered: false },
|
|
262
|
+
};
|
|
263
|
+
},
|
|
264
|
+
parseHTML() {
|
|
265
|
+
return [
|
|
266
|
+
{
|
|
267
|
+
tag: `div[data-vesper-container="${type}"]`,
|
|
268
|
+
getAttrs: (element) => ({
|
|
269
|
+
open: element.getAttribute('data-open') ?? '',
|
|
270
|
+
close: element.getAttribute('data-close') ?? '',
|
|
271
|
+
}),
|
|
272
|
+
},
|
|
273
|
+
];
|
|
274
|
+
},
|
|
275
|
+
renderHTML({ node }) {
|
|
276
|
+
return [
|
|
277
|
+
'div',
|
|
278
|
+
{
|
|
279
|
+
'data-vesper-container': type,
|
|
280
|
+
'data-open': String(node.attrs.open),
|
|
281
|
+
'data-close': String(node.attrs.close),
|
|
282
|
+
},
|
|
283
|
+
0,
|
|
284
|
+
];
|
|
285
|
+
},
|
|
286
|
+
});
|
|
287
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import type { Editor } from '@tiptap/core';
|
|
2
|
+
import type { Node as PmNode } from '@tiptap/pm/model';
|
|
3
|
+
import { NodeSelection, TextSelection, type Selection } from '@tiptap/pm/state';
|
|
4
|
+
import {
|
|
5
|
+
parseBody,
|
|
6
|
+
type BodyBlock,
|
|
7
|
+
type BodyFormat,
|
|
8
|
+
type MarkdocParseOptions,
|
|
9
|
+
} from '@vespercms/core';
|
|
10
|
+
import { isManaged } from './commands.ts';
|
|
11
|
+
import type { ContainerTypes } from './nodes.ts';
|
|
12
|
+
|
|
13
|
+
/*
|
|
14
|
+
* Taking someone to a place in the body (an accessibility note's "Show" and
|
|
15
|
+
* "Go to field"). Notes point at offsets in the body's text; the writing
|
|
16
|
+
* view's nodes are its blocks in the same order (DECISIONS.md, "The body
|
|
17
|
+
* editor splices changed blocks into the original text"), nested only
|
|
18
|
+
* inside containers. So the text is parsed again, the blocks holding the
|
|
19
|
+
* offset are found at each level, and the node at the same place is chosen.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/** The index of the block holding `offset` at each level, going into containers' children. */
|
|
23
|
+
export function blockPathAt(blocks: readonly BodyBlock[], offset: number): number[] {
|
|
24
|
+
const path: number[] = [];
|
|
25
|
+
let level = blocks;
|
|
26
|
+
while (level.length > 0) {
|
|
27
|
+
let index = level.findIndex((block) => offset >= block.start && offset <= block.end);
|
|
28
|
+
// Between two blocks: the one before, or the first.
|
|
29
|
+
if (index === -1)
|
|
30
|
+
index = Math.max(
|
|
31
|
+
0,
|
|
32
|
+
level.findLastIndex((block) => block.start <= offset),
|
|
33
|
+
);
|
|
34
|
+
path.push(index);
|
|
35
|
+
const children = level[index]?.tag?.children ?? [];
|
|
36
|
+
// An offset in a container's opening or closing selects the container itself.
|
|
37
|
+
if (!children.some((child) => offset >= child.start && offset <= child.end)) break;
|
|
38
|
+
level = children;
|
|
39
|
+
}
|
|
40
|
+
return path;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The node at that path in the editor's document, and where it starts. */
|
|
44
|
+
function nodeAtPath(doc: PmNode, path: readonly number[]): { node: PmNode; pos: number } | null {
|
|
45
|
+
let parent = doc;
|
|
46
|
+
let contentStart = 0;
|
|
47
|
+
let found: { node: PmNode; pos: number } | null = null;
|
|
48
|
+
for (const wanted of path) {
|
|
49
|
+
if (parent.childCount === 0) break;
|
|
50
|
+
const index = Math.min(wanted, parent.childCount - 1);
|
|
51
|
+
let pos = contentStart;
|
|
52
|
+
for (let i = 0; i < index; i++) pos += parent.child(i).nodeSize;
|
|
53
|
+
const node = parent.child(index);
|
|
54
|
+
found = { node, pos };
|
|
55
|
+
if (node.isTextblock || node.isAtom) break;
|
|
56
|
+
parent = node;
|
|
57
|
+
contentStart = pos + 1;
|
|
58
|
+
}
|
|
59
|
+
return found;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Selects the block at `offset` of `text` in the writing view, focuses it and scrolls to it. */
|
|
63
|
+
export function revealInEditor(
|
|
64
|
+
editor: Editor,
|
|
65
|
+
text: string,
|
|
66
|
+
offset: number,
|
|
67
|
+
options: { format: BodyFormat; markdoc?: MarkdocParseOptions; containers: ContainerTypes },
|
|
68
|
+
): void {
|
|
69
|
+
const parsed = parseBody(
|
|
70
|
+
text,
|
|
71
|
+
options.format,
|
|
72
|
+
options.markdoc ? { markdoc: options.markdoc } : {},
|
|
73
|
+
);
|
|
74
|
+
const target = nodeAtPath(editor.state.doc, blockPathAt(parsed.blocks, offset));
|
|
75
|
+
// Focus first: the caret that focus brings would otherwise replace the selection.
|
|
76
|
+
editor.view.focus();
|
|
77
|
+
if (!target) return;
|
|
78
|
+
const { doc } = editor.state;
|
|
79
|
+
let selection: Selection;
|
|
80
|
+
if (target.node.isTextblock) selection = TextSelection.create(doc, target.pos + 1);
|
|
81
|
+
else if (isManaged(target.node.type.name, options.containers))
|
|
82
|
+
selection = NodeSelection.create(doc, target.pos);
|
|
83
|
+
else selection = TextSelection.near(doc.resolve(target.pos + 1));
|
|
84
|
+
editor.view.dispatch(editor.state.tr.setSelection(selection).scrollIntoView());
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Selects a range of the source view's text, focuses it and scrolls to it. */
|
|
88
|
+
export function revealInSource(textarea: HTMLTextAreaElement, start: number, end: number): void {
|
|
89
|
+
textarea.setSelectionRange(start, end);
|
|
90
|
+
// Focusing after the selection is set scrolls the text area to it.
|
|
91
|
+
textarea.blur();
|
|
92
|
+
textarea.focus();
|
|
93
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { NodeSelection } from '@tiptap/pm/state';
|
|
2
|
+
import type { EditorView } from '@tiptap/pm/view';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A formatting toolbar inside its editor's frame, stuck below whatever
|
|
6
|
+
* already floats at the top of the scroller (`--sticky-top`, set by the
|
|
7
|
+
* screen or the pane), and only while its editor is on screen. Outside them,
|
|
8
|
+
* in a dialog, it sticks to the dialog's top.
|
|
9
|
+
*/
|
|
10
|
+
export const stuckToolbar =
|
|
11
|
+
'sticky top-[var(--sticky-top,0px)] z-1 rounded-t-md border-b border-border bg-surface-raised p-1';
|
|
12
|
+
|
|
13
|
+
/** Less than the text's own padding, so a toolbar that isn't stuck never sets this off. */
|
|
14
|
+
const GAP = 4;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* ProseMirror keeps the caret inside its scroller's box, but a stuck
|
|
18
|
+
* formatting toolbar (and the glass above it) covers that box's top, so the
|
|
19
|
+
* caret could move up beneath them out of sight. Before ProseMirror scrolls,
|
|
20
|
+
* this scrolls a caret that's under the toolbar clear of it; ProseMirror's
|
|
21
|
+
* own scrolling then does the rest. Always false, so it never replaces it.
|
|
22
|
+
*/
|
|
23
|
+
export function scrollClearOfToolbar(view: EditorView, toolbar: HTMLElement | null): boolean {
|
|
24
|
+
if (!toolbar) return false;
|
|
25
|
+
const { selection } = view.state;
|
|
26
|
+
let top: number;
|
|
27
|
+
try {
|
|
28
|
+
top = view.coordsAtPos(
|
|
29
|
+
selection instanceof NodeSelection ? selection.from : selection.head,
|
|
30
|
+
).top;
|
|
31
|
+
} catch {
|
|
32
|
+
// No layout (jsdom).
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
const clear = toolbar.getBoundingClientRect().bottom + GAP;
|
|
36
|
+
// Above the text's own top, the caret is scrolled away inside a resized editor: ProseMirror's.
|
|
37
|
+
if (top >= clear || top < view.dom.getBoundingClientRect().top) return false;
|
|
38
|
+
scrollerOf(view.dom).scrollBy(0, top - clear);
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function scrollerOf(element: HTMLElement): Element | Window {
|
|
43
|
+
for (let parent = element.parentElement; parent; parent = parent.parentElement) {
|
|
44
|
+
const { overflowY } = getComputedStyle(parent);
|
|
45
|
+
if (
|
|
46
|
+
(overflowY === 'auto' || overflowY === 'scroll') &&
|
|
47
|
+
parent.scrollHeight > parent.clientHeight
|
|
48
|
+
) {
|
|
49
|
+
return parent;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return window;
|
|
53
|
+
}
|