smoodly 0.0.12 → 0.0.14
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 +294 -40
- package/dist/admin/editor/CanvasOverlay.d.ts +6 -2
- package/dist/admin/editor/CanvasOverlay.js +9 -11
- package/dist/admin/editor/EditorView.js +135 -28
- package/dist/admin/editor/FloatingToolbar.d.ts +16 -0
- package/dist/admin/editor/FloatingToolbar.js +14 -0
- package/dist/admin/editor/InlineRichtext.d.ts +9 -0
- package/dist/admin/editor/InlineRichtext.js +50 -0
- package/dist/admin/editor/Outline.d.ts +16 -10
- package/dist/admin/editor/Outline.js +13 -10
- package/dist/admin/editor/PageInspector.d.ts +21 -0
- package/dist/admin/editor/PageInspector.js +24 -0
- package/dist/admin/editor/PageSettings.d.ts +1 -3
- package/dist/admin/editor/PageSettings.js +7 -6
- package/dist/admin/editor/SeoSettings.d.ts +7 -0
- package/dist/admin/editor/SeoSettings.js +15 -0
- package/dist/admin/editor/inline-dom.d.ts +27 -0
- package/dist/admin/editor/inline-dom.js +43 -0
- package/dist/admin/editor/inline-session.d.ts +30 -0
- package/dist/admin/editor/inline-session.js +19 -0
- package/dist/admin/editor/protocol.d.ts +22 -1
- package/dist/admin/editor/useCanvasBridge.d.ts +8 -4
- package/dist/admin/editor/useCanvasBridge.js +12 -7
- package/dist/admin/editor/useInlineSession.d.ts +46 -0
- package/dist/admin/editor/useInlineSession.js +244 -0
- package/dist/admin/field-paths.d.ts +13 -0
- package/dist/admin/field-paths.js +49 -0
- package/dist/admin/forms/FieldWidget.d.ts +7 -2
- package/dist/admin/forms/FieldWidget.js +35 -43
- package/dist/admin/forms/ListField.d.ts +10 -0
- package/dist/admin/forms/ListField.js +42 -0
- package/dist/admin/forms/MediaWidget.js +10 -3
- package/dist/admin/forms/RichtextElementCard.d.ts +3 -0
- package/dist/admin/forms/RichtextElementCard.js +37 -0
- package/dist/admin/forms/RichtextImageCard.d.ts +3 -0
- package/dist/admin/forms/RichtextImageCard.js +42 -0
- package/dist/admin/forms/RichtextInsertMenu.d.ts +6 -0
- package/dist/admin/forms/RichtextInsertMenu.js +6 -0
- package/dist/admin/forms/RichtextPopovers.d.ts +7 -0
- package/dist/admin/forms/RichtextPopovers.js +25 -0
- package/dist/admin/forms/RichtextToolbar.d.ts +4 -2
- package/dist/admin/forms/RichtextToolbar.js +4 -2
- package/dist/admin/forms/RichtextWidget.d.ts +4 -1
- package/dist/admin/forms/RichtextWidget.js +23 -120
- package/dist/admin/forms/elements-context.d.ts +7 -0
- package/dist/admin/forms/elements-context.js +13 -0
- package/dist/admin/forms/reveal.d.ts +5 -0
- package/dist/admin/forms/reveal.js +47 -0
- package/dist/admin/forms/richtext-extensions.d.ts +8 -0
- package/dist/admin/forms/richtext-extensions.js +11 -0
- package/dist/admin/forms/richtext-form-context.d.ts +16 -0
- package/dist/admin/forms/richtext-form-context.js +18 -0
- package/dist/admin/forms/richtext-insert.d.ts +38 -0
- package/dist/admin/forms/richtext-insert.js +37 -0
- package/dist/admin/forms/richtext-nodes.d.ts +37 -0
- package/dist/admin/forms/richtext-nodes.js +85 -0
- package/dist/admin/forms/richtext-paste.js +5 -0
- package/dist/admin/forms/tabs.d.ts +3 -0
- package/dist/admin/forms/tabs.js +6 -0
- package/dist/admin/forms/useRichtextEditor.d.ts +51 -0
- package/dist/admin/forms/useRichtextEditor.js +281 -0
- package/dist/admin/next/bridge.js +51 -20
- package/dist/admin/ops-impl.js +85 -19
- package/dist/admin/richtext-doc.d.ts +3 -0
- package/dist/admin/richtext-doc.js +12 -0
- package/dist/admin/serialize.d.ts +11 -0
- package/dist/admin/serialize.js +18 -1
- package/dist/admin/shell/AdminApp.js +2 -1
- package/dist/admin/shell/EntriesList.js +34 -12
- package/dist/admin/shell/EntryForm.js +22 -15
- package/dist/admin/shell/IconRail.js +6 -5
- package/dist/admin/shell/ListView.d.ts +9 -2
- package/dist/admin/shell/ListView.js +7 -6
- package/dist/admin/shell/LoginView.js +2 -2
- package/dist/admin/shell/PagesList.js +53 -24
- package/dist/admin/shell/SharedForm.js +17 -11
- package/dist/admin/shell/SharedList.js +5 -3
- package/dist/admin/shell/entries-list.d.ts +4 -0
- package/dist/admin/shell/entries-list.js +6 -0
- package/dist/admin/shell/pages-tree.d.ts +24 -0
- package/dist/admin/shell/pages-tree.js +59 -1
- package/dist/admin/tree-ops.d.ts +11 -1
- package/dist/admin/tree-ops.js +32 -1
- package/dist/admin/ui/LocaleSwitcher.d.ts +10 -0
- package/dist/admin/ui/LocaleSwitcher.js +28 -8
- package/dist/admin/ui/Modal.js +14 -2
- package/dist/admin/ui/ResizeHandle.d.ts +1 -0
- package/dist/admin/ui/ResizeHandle.js +127 -0
- package/dist/admin/ui/primitives.d.ts +38 -4
- package/dist/admin/ui/primitives.js +35 -10
- package/dist/admin/ui/resize.d.ts +16 -0
- package/dist/admin/ui/resize.js +23 -0
- package/dist/admin/ui/sortable.d.ts +23 -0
- package/dist/admin/ui/sortable.js +57 -0
- package/dist/admin/ui/theme.d.ts +3 -1
- package/dist/admin/ui/theme.js +527 -165
- package/dist/admin/ui/useSortable.d.ts +35 -0
- package/dist/admin/ui/useSortable.js +220 -0
- package/dist/admin/validate.d.ts +6 -1
- package/dist/admin/validate.js +52 -12
- package/dist/collections.d.ts +6 -5
- package/dist/config.d.ts +1 -0
- package/dist/config.js +36 -0
- package/dist/entry-store.d.ts +23 -3
- package/dist/entry-store.js +31 -5
- package/dist/field-defaults.d.ts +8 -0
- package/dist/field-defaults.js +18 -0
- package/dist/fields.d.ts +30 -1
- package/dist/fields.js +33 -3
- package/dist/image/SmoodlyImage.d.ts +4 -1
- package/dist/image/SmoodlyImage.js +2 -2
- package/dist/index.d.ts +16 -7
- package/dist/index.js +10 -4
- package/dist/marks.d.ts +53 -0
- package/dist/marks.js +43 -0
- package/dist/media.d.ts +3 -2
- package/dist/media.js +48 -6
- package/dist/next/index.d.ts +2 -0
- package/dist/next/index.js +1 -0
- package/dist/next/meta.d.ts +7 -3
- package/dist/next/meta.js +18 -9
- package/dist/next/page-renderer.js +1 -1
- package/dist/next/queries.d.ts +36 -0
- package/dist/next/queries.js +87 -0
- package/dist/page-tree.d.ts +3 -0
- package/dist/page-tree.js +6 -0
- package/dist/page.d.ts +11 -3
- package/dist/page.js +26 -0
- package/dist/pairing.d.ts +2 -1
- package/dist/pairing.js +2 -9
- package/dist/refs.d.ts +2 -2
- package/dist/refs.js +33 -12
- package/dist/render.d.ts +14 -7
- package/dist/render.js +4 -3
- package/dist/resolve.d.ts +12 -2
- package/dist/resolve.js +16 -7
- package/dist/revalidate.d.ts +6 -0
- package/dist/revalidate.js +6 -0
- package/dist/richtext/index.d.ts +12 -0
- package/dist/richtext/index.js +9 -0
- package/dist/richtext-render.d.ts +22 -2
- package/dist/richtext-render.js +40 -13
- package/dist/richtext-walk.d.ts +44 -0
- package/dist/richtext-walk.js +46 -0
- package/dist/schema.d.ts +1 -1
- package/dist/schema.js +13 -1
- package/dist/section-wrapper.js +2 -1
- package/dist/shared.d.ts +7 -7
- package/dist/site.d.ts +25 -1
- package/dist/site.js +89 -22
- package/dist/store.d.ts +7 -1
- package/dist/store.js +13 -4
- package/dist/supabase-entry-store.d.ts +1 -0
- package/dist/supabase-entry-store.js +34 -11
- package/dist/supabase-store.d.ts +1 -0
- package/dist/supabase-store.js +14 -1
- package/dist/toolset.d.ts +7 -1
- package/dist/toolset.js +13 -4
- package/package.json +6 -1
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
// The richtext editor behind both mounts (widget spec 2026-09-08 §3;
|
|
3
|
+
// inline editing spec 2026-09-10 §5): the TipTap instance over the full
|
|
4
|
+
// schema with the toolset gating creation, the toolbar's active state,
|
|
5
|
+
// the link popover, the "+" menu, the slash menu and the image picker.
|
|
6
|
+
// The sidebar widget and the canvas editor render this state in their own
|
|
7
|
+
// layouts. The editor owns its document and emits TipTap JSON on every
|
|
8
|
+
// change; the parent stores the emitted document by reference, so a value
|
|
9
|
+
// that is not the last emitted document is a change from outside and
|
|
10
|
+
// re-seeds the editor. Block nodes render as cards, bound once through
|
|
11
|
+
// setNodeViews.
|
|
12
|
+
import { useEffect, useMemo, useRef, useState } from "react";
|
|
13
|
+
import { useEditor, useEditorState } from "@tiptap/react";
|
|
14
|
+
import { Slice } from "@tiptap/pm/model";
|
|
15
|
+
import { allowedOf } from "../../toolset.js";
|
|
16
|
+
import { plainDoc } from "../richtext-doc.js";
|
|
17
|
+
import { richtextExtensions } from "./richtext-extensions.js";
|
|
18
|
+
import { gatePastedSlice } from "./richtext-paste.js";
|
|
19
|
+
import { INACTIVE } from "./RichtextToolbar.js";
|
|
20
|
+
import { elementNode, filterItems, imageNode, insertItems } from "./richtext-insert.js";
|
|
21
|
+
import { insertBlock, setNodeViews } from "./richtext-nodes.js";
|
|
22
|
+
import { RichtextImageCard } from "./RichtextImageCard.js";
|
|
23
|
+
import { RichtextElementCard } from "./RichtextElementCard.js";
|
|
24
|
+
import { useElements } from "./elements-context.js";
|
|
25
|
+
import { useAssets } from "./assets-context.js";
|
|
26
|
+
setNodeViews({ image: RichtextImageCard, element: RichtextElementCard });
|
|
27
|
+
const EMPTY = { type: "doc", content: [{ type: "paragraph" }] };
|
|
28
|
+
const LEVELS = [1, 2, 3, 4, 5, 6];
|
|
29
|
+
const isDoc = (v) => !!v && typeof v === "object" && v.type === "doc";
|
|
30
|
+
export function useRichtextEditor({ value, toolset, onChange, contentClass, onEscape }) {
|
|
31
|
+
const allowed = useMemo(() => allowedOf(toolset), [toolset]);
|
|
32
|
+
const elements = useElements();
|
|
33
|
+
const assets = useAssets();
|
|
34
|
+
const items = useMemo(() => insertItems(allowed, elements), [allowed, elements]);
|
|
35
|
+
// The parent's callbacks close over its render; the editor's handlers are
|
|
36
|
+
// created once, so read the latest through refs.
|
|
37
|
+
const onChangeRef = useRef(onChange);
|
|
38
|
+
onChangeRef.current = onChange;
|
|
39
|
+
const onEscapeRef = useRef(onEscape);
|
|
40
|
+
onEscapeRef.current = onEscape;
|
|
41
|
+
// The last document this editor emitted — anything else arriving as
|
|
42
|
+
// `value` came from outside and re-seeds the editor.
|
|
43
|
+
const emitted = useRef(value);
|
|
44
|
+
const [link, setLink] = useState({ open: false, href: "" });
|
|
45
|
+
const openLinkRef = useRef(() => { });
|
|
46
|
+
// The "+" popover, the slash menu and the image picker.
|
|
47
|
+
const [menu, setMenu] = useState({ open: false, query: "", active: 0 });
|
|
48
|
+
const [slash, setSlash] = useState(null);
|
|
49
|
+
const [slashActive, setSlashActive] = useState(0);
|
|
50
|
+
const slashRef = useRef({ state: null, active: 0, dismissed: false });
|
|
51
|
+
const [picker, setPicker] = useState(false);
|
|
52
|
+
// The slash extension is created once; it reaches this render's insert through the ref.
|
|
53
|
+
const insertRef = useRef(() => { });
|
|
54
|
+
// Where the next insert goes: recorded when the "+" menu opens (the menu
|
|
55
|
+
// and the picker take focus), read back at pick time. Null = the caret.
|
|
56
|
+
const anchorRef = useRef(null);
|
|
57
|
+
const editor = useEditor({
|
|
58
|
+
immediatelyRender: false,
|
|
59
|
+
extensions: richtextExtensions(allowed, {
|
|
60
|
+
onLink: () => openLinkRef.current(),
|
|
61
|
+
slash: {
|
|
62
|
+
elements,
|
|
63
|
+
onState: (state) => {
|
|
64
|
+
const r = slashRef.current;
|
|
65
|
+
if (state === null) {
|
|
66
|
+
r.dismissed = false;
|
|
67
|
+
r.state = null;
|
|
68
|
+
setSlash(null);
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
if (r.state === null) {
|
|
72
|
+
r.dismissed = false;
|
|
73
|
+
r.active = 0;
|
|
74
|
+
}
|
|
75
|
+
r.state = state;
|
|
76
|
+
r.active = Math.min(r.active, Math.max(0, state.items.length - 1));
|
|
77
|
+
setSlashActive(r.active);
|
|
78
|
+
setSlash(r.dismissed ? null : state);
|
|
79
|
+
},
|
|
80
|
+
onKeyDown: (event) => {
|
|
81
|
+
const r = slashRef.current;
|
|
82
|
+
if (!r.state || r.dismissed)
|
|
83
|
+
return false;
|
|
84
|
+
const n = Math.max(1, r.state.items.length);
|
|
85
|
+
if (event.key === "Escape") {
|
|
86
|
+
r.dismissed = true;
|
|
87
|
+
setSlash(null);
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
if (event.key === "ArrowDown") {
|
|
91
|
+
r.active = (r.active + 1) % n;
|
|
92
|
+
setSlashActive(r.active);
|
|
93
|
+
return true;
|
|
94
|
+
}
|
|
95
|
+
if (event.key === "ArrowUp") {
|
|
96
|
+
r.active = (r.active - 1 + n) % n;
|
|
97
|
+
setSlashActive(r.active);
|
|
98
|
+
return true;
|
|
99
|
+
}
|
|
100
|
+
if (event.key === "Enter") {
|
|
101
|
+
const item = r.state.items[r.active];
|
|
102
|
+
if (item)
|
|
103
|
+
r.state.pick(item);
|
|
104
|
+
return true;
|
|
105
|
+
}
|
|
106
|
+
return false;
|
|
107
|
+
},
|
|
108
|
+
onPick: (item) => insertRef.current(item),
|
|
109
|
+
},
|
|
110
|
+
}),
|
|
111
|
+
content: isDoc(value) ? value : EMPTY,
|
|
112
|
+
editorProps: {
|
|
113
|
+
attributes: { class: contentClass },
|
|
114
|
+
transformPasted: (slice, view) => Slice.fromJSON(view.state.schema, gatePastedSlice(slice.toJSON(), allowed)),
|
|
115
|
+
// Direct editor props run before plugin props, so leave Escape to
|
|
116
|
+
// the slash plugin while its menu is up; otherwise the canvas editor
|
|
117
|
+
// ends its session on it.
|
|
118
|
+
handleKeyDown: (_view, event) => {
|
|
119
|
+
if (event.key !== "Escape" || !onEscapeRef.current)
|
|
120
|
+
return false;
|
|
121
|
+
const r = slashRef.current;
|
|
122
|
+
if (r.state && !r.dismissed)
|
|
123
|
+
return false;
|
|
124
|
+
onEscapeRef.current();
|
|
125
|
+
return true;
|
|
126
|
+
},
|
|
127
|
+
},
|
|
128
|
+
onUpdate: ({ editor }) => {
|
|
129
|
+
const doc = plainDoc(editor.getJSON());
|
|
130
|
+
emitted.current = doc;
|
|
131
|
+
onChangeRef.current(doc);
|
|
132
|
+
},
|
|
133
|
+
}, [allowed, elements]);
|
|
134
|
+
useEffect(() => {
|
|
135
|
+
if (!editor || value === emitted.current)
|
|
136
|
+
return;
|
|
137
|
+
emitted.current = value;
|
|
138
|
+
editor.commands.setContent(isDoc(value) ? value : EMPTY, { emitUpdate: false });
|
|
139
|
+
}, [editor, value]);
|
|
140
|
+
const active = useEditorState({
|
|
141
|
+
editor,
|
|
142
|
+
selector: ({ editor }) => editor
|
|
143
|
+
? {
|
|
144
|
+
bold: editor.isActive("bold"),
|
|
145
|
+
italic: editor.isActive("italic"),
|
|
146
|
+
link: editor.isActive("link"),
|
|
147
|
+
bulletList: editor.isActive("bulletList"),
|
|
148
|
+
orderedList: editor.isActive("orderedList"),
|
|
149
|
+
blockquote: editor.isActive("blockquote"),
|
|
150
|
+
heading: LEVELS.find((level) => editor.isActive("heading", { level })) ?? null,
|
|
151
|
+
}
|
|
152
|
+
: INACTIVE,
|
|
153
|
+
});
|
|
154
|
+
/** One insert path for the "+" menu and the slash menu. An image needs the picker first. */
|
|
155
|
+
const insert = (item) => {
|
|
156
|
+
setMenu({ open: false, query: "", active: 0 });
|
|
157
|
+
if (!editor)
|
|
158
|
+
return;
|
|
159
|
+
// The "+" menu recorded its anchor; the slash command runs at the caret (its "/" just deleted).
|
|
160
|
+
const at = anchorRef.current ?? editor.state.selection.to;
|
|
161
|
+
if (item.kind === "image") {
|
|
162
|
+
anchorRef.current = at;
|
|
163
|
+
setPicker(true);
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
anchorRef.current = null;
|
|
167
|
+
insertBlock(editor, elementNode(item.element), at);
|
|
168
|
+
};
|
|
169
|
+
insertRef.current = insert;
|
|
170
|
+
const pickImage = (record) => {
|
|
171
|
+
assets.remember(record);
|
|
172
|
+
setPicker(false);
|
|
173
|
+
const at = anchorRef.current ?? undefined;
|
|
174
|
+
anchorRef.current = null;
|
|
175
|
+
if (editor)
|
|
176
|
+
insertBlock(editor, imageNode(record.id), at);
|
|
177
|
+
};
|
|
178
|
+
const closePicker = () => {
|
|
179
|
+
setPicker(false);
|
|
180
|
+
const at = anchorRef.current ?? undefined;
|
|
181
|
+
anchorRef.current = null;
|
|
182
|
+
editor?.commands.focus(at);
|
|
183
|
+
};
|
|
184
|
+
// Read the caret's link from the editor at open time, not from the last
|
|
185
|
+
// render: a selection change and the press can land in the same tick.
|
|
186
|
+
const openLink = () => setLink({ open: true, href: editor?.getAttributes("link").href ?? "" });
|
|
187
|
+
openLinkRef.current = openLink;
|
|
188
|
+
const closeLink = () => {
|
|
189
|
+
setLink({ open: false, href: "" });
|
|
190
|
+
editor?.commands.focus();
|
|
191
|
+
};
|
|
192
|
+
const applyLink = () => {
|
|
193
|
+
if (!editor)
|
|
194
|
+
return;
|
|
195
|
+
const href = link.href.trim();
|
|
196
|
+
const chain = editor.chain().focus().extendMarkRange("link");
|
|
197
|
+
if (href === "")
|
|
198
|
+
chain.unsetLink().run();
|
|
199
|
+
else
|
|
200
|
+
chain.setLink({ href }).run();
|
|
201
|
+
setLink({ open: false, href: "" });
|
|
202
|
+
};
|
|
203
|
+
const removeLink = () => {
|
|
204
|
+
editor?.chain().focus().extendMarkRange("link").unsetLink().run();
|
|
205
|
+
setLink({ open: false, href: "" });
|
|
206
|
+
};
|
|
207
|
+
const run = (command) => {
|
|
208
|
+
if (!editor)
|
|
209
|
+
return;
|
|
210
|
+
if (command === "link") {
|
|
211
|
+
openLink();
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
if (command === "insert") {
|
|
215
|
+
anchorRef.current = menu.open ? null : editor.state.selection.to;
|
|
216
|
+
setMenu((m) => ({ open: !m.open, query: "", active: 0 }));
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
const chain = editor.chain().focus();
|
|
220
|
+
if (typeof command === "object")
|
|
221
|
+
chain.toggleHeading({ level: command.heading }).run();
|
|
222
|
+
else if (command === "bold")
|
|
223
|
+
chain.toggleBold().run();
|
|
224
|
+
else if (command === "italic")
|
|
225
|
+
chain.toggleItalic().run();
|
|
226
|
+
else if (command === "bulletList")
|
|
227
|
+
chain.toggleBulletList().run();
|
|
228
|
+
else if (command === "orderedList")
|
|
229
|
+
chain.toggleOrderedList().run();
|
|
230
|
+
else if (command === "blockquote")
|
|
231
|
+
chain.toggleBlockquote().run();
|
|
232
|
+
};
|
|
233
|
+
const menuItems = filterItems(items, menu.query);
|
|
234
|
+
const closeMenu = () => {
|
|
235
|
+
setMenu({ open: false, query: "", active: 0 });
|
|
236
|
+
const at = anchorRef.current ?? undefined;
|
|
237
|
+
anchorRef.current = null;
|
|
238
|
+
editor?.commands.focus(at);
|
|
239
|
+
};
|
|
240
|
+
// The menu floats over the content, so focus leaving it (a click
|
|
241
|
+
// anywhere else) closes it — without stealing focus back to the editor.
|
|
242
|
+
const dismissMenu = (e) => {
|
|
243
|
+
if (e.currentTarget.contains(e.relatedTarget))
|
|
244
|
+
return;
|
|
245
|
+
setMenu({ open: false, query: "", active: 0 });
|
|
246
|
+
anchorRef.current = null;
|
|
247
|
+
};
|
|
248
|
+
const onMenuKeyDown = (e) => {
|
|
249
|
+
const n = Math.max(1, menuItems.length);
|
|
250
|
+
if (e.key === "Escape") {
|
|
251
|
+
e.preventDefault();
|
|
252
|
+
e.stopPropagation();
|
|
253
|
+
closeMenu();
|
|
254
|
+
}
|
|
255
|
+
if (e.key === "ArrowDown") {
|
|
256
|
+
e.preventDefault();
|
|
257
|
+
setMenu((m) => ({ ...m, active: (m.active + 1) % n }));
|
|
258
|
+
}
|
|
259
|
+
if (e.key === "ArrowUp") {
|
|
260
|
+
e.preventDefault();
|
|
261
|
+
setMenu((m) => ({ ...m, active: (m.active - 1 + n) % n }));
|
|
262
|
+
}
|
|
263
|
+
if (e.key === "Enter") {
|
|
264
|
+
e.preventDefault();
|
|
265
|
+
const item = menuItems[menu.active];
|
|
266
|
+
if (item)
|
|
267
|
+
insert(item);
|
|
268
|
+
}
|
|
269
|
+
};
|
|
270
|
+
return {
|
|
271
|
+
editor, allowed, active: active ?? INACTIVE, canInsert: items.length > 0, run,
|
|
272
|
+
link: { open: link.open, href: link.href, setHref: (href) => setLink({ open: true, href }), apply: applyLink, remove: removeLink, close: closeLink },
|
|
273
|
+
menu: {
|
|
274
|
+
open: menu.open, query: menu.query, active: menu.active, items: menuItems,
|
|
275
|
+
setQuery: (query) => setMenu({ open: true, query, active: 0 }), onKeyDown: onMenuKeyDown,
|
|
276
|
+
pick: insert, close: closeMenu, dismiss: dismissMenu,
|
|
277
|
+
},
|
|
278
|
+
slash: slash ? { state: slash, active: slashActive } : null,
|
|
279
|
+
picker: { open: picker, pick: pickImage, close: closePicker },
|
|
280
|
+
};
|
|
281
|
+
}
|
|
@@ -1,41 +1,72 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
// Runs INSIDE the draft-route iframe (glue adds it only when draft mode +
|
|
3
|
-
// the editor param are on). Reports geometry,
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
3
|
+
// the editor param are on). Reports geometry — section nodes, empty zones
|
|
4
|
+
// and, since spec 2026-09-10, every marked field with its resolved path —
|
|
5
|
+
// forwards select/hover with the field under the pointer, executes
|
|
6
|
+
// refresh/scroll, and says when a render has committed. The admin overlay
|
|
7
|
+
// draws all chrome. Rects are in document coordinates: the iframe is sized
|
|
8
|
+
// to the full document height by the editor, so client rects + scroll
|
|
9
|
+
// offsets map 1:1 onto the overlay.
|
|
7
10
|
import { useEffect } from "react";
|
|
8
11
|
import { useRouter } from "next/navigation";
|
|
12
|
+
import { EDITOR_ATTR, FIELD_ATTR, NODE_ATTR, ZONE_ATTR, resolveMarkPath } from "../../marks.js";
|
|
13
|
+
const post = (m) => window.parent.postMessage(m, "*");
|
|
9
14
|
export function EditorBridge() {
|
|
10
15
|
const router = useRouter();
|
|
16
|
+
// Every commit of this component — the mount, then each RSC refresh,
|
|
17
|
+
// which re-renders the route — tells the editor the new markup is in
|
|
18
|
+
// the DOM (spec 2026-09-10 §5: the inline richtext editor lets go of
|
|
19
|
+
// its container only then).
|
|
20
|
+
useEffect(() => { post({ source: "smoodly", kind: "rendered" }); });
|
|
11
21
|
useEffect(() => {
|
|
12
|
-
const
|
|
22
|
+
const docRect = (el) => {
|
|
23
|
+
const r = el.getBoundingClientRect();
|
|
24
|
+
return { x: r.left + window.scrollX, y: r.top + window.scrollY, w: r.width, h: r.height };
|
|
25
|
+
};
|
|
26
|
+
const all = (selector) => Array.from(document.querySelectorAll(selector));
|
|
27
|
+
const inEditor = (el) => Boolean(el?.closest?.(`[${EDITOR_ATTR}]`));
|
|
13
28
|
const report = () => {
|
|
14
29
|
// Array.from, not spread: the package's lib set is ES2020 + DOM
|
|
15
30
|
// (no DOM.Iterable), so a NodeList isn't statically iterable here.
|
|
16
|
-
const nodes =
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
})
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
31
|
+
const nodes = all(`[${NODE_ATTR}]`).map((el) => ({ id: el.getAttribute(NODE_ATTR), ...docRect(el) }));
|
|
32
|
+
const emptyZones = all(`[${ZONE_ATTR}]`).map((el) => ({ zone: el.getAttribute(ZONE_ATTR), ...docRect(el) }));
|
|
33
|
+
const fields = [];
|
|
34
|
+
for (const el of all(`[${FIELD_ATTR}]`)) {
|
|
35
|
+
// A mark inside the admin's own editor root (a nested element card) is not the page's.
|
|
36
|
+
if (inEditor(el))
|
|
37
|
+
continue;
|
|
38
|
+
const mark = resolveMarkPath(el);
|
|
39
|
+
if (mark)
|
|
40
|
+
fields.push({ ...mark, ...docRect(el) });
|
|
41
|
+
}
|
|
42
|
+
post({ source: "smoodly", kind: "rects", nodes, emptyZones, fields, docH: document.documentElement.scrollHeight });
|
|
25
43
|
};
|
|
26
44
|
const raf = () => requestAnimationFrame(report);
|
|
27
45
|
const ro = new ResizeObserver(raf);
|
|
28
46
|
ro.observe(document.documentElement);
|
|
47
|
+
// characterData too: a text session edits a text node in place, and the outline must follow.
|
|
29
48
|
const mo = new MutationObserver(raf);
|
|
30
|
-
mo.observe(document.body, { subtree: true, childList: true, attributes: true });
|
|
31
|
-
const
|
|
49
|
+
mo.observe(document.body, { subtree: true, childList: true, attributes: true, characterData: true });
|
|
50
|
+
const elementOf = (target) => target;
|
|
51
|
+
const nodeIdAt = (target) => elementOf(target)?.closest?.(`[${NODE_ATTR}]`)?.getAttribute(NODE_ATTR) ?? null;
|
|
52
|
+
const pathAt = (target) => {
|
|
53
|
+
const el = elementOf(target)?.closest?.(`[${FIELD_ATTR}]`);
|
|
54
|
+
return el ? resolveMarkPath(el)?.path ?? null : null;
|
|
55
|
+
};
|
|
32
56
|
const onClick = (e) => {
|
|
33
57
|
// The canvas must never navigate away from under the editor.
|
|
34
|
-
if (e.target?.closest?.("a"))
|
|
58
|
+
if (elementOf(e.target)?.closest?.("a"))
|
|
35
59
|
e.preventDefault();
|
|
36
|
-
|
|
60
|
+
// Inside its own editor root the admin owns every click.
|
|
61
|
+
if (inEditor(elementOf(e.target)))
|
|
62
|
+
return;
|
|
63
|
+
post({ source: "smoodly", kind: "select", id: nodeIdAt(e.target), path: pathAt(e.target), x: e.clientX, y: e.clientY });
|
|
64
|
+
};
|
|
65
|
+
const onMove = (e) => {
|
|
66
|
+
if (inEditor(elementOf(e.target)))
|
|
67
|
+
return;
|
|
68
|
+
post({ source: "smoodly", kind: "hover", id: nodeIdAt(e.target), path: pathAt(e.target) });
|
|
37
69
|
};
|
|
38
|
-
const onMove = (e) => post({ source: "smoodly", kind: "hover", id: nodeIdAt(e.target) });
|
|
39
70
|
const onMessage = (e) => {
|
|
40
71
|
const m = e.data;
|
|
41
72
|
if (m?.source !== "smoodly")
|
|
@@ -44,7 +75,7 @@ export function EditorBridge() {
|
|
|
44
75
|
router.refresh();
|
|
45
76
|
if (m.kind === "scroll") {
|
|
46
77
|
document
|
|
47
|
-
.querySelector(`[
|
|
78
|
+
.querySelector(`[${NODE_ATTR}="${CSS.escape(m.id)}"]`)
|
|
48
79
|
?.scrollIntoView({ behavior: "smooth", block: "center" });
|
|
49
80
|
}
|
|
50
81
|
};
|
package/dist/admin/ops-impl.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { entryFieldsIn, entryTitleIn } from "../entry-store.js";
|
|
2
2
|
import { ASSET_COLLECTION } from "../media.js";
|
|
3
3
|
import { BASE } from "./shell/base.js";
|
|
4
|
-
import { affectedTargets, entryTag, pageTag, sharedTag } from "../revalidate.js";
|
|
4
|
+
import { affectedTargets, collectionTag, entryTag, pageTag, pagesTreeTag, sharedTag } from "../revalidate.js";
|
|
5
5
|
import { ensureFixedNodes, fixedNodeOf } from "./fixed-nodes.js";
|
|
6
6
|
import { ensureSharedItems } from "./shared-items.js";
|
|
7
7
|
import { validateFields } from "./validate.js";
|
|
8
|
+
import { elementSchemas } from "../richtext-walk.js";
|
|
8
9
|
const ok = (data) => ({ ok: true, data });
|
|
9
10
|
const err = (code, message, fields) => ({ ok: false, code, message, ...(fields ? { fields } : {}) });
|
|
10
11
|
/** Own-property, non-array plain object — same shape guard the tree checks
|
|
@@ -88,6 +89,8 @@ export function createAdminOps(deps) {
|
|
|
88
89
|
const { config, pages, entries, effects } = deps;
|
|
89
90
|
const href = deps.href ?? ((path) => path);
|
|
90
91
|
const collectionFields = (name) => config.registry.collections?.find((c) => c.name === name)?.fields ?? null;
|
|
92
|
+
// Element nodes inside richtext validate against these (spec 2026-09-08 block nodes §2).
|
|
93
|
+
const elementDescs = elementSchemas((config.registry.elements ?? []).map((e) => ({ name: e.schema.name, fields: e.schema.fields ?? {}, styles: e.schema.styles })));
|
|
91
94
|
const sectionFields = (type) => config.registry.sections?.find((s) => s.schema.name === type)?.schema;
|
|
92
95
|
const sharedItem = (name) => config.registry.shared?.find((s) => s.name === name) ?? null;
|
|
93
96
|
/** One row per registration; null when materialization could not create it. */
|
|
@@ -119,6 +122,36 @@ export function createAdminOps(deps) {
|
|
|
119
122
|
effects.revalidate(sharedTag(item.name));
|
|
120
123
|
}
|
|
121
124
|
};
|
|
125
|
+
/** An asset's metadata (alt, title, caption, credit) is read wherever media
|
|
126
|
+
* resolves (spec 2026-09-08 §9): the entry and page units that reference the
|
|
127
|
+
* asset, directly or through an entry, the shared items among those rows,
|
|
128
|
+
* and the list units of every referencing entry's collection. The walk is
|
|
129
|
+
* the entry fan-out's, seeded with the asset's id — which is no entry, so
|
|
130
|
+
* it gets no tag of its own. */
|
|
131
|
+
const expireAssetUsers = async (assetId) => {
|
|
132
|
+
if (!effects?.revalidate)
|
|
133
|
+
return;
|
|
134
|
+
const { pageIds, entryIds } = await affectedTargets({ pages, entries }, assetId);
|
|
135
|
+
const users = entryIds.filter((id) => id !== assetId);
|
|
136
|
+
for (const id of users)
|
|
137
|
+
effects.revalidate(entryTag(id));
|
|
138
|
+
for (const id of pageIds)
|
|
139
|
+
effects.revalidate(pageTag(id));
|
|
140
|
+
const touched = new Set(users);
|
|
141
|
+
for (const item of config.registry.shared ?? []) {
|
|
142
|
+
const row = (await entries.list(item.name))[0];
|
|
143
|
+
if (row && touched.has(row.id))
|
|
144
|
+
effects.revalidate(sharedTag(item.name));
|
|
145
|
+
}
|
|
146
|
+
for (const c of config.registry.collections ?? []) {
|
|
147
|
+
for (const id of users) {
|
|
148
|
+
if (await entries.get(c.name, id)) {
|
|
149
|
+
effects.revalidate(collectionTag(c.name));
|
|
150
|
+
break;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
};
|
|
122
155
|
/** Expire the cached units a tree change touched, in order, once each. */
|
|
123
156
|
const expirePages = (ids) => {
|
|
124
157
|
if (!effects?.revalidate)
|
|
@@ -126,6 +159,10 @@ export function createAdminOps(deps) {
|
|
|
126
159
|
for (const id of new Set(ids.filter((x) => typeof x === "string")))
|
|
127
160
|
effects.revalidate(pageTag(id));
|
|
128
161
|
};
|
|
162
|
+
/** The tree listing's unit (spec 2026-09-11 §2): every page op that changes what it shows. */
|
|
163
|
+
const expireTree = () => effects?.revalidate?.(pagesTreeTag());
|
|
164
|
+
/** A collection's list units: every entry write, since a non-versioned collection's published read is its live row. */
|
|
165
|
+
const expireCollection = (name) => effects?.revalidate?.(collectionTag(name));
|
|
129
166
|
/** A node and every descendant, node first, in listPages() order. */
|
|
130
167
|
const subtreeIds = async (id) => {
|
|
131
168
|
const all = await pages.listPages();
|
|
@@ -241,10 +278,12 @@ export function createAdminOps(deps) {
|
|
|
241
278
|
// A root create competes with the code-owned slugs: make sure they exist first.
|
|
242
279
|
await ensureFixedNodes(pages, config);
|
|
243
280
|
}
|
|
244
|
-
|
|
281
|
+
const created = await pages.createPage({
|
|
245
282
|
locale: input.locale, slug: input.slug, template: input.template, parentId: input.parentId,
|
|
246
283
|
...(input.title !== undefined ? { title: input.title } : {}),
|
|
247
|
-
})
|
|
284
|
+
});
|
|
285
|
+
expireTree();
|
|
286
|
+
return ok(created);
|
|
248
287
|
}),
|
|
249
288
|
saveDraft: (id, locale, tree) => guard(async () => {
|
|
250
289
|
const unsupported = supportedLocale(locale);
|
|
@@ -278,17 +317,23 @@ export function createAdminOps(deps) {
|
|
|
278
317
|
const schema = sectionFields(node.type);
|
|
279
318
|
if (!schema?.fields)
|
|
280
319
|
continue;
|
|
281
|
-
for (const fe of validateFields(node.fields ?? {}, schema.fields)) {
|
|
320
|
+
for (const fe of validateFields(node.fields ?? {}, schema.fields, elementDescs)) {
|
|
282
321
|
fieldErrors.push({ field: `${node.id}.${fe.field}`, message: fe.message });
|
|
283
322
|
}
|
|
284
323
|
}
|
|
285
324
|
}
|
|
325
|
+
// The page's own fields (spec 2026-09-10 page fields §2); SEO meta is never validated.
|
|
326
|
+
const pageDescs = openTemplate(record.template)?.schema?.fields ?? {};
|
|
327
|
+
for (const fe of validateFields(draft.fields ?? {}, pageDescs, elementDescs)) {
|
|
328
|
+
fieldErrors.push({ field: `page.fields.${fe.field}`, message: fe.message });
|
|
329
|
+
}
|
|
286
330
|
if (fieldErrors.length > 0) {
|
|
287
331
|
return err("validation", `smoodly: cannot publish "${row.slug}" — fix the listed fields first.`, fieldErrors);
|
|
288
332
|
}
|
|
289
333
|
const published = await pages.publish(id, locale);
|
|
290
334
|
// The parent's cached unit lists its published children.
|
|
291
335
|
expirePages([published.id, published.parentId]);
|
|
336
|
+
expireTree();
|
|
292
337
|
return ok(published);
|
|
293
338
|
}),
|
|
294
339
|
delete: (id) => guard(async () => {
|
|
@@ -304,6 +349,7 @@ export function createAdminOps(deps) {
|
|
|
304
349
|
// The cached unit is keyed by (locale, path) but tagged by the id
|
|
305
350
|
// found at the first miss; the parent's unit lists its children.
|
|
306
351
|
expirePages([id, record.parentId]);
|
|
352
|
+
expireTree();
|
|
307
353
|
return ok(null);
|
|
308
354
|
}),
|
|
309
355
|
rename: (id, patch) => guard(async () => {
|
|
@@ -322,17 +368,20 @@ export function createAdminOps(deps) {
|
|
|
322
368
|
// Every descendant's path changed with this segment; the parent
|
|
323
369
|
// lists this node's title and path.
|
|
324
370
|
expirePages([...(await subtreeIds(id)), record.parentId]);
|
|
371
|
+
expireTree();
|
|
325
372
|
return ok(renamed);
|
|
326
373
|
}),
|
|
327
374
|
move: (id, to) => guard(async () => {
|
|
328
375
|
const record = await pages.getPage(id);
|
|
329
376
|
if (!record)
|
|
330
377
|
return err("not_found", `smoodly: no page "${id}".`);
|
|
331
|
-
if (fixedNodeOf(record, config))
|
|
332
|
-
return owned(record, "it stays at the root");
|
|
333
|
-
if (isHomeNode(record))
|
|
334
|
-
return err("blocked", `smoodly: "${nameOf(record)}" is the site root — it stays at the root.`);
|
|
335
378
|
if (to.parentId !== null) {
|
|
379
|
+
// Code owns a fixed node's URL, never the root's listing order:
|
|
380
|
+
// reparenting is refused, reordering among the root siblings is not.
|
|
381
|
+
if (fixedNodeOf(record, config))
|
|
382
|
+
return owned(record, "it stays at the root");
|
|
383
|
+
if (isHomeNode(record))
|
|
384
|
+
return err("blocked", `smoodly: "${nameOf(record)}" is the site root — it stays at the root.`);
|
|
336
385
|
const parent = await pages.getPage(to.parentId);
|
|
337
386
|
if (!parent)
|
|
338
387
|
return err("not_found", `smoodly: no page "${to.parentId}".`);
|
|
@@ -342,6 +391,7 @@ export function createAdminOps(deps) {
|
|
|
342
391
|
}
|
|
343
392
|
const moved = await pages.move(id, to);
|
|
344
393
|
expirePages([...(await subtreeIds(id)), record.parentId, moved.parentId]);
|
|
394
|
+
expireTree();
|
|
345
395
|
return ok(moved);
|
|
346
396
|
}),
|
|
347
397
|
setTemplate: (id, template) => guard(async () => {
|
|
@@ -358,7 +408,9 @@ export function createAdminOps(deps) {
|
|
|
358
408
|
if (registration.schema.slug !== undefined) {
|
|
359
409
|
return err("validation", `smoodly: page template "${template}" is fixed — Smoodly creates that page for you.`);
|
|
360
410
|
}
|
|
361
|
-
|
|
411
|
+
const withTemplate = await pages.setTemplate(id, template);
|
|
412
|
+
expireTree();
|
|
413
|
+
return ok(withTemplate);
|
|
362
414
|
}),
|
|
363
415
|
addLocale: (id, locale, options) => guard(async () => {
|
|
364
416
|
const unsupported = supportedLocale(locale) ?? supportedLocale(options?.from);
|
|
@@ -366,8 +418,10 @@ export function createAdminOps(deps) {
|
|
|
366
418
|
return unsupported;
|
|
367
419
|
if (!(await pages.getPage(id)))
|
|
368
420
|
return err("not_found", `smoodly: no page "${id}".`);
|
|
369
|
-
// A new locale is a draft: nothing public changes
|
|
370
|
-
|
|
421
|
+
// A new locale is a draft: nothing public changes but the tree listing (spec 2026-09-11 §2).
|
|
422
|
+
const added = await pages.addLocale(id, locale, { from: options.from });
|
|
423
|
+
expireTree();
|
|
424
|
+
return ok(added);
|
|
371
425
|
}),
|
|
372
426
|
removeLocale: (id, locale) => guard(async () => {
|
|
373
427
|
const unsupported = supportedLocale(locale);
|
|
@@ -383,6 +437,7 @@ export function createAdminOps(deps) {
|
|
|
383
437
|
const removed = await pages.removeLocale(id, locale);
|
|
384
438
|
// The locale may have been published: its unit and the parent's listing go.
|
|
385
439
|
expirePages([id, record.parentId]);
|
|
440
|
+
expireTree();
|
|
386
441
|
return ok(removed);
|
|
387
442
|
}),
|
|
388
443
|
},
|
|
@@ -409,10 +464,12 @@ export function createAdminOps(deps) {
|
|
|
409
464
|
const descriptors = collectionFields(collection);
|
|
410
465
|
if (!descriptors)
|
|
411
466
|
return err("not_found", `smoodly: no collection named "${collection}".`);
|
|
412
|
-
const fieldErrors = validateFields(input.fields, descriptors);
|
|
467
|
+
const fieldErrors = validateFields(input.fields, descriptors, elementDescs);
|
|
413
468
|
if (fieldErrors.length > 0)
|
|
414
469
|
return err("validation", "Some fields need attention.", fieldErrors);
|
|
415
|
-
|
|
470
|
+
const created = await entries.create(collection, input.fields, { locale: input.locale, parentId: input.parentId ?? null });
|
|
471
|
+
expireCollection(collection);
|
|
472
|
+
return ok(created);
|
|
416
473
|
}),
|
|
417
474
|
save: (collection, id, locale, fields) => guard(async () => {
|
|
418
475
|
const unsupported = supportedLocale(locale);
|
|
@@ -421,11 +478,12 @@ export function createAdminOps(deps) {
|
|
|
421
478
|
const descriptors = collectionFields(collection);
|
|
422
479
|
if (!descriptors)
|
|
423
480
|
return err("not_found", `smoodly: no collection named "${collection}".`);
|
|
424
|
-
const fieldErrors = validateFields(fields, descriptors);
|
|
481
|
+
const fieldErrors = validateFields(fields, descriptors, elementDescs);
|
|
425
482
|
if (fieldErrors.length > 0)
|
|
426
483
|
return err("validation", "Some fields need attention.", fieldErrors);
|
|
427
484
|
const entry = await entries.update(collection, id, locale, fields);
|
|
428
485
|
await fanOut(id);
|
|
486
|
+
expireCollection(collection);
|
|
429
487
|
return ok(entry);
|
|
430
488
|
}),
|
|
431
489
|
setStatus: (collection, id, locale, status) => guard(async () => {
|
|
@@ -434,24 +492,29 @@ export function createAdminOps(deps) {
|
|
|
434
492
|
return unsupported;
|
|
435
493
|
const entry = await entries.setStatus(collection, id, locale, status);
|
|
436
494
|
await fanOut(id);
|
|
495
|
+
expireCollection(collection);
|
|
437
496
|
return ok(entry);
|
|
438
497
|
}),
|
|
439
498
|
// The tag expires only after the delete succeeded; a blocked delete expires nothing.
|
|
440
|
-
delete: (collection, id) => guard(async () => (await entries.delete(collection, id), await fanOut(id), ok(null))),
|
|
499
|
+
delete: (collection, id) => guard(async () => (await entries.delete(collection, id), await fanOut(id), expireCollection(collection), ok(null))),
|
|
441
500
|
move: (collection, id, to) => guard(async () => {
|
|
442
501
|
const moved = await entries.move(collection, id, to);
|
|
443
502
|
if (effects?.revalidate) {
|
|
444
503
|
for (const entryId of await entrySubtreeIds(collection, id))
|
|
445
504
|
effects.revalidate(entryTag(entryId));
|
|
446
505
|
}
|
|
506
|
+
expireCollection(collection);
|
|
447
507
|
return ok(moved);
|
|
448
508
|
}),
|
|
449
|
-
// A new locale starts as a draft: nothing public changes,
|
|
509
|
+
// A new locale starts as a draft: nothing public changes, but the collection's
|
|
510
|
+
// list units are expired anyway (spec 2026-09-11 §2) so they can never go stale.
|
|
450
511
|
addLocale: (collection, id, locale, options) => guard(async () => {
|
|
451
512
|
const unsupported = supportedLocale(locale);
|
|
452
513
|
if (unsupported)
|
|
453
514
|
return unsupported;
|
|
454
|
-
|
|
515
|
+
const added = await entries.addLocale(collection, id, locale, { from: options.from });
|
|
516
|
+
expireCollection(collection);
|
|
517
|
+
return ok(added);
|
|
455
518
|
}),
|
|
456
519
|
removeLocale: (collection, id, locale) => guard(async () => {
|
|
457
520
|
const unsupported = supportedLocale(locale);
|
|
@@ -459,6 +522,7 @@ export function createAdminOps(deps) {
|
|
|
459
522
|
return unsupported;
|
|
460
523
|
const entry = await entries.removeLocale(collection, id, locale);
|
|
461
524
|
await fanOut(id);
|
|
525
|
+
expireCollection(collection);
|
|
462
526
|
return ok(entry);
|
|
463
527
|
}),
|
|
464
528
|
},
|
|
@@ -488,7 +552,7 @@ export function createAdminOps(deps) {
|
|
|
488
552
|
const item = sharedItem(name);
|
|
489
553
|
if (!item)
|
|
490
554
|
return err("not_found", `smoodly: no shared item named "${name}".`);
|
|
491
|
-
const fieldErrors = validateFields(fields, item.fields);
|
|
555
|
+
const fieldErrors = validateFields(fields, item.fields, elementDescs);
|
|
492
556
|
if (fieldErrors.length > 0)
|
|
493
557
|
return err("validation", "Some fields need attention.", fieldErrors);
|
|
494
558
|
const record = await sharedRecord(name);
|
|
@@ -555,7 +619,9 @@ export function createAdminOps(deps) {
|
|
|
555
619
|
return err("validation", `smoodly: ${key} must be text.`);
|
|
556
620
|
patch[key] = v;
|
|
557
621
|
}
|
|
558
|
-
|
|
622
|
+
const updated = await assets.update(id, patch);
|
|
623
|
+
await expireAssetUsers(id);
|
|
624
|
+
return ok(updated);
|
|
559
625
|
}),
|
|
560
626
|
delete: (id) => guard(async () => (assets ? (await assets.delete(id), ok(null)) : noMedia())),
|
|
561
627
|
usage: (id) => guard(async () => {
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
/** True when any text node in the tree has non-whitespace text. */
|
|
2
2
|
export declare function hasText(doc: unknown): boolean;
|
|
3
|
+
/** True when the document has text, or an image or element block (spec
|
|
4
|
+
* 2026-09-08 block nodes §2): a picture alone is content. */
|
|
5
|
+
export declare function hasContent(doc: unknown): boolean;
|
|
3
6
|
/** The document as plain JSON. ProseMirror builds node attrs with a null
|
|
4
7
|
* prototype, which React's server-action encoding refuses to serialize
|
|
5
8
|
* (it becomes a temporary reference and the server's structuredClone
|