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.
- package/README.md +259 -38
- package/dist/admin/editor/CanvasOverlay.d.ts +6 -2
- package/dist/admin/editor/CanvasOverlay.js +9 -11
- package/dist/admin/editor/EditorView.js +130 -25
- 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/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 +12 -40
- 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 +13 -8
- package/dist/admin/richtext-doc.d.ts +3 -0
- package/dist/admin/richtext-doc.js +12 -0
- package/dist/admin/serialize.d.ts +7 -0
- package/dist/admin/serialize.js +16 -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 +9 -1
- package/dist/admin/tree-ops.js +27 -0
- 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 +29 -4
- package/dist/admin/ui/primitives.js +29 -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 +512 -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 +28 -9
- package/dist/config.d.ts +1 -0
- package/dist/config.js +36 -0
- package/dist/entry-store.d.ts +5 -2
- package/dist/entry-store.js +4 -3
- package/dist/fields.d.ts +4 -0
- package/dist/fields.js +2 -2
- package/dist/image/SmoodlyImage.d.ts +4 -1
- package/dist/image/SmoodlyImage.js +2 -2
- package/dist/index.d.ts +8 -1
- package/dist/index.js +5 -1
- 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/refs.d.ts +2 -2
- package/dist/refs.js +33 -12
- package/dist/render.js +2 -1
- package/dist/resolve.d.ts +8 -2
- package/dist/resolve.js +8 -7
- 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/site.d.ts +2 -0
- package/dist/site.js +7 -4
- package/dist/store.d.ts +3 -1
- package/dist/store.js +4 -4
- package/dist/supabase-entry-store.d.ts +1 -0
- package/dist/supabase-entry-store.js +2 -1
- package/dist/supabase-store.js +1 -1
- package/dist/toolset.d.ts +7 -1
- package/dist/toolset.js +13 -4
- 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;
|
package/dist/admin/forms/tabs.js
CHANGED
|
@@ -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,
|
|
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
|
@@ -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
|