@cosmicdrift/kumiko-renderer-web 0.181.0 → 0.182.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 +4 -4
- package/src/__tests__/create-app.test.tsx +44 -1
- package/src/app/__tests__/merge-content-editors.test.ts +44 -0
- package/src/app/__tests__/plain-content-editor.test.tsx +44 -0
- package/src/app/client-plugin.tsx +10 -0
- package/src/app/create-app.tsx +38 -10
- package/src/app/plain-content-editor.tsx +66 -0
- package/src/index.ts +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-renderer-web",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.182.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.182.0",
|
|
20
|
+
"@cosmicdrift/kumiko-headless": "0.182.0",
|
|
21
|
+
"@cosmicdrift/kumiko-renderer": "0.182.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",
|
|
@@ -9,10 +9,11 @@ import type { Dispatcher } from "@cosmicdrift/kumiko-headless";
|
|
|
9
9
|
import type {
|
|
10
10
|
AppSchema,
|
|
11
11
|
ColumnRendererProps,
|
|
12
|
+
ContentEditorProps,
|
|
12
13
|
FeatureSchema,
|
|
13
14
|
NavApi,
|
|
14
15
|
} from "@cosmicdrift/kumiko-renderer";
|
|
15
|
-
import { createStaticLocaleResolver } from "@cosmicdrift/kumiko-renderer";
|
|
16
|
+
import { createStaticLocaleResolver, useContentEditor } from "@cosmicdrift/kumiko-renderer";
|
|
16
17
|
import { act, screen, waitFor } from "@testing-library/react";
|
|
17
18
|
import type { ReactNode } from "react";
|
|
18
19
|
import type { ClientFeatureDefinition } from "../app/client-plugin";
|
|
@@ -315,6 +316,48 @@ describe("createKumikoApp", () => {
|
|
|
315
316
|
expect(screen.getByTestId("ca-swatch-field").textContent).toBe("color");
|
|
316
317
|
});
|
|
317
318
|
|
|
319
|
+
test("clientFeatures.contentEditors → createKumikoApp verdrahtet Merge → Provider → useContentEditor end-to-end", async () => {
|
|
320
|
+
// Proves the whole chain: ClientFeatureDefinition.contentEditors →
|
|
321
|
+
// mergeContentEditors → ContentEditorsProvider in the tree →
|
|
322
|
+
// useContentEditor in the consumer. Without the provider mount,
|
|
323
|
+
// useContentEditor would always fall back to the textarea — this test
|
|
324
|
+
// would fail then.
|
|
325
|
+
function RichEditor({ value }: ContentEditorProps): ReactNode {
|
|
326
|
+
return <div data-testid="ca-rich-editor">{value}</div>;
|
|
327
|
+
}
|
|
328
|
+
function EditorProbe(): ReactNode {
|
|
329
|
+
const Editor = useContentEditor("rich");
|
|
330
|
+
return <Editor value="hello" onChange={() => {}} variables={[]} readOnly={false} />;
|
|
331
|
+
}
|
|
332
|
+
const probeSchema: FeatureSchema = {
|
|
333
|
+
featureName: "tasks",
|
|
334
|
+
entities: {},
|
|
335
|
+
screens: [{ id: "editor-probe", type: "custom", renderer: {} }],
|
|
336
|
+
navs: [
|
|
337
|
+
{
|
|
338
|
+
id: "editor-probe",
|
|
339
|
+
label: "tasks:nav.editor-probe",
|
|
340
|
+
screen: "tasks:screen:editor-probe",
|
|
341
|
+
},
|
|
342
|
+
],
|
|
343
|
+
};
|
|
344
|
+
const clientFeature: ClientFeatureDefinition = {
|
|
345
|
+
name: "tasks",
|
|
346
|
+
components: { "editor-probe": EditorProbe },
|
|
347
|
+
contentEditors: { rich: RichEditor },
|
|
348
|
+
};
|
|
349
|
+
|
|
350
|
+
mountRoot();
|
|
351
|
+
await mountApp({
|
|
352
|
+
schema: probeSchema,
|
|
353
|
+
dispatcher: makeDispatcher(),
|
|
354
|
+
clientFeatures: [clientFeature],
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
expect(await screen.findByTestId("ca-rich-editor")).toBeTruthy();
|
|
358
|
+
expect(screen.getByTestId("ca-rich-editor").textContent).toBe("hello");
|
|
359
|
+
});
|
|
360
|
+
|
|
318
361
|
test("schema.translations (r.translations, #1059) resolves nav/dashboard labels without any clientFeatures duplication", async () => {
|
|
319
362
|
// Mirrors cap-counter's real shape: a feature that declares r.translations
|
|
320
363
|
// for its own nav label but ships NO web/i18n.ts / clientFeatures entry at
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { describe, expect, spyOn, test } from "bun:test";
|
|
2
|
+
import type { ContentEditorComponent } from "@cosmicdrift/kumiko-renderer";
|
|
3
|
+
import type { ClientFeatureDefinition } from "../client-plugin";
|
|
4
|
+
import { mergeContentEditors } from "../create-app";
|
|
5
|
+
|
|
6
|
+
const editor = (): ContentEditorComponent => (() => null) as ContentEditorComponent;
|
|
7
|
+
|
|
8
|
+
describe("mergeContentEditors", () => {
|
|
9
|
+
test("merges contentEditors from multiple clientFeatures", () => {
|
|
10
|
+
const plainEditor = editor();
|
|
11
|
+
const richEditor = editor();
|
|
12
|
+
const features: ClientFeatureDefinition[] = [
|
|
13
|
+
{ name: "a", contentEditors: { plain: plainEditor } },
|
|
14
|
+
{ name: "b", contentEditors: { rich: richEditor } },
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
const merged = mergeContentEditors(features);
|
|
18
|
+
expect(merged["plain"]).toBe(plainEditor);
|
|
19
|
+
expect(merged["rich"]).toBe(richEditor);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("Key-Kollision → warnt + last-wins gewinnt", () => {
|
|
23
|
+
const warnSpy = spyOn(console, "warn").mockImplementation(() => {});
|
|
24
|
+
const first = editor();
|
|
25
|
+
const second = editor();
|
|
26
|
+
const features: ClientFeatureDefinition[] = [
|
|
27
|
+
{ name: "first", contentEditors: { plain: first } },
|
|
28
|
+
{ name: "second", contentEditors: { plain: second } },
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
const merged = mergeContentEditors(features);
|
|
32
|
+
expect(merged["plain"]).toBe(second);
|
|
33
|
+
expect(warnSpy).toHaveBeenCalledWith(
|
|
34
|
+
expect.stringContaining('contentEditor "plain" defined by multiple clientFeatures'),
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
warnSpy.mockRestore();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("clientFeatures ohne contentEditors werden übersprungen", () => {
|
|
41
|
+
const features: ClientFeatureDefinition[] = [{ name: "a" }];
|
|
42
|
+
expect(mergeContentEditors(features)).toEqual({});
|
|
43
|
+
});
|
|
44
|
+
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { type ReactNode, useState } from "react";
|
|
3
|
+
import { fireEvent, render, screen } from "../../__tests__/test-utils";
|
|
4
|
+
import { PlainContentEditor } from "../plain-content-editor";
|
|
5
|
+
|
|
6
|
+
function Controlled({ initial }: { readonly initial: string }): ReactNode {
|
|
7
|
+
const [value, setValue] = useState(initial);
|
|
8
|
+
return (
|
|
9
|
+
<PlainContentEditor value={value} onChange={setValue} variables={["name"]} readOnly={false} />
|
|
10
|
+
);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
describe("PlainContentEditor", () => {
|
|
14
|
+
test("renders the textarea plus one chip per variable", () => {
|
|
15
|
+
render(
|
|
16
|
+
<PlainContentEditor value="" onChange={() => {}} variables={["name"]} readOnly={false} />,
|
|
17
|
+
);
|
|
18
|
+
expect(screen.getByRole("textbox")).toBeTruthy();
|
|
19
|
+
expect(screen.getByText("{{name}}")).toBeTruthy();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("no variables → no chips", () => {
|
|
23
|
+
render(<PlainContentEditor value="" onChange={() => {}} variables={[]} readOnly={false} />);
|
|
24
|
+
expect(screen.queryAllByRole("button")).toHaveLength(0);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("chip click inserts the placeholder at the caret, not appended", () => {
|
|
28
|
+
render(<Controlled initial="Hello world" />);
|
|
29
|
+
const textarea = screen.getByRole("textbox") as HTMLTextAreaElement;
|
|
30
|
+
// Caret between the two spaces (index 6), so the insert must land there —
|
|
31
|
+
// an append-only implementation would put it at the string's end instead.
|
|
32
|
+
textarea.setSelectionRange(6, 6);
|
|
33
|
+
fireEvent.click(screen.getByText("{{name}}"));
|
|
34
|
+
expect(textarea.value).toBe("Hello {{name}} world");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("chip click replaces a selection instead of inserting alongside it", () => {
|
|
38
|
+
render(<Controlled initial="Hello world" />);
|
|
39
|
+
const textarea = screen.getByRole("textbox") as HTMLTextAreaElement;
|
|
40
|
+
textarea.setSelectionRange(6, 11); // selects "world"
|
|
41
|
+
fireEvent.click(screen.getByText("{{name}}"));
|
|
42
|
+
expect(textarea.value).toBe("Hello {{name}}");
|
|
43
|
+
});
|
|
44
|
+
});
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
import type { TargetRef, TreeChildrenSubscribe } from "@cosmicdrift/kumiko-framework/engine";
|
|
13
13
|
import type {
|
|
14
14
|
ColumnRendererComponent,
|
|
15
|
+
ContentEditorComponent,
|
|
15
16
|
ExtensionSectionComponent,
|
|
16
17
|
QualifiedContentCollection,
|
|
17
18
|
TranslationsByLocale,
|
|
@@ -89,6 +90,15 @@ export type ClientFeatureDefinition = {
|
|
|
89
90
|
collections: readonly QualifiedContentCollection[],
|
|
90
91
|
) => CollectionNavProviders;
|
|
91
92
|
|
|
93
|
+
/** Content-Editor-Components — Map `contentFormat` ("plain" | "rich" | "markdown") →
|
|
94
|
+
* React-Component. `r.contentCollection()` declares which format an
|
|
95
|
+
* editor edits; the collection's editor UI (template-resolver's
|
|
96
|
+
* TextBlockEditor et al.) looks the component up here and falls back to
|
|
97
|
+
* a plain textarea when no clientFeature registered one for that
|
|
98
|
+
* format. Same Last-Wins-Semantik wie columnRenderers. Public component
|
|
99
|
+
* contract: `{ value, onChange, variables, readOnly }`. */
|
|
100
|
+
readonly contentEditors?: Readonly<Record<string, ContentEditorComponent>>;
|
|
101
|
+
|
|
92
102
|
/** Editor-Resolver-Komponenten pro featureId:action-Key. Wenn ein
|
|
93
103
|
* TreeNode mit target angeklickt wird, schlägt der EditorPanel das
|
|
94
104
|
* Component hier nach und rendert es. Komponenten erhalten target
|
package/src/app/create-app.tsx
CHANGED
|
@@ -11,6 +11,8 @@ import {
|
|
|
11
11
|
type AppSchema,
|
|
12
12
|
type ColumnRendererComponent,
|
|
13
13
|
ColumnRenderersProvider,
|
|
14
|
+
type ContentEditorComponent,
|
|
15
|
+
ContentEditorsProvider,
|
|
14
16
|
CustomScreensProvider,
|
|
15
17
|
DashboardBodyProvider,
|
|
16
18
|
DispatcherProvider,
|
|
@@ -216,6 +218,28 @@ export function firstOpenScreenQn(features: readonly FeatureSchema[]): string |
|
|
|
216
218
|
return undefined;
|
|
217
219
|
}
|
|
218
220
|
|
|
221
|
+
// Merges the content-editor map — same last-wins semantics as
|
|
222
|
+
// columnRenderers. No entry for a contentFormat → useContentEditor falls
|
|
223
|
+
// back to the textarea on its own, no warning needed.
|
|
224
|
+
export function mergeContentEditors(
|
|
225
|
+
clientFeatures: readonly ClientFeatureDefinition[],
|
|
226
|
+
): Record<string, ContentEditorComponent> {
|
|
227
|
+
const contentEditors: Record<string, ContentEditorComponent> = {};
|
|
228
|
+
for (const f of clientFeatures) {
|
|
229
|
+
if (f.contentEditors === undefined) continue;
|
|
230
|
+
for (const [key, value] of Object.entries(f.contentEditors)) {
|
|
231
|
+
if (contentEditors[key] !== undefined) {
|
|
232
|
+
// biome-ignore lint/suspicious/noConsole: dev-warning für Schema-Konflikte
|
|
233
|
+
console.warn(
|
|
234
|
+
`[kumiko] contentEditor "${key}" defined by multiple clientFeatures — last definition (from "${f.name}") wins.`,
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
contentEditors[key] = value;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
return contentEditors;
|
|
241
|
+
}
|
|
242
|
+
|
|
219
243
|
export function createKumikoApp(options: CreateKumikoAppOptions = {}): { readonly root: Root } {
|
|
220
244
|
const rootId = options.rootId ?? "root";
|
|
221
245
|
const container = document.getElementById(rootId);
|
|
@@ -320,6 +344,8 @@ export function createKumikoApp(options: CreateKumikoAppOptions = {}): { readonl
|
|
|
320
344
|
}
|
|
321
345
|
}
|
|
322
346
|
|
|
347
|
+
const contentEditors = mergeContentEditors(clientFeatures);
|
|
348
|
+
|
|
323
349
|
const { navProviders, navEntities } = buildNavProviderMaps(
|
|
324
350
|
clientFeatures,
|
|
325
351
|
app.features.flatMap((f) => f.contentCollections ?? []),
|
|
@@ -367,16 +393,18 @@ export function createKumikoApp(options: CreateKumikoAppOptions = {}): { readonl
|
|
|
367
393
|
<DashboardBodyProvider value={WebDashboardBody}>
|
|
368
394
|
<CustomScreensProvider value={customScreens}>
|
|
369
395
|
<ColumnRenderersProvider value={columnRenderers}>
|
|
370
|
-
<
|
|
371
|
-
<
|
|
372
|
-
<
|
|
373
|
-
<
|
|
374
|
-
<
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
396
|
+
<ContentEditorsProvider value={contentEditors}>
|
|
397
|
+
<ExtensionSectionsProvider value={extensionSectionComponents}>
|
|
398
|
+
<NavProvidersProvider value={navProviders} entities={navEntities}>
|
|
399
|
+
<ResolversProvider resolvers={resolvers}>
|
|
400
|
+
<ToastProvider>
|
|
401
|
+
<UpdateChecker />
|
|
402
|
+
{stackWrappers(providers, stackWrappers(gates, screenNode))}
|
|
403
|
+
</ToastProvider>
|
|
404
|
+
</ResolversProvider>
|
|
405
|
+
</NavProvidersProvider>
|
|
406
|
+
</ExtensionSectionsProvider>
|
|
407
|
+
</ContentEditorsProvider>
|
|
380
408
|
</ColumnRenderersProvider>
|
|
381
409
|
</CustomScreensProvider>
|
|
382
410
|
</DashboardBodyProvider>
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// @runtime client
|
|
2
|
+
//
|
|
3
|
+
// "plain" contentFormat editor: the primitives textarea (TextareaContentEditor,
|
|
4
|
+
// unchanged — keeps its styling and its Field/label association) plus a
|
|
5
|
+
// variable-chip bar underneath. A chip click inserts `{{name}}` at the caret
|
|
6
|
+
// instead of appending to the end.
|
|
7
|
+
//
|
|
8
|
+
// ponytail: looks the textarea up by CONTENT_EDITOR_ELEMENT_ID rather than a
|
|
9
|
+
// ref threaded through the primitives Input contract — that contract is
|
|
10
|
+
// cross-platform (RN has no DOM node), this file is `@runtime client`-only
|
|
11
|
+
// and CONTENT_EDITOR_ELEMENT_ID exists exactly as this DOM hook. Upgrade to a
|
|
12
|
+
// forwarded ref if a screen ever needs two plain editors mounted at once.
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
CONTENT_EDITOR_ELEMENT_ID,
|
|
16
|
+
type ContentEditorProps,
|
|
17
|
+
TextareaContentEditor,
|
|
18
|
+
VariableChips,
|
|
19
|
+
} from "@cosmicdrift/kumiko-renderer";
|
|
20
|
+
import type { ReactNode } from "react";
|
|
21
|
+
import { useEffect, useState } from "react";
|
|
22
|
+
|
|
23
|
+
export function PlainContentEditor({
|
|
24
|
+
value,
|
|
25
|
+
onChange,
|
|
26
|
+
variables,
|
|
27
|
+
readOnly,
|
|
28
|
+
}: ContentEditorProps): ReactNode {
|
|
29
|
+
const [caret, setCaret] = useState<number | null>(null);
|
|
30
|
+
|
|
31
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: keyed on value only — must fire once per committed insert, not on every caret write
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
if (caret === null) return;
|
|
34
|
+
const el = document.getElementById(CONTENT_EDITOR_ELEMENT_ID);
|
|
35
|
+
if (el instanceof HTMLTextAreaElement) {
|
|
36
|
+
el.focus();
|
|
37
|
+
el.setSelectionRange(caret, caret);
|
|
38
|
+
}
|
|
39
|
+
setCaret(null);
|
|
40
|
+
}, [value]);
|
|
41
|
+
|
|
42
|
+
const insertAtCaret = (name: string): void => {
|
|
43
|
+
const el = document.getElementById(CONTENT_EDITOR_ELEMENT_ID);
|
|
44
|
+
const placeholder = `{{${name}}}`;
|
|
45
|
+
if (!(el instanceof HTMLTextAreaElement)) {
|
|
46
|
+
onChange(value + placeholder);
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
const start = el.selectionStart ?? value.length;
|
|
50
|
+
const end = el.selectionEnd ?? value.length;
|
|
51
|
+
setCaret(start + placeholder.length);
|
|
52
|
+
onChange(value.slice(0, start) + placeholder + value.slice(end));
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<div>
|
|
57
|
+
<TextareaContentEditor
|
|
58
|
+
value={value}
|
|
59
|
+
onChange={onChange}
|
|
60
|
+
variables={variables}
|
|
61
|
+
readOnly={readOnly}
|
|
62
|
+
/>
|
|
63
|
+
<VariableChips variables={variables} onInsert={insertAtCaret} disabled={readOnly} />
|
|
64
|
+
</div>
|
|
65
|
+
);
|
|
66
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -95,6 +95,7 @@ export type { CreatePublicSurfaceOptions, PublicRoute } from "./app/create-publi
|
|
|
95
95
|
export { createPublicSurface } from "./app/create-public-surface";
|
|
96
96
|
export type { KumikoLinkProps } from "./app/nav";
|
|
97
97
|
export { KumikoLink, useBrowserNavApi } from "./app/nav";
|
|
98
|
+
export { PlainContentEditor } from "./app/plain-content-editor";
|
|
98
99
|
export { useResolvers } from "./app/resolvers-context";
|
|
99
100
|
export type { AppLayoutProps } from "./layout/app-layout";
|
|
100
101
|
export { AppLayout } from "./layout/app-layout";
|