@backmatter/studio 0.0.15 → 0.0.17
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/dist/chunks/{UiKitApp-BDsrXN12.js → UiKitApp-D7vllRQE.js} +2 -2
- package/dist/chunks/{WorkspaceRoutes-BTZ1Ah4F.js → WorkspaceRoutes-DH1LD-2Q.js} +30013 -27965
- package/dist/chunks/{dashboard-FT7dm5wi.js → dashboard-Dsaeud2x.js} +55 -54
- package/dist/chunks/{define-studio-BYtYDGLX.js → define-studio-DlYjgrwr.js} +12 -16
- package/dist/chunks/{index-DHJvh5Uy.js → index-CGMqw2NH.js} +1 -1
- package/dist/dashboard.js +2 -2
- package/dist/define.js +1 -1
- package/dist/index.js +4 -4
- package/dist/site-widget.css +49 -1
- package/dist/site-widget.js +8341 -8058
- package/dist/studio.css +279 -13
- package/package.json +2 -2
- package/src/WorkspaceRoutes.tsx +5 -2
- package/src/api.ts +82 -7
- package/src/chat/ChangeCard.tsx +8 -2
- package/src/chat/ChatMessage.tsx +171 -102
- package/src/chat/ChatPanel.tsx +54 -34
- package/src/chat/ChatProvider.tsx +122 -20
- package/src/chat/Composer.tsx +51 -8
- package/src/chat/PointerChip.tsx +9 -5
- package/src/chat/ThreadHistory.tsx +3 -3
- package/src/chat/chat-context.ts +67 -3
- package/src/chat/chat-helpers.ts +12 -0
- package/src/chat/chat-state.ts +11 -0
- package/src/chrome/AppShell.tsx +16 -5
- package/src/chrome/CommandPalette.tsx +12 -0
- package/src/chrome/ContentExplorerSidebar.tsx +23 -3
- package/src/chrome/Toast.tsx +115 -0
- package/src/chrome/UserMenu.tsx +34 -3
- package/src/chrome/WorkspaceStatusBar.tsx +221 -30
- package/src/chrome/commit-error.ts +13 -0
- package/src/chrome/content-api-url.ts +0 -78
- package/src/chrome/content-tree-visibility.ts +13 -3
- package/src/chrome/explorer-icons.tsx +5 -0
- package/src/chrome/pull-failure.ts +61 -0
- package/src/code-session-entry.ts +9 -0
- package/src/components/WorkspaceSwitcher.tsx +2 -2
- package/src/element-origin.ts +149 -0
- package/src/fields/FieldControl.tsx +748 -0
- package/src/fields/choose-field-input.ts +64 -0
- package/src/fields/load-relation-documents.ts +35 -0
- package/src/fields/omit-field.ts +12 -0
- package/src/fields/relation-targets.ts +44 -0
- package/src/fields/resolve-object-fields.ts +21 -0
- package/src/host-pointer-bootstrap.ts +11 -0
- package/src/host-pointer.ts +147 -0
- package/src/install-preview-pointer.ts +19 -3
- package/src/pages/BlockSettingsDialog.tsx +19 -115
- package/src/pages/BlocksList.tsx +43 -35
- package/src/pages/BodyProse.tsx +16 -1
- package/src/pages/CodeSessionFiles.tsx +221 -0
- package/src/pages/CodeSessionPage.tsx +1361 -0
- package/src/pages/DocEditorPage.tsx +120 -107
- package/src/pages/PreviewSize.tsx +181 -0
- package/src/pages/RenderedBody.tsx +17 -2
- package/src/pages/SettingsGeneralPage.tsx +67 -2
- package/src/pages/WorkspaceHomePage.tsx +313 -0
- package/src/pages/WorkspaceLayout.tsx +10 -1
- package/src/pages/branch-notice.ts +16 -0
- package/src/pages/code-session-boot.ts +43 -0
- package/src/pages/code-session-context.tsx +53 -0
- package/src/pages/code-session-files.ts +42 -0
- package/src/pages/code-session-frame.ts +52 -0
- package/src/pages/code-session-routes.ts +42 -0
- package/src/pages/home-rows.ts +140 -0
- package/src/pages/preview-size.ts +33 -0
- package/src/pages/session-preview.ts +109 -0
- package/src/pages/use-session-preview.ts +70 -0
- package/src/replica/store.ts +11 -0
- package/src/replica/types.ts +6 -0
- package/src/shell-resets.css +15 -0
- package/src/site-pointer.ts +24 -5
- package/src/studio-config.ts +1 -1
- package/src/ui/Input.tsx +178 -20
- package/src/ui/ListRow.tsx +12 -3
- package/src/ui/Page.tsx +12 -1
- package/src/ui/popup.ts +11 -2
- package/src/workspace-path.ts +35 -3
- package/src/chrome/ContentApiPopover.tsx +0 -215
- package/src/pages/WorkspaceHomeRedirect.tsx +0 -17
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// packages/studio/src/fields/choose-field-input.ts
|
|
2
|
+
import type { SchemaField } from "../api";
|
|
3
|
+
import { resolveFieldInput, type StudioDefinition } from "../define-studio";
|
|
4
|
+
|
|
5
|
+
export type FieldInputKind =
|
|
6
|
+
| "custom"
|
|
7
|
+
| "select"
|
|
8
|
+
| "color"
|
|
9
|
+
| "slider"
|
|
10
|
+
| "relation"
|
|
11
|
+
| "object"
|
|
12
|
+
| "array"
|
|
13
|
+
| "media"
|
|
14
|
+
| "boolean"
|
|
15
|
+
| "number"
|
|
16
|
+
| "blocks"
|
|
17
|
+
| "text";
|
|
18
|
+
|
|
19
|
+
export function chooseFieldInput(
|
|
20
|
+
definition: StudioDefinition,
|
|
21
|
+
field: SchemaField,
|
|
22
|
+
models: Array<{ type: string; external?: boolean }>,
|
|
23
|
+
): FieldInputKind {
|
|
24
|
+
if (resolveFieldInput(definition, field)) {
|
|
25
|
+
return "custom";
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (field.kind === "text") {
|
|
29
|
+
if (field.options && field.options.length > 0) {
|
|
30
|
+
return "select";
|
|
31
|
+
}
|
|
32
|
+
if (field.input === "color") {
|
|
33
|
+
return "color";
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (field.kind === "number") {
|
|
38
|
+
if (typeof field.min === "number" && typeof field.max === "number") {
|
|
39
|
+
return "slider";
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (field.kind === "relation") {
|
|
44
|
+
const target = models.find((m) => m.type === field.relationTo);
|
|
45
|
+
if (target && !target.external) {
|
|
46
|
+
return "relation";
|
|
47
|
+
}
|
|
48
|
+
return "text";
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (field.kind === "object") {
|
|
52
|
+
return "object";
|
|
53
|
+
}
|
|
54
|
+
if (field.kind === "array") {
|
|
55
|
+
return "array";
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (field.kind === "media") return "media";
|
|
59
|
+
if (field.kind === "boolean") return "boolean";
|
|
60
|
+
if (field.kind === "number") return "number";
|
|
61
|
+
if (field.kind === "blocks") return "blocks";
|
|
62
|
+
|
|
63
|
+
return "text";
|
|
64
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// packages/studio/src/fields/load-relation-documents.ts
|
|
2
|
+
type DocListRow = { type: string; slug: string; status: string };
|
|
3
|
+
type DocEntry = { slug: string; frontmatter: Record<string, unknown> };
|
|
4
|
+
|
|
5
|
+
export async function loadRelationDocuments(
|
|
6
|
+
types: string[],
|
|
7
|
+
list: () => Promise<DocListRow[]>,
|
|
8
|
+
read: (type: string, slug: string) => Promise<{ frontmatter: Record<string, unknown> }>,
|
|
9
|
+
): Promise<Record<string, DocEntry[]>> {
|
|
10
|
+
const wanted = new Set(types);
|
|
11
|
+
const grouped: Record<string, DocEntry[]> = Object.fromEntries(types.map((type) => [type, []]));
|
|
12
|
+
let rows: DocListRow[];
|
|
13
|
+
try {
|
|
14
|
+
rows = await list();
|
|
15
|
+
} catch {
|
|
16
|
+
return grouped;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const selected = rows.filter((row) => wanted.has(row.type) && row.status !== "deleted");
|
|
20
|
+
const loaded = await Promise.all(
|
|
21
|
+
selected.map(async (row) => {
|
|
22
|
+
try {
|
|
23
|
+
const { frontmatter } = await read(row.type, row.slug);
|
|
24
|
+
return { type: row.type, entry: { slug: row.slug, frontmatter } satisfies DocEntry };
|
|
25
|
+
} catch {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
}),
|
|
29
|
+
);
|
|
30
|
+
for (const item of loaded) {
|
|
31
|
+
if (!item) continue;
|
|
32
|
+
grouped[item.type]!.push(item.entry);
|
|
33
|
+
}
|
|
34
|
+
return grouped;
|
|
35
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// packages/studio/src/fields/omit-field.ts
|
|
2
|
+
export function omitField(
|
|
3
|
+
record: Record<string, unknown>,
|
|
4
|
+
name: string,
|
|
5
|
+
next: unknown,
|
|
6
|
+
): Record<string, unknown> {
|
|
7
|
+
if (next === undefined) {
|
|
8
|
+
const { [name]: _removed, ...rest } = record;
|
|
9
|
+
return rest;
|
|
10
|
+
}
|
|
11
|
+
return { ...record, [name]: next };
|
|
12
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// packages/studio/src/fields/relation-targets.ts
|
|
2
|
+
import type { SchemaField } from "../api";
|
|
3
|
+
import { resolveObjectFields } from "./resolve-object-fields";
|
|
4
|
+
|
|
5
|
+
type ModelRef = { type: string; external?: boolean };
|
|
6
|
+
type ObjectDef = { name: string; fields: Record<string, SchemaField> };
|
|
7
|
+
|
|
8
|
+
function collectFromFields(
|
|
9
|
+
fields: Record<string, SchemaField>,
|
|
10
|
+
models: ModelRef[],
|
|
11
|
+
objects: ObjectDef[],
|
|
12
|
+
visiting: Set<string>,
|
|
13
|
+
targets: Set<string>,
|
|
14
|
+
): void {
|
|
15
|
+
for (const field of Object.values(fields)) {
|
|
16
|
+
if (field.kind === "relation" && field.relationTo) {
|
|
17
|
+
const target = models.find((model) => model.type === field.relationTo);
|
|
18
|
+
if (target && !target.external) {
|
|
19
|
+
targets.add(field.relationTo);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const nested = resolveObjectFields(field, objects);
|
|
24
|
+
if (!nested) continue;
|
|
25
|
+
|
|
26
|
+
let nextVisiting = visiting;
|
|
27
|
+
if (field.object && (field.kind === "object" || field.kind === "array")) {
|
|
28
|
+
if (visiting.has(field.object)) continue;
|
|
29
|
+
nextVisiting = new Set(visiting).add(field.object);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
collectFromFields(nested, models, objects, nextVisiting, targets);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function relationTargets(
|
|
37
|
+
fields: Record<string, SchemaField>,
|
|
38
|
+
models: ModelRef[],
|
|
39
|
+
objects: ObjectDef[],
|
|
40
|
+
): string[] {
|
|
41
|
+
const targets = new Set<string>();
|
|
42
|
+
collectFromFields(fields, models, objects, new Set<string>(), targets);
|
|
43
|
+
return [...targets].sort();
|
|
44
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// packages/studio/src/fields/resolve-object-fields.ts
|
|
2
|
+
import type { SchemaField } from "../api";
|
|
3
|
+
|
|
4
|
+
export function resolveObjectFields(
|
|
5
|
+
field: SchemaField,
|
|
6
|
+
objects: Array<{ name: string; fields: Record<string, SchemaField> }>,
|
|
7
|
+
): Record<string, SchemaField> | null {
|
|
8
|
+
if (field.fields) {
|
|
9
|
+
return field.fields;
|
|
10
|
+
}
|
|
11
|
+
if (field.object) {
|
|
12
|
+
const entry = objects.find((object) => object.name === field.object);
|
|
13
|
+
return entry?.fields ?? null;
|
|
14
|
+
}
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function nestedStartsCollapsed(ancestorObjectNames: string[], objectName: string | undefined): boolean {
|
|
19
|
+
if (!objectName) return false;
|
|
20
|
+
return ancestorObjectNames.includes(objectName);
|
|
21
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// packages/studio/src/host-pointer-bootstrap.ts
|
|
2
|
+
// Built into the script the code session sandbox injects. The source of truth
|
|
3
|
+
// is bindHostPointer, the same pointer the document editor's preview uses.
|
|
4
|
+
import { bindHostPointer } from "./host-pointer";
|
|
5
|
+
|
|
6
|
+
function start() {
|
|
7
|
+
bindHostPointer(document);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", start);
|
|
11
|
+
else start();
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// packages/studio/src/host-pointer.ts
|
|
2
|
+
//
|
|
3
|
+
// Page side of the preview channel for a framed host that is not running the
|
|
4
|
+
// preview client. The document editor's preview page uses the same pointer and
|
|
5
|
+
// the same messages (hello, point, grab, highlight). This does not accept a
|
|
6
|
+
// document push.
|
|
7
|
+
import {
|
|
8
|
+
createWindowPreviewTransport,
|
|
9
|
+
samePreviewRef,
|
|
10
|
+
type PreviewChannelRef,
|
|
11
|
+
type PreviewWindowLike,
|
|
12
|
+
} from "@backmatter/core/preview";
|
|
13
|
+
import { installPreviewPointer } from "./install-preview-pointer";
|
|
14
|
+
import { findPointedElement, highlightPointedElement } from "./site-pointer";
|
|
15
|
+
|
|
16
|
+
const POINTER_STYLE = "html.backmatter-pointing, html.backmatter-pointing * { cursor: crosshair !important; }";
|
|
17
|
+
|
|
18
|
+
function parentOrigin(view: Window): string {
|
|
19
|
+
try {
|
|
20
|
+
if (view.document.referrer) return new URL(view.document.referrer).origin;
|
|
21
|
+
} catch {
|
|
22
|
+
// A malformed referrer means we cannot name the framing Studio.
|
|
23
|
+
}
|
|
24
|
+
const ancestors = (view.location as Location & { ancestorOrigins?: DOMStringList }).ancestorOrigins;
|
|
25
|
+
if (ancestors && ancestors.length > 0) return ancestors.item(0) ?? "";
|
|
26
|
+
return "";
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The document the page announces. A missing marker falls back to the route. */
|
|
30
|
+
export function hostPointerRef(doc: Document): PreviewChannelRef {
|
|
31
|
+
const marker = doc.querySelector("[data-backmatter-doc]");
|
|
32
|
+
const type = marker?.getAttribute("data-type");
|
|
33
|
+
const slug = marker?.getAttribute("data-slug");
|
|
34
|
+
if (type && slug) return { type, slug };
|
|
35
|
+
const path = doc.defaultView?.location.pathname || "/";
|
|
36
|
+
return { type: "route", slug: path };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Tell the framing Studio this page can be pointed at, and arm when it asks.
|
|
41
|
+
* No-ops on a top-level page and on a page that already runs the preview client.
|
|
42
|
+
*/
|
|
43
|
+
export function bindHostPointer(doc: Document): () => void {
|
|
44
|
+
const view = doc.defaultView;
|
|
45
|
+
if (!view || view.parent === view) return () => {};
|
|
46
|
+
if (doc.querySelector("[data-backmatter-preview]")) return () => {};
|
|
47
|
+
const flagged = view as Window & { __backmatterHostPointer?: boolean };
|
|
48
|
+
if (flagged.__backmatterHostPointer) return () => {};
|
|
49
|
+
|
|
50
|
+
const origin = parentOrigin(view);
|
|
51
|
+
if (!origin) return () => {};
|
|
52
|
+
flagged.__backmatterHostPointer = true;
|
|
53
|
+
|
|
54
|
+
const ref = hostPointerRef(doc);
|
|
55
|
+
if (!doc.querySelector("[data-backmatter-pointer-style]")) {
|
|
56
|
+
const style = doc.createElement("style");
|
|
57
|
+
style.setAttribute("data-backmatter-pointer-style", "");
|
|
58
|
+
style.textContent = POINTER_STYLE;
|
|
59
|
+
doc.head.appendChild(style);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const transport = createWindowPreviewTransport({
|
|
63
|
+
target: view.parent,
|
|
64
|
+
targetOrigin: origin,
|
|
65
|
+
allowedOrigins: [origin],
|
|
66
|
+
host: view as unknown as PreviewWindowLike,
|
|
67
|
+
});
|
|
68
|
+
function sendGrab(grab: {
|
|
69
|
+
blockId: string | null;
|
|
70
|
+
blockType: string | null;
|
|
71
|
+
blockIndex: number | null;
|
|
72
|
+
quote: string;
|
|
73
|
+
shift: boolean;
|
|
74
|
+
component?: string | null;
|
|
75
|
+
file?: string | null;
|
|
76
|
+
line?: number | null;
|
|
77
|
+
column?: number | null;
|
|
78
|
+
stack?: string[];
|
|
79
|
+
}) {
|
|
80
|
+
transport.send({
|
|
81
|
+
type: "backmatter:grab",
|
|
82
|
+
v: 1,
|
|
83
|
+
ref,
|
|
84
|
+
blockId: grab.blockId,
|
|
85
|
+
blockType: grab.blockType,
|
|
86
|
+
blockIndex: grab.blockIndex,
|
|
87
|
+
quote: grab.quote,
|
|
88
|
+
shift: grab.shift,
|
|
89
|
+
...(grab.component ? { component: grab.component } : {}),
|
|
90
|
+
...(grab.file ? { file: grab.file } : {}),
|
|
91
|
+
...(grab.line ? { line: grab.line } : {}),
|
|
92
|
+
...(grab.column ? { column: grab.column } : {}),
|
|
93
|
+
...(grab.stack && grab.stack.length > 0 ? { stack: grab.stack } : {}),
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
const pointer = installPreviewPointer(doc, {
|
|
97
|
+
bar: () => doc.querySelector("[data-backmatter-devbar]"),
|
|
98
|
+
includeUnmarked: true,
|
|
99
|
+
ignore: (target) => {
|
|
100
|
+
if (!(target instanceof view.Element)) return true;
|
|
101
|
+
if (target.closest("[data-backmatter-panel]")) return true;
|
|
102
|
+
return target.closest("iframe") !== null;
|
|
103
|
+
},
|
|
104
|
+
onEditorGrab: sendGrab,
|
|
105
|
+
onUnmarkedGrab: sendGrab,
|
|
106
|
+
onDevbarGrab: () => {},
|
|
107
|
+
onEscape: (source) => {
|
|
108
|
+
if (source !== "editor") return;
|
|
109
|
+
transport.send({ type: "backmatter:point", v: 1, ref, on: false });
|
|
110
|
+
},
|
|
111
|
+
});
|
|
112
|
+
const unsubscribe = transport.subscribe((message) => {
|
|
113
|
+
if (message.type === "backmatter:hotkey" || message.type === "backmatter:hello") return;
|
|
114
|
+
if (!("ref" in message)) return;
|
|
115
|
+
// Studio binds after the first hello on a reload. A message for another
|
|
116
|
+
// document is the cue to announce this page again.
|
|
117
|
+
if (!samePreviewRef(message.ref, ref)) {
|
|
118
|
+
if (message.type === "backmatter:point" || message.type === "backmatter:highlight") hello();
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
if (message.type === "backmatter:point") {
|
|
122
|
+
if (message.on) pointer.arm("editor");
|
|
123
|
+
else if (pointer.source() === "editor") pointer.disarm();
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
if (message.type === "backmatter:highlight") {
|
|
127
|
+
const target = message.blockId
|
|
128
|
+
? findPointedElement(doc, { blockId: message.blockId, quote: "" })
|
|
129
|
+
: null;
|
|
130
|
+
highlightPointedElement(doc, target);
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
function hello() {
|
|
135
|
+
transport.send({ type: "backmatter:hello", v: 1, ref, capabilities: ["point"] });
|
|
136
|
+
}
|
|
137
|
+
hello();
|
|
138
|
+
const retries = [100, 500].map((ms) => view.setTimeout(hello, ms));
|
|
139
|
+
|
|
140
|
+
return () => {
|
|
141
|
+
for (const id of retries) view.clearTimeout(id);
|
|
142
|
+
unsubscribe();
|
|
143
|
+
pointer.dispose();
|
|
144
|
+
transport.close();
|
|
145
|
+
flagged.__backmatterHostPointer = false;
|
|
146
|
+
};
|
|
147
|
+
}
|
|
@@ -8,12 +8,25 @@ import {
|
|
|
8
8
|
type PointerGrab,
|
|
9
9
|
} from "./site-pointer";
|
|
10
10
|
|
|
11
|
+
function placeOf(grab: PointerGrab) {
|
|
12
|
+
return {
|
|
13
|
+
...(grab.component ? { component: grab.component } : {}),
|
|
14
|
+
...(grab.file ? { file: grab.file } : {}),
|
|
15
|
+
...(grab.line ? { line: grab.line } : {}),
|
|
16
|
+
...(grab.column ? { column: grab.column } : {}),
|
|
17
|
+
...(grab.stack.length > 0 ? { stack: grab.stack } : {}),
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
11
21
|
export function installPreviewPointer(
|
|
12
22
|
doc: Document,
|
|
13
23
|
options: {
|
|
14
24
|
bar: () => Element | null;
|
|
15
25
|
ignore?: (target: EventTarget | null) => boolean;
|
|
16
|
-
|
|
26
|
+
/** Armed clicks that are not page blocks. The document editor leaves this off. */
|
|
27
|
+
includeUnmarked?: boolean;
|
|
28
|
+
onEditorGrab: (grab: RoutedGrab & ReturnType<typeof placeOf>) => void;
|
|
29
|
+
onUnmarkedGrab?: (grab: PointerGrab & { shift: boolean }) => void;
|
|
17
30
|
onDevbarGrab: (grab: PointerGrab) => void;
|
|
18
31
|
onEscape?: (source: PointerArmSource) => void;
|
|
19
32
|
},
|
|
@@ -27,8 +40,11 @@ export function installPreviewPointer(
|
|
|
27
40
|
|
|
28
41
|
function deliver(grab: PointerGrab, shift: boolean) {
|
|
29
42
|
const route = routePointerHit(source, { ...grab, shift });
|
|
30
|
-
if (route.to === "editor") options.onEditorGrab(route.grab);
|
|
31
|
-
else if (route.to === "devbar") options.onDevbarGrab(
|
|
43
|
+
if (route.to === "editor") options.onEditorGrab({ ...route.grab, ...placeOf(grab) });
|
|
44
|
+
else if (route.to === "devbar") options.onDevbarGrab(grab);
|
|
45
|
+
else if (options.includeUnmarked && source === "editor" && (grab.quote || grab.component || grab.file)) {
|
|
46
|
+
options.onUnmarkedGrab?.({ ...grab, shift });
|
|
47
|
+
}
|
|
32
48
|
}
|
|
33
49
|
|
|
34
50
|
const pointer = installSitePointer(doc, {
|
|
@@ -3,30 +3,32 @@ import { useState } from "react";
|
|
|
3
3
|
import { Dialog } from "@base-ui/react/dialog";
|
|
4
4
|
import * as stylex from "@stylexjs/stylex";
|
|
5
5
|
import type { SchemaField } from "../api";
|
|
6
|
-
import {
|
|
6
|
+
import { FieldControl } from "../fields/FieldControl";
|
|
7
|
+
import { omitField } from "../fields/omit-field";
|
|
7
8
|
import { colors, radius, space, type, z } from "../styles/tokens.stylex";
|
|
8
9
|
import { Button } from "../ui/Button";
|
|
9
10
|
import { Field } from "../ui/Field";
|
|
10
|
-
import { Input, Textarea } from "../ui/Input";
|
|
11
11
|
import { cx, isTransitioning } from "../ui/popup";
|
|
12
|
-
|
|
12
|
+
|
|
13
|
+
type ModelRef = { type: string; external?: boolean; fields?: Record<string, SchemaField> };
|
|
14
|
+
type RelationDocumentLists = Record<
|
|
15
|
+
string,
|
|
16
|
+
Array<{ slug: string; frontmatter: Record<string, unknown> }>
|
|
17
|
+
>;
|
|
13
18
|
|
|
14
19
|
function fieldLabel(name: string, field: SchemaField) {
|
|
15
20
|
if ("label" in field && typeof field.label === "string" && field.label) return field.label;
|
|
16
21
|
return name.charAt(0).toUpperCase() + name.slice(1);
|
|
17
22
|
}
|
|
18
23
|
|
|
19
|
-
function compositeText(value: unknown): string {
|
|
20
|
-
if (value == null || value === "") return "";
|
|
21
|
-
if (typeof value === "string") return value;
|
|
22
|
-
return JSON.stringify(value, null, 2);
|
|
23
|
-
}
|
|
24
|
-
|
|
25
24
|
export function BlockSettingsDialog({
|
|
26
25
|
name,
|
|
27
26
|
title,
|
|
28
27
|
fields,
|
|
29
28
|
value,
|
|
29
|
+
models,
|
|
30
|
+
objects = [],
|
|
31
|
+
documentLists,
|
|
30
32
|
readOnly,
|
|
31
33
|
onOpenChange,
|
|
32
34
|
onSave,
|
|
@@ -36,32 +38,19 @@ export function BlockSettingsDialog({
|
|
|
36
38
|
title?: string;
|
|
37
39
|
fields: Record<string, SchemaField>;
|
|
38
40
|
value: Record<string, unknown>;
|
|
41
|
+
models: ModelRef[];
|
|
42
|
+
objects?: Array<{ name: string; fields: Record<string, SchemaField> }>;
|
|
43
|
+
documentLists?: RelationDocumentLists;
|
|
39
44
|
readOnly?: boolean;
|
|
40
45
|
onOpenChange: (open: boolean) => void;
|
|
41
46
|
onSave: (props: Record<string, unknown>) => void;
|
|
42
47
|
onRemove: () => void;
|
|
43
48
|
}) {
|
|
44
49
|
const [draft, setDraft] = useState(value);
|
|
45
|
-
const [error, setError] = useState<string | null>(null);
|
|
46
50
|
const names = Object.keys(fields);
|
|
47
51
|
|
|
48
52
|
function save() {
|
|
49
|
-
|
|
50
|
-
for (const [fieldName, field] of Object.entries(fields)) {
|
|
51
|
-
if (field.kind !== "object" && field.kind !== "array") continue;
|
|
52
|
-
const raw = next[fieldName];
|
|
53
|
-
if (typeof raw !== "string" || raw.trim() === "") {
|
|
54
|
-
next[fieldName] = raw === "" ? "" : raw;
|
|
55
|
-
continue;
|
|
56
|
-
}
|
|
57
|
-
try {
|
|
58
|
-
next[fieldName] = JSON.parse(raw);
|
|
59
|
-
} catch {
|
|
60
|
-
setError(`${fieldLabel(fieldName, field)} needs valid JSON.`);
|
|
61
|
-
return;
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
onSave(next);
|
|
53
|
+
onSave(draft);
|
|
65
54
|
}
|
|
66
55
|
|
|
67
56
|
return (
|
|
@@ -100,17 +89,18 @@ export function BlockSettingsDialog({
|
|
|
100
89
|
name={fieldName}
|
|
101
90
|
field={field}
|
|
102
91
|
value={draft[fieldName]}
|
|
92
|
+
models={models}
|
|
93
|
+
objects={objects}
|
|
94
|
+
documentLists={documentLists}
|
|
103
95
|
readOnly={readOnly}
|
|
104
96
|
onChange={(next) => {
|
|
105
|
-
|
|
106
|
-
setDraft((current) => ({ ...current, [fieldName]: next }));
|
|
97
|
+
setDraft((current) => omitField(current, fieldName, next));
|
|
107
98
|
}}
|
|
108
99
|
/>
|
|
109
100
|
</Field>
|
|
110
101
|
);
|
|
111
102
|
})
|
|
112
103
|
)}
|
|
113
|
-
{error ? <p {...stylex.props(styles.error)}>{error}</p> : null}
|
|
114
104
|
<div {...stylex.props(styles.actions)}>
|
|
115
105
|
{readOnly ? null : (
|
|
116
106
|
<Button variant="danger" onClick={onRemove}>
|
|
@@ -137,82 +127,6 @@ export function BlockSettingsDialog({
|
|
|
137
127
|
);
|
|
138
128
|
}
|
|
139
129
|
|
|
140
|
-
function FieldControl({
|
|
141
|
-
name,
|
|
142
|
-
field,
|
|
143
|
-
value,
|
|
144
|
-
readOnly,
|
|
145
|
-
onChange,
|
|
146
|
-
}: {
|
|
147
|
-
name: string;
|
|
148
|
-
field: SchemaField;
|
|
149
|
-
value: unknown;
|
|
150
|
-
readOnly?: boolean;
|
|
151
|
-
onChange: (value: unknown) => void;
|
|
152
|
-
}) {
|
|
153
|
-
const CustomInput = useFieldInput(field);
|
|
154
|
-
if (CustomInput) {
|
|
155
|
-
return (
|
|
156
|
-
<CustomInput name={name} field={field} value={value} readOnly={readOnly} onChange={onChange} />
|
|
157
|
-
);
|
|
158
|
-
}
|
|
159
|
-
if (field.kind === "media") {
|
|
160
|
-
return <MediaFieldInput name={name} value={value} readOnly={readOnly} onChange={onChange} />;
|
|
161
|
-
}
|
|
162
|
-
if (field.kind === "boolean") {
|
|
163
|
-
return (
|
|
164
|
-
<input
|
|
165
|
-
type="checkbox"
|
|
166
|
-
checked={value === true}
|
|
167
|
-
disabled={readOnly}
|
|
168
|
-
onChange={(event) => onChange(event.target.checked)}
|
|
169
|
-
{...stylex.props(styles.check)}
|
|
170
|
-
/>
|
|
171
|
-
);
|
|
172
|
-
}
|
|
173
|
-
if (field.kind === "number") {
|
|
174
|
-
return (
|
|
175
|
-
<Input
|
|
176
|
-
type="number"
|
|
177
|
-
value={value == null || value === "" ? "" : String(value)}
|
|
178
|
-
readOnly={readOnly}
|
|
179
|
-
onChange={(event) => {
|
|
180
|
-
const raw = event.target.value;
|
|
181
|
-
onChange(raw === "" ? "" : Number(raw));
|
|
182
|
-
}}
|
|
183
|
-
/>
|
|
184
|
-
);
|
|
185
|
-
}
|
|
186
|
-
if ("multiline" in field && field.multiline) {
|
|
187
|
-
return (
|
|
188
|
-
<Textarea
|
|
189
|
-
rows={4}
|
|
190
|
-
value={value == null ? "" : String(value)}
|
|
191
|
-
readOnly={readOnly}
|
|
192
|
-
onChange={(event) => onChange(event.target.value)}
|
|
193
|
-
/>
|
|
194
|
-
);
|
|
195
|
-
}
|
|
196
|
-
if (field.kind === "object" || field.kind === "array") {
|
|
197
|
-
return (
|
|
198
|
-
<Textarea
|
|
199
|
-
rows={4}
|
|
200
|
-
value={compositeText(value)}
|
|
201
|
-
readOnly={readOnly}
|
|
202
|
-
spellCheck={false}
|
|
203
|
-
onChange={(event) => onChange(event.target.value)}
|
|
204
|
-
/>
|
|
205
|
-
);
|
|
206
|
-
}
|
|
207
|
-
return (
|
|
208
|
-
<Input
|
|
209
|
-
value={value == null ? "" : String(value)}
|
|
210
|
-
readOnly={readOnly}
|
|
211
|
-
onChange={(event) => onChange(event.target.value)}
|
|
212
|
-
/>
|
|
213
|
-
);
|
|
214
|
-
}
|
|
215
|
-
|
|
216
130
|
const styles = stylex.create({
|
|
217
131
|
backdrop: {
|
|
218
132
|
position: "fixed",
|
|
@@ -282,16 +196,6 @@ const styles = stylex.create({
|
|
|
282
196
|
color: colors.textMuted,
|
|
283
197
|
fontSize: type.sm,
|
|
284
198
|
},
|
|
285
|
-
error: {
|
|
286
|
-
margin: 0,
|
|
287
|
-
color: colors.danger,
|
|
288
|
-
fontSize: type.sm,
|
|
289
|
-
},
|
|
290
|
-
check: {
|
|
291
|
-
width: 16,
|
|
292
|
-
height: 16,
|
|
293
|
-
margin: 0,
|
|
294
|
-
},
|
|
295
199
|
actions: {
|
|
296
200
|
display: "flex",
|
|
297
201
|
alignItems: "center",
|