smoodly 0.0.11 → 0.0.13

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.
Files changed (132) hide show
  1. package/README.md +259 -38
  2. package/dist/admin/editor/CanvasOverlay.d.ts +6 -2
  3. package/dist/admin/editor/CanvasOverlay.js +9 -11
  4. package/dist/admin/editor/EditorView.js +130 -25
  5. package/dist/admin/editor/FloatingToolbar.d.ts +16 -0
  6. package/dist/admin/editor/FloatingToolbar.js +14 -0
  7. package/dist/admin/editor/InlineRichtext.d.ts +9 -0
  8. package/dist/admin/editor/InlineRichtext.js +50 -0
  9. package/dist/admin/editor/Outline.d.ts +16 -10
  10. package/dist/admin/editor/Outline.js +13 -10
  11. package/dist/admin/editor/inline-dom.d.ts +27 -0
  12. package/dist/admin/editor/inline-dom.js +43 -0
  13. package/dist/admin/editor/inline-session.d.ts +30 -0
  14. package/dist/admin/editor/inline-session.js +19 -0
  15. package/dist/admin/editor/protocol.d.ts +22 -1
  16. package/dist/admin/editor/useCanvasBridge.d.ts +8 -4
  17. package/dist/admin/editor/useCanvasBridge.js +12 -7
  18. package/dist/admin/editor/useInlineSession.d.ts +46 -0
  19. package/dist/admin/editor/useInlineSession.js +244 -0
  20. package/dist/admin/field-paths.d.ts +13 -0
  21. package/dist/admin/field-paths.js +49 -0
  22. package/dist/admin/forms/FieldWidget.d.ts +7 -2
  23. package/dist/admin/forms/FieldWidget.js +12 -40
  24. package/dist/admin/forms/ListField.d.ts +10 -0
  25. package/dist/admin/forms/ListField.js +42 -0
  26. package/dist/admin/forms/MediaWidget.js +10 -3
  27. package/dist/admin/forms/RichtextElementCard.d.ts +3 -0
  28. package/dist/admin/forms/RichtextElementCard.js +37 -0
  29. package/dist/admin/forms/RichtextImageCard.d.ts +3 -0
  30. package/dist/admin/forms/RichtextImageCard.js +42 -0
  31. package/dist/admin/forms/RichtextInsertMenu.d.ts +6 -0
  32. package/dist/admin/forms/RichtextInsertMenu.js +6 -0
  33. package/dist/admin/forms/RichtextPopovers.d.ts +7 -0
  34. package/dist/admin/forms/RichtextPopovers.js +25 -0
  35. package/dist/admin/forms/RichtextToolbar.d.ts +4 -2
  36. package/dist/admin/forms/RichtextToolbar.js +4 -2
  37. package/dist/admin/forms/RichtextWidget.d.ts +4 -1
  38. package/dist/admin/forms/RichtextWidget.js +23 -120
  39. package/dist/admin/forms/elements-context.d.ts +7 -0
  40. package/dist/admin/forms/elements-context.js +13 -0
  41. package/dist/admin/forms/reveal.d.ts +5 -0
  42. package/dist/admin/forms/reveal.js +47 -0
  43. package/dist/admin/forms/richtext-extensions.d.ts +8 -0
  44. package/dist/admin/forms/richtext-extensions.js +11 -0
  45. package/dist/admin/forms/richtext-form-context.d.ts +16 -0
  46. package/dist/admin/forms/richtext-form-context.js +18 -0
  47. package/dist/admin/forms/richtext-insert.d.ts +38 -0
  48. package/dist/admin/forms/richtext-insert.js +37 -0
  49. package/dist/admin/forms/richtext-nodes.d.ts +37 -0
  50. package/dist/admin/forms/richtext-nodes.js +85 -0
  51. package/dist/admin/forms/richtext-paste.js +5 -0
  52. package/dist/admin/forms/tabs.d.ts +3 -0
  53. package/dist/admin/forms/tabs.js +6 -0
  54. package/dist/admin/forms/useRichtextEditor.d.ts +51 -0
  55. package/dist/admin/forms/useRichtextEditor.js +281 -0
  56. package/dist/admin/next/bridge.js +51 -20
  57. package/dist/admin/ops-impl.js +13 -8
  58. package/dist/admin/richtext-doc.d.ts +3 -0
  59. package/dist/admin/richtext-doc.js +12 -0
  60. package/dist/admin/serialize.d.ts +7 -0
  61. package/dist/admin/serialize.js +16 -1
  62. package/dist/admin/shell/AdminApp.js +2 -1
  63. package/dist/admin/shell/EntriesList.js +34 -12
  64. package/dist/admin/shell/EntryForm.js +22 -15
  65. package/dist/admin/shell/IconRail.js +6 -5
  66. package/dist/admin/shell/ListView.d.ts +9 -2
  67. package/dist/admin/shell/ListView.js +7 -6
  68. package/dist/admin/shell/LoginView.js +2 -2
  69. package/dist/admin/shell/PagesList.js +53 -24
  70. package/dist/admin/shell/SharedForm.js +17 -11
  71. package/dist/admin/shell/SharedList.js +5 -3
  72. package/dist/admin/shell/entries-list.d.ts +4 -0
  73. package/dist/admin/shell/entries-list.js +6 -0
  74. package/dist/admin/shell/pages-tree.d.ts +24 -0
  75. package/dist/admin/shell/pages-tree.js +59 -1
  76. package/dist/admin/tree-ops.d.ts +9 -1
  77. package/dist/admin/tree-ops.js +27 -0
  78. package/dist/admin/ui/LocaleSwitcher.d.ts +10 -0
  79. package/dist/admin/ui/LocaleSwitcher.js +28 -8
  80. package/dist/admin/ui/Modal.js +14 -2
  81. package/dist/admin/ui/ResizeHandle.d.ts +1 -0
  82. package/dist/admin/ui/ResizeHandle.js +127 -0
  83. package/dist/admin/ui/primitives.d.ts +29 -4
  84. package/dist/admin/ui/primitives.js +29 -10
  85. package/dist/admin/ui/resize.d.ts +16 -0
  86. package/dist/admin/ui/resize.js +23 -0
  87. package/dist/admin/ui/sortable.d.ts +23 -0
  88. package/dist/admin/ui/sortable.js +57 -0
  89. package/dist/admin/ui/theme.d.ts +3 -1
  90. package/dist/admin/ui/theme.js +512 -165
  91. package/dist/admin/ui/useSortable.d.ts +35 -0
  92. package/dist/admin/ui/useSortable.js +220 -0
  93. package/dist/admin/validate.d.ts +6 -1
  94. package/dist/admin/validate.js +28 -9
  95. package/dist/config.d.ts +1 -0
  96. package/dist/config.js +36 -0
  97. package/dist/entry-store.d.ts +5 -2
  98. package/dist/entry-store.js +4 -3
  99. package/dist/fields.d.ts +4 -0
  100. package/dist/fields.js +2 -2
  101. package/dist/image/SmoodlyImage.d.ts +4 -1
  102. package/dist/image/SmoodlyImage.js +2 -2
  103. package/dist/index.d.ts +8 -1
  104. package/dist/index.js +5 -1
  105. package/dist/marks.d.ts +53 -0
  106. package/dist/marks.js +43 -0
  107. package/dist/media.d.ts +3 -2
  108. package/dist/media.js +48 -6
  109. package/dist/refs.d.ts +2 -2
  110. package/dist/refs.js +33 -12
  111. package/dist/render.js +2 -1
  112. package/dist/resolve.d.ts +8 -2
  113. package/dist/resolve.js +8 -7
  114. package/dist/richtext/index.d.ts +12 -0
  115. package/dist/richtext/index.js +9 -0
  116. package/dist/richtext-render.d.ts +22 -2
  117. package/dist/richtext-render.js +40 -13
  118. package/dist/richtext-walk.d.ts +44 -0
  119. package/dist/richtext-walk.js +46 -0
  120. package/dist/schema.d.ts +1 -1
  121. package/dist/schema.js +13 -1
  122. package/dist/section-wrapper.js +2 -1
  123. package/dist/site.d.ts +2 -0
  124. package/dist/site.js +7 -4
  125. package/dist/store.d.ts +3 -1
  126. package/dist/store.js +4 -4
  127. package/dist/supabase-entry-store.d.ts +1 -0
  128. package/dist/supabase-entry-store.js +2 -1
  129. package/dist/supabase-store.js +1 -1
  130. package/dist/toolset.d.ts +7 -1
  131. package/dist/toolset.js +13 -4
  132. package/package.json +10 -1
@@ -0,0 +1,85 @@
1
+ // The two block node types (spec 2026-09-08 block nodes §2) and the slash
2
+ // menu. Always in the schema; the toolset gates insertion elsewhere. Node
3
+ // views are injected by the widget (`setNodeViews`), so this file has no
4
+ // React component import and loads under bare node for the schema tests.
5
+ import { Extension, Node, mergeAttributes } from "@tiptap/core";
6
+ import { ReactNodeViewRenderer } from "@tiptap/react";
7
+ import Suggestion from "@tiptap/suggestion";
8
+ const views = { image: null, element: null };
9
+ /** Bind the card components once, before the first editor mounts. */
10
+ export function setNodeViews(next) { Object.assign(views, next); }
11
+ const jsonAttr = (name, fallback) => ({
12
+ default: fallback,
13
+ parseHTML: (el) => { try {
14
+ return JSON.parse(el.getAttribute(`data-${name}`) ?? "");
15
+ }
16
+ catch {
17
+ return fallback;
18
+ } },
19
+ renderHTML: (attrs) => ({ [`data-${name}`]: JSON.stringify(attrs[name] ?? fallback) }),
20
+ });
21
+ export const ImageNode = Node.create({
22
+ name: "image",
23
+ group: "block",
24
+ atom: true,
25
+ draggable: true,
26
+ addAttributes() {
27
+ return { image: jsonAttr("image", null), caption: { default: "" } };
28
+ },
29
+ parseHTML() { return [{ tag: "div[data-smoodly-image]" }]; },
30
+ renderHTML({ HTMLAttributes }) { return ["div", mergeAttributes({ "data-smoodly-image": "" }, HTMLAttributes)]; },
31
+ addNodeView() { return views.image ? ReactNodeViewRenderer(views.image) : null; },
32
+ });
33
+ export const ElementNode = Node.create({
34
+ name: "element",
35
+ group: "block",
36
+ atom: true,
37
+ draggable: true,
38
+ addAttributes() {
39
+ return { name: { default: "" }, fields: jsonAttr("fields", {}), styles: jsonAttr("styles", {}) };
40
+ },
41
+ parseHTML() { return [{ tag: "div[data-smoodly-element]" }]; },
42
+ renderHTML({ HTMLAttributes }) { return ["div", mergeAttributes({ "data-smoodly-element": "" }, HTMLAttributes)]; },
43
+ addNodeView() { return views.element ? ReactNodeViewRenderer(views.element) : null; },
44
+ });
45
+ /** Insert a block at `at` (default: the selection's end — after a selected
46
+ * card, never replacing it), followed by an empty paragraph so the caret
47
+ * lands somewhere. The position is explicit because a menu or a picker
48
+ * has taken focus in between, and a refocused contenteditable may place
49
+ * its DOM caret at the first block before the editor's own selection is
50
+ * restored; an empty paragraph at `at` is replaced, as TipTap does. */
51
+ export function insertBlock(editor, node, at) {
52
+ const pos = at ?? editor.state.selection.to;
53
+ editor.chain().insertContentAt(pos, [node, { type: "paragraph" }]).focus().run();
54
+ }
55
+ /** `/` at the start of an empty paragraph opens the insert menu (spec §3). */
56
+ export const SlashMenu = Extension.create({
57
+ name: "smoodlySlash",
58
+ addOptions() { return { items: () => [], onState: () => { }, onKeyDown: () => false, onPick: () => { } }; },
59
+ addProseMirrorPlugins() {
60
+ const { options } = this;
61
+ return [
62
+ Suggestion({
63
+ editor: this.editor,
64
+ char: "/",
65
+ startOfLine: true,
66
+ allow: ({ state, range }) => {
67
+ const $from = state.doc.resolve(range.from);
68
+ return $from.parent.type.name === "paragraph" && $from.parentOffset === 0;
69
+ },
70
+ items: ({ query }) => options.items(query),
71
+ command: ({ editor, range, props }) => {
72
+ editor.chain().focus().deleteRange(range).run();
73
+ options.onState(null);
74
+ options.onPick(props);
75
+ },
76
+ render: () => ({
77
+ onStart: (p) => options.onState({ items: p.items, rect: p.clientRect?.() ?? null, pick: (item) => p.command(item) }),
78
+ onUpdate: (p) => options.onState({ items: p.items, rect: p.clientRect?.() ?? null, pick: (item) => p.command(item) }),
79
+ onKeyDown: (p) => options.onKeyDown(p.event),
80
+ onExit: () => options.onState(null),
81
+ }),
82
+ }),
83
+ ];
84
+ },
85
+ });
@@ -21,6 +21,11 @@ function gateNode(n, allowed) {
21
21
  const { marks: _drop, ...rest } = n;
22
22
  return [marks.length > 0 ? { ...rest, marks } : rest];
23
23
  }
24
+ // Block nodes (spec 2026-09-08 block nodes §2): nothing to demote to, so a forbidden one is dropped.
25
+ if (n.type === "image")
26
+ return allowed.image ? [n] : [];
27
+ if (n.type === "element")
28
+ return allowed.elements.includes(String(n.attrs?.name)) ? [n] : [];
24
29
  const content = n.content ? gateNodes(n.content, allowed) : undefined;
25
30
  const withContent = (node) => (content ? { ...node, content } : node);
26
31
  if (n.type === "heading") {
@@ -9,3 +9,6 @@ export declare function tabsFor(section: AdminSection): {
9
9
  tab: TabName;
10
10
  entries: TabEntry[];
11
11
  }[];
12
+ /** The tab holding a content field, by its dotted path's top-level key — a canvas
13
+ * click on a marked image reveals it on Media, where the projection put it. */
14
+ export declare function tabOf(section: AdminSection, path: string): TabName | undefined;
@@ -18,3 +18,9 @@ export function tabsFor(section) {
18
18
  .filter((tab) => buckets[tab].length > 0)
19
19
  .map((tab) => ({ tab, entries: buckets[tab] }));
20
20
  }
21
+ /** The tab holding a content field, by its dotted path's top-level key — a canvas
22
+ * click on a marked image reveals it on Media, where the projection put it. */
23
+ export function tabOf(section, path) {
24
+ const key = path.split(".")[0];
25
+ return tabsFor(section).find((t) => t.entries.some((e) => e.ns === "fields" && e.key === key))?.tab;
26
+ }
@@ -0,0 +1,51 @@
1
+ import { type FocusEvent, type KeyboardEvent } from "react";
2
+ import { type Editor } from "@tiptap/react";
3
+ import type { AssetRecord } from "../../media.ts";
4
+ import { type Allowed } from "../../toolset.ts";
5
+ import { type ActiveState, type ToolbarCommand } from "./RichtextToolbar.tsx";
6
+ import { type InsertItem } from "./richtext-insert.ts";
7
+ import { type SlashState } from "./richtext-nodes.ts";
8
+ export type RichtextApi = {
9
+ editor: Editor | null;
10
+ allowed: Allowed;
11
+ active: ActiveState;
12
+ canInsert: boolean;
13
+ run(command: ToolbarCommand): void;
14
+ link: {
15
+ open: boolean;
16
+ href: string;
17
+ setHref(href: string): void;
18
+ apply(): void;
19
+ remove(): void;
20
+ close(): void;
21
+ };
22
+ menu: {
23
+ open: boolean;
24
+ query: string;
25
+ active: number;
26
+ items: InsertItem[];
27
+ setQuery(query: string): void;
28
+ onKeyDown(e: KeyboardEvent<HTMLInputElement>): void;
29
+ pick(item: InsertItem): void;
30
+ close(): void;
31
+ dismiss(e: FocusEvent<HTMLDivElement>): void;
32
+ };
33
+ slash: {
34
+ state: SlashState;
35
+ active: number;
36
+ } | null;
37
+ picker: {
38
+ open: boolean;
39
+ pick(record: AssetRecord): void;
40
+ close(): void;
41
+ };
42
+ };
43
+ export declare function useRichtextEditor({ value, toolset, onChange, contentClass, onEscape }: {
44
+ value: unknown;
45
+ toolset: unknown;
46
+ onChange(doc: unknown): void;
47
+ /** The class on the ProseMirror root: the widget's content box, or the canvas editor's marker. */
48
+ contentClass: string;
49
+ /** Escape in the content with no slash menu open — the canvas editor ends its session. */
50
+ onEscape?(): void;
51
+ }): RichtextApi;
@@ -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, forwards select/hover,
4
- // executes refresh/scroll the admin overlay draws all chrome. Rects are
5
- // in document coordinates: the iframe is sized to the full document height
6
- // by the editor, so client rects + scroll offsets map 1:1 onto the overlay.
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 post = (m) => window.parent.postMessage(m, "*");
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 = Array.from(document.querySelectorAll("[data-smoodly-node]")).map((el) => {
17
- const r = el.getBoundingClientRect();
18
- return { id: el.dataset.smoodlyNode, x: r.left + window.scrollX, y: r.top + window.scrollY, w: r.width, h: r.height };
19
- });
20
- const emptyZones = Array.from(document.querySelectorAll("[data-smoodly-zone]")).map((el) => {
21
- const r = el.getBoundingClientRect();
22
- return { zone: el.dataset.smoodlyZone, x: r.left + window.scrollX, y: r.top + window.scrollY, w: r.width, h: r.height };
23
- });
24
- post({ source: "smoodly", kind: "rects", nodes, emptyZones, docH: document.documentElement.scrollHeight });
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 nodeIdAt = (target) => target?.closest?.("[data-smoodly-node]")?.dataset.smoodlyNode ?? null;
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
- post({ source: "smoodly", kind: "select", id: nodeIdAt(e.target) });
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(`[data-smoodly-node="${CSS.escape(m.id)}"]`)
78
+ .querySelector(`[${NODE_ATTR}="${CSS.escape(m.id)}"]`)
48
79
  ?.scrollIntoView({ behavior: "smooth", block: "center" });
49
80
  }
50
81
  };
@@ -5,6 +5,7 @@ import { affectedTargets, entryTag, pageTag, 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. */
@@ -278,7 +281,7 @@ export function createAdminOps(deps) {
278
281
  const schema = sectionFields(node.type);
279
282
  if (!schema?.fields)
280
283
  continue;
281
- for (const fe of validateFields(node.fields ?? {}, schema.fields)) {
284
+ for (const fe of validateFields(node.fields ?? {}, schema.fields, elementDescs)) {
282
285
  fieldErrors.push({ field: `${node.id}.${fe.field}`, message: fe.message });
283
286
  }
284
287
  }
@@ -328,11 +331,13 @@ export function createAdminOps(deps) {
328
331
  const record = await pages.getPage(id);
329
332
  if (!record)
330
333
  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
334
  if (to.parentId !== null) {
335
+ // Code owns a fixed node's URL, never the root's listing order:
336
+ // reparenting is refused, reordering among the root siblings is not.
337
+ if (fixedNodeOf(record, config))
338
+ return owned(record, "it stays at the root");
339
+ if (isHomeNode(record))
340
+ return err("blocked", `smoodly: "${nameOf(record)}" is the site root — it stays at the root.`);
336
341
  const parent = await pages.getPage(to.parentId);
337
342
  if (!parent)
338
343
  return err("not_found", `smoodly: no page "${to.parentId}".`);
@@ -409,7 +414,7 @@ export function createAdminOps(deps) {
409
414
  const descriptors = collectionFields(collection);
410
415
  if (!descriptors)
411
416
  return err("not_found", `smoodly: no collection named "${collection}".`);
412
- const fieldErrors = validateFields(input.fields, descriptors);
417
+ const fieldErrors = validateFields(input.fields, descriptors, elementDescs);
413
418
  if (fieldErrors.length > 0)
414
419
  return err("validation", "Some fields need attention.", fieldErrors);
415
420
  return ok(await entries.create(collection, input.fields, { locale: input.locale, parentId: input.parentId ?? null }));
@@ -421,7 +426,7 @@ export function createAdminOps(deps) {
421
426
  const descriptors = collectionFields(collection);
422
427
  if (!descriptors)
423
428
  return err("not_found", `smoodly: no collection named "${collection}".`);
424
- const fieldErrors = validateFields(fields, descriptors);
429
+ const fieldErrors = validateFields(fields, descriptors, elementDescs);
425
430
  if (fieldErrors.length > 0)
426
431
  return err("validation", "Some fields need attention.", fieldErrors);
427
432
  const entry = await entries.update(collection, id, locale, fields);
@@ -488,7 +493,7 @@ export function createAdminOps(deps) {
488
493
  const item = sharedItem(name);
489
494
  if (!item)
490
495
  return err("not_found", `smoodly: no shared item named "${name}".`);
491
- const fieldErrors = validateFields(fields, item.fields);
496
+ const fieldErrors = validateFields(fields, item.fields, elementDescs);
492
497
  if (fieldErrors.length > 0)
493
498
  return err("validation", "Some fields need attention.", fieldErrors);
494
499
  const record = await sharedRecord(name);
@@ -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
@@ -9,6 +9,18 @@ export function hasText(doc) {
9
9
  return true;
10
10
  return Array.isArray(n.content) && n.content.some(hasText);
11
11
  }
12
+ /** True when the document has text, or an image or element block (spec
13
+ * 2026-09-08 block nodes §2): a picture alone is content. */
14
+ export function hasContent(doc) {
15
+ if (!doc || typeof doc !== "object")
16
+ return false;
17
+ const n = doc;
18
+ if (n.type === "image" || n.type === "element")
19
+ return true;
20
+ if (typeof n.text === "string" && n.text.trim() !== "")
21
+ return true;
22
+ return Array.isArray(n.content) && n.content.some(hasContent);
23
+ }
12
24
  /** The document as plain JSON. ProseMirror builds node attrs with a null
13
25
  * prototype, which React's server-action encoding refuses to serialize
14
26
  * (it becomes a temporary reference and the server's structuredClone