@cosmicdrift/kumiko-renderer-web 0.182.0 → 0.183.0
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-renderer-web",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.183.0",
|
|
4
4
|
"description": "Web-platform bindings for @cosmicdrift/kumiko-renderer. HTML default-primitives, browser history-based navigation, EventSource-backed live events, and a one-call createKumikoApp that mounts the whole stack via react-dom.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -16,9 +16,9 @@
|
|
|
16
16
|
"./styles.css": "./src/styles.css"
|
|
17
17
|
},
|
|
18
18
|
"dependencies": {
|
|
19
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
20
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
21
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
19
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.183.0",
|
|
20
|
+
"@cosmicdrift/kumiko-headless": "0.183.0",
|
|
21
|
+
"@cosmicdrift/kumiko-renderer": "0.183.0",
|
|
22
22
|
"@radix-ui/react-dialog": "^1.1.15",
|
|
23
23
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
|
24
24
|
"@radix-ui/react-label": "^2.1.8",
|
|
@@ -38,7 +38,11 @@
|
|
|
38
38
|
"react-dom": "^19.2.6",
|
|
39
39
|
"react-resizable-panels": "^4.12.2",
|
|
40
40
|
"tailwind-merge": "^3.6.0",
|
|
41
|
-
"temporal-polyfill": "^0.3.2"
|
|
41
|
+
"temporal-polyfill": "^0.3.2",
|
|
42
|
+
"@tiptap/core": "^3.29.2",
|
|
43
|
+
"@tiptap/pm": "^3.29.2",
|
|
44
|
+
"@tiptap/react": "^3.29.2",
|
|
45
|
+
"@tiptap/starter-kit": "^3.29.2"
|
|
42
46
|
},
|
|
43
47
|
"peerDependencies": {
|
|
44
48
|
"@tailwindcss/cli": "^4.3.0",
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { CONTENT_EDITOR_ELEMENT_ID } from "@cosmicdrift/kumiko-renderer";
|
|
3
|
+
import { render, screen } from "../../__tests__/test-utils";
|
|
4
|
+
import { RichContentEditor } from "../rich-content-editor";
|
|
5
|
+
|
|
6
|
+
describe("RichContentEditor", () => {
|
|
7
|
+
test("falls back to the plain textarea while the tiptap chunk loads, then swaps in the editor", async () => {
|
|
8
|
+
render(
|
|
9
|
+
<RichContentEditor
|
|
10
|
+
value="<p>hello</p>"
|
|
11
|
+
onChange={() => {}}
|
|
12
|
+
variables={[]}
|
|
13
|
+
readOnly={false}
|
|
14
|
+
/>,
|
|
15
|
+
);
|
|
16
|
+
// The textarea id proves #1794's "never an empty panel" fallback is the
|
|
17
|
+
// Suspense boundary — a null fallback would leave nothing to find here.
|
|
18
|
+
expect(document.getElementById(CONTENT_EDITOR_ELEMENT_ID)).not.toBeNull();
|
|
19
|
+
expect(await screen.findByText("hello")).toBeTruthy();
|
|
20
|
+
});
|
|
21
|
+
});
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { CONTENT_EDITOR_ELEMENT_ID } from "@cosmicdrift/kumiko-renderer";
|
|
3
|
+
import { type ReactNode, useState } from "react";
|
|
4
|
+
import { fireEvent, render, screen } from "../../__tests__/test-utils";
|
|
5
|
+
import TiptapEditor from "../tiptap-editor";
|
|
6
|
+
|
|
7
|
+
function Controlled({ initial }: { readonly initial: string }): ReactNode {
|
|
8
|
+
const [value, setValue] = useState(initial);
|
|
9
|
+
return <TiptapEditor value={value} onChange={setValue} variables={["name"]} readOnly={false} />;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// Mirrors the real caller: TextBlockEditor mounts with value="" and only
|
|
13
|
+
// gets the loaded content after its by-slug query resolves — value arrives
|
|
14
|
+
// as a prop update, not at mount. A test that renders the final HTML
|
|
15
|
+
// straight away would stay green even if the sync effect were missing.
|
|
16
|
+
function LateValue({ loaded }: { readonly loaded: string }): ReactNode {
|
|
17
|
+
const [value, setValue] = useState("");
|
|
18
|
+
return (
|
|
19
|
+
<div>
|
|
20
|
+
<button type="button" onClick={() => setValue(loaded)}>
|
|
21
|
+
load
|
|
22
|
+
</button>
|
|
23
|
+
<TiptapEditor value={value} onChange={setValue} variables={[]} readOnly={false} />
|
|
24
|
+
</div>
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe("TiptapEditor — jsdom smoke", () => {
|
|
29
|
+
test("mounts a contenteditable surface for the given HTML", async () => {
|
|
30
|
+
render(
|
|
31
|
+
<TiptapEditor value="<p>hello</p>" onChange={() => {}} variables={[]} readOnly={false} />,
|
|
32
|
+
);
|
|
33
|
+
const editable = await screen.findByText("hello");
|
|
34
|
+
expect(editable.closest('[contenteditable="true"]')).not.toBeNull();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("the contenteditable surface carries CONTENT_EDITOR_ELEMENT_ID so the wrapping Field's label stays associated", async () => {
|
|
38
|
+
render(
|
|
39
|
+
<TiptapEditor value="<p>hello</p>" onChange={() => {}} variables={[]} readOnly={false} />,
|
|
40
|
+
);
|
|
41
|
+
expect(document.getElementById(CONTENT_EDITOR_ELEMENT_ID)).not.toBeNull();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("no variables → no chip bar", async () => {
|
|
45
|
+
render(
|
|
46
|
+
<TiptapEditor value="<p>hello</p>" onChange={() => {}} variables={[]} readOnly={false} />,
|
|
47
|
+
);
|
|
48
|
+
await screen.findByText("hello");
|
|
49
|
+
expect(screen.queryAllByRole("button", { name: "Bold" })).toHaveLength(1);
|
|
50
|
+
expect(screen.queryByText(/^\{\{.*\}\}$/)).toBeNull();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("every toolbar action resolves by its accessible name", async () => {
|
|
54
|
+
render(
|
|
55
|
+
<TiptapEditor value="<p>hello</p>" onChange={() => {}} variables={[]} readOnly={false} />,
|
|
56
|
+
);
|
|
57
|
+
await screen.findByText("hello");
|
|
58
|
+
for (const name of [
|
|
59
|
+
"Bold",
|
|
60
|
+
"Italic",
|
|
61
|
+
"Heading 1",
|
|
62
|
+
"Heading 2",
|
|
63
|
+
"Bullet list",
|
|
64
|
+
"Numbered list",
|
|
65
|
+
]) {
|
|
66
|
+
expect(screen.getByRole("button", { name })).toBeTruthy();
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("a value arriving after mount (async load) still reaches the editor", async () => {
|
|
71
|
+
render(<LateValue loaded="<p>loaded content</p>" />);
|
|
72
|
+
expect(screen.queryByText("loaded content")).toBeNull();
|
|
73
|
+
fireEvent.click(screen.getByText("load"));
|
|
74
|
+
expect(await screen.findByText("loaded content")).toBeTruthy();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("toolbar H1 toggles the block type and reports it via onChange", async () => {
|
|
78
|
+
// Block-type commands (headings) apply at the cursor's block, unlike
|
|
79
|
+
// inline marks (bold/italic) which need an explicit text selection —
|
|
80
|
+
// simulating a real range selection in happy-dom is unreliable, so this
|
|
81
|
+
// is the toolbar-wiring smoke test; the extension itself is tiptap's.
|
|
82
|
+
render(<Controlled initial="<p>hello</p>" />);
|
|
83
|
+
await screen.findByText("hello");
|
|
84
|
+
fireEvent.click(screen.getByRole("button", { name: "Heading 1" }));
|
|
85
|
+
const editable = (await screen.findByText("hello")).closest('[contenteditable="true"]');
|
|
86
|
+
expect(editable?.innerHTML).toContain("<h1");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("variable chip inserts {{name}} as plain text at the cursor", async () => {
|
|
90
|
+
render(<Controlled initial="<p></p>" />);
|
|
91
|
+
fireEvent.click(screen.getByText("{{name}}"));
|
|
92
|
+
expect(await screen.findByText("{{name}}", { selector: "p" })).toBeTruthy();
|
|
93
|
+
});
|
|
94
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// @runtime client
|
|
2
|
+
//
|
|
3
|
+
// "rich" contentFormat editor: WYSIWYG on tiptap. Public contract is the
|
|
4
|
+
// same four props every ContentEditorComponent gets — no tiptap type
|
|
5
|
+
// crosses this boundary. tiptap itself is dynamic-imported (TiptapEditor
|
|
6
|
+
// lives in ./tiptap-editor) so an app that never mounts a "rich" collection
|
|
7
|
+
// never pays for it; the fallback while the chunk loads is the same plain
|
|
8
|
+
// textarea #1794 uses for a missing editor, never a blank panel.
|
|
9
|
+
|
|
10
|
+
import { type ContentEditorProps, TextareaContentEditor } from "@cosmicdrift/kumiko-renderer";
|
|
11
|
+
import { lazy, type ReactNode, Suspense } from "react";
|
|
12
|
+
|
|
13
|
+
const TiptapEditor = lazy(() => import("./tiptap-editor"));
|
|
14
|
+
|
|
15
|
+
export function RichContentEditor(props: ContentEditorProps): ReactNode {
|
|
16
|
+
return (
|
|
17
|
+
<Suspense fallback={<TextareaContentEditor {...props} />}>
|
|
18
|
+
<TiptapEditor {...props} />
|
|
19
|
+
</Suspense>
|
|
20
|
+
);
|
|
21
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
// @runtime client
|
|
2
|
+
//
|
|
3
|
+
// tiptap-backed implementation behind RichContentEditor's lazy() boundary —
|
|
4
|
+
// never import this file directly, tiptap must only load when a "rich"
|
|
5
|
+
// collection is actually rendered. StarterKit covers bold/italic/lists/
|
|
6
|
+
// headings, and its bundled Link extension autolinks on type/paste — no
|
|
7
|
+
// link button, so no URL-prompt UI to build or test.
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
CONTENT_EDITOR_ELEMENT_ID,
|
|
11
|
+
type ContentEditorProps,
|
|
12
|
+
TextareaContentEditor,
|
|
13
|
+
usePrimitives,
|
|
14
|
+
useTranslation,
|
|
15
|
+
VariableChips,
|
|
16
|
+
} from "@cosmicdrift/kumiko-renderer";
|
|
17
|
+
import { EditorContent, useEditor } from "@tiptap/react";
|
|
18
|
+
import StarterKit from "@tiptap/starter-kit";
|
|
19
|
+
import {
|
|
20
|
+
Bold as BoldIcon,
|
|
21
|
+
Heading1,
|
|
22
|
+
Heading2,
|
|
23
|
+
Italic as ItalicIcon,
|
|
24
|
+
List,
|
|
25
|
+
ListOrdered,
|
|
26
|
+
} from "lucide-react";
|
|
27
|
+
import { type ReactNode, useEffect } from "react";
|
|
28
|
+
import { cn } from "../lib/cn";
|
|
29
|
+
|
|
30
|
+
type ToolbarAction = {
|
|
31
|
+
readonly label: string;
|
|
32
|
+
readonly icon: typeof BoldIcon;
|
|
33
|
+
readonly isActive: boolean;
|
|
34
|
+
readonly onClick: () => void;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
function Toolbar({
|
|
38
|
+
actions,
|
|
39
|
+
disabled,
|
|
40
|
+
}: {
|
|
41
|
+
readonly actions: readonly ToolbarAction[];
|
|
42
|
+
readonly disabled: boolean;
|
|
43
|
+
}): ReactNode {
|
|
44
|
+
const { Button } = usePrimitives();
|
|
45
|
+
return (
|
|
46
|
+
<div className="flex flex-wrap gap-1 border-b border-input p-1">
|
|
47
|
+
{actions.map((action) => (
|
|
48
|
+
<Button
|
|
49
|
+
key={action.label}
|
|
50
|
+
type="button"
|
|
51
|
+
variant={action.isActive ? "primary" : "secondary"}
|
|
52
|
+
size="icon"
|
|
53
|
+
disabled={disabled}
|
|
54
|
+
onClick={action.onClick}
|
|
55
|
+
ariaLabel={action.label}
|
|
56
|
+
>
|
|
57
|
+
<action.icon size={16} />
|
|
58
|
+
</Button>
|
|
59
|
+
))}
|
|
60
|
+
</div>
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export default function TiptapEditor({
|
|
65
|
+
value,
|
|
66
|
+
onChange,
|
|
67
|
+
variables,
|
|
68
|
+
readOnly,
|
|
69
|
+
}: ContentEditorProps): ReactNode {
|
|
70
|
+
const t = useTranslation();
|
|
71
|
+
const editor = useEditor({
|
|
72
|
+
extensions: [StarterKit],
|
|
73
|
+
content: value,
|
|
74
|
+
editable: !readOnly,
|
|
75
|
+
// Suspense (RichContentEditor's lazy boundary) speculatively mounts
|
|
76
|
+
// this component before committing it; immediate render would create
|
|
77
|
+
// an editor instance during that throwaway pass.
|
|
78
|
+
immediatelyRender: false,
|
|
79
|
+
// Same id the textarea fallback uses — the Field wrapping the editor
|
|
80
|
+
// (TextBlockEditor) associates its label via this id; the editor
|
|
81
|
+
// contract has no `id` prop of its own, see content-editors.tsx.
|
|
82
|
+
editorProps: { attributes: { id: CONTENT_EDITOR_ELEMENT_ID } },
|
|
83
|
+
onUpdate: ({ editor: e }) => onChange(e.getHTML()),
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
// TextBlockEditor loads the entry's content asynchronously (by-slug query
|
|
87
|
+
// resolves after mount), so `value` arrives after useEditor already read
|
|
88
|
+
// its initial (empty) content once. Sync it in — guarded against
|
|
89
|
+
// clobbering in-flight typing by comparing against the editor's own HTML.
|
|
90
|
+
useEffect(() => {
|
|
91
|
+
if (editor && value !== editor.getHTML()) {
|
|
92
|
+
editor.commands.setContent(value, { emitUpdate: false });
|
|
93
|
+
}
|
|
94
|
+
}, [value, editor]);
|
|
95
|
+
|
|
96
|
+
useEffect(() => {
|
|
97
|
+
editor?.setEditable(!readOnly);
|
|
98
|
+
}, [readOnly, editor]);
|
|
99
|
+
|
|
100
|
+
if (!editor)
|
|
101
|
+
return (
|
|
102
|
+
<TextareaContentEditor
|
|
103
|
+
value={value}
|
|
104
|
+
onChange={onChange}
|
|
105
|
+
variables={variables}
|
|
106
|
+
readOnly={readOnly}
|
|
107
|
+
/>
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
const actions: readonly ToolbarAction[] = [
|
|
111
|
+
{
|
|
112
|
+
label: t("kumiko.contentEditor.bold"),
|
|
113
|
+
icon: BoldIcon,
|
|
114
|
+
isActive: editor.isActive("bold"),
|
|
115
|
+
onClick: () => editor.chain().focus().toggleBold().run(),
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
label: t("kumiko.contentEditor.italic"),
|
|
119
|
+
icon: ItalicIcon,
|
|
120
|
+
isActive: editor.isActive("italic"),
|
|
121
|
+
onClick: () => editor.chain().focus().toggleItalic().run(),
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
label: t("kumiko.contentEditor.heading1"),
|
|
125
|
+
icon: Heading1,
|
|
126
|
+
isActive: editor.isActive("heading", { level: 1 }),
|
|
127
|
+
onClick: () => editor.chain().focus().toggleHeading({ level: 1 }).run(),
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
label: t("kumiko.contentEditor.heading2"),
|
|
131
|
+
icon: Heading2,
|
|
132
|
+
isActive: editor.isActive("heading", { level: 2 }),
|
|
133
|
+
onClick: () => editor.chain().focus().toggleHeading({ level: 2 }).run(),
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
label: t("kumiko.contentEditor.bulletList"),
|
|
137
|
+
icon: List,
|
|
138
|
+
isActive: editor.isActive("bulletList"),
|
|
139
|
+
onClick: () => editor.chain().focus().toggleBulletList().run(),
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
label: t("kumiko.contentEditor.orderedList"),
|
|
143
|
+
icon: ListOrdered,
|
|
144
|
+
isActive: editor.isActive("orderedList"),
|
|
145
|
+
onClick: () => editor.chain().focus().toggleOrderedList().run(),
|
|
146
|
+
},
|
|
147
|
+
];
|
|
148
|
+
|
|
149
|
+
const insertVariable = (name: string): void => {
|
|
150
|
+
editor
|
|
151
|
+
.chain()
|
|
152
|
+
.focus()
|
|
153
|
+
.insertContent({ type: "text", text: `{{${name}}}` })
|
|
154
|
+
.run();
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
return (
|
|
158
|
+
<div className="rounded-md border border-input bg-transparent">
|
|
159
|
+
<Toolbar actions={actions} disabled={readOnly} />
|
|
160
|
+
<EditorContent
|
|
161
|
+
editor={editor}
|
|
162
|
+
className={cn(
|
|
163
|
+
"min-h-40 px-3 py-2 text-base outline-none md:text-sm",
|
|
164
|
+
"[&_.ProseMirror]:min-h-40 [&_.ProseMirror]:outline-none",
|
|
165
|
+
"[&_ul]:list-disc [&_ol]:list-decimal [&_ul]:pl-6 [&_ol]:pl-6",
|
|
166
|
+
"[&_h1]:text-xl [&_h1]:font-bold [&_h2]:text-lg [&_h2]:font-bold",
|
|
167
|
+
"[&_a]:underline [&_a]:text-primary",
|
|
168
|
+
"[&_strong]:font-bold [&_em]:italic",
|
|
169
|
+
)}
|
|
170
|
+
/>
|
|
171
|
+
{variables.length > 0 && (
|
|
172
|
+
<div className="border-t border-input p-1">
|
|
173
|
+
<VariableChips variables={variables} onInsert={insertVariable} disabled={readOnly} />
|
|
174
|
+
</div>
|
|
175
|
+
)}
|
|
176
|
+
</div>
|
|
177
|
+
);
|
|
178
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -97,6 +97,7 @@ export type { KumikoLinkProps } from "./app/nav";
|
|
|
97
97
|
export { KumikoLink, useBrowserNavApi } from "./app/nav";
|
|
98
98
|
export { PlainContentEditor } from "./app/plain-content-editor";
|
|
99
99
|
export { useResolvers } from "./app/resolvers-context";
|
|
100
|
+
export { RichContentEditor } from "./app/rich-content-editor";
|
|
100
101
|
export type { AppLayoutProps } from "./layout/app-layout";
|
|
101
102
|
export { AppLayout } from "./layout/app-layout";
|
|
102
103
|
export type { AvatarProps, AvatarSize } from "./layout/avatar";
|