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,47 @@
|
|
|
1
|
+
// Revealing a field in the inspector from a canvas click (spec 2026-09-10
|
|
2
|
+
// §8): scroll its widget into view, pulse it once, focus its first
|
|
3
|
+
// control; a widget inside a collapsed list row is reached by expanding
|
|
4
|
+
// the row first. The path grammar is FieldWidget's; the DOM hook is the
|
|
5
|
+
// data-sm-path attribute Field and ListRow carry.
|
|
6
|
+
export const REVEAL_CLASS = "sm-field--reveal";
|
|
7
|
+
export const PATH_ATTR = "data-sm-path";
|
|
8
|
+
/** The path, then each enclosing path, outermost last. */
|
|
9
|
+
export function ancestorsOf(path) {
|
|
10
|
+
const segments = path.split(".").filter((s) => s !== "");
|
|
11
|
+
const out = [];
|
|
12
|
+
for (let n = segments.length; n >= 1; n--)
|
|
13
|
+
out.push(segments.slice(0, n).join("."));
|
|
14
|
+
return out;
|
|
15
|
+
}
|
|
16
|
+
export function revealField(root, path) {
|
|
17
|
+
const find = (p) => root.querySelector(`[${PATH_ATTR}="${CSS.escape(p)}"]`);
|
|
18
|
+
const target = find(path);
|
|
19
|
+
if (target) {
|
|
20
|
+
pulse(target);
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
// Inside a collapsed row: expand the nearest rendered ancestor, then look again.
|
|
24
|
+
for (const p of ancestorsOf(path).slice(1)) {
|
|
25
|
+
const row = find(p);
|
|
26
|
+
if (!row)
|
|
27
|
+
continue;
|
|
28
|
+
const toggle = row.querySelector('[aria-expanded="false"]');
|
|
29
|
+
if (toggle) {
|
|
30
|
+
toggle.click();
|
|
31
|
+
requestAnimationFrame(() => revealField(root, path));
|
|
32
|
+
}
|
|
33
|
+
else
|
|
34
|
+
pulse(row);
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
function pulse(el) {
|
|
39
|
+
el.scrollIntoView({ block: "center", behavior: "smooth" });
|
|
40
|
+
el.classList.remove(REVEAL_CLASS);
|
|
41
|
+
void el.offsetWidth; // restart the animation when the same field is revealed twice
|
|
42
|
+
el.classList.add(REVEAL_CLASS);
|
|
43
|
+
el.addEventListener("animationend", () => el.classList.remove(REVEAL_CLASS), { once: true });
|
|
44
|
+
// The value control first — an image's first control is its picker button, so Enter opens the library.
|
|
45
|
+
const control = el.querySelector("input,textarea,select,[contenteditable]") ?? el.querySelector("button");
|
|
46
|
+
control?.focus({ preventScroll: true });
|
|
47
|
+
}
|
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
import { type AnyExtension } from "@tiptap/core";
|
|
2
2
|
import type { Allowed } from "../../toolset.ts";
|
|
3
|
+
import type { AdminElement } from "../serialize.ts";
|
|
4
|
+
import { type SlashOptions } from "./richtext-nodes.ts";
|
|
5
|
+
/** The slash menu's wiring: the registry's elements (the toolset picks
|
|
6
|
+
* from them) and the widget's state and key handlers. */
|
|
7
|
+
export type SlashHooks = Omit<SlashOptions, "items"> & {
|
|
8
|
+
elements: AdminElement[];
|
|
9
|
+
};
|
|
3
10
|
export declare function richtextExtensions(allowed: Allowed, hooks: {
|
|
4
11
|
onLink(): void;
|
|
12
|
+
slash?: SlashHooks;
|
|
5
13
|
}): AnyExtension[];
|
|
@@ -13,6 +13,8 @@ import Italic from "@tiptap/extension-italic";
|
|
|
13
13
|
import Link from "@tiptap/extension-link";
|
|
14
14
|
import Blockquote from "@tiptap/extension-blockquote";
|
|
15
15
|
import { BulletList, OrderedList } from "@tiptap/extension-list";
|
|
16
|
+
import { filterItems, insertItems } from "./richtext-insert.js";
|
|
17
|
+
import { ElementNode, ImageNode, SlashMenu } from "./richtext-nodes.js";
|
|
16
18
|
/** Keep the mark in the schema; add no way to create it when `on` is false. */
|
|
17
19
|
function gateMarkConfig(on) {
|
|
18
20
|
return {
|
|
@@ -32,6 +34,8 @@ const ALL_LEVELS = [1, 2, 3, 4, 5, 6];
|
|
|
32
34
|
export function richtextExtensions(allowed, hooks) {
|
|
33
35
|
const link = allowed.marks.has("link");
|
|
34
36
|
const levels = allowed.headingLevels;
|
|
37
|
+
const items = hooks.slash ? insertItems(allowed, hooks.slash.elements) : [];
|
|
38
|
+
const slash = hooks.slash;
|
|
35
39
|
return [
|
|
36
40
|
StarterKit.configure({
|
|
37
41
|
heading: false, bold: false, italic: false, link: false, blockquote: false,
|
|
@@ -70,5 +74,12 @@ export function richtextExtensions(allowed, hooks) {
|
|
|
70
74
|
return link ? { "Mod-k": () => { hooks.onLink(); return true; } } : {};
|
|
71
75
|
},
|
|
72
76
|
}),
|
|
77
|
+
// Block nodes (spec 2026-09-08 block nodes §2): always in the schema; the
|
|
78
|
+
// toolset gates the insert menu, the slash menu and paste.
|
|
79
|
+
ImageNode,
|
|
80
|
+
ElementNode,
|
|
81
|
+
...(slash && items.length > 0
|
|
82
|
+
? [SlashMenu.configure({ items: (q) => filterItems(items, q), onState: slash.onState, onKeyDown: slash.onKeyDown, onPick: slash.onPick })]
|
|
83
|
+
: []),
|
|
73
84
|
];
|
|
74
85
|
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { type ReactElement, type ReactNode } from "react";
|
|
2
|
+
export type RichtextFormInfo = {
|
|
3
|
+
errors?: Record<string, string>;
|
|
4
|
+
path: string;
|
|
5
|
+
/** Mounted in the canvas: cards carry the admin's classes and the light theme (spec 2026-09-10 §5). */
|
|
6
|
+
canvas?: boolean;
|
|
7
|
+
};
|
|
8
|
+
export declare function RichtextFormProvider({ value, children }: {
|
|
9
|
+
value: RichtextFormInfo;
|
|
10
|
+
children: ReactNode;
|
|
11
|
+
}): ReactElement;
|
|
12
|
+
export declare function useRichtextForm(): RichtextFormInfo;
|
|
13
|
+
/** A card's classes: inside the canvas it scopes the admin tokens to itself
|
|
14
|
+
* (`sm-admin`, light — the page stays light) and opts out of Tailwind
|
|
15
|
+
* typography (`not-prose`), which would otherwise style its internals. */
|
|
16
|
+
export declare function cardClass(selected: boolean, canvas: boolean | undefined): string;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
+
// What a card inside a richtext editor needs from the enclosing form: the
|
|
4
|
+
// error map and its own dotted path, so an element card can show
|
|
5
|
+
// `body.elements.2.heading` under the right input — and whether the
|
|
6
|
+
// editor is mounted in the canvas, where a card is admin UI on the page.
|
|
7
|
+
import { createContext, useContext } from "react";
|
|
8
|
+
const Ctx = createContext({ path: "" });
|
|
9
|
+
export function RichtextFormProvider({ value, children }) {
|
|
10
|
+
return _jsx(Ctx.Provider, { value: value, children: children });
|
|
11
|
+
}
|
|
12
|
+
export function useRichtextForm() { return useContext(Ctx); }
|
|
13
|
+
/** A card's classes: inside the canvas it scopes the admin tokens to itself
|
|
14
|
+
* (`sm-admin`, light — the page stays light) and opts out of Tailwind
|
|
15
|
+
* typography (`not-prose`), which would otherwise style its internals. */
|
|
16
|
+
export function cardClass(selected, canvas) {
|
|
17
|
+
return ["sm-rtcard", selected && "sm-rtcard--selected", canvas && "sm-admin not-prose"].filter(Boolean).join(" ");
|
|
18
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { Node as PmNode } from "@tiptap/pm/model";
|
|
2
|
+
import type { Allowed } from "../../toolset.ts";
|
|
3
|
+
import type { AdminElement } from "../serialize.ts";
|
|
4
|
+
export type InsertItem = {
|
|
5
|
+
kind: "image";
|
|
6
|
+
title: "Image";
|
|
7
|
+
icon: string;
|
|
8
|
+
} | {
|
|
9
|
+
kind: "element";
|
|
10
|
+
title: string;
|
|
11
|
+
icon: string;
|
|
12
|
+
element: AdminElement;
|
|
13
|
+
};
|
|
14
|
+
export declare function insertItems(allowed: Allowed, elements: AdminElement[]): InsertItem[];
|
|
15
|
+
export declare function filterItems(items: InsertItem[], query: string): InsertItem[];
|
|
16
|
+
export type ElementNodeJSON = {
|
|
17
|
+
type: "element";
|
|
18
|
+
attrs: {
|
|
19
|
+
name: string;
|
|
20
|
+
fields: Record<string, unknown>;
|
|
21
|
+
styles: Record<string, unknown>;
|
|
22
|
+
};
|
|
23
|
+
};
|
|
24
|
+
export type ImageNodeJSON = {
|
|
25
|
+
type: "image";
|
|
26
|
+
attrs: {
|
|
27
|
+
image: {
|
|
28
|
+
asset: string;
|
|
29
|
+
alt?: string;
|
|
30
|
+
};
|
|
31
|
+
caption: string;
|
|
32
|
+
};
|
|
33
|
+
};
|
|
34
|
+
/** A fresh element: sample first, then the empties, styles from their defaults — as a section insertion does. */
|
|
35
|
+
export declare function elementNode(element: AdminElement): ElementNodeJSON;
|
|
36
|
+
export declare function imageNode(assetId: string): ImageNodeJSON;
|
|
37
|
+
/** How many element nodes precede `pos` — the validator's `elements.<i>` index. */
|
|
38
|
+
export declare function elementIndexAt(doc: PmNode, pos: number): number;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { nodeFromSample } from "../tree-ops.js";
|
|
2
|
+
export function insertItems(allowed, elements) {
|
|
3
|
+
const byName = new Map(elements.map((e) => [e.name, e]));
|
|
4
|
+
const out = [];
|
|
5
|
+
if (allowed.image)
|
|
6
|
+
out.push({ kind: "image", title: "Image", icon: "▣" });
|
|
7
|
+
for (const name of allowed.elements) {
|
|
8
|
+
const element = byName.get(name);
|
|
9
|
+
if (element)
|
|
10
|
+
out.push({ kind: "element", title: element.title, icon: element.icon ?? "▤", element });
|
|
11
|
+
}
|
|
12
|
+
return out;
|
|
13
|
+
}
|
|
14
|
+
export function filterItems(items, query) {
|
|
15
|
+
const q = query.trim().toLowerCase();
|
|
16
|
+
return q === "" ? items : items.filter((i) => i.title.toLowerCase().includes(q));
|
|
17
|
+
}
|
|
18
|
+
/** A fresh element: sample first, then the empties, styles from their defaults — as a section insertion does. */
|
|
19
|
+
export function elementNode(element) {
|
|
20
|
+
const { fields, styles } = nodeFromSample(element, "");
|
|
21
|
+
return { type: "element", attrs: { name: element.name, fields, styles: styles ?? {} } };
|
|
22
|
+
}
|
|
23
|
+
export function imageNode(assetId) {
|
|
24
|
+
return { type: "image", attrs: { image: { asset: assetId }, caption: "" } };
|
|
25
|
+
}
|
|
26
|
+
/** How many element nodes precede `pos` — the validator's `elements.<i>` index. */
|
|
27
|
+
export function elementIndexAt(doc, pos) {
|
|
28
|
+
let i = 0;
|
|
29
|
+
doc.descendants((n, p) => {
|
|
30
|
+
if (p >= pos)
|
|
31
|
+
return false;
|
|
32
|
+
if (n.type.name === "element")
|
|
33
|
+
i++;
|
|
34
|
+
return true;
|
|
35
|
+
});
|
|
36
|
+
return i;
|
|
37
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { Extension, Node, type Editor } from "@tiptap/core";
|
|
2
|
+
import { type ReactNodeViewProps } from "@tiptap/react";
|
|
3
|
+
import type { ComponentType } from "react";
|
|
4
|
+
import type { InsertItem, ElementNodeJSON, ImageNodeJSON } from "./richtext-insert.ts";
|
|
5
|
+
type Views = {
|
|
6
|
+
image: ComponentType<ReactNodeViewProps> | null;
|
|
7
|
+
element: ComponentType<ReactNodeViewProps> | null;
|
|
8
|
+
};
|
|
9
|
+
/** Bind the card components once, before the first editor mounts. */
|
|
10
|
+
export declare function setNodeViews(next: Views): void;
|
|
11
|
+
export declare const ImageNode: Node<any, any>;
|
|
12
|
+
export declare const ElementNode: Node<any, any>;
|
|
13
|
+
/** Insert a block at `at` (default: the selection's end — after a selected
|
|
14
|
+
* card, never replacing it), followed by an empty paragraph so the caret
|
|
15
|
+
* lands somewhere. The position is explicit because a menu or a picker
|
|
16
|
+
* has taken focus in between, and a refocused contenteditable may place
|
|
17
|
+
* its DOM caret at the first block before the editor's own selection is
|
|
18
|
+
* restored; an empty paragraph at `at` is replaced, as TipTap does. */
|
|
19
|
+
export declare function insertBlock(editor: Editor, node: ElementNodeJSON | ImageNodeJSON, at?: number): void;
|
|
20
|
+
export type SlashState = {
|
|
21
|
+
items: InsertItem[];
|
|
22
|
+
rect: DOMRect | null;
|
|
23
|
+
pick(item: InsertItem): void;
|
|
24
|
+
};
|
|
25
|
+
export type SlashOptions = {
|
|
26
|
+
items(query: string): InsertItem[];
|
|
27
|
+
onState(state: SlashState | null): void;
|
|
28
|
+
/** Arrow, Enter and Escape while the menu is open; true when handled. */
|
|
29
|
+
onKeyDown(event: KeyboardEvent): boolean;
|
|
30
|
+
/** The pick: this editor's widget inserts (an image needs the picker first).
|
|
31
|
+
* Per editor, never a shared slot — a nested richtext editor inside an
|
|
32
|
+
* element card has its own widget and must not receive the outer pick. */
|
|
33
|
+
onPick(item: InsertItem): void;
|
|
34
|
+
};
|
|
35
|
+
/** `/` at the start of an empty paragraph opens the insert menu (spec §3). */
|
|
36
|
+
export declare const SlashMenu: Extension<SlashOptions, any>;
|
|
37
|
+
export {};
|
|
@@ -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;
|