vestrycms 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +348 -2
- package/dist/config/fields.d.ts +30 -0
- package/dist/config/fields.js +75 -0
- package/dist/config/index.d.ts +9 -0
- package/dist/config/index.js +18 -0
- package/dist/config/types.d.ts +403 -0
- package/dist/config/types.js +10 -0
- package/dist/core/api.d.ts +67 -0
- package/dist/core/api.js +538 -0
- package/dist/core/auth.d.ts +1561 -0
- package/dist/core/auth.js +234 -0
- package/dist/core/cache.d.ts +27 -0
- package/dist/core/cache.js +52 -0
- package/dist/core/entries.d.ts +19 -0
- package/dist/core/entries.js +127 -0
- package/dist/core/index-types.d.ts +20 -0
- package/dist/core/index-types.js +1 -0
- package/dist/core/index.d.ts +13 -0
- package/dist/core/index.js +13 -0
- package/dist/core/mail.d.ts +28 -0
- package/dist/core/mail.js +62 -0
- package/dist/core/paths.d.ts +35 -0
- package/dist/core/paths.js +148 -0
- package/dist/core/roles.d.ts +24 -0
- package/dist/core/roles.js +15 -0
- package/dist/core/service.d.ts +102 -0
- package/dist/core/service.js +478 -0
- package/dist/core/source.d.ts +82 -0
- package/dist/core/source.js +75 -0
- package/dist/core/sources/github-app.d.ts +26 -0
- package/dist/core/sources/github-app.js +161 -0
- package/dist/core/sources/github.d.ts +40 -0
- package/dist/core/sources/github.js +278 -0
- package/dist/core/sources/local.d.ts +16 -0
- package/dist/core/sources/local.js +98 -0
- package/dist/core/values.d.ts +22 -0
- package/dist/core/values.js +123 -0
- package/dist/env.d.ts +19 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.js +167 -0
- package/dist/integration/build-index.d.ts +8 -0
- package/dist/integration/build-index.js +67 -0
- package/dist/integration/client-deps.d.ts +10 -0
- package/dist/integration/client-deps.js +43 -0
- package/dist/integration/dev-middleware.d.ts +6 -0
- package/dist/integration/dev-middleware.js +48 -0
- package/dist/integration/formatter.d.ts +7 -0
- package/dist/integration/formatter.js +56 -0
- package/dist/mdx/expressions.d.ts +14 -0
- package/dist/mdx/expressions.js +141 -0
- package/dist/mdx/index.d.ts +4 -0
- package/dist/mdx/index.js +4 -0
- package/dist/mdx/parse.d.ts +6 -0
- package/dist/mdx/parse.js +378 -0
- package/dist/mdx/serialize.d.ts +9 -0
- package/dist/mdx/serialize.js +396 -0
- package/dist/mdx/types.d.ts +40 -0
- package/dist/mdx/types.js +6 -0
- package/dist/pages/admin.astro +27 -0
- package/dist/pages/api.d.ts +3 -0
- package/dist/pages/api.js +31 -0
- package/dist/ui/App.d.ts +4 -0
- package/dist/ui/App.js +70 -0
- package/dist/ui/api.d.ts +83 -0
- package/dist/ui/api.js +97 -0
- package/dist/ui/auth-client.d.ts +27 -0
- package/dist/ui/auth-client.js +29 -0
- package/dist/ui/components/AssetPicker.d.ts +27 -0
- package/dist/ui/components/AssetPicker.js +97 -0
- package/dist/ui/components/Icon.d.ts +11 -0
- package/dist/ui/components/Icon.js +46 -0
- package/dist/ui/components/Layout.d.ts +15 -0
- package/dist/ui/components/Layout.js +87 -0
- package/dist/ui/components/editor/EditorContext.d.ts +15 -0
- package/dist/ui/components/editor/EditorContext.js +9 -0
- package/dist/ui/components/editor/NodeViews.d.ts +4 -0
- package/dist/ui/components/editor/NodeViews.js +127 -0
- package/dist/ui/components/editor/RichTextEditor.d.ts +24 -0
- package/dist/ui/components/editor/RichTextEditor.js +253 -0
- package/dist/ui/components/editor/extensions.d.ts +20 -0
- package/dist/ui/components/editor/extensions.js +156 -0
- package/dist/ui/components/fields/CodeInput.d.ts +10 -0
- package/dist/ui/components/fields/CodeInput.js +8 -0
- package/dist/ui/components/fields/CodeMirrorInput.d.ts +2 -0
- package/dist/ui/components/fields/CodeMirrorInput.js +44 -0
- package/dist/ui/components/fields/FieldRenderer.d.ts +41 -0
- package/dist/ui/components/fields/FieldRenderer.js +299 -0
- package/dist/ui/components/fields/ImageTools.d.ts +31 -0
- package/dist/ui/components/fields/ImageTools.js +296 -0
- package/dist/ui/components/fields/MarkdownField.d.ts +7 -0
- package/dist/ui/components/fields/MarkdownField.js +16 -0
- package/dist/ui/components/fields/TreeInput.d.ts +13 -0
- package/dist/ui/components/fields/TreeInput.js +221 -0
- package/dist/ui/components/ui.d.ts +63 -0
- package/dist/ui/components/ui.js +113 -0
- package/dist/ui/context.d.ts +17 -0
- package/dist/ui/context.js +13 -0
- package/dist/ui/lib/format.d.ts +5 -0
- package/dist/ui/lib/format.js +11 -0
- package/dist/ui/lib/utils.d.ts +24 -0
- package/dist/ui/lib/utils.js +255 -0
- package/dist/ui/main.d.ts +1 -0
- package/dist/ui/main.js +11 -0
- package/dist/ui/pages/Assets.d.ts +1 -0
- package/dist/ui/pages/Assets.js +69 -0
- package/dist/ui/pages/CollectionList.d.ts +6 -0
- package/dist/ui/pages/CollectionList.js +115 -0
- package/dist/ui/pages/Dashboard.d.ts +1 -0
- package/dist/ui/pages/Dashboard.js +57 -0
- package/dist/ui/pages/EntryEditor.d.ts +3 -0
- package/dist/ui/pages/EntryEditor.js +313 -0
- package/dist/ui/pages/Login.d.ts +6 -0
- package/dist/ui/pages/Login.js +88 -0
- package/dist/ui/pages/Users.d.ts +1 -0
- package/dist/ui/pages/Users.js +102 -0
- package/dist/ui/styles.css +2536 -0
- package/package.json +121 -3
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { useQuery } from "@tanstack/react-query";
|
|
3
|
+
import { ChevronDown, ChevronRight, ChevronUp, Copy, GripVertical, Image as ImageIcon, Plus, Trash2, X, } from "lucide-react";
|
|
4
|
+
import { useId, useRef, useState } from "react";
|
|
5
|
+
import { createPortal } from "react-dom";
|
|
6
|
+
import { DndContext, DragOverlay, KeyboardSensor, PointerSensor, closestCenter, useSensor, useSensors, } from "@dnd-kit/core";
|
|
7
|
+
import { SortableContext, arrayMove, sortableKeyboardCoordinates, useSortable, verticalListSortingStrategy, } from "@dnd-kit/sortable";
|
|
8
|
+
import { CSS } from "@dnd-kit/utilities";
|
|
9
|
+
import { restrictToParentElement, restrictToVerticalAxis, } from "@dnd-kit/modifiers";
|
|
10
|
+
import { useVestry } from "../../context.js";
|
|
11
|
+
import { basename, cx, defaultValue, fieldLabel, isImagePath, slugify, summarize, } from "../../lib/utils.js";
|
|
12
|
+
import { AssetPicker } from "../AssetPicker.js";
|
|
13
|
+
import { Button, Toggle } from "../ui.js";
|
|
14
|
+
import { MarkdownField } from "./MarkdownField.js";
|
|
15
|
+
import { CodeInput } from "./CodeInput.js";
|
|
16
|
+
import { CropInput, FocalPointInput } from "./ImageTools.js";
|
|
17
|
+
import { TreeInput } from "./TreeInput.js";
|
|
18
|
+
/** Renders a schema as a responsive grid of fields. */
|
|
19
|
+
export function FieldSet({ schema, values, onChange, errors, filter, ctx, pathPrefix = "", hideLabels, }) {
|
|
20
|
+
const entries = Object.entries(schema).filter(([k, f]) => !f.hidden && (!filter || filter(k, f)));
|
|
21
|
+
if (!entries.length)
|
|
22
|
+
return null;
|
|
23
|
+
return (_jsx("div", { className: "v-fields", children: entries.map(([key, field]) => (_jsx("div", { className: cx("v-field", field.width === "half" && "v-field--half", field.width === "third" && "v-field--third"), children: _jsx(FieldRenderer, { fieldKey: key, path: pathPrefix ? `${pathPrefix}.${key}` : key, field: field, value: values[key], onChange: (v) => onChange({ ...values, [key]: v }), errors: errors, ctx: { ...ctx, siblings: values }, hideLabel: hideLabels?.includes(key) }) }, key))) }));
|
|
24
|
+
}
|
|
25
|
+
export function FieldRenderer(props) {
|
|
26
|
+
const { field, fieldKey, path, errors } = props;
|
|
27
|
+
const id = useId();
|
|
28
|
+
const error = errors?.find((e) => e.path === path)?.message;
|
|
29
|
+
const label = fieldLabel(fieldKey, field);
|
|
30
|
+
const inline = field.type === "boolean";
|
|
31
|
+
return (_jsxs("div", { className: cx(error && "has-error"), children: [!inline && !props.hideLabel && (_jsxs("label", { className: "v-field__label", htmlFor: id, children: [label, field.required && _jsx("span", { className: "v-field__required", children: "*" })] })), _jsx(FieldInput, { ...props, id: id, label: label }), field.type === "date" &&
|
|
32
|
+
field.timeHint &&
|
|
33
|
+
typeof props.value === "string" &&
|
|
34
|
+
props.value.length <= 10 && (_jsxs("div", { className: "v-field__hint", children: ["Time: ", field.timeHint] })), field.description && (_jsx("div", { className: "v-field__hint", children: field.description })), error && _jsx("div", { className: "v-field__error", children: error })] }));
|
|
35
|
+
}
|
|
36
|
+
function FieldInput(props) {
|
|
37
|
+
const { field, value, onChange, id, label, ctx } = props;
|
|
38
|
+
const readOnly = field.readOnly;
|
|
39
|
+
switch (field.type) {
|
|
40
|
+
case "text":
|
|
41
|
+
return (_jsx("input", { id: id, className: "v-input", value: value ?? "", placeholder: field.placeholder, maxLength: field.maxLength, readOnly: readOnly, onChange: (e) => onChange(e.target.value) }));
|
|
42
|
+
case "slug":
|
|
43
|
+
return (_jsx(SlugInput, { id: id, value: value ?? "", onChange: onChange, from: field.from, ctx: ctx, readOnly: readOnly }));
|
|
44
|
+
case "textarea":
|
|
45
|
+
return (_jsx("textarea", { id: id, className: "v-textarea", rows: field.rows ?? 4, value: value ?? "", placeholder: field.placeholder, maxLength: field.maxLength, readOnly: readOnly, onChange: (e) => onChange(e.target.value) }));
|
|
46
|
+
case "number":
|
|
47
|
+
return (_jsx("input", { id: id, type: "number", className: "v-input", value: value === null || value === undefined ? "" : String(value), min: field.min, max: field.max, step: field.step ?? "any", readOnly: readOnly, onChange: (e) => onChange(e.target.value === "" ? null : Number(e.target.value)) }));
|
|
48
|
+
case "boolean":
|
|
49
|
+
return (_jsx(Toggle, { checked: Boolean(value), onChange: onChange, label: label, disabled: readOnly }));
|
|
50
|
+
case "date": {
|
|
51
|
+
const str = typeof value === "string"
|
|
52
|
+
? value
|
|
53
|
+
: value instanceof Date
|
|
54
|
+
? value.toISOString()
|
|
55
|
+
: "";
|
|
56
|
+
const datePart = str.slice(0, 10);
|
|
57
|
+
const timePart = str.length > 10 ? str.slice(11, 16) : "";
|
|
58
|
+
if (!field.time) {
|
|
59
|
+
return (_jsx("input", { id: id, type: "date", className: "v-input", value: datePart, readOnly: readOnly, onChange: (e) => onChange(e.target.value) }));
|
|
60
|
+
}
|
|
61
|
+
return (_jsxs("div", { className: "v-input-group", children: [_jsx("input", { id: id, type: "date", className: "v-input", value: datePart, readOnly: readOnly, onChange: (e) => onChange(timePart && e.target.value
|
|
62
|
+
? `${e.target.value}T${timePart}`
|
|
63
|
+
: e.target.value) }), _jsx("input", { type: "time", className: "v-input", style: { flex: "0 0 130px" }, value: timePart, readOnly: readOnly, title: field.timeHint, onChange: (e) => onChange(e.target.value && datePart
|
|
64
|
+
? `${datePart}T${e.target.value}`
|
|
65
|
+
: datePart) })] }));
|
|
66
|
+
}
|
|
67
|
+
case "datetime": {
|
|
68
|
+
if (field.autoUpdate) {
|
|
69
|
+
return (_jsx("input", { id: id, className: "v-input", readOnly: true, value: value ? new Date(String(value)).toLocaleString() : "Set on save" }));
|
|
70
|
+
}
|
|
71
|
+
return (_jsx(DatetimeInput, { id: id, value: value, storage: field.storage ?? "local", onChange: onChange, readOnly: readOnly }));
|
|
72
|
+
}
|
|
73
|
+
case "select":
|
|
74
|
+
return (_jsxs("div", { className: "v-row", children: [field.options.find((o) => o.value === value)?.color && (_jsx("span", { className: "v-swatch", style: {
|
|
75
|
+
background: field.options.find((o) => o.value === value).color,
|
|
76
|
+
} })), _jsxs("select", { id: id, className: "v-select", value: value ?? "", disabled: readOnly, onChange: (e) => onChange(e.target.value || undefined), children: [(field.clearable || value === undefined || value === "") && (_jsx("option", { value: "", children: "\u2014" })), field.options.map((o) => (_jsx("option", { value: o.value, children: o.label }, o.value)))] })] }));
|
|
77
|
+
case "multiselect": {
|
|
78
|
+
const selected = Array.isArray(value) ? value : [];
|
|
79
|
+
return (_jsx("div", { className: "v-chips", children: field.options.map((o) => {
|
|
80
|
+
const on = selected.includes(o.value);
|
|
81
|
+
return (_jsxs("button", { type: "button", className: cx("v-chip", on && "is-on"), onClick: () => onChange(on
|
|
82
|
+
? selected.filter((v) => v !== o.value)
|
|
83
|
+
: [...selected, o.value]), disabled: readOnly, children: [o.color && (_jsx("span", { className: "v-swatch", style: { background: o.color } })), o.label] }, o.value));
|
|
84
|
+
}) }));
|
|
85
|
+
}
|
|
86
|
+
case "color":
|
|
87
|
+
return (_jsxs("div", { className: "v-input-group", children: [_jsx("input", { type: "color", value: value || "#000000", onChange: (e) => onChange(e.target.value), disabled: readOnly, style: { width: 44, height: 36, padding: 2 } }), _jsx("input", { id: id, className: "v-input v-input--mono", value: value ?? "", onChange: (e) => onChange(e.target.value), readOnly: readOnly })] }));
|
|
88
|
+
case "image":
|
|
89
|
+
case "file":
|
|
90
|
+
return _jsx(AssetInput, { ...props });
|
|
91
|
+
case "reference":
|
|
92
|
+
return (_jsx(ReferenceInput, { id: id, collection: field.collection, value: value ?? "", onChange: onChange, readOnly: readOnly }));
|
|
93
|
+
case "object": {
|
|
94
|
+
const obj = (value && typeof value === "object" ? value : {});
|
|
95
|
+
const body = (_jsx(FieldSet, { schema: field.fields, values: obj, onChange: onChange, errors: props.errors, ctx: ctx, pathPrefix: props.path }));
|
|
96
|
+
return field.inline ? (body) : (_jsx("div", { className: "v-group", children: _jsx("div", { className: "v-group__body", children: body }) }));
|
|
97
|
+
}
|
|
98
|
+
case "array":
|
|
99
|
+
return _jsx(ArrayInput, { ...props, field: field });
|
|
100
|
+
case "markdown":
|
|
101
|
+
return (_jsx(MarkdownField, { value: value ?? "", onChange: onChange, components: field.components, placeholder: field.description }));
|
|
102
|
+
case "code":
|
|
103
|
+
return (_jsx(CodeInput, { value: value ?? "", onChange: onChange, language: field.language ?? "text", rows: field.rows, readOnly: readOnly }));
|
|
104
|
+
case "json":
|
|
105
|
+
return (_jsx(JsonInput, { value: value, onChange: onChange, readOnly: readOnly }));
|
|
106
|
+
case "crop":
|
|
107
|
+
return (_jsx(CropInput, { field: field, value: value, onChange: onChange, siblings: ctx?.siblings, pendingUploads: ctx?.pendingUploads }));
|
|
108
|
+
case "focalPoint":
|
|
109
|
+
return (_jsx(FocalPointInput, { field: field, value: value, onChange: onChange, siblings: ctx?.siblings, pendingUploads: ctx?.pendingUploads }));
|
|
110
|
+
case "tree":
|
|
111
|
+
return (_jsx(TreeInput, { field: field, value: value, onChange: onChange, errors: props.errors, ctx: ctx, path: props.path }));
|
|
112
|
+
default:
|
|
113
|
+
return _jsx("input", { className: "v-input", readOnly: true, value: String(value ?? "") });
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
function SlugInput({ id, value, onChange, from, ctx, readOnly, }) {
|
|
117
|
+
const source = from ? ctx?.siblings?.[from] : undefined;
|
|
118
|
+
const suggestion = typeof source === "string" ? slugify(source) : "";
|
|
119
|
+
return (_jsxs("div", { className: "v-input-group", children: [_jsx("input", { id: id, className: "v-input v-input--mono", value: value, readOnly: readOnly, onChange: (e) => onChange(e.target.value), placeholder: suggestion || undefined }), suggestion && suggestion !== value && !readOnly && (_jsx(Button, { size: "sm", onClick: () => onChange(suggestion), children: "Generate" }))] }));
|
|
120
|
+
}
|
|
121
|
+
function DatetimeInput({ id, value, storage, onChange, readOnly, }) {
|
|
122
|
+
// Display in local wall-clock; store either as `YYYY-MM-DDTHH:mm` or ISO.
|
|
123
|
+
let display = "";
|
|
124
|
+
if (typeof value === "string" && value) {
|
|
125
|
+
if (storage === "local")
|
|
126
|
+
display = value.slice(0, 16);
|
|
127
|
+
else {
|
|
128
|
+
const d = new Date(value);
|
|
129
|
+
if (!Number.isNaN(d.getTime())) {
|
|
130
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
131
|
+
display = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return (_jsx("input", { id: id, type: "datetime-local", className: "v-input", value: display, readOnly: readOnly, onChange: (e) => {
|
|
136
|
+
const v = e.target.value;
|
|
137
|
+
if (!v)
|
|
138
|
+
return onChange("");
|
|
139
|
+
onChange(storage === "local" ? v : new Date(v).toISOString());
|
|
140
|
+
} }));
|
|
141
|
+
}
|
|
142
|
+
function AssetInput(props) {
|
|
143
|
+
const { api } = useVestry();
|
|
144
|
+
const field = props.field;
|
|
145
|
+
const value = props.value ?? "";
|
|
146
|
+
const [open, setOpen] = useState(false);
|
|
147
|
+
const pending = props.ctx?.pendingUploads?.[value];
|
|
148
|
+
const isImage = field.type === "image" || isImagePath(value);
|
|
149
|
+
const previewUrl = value
|
|
150
|
+
? pending
|
|
151
|
+
? pending.previewUrl
|
|
152
|
+
: /^https?:/.test(value)
|
|
153
|
+
? value
|
|
154
|
+
: api.fileUrl(value)
|
|
155
|
+
: null;
|
|
156
|
+
const accept = field.accept ??
|
|
157
|
+
(field.type === "image"
|
|
158
|
+
? ["jpg", "jpeg", "png", "gif", "webp", "avif", "svg"]
|
|
159
|
+
: undefined);
|
|
160
|
+
return (_jsxs("div", { className: "v-image-field", children: [_jsx("div", { className: "v-image-field__preview", children: previewUrl && isImage ? (_jsx("img", { src: previewUrl, alt: "" })) : (_jsx(ImageIcon, {})) }), _jsxs("div", { className: "v-image-field__info", children: [value ? (_jsxs("div", { className: "v-image-field__path", children: [_jsx("strong", { style: { color: "var(--v-text)" }, children: basename(value) }), _jsx("br", {}), value.slice(0, value.length - basename(value).length)] })) : (_jsx("div", { className: "v-muted", style: { marginBottom: 8 }, children: "No file selected" })), _jsxs("div", { className: "v-image-field__actions", children: [_jsx(Button, { size: "sm", onClick: () => setOpen(true), disabled: field.readOnly, children: value ? "Replace" : "Choose" }), value && (_jsxs(Button, { size: "sm", variant: "ghost", onClick: () => props.onChange(""), disabled: field.readOnly, children: [_jsx(X, {}), " Clear"] }))] })] }), open && (_jsx(AssetPicker, { directory: field.directory, accept: accept, allowUpload: field.upload !== false, title: field.type === "image" ? "Choose an image" : "Choose a file", deferUploads: Boolean(props.ctx?.addUpload), onClose: () => setOpen(false), onPick: (path, upload) => {
|
|
161
|
+
if (upload)
|
|
162
|
+
props.ctx?.addUpload?.(upload);
|
|
163
|
+
props.onChange(path);
|
|
164
|
+
setOpen(false);
|
|
165
|
+
} }))] }));
|
|
166
|
+
}
|
|
167
|
+
function ReferenceInput({ id, collection, value, onChange, readOnly, }) {
|
|
168
|
+
const { api } = useVestry();
|
|
169
|
+
const q = useQuery({
|
|
170
|
+
queryKey: ["entries", collection],
|
|
171
|
+
queryFn: () => api.listEntries(collection),
|
|
172
|
+
staleTime: 60_000,
|
|
173
|
+
});
|
|
174
|
+
const entries = q.data?.entries ?? [];
|
|
175
|
+
return (_jsxs("select", { id: id, className: "v-select", value: value, disabled: readOnly, onChange: (e) => onChange(e.target.value || undefined), children: [_jsx("option", { value: "", children: q.isLoading ? "Loading…" : "—" }), value && !entries.some((e) => e.slug === value) && (_jsx("option", { value: value, children: value })), entries.map((e) => (_jsx("option", { value: e.slug, children: e.title }, e.slug)))] }));
|
|
176
|
+
}
|
|
177
|
+
let nextRowId = 0;
|
|
178
|
+
const newRowId = () => `row-${++nextRowId}`;
|
|
179
|
+
/**
|
|
180
|
+
* A sortable list item. dnd-kit moves the neighbours out of the way with
|
|
181
|
+
* transforms so a gap opens where the row will land; the row itself stays in
|
|
182
|
+
* flow as a dimmed placeholder while a clone floats in the DragOverlay.
|
|
183
|
+
*/
|
|
184
|
+
function SortableRow({ id, className, children, }) {
|
|
185
|
+
const { attributes, listeners, setNodeRef, setActivatorNodeRef, transform, transition, isDragging, } = useSortable({ id });
|
|
186
|
+
return (_jsx("div", { ref: setNodeRef, className: cx(className, isDragging && "is-placeholder"), style: { transform: CSS.Translate.toString(transform), transition }, children: children({
|
|
187
|
+
ref: setActivatorNodeRef,
|
|
188
|
+
listeners: {
|
|
189
|
+
...listeners,
|
|
190
|
+
// Keep the pointer-down from reaching an enclosing rich text
|
|
191
|
+
// editor, which would otherwise treat it as a selection drag.
|
|
192
|
+
onPointerDown: (e) => {
|
|
193
|
+
e.stopPropagation();
|
|
194
|
+
listeners?.onPointerDown?.(e);
|
|
195
|
+
},
|
|
196
|
+
},
|
|
197
|
+
attributes: attributes,
|
|
198
|
+
}, isDragging) }));
|
|
199
|
+
}
|
|
200
|
+
function ArrayInput(props) {
|
|
201
|
+
const { field, onChange, ctx } = props;
|
|
202
|
+
const items = Array.isArray(props.value) ? props.value : [];
|
|
203
|
+
const [openIdx, setOpenIdx] = useState(null);
|
|
204
|
+
const [activeId, setActiveId] = useState(null);
|
|
205
|
+
const isObject = field.of.type === "object";
|
|
206
|
+
// Stable ids for dnd-kit, kept parallel to the items array.
|
|
207
|
+
const idsRef = useRef([]);
|
|
208
|
+
if (idsRef.current.length < items.length) {
|
|
209
|
+
idsRef.current = [
|
|
210
|
+
...idsRef.current,
|
|
211
|
+
...Array.from({ length: items.length - idsRef.current.length }, newRowId),
|
|
212
|
+
];
|
|
213
|
+
}
|
|
214
|
+
else if (idsRef.current.length > items.length) {
|
|
215
|
+
idsRef.current = idsRef.current.slice(0, items.length);
|
|
216
|
+
}
|
|
217
|
+
const ids = idsRef.current;
|
|
218
|
+
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 4 } }), useSensor(KeyboardSensor, {
|
|
219
|
+
coordinateGetter: sortableKeyboardCoordinates,
|
|
220
|
+
}));
|
|
221
|
+
const update = (next, nextIds) => {
|
|
222
|
+
if (nextIds)
|
|
223
|
+
idsRef.current = nextIds;
|
|
224
|
+
onChange(next);
|
|
225
|
+
};
|
|
226
|
+
const move = (from, to) => {
|
|
227
|
+
if (to < 0 || to >= items.length || from === to)
|
|
228
|
+
return;
|
|
229
|
+
update(arrayMove(items, from, to), arrayMove(ids, from, to));
|
|
230
|
+
setOpenIdx((o) => (o === from ? to : o));
|
|
231
|
+
};
|
|
232
|
+
const remove = (i) => update(items.filter((_, j) => j !== i), ids.filter((_, j) => j !== i));
|
|
233
|
+
const duplicate = (i) => update([
|
|
234
|
+
...items.slice(0, i + 1),
|
|
235
|
+
JSON.parse(JSON.stringify(items[i])),
|
|
236
|
+
...items.slice(i + 1),
|
|
237
|
+
], [...ids.slice(0, i + 1), newRowId(), ...ids.slice(i + 1)]);
|
|
238
|
+
const setItem = (i, v) => update(items.map((x, j) => (j === i ? v : x)));
|
|
239
|
+
const moveButtons = (i) => (_jsxs(_Fragment, { children: [_jsx(Button, { variant: "ghost", size: "xs", icon: true, title: "Move up", onClick: () => move(i, i - 1), disabled: i === 0, children: _jsx(ChevronUp, {}) }), _jsx(Button, { variant: "ghost", size: "xs", icon: true, title: "Move down", onClick: () => move(i, i + 1), disabled: i === items.length - 1, children: _jsx(ChevronDown, {}) })] }));
|
|
240
|
+
/** Header of an object item; `overlay` renders a static clone for the drag preview. */
|
|
241
|
+
const objectHeader = (i, open, handle) => {
|
|
242
|
+
const item = items[i];
|
|
243
|
+
const title = summarize(item, field) || `${fieldLabel("item", field.of)} ${i + 1}`;
|
|
244
|
+
return (_jsxs("div", { className: "v-list__header", onClick: handle ? () => setOpenIdx(open ? null : i) : undefined, children: [_jsx("span", { className: "v-list__handle", title: "Drag to reorder", ref: handle?.ref, ...(handle?.listeners ?? {}), ...(handle?.attributes ?? {}), children: _jsx(GripVertical, {}) }), open ? _jsx(ChevronDown, { size: 14 }) : _jsx(ChevronRight, { size: 14 }), _jsx("span", { className: "v-list__title", children: title }), handle && (_jsxs("div", { className: "v-list__actions", onClick: (e) => e.stopPropagation(), children: [moveButtons(i), _jsx(Button, { variant: "ghost", size: "xs", icon: true, title: "Duplicate", onClick: () => duplicate(i), children: _jsx(Copy, {}) }), _jsx(Button, { variant: "ghost", size: "xs", icon: true, title: "Remove", onClick: () => remove(i), children: _jsx(Trash2, {}) })] }))] }));
|
|
245
|
+
};
|
|
246
|
+
const scalarRow = (i, handle) => (_jsxs(_Fragment, { children: [_jsx("span", { className: "v-list__handle", title: "Drag to reorder", ref: handle?.ref, ...(handle?.listeners ?? {}), ...(handle?.attributes ?? {}), children: _jsx(GripVertical, {}) }), _jsx("div", { className: "v-grow", children: _jsx(FieldInput, { fieldKey: `${props.fieldKey}.${i}`, path: `${props.path}.${i}`, field: field.of, value: items[i], onChange: (v) => setItem(i, v), errors: props.errors, ctx: ctx, id: handle ? `${props.path}.${i}` : `${props.path}.${i}.overlay`, label: "" }) }), _jsxs("div", { className: "v-list__actions", children: [moveButtons(i), _jsx(Button, { variant: "ghost", size: "xs", icon: true, title: "Remove", onClick: () => remove(i), children: _jsx(Trash2, {}) })] })] }));
|
|
247
|
+
const rowClass = (i) => cx(isObject ? "v-list__item" : "v-list__row", isDisabled(items[i]) && "is-disabled");
|
|
248
|
+
const activeIdx = activeId ? ids.indexOf(activeId) : -1;
|
|
249
|
+
return (_jsxs("div", { children: [_jsxs(DndContext, { sensors: sensors, collisionDetection: closestCenter, modifiers: [restrictToVerticalAxis, restrictToParentElement], onDragStart: (e) => {
|
|
250
|
+
setActiveId(String(e.active.id));
|
|
251
|
+
if (isObject)
|
|
252
|
+
setOpenIdx(null);
|
|
253
|
+
}, onDragEnd: (e) => {
|
|
254
|
+
setActiveId(null);
|
|
255
|
+
const from = ids.indexOf(String(e.active.id));
|
|
256
|
+
const to = e.over ? ids.indexOf(String(e.over.id)) : -1;
|
|
257
|
+
if (from !== -1 && to !== -1)
|
|
258
|
+
move(from, to);
|
|
259
|
+
}, onDragCancel: () => setActiveId(null), children: [_jsx(SortableContext, { items: ids, strategy: verticalListSortingStrategy, children: _jsx("div", { className: "v-list", children: items.map((item, i) => {
|
|
260
|
+
const open = openIdx === i;
|
|
261
|
+
return (_jsx(SortableRow, { id: ids[i], className: rowClass(i), children: (handle) => isObject ? (_jsxs(_Fragment, { children: [objectHeader(i, open, handle), open && (_jsx("div", { className: "v-list__body", children: _jsx(FieldSet, { schema: field.of.fields, values: (item ?? {}), onChange: (v) => setItem(i, v), errors: props.errors, ctx: ctx, pathPrefix: `${props.path}.${i}` }) }))] })) : (scalarRow(i, handle)) }, ids[i]));
|
|
262
|
+
}) }) }), createPortal(_jsx(DragOverlay, { dropAnimation: {
|
|
263
|
+
duration: 180,
|
|
264
|
+
easing: "cubic-bezier(0.2, 0, 0, 1)",
|
|
265
|
+
}, children: activeIdx !== -1 && (_jsx("div", { className: cx(rowClass(activeIdx), "v-list__ghost"), children: isObject
|
|
266
|
+
? objectHeader(activeIdx, false, null)
|
|
267
|
+
: scalarRow(activeIdx, null) })) }), document.body)] }), _jsxs(Button, { size: "sm", className: "v-list__add", disabled: field.max !== undefined && items.length >= field.max, onClick: () => {
|
|
268
|
+
update([...items, defaultValue(field.of)], [...ids, newRowId()]);
|
|
269
|
+
setOpenIdx(items.length);
|
|
270
|
+
}, children: [_jsx(Plus, {}), " ", field.addLabel ?? "Add item"] })] }));
|
|
271
|
+
}
|
|
272
|
+
function JsonInput({ value, onChange, readOnly, }) {
|
|
273
|
+
const [text, setText] = useState(() => value === undefined ? "" : JSON.stringify(value, null, 2));
|
|
274
|
+
const [error, setError] = useState(null);
|
|
275
|
+
return (_jsxs("div", { children: [_jsx(CodeInput, { value: text, language: "json", readOnly: readOnly, onChange: (t) => {
|
|
276
|
+
setText(t);
|
|
277
|
+
if (!t.trim()) {
|
|
278
|
+
setError(null);
|
|
279
|
+
onChange(undefined);
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
try {
|
|
283
|
+
onChange(JSON.parse(t));
|
|
284
|
+
setError(null);
|
|
285
|
+
}
|
|
286
|
+
catch (e) {
|
|
287
|
+
setError(e.message);
|
|
288
|
+
}
|
|
289
|
+
} }), error && _jsx("div", { className: "v-field__error", children: error })] }));
|
|
290
|
+
}
|
|
291
|
+
/** Items with `enabled: false` (a common toggle in page-builder data) are shown dimmed. */
|
|
292
|
+
function isDisabled(item) {
|
|
293
|
+
return (typeof item === "object" &&
|
|
294
|
+
item !== null &&
|
|
295
|
+
item.enabled === false);
|
|
296
|
+
}
|
|
297
|
+
export function FieldGroup({ title, children, }) {
|
|
298
|
+
return (_jsxs("div", { className: "v-card", children: [_jsx("div", { className: "v-card__header", children: _jsx("h3", { className: "v-card__title", children: title }) }), _jsx("div", { className: "v-card__body", children: children })] }));
|
|
299
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { CropField, FocalPointField } from "../../../config/types.js";
|
|
2
|
+
import type { PendingUpload } from "../AssetPicker.js";
|
|
3
|
+
export interface CropRect {
|
|
4
|
+
x: number;
|
|
5
|
+
y: number;
|
|
6
|
+
width: number;
|
|
7
|
+
height: number;
|
|
8
|
+
}
|
|
9
|
+
export interface FocalPoint {
|
|
10
|
+
x: number;
|
|
11
|
+
y: number;
|
|
12
|
+
}
|
|
13
|
+
/** Largest rectangle of the given aspect that fits the image, centred. */
|
|
14
|
+
export declare function defaultCrop(natural: {
|
|
15
|
+
width: number;
|
|
16
|
+
height: number;
|
|
17
|
+
}, aspect?: number): CropRect;
|
|
18
|
+
export declare function CropInput({ field, value, onChange, siblings, pendingUploads, }: {
|
|
19
|
+
field: CropField;
|
|
20
|
+
value: unknown;
|
|
21
|
+
onChange: (v: CropRect | undefined) => void;
|
|
22
|
+
siblings?: Record<string, unknown>;
|
|
23
|
+
pendingUploads?: Record<string, PendingUpload>;
|
|
24
|
+
}): import("react").JSX.Element;
|
|
25
|
+
export declare function FocalPointInput({ field, value, onChange, siblings, pendingUploads, }: {
|
|
26
|
+
field: FocalPointField;
|
|
27
|
+
value: unknown;
|
|
28
|
+
onChange: (v: FocalPoint | undefined) => void;
|
|
29
|
+
siblings?: Record<string, unknown>;
|
|
30
|
+
pendingUploads?: Record<string, PendingUpload>;
|
|
31
|
+
}): import("react").JSX.Element;
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { Crosshair, Maximize2, RotateCcw } from "lucide-react";
|
|
3
|
+
import { useCallback, useEffect, useLayoutEffect, useRef, useState, } from "react";
|
|
4
|
+
import { useVestry } from "../../context.js";
|
|
5
|
+
import { cx } from "../../lib/utils.js";
|
|
6
|
+
import { Button } from "../ui.js";
|
|
7
|
+
/** Resolve the sibling image field's value to a URL the browser can load. */
|
|
8
|
+
function useImageSource(imagePath, pendingUploads) {
|
|
9
|
+
const { api } = useVestry();
|
|
10
|
+
if (typeof imagePath !== "string" || !imagePath)
|
|
11
|
+
return null;
|
|
12
|
+
const pending = pendingUploads?.[imagePath];
|
|
13
|
+
if (pending)
|
|
14
|
+
return pending.previewUrl;
|
|
15
|
+
return /^https?:\/\//.test(imagePath) ? imagePath : api.fileUrl(imagePath);
|
|
16
|
+
}
|
|
17
|
+
/** Load the image, track its natural size, and fit it into the available width. */
|
|
18
|
+
function useFittedImage(src, maxHeight) {
|
|
19
|
+
const containerRef = useRef(null);
|
|
20
|
+
const [natural, setNatural] = useState(null);
|
|
21
|
+
const [containerWidth, setContainerWidth] = useState(0);
|
|
22
|
+
const [error, setError] = useState(false);
|
|
23
|
+
useEffect(() => {
|
|
24
|
+
setNatural(null);
|
|
25
|
+
setError(false);
|
|
26
|
+
if (!src)
|
|
27
|
+
return;
|
|
28
|
+
const img = new Image();
|
|
29
|
+
img.onload = () => setNatural({ width: img.naturalWidth, height: img.naturalHeight });
|
|
30
|
+
img.onerror = () => setError(true);
|
|
31
|
+
img.src = src;
|
|
32
|
+
}, [src]);
|
|
33
|
+
useLayoutEffect(() => {
|
|
34
|
+
const el = containerRef.current;
|
|
35
|
+
if (!el)
|
|
36
|
+
return;
|
|
37
|
+
const update = () => setContainerWidth(el.clientWidth);
|
|
38
|
+
update();
|
|
39
|
+
const ro = new ResizeObserver(update);
|
|
40
|
+
ro.observe(el);
|
|
41
|
+
return () => ro.disconnect();
|
|
42
|
+
}, [src]);
|
|
43
|
+
let fitted = null;
|
|
44
|
+
if (natural && containerWidth > 0) {
|
|
45
|
+
const scale = Math.min(containerWidth / natural.width, maxHeight / natural.height, 1);
|
|
46
|
+
fitted = {
|
|
47
|
+
natural,
|
|
48
|
+
display: {
|
|
49
|
+
width: Math.round(natural.width * scale),
|
|
50
|
+
height: Math.round(natural.height * scale),
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
return { containerRef, fitted, error };
|
|
55
|
+
}
|
|
56
|
+
/** Dragging a handle must not start a text selection elsewhere on the page. */
|
|
57
|
+
const preventSelection = (e) => e.preventDefault();
|
|
58
|
+
const clamp = (v, min, max) => Math.min(max, Math.max(min, v));
|
|
59
|
+
/** Largest rectangle of the given aspect that fits the image, centred. */
|
|
60
|
+
export function defaultCrop(natural, aspect) {
|
|
61
|
+
if (!aspect)
|
|
62
|
+
return { x: 0, y: 0, width: natural.width, height: natural.height };
|
|
63
|
+
let width = natural.width;
|
|
64
|
+
let height = Math.round(width / aspect);
|
|
65
|
+
if (height > natural.height) {
|
|
66
|
+
height = natural.height;
|
|
67
|
+
width = Math.round(height * aspect);
|
|
68
|
+
}
|
|
69
|
+
return {
|
|
70
|
+
x: Math.round((natural.width - width) / 2),
|
|
71
|
+
y: Math.round((natural.height - height) / 2),
|
|
72
|
+
width,
|
|
73
|
+
height,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
function normalizeCrop(rect, natural, aspect, minSize) {
|
|
77
|
+
let width = clamp(Math.round(rect.width), minSize, natural.width);
|
|
78
|
+
let height = clamp(Math.round(rect.height), minSize, natural.height);
|
|
79
|
+
if (aspect) {
|
|
80
|
+
height = Math.round(width / aspect);
|
|
81
|
+
if (height > natural.height) {
|
|
82
|
+
height = natural.height;
|
|
83
|
+
width = Math.round(height * aspect);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
const x = clamp(Math.round(rect.x), 0, natural.width - width);
|
|
87
|
+
const y = clamp(Math.round(rect.y), 0, natural.height - height);
|
|
88
|
+
return { x, y, width, height };
|
|
89
|
+
}
|
|
90
|
+
const HANDLES = ["nw", "n", "ne", "e", "se", "s", "sw", "w"];
|
|
91
|
+
export function CropInput({ field, value, onChange, siblings, pendingUploads, }) {
|
|
92
|
+
const src = useImageSource(siblings?.[field.image], pendingUploads);
|
|
93
|
+
const { containerRef, fitted, error } = useFittedImage(src, 440);
|
|
94
|
+
const minSize = field.minSize ?? 16;
|
|
95
|
+
const aspect = field.aspect;
|
|
96
|
+
const rect = isCropRect(value) ? value : null;
|
|
97
|
+
const drag = useRef(null);
|
|
98
|
+
// Once the image is known, seed a default crop or clamp an out-of-range one.
|
|
99
|
+
useEffect(() => {
|
|
100
|
+
if (!fitted)
|
|
101
|
+
return;
|
|
102
|
+
if (!rect) {
|
|
103
|
+
onChange(defaultCrop(fitted.natural, aspect));
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
const fixed = normalizeCrop(rect, fitted.natural, aspect, minSize);
|
|
107
|
+
if (fixed.x !== rect.x ||
|
|
108
|
+
fixed.y !== rect.y ||
|
|
109
|
+
fixed.width !== rect.width ||
|
|
110
|
+
fixed.height !== rect.height)
|
|
111
|
+
onChange(fixed);
|
|
112
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
113
|
+
}, [fitted?.natural.width, fitted?.natural.height]);
|
|
114
|
+
const scale = fitted ? fitted.display.width / fitted.natural.width : 1;
|
|
115
|
+
const onPointerDown = (mode) => (e) => {
|
|
116
|
+
if (!rect || !fitted)
|
|
117
|
+
return;
|
|
118
|
+
e.preventDefault();
|
|
119
|
+
e.stopPropagation();
|
|
120
|
+
e.currentTarget.setPointerCapture(e.pointerId);
|
|
121
|
+
drag.current = {
|
|
122
|
+
mode,
|
|
123
|
+
start: { x: e.clientX, y: e.clientY },
|
|
124
|
+
origin: rect,
|
|
125
|
+
};
|
|
126
|
+
};
|
|
127
|
+
const onPointerMove = (e) => {
|
|
128
|
+
const d = drag.current;
|
|
129
|
+
if (!d || !fitted)
|
|
130
|
+
return;
|
|
131
|
+
const dx = (e.clientX - d.start.x) / scale;
|
|
132
|
+
const dy = (e.clientY - d.start.y) / scale;
|
|
133
|
+
const o = d.origin;
|
|
134
|
+
let next;
|
|
135
|
+
if (d.mode === "move") {
|
|
136
|
+
next = { ...o, x: o.x + dx, y: o.y + dy };
|
|
137
|
+
}
|
|
138
|
+
else {
|
|
139
|
+
let { x, y, width, height } = o;
|
|
140
|
+
if (d.mode.includes("e"))
|
|
141
|
+
width = o.width + dx;
|
|
142
|
+
if (d.mode.includes("s"))
|
|
143
|
+
height = o.height + dy;
|
|
144
|
+
if (d.mode.includes("w")) {
|
|
145
|
+
width = o.width - dx;
|
|
146
|
+
x = o.x + dx;
|
|
147
|
+
}
|
|
148
|
+
if (d.mode.includes("n")) {
|
|
149
|
+
height = o.height - dy;
|
|
150
|
+
y = o.y + dy;
|
|
151
|
+
}
|
|
152
|
+
if (aspect) {
|
|
153
|
+
// Drive the locked dimension from whichever one the handle moves most.
|
|
154
|
+
const horizontal = d.mode === "e" || d.mode === "w";
|
|
155
|
+
const vertical = d.mode === "n" || d.mode === "s";
|
|
156
|
+
if (vertical || (!horizontal && Math.abs(dy) > Math.abs(dx)))
|
|
157
|
+
width = height * aspect;
|
|
158
|
+
else
|
|
159
|
+
height = width / aspect;
|
|
160
|
+
if (d.mode.includes("w"))
|
|
161
|
+
x = o.x + o.width - width;
|
|
162
|
+
if (d.mode.includes("n"))
|
|
163
|
+
y = o.y + o.height - height;
|
|
164
|
+
}
|
|
165
|
+
next = {
|
|
166
|
+
x,
|
|
167
|
+
y,
|
|
168
|
+
width: Math.max(minSize, width),
|
|
169
|
+
height: Math.max(minSize, height),
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
onChange(normalizeCrop(next, fitted.natural, aspect, minSize));
|
|
173
|
+
};
|
|
174
|
+
const onPointerUp = () => {
|
|
175
|
+
drag.current = null;
|
|
176
|
+
};
|
|
177
|
+
const onKeyDown = (e) => {
|
|
178
|
+
if (!rect || !fitted)
|
|
179
|
+
return;
|
|
180
|
+
const step = e.shiftKey ? 10 : 1;
|
|
181
|
+
const moves = {
|
|
182
|
+
ArrowLeft: [-step, 0],
|
|
183
|
+
ArrowRight: [step, 0],
|
|
184
|
+
ArrowUp: [0, -step],
|
|
185
|
+
ArrowDown: [0, step],
|
|
186
|
+
};
|
|
187
|
+
const m = moves[e.key];
|
|
188
|
+
if (!m)
|
|
189
|
+
return;
|
|
190
|
+
e.preventDefault();
|
|
191
|
+
onChange(normalizeCrop({ ...rect, x: rect.x + m[0], y: rect.y + m[1] }, fitted.natural, aspect, minSize));
|
|
192
|
+
};
|
|
193
|
+
if (!src)
|
|
194
|
+
return (_jsxs("div", { className: "v-image-tool__empty", children: ["Choose an image in \u201C", field.image, "\u201D first."] }));
|
|
195
|
+
if (error)
|
|
196
|
+
return (_jsx("div", { className: "v-image-tool__empty", children: "The image could not be loaded." }));
|
|
197
|
+
const disp = rect && fitted
|
|
198
|
+
? {
|
|
199
|
+
left: rect.x * scale,
|
|
200
|
+
top: rect.y * scale,
|
|
201
|
+
width: rect.width * scale,
|
|
202
|
+
height: rect.height * scale,
|
|
203
|
+
}
|
|
204
|
+
: null;
|
|
205
|
+
return (_jsxs("div", { className: "v-image-tool", children: [_jsxs("div", { ref: containerRef, className: "v-image-tool__stage", children: [fitted && (_jsxs("div", { className: "v-crop", style: {
|
|
206
|
+
width: fitted.display.width,
|
|
207
|
+
height: fitted.display.height,
|
|
208
|
+
}, onPointerMove: onPointerMove, onPointerUp: onPointerUp, onPointerCancel: onPointerUp, children: [_jsx("img", { src: src, alt: "", draggable: false, style: {
|
|
209
|
+
width: fitted.display.width,
|
|
210
|
+
height: fitted.display.height,
|
|
211
|
+
} }), disp && (_jsxs(_Fragment, { children: [_jsx("div", { className: "v-crop__shade", style: { top: 0, left: 0, right: 0, height: disp.top } }), _jsx("div", { className: "v-crop__shade", style: {
|
|
212
|
+
top: disp.top + disp.height,
|
|
213
|
+
left: 0,
|
|
214
|
+
right: 0,
|
|
215
|
+
bottom: 0,
|
|
216
|
+
} }), _jsx("div", { className: "v-crop__shade", style: {
|
|
217
|
+
top: disp.top,
|
|
218
|
+
left: 0,
|
|
219
|
+
width: disp.left,
|
|
220
|
+
height: disp.height,
|
|
221
|
+
} }), _jsx("div", { className: "v-crop__shade", style: {
|
|
222
|
+
top: disp.top,
|
|
223
|
+
left: disp.left + disp.width,
|
|
224
|
+
right: 0,
|
|
225
|
+
height: disp.height,
|
|
226
|
+
} }), _jsxs("div", { className: "v-crop__box", style: {
|
|
227
|
+
left: disp.left,
|
|
228
|
+
top: disp.top,
|
|
229
|
+
width: disp.width,
|
|
230
|
+
height: disp.height,
|
|
231
|
+
}, tabIndex: 0, role: "slider", "aria-label": "Crop area", onPointerDown: onPointerDown("move"), onMouseDown: preventSelection, onKeyDown: onKeyDown, children: [_jsx("div", { className: "v-crop__grid" }), HANDLES.map((h) => (_jsx("div", { className: cx("v-crop__handle", `v-crop__handle--${h}`), onPointerDown: onPointerDown(h), onMouseDown: preventSelection }, h)))] })] }))] })), !fitted && _jsx("div", { className: "v-image-tool__loading", children: "Loading image\u2026" })] }), _jsxs("div", { className: "v-image-tool__bar", children: [_jsxs("span", { className: "v-mono v-muted", children: [rect
|
|
232
|
+
? `${rect.width} × ${rect.height} px at ${rect.x}, ${rect.y}`
|
|
233
|
+
: "—", fitted &&
|
|
234
|
+
` · image ${fitted.natural.width} × ${fitted.natural.height}`, aspect && ` · ${formatAspect(aspect)}`] }), _jsx("span", { className: "v-grow" }), _jsxs(Button, { size: "xs", onClick: () => fitted && onChange(defaultCrop(fitted.natural, aspect)), disabled: !fitted, title: "Largest crop, centred", children: [_jsx(Maximize2, {}), " Fit"] })] })] }));
|
|
235
|
+
}
|
|
236
|
+
function isCropRect(v) {
|
|
237
|
+
return (typeof v === "object" &&
|
|
238
|
+
v !== null &&
|
|
239
|
+
["x", "y", "width", "height"].every((k) => typeof v[k] === "number"));
|
|
240
|
+
}
|
|
241
|
+
function formatAspect(aspect) {
|
|
242
|
+
for (const [w, h] of [
|
|
243
|
+
[1, 1],
|
|
244
|
+
[4, 3],
|
|
245
|
+
[3, 4],
|
|
246
|
+
[3, 2],
|
|
247
|
+
[2, 3],
|
|
248
|
+
[16, 9],
|
|
249
|
+
[9, 16],
|
|
250
|
+
[8, 9],
|
|
251
|
+
[21, 9],
|
|
252
|
+
[1.91, 1],
|
|
253
|
+
]) {
|
|
254
|
+
if (Math.abs(w / h - aspect) < 0.005)
|
|
255
|
+
return `${w}:${h}`;
|
|
256
|
+
}
|
|
257
|
+
return `${aspect.toFixed(3)}:1`;
|
|
258
|
+
}
|
|
259
|
+
export function FocalPointInput({ field, value, onChange, siblings, pendingUploads, }) {
|
|
260
|
+
const src = useImageSource(siblings?.[field.image], pendingUploads);
|
|
261
|
+
const { containerRef, fitted, error } = useFittedImage(src, 360);
|
|
262
|
+
const point = isFocalPoint(value) ? value : { x: 50, y: 50 };
|
|
263
|
+
const dragging = useRef(false);
|
|
264
|
+
const setFromEvent = useCallback((e) => {
|
|
265
|
+
const r = e.currentTarget.getBoundingClientRect();
|
|
266
|
+
const x = clamp(((e.clientX - r.left) / r.width) * 100, 0, 100);
|
|
267
|
+
const y = clamp(((e.clientY - r.top) / r.height) * 100, 0, 100);
|
|
268
|
+
onChange({ x: Math.round(x * 10) / 10, y: Math.round(y * 10) / 10 });
|
|
269
|
+
}, [onChange]);
|
|
270
|
+
if (!src)
|
|
271
|
+
return (_jsxs("div", { className: "v-image-tool__empty", children: ["Choose an image in \u201C", field.image, "\u201D first."] }));
|
|
272
|
+
if (error)
|
|
273
|
+
return (_jsx("div", { className: "v-image-tool__empty", children: "The image could not be loaded." }));
|
|
274
|
+
const previews = field.previews ?? ["16:9", "1:1", "3:4"];
|
|
275
|
+
const position = `${point.x}% ${point.y}%`;
|
|
276
|
+
return (_jsxs("div", { className: "v-image-tool", children: [_jsx("div", { ref: containerRef, className: "v-image-tool__stage", children: fitted ? (_jsxs("div", { className: "v-focal", style: {
|
|
277
|
+
width: fitted.display.width,
|
|
278
|
+
height: fitted.display.height,
|
|
279
|
+
}, onMouseDown: preventSelection, onPointerDown: (e) => {
|
|
280
|
+
dragging.current = true;
|
|
281
|
+
e.currentTarget.setPointerCapture(e.pointerId);
|
|
282
|
+
setFromEvent(e);
|
|
283
|
+
}, onPointerMove: (e) => dragging.current && setFromEvent(e), onPointerUp: () => (dragging.current = false), onPointerCancel: () => (dragging.current = false), children: [_jsx("img", { src: src, alt: "", draggable: false, style: {
|
|
284
|
+
width: fitted.display.width,
|
|
285
|
+
height: fitted.display.height,
|
|
286
|
+
} }), _jsx("div", { className: "v-focal__line v-focal__line--v", style: { left: `${point.x}%` } }), _jsx("div", { className: "v-focal__line v-focal__line--h", style: { top: `${point.y}%` } }), _jsx("div", { className: "v-focal__marker", style: { left: `${point.x}%`, top: `${point.y}%` }, role: "img", "aria-label": `Focal point at ${point.x}%, ${point.y}%`, children: _jsx(Crosshair, {}) })] })) : (_jsx("div", { className: "v-image-tool__loading", children: "Loading image\u2026" })) }), _jsxs("div", { className: "v-image-tool__bar", children: [_jsxs("span", { className: "v-mono v-muted", children: [point.x, "% ", point.y, "%"] }), _jsx("div", { className: "v-focal__previews", children: previews.map((p) => {
|
|
287
|
+
const [w, h] = p.split(":").map(Number);
|
|
288
|
+
return (_jsx("div", { className: "v-focal__preview", style: { aspectRatio: `${w} / ${h}` }, title: `${p} preview`, children: _jsx("img", { src: src, alt: "", style: { objectPosition: position } }) }, p));
|
|
289
|
+
}) }), _jsx("span", { className: "v-grow" }), _jsxs(Button, { size: "xs", onClick: () => onChange({ x: 50, y: 50 }), title: "Back to centre", children: [_jsx(RotateCcw, {}), " Centre"] })] })] }));
|
|
290
|
+
}
|
|
291
|
+
function isFocalPoint(v) {
|
|
292
|
+
return (typeof v === "object" &&
|
|
293
|
+
v !== null &&
|
|
294
|
+
typeof v.x === "number" &&
|
|
295
|
+
typeof v.y === "number");
|
|
296
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/** A Markdown-valued frontmatter field (e.g. a hero tagline) edited with the rich text editor. */
|
|
2
|
+
export declare function MarkdownField({ value, onChange, components, placeholder, }: {
|
|
3
|
+
value: string;
|
|
4
|
+
onChange: (v: string) => void;
|
|
5
|
+
components?: string[];
|
|
6
|
+
placeholder?: string;
|
|
7
|
+
}): import("react").JSX.Element;
|