@cosmicdrift/kumiko-renderer-web 0.193.1 → 0.195.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 +10 -2
- package/src/__tests__/entity-edit-redirect.test.tsx +143 -0
- package/src/__tests__/field-hide-label.test.tsx +166 -1
- package/src/__tests__/kumiko-screen.test.tsx +59 -0
- package/src/__tests__/nav-tree.test.tsx +43 -11
- package/src/__tests__/primitives.test.tsx +33 -2
- package/src/__tests__/render-edit.test.tsx +397 -9
- package/src/__tests__/test-utils.tsx +20 -0
- package/src/__tests__/wizard-form-validation.test.tsx +129 -1
- package/src/app/__tests__/merge-content-editors.test.ts +2 -2
- package/src/app/__tests__/plain-content-editor.test.tsx +28 -3
- package/src/app/__tests__/rich-content-editor.test.tsx +6 -2
- package/src/app/__tests__/tiptap-editor.test.tsx +102 -7
- package/src/app/client-plugin.tsx +3 -3
- package/src/app/create-app.tsx +40 -49
- package/src/app/plain-content-editor.tsx +9 -8
- package/src/app/tiptap-editor.tsx +40 -12
- package/src/index.ts +1 -0
- package/src/layout/__tests__/workspace-switcher.test.tsx +62 -2
- package/src/layout/sidebar-panel.tsx +1 -4
- package/src/layout/workspace-switcher.tsx +26 -13
- package/src/lib/__tests__/resize-image.test.ts +42 -3
- package/src/lib/clamp.ts +3 -0
- package/src/lib/resize-image.ts +6 -1
- package/src/primitives/__tests__/data-table-logic.test.ts +15 -3
- package/src/primitives/__tests__/date-field.test.tsx +37 -0
- package/src/primitives/__tests__/date-parse.test.ts +14 -2
- package/src/primitives/__tests__/embedded-list-input.test.tsx +77 -2
- package/src/primitives/__tests__/file-upload.test.tsx +24 -0
- package/src/primitives/date-field.tsx +8 -0
- package/src/primitives/date-parse.ts +6 -1
- package/src/primitives/dropdown-menu.tsx +23 -0
- package/src/primitives/embedded-list-input.tsx +23 -13
- package/src/primitives/file-upload.tsx +3 -0
- package/src/primitives/index.tsx +114 -120
- package/src/primitives/money-input.tsx +4 -7
- package/src/primitives/use-narrow-viewport.ts +28 -0
- package/src/ui/sheet.tsx +3 -1
- package/src/widgets/__tests__/drawer.test.tsx +315 -0
- package/src/widgets/__tests__/infinity-list.test.tsx +38 -0
- package/src/widgets/__tests__/upload-zone.test.tsx +117 -2
- package/src/widgets/__tests__/widgets.test.tsx +25 -0
- package/src/widgets/ai-text-field.tsx +3 -1
- package/src/widgets/drawer.tsx +62 -31
- package/src/widgets/infinity-list.tsx +18 -5
- package/src/widgets/progress-bar.tsx +3 -1
- package/src/widgets/step-bar.tsx +1 -1
- package/src/widgets/upload-zone.tsx +45 -3
|
@@ -19,7 +19,7 @@ describe("mergeContentEditors", () => {
|
|
|
19
19
|
expect(merged["rich"]).toBe(richEditor);
|
|
20
20
|
});
|
|
21
21
|
|
|
22
|
-
test("
|
|
22
|
+
test("key collision → warns + last-wins applies", () => {
|
|
23
23
|
const warnSpy = spyOn(console, "warn").mockImplementation(() => {});
|
|
24
24
|
const first = editor();
|
|
25
25
|
const second = editor();
|
|
@@ -37,7 +37,7 @@ describe("mergeContentEditors", () => {
|
|
|
37
37
|
warnSpy.mockRestore();
|
|
38
38
|
});
|
|
39
39
|
|
|
40
|
-
test("clientFeatures
|
|
40
|
+
test("clientFeatures without contentEditors are skipped", () => {
|
|
41
41
|
const features: ClientFeatureDefinition[] = [{ name: "a" }];
|
|
42
42
|
expect(mergeContentEditors(features)).toEqual({});
|
|
43
43
|
});
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { CONTENT_EDITOR_ELEMENT_ID } from "@cosmicdrift/kumiko-renderer";
|
|
2
3
|
import { type ReactNode, useState } from "react";
|
|
3
4
|
import { fireEvent, render, screen } from "../../__tests__/test-utils";
|
|
4
5
|
import { PlainContentEditor } from "../plain-content-editor";
|
|
@@ -6,21 +7,41 @@ import { PlainContentEditor } from "../plain-content-editor";
|
|
|
6
7
|
function Controlled({ initial }: { readonly initial: string }): ReactNode {
|
|
7
8
|
const [value, setValue] = useState(initial);
|
|
8
9
|
return (
|
|
9
|
-
<PlainContentEditor
|
|
10
|
+
<PlainContentEditor
|
|
11
|
+
id={CONTENT_EDITOR_ELEMENT_ID}
|
|
12
|
+
value={value}
|
|
13
|
+
onChange={setValue}
|
|
14
|
+
variables={["name"]}
|
|
15
|
+
readOnly={false}
|
|
16
|
+
/>
|
|
10
17
|
);
|
|
11
18
|
}
|
|
12
19
|
|
|
13
20
|
describe("PlainContentEditor", () => {
|
|
14
21
|
test("renders the textarea plus one chip per variable", () => {
|
|
15
22
|
render(
|
|
16
|
-
<PlainContentEditor
|
|
23
|
+
<PlainContentEditor
|
|
24
|
+
id={CONTENT_EDITOR_ELEMENT_ID}
|
|
25
|
+
value=""
|
|
26
|
+
onChange={() => {}}
|
|
27
|
+
variables={["name"]}
|
|
28
|
+
readOnly={false}
|
|
29
|
+
/>,
|
|
17
30
|
);
|
|
18
31
|
expect(screen.getByRole("textbox")).toBeTruthy();
|
|
19
32
|
expect(screen.getByText("{{name}}")).toBeTruthy();
|
|
20
33
|
});
|
|
21
34
|
|
|
22
35
|
test("no variables → no chips", () => {
|
|
23
|
-
render(
|
|
36
|
+
render(
|
|
37
|
+
<PlainContentEditor
|
|
38
|
+
id={CONTENT_EDITOR_ELEMENT_ID}
|
|
39
|
+
value=""
|
|
40
|
+
onChange={() => {}}
|
|
41
|
+
variables={[]}
|
|
42
|
+
readOnly={false}
|
|
43
|
+
/>,
|
|
44
|
+
);
|
|
24
45
|
expect(screen.queryAllByRole("button")).toHaveLength(0);
|
|
25
46
|
});
|
|
26
47
|
|
|
@@ -32,6 +53,10 @@ describe("PlainContentEditor", () => {
|
|
|
32
53
|
textarea.setSelectionRange(6, 6);
|
|
33
54
|
fireEvent.click(screen.getByText("{{name}}"));
|
|
34
55
|
expect(textarea.value).toBe("Hello {{name}} world");
|
|
56
|
+
// Caret-restore effect: focus + selection must land right after the
|
|
57
|
+
// inserted placeholder, not wherever the DOM happened to leave it.
|
|
58
|
+
expect(textarea.selectionStart).toBe(14);
|
|
59
|
+
expect(textarea.selectionEnd).toBe(14);
|
|
35
60
|
});
|
|
36
61
|
|
|
37
62
|
test("chip click replaces a selection instead of inserting alongside it", () => {
|
|
@@ -7,6 +7,7 @@ describe("RichContentEditor", () => {
|
|
|
7
7
|
test("falls back to the plain textarea while the tiptap chunk loads, then swaps in the editor", async () => {
|
|
8
8
|
render(
|
|
9
9
|
<RichContentEditor
|
|
10
|
+
id={CONTENT_EDITOR_ELEMENT_ID}
|
|
10
11
|
value="<p>hello</p>"
|
|
11
12
|
onChange={() => {}}
|
|
12
13
|
variables={[]}
|
|
@@ -15,7 +16,10 @@ describe("RichContentEditor", () => {
|
|
|
15
16
|
);
|
|
16
17
|
// The textarea id proves #1794's "never an empty panel" fallback is the
|
|
17
18
|
// Suspense boundary — a null fallback would leave nothing to find here.
|
|
18
|
-
|
|
19
|
-
|
|
19
|
+
// TiptapEditor puts the same id on its contenteditable, so asserting
|
|
20
|
+
// presence alone wouldn't distinguish the two; assert the tag instead.
|
|
21
|
+
expect(document.getElementById(CONTENT_EDITOR_ELEMENT_ID)?.tagName).toBe("TEXTAREA");
|
|
22
|
+
const editable = await screen.findByText("hello");
|
|
23
|
+
expect(editable.closest('[contenteditable="true"]')).not.toBeNull();
|
|
20
24
|
});
|
|
21
25
|
});
|
|
@@ -6,7 +6,33 @@ import TiptapEditor from "../tiptap-editor";
|
|
|
6
6
|
|
|
7
7
|
function Controlled({ initial }: { readonly initial: string }): ReactNode {
|
|
8
8
|
const [value, setValue] = useState(initial);
|
|
9
|
-
return
|
|
9
|
+
return (
|
|
10
|
+
<TiptapEditor
|
|
11
|
+
id={CONTENT_EDITOR_ELEMENT_ID}
|
|
12
|
+
value={value}
|
|
13
|
+
onChange={setValue}
|
|
14
|
+
variables={["name"]}
|
|
15
|
+
readOnly={false}
|
|
16
|
+
/>
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function ReadOnlyToggle({ initial }: { readonly initial: string }): ReactNode {
|
|
21
|
+
const [readOnly, setReadOnly] = useState(false);
|
|
22
|
+
return (
|
|
23
|
+
<div>
|
|
24
|
+
<button type="button" onClick={() => setReadOnly((r) => !r)}>
|
|
25
|
+
toggle readOnly
|
|
26
|
+
</button>
|
|
27
|
+
<TiptapEditor
|
|
28
|
+
id={CONTENT_EDITOR_ELEMENT_ID}
|
|
29
|
+
value={initial}
|
|
30
|
+
onChange={() => {}}
|
|
31
|
+
variables={[]}
|
|
32
|
+
readOnly={readOnly}
|
|
33
|
+
/>
|
|
34
|
+
</div>
|
|
35
|
+
);
|
|
10
36
|
}
|
|
11
37
|
|
|
12
38
|
// Mirrors the real caller: TextBlockEditor mounts with value="" and only
|
|
@@ -20,7 +46,13 @@ function LateValue({ loaded }: { readonly loaded: string }): ReactNode {
|
|
|
20
46
|
<button type="button" onClick={() => setValue(loaded)}>
|
|
21
47
|
load
|
|
22
48
|
</button>
|
|
23
|
-
<TiptapEditor
|
|
49
|
+
<TiptapEditor
|
|
50
|
+
id={CONTENT_EDITOR_ELEMENT_ID}
|
|
51
|
+
value={value}
|
|
52
|
+
onChange={setValue}
|
|
53
|
+
variables={[]}
|
|
54
|
+
readOnly={false}
|
|
55
|
+
/>
|
|
24
56
|
</div>
|
|
25
57
|
);
|
|
26
58
|
}
|
|
@@ -28,22 +60,40 @@ function LateValue({ loaded }: { readonly loaded: string }): ReactNode {
|
|
|
28
60
|
describe("TiptapEditor — jsdom smoke", () => {
|
|
29
61
|
test("mounts a contenteditable surface for the given HTML", async () => {
|
|
30
62
|
render(
|
|
31
|
-
<TiptapEditor
|
|
63
|
+
<TiptapEditor
|
|
64
|
+
id={CONTENT_EDITOR_ELEMENT_ID}
|
|
65
|
+
value="<p>hello</p>"
|
|
66
|
+
onChange={() => {}}
|
|
67
|
+
variables={[]}
|
|
68
|
+
readOnly={false}
|
|
69
|
+
/>,
|
|
32
70
|
);
|
|
33
71
|
const editable = await screen.findByText("hello");
|
|
34
72
|
expect(editable.closest('[contenteditable="true"]')).not.toBeNull();
|
|
35
73
|
});
|
|
36
74
|
|
|
37
|
-
test("the contenteditable surface
|
|
75
|
+
test("the editor's id prop lands on the contenteditable surface so the wrapping Field's label stays associated", async () => {
|
|
38
76
|
render(
|
|
39
|
-
<TiptapEditor
|
|
77
|
+
<TiptapEditor
|
|
78
|
+
id={CONTENT_EDITOR_ELEMENT_ID}
|
|
79
|
+
value="<p>hello</p>"
|
|
80
|
+
onChange={() => {}}
|
|
81
|
+
variables={[]}
|
|
82
|
+
readOnly={false}
|
|
83
|
+
/>,
|
|
40
84
|
);
|
|
41
85
|
expect(document.getElementById(CONTENT_EDITOR_ELEMENT_ID)).not.toBeNull();
|
|
42
86
|
});
|
|
43
87
|
|
|
44
88
|
test("no variables → no chip bar", async () => {
|
|
45
89
|
render(
|
|
46
|
-
<TiptapEditor
|
|
90
|
+
<TiptapEditor
|
|
91
|
+
id={CONTENT_EDITOR_ELEMENT_ID}
|
|
92
|
+
value="<p>hello</p>"
|
|
93
|
+
onChange={() => {}}
|
|
94
|
+
variables={[]}
|
|
95
|
+
readOnly={false}
|
|
96
|
+
/>,
|
|
47
97
|
);
|
|
48
98
|
await screen.findByText("hello");
|
|
49
99
|
expect(screen.queryAllByRole("button", { name: "Bold" })).toHaveLength(1);
|
|
@@ -52,7 +102,13 @@ describe("TiptapEditor — jsdom smoke", () => {
|
|
|
52
102
|
|
|
53
103
|
test("every toolbar action resolves by its accessible name", async () => {
|
|
54
104
|
render(
|
|
55
|
-
<TiptapEditor
|
|
105
|
+
<TiptapEditor
|
|
106
|
+
id={CONTENT_EDITOR_ELEMENT_ID}
|
|
107
|
+
value="<p>hello</p>"
|
|
108
|
+
onChange={() => {}}
|
|
109
|
+
variables={[]}
|
|
110
|
+
readOnly={false}
|
|
111
|
+
/>,
|
|
56
112
|
);
|
|
57
113
|
await screen.findByText("hello");
|
|
58
114
|
for (const name of [
|
|
@@ -91,4 +147,43 @@ describe("TiptapEditor — jsdom smoke", () => {
|
|
|
91
147
|
fireEvent.click(screen.getByText("{{name}}"));
|
|
92
148
|
expect(await screen.findByText("{{name}}", { selector: "p" })).toBeTruthy();
|
|
93
149
|
});
|
|
150
|
+
|
|
151
|
+
test("toggling readOnly flips the surface's contenteditable and disables the toolbar", async () => {
|
|
152
|
+
// @tiptap/react re-applies `editable` from options on every options
|
|
153
|
+
// refresh, overriding what `editable: !readOnly` set at creation — so
|
|
154
|
+
// this only stays correct across a readOnly prop change via the
|
|
155
|
+
// `editor.setEditable` effect. A test that only mounts with a fixed
|
|
156
|
+
// readOnly value can't catch that effect being removed.
|
|
157
|
+
render(<ReadOnlyToggle initial="<p>hello</p>" />);
|
|
158
|
+
const editable = await screen.findByText("hello");
|
|
159
|
+
expect(editable.closest('[contenteditable="true"]')).not.toBeNull();
|
|
160
|
+
expect((screen.getByRole("button", { name: "Bold" }) as HTMLButtonElement).disabled).toBe(
|
|
161
|
+
false,
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
fireEvent.click(screen.getByText("toggle readOnly"));
|
|
165
|
+
|
|
166
|
+
expect(editable.closest('[contenteditable="false"]')).not.toBeNull();
|
|
167
|
+
expect((screen.getByRole("button", { name: "Bold" }) as HTMLButtonElement).disabled).toBe(true);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
// Documents the ponytail-noted ceiling on useEditor: markup StarterKit's
|
|
171
|
+
// schema doesn't model (tables, raw divs/spans, style attrs) is dropped on
|
|
172
|
+
// first parse, invisible until the first onChange fires. Not a bug to fix
|
|
173
|
+
// here — a fixture for whoever hits the ceiling next.
|
|
174
|
+
test("markup outside StarterKit's schema (a <table>) is dropped on the first onChange", async () => {
|
|
175
|
+
const values: string[] = [];
|
|
176
|
+
render(
|
|
177
|
+
<TiptapEditor
|
|
178
|
+
id={CONTENT_EDITOR_ELEMENT_ID}
|
|
179
|
+
value="<table><tbody><tr><td>cell</td></tr></tbody></table><p>hello</p>"
|
|
180
|
+
onChange={(html) => values.push(html)}
|
|
181
|
+
variables={[]}
|
|
182
|
+
readOnly={false}
|
|
183
|
+
/>,
|
|
184
|
+
);
|
|
185
|
+
const editable = (await screen.findByText("hello")).closest('[contenteditable="true"]');
|
|
186
|
+
fireEvent.click(screen.getByRole("button", { name: "Bold" }));
|
|
187
|
+
expect(editable?.innerHTML).not.toContain("<table");
|
|
188
|
+
});
|
|
94
189
|
});
|
|
@@ -90,12 +90,12 @@ export type ClientFeatureDefinition = {
|
|
|
90
90
|
collections: readonly QualifiedContentCollection[],
|
|
91
91
|
) => CollectionNavProviders;
|
|
92
92
|
|
|
93
|
-
/** Content
|
|
94
|
-
* React
|
|
93
|
+
/** Content editor components — map `contentFormat` ("plain" | "rich" | "markdown") →
|
|
94
|
+
* React component. `r.contentCollection()` declares which format an
|
|
95
95
|
* editor edits; the collection's editor UI (template-resolver's
|
|
96
96
|
* TextBlockEditor et al.) looks the component up here and falls back to
|
|
97
97
|
* a plain textarea when no clientFeature registered one for that
|
|
98
|
-
* format. Same
|
|
98
|
+
* format. Same last-wins semantics as columnRenderers. Public component
|
|
99
99
|
* contract: `{ value, onChange, variables, readOnly }`. */
|
|
100
100
|
readonly contentEditors?: Readonly<Record<string, ContentEditorComponent>>;
|
|
101
101
|
|
package/src/app/create-app.tsx
CHANGED
|
@@ -9,7 +9,6 @@ import type {
|
|
|
9
9
|
import {
|
|
10
10
|
AppFeaturesProvider,
|
|
11
11
|
type AppSchema,
|
|
12
|
-
type ColumnRendererComponent,
|
|
13
12
|
ColumnRenderersProvider,
|
|
14
13
|
type ContentEditorComponent,
|
|
15
14
|
ContentEditorsProvider,
|
|
@@ -18,7 +17,6 @@ import {
|
|
|
18
17
|
DispatcherProvider,
|
|
19
18
|
type DraftStorage,
|
|
20
19
|
DraftStorageProvider,
|
|
21
|
-
type ExtensionSectionComponent,
|
|
22
20
|
ExtensionSectionsProvider,
|
|
23
21
|
type FeatureSchema,
|
|
24
22
|
KumikoScreen,
|
|
@@ -99,7 +97,7 @@ export function buildNavProviderMaps(
|
|
|
99
97
|
for (const [navId, provider] of Object.entries(f.navProviders ?? {})) {
|
|
100
98
|
const qn = qualifyNavProviderKey(f.name, navId);
|
|
101
99
|
if (navProviders.has(qn) && !derivedQns.delete(qn)) {
|
|
102
|
-
// biome-ignore lint/suspicious/noConsole: dev
|
|
100
|
+
// biome-ignore lint/suspicious/noConsole: dev warning for schema conflicts
|
|
103
101
|
console.warn(
|
|
104
102
|
`[kumiko] navProvider for "${qn}" defined by multiple clientFeatures — last wins.`,
|
|
105
103
|
);
|
|
@@ -228,26 +226,39 @@ export function firstOpenScreenQn(features: readonly FeatureSchema[]): string |
|
|
|
228
226
|
return undefined;
|
|
229
227
|
}
|
|
230
228
|
|
|
231
|
-
//
|
|
232
|
-
// columnRenderers
|
|
233
|
-
//
|
|
234
|
-
|
|
229
|
+
// Last-wins merge for a clientFeature-contributed map (contentEditors,
|
|
230
|
+
// columnRenderers, extensionSectionComponents, ...): later features in
|
|
231
|
+
// `clientFeatures` override earlier ones on key collision, logging once
|
|
232
|
+
// per collision so an accidental override isn't silent.
|
|
233
|
+
function mergeByKey<T>(
|
|
235
234
|
clientFeatures: readonly ClientFeatureDefinition[],
|
|
236
|
-
|
|
237
|
-
|
|
235
|
+
pick: (f: ClientFeatureDefinition) => Record<string, T> | undefined,
|
|
236
|
+
label: string,
|
|
237
|
+
): Record<string, T> {
|
|
238
|
+
const merged: Record<string, T> = {};
|
|
238
239
|
for (const f of clientFeatures) {
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
240
|
+
const entries = pick(f);
|
|
241
|
+
if (entries === undefined) continue;
|
|
242
|
+
for (const [key, value] of Object.entries(entries)) {
|
|
243
|
+
if (merged[key] !== undefined) {
|
|
244
|
+
// biome-ignore lint/suspicious/noConsole: dev warning for schema conflicts
|
|
243
245
|
console.warn(
|
|
244
|
-
`[kumiko]
|
|
246
|
+
`[kumiko] ${label} "${key}" defined by multiple clientFeatures — last definition (from "${f.name}") wins.`,
|
|
245
247
|
);
|
|
246
248
|
}
|
|
247
|
-
|
|
249
|
+
merged[key] = value;
|
|
248
250
|
}
|
|
249
251
|
}
|
|
250
|
-
return
|
|
252
|
+
return merged;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Merges the content-editor map — same last-wins semantics as
|
|
256
|
+
// columnRenderers. No entry for a contentFormat → useContentEditor falls
|
|
257
|
+
// back to the textarea on its own, no warning needed.
|
|
258
|
+
export function mergeContentEditors(
|
|
259
|
+
clientFeatures: readonly ClientFeatureDefinition[],
|
|
260
|
+
): Record<string, ContentEditorComponent> {
|
|
261
|
+
return mergeByKey(clientFeatures, (f) => f.contentEditors, "contentEditor");
|
|
251
262
|
}
|
|
252
263
|
|
|
253
264
|
export function createKumikoApp(options: CreateKumikoAppOptions = {}): { readonly root: Root } {
|
|
@@ -321,39 +332,19 @@ export function createKumikoApp(options: CreateKumikoAppOptions = {}): { readonl
|
|
|
321
332
|
for (const f of clientFeatures) {
|
|
322
333
|
if (f.components !== undefined) Object.assign(customScreens, f.components);
|
|
323
334
|
}
|
|
324
|
-
// Column-
|
|
325
|
-
//
|
|
326
|
-
//
|
|
327
|
-
//
|
|
328
|
-
const columnRenderers
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
}
|
|
338
|
-
columnRenderers[key] = value;
|
|
339
|
-
}
|
|
340
|
-
}
|
|
341
|
-
// Extension-Section-Components — same Last-Wins + Warn-Semantik wie
|
|
342
|
-
// columnRenderers. Mountet sich am ExtensionSectionsProvider; RenderEdit
|
|
343
|
-
// löst die Component aus dem `__component`-Marker der section.
|
|
344
|
-
const extensionSectionComponents: Record<string, ExtensionSectionComponent> = {};
|
|
345
|
-
for (const f of clientFeatures) {
|
|
346
|
-
if (f.extensionSectionComponents === undefined) continue;
|
|
347
|
-
for (const [key, value] of Object.entries(f.extensionSectionComponents)) {
|
|
348
|
-
if (extensionSectionComponents[key] !== undefined) {
|
|
349
|
-
// biome-ignore lint/suspicious/noConsole: dev-warning für Schema-Konflikte
|
|
350
|
-
console.warn(
|
|
351
|
-
`[kumiko] extensionSectionComponent "${key}" defined by multiple clientFeatures — last definition (from "${f.name}") wins.`,
|
|
352
|
-
);
|
|
353
|
-
}
|
|
354
|
-
extensionSectionComponents[key] = value;
|
|
355
|
-
}
|
|
356
|
-
}
|
|
335
|
+
// Column-renderer map, same last-wins semantics as customScreens —
|
|
336
|
+
// duplicate keys across features are rarely intentional, so a
|
|
337
|
+
// collision logs once instead of silently overriding a library's
|
|
338
|
+
// renderer.
|
|
339
|
+
const columnRenderers = mergeByKey(clientFeatures, (f) => f.columnRenderers, "columnRenderer");
|
|
340
|
+
// Extension-section components — same last-wins + warn semantics as
|
|
341
|
+
// columnRenderers. Mounted on ExtensionSectionsProvider; RenderEdit
|
|
342
|
+
// resolves the component from the section's `__component` marker.
|
|
343
|
+
const extensionSectionComponents = mergeByKey(
|
|
344
|
+
clientFeatures,
|
|
345
|
+
(f) => f.extensionSectionComponents,
|
|
346
|
+
"extensionSectionComponent",
|
|
347
|
+
);
|
|
357
348
|
|
|
358
349
|
const contentEditors = mergeContentEditors(clientFeatures);
|
|
359
350
|
|
|
@@ -5,14 +5,13 @@
|
|
|
5
5
|
// variable-chip bar underneath. A chip click inserts `{{name}}` at the caret
|
|
6
6
|
// instead of appending to the end.
|
|
7
7
|
//
|
|
8
|
-
// ponytail: looks the textarea up
|
|
9
|
-
// ref threaded through the primitives Input contract — that contract
|
|
10
|
-
// cross-platform (RN has no DOM node), this file is `@runtime client`-
|
|
11
|
-
//
|
|
12
|
-
//
|
|
8
|
+
// ponytail: looks the textarea up via document.getElementById(id) rather
|
|
9
|
+
// than a ref threaded through the primitives Input contract — that contract
|
|
10
|
+
// is cross-platform (RN has no DOM node), this file is `@runtime client`-
|
|
11
|
+
// only. `id` is per-instance (caller passes a stable unique id via
|
|
12
|
+
// ContentEditorProps.id), so two plain editors mounted at once don't collide.
|
|
13
13
|
|
|
14
14
|
import {
|
|
15
|
-
CONTENT_EDITOR_ELEMENT_ID,
|
|
16
15
|
type ContentEditorProps,
|
|
17
16
|
TextareaContentEditor,
|
|
18
17
|
VariableChips,
|
|
@@ -21,6 +20,7 @@ import type { ReactNode } from "react";
|
|
|
21
20
|
import { useEffect, useState } from "react";
|
|
22
21
|
|
|
23
22
|
export function PlainContentEditor({
|
|
23
|
+
id,
|
|
24
24
|
value,
|
|
25
25
|
onChange,
|
|
26
26
|
variables,
|
|
@@ -31,7 +31,7 @@ export function PlainContentEditor({
|
|
|
31
31
|
// biome-ignore lint/correctness/useExhaustiveDependencies: keyed on value only — must fire once per committed insert, not on every caret write
|
|
32
32
|
useEffect(() => {
|
|
33
33
|
if (caret === null) return;
|
|
34
|
-
const el = document.getElementById(
|
|
34
|
+
const el = document.getElementById(id);
|
|
35
35
|
if (el instanceof HTMLTextAreaElement) {
|
|
36
36
|
el.focus();
|
|
37
37
|
el.setSelectionRange(caret, caret);
|
|
@@ -40,7 +40,7 @@ export function PlainContentEditor({
|
|
|
40
40
|
}, [value]);
|
|
41
41
|
|
|
42
42
|
const insertAtCaret = (name: string): void => {
|
|
43
|
-
const el = document.getElementById(
|
|
43
|
+
const el = document.getElementById(id);
|
|
44
44
|
const placeholder = `{{${name}}}`;
|
|
45
45
|
if (!(el instanceof HTMLTextAreaElement)) {
|
|
46
46
|
onChange(value + placeholder);
|
|
@@ -55,6 +55,7 @@ export function PlainContentEditor({
|
|
|
55
55
|
return (
|
|
56
56
|
<div>
|
|
57
57
|
<TextareaContentEditor
|
|
58
|
+
id={id}
|
|
58
59
|
value={value}
|
|
59
60
|
onChange={onChange}
|
|
60
61
|
variables={variables}
|
|
@@ -7,14 +7,13 @@
|
|
|
7
7
|
// link button, so no URL-prompt UI to build or test.
|
|
8
8
|
|
|
9
9
|
import {
|
|
10
|
-
CONTENT_EDITOR_ELEMENT_ID,
|
|
11
10
|
type ContentEditorProps,
|
|
12
11
|
TextareaContentEditor,
|
|
13
12
|
usePrimitives,
|
|
14
13
|
useTranslation,
|
|
15
14
|
VariableChips,
|
|
16
15
|
} from "@cosmicdrift/kumiko-renderer";
|
|
17
|
-
import { EditorContent, useEditor } from "@tiptap/react";
|
|
16
|
+
import { EditorContent, useEditor, useEditorState } from "@tiptap/react";
|
|
18
17
|
import StarterKit from "@tiptap/starter-kit";
|
|
19
18
|
import {
|
|
20
19
|
Bold as BoldIcon,
|
|
@@ -62,12 +61,19 @@ function Toolbar({
|
|
|
62
61
|
}
|
|
63
62
|
|
|
64
63
|
export default function TiptapEditor({
|
|
64
|
+
id,
|
|
65
65
|
value,
|
|
66
66
|
onChange,
|
|
67
67
|
variables,
|
|
68
68
|
readOnly,
|
|
69
69
|
}: ContentEditorProps): ReactNode {
|
|
70
70
|
const t = useTranslation();
|
|
71
|
+
// ponytail: rich = StarterKit's schema (bold/italic/lists/headings/links).
|
|
72
|
+
// Anything the schema doesn't model — <table>, <div>/<span>, style attrs,
|
|
73
|
+
// <img>, classes — is dropped on first parse (setContent below, and the
|
|
74
|
+
// initial `content: value` here). Upgrade if that ceiling is hit: add the
|
|
75
|
+
// matching tiptap extensions, or a raw-HTML mode for markup StarterKit
|
|
76
|
+
// can't represent.
|
|
71
77
|
const editor = useEditor({
|
|
72
78
|
extensions: [StarterKit],
|
|
73
79
|
content: value,
|
|
@@ -76,13 +82,34 @@ export default function TiptapEditor({
|
|
|
76
82
|
// this component before committing it; immediate render would create
|
|
77
83
|
// an editor instance during that throwaway pass.
|
|
78
84
|
immediatelyRender: false,
|
|
79
|
-
//
|
|
80
|
-
//
|
|
81
|
-
//
|
|
82
|
-
editorProps: { attributes: { id
|
|
85
|
+
// Caller-supplied id — the Field wrapping the editor (TextBlockEditor)
|
|
86
|
+
// associates its label via this id, see ContentEditorProps.id in
|
|
87
|
+
// content-editors.tsx.
|
|
88
|
+
editorProps: { attributes: { id } },
|
|
83
89
|
onUpdate: ({ editor: e }) => onChange(e.getHTML()),
|
|
84
90
|
});
|
|
85
91
|
|
|
92
|
+
// `useEditor` alone leaves `editor.isActive(...)` reads in the render body
|
|
93
|
+
// frozen on the last prop-driven render — @tiptap/react v3 defaults
|
|
94
|
+
// `shouldRerenderOnTransaction` to false, so a selection-only change (e.g.
|
|
95
|
+
// moving the cursor into bold text without typing) never triggers a
|
|
96
|
+
// re-render on its own. `useEditorState` subscribes to transactions itself
|
|
97
|
+
// and only re-renders when the selected slice of state actually changes.
|
|
98
|
+
const activeState = useEditorState({
|
|
99
|
+
editor,
|
|
100
|
+
selector: ({ editor: e }) =>
|
|
101
|
+
e === null
|
|
102
|
+
? null
|
|
103
|
+
: {
|
|
104
|
+
bold: e.isActive("bold"),
|
|
105
|
+
italic: e.isActive("italic"),
|
|
106
|
+
heading1: e.isActive("heading", { level: 1 }),
|
|
107
|
+
heading2: e.isActive("heading", { level: 2 }),
|
|
108
|
+
bulletList: e.isActive("bulletList"),
|
|
109
|
+
orderedList: e.isActive("orderedList"),
|
|
110
|
+
},
|
|
111
|
+
});
|
|
112
|
+
|
|
86
113
|
// TextBlockEditor loads the entry's content asynchronously (by-slug query
|
|
87
114
|
// resolves after mount), so `value` arrives after useEditor already read
|
|
88
115
|
// its initial (empty) content once. Sync it in — guarded against
|
|
@@ -100,6 +127,7 @@ export default function TiptapEditor({
|
|
|
100
127
|
if (!editor)
|
|
101
128
|
return (
|
|
102
129
|
<TextareaContentEditor
|
|
130
|
+
id={id}
|
|
103
131
|
value={value}
|
|
104
132
|
onChange={onChange}
|
|
105
133
|
variables={variables}
|
|
@@ -111,37 +139,37 @@ export default function TiptapEditor({
|
|
|
111
139
|
{
|
|
112
140
|
label: t("kumiko.contentEditor.bold"),
|
|
113
141
|
icon: BoldIcon,
|
|
114
|
-
isActive:
|
|
142
|
+
isActive: activeState?.bold ?? false,
|
|
115
143
|
onClick: () => editor.chain().focus().toggleBold().run(),
|
|
116
144
|
},
|
|
117
145
|
{
|
|
118
146
|
label: t("kumiko.contentEditor.italic"),
|
|
119
147
|
icon: ItalicIcon,
|
|
120
|
-
isActive:
|
|
148
|
+
isActive: activeState?.italic ?? false,
|
|
121
149
|
onClick: () => editor.chain().focus().toggleItalic().run(),
|
|
122
150
|
},
|
|
123
151
|
{
|
|
124
152
|
label: t("kumiko.contentEditor.heading1"),
|
|
125
153
|
icon: Heading1,
|
|
126
|
-
isActive:
|
|
154
|
+
isActive: activeState?.heading1 ?? false,
|
|
127
155
|
onClick: () => editor.chain().focus().toggleHeading({ level: 1 }).run(),
|
|
128
156
|
},
|
|
129
157
|
{
|
|
130
158
|
label: t("kumiko.contentEditor.heading2"),
|
|
131
159
|
icon: Heading2,
|
|
132
|
-
isActive:
|
|
160
|
+
isActive: activeState?.heading2 ?? false,
|
|
133
161
|
onClick: () => editor.chain().focus().toggleHeading({ level: 2 }).run(),
|
|
134
162
|
},
|
|
135
163
|
{
|
|
136
164
|
label: t("kumiko.contentEditor.bulletList"),
|
|
137
165
|
icon: List,
|
|
138
|
-
isActive:
|
|
166
|
+
isActive: activeState?.bulletList ?? false,
|
|
139
167
|
onClick: () => editor.chain().focus().toggleBulletList().run(),
|
|
140
168
|
},
|
|
141
169
|
{
|
|
142
170
|
label: t("kumiko.contentEditor.orderedList"),
|
|
143
171
|
icon: ListOrdered,
|
|
144
|
-
isActive:
|
|
172
|
+
isActive: activeState?.orderedList ?? false,
|
|
145
173
|
onClick: () => editor.chain().focus().toggleOrderedList().run(),
|
|
146
174
|
},
|
|
147
175
|
];
|
package/src/index.ts
CHANGED
|
@@ -153,6 +153,7 @@ export {
|
|
|
153
153
|
DropdownMenuTrigger,
|
|
154
154
|
} from "./primitives/dropdown-menu";
|
|
155
155
|
export { PageSection, Stack } from "./primitives/layout";
|
|
156
|
+
export { formatMoney } from "./primitives/money-input";
|
|
156
157
|
export type { ToastOptions, ToastProviderProps, ToastVariant } from "./primitives/toast";
|
|
157
158
|
export { ToastProvider, useToast } from "./primitives/toast";
|
|
158
159
|
export type { CreateEventSourceLiveEventsOptions } from "./sse/live-events";
|