@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.182.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.182.0",
20
- "@cosmicdrift/kumiko-headless": "0.182.0",
21
- "@cosmicdrift/kumiko-renderer": "0.182.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";