smoodly 0.0.12 → 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 +219 -25
- 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 +6 -1
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
// The element node view (spec 2026-09-08 block nodes §3): the node view IS
|
|
4
|
+
// the form — a header (icon, title, collapse, delete) and the element's
|
|
5
|
+
// fields then styles through FieldWidget, reading and writing attrs.
|
|
6
|
+
// A richtext field inside mounts a nested RichtextWidget with its own
|
|
7
|
+
// toolset. Errors come from the enclosing form under
|
|
8
|
+
// `<path>.elements.<i>.<key>`, i being this node's index among the
|
|
9
|
+
// document's element nodes. Collapse is component state only: a card
|
|
10
|
+
// starts collapsed (2026-09-09) unless it holds nothing yet — a freshly
|
|
11
|
+
// inserted element opens, so its fields can be filled straight away.
|
|
12
|
+
import { useState } from "react";
|
|
13
|
+
import { NodeViewWrapper } from "@tiptap/react";
|
|
14
|
+
import { IconButton } from "../ui/primitives.js";
|
|
15
|
+
import { FieldWidget } from "./FieldWidget.js";
|
|
16
|
+
import { useElements } from "./elements-context.js";
|
|
17
|
+
import { cardClass, useRichtextForm } from "./richtext-form-context.js";
|
|
18
|
+
import { elementIndexAt } from "./richtext-insert.js";
|
|
19
|
+
/** No field holds a value yet — the state right after an insert. */
|
|
20
|
+
function isBlank(fields) {
|
|
21
|
+
return Object.values(fields).every((v) => v === undefined || v === null || v === "");
|
|
22
|
+
}
|
|
23
|
+
export function RichtextElementCard({ node, editor, getPos, updateAttributes, deleteNode, selected }) {
|
|
24
|
+
const elements = useElements();
|
|
25
|
+
const form = useRichtextForm();
|
|
26
|
+
const name = String(node.attrs.name ?? "");
|
|
27
|
+
const element = elements.find((e) => e.name === name);
|
|
28
|
+
const fields = (node.attrs.fields && typeof node.attrs.fields === "object" ? node.attrs.fields : {});
|
|
29
|
+
const styles = (node.attrs.styles && typeof node.attrs.styles === "object" ? node.attrs.styles : {});
|
|
30
|
+
const [open, setOpen] = useState(() => isBlank(fields));
|
|
31
|
+
const pos = getPos();
|
|
32
|
+
const index = typeof pos === "number" ? elementIndexAt(editor.state.doc, pos) : 0;
|
|
33
|
+
const base = `${form.path}.elements.${index}`;
|
|
34
|
+
const select = () => { if (typeof pos === "number")
|
|
35
|
+
editor.commands.setNodeSelection(pos); };
|
|
36
|
+
return (_jsxs(NodeViewWrapper, { className: cardClass(selected, form.canvas), "data-theme": form.canvas ? "light" : undefined, children: [_jsxs("div", { className: "sm-rtcard__head", "data-drag-handle": true, onClick: select, children: [_jsx("span", { className: "sm-rtcard__icon", "aria-hidden": "true", children: element?.icon ?? "▤" }), _jsx("span", { className: "sm-rtcard__title", children: element?.title ?? `Unknown element "${name}"` }), _jsx(IconButton, { label: open ? "Collapse" : "Expand", onClick: (e) => { e.stopPropagation(); setOpen((o) => !o); }, children: open ? "▾" : "▸" }), _jsx(IconButton, { label: "Delete element", onClick: (e) => { e.stopPropagation(); deleteNode(); }, children: "\u00D7" })] }), open && element && (_jsxs("div", { className: "sm-rtcard__body", contentEditable: false, children: [Object.entries(element.fields).map(([key, d]) => (_jsx(FieldWidget, { fieldKey: key, descriptor: d, value: fields[key], errors: form.errors, path: `${base}.${key}`, onChange: (v) => updateAttributes({ fields: { ...fields, [key]: v } }) }, key))), Object.entries(element.styles).map(([key, d]) => (_jsx(FieldWidget, { fieldKey: key, descriptor: d, value: styles[key], path: `${base}.styles.${key}`, onChange: (v) => updateAttributes({ styles: { ...styles, [key]: v } }) }, `style-${key}`)))] }))] }));
|
|
37
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
// The image node view (spec 2026-09-08 block nodes §3): thumbnail,
|
|
4
|
+
// filename, an alt input whose placeholder is the library's alt, a
|
|
5
|
+
// caption input, Replace and Delete. Attribute edits go through
|
|
6
|
+
// updateAttributes so the outer history sees them. Inputs stop keys from
|
|
7
|
+
// reaching the editor (TipTap's node view stops events from form
|
|
8
|
+
// controls), so Backspace in the caption never deletes the node.
|
|
9
|
+
import { useEffect, useState } from "react";
|
|
10
|
+
import { NodeViewWrapper } from "@tiptap/react";
|
|
11
|
+
import { IconButton } from "../ui/primitives.js";
|
|
12
|
+
import { useAssets } from "./assets-context.js";
|
|
13
|
+
import { MediaPicker } from "./MediaPicker.js";
|
|
14
|
+
import { cardClass, useRichtextForm } from "./richtext-form-context.js";
|
|
15
|
+
export function RichtextImageCard({ node, editor, getPos, updateAttributes, deleteNode, selected }) {
|
|
16
|
+
const assets = useAssets();
|
|
17
|
+
const form = useRichtextForm();
|
|
18
|
+
const value = (node.attrs.image ?? {});
|
|
19
|
+
const caption = typeof node.attrs.caption === "string" ? node.attrs.caption : "";
|
|
20
|
+
const id = typeof value.asset === "string" ? value.asset : null;
|
|
21
|
+
const [asset, setAsset] = useState(null);
|
|
22
|
+
const [picker, setPicker] = useState(false);
|
|
23
|
+
useEffect(() => {
|
|
24
|
+
if (!id) {
|
|
25
|
+
setAsset(null);
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
let live = true;
|
|
29
|
+
assets.get(id).then((r) => { if (live)
|
|
30
|
+
setAsset(r); });
|
|
31
|
+
return () => { live = false; };
|
|
32
|
+
}, [id, assets]);
|
|
33
|
+
const select = () => { const pos = getPos(); if (typeof pos === "number")
|
|
34
|
+
editor.commands.setNodeSelection(pos); };
|
|
35
|
+
const setAlt = (alt) => updateAttributes({ image: alt === "" ? { asset: id } : { asset: id, alt } });
|
|
36
|
+
const replace = (record) => {
|
|
37
|
+
assets.remember(record);
|
|
38
|
+
setPicker(false);
|
|
39
|
+
updateAttributes({ image: value.alt ? { asset: record.id, alt: value.alt } : { asset: record.id } });
|
|
40
|
+
};
|
|
41
|
+
return (_jsxs(NodeViewWrapper, { className: cardClass(selected, form.canvas), "data-theme": form.canvas ? "light" : undefined, "data-drag-handle": true, children: [_jsxs("div", { className: "sm-rtcard__head", onClick: select, children: [_jsx("span", { className: "sm-rtcard__icon", "aria-hidden": "true", children: "\u25A3" }), _jsx("span", { className: "sm-rtcard__title", children: "Image" }), _jsx(IconButton, { label: "Replace image", onClick: () => setPicker(true), children: "\u21BB" }), _jsx(IconButton, { label: "Delete image", onClick: () => deleteNode(), children: "\u00D7" })] }), _jsxs("div", { className: "sm-rtcard__body", contentEditable: false, children: [asset ? _jsx("img", { className: "sm-rtcard__thumb", src: asset.url, alt: "" }) : _jsx("div", { className: "sm-rtcard__thumb", style: { height: 80 } }), asset && _jsx("span", { className: "sm-rtcard__file", children: asset.filename }), _jsx("input", { className: "sm-input", "aria-label": "Alt text", placeholder: asset?.alt ?? "Alt text", value: value.alt ?? "", onChange: (e) => setAlt(e.target.value) }), _jsx("input", { className: "sm-input", "aria-label": "Caption", placeholder: "Caption", value: caption, onChange: (e) => updateAttributes({ caption: e.target.value }) })] }), picker && _jsx(MediaPicker, { kinds: ["image"], onPick: replace, onClose: () => setPicker(false) })] }));
|
|
42
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
const keepFocus = (e) => e.preventDefault();
|
|
4
|
+
export function RichtextInsertMenu({ items, active, onPick }) {
|
|
5
|
+
return (_jsxs("div", { className: "sm-picker sm-richtext__insert", role: "listbox", "aria-label": "Insert", children: [items.length === 0 && _jsx("div", { className: "sm-richtext__insert-empty", children: "Nothing matches" }), items.map((item, i) => (_jsxs("button", { type: "button", role: "option", "aria-selected": i === active, className: i === active ? "sm-picker__item sm-picker__item--active" : "sm-picker__item", onMouseDown: keepFocus, onClick: () => onPick(item), children: [_jsx("span", { className: "sm-picker__icon", children: item.icon }), _jsx("span", { style: { fontWeight: 600 }, children: item.title })] }, `${item.kind}-${item.title}`)))] }));
|
|
6
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { RichtextApi } from "./useRichtextEditor.ts";
|
|
2
|
+
export declare function RichtextMenu({ menu }: {
|
|
3
|
+
menu: RichtextApi["menu"];
|
|
4
|
+
}): import("react").JSX.Element;
|
|
5
|
+
export declare function RichtextLinkRow({ link }: {
|
|
6
|
+
link: RichtextApi["link"];
|
|
7
|
+
}): import("react").JSX.Element;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
// The richtext editor's two popovers, shared by the sidebar widget and
|
|
4
|
+
// the canvas toolbar (spec 2026-09-10 §5): the "+" insert menu with its
|
|
5
|
+
// filter, and the link row. Presentational over the hook's state.
|
|
6
|
+
import { Button } from "../ui/primitives.js";
|
|
7
|
+
import { RichtextInsertMenu } from "./RichtextInsertMenu.js";
|
|
8
|
+
export function RichtextMenu({ menu }) {
|
|
9
|
+
// Focus leaving the menu (a click anywhere else) closes it, without stealing focus back to the editor.
|
|
10
|
+
return (_jsxs("div", { className: "sm-richtext__menu", onBlur: menu.dismiss, children: [_jsx("input", { className: "sm-input", placeholder: "Filter\u2026", value: menu.query, autoFocus: true, "aria-label": "Insert filter", onChange: (e) => menu.setQuery(e.target.value), onKeyDown: menu.onKeyDown }), _jsx(RichtextInsertMenu, { items: menu.items, active: menu.active, onPick: menu.pick })] }));
|
|
11
|
+
}
|
|
12
|
+
export function RichtextLinkRow({ link }) {
|
|
13
|
+
return (_jsxs("div", { className: "sm-richtext__link", children: [_jsx("input", { className: "sm-input", placeholder: "https://\u2026 or /path", value: link.href, autoFocus: true, "aria-label": "Link URL", onChange: (e) => link.setHref(e.target.value), onKeyDown: (e) => {
|
|
14
|
+
// Escape must not reach the editor's window handler, which clears the block selection.
|
|
15
|
+
if (e.key === "Enter") {
|
|
16
|
+
e.preventDefault();
|
|
17
|
+
link.apply();
|
|
18
|
+
}
|
|
19
|
+
if (e.key === "Escape") {
|
|
20
|
+
e.preventDefault();
|
|
21
|
+
e.stopPropagation();
|
|
22
|
+
link.close();
|
|
23
|
+
}
|
|
24
|
+
} }), _jsx(Button, { onClick: link.apply, children: "Apply" }), _jsx(Button, { variant: "secondary", onClick: link.remove, children: "Remove" })] }));
|
|
25
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Allowed } from "../../toolset.ts";
|
|
2
|
-
export type ToolbarCommand = "bold" | "italic" | "link" | "bulletList" | "orderedList" | "blockquote" | {
|
|
2
|
+
export type ToolbarCommand = "bold" | "italic" | "link" | "bulletList" | "orderedList" | "blockquote" | "insert" | {
|
|
3
3
|
heading: number;
|
|
4
4
|
};
|
|
5
5
|
export type ActiveState = {
|
|
@@ -13,8 +13,10 @@ export type ActiveState = {
|
|
|
13
13
|
heading: number | null;
|
|
14
14
|
};
|
|
15
15
|
export declare const INACTIVE: ActiveState;
|
|
16
|
-
export declare function RichtextToolbar({ allowed, active, onCommand }: {
|
|
16
|
+
export declare function RichtextToolbar({ allowed, active, canInsert, onCommand }: {
|
|
17
17
|
allowed: Allowed;
|
|
18
18
|
active: ActiveState;
|
|
19
|
+
/** Whether the toolset allows an image or an element — the "+" button. */
|
|
20
|
+
canInsert: boolean;
|
|
19
21
|
onCommand(command: ToolbarCommand): void;
|
|
20
22
|
}): import("react").JSX.Element | null;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
-
import { Bold, Heading1, Heading2, Heading3, Heading4, Heading5, Heading6, Italic, Link, List, ListOrdered, Quote } from "lucide-react";
|
|
3
|
+
import { Bold, Heading1, Heading2, Heading3, Heading4, Heading5, Heading6, Italic, Link, List, ListOrdered, Plus, Quote } from "lucide-react";
|
|
4
4
|
import { IconButton } from "../ui/primitives.js";
|
|
5
5
|
export const INACTIVE = {
|
|
6
6
|
bold: false, italic: false, link: false, bulletList: false, orderedList: false, blockquote: false, heading: null,
|
|
@@ -9,7 +9,7 @@ const HEADING_ICONS = { 1: Heading1, 2: Heading2, 3: Heading3, 4: Heading4, 5: H
|
|
|
9
9
|
const ICON = 14;
|
|
10
10
|
/** Keep the editor's selection: a toolbar press must not blur the content. */
|
|
11
11
|
const keepFocus = (e) => e.preventDefault();
|
|
12
|
-
export function RichtextToolbar({ allowed, active, onCommand }) {
|
|
12
|
+
export function RichtextToolbar({ allowed, active, canInsert, onCommand }) {
|
|
13
13
|
const button = (label, pressed, command, Icon) => (_jsx(IconButton, { label: label, "aria-pressed": pressed, onMouseDown: keepFocus, onClick: () => onCommand(command), children: _jsx(Icon, { size: ICON, "aria-hidden": "true" }) }, label));
|
|
14
14
|
const groups = [];
|
|
15
15
|
const marks = [
|
|
@@ -31,6 +31,8 @@ export function RichtextToolbar({ allowed, active, onCommand }) {
|
|
|
31
31
|
].filter(Boolean);
|
|
32
32
|
if (blocks.length > 0)
|
|
33
33
|
groups.push(blocks);
|
|
34
|
+
if (canInsert)
|
|
35
|
+
groups.push([button("Insert", false, "insert", Plus)]);
|
|
34
36
|
if (groups.length === 0)
|
|
35
37
|
return null;
|
|
36
38
|
return (_jsx("div", { className: "sm-richtext__bar", role: "toolbar", "aria-label": "Formatting", children: groups.map((group, i) => (_jsxs("span", { style: { display: "contents" }, children: [i > 0 && _jsx("span", { className: "sm-richtext__sep", "aria-hidden": "true" }), group] }, i))) }));
|
|
@@ -1,5 +1,8 @@
|
|
|
1
|
-
export declare function RichtextWidget({ value, toolset, onChange }: {
|
|
1
|
+
export declare function RichtextWidget({ value, toolset, onChange, errors, path }: {
|
|
2
2
|
value: unknown;
|
|
3
3
|
toolset: unknown;
|
|
4
4
|
onChange(doc: unknown): void;
|
|
5
|
+
/** The enclosing form's errors and this field's dotted path, for the cards. */
|
|
6
|
+
errors?: Record<string, string>;
|
|
7
|
+
path?: string;
|
|
5
8
|
}): import("react").JSX.Element;
|
|
@@ -1,123 +1,26 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
-
// The richtext field widget (spec 2026-09-08 §3):
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
const
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
const onChangeRef = useRef(onChange);
|
|
27
|
-
onChangeRef.current = onChange;
|
|
28
|
-
// The last document this widget emitted — anything else arriving as
|
|
29
|
-
// `value` came from outside and re-seeds the editor.
|
|
30
|
-
const emitted = useRef(value);
|
|
31
|
-
const [link, setLink] = useState({ open: false, href: "" });
|
|
32
|
-
const openLinkRef = useRef(() => { });
|
|
33
|
-
const editor = useEditor({
|
|
34
|
-
immediatelyRender: false,
|
|
35
|
-
extensions: richtextExtensions(allowed, { onLink: () => openLinkRef.current() }),
|
|
36
|
-
content: isDoc(value) ? value : EMPTY,
|
|
37
|
-
editorProps: {
|
|
38
|
-
attributes: { class: "sm-richtext__content" },
|
|
39
|
-
transformPasted: (slice, view) => Slice.fromJSON(view.state.schema, gatePastedSlice(slice.toJSON(), allowed)),
|
|
40
|
-
},
|
|
41
|
-
onUpdate: ({ editor }) => {
|
|
42
|
-
const doc = plainDoc(editor.getJSON());
|
|
43
|
-
emitted.current = doc;
|
|
44
|
-
onChangeRef.current(doc);
|
|
45
|
-
},
|
|
46
|
-
}, [allowed]);
|
|
47
|
-
useEffect(() => {
|
|
48
|
-
if (!editor || value === emitted.current)
|
|
49
|
-
return;
|
|
50
|
-
emitted.current = value;
|
|
51
|
-
editor.commands.setContent(isDoc(value) ? value : EMPTY, { emitUpdate: false });
|
|
52
|
-
}, [editor, value]);
|
|
53
|
-
const active = useEditorState({
|
|
54
|
-
editor,
|
|
55
|
-
selector: ({ editor }) => editor
|
|
56
|
-
? {
|
|
57
|
-
bold: editor.isActive("bold"),
|
|
58
|
-
italic: editor.isActive("italic"),
|
|
59
|
-
link: editor.isActive("link"),
|
|
60
|
-
bulletList: editor.isActive("bulletList"),
|
|
61
|
-
orderedList: editor.isActive("orderedList"),
|
|
62
|
-
blockquote: editor.isActive("blockquote"),
|
|
63
|
-
heading: LEVELS.find((level) => editor.isActive("heading", { level })) ?? null,
|
|
64
|
-
}
|
|
65
|
-
: INACTIVE,
|
|
66
|
-
});
|
|
67
|
-
// Read the caret's link from the editor at open time, not from the last
|
|
68
|
-
// render: a selection change and the press can land in the same tick.
|
|
69
|
-
const openLink = () => setLink({ open: true, href: editor?.getAttributes("link").href ?? "" });
|
|
70
|
-
openLinkRef.current = openLink;
|
|
71
|
-
const closeLink = () => {
|
|
72
|
-
setLink({ open: false, href: "" });
|
|
73
|
-
editor?.commands.focus();
|
|
74
|
-
};
|
|
75
|
-
const applyLink = () => {
|
|
76
|
-
if (!editor)
|
|
77
|
-
return;
|
|
78
|
-
const href = link.href.trim();
|
|
79
|
-
const chain = editor.chain().focus().extendMarkRange("link");
|
|
80
|
-
if (href === "")
|
|
81
|
-
chain.unsetLink().run();
|
|
82
|
-
else
|
|
83
|
-
chain.setLink({ href }).run();
|
|
84
|
-
setLink({ open: false, href: "" });
|
|
85
|
-
};
|
|
86
|
-
const removeLink = () => {
|
|
87
|
-
editor?.chain().focus().extendMarkRange("link").unsetLink().run();
|
|
88
|
-
setLink({ open: false, href: "" });
|
|
89
|
-
};
|
|
90
|
-
const run = (command) => {
|
|
91
|
-
if (!editor)
|
|
92
|
-
return;
|
|
93
|
-
if (command === "link") {
|
|
94
|
-
openLink();
|
|
95
|
-
return;
|
|
96
|
-
}
|
|
97
|
-
const chain = editor.chain().focus();
|
|
98
|
-
if (typeof command === "object")
|
|
99
|
-
chain.toggleHeading({ level: command.heading }).run();
|
|
100
|
-
else if (command === "bold")
|
|
101
|
-
chain.toggleBold().run();
|
|
102
|
-
else if (command === "italic")
|
|
103
|
-
chain.toggleItalic().run();
|
|
104
|
-
else if (command === "bulletList")
|
|
105
|
-
chain.toggleBulletList().run();
|
|
106
|
-
else if (command === "orderedList")
|
|
107
|
-
chain.toggleOrderedList().run();
|
|
108
|
-
else if (command === "blockquote")
|
|
109
|
-
chain.toggleBlockquote().run();
|
|
110
|
-
};
|
|
111
|
-
return (_jsxs("div", { className: "sm-richtext", children: [_jsx(RichtextToolbar, { allowed: allowed, active: active ?? INACTIVE, onCommand: run }), link.open && (_jsxs("div", { className: "sm-richtext__link", children: [_jsx("input", { className: "sm-input", placeholder: "https://\u2026 or /path", value: link.href, autoFocus: true, "aria-label": "Link URL", onChange: (e) => setLink({ open: true, href: e.target.value }), onKeyDown: (e) => {
|
|
112
|
-
// Escape must not reach the editor's window handler, which clears the block selection.
|
|
113
|
-
if (e.key === "Enter") {
|
|
114
|
-
e.preventDefault();
|
|
115
|
-
applyLink();
|
|
116
|
-
}
|
|
117
|
-
if (e.key === "Escape") {
|
|
118
|
-
e.preventDefault();
|
|
119
|
-
e.stopPropagation();
|
|
120
|
-
closeLink();
|
|
121
|
-
}
|
|
122
|
-
} }), _jsx(Button, { onClick: applyLink, children: "Apply" }), _jsx(Button, { variant: "secondary", onClick: removeLink, children: "Remove" })] })), _jsx(EditorContent, { editor: editor })] }));
|
|
3
|
+
// The richtext field widget (spec 2026-09-08 §3): the shared editor
|
|
4
|
+
// (useRichtextEditor) under a fixed toolbar, the "+" menu floating under
|
|
5
|
+
// the toolbar (never pushing the content), the slash menu under the
|
|
6
|
+
// caret, the picker as a modal. Inside the canvas (spec 2026-09-10 §5) a
|
|
7
|
+
// nested widget in an element card inherits the form context's `canvas`
|
|
8
|
+
// flag, so its own cards stay admin-styled.
|
|
9
|
+
import { useMemo } from "react";
|
|
10
|
+
import { EditorContent } from "@tiptap/react";
|
|
11
|
+
import { RichtextToolbar } from "./RichtextToolbar.js";
|
|
12
|
+
import { RichtextInsertMenu } from "./RichtextInsertMenu.js";
|
|
13
|
+
import { RichtextLinkRow, RichtextMenu } from "./RichtextPopovers.js";
|
|
14
|
+
import { RichtextFormProvider, useRichtextForm } from "./richtext-form-context.js";
|
|
15
|
+
import { MediaPicker } from "./MediaPicker.js";
|
|
16
|
+
import { useRichtextEditor } from "./useRichtextEditor.js";
|
|
17
|
+
export function RichtextWidget({ value, toolset, onChange, errors, path = "" }) {
|
|
18
|
+
const rt = useRichtextEditor({ value, toolset, onChange, contentClass: "sm-richtext__content" });
|
|
19
|
+
const parent = useRichtextForm();
|
|
20
|
+
const formInfo = useMemo(() => ({ errors, path, canvas: parent.canvas }), [errors, path, parent.canvas]);
|
|
21
|
+
return (_jsx(RichtextFormProvider, { value: formInfo, children: _jsxs("div", { className: "sm-richtext", children: [_jsxs("div", { className: "sm-richtext__head", children: [_jsx(RichtextToolbar, { allowed: rt.allowed, active: rt.active, canInsert: rt.canInsert, onCommand: rt.run }), rt.menu.open && _jsx(RichtextMenu, { menu: rt.menu })] }), rt.link.open && _jsx(RichtextLinkRow, { link: rt.link }), _jsxs("div", { className: "sm-richtext__body", children: [_jsx(EditorContent, { editor: rt.editor }), rt.slash && (_jsx("div", { className: "sm-richtext__slash", style: { top: rt.slash.state.rect ? rt.slash.state.rect.bottom - (editorTop(rt.editor) ?? 0) + 4 : 0 }, children: _jsx(RichtextInsertMenu, { items: rt.slash.state.items, active: rt.slash.active, onPick: (item) => rt.slash?.state.pick(item) }) }))] }), rt.picker.open && _jsx(MediaPicker, { kinds: ["image"], onPick: rt.picker.pick, onClose: rt.picker.close })] }) }));
|
|
22
|
+
}
|
|
23
|
+
/** The editor's top in viewport coordinates, to place the slash menu relative to the body wrapper. */
|
|
24
|
+
function editorTop(editor) {
|
|
25
|
+
return editor?.view.dom.parentElement?.getBoundingClientRect().top;
|
|
123
26
|
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { type ReactElement, type ReactNode } from "react";
|
|
2
|
+
import type { AdminElement } from "../serialize.ts";
|
|
3
|
+
export declare function ElementsProvider({ elements, children }: {
|
|
4
|
+
elements: AdminElement[];
|
|
5
|
+
children: ReactNode;
|
|
6
|
+
}): ReactElement;
|
|
7
|
+
export declare function useElements(): AdminElement[];
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
+
// The serialized element descriptors, for the richtext widget's insert
|
|
4
|
+
// menu and element cards. Defaults to none, so a form rendered without
|
|
5
|
+
// the shell (tests, a bare page) still works.
|
|
6
|
+
import { createContext, useContext } from "react";
|
|
7
|
+
const Ctx = createContext([]);
|
|
8
|
+
export function ElementsProvider({ elements, children }) {
|
|
9
|
+
return _jsx(Ctx.Provider, { value: elements, children: children });
|
|
10
|
+
}
|
|
11
|
+
export function useElements() {
|
|
12
|
+
return useContext(Ctx);
|
|
13
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export declare const REVEAL_CLASS = "sm-field--reveal";
|
|
2
|
+
export declare const PATH_ATTR = "data-sm-path";
|
|
3
|
+
/** The path, then each enclosing path, outermost last. */
|
|
4
|
+
export declare function ancestorsOf(path: string): string[];
|
|
5
|
+
export declare function revealField(root: ParentNode, path: string): void;
|
|
@@ -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 {};
|