@cosmicdrift/kumiko-renderer-web 0.194.0 → 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 +9 -1
- package/src/__tests__/entity-edit-redirect.test.tsx +32 -0
- package/src/__tests__/render-edit.test.tsx +53 -0
- package/src/app/__tests__/plain-content-editor.test.tsx +24 -3
- package/src/app/__tests__/rich-content-editor.test.tsx +1 -0
- package/src/app/__tests__/tiptap-editor.test.tsx +53 -8
- package/src/app/plain-content-editor.tsx +9 -8
- package/src/app/tiptap-editor.tsx +34 -16
- package/src/index.ts +1 -0
- package/src/layout/__tests__/workspace-switcher.test.tsx +22 -2
- package/src/layout/workspace-switcher.tsx +6 -0
- package/src/lib/__tests__/resize-image.test.ts +42 -3
- package/src/lib/resize-image.ts +6 -1
- package/src/primitives/__tests__/embedded-list-input.test.tsx +43 -2
- package/src/primitives/embedded-list-input.tsx +9 -8
- package/src/primitives/use-narrow-viewport.ts +28 -0
- package/src/widgets/__tests__/drawer.test.tsx +22 -0
- package/src/widgets/__tests__/upload-zone.test.tsx +27 -1
- package/src/widgets/drawer.tsx +10 -4
- package/src/widgets/upload-zone.tsx +6 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-renderer-web",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.195.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.195.0",
|
|
20
|
+
"@cosmicdrift/kumiko-headless": "0.195.0",
|
|
21
|
+
"@cosmicdrift/kumiko-renderer": "0.195.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",
|
|
@@ -327,7 +327,15 @@ describe("createKumikoApp", () => {
|
|
|
327
327
|
}
|
|
328
328
|
function EditorProbe(): ReactNode {
|
|
329
329
|
const Editor = useContentEditor("rich");
|
|
330
|
-
return
|
|
330
|
+
return (
|
|
331
|
+
<Editor
|
|
332
|
+
id="ca-rich-editor-probe"
|
|
333
|
+
value="hello"
|
|
334
|
+
onChange={() => {}}
|
|
335
|
+
variables={[]}
|
|
336
|
+
readOnly={false}
|
|
337
|
+
/>
|
|
338
|
+
);
|
|
331
339
|
}
|
|
332
340
|
const probeSchema: FeatureSchema = {
|
|
333
341
|
featureName: "tasks",
|
|
@@ -74,6 +74,38 @@ describe("entityEdit redirect (#1942)", () => {
|
|
|
74
74
|
await waitFor(() => expect(navigated).toEqual([{ screenId: "product-detail" }]));
|
|
75
75
|
});
|
|
76
76
|
|
|
77
|
+
test("create: successful save with redirect set carries the newly created entityId along (#1945)", async () => {
|
|
78
|
+
const navigated: NavTarget[] = [];
|
|
79
|
+
const dispatcher = createMockDispatcher({
|
|
80
|
+
write: (async () => ({
|
|
81
|
+
isSuccess: true,
|
|
82
|
+
data: { id: "new-99" },
|
|
83
|
+
})) as unknown as Dispatcher["write"],
|
|
84
|
+
});
|
|
85
|
+
render(
|
|
86
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
87
|
+
<NavProvider
|
|
88
|
+
value={{
|
|
89
|
+
route: { screenId: "shop:screen:product-edit" },
|
|
90
|
+
navigate: (target) => navigated.push(target),
|
|
91
|
+
replace: () => {},
|
|
92
|
+
hrefFor: () => "",
|
|
93
|
+
searchParams: {},
|
|
94
|
+
setSearchParams: () => {},
|
|
95
|
+
}}
|
|
96
|
+
>
|
|
97
|
+
<KumikoScreen schema={buildSchema("product-detail")} qn="shop:screen:product-edit" />
|
|
98
|
+
</NavProvider>
|
|
99
|
+
</DispatcherProvider>,
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
fillNameAndSubmit();
|
|
103
|
+
|
|
104
|
+
await waitFor(() =>
|
|
105
|
+
expect(navigated).toEqual([{ screenId: "product-detail", entityId: "new-99" }]),
|
|
106
|
+
);
|
|
107
|
+
});
|
|
108
|
+
|
|
77
109
|
test("create: no redirect set falls back to the entity's list screen", async () => {
|
|
78
110
|
const navigated: NavTarget[] = [];
|
|
79
111
|
render(
|
|
@@ -1517,6 +1517,59 @@ describe("RenderEdit wizard draft", () => {
|
|
|
1517
1517
|
expect(titleInputAgain.value).toBe("Acme");
|
|
1518
1518
|
});
|
|
1519
1519
|
|
|
1520
|
+
// fw#1929: bare crypto.randomUUID() breaks in non-secure contexts and
|
|
1521
|
+
// React Native/Hermes without a polyfill. With it deleted, minting a
|
|
1522
|
+
// draftId must still work through the mintDraftId() fallback instead of
|
|
1523
|
+
// throwing.
|
|
1524
|
+
test("draft id still mints when crypto.randomUUID is unavailable (fw#1929)", async () => {
|
|
1525
|
+
const cryptoRef = (globalThis as { crypto?: { randomUUID?: () => string } }).crypto;
|
|
1526
|
+
const original = cryptoRef?.randomUUID;
|
|
1527
|
+
// `randomUUID` lives on crypto's prototype (non-configurable) — `delete`
|
|
1528
|
+
// is a silent no-op there, so shadow it with an own `undefined` instead.
|
|
1529
|
+
if (cryptoRef) cryptoRef.randomUUID = undefined;
|
|
1530
|
+
|
|
1531
|
+
try {
|
|
1532
|
+
const draftKeys: string[] = [];
|
|
1533
|
+
const dispatcher = createMockDispatcher({
|
|
1534
|
+
query: (async () => ({ isSuccess: true, data: {} })) as Dispatcher["query"],
|
|
1535
|
+
write: (async (type: string, payload: unknown) => {
|
|
1536
|
+
if (type === "form-draft:write:save") {
|
|
1537
|
+
draftKeys.push((payload as { draftKey: string }).draftKey);
|
|
1538
|
+
}
|
|
1539
|
+
return { isSuccess: true, data: { id: "1" } };
|
|
1540
|
+
}) as Dispatcher["write"],
|
|
1541
|
+
});
|
|
1542
|
+
|
|
1543
|
+
render(
|
|
1544
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
1545
|
+
<DraftStorageProvider value={createFakeDraftStorage()}>
|
|
1546
|
+
<RenderEdit<TestValues>
|
|
1547
|
+
screen={makeDraftWizardScreen(true)}
|
|
1548
|
+
entity={orderEntity}
|
|
1549
|
+
featureName="orders"
|
|
1550
|
+
initial={{ title: "", count: 0 }}
|
|
1551
|
+
writeCommand="order:create"
|
|
1552
|
+
/>
|
|
1553
|
+
</DraftStorageProvider>
|
|
1554
|
+
</DispatcherProvider>,
|
|
1555
|
+
);
|
|
1556
|
+
|
|
1557
|
+
fireEvent.change(
|
|
1558
|
+
screen.getByTestId("field-title").querySelector("input") as HTMLInputElement,
|
|
1559
|
+
{ target: { value: "Acme" } },
|
|
1560
|
+
);
|
|
1561
|
+
await act(async () => {
|
|
1562
|
+
fireEvent.submit(screen.getByTestId("render-edit-form"));
|
|
1563
|
+
await Promise.resolve();
|
|
1564
|
+
});
|
|
1565
|
+
|
|
1566
|
+
expect(draftKeys.length).toBeGreaterThan(0);
|
|
1567
|
+
expect(draftKeys[0]).toContain(":new:draft-");
|
|
1568
|
+
} finally {
|
|
1569
|
+
if (cryptoRef && original) cryptoRef.randomUUID = original;
|
|
1570
|
+
}
|
|
1571
|
+
});
|
|
1572
|
+
|
|
1520
1573
|
test("a successful submit discards the draft", async () => {
|
|
1521
1574
|
const { dispatcher, store, calls } = makeDraftDispatcher();
|
|
1522
1575
|
|
|
@@ -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
|
|
|
@@ -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={[]}
|
|
@@ -6,7 +6,15 @@ 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
|
+
);
|
|
10
18
|
}
|
|
11
19
|
|
|
12
20
|
function ReadOnlyToggle({ initial }: { readonly initial: string }): ReactNode {
|
|
@@ -16,7 +24,13 @@ function ReadOnlyToggle({ initial }: { readonly initial: string }): ReactNode {
|
|
|
16
24
|
<button type="button" onClick={() => setReadOnly((r) => !r)}>
|
|
17
25
|
toggle readOnly
|
|
18
26
|
</button>
|
|
19
|
-
<TiptapEditor
|
|
27
|
+
<TiptapEditor
|
|
28
|
+
id={CONTENT_EDITOR_ELEMENT_ID}
|
|
29
|
+
value={initial}
|
|
30
|
+
onChange={() => {}}
|
|
31
|
+
variables={[]}
|
|
32
|
+
readOnly={readOnly}
|
|
33
|
+
/>
|
|
20
34
|
</div>
|
|
21
35
|
);
|
|
22
36
|
}
|
|
@@ -32,7 +46,13 @@ function LateValue({ loaded }: { readonly loaded: string }): ReactNode {
|
|
|
32
46
|
<button type="button" onClick={() => setValue(loaded)}>
|
|
33
47
|
load
|
|
34
48
|
</button>
|
|
35
|
-
<TiptapEditor
|
|
49
|
+
<TiptapEditor
|
|
50
|
+
id={CONTENT_EDITOR_ELEMENT_ID}
|
|
51
|
+
value={value}
|
|
52
|
+
onChange={setValue}
|
|
53
|
+
variables={[]}
|
|
54
|
+
readOnly={false}
|
|
55
|
+
/>
|
|
36
56
|
</div>
|
|
37
57
|
);
|
|
38
58
|
}
|
|
@@ -40,22 +60,40 @@ function LateValue({ loaded }: { readonly loaded: string }): ReactNode {
|
|
|
40
60
|
describe("TiptapEditor — jsdom smoke", () => {
|
|
41
61
|
test("mounts a contenteditable surface for the given HTML", async () => {
|
|
42
62
|
render(
|
|
43
|
-
<TiptapEditor
|
|
63
|
+
<TiptapEditor
|
|
64
|
+
id={CONTENT_EDITOR_ELEMENT_ID}
|
|
65
|
+
value="<p>hello</p>"
|
|
66
|
+
onChange={() => {}}
|
|
67
|
+
variables={[]}
|
|
68
|
+
readOnly={false}
|
|
69
|
+
/>,
|
|
44
70
|
);
|
|
45
71
|
const editable = await screen.findByText("hello");
|
|
46
72
|
expect(editable.closest('[contenteditable="true"]')).not.toBeNull();
|
|
47
73
|
});
|
|
48
74
|
|
|
49
|
-
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 () => {
|
|
50
76
|
render(
|
|
51
|
-
<TiptapEditor
|
|
77
|
+
<TiptapEditor
|
|
78
|
+
id={CONTENT_EDITOR_ELEMENT_ID}
|
|
79
|
+
value="<p>hello</p>"
|
|
80
|
+
onChange={() => {}}
|
|
81
|
+
variables={[]}
|
|
82
|
+
readOnly={false}
|
|
83
|
+
/>,
|
|
52
84
|
);
|
|
53
85
|
expect(document.getElementById(CONTENT_EDITOR_ELEMENT_ID)).not.toBeNull();
|
|
54
86
|
});
|
|
55
87
|
|
|
56
88
|
test("no variables → no chip bar", async () => {
|
|
57
89
|
render(
|
|
58
|
-
<TiptapEditor
|
|
90
|
+
<TiptapEditor
|
|
91
|
+
id={CONTENT_EDITOR_ELEMENT_ID}
|
|
92
|
+
value="<p>hello</p>"
|
|
93
|
+
onChange={() => {}}
|
|
94
|
+
variables={[]}
|
|
95
|
+
readOnly={false}
|
|
96
|
+
/>,
|
|
59
97
|
);
|
|
60
98
|
await screen.findByText("hello");
|
|
61
99
|
expect(screen.queryAllByRole("button", { name: "Bold" })).toHaveLength(1);
|
|
@@ -64,7 +102,13 @@ describe("TiptapEditor — jsdom smoke", () => {
|
|
|
64
102
|
|
|
65
103
|
test("every toolbar action resolves by its accessible name", async () => {
|
|
66
104
|
render(
|
|
67
|
-
<TiptapEditor
|
|
105
|
+
<TiptapEditor
|
|
106
|
+
id={CONTENT_EDITOR_ELEMENT_ID}
|
|
107
|
+
value="<p>hello</p>"
|
|
108
|
+
onChange={() => {}}
|
|
109
|
+
variables={[]}
|
|
110
|
+
readOnly={false}
|
|
111
|
+
/>,
|
|
68
112
|
);
|
|
69
113
|
await screen.findByText("hello");
|
|
70
114
|
for (const name of [
|
|
@@ -131,6 +175,7 @@ describe("TiptapEditor — jsdom smoke", () => {
|
|
|
131
175
|
const values: string[] = [];
|
|
132
176
|
render(
|
|
133
177
|
<TiptapEditor
|
|
178
|
+
id={CONTENT_EDITOR_ELEMENT_ID}
|
|
134
179
|
value="<table><tbody><tr><td>cell</td></tr></tbody></table><p>hello</p>"
|
|
135
180
|
onChange={(html) => values.push(html)}
|
|
136
181
|
variables={[]}
|
|
@@ -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,6 +61,7 @@ function Toolbar({
|
|
|
62
61
|
}
|
|
63
62
|
|
|
64
63
|
export default function TiptapEditor({
|
|
64
|
+
id,
|
|
65
65
|
value,
|
|
66
66
|
onChange,
|
|
67
67
|
variables,
|
|
@@ -82,17 +82,34 @@ export default function TiptapEditor({
|
|
|
82
82
|
// this component before committing it; immediate render would create
|
|
83
83
|
// an editor instance during that throwaway pass.
|
|
84
84
|
immediatelyRender: false,
|
|
85
|
-
//
|
|
86
|
-
//
|
|
87
|
-
//
|
|
88
|
-
|
|
89
|
-
// editors (or a rich + a plain editor on one screen) collide on this
|
|
90
|
-
// DOM id, same ceiling plain-content-editor.tsx documents. Upgrade:
|
|
91
|
-
// thread `id` through ContentEditorProps as an optional override.
|
|
92
|
-
editorProps: { attributes: { id: CONTENT_EDITOR_ELEMENT_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 } },
|
|
93
89
|
onUpdate: ({ editor: e }) => onChange(e.getHTML()),
|
|
94
90
|
});
|
|
95
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
|
+
|
|
96
113
|
// TextBlockEditor loads the entry's content asynchronously (by-slug query
|
|
97
114
|
// resolves after mount), so `value` arrives after useEditor already read
|
|
98
115
|
// its initial (empty) content once. Sync it in — guarded against
|
|
@@ -110,6 +127,7 @@ export default function TiptapEditor({
|
|
|
110
127
|
if (!editor)
|
|
111
128
|
return (
|
|
112
129
|
<TextareaContentEditor
|
|
130
|
+
id={id}
|
|
113
131
|
value={value}
|
|
114
132
|
onChange={onChange}
|
|
115
133
|
variables={variables}
|
|
@@ -121,37 +139,37 @@ export default function TiptapEditor({
|
|
|
121
139
|
{
|
|
122
140
|
label: t("kumiko.contentEditor.bold"),
|
|
123
141
|
icon: BoldIcon,
|
|
124
|
-
isActive:
|
|
142
|
+
isActive: activeState?.bold ?? false,
|
|
125
143
|
onClick: () => editor.chain().focus().toggleBold().run(),
|
|
126
144
|
},
|
|
127
145
|
{
|
|
128
146
|
label: t("kumiko.contentEditor.italic"),
|
|
129
147
|
icon: ItalicIcon,
|
|
130
|
-
isActive:
|
|
148
|
+
isActive: activeState?.italic ?? false,
|
|
131
149
|
onClick: () => editor.chain().focus().toggleItalic().run(),
|
|
132
150
|
},
|
|
133
151
|
{
|
|
134
152
|
label: t("kumiko.contentEditor.heading1"),
|
|
135
153
|
icon: Heading1,
|
|
136
|
-
isActive:
|
|
154
|
+
isActive: activeState?.heading1 ?? false,
|
|
137
155
|
onClick: () => editor.chain().focus().toggleHeading({ level: 1 }).run(),
|
|
138
156
|
},
|
|
139
157
|
{
|
|
140
158
|
label: t("kumiko.contentEditor.heading2"),
|
|
141
159
|
icon: Heading2,
|
|
142
|
-
isActive:
|
|
160
|
+
isActive: activeState?.heading2 ?? false,
|
|
143
161
|
onClick: () => editor.chain().focus().toggleHeading({ level: 2 }).run(),
|
|
144
162
|
},
|
|
145
163
|
{
|
|
146
164
|
label: t("kumiko.contentEditor.bulletList"),
|
|
147
165
|
icon: List,
|
|
148
|
-
isActive:
|
|
166
|
+
isActive: activeState?.bulletList ?? false,
|
|
149
167
|
onClick: () => editor.chain().focus().toggleBulletList().run(),
|
|
150
168
|
},
|
|
151
169
|
{
|
|
152
170
|
label: t("kumiko.contentEditor.orderedList"),
|
|
153
171
|
icon: ListOrdered,
|
|
154
|
-
isActive:
|
|
172
|
+
isActive: activeState?.orderedList ?? false,
|
|
155
173
|
onClick: () => editor.chain().focus().toggleOrderedList().run(),
|
|
156
174
|
},
|
|
157
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";
|
|
@@ -6,14 +6,14 @@
|
|
|
6
6
|
// callback. Radix opens on pointerdown → userEvent instead of
|
|
7
7
|
// fireEvent.click, same as language-switcher.test.tsx.
|
|
8
8
|
|
|
9
|
-
import { describe, expect, mock, test } from "bun:test";
|
|
9
|
+
import { describe, expect, mock, spyOn, test } from "bun:test";
|
|
10
10
|
import {
|
|
11
11
|
createStaticLocaleResolver,
|
|
12
12
|
LocaleProvider,
|
|
13
13
|
type WorkspaceSchema,
|
|
14
14
|
} from "@cosmicdrift/kumiko-renderer";
|
|
15
15
|
import userEvent from "@testing-library/user-event";
|
|
16
|
-
import { renderWithSidebar, screen } from "../../__tests__/test-utils";
|
|
16
|
+
import { render, renderWithSidebar, screen } from "../../__tests__/test-utils";
|
|
17
17
|
import { WorkspaceSwitcher } from "../workspace-switcher";
|
|
18
18
|
|
|
19
19
|
function ws(id: string, label = id): WorkspaceSchema {
|
|
@@ -95,6 +95,26 @@ describe("WorkspaceSwitcher — Render", () => {
|
|
|
95
95
|
expect(screen.getByText("Select workspace")).toBeTruthy();
|
|
96
96
|
});
|
|
97
97
|
|
|
98
|
+
test("rendering outside a SidebarProvider throws — the JSDoc requirement is a real crash, not just documentation (fw#1816)", () => {
|
|
99
|
+
// SidebarMenuButton calls useSidebar() internally; without a
|
|
100
|
+
// SidebarProvider ancestor that throws synchronously during render.
|
|
101
|
+
// Silence the expected console.error noise React logs alongside it.
|
|
102
|
+
const consoleError = spyOn(console, "error").mockImplementation(() => {});
|
|
103
|
+
try {
|
|
104
|
+
expect(() =>
|
|
105
|
+
render(
|
|
106
|
+
<WorkspaceSwitcher
|
|
107
|
+
workspaces={[ws("a", "Alpha"), ws("b", "Beta")]}
|
|
108
|
+
activeId="a"
|
|
109
|
+
onSelect={() => {}}
|
|
110
|
+
/>,
|
|
111
|
+
),
|
|
112
|
+
).toThrow(/useSidebar must be used within a SidebarProvider/);
|
|
113
|
+
} finally {
|
|
114
|
+
consoleError.mockRestore();
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
|
|
98
118
|
test("Click auf einen Eintrag ruft onSelect mit der Workspace-id", async () => {
|
|
99
119
|
const user = userEvent.setup();
|
|
100
120
|
const onSelect = mock((_id: string) => {});
|
|
@@ -34,6 +34,12 @@ export type WorkspaceSwitcherProps = {
|
|
|
34
34
|
readonly testId?: string;
|
|
35
35
|
};
|
|
36
36
|
|
|
37
|
+
/**
|
|
38
|
+
* Requires an ancestor `SidebarProvider` (see `../ui/sidebar`) — this
|
|
39
|
+
* component renders `SidebarMenuButton`, which calls `useSidebar()`
|
|
40
|
+
* internally and throws ("useSidebar must be used within a
|
|
41
|
+
* SidebarProvider.") when rendered outside one.
|
|
42
|
+
*/
|
|
37
43
|
export function WorkspaceSwitcher({
|
|
38
44
|
workspaces,
|
|
39
45
|
activeId,
|
|
@@ -49,7 +49,9 @@ describe("resizeImageBeforeUpload", () => {
|
|
|
49
49
|
globalThis.createImageBitmap = mock(async () => bitmap);
|
|
50
50
|
|
|
51
51
|
try {
|
|
52
|
-
|
|
52
|
+
// Content sized well above the 1-byte re-encoded blob so the resize
|
|
53
|
+
// wins the size-guard and the assertions below observe its output.
|
|
54
|
+
const file = new File(["x".repeat(2000)], "photo.jpg", { type: "image/jpeg" });
|
|
53
55
|
const result = await resizeImageBeforeUpload(file, 2560);
|
|
54
56
|
expect(capturedWidth).toBe(2560);
|
|
55
57
|
expect(capturedHeight).toBe(1280);
|
|
@@ -81,7 +83,9 @@ describe("resizeImageBeforeUpload", () => {
|
|
|
81
83
|
}));
|
|
82
84
|
|
|
83
85
|
try {
|
|
84
|
-
|
|
86
|
+
// Content sized well above the 1-byte re-encoded blob so the resize
|
|
87
|
+
// wins the size-guard and the assertions below observe its output.
|
|
88
|
+
const file = new File(["x".repeat(2000)], "photo.heic", { type: "image/heic" });
|
|
85
89
|
const result = await resizeImageBeforeUpload(file);
|
|
86
90
|
expect(result.name).toBe("photo.jpg");
|
|
87
91
|
expect(result.type).toBe("image/jpeg");
|
|
@@ -114,7 +118,9 @@ describe("resizeImageBeforeUpload", () => {
|
|
|
114
118
|
}));
|
|
115
119
|
|
|
116
120
|
try {
|
|
117
|
-
|
|
121
|
+
// Content sized well above the 1-byte re-encoded blob so the resize
|
|
122
|
+
// wins the size-guard and the assertions below observe its output.
|
|
123
|
+
const file = new File(["x".repeat(2000)], "photo.webp", { type: "image/webp" });
|
|
118
124
|
const result = await resizeImageBeforeUpload(file);
|
|
119
125
|
expect(result.name).toBe("photo.png");
|
|
120
126
|
expect(result.type).toBe("image/png");
|
|
@@ -126,6 +132,39 @@ describe("resizeImageBeforeUpload", () => {
|
|
|
126
132
|
}
|
|
127
133
|
});
|
|
128
134
|
|
|
135
|
+
test("belässt ein bereits optimiertes Bild unverändert, wenn der Re-Encode es vergrößern würde", async () => {
|
|
136
|
+
// A small in-spec PNG (e.g. palette-optimized) round-tripped through a
|
|
137
|
+
// canvas can come back as a bigger full-RGBA blob — the size guard must
|
|
138
|
+
// then keep serving the original bytes instead of the larger re-encode.
|
|
139
|
+
class FakeOffscreenCanvas {
|
|
140
|
+
getContext() {
|
|
141
|
+
return { drawImage: mock(() => {}) };
|
|
142
|
+
}
|
|
143
|
+
convertToBlob() {
|
|
144
|
+
return Promise.resolve(new Blob(["x".repeat(2000)], { type: "image/png" }));
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
// @ts-expect-error test stub for a browser-only API missing in jsdom
|
|
148
|
+
globalThis.OffscreenCanvas = FakeOffscreenCanvas;
|
|
149
|
+
globalThis.createImageBitmap = mock(async () => ({
|
|
150
|
+
close: mock(() => {}),
|
|
151
|
+
height: 100,
|
|
152
|
+
width: 100,
|
|
153
|
+
}));
|
|
154
|
+
|
|
155
|
+
try {
|
|
156
|
+
const file = new File(["x"], "photo.png", { type: "image/png" });
|
|
157
|
+
const result = await resizeImageBeforeUpload(file);
|
|
158
|
+
expect(result).toBe(file);
|
|
159
|
+
expect(result.size).toBeLessThanOrEqual(file.size);
|
|
160
|
+
} finally {
|
|
161
|
+
// @ts-expect-error restore missing-API baseline
|
|
162
|
+
globalThis.OffscreenCanvas = undefined;
|
|
163
|
+
// @ts-expect-error restore missing-API baseline
|
|
164
|
+
globalThis.createImageBitmap = undefined;
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
|
|
129
168
|
test("fällt bei Decode-Fehlern auf die Originaldatei zurück", async () => {
|
|
130
169
|
class FakeOffscreenCanvas {
|
|
131
170
|
getContext() {
|
package/src/lib/resize-image.ts
CHANGED
|
@@ -54,7 +54,12 @@ export async function resizeImageBeforeUpload(
|
|
|
54
54
|
const actualType = blob.type || file.type;
|
|
55
55
|
const fileName =
|
|
56
56
|
actualType === file.type ? file.name : withMatchingExtension(file.name, actualType);
|
|
57
|
-
|
|
57
|
+
const resized = new File([blob], fileName, { type: actualType });
|
|
58
|
+
// Only takes the re-encode if it actually shrank the file — a
|
|
59
|
+
// palette-optimized PNG or a small in-spec photo can come back larger
|
|
60
|
+
// after a full RGBA canvas round-trip. EXIF/GPS stripping is then
|
|
61
|
+
// best-effort: an original that's kept as-is keeps its metadata too.
|
|
62
|
+
return resized.size < file.size ? resized : file;
|
|
58
63
|
} catch {
|
|
59
64
|
return file;
|
|
60
65
|
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// test interacts with the rendered DOM (click/type/paste) and asserts on
|
|
3
3
|
// the outcome; none merely check that the component mounts.
|
|
4
4
|
|
|
5
|
-
import { describe, expect, mock, test } from "bun:test";
|
|
5
|
+
import { describe, expect, mock, spyOn, test } from "bun:test";
|
|
6
6
|
import type { FieldIssue } from "@cosmicdrift/kumiko-headless";
|
|
7
7
|
import type { EmbeddedListColumn, EmbeddedListInputProps } from "@cosmicdrift/kumiko-renderer";
|
|
8
8
|
import {
|
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
LocaleProvider,
|
|
12
12
|
} from "@cosmicdrift/kumiko-renderer";
|
|
13
13
|
import { fireEvent, render, screen, within } from "@testing-library/react";
|
|
14
|
-
import { type ReactElement, useState } from "react";
|
|
14
|
+
import { act, type ReactElement, useState } from "react";
|
|
15
15
|
import { EmbeddedListInput } from "../embedded-list-input";
|
|
16
16
|
|
|
17
17
|
function setViewportWidth(width: number): void {
|
|
@@ -135,6 +135,47 @@ describe("EmbeddedListInput — desktop/mobile are mutually exclusive mounts (#1
|
|
|
135
135
|
setViewportWidth(originalWidth);
|
|
136
136
|
}
|
|
137
137
|
});
|
|
138
|
+
|
|
139
|
+
test("mobile viewport at mount never creates the desktop table — no mount-then-discard flicker (fw#1868)", () => {
|
|
140
|
+
// The old useIsMobile hook only set its value in a useEffect, so the
|
|
141
|
+
// very first render always mounted the desktop table (isMobile=false)
|
|
142
|
+
// before an effect swapped in the mobile cards — even on a phone. Since
|
|
143
|
+
// effects flush synchronously inside render()'s act(), a plain post-
|
|
144
|
+
// render DOM assertion can't tell the two implementations apart (both
|
|
145
|
+
// end up correct); spying on document.createElement across the whole
|
|
146
|
+
// render call is the only way to prove a <table> was never built at all.
|
|
147
|
+
const originalWidth = window.innerWidth;
|
|
148
|
+
window.innerWidth = 500;
|
|
149
|
+
const createElementSpy = spyOn(document, "createElement");
|
|
150
|
+
try {
|
|
151
|
+
renderWithLocale(<EmbeddedListInput {...baseProps({ rows })} />);
|
|
152
|
+
expect(screen.getByTestId("lines-mobile")).toBeTruthy();
|
|
153
|
+
expect(screen.queryByTestId("lines-desktop")).toBeNull();
|
|
154
|
+
expect(createElementSpy.mock.calls.some(([tag]) => tag === "table")).toBe(false);
|
|
155
|
+
} finally {
|
|
156
|
+
window.innerWidth = originalWidth;
|
|
157
|
+
createElementSpy.mockRestore();
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test("resizing past the breakpoint after mount swaps the table for the card layout", async () => {
|
|
162
|
+
const originalWidth = window.innerWidth;
|
|
163
|
+
try {
|
|
164
|
+
renderWithLocale(<EmbeddedListInput {...baseProps({ rows })} />);
|
|
165
|
+
expect(screen.getByTestId("lines-desktop")).toBeTruthy();
|
|
166
|
+
|
|
167
|
+
await act(async () => {
|
|
168
|
+
window.innerWidth = 500;
|
|
169
|
+
window.dispatchEvent(new Event("resize"));
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
expect(screen.queryByTestId("lines-desktop")).toBeNull();
|
|
173
|
+
expect(screen.getByTestId("lines-mobile")).toBeTruthy();
|
|
174
|
+
expect(document.querySelectorAll('[data-cell-id="lines-0-amount"]').length).toBe(1);
|
|
175
|
+
} finally {
|
|
176
|
+
window.innerWidth = originalWidth;
|
|
177
|
+
}
|
|
178
|
+
});
|
|
138
179
|
});
|
|
139
180
|
|
|
140
181
|
describe("EmbeddedListInput — row mutation callbacks", () => {
|
|
@@ -5,12 +5,13 @@
|
|
|
5
5
|
// paste).
|
|
6
6
|
//
|
|
7
7
|
// Only one of the two layouts (table for md+, cards below md) is mounted
|
|
8
|
-
// at a time, picked via
|
|
9
|
-
// `hidden`/`md:hidden` left two live inputs per cell sharing one DOM
|
|
10
|
-
// (#1854).
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
8
|
+
// at a time, picked via useIsNarrowViewport — mounting both and toggling
|
|
9
|
+
// with `hidden`/`md:hidden` left two live inputs per cell sharing one DOM
|
|
10
|
+
// id (#1854). The `hidden md:block` / `md:hidden` classes stay on the
|
|
11
|
+
// wrapper divs as a defensive fallback (e.g. SSR/no-JS, where
|
|
12
|
+
// getServerSnapshot forces the desktop layout) even though
|
|
13
|
+
// useIsNarrowViewport — unlike the vendored useIsMobile it replaced —
|
|
14
|
+
// already reports the correct value on the very first client render.
|
|
14
15
|
//
|
|
15
16
|
// ponytail: crossing the mobile/desktop breakpoint unmounts the active
|
|
16
17
|
// layout and mounts the other, so an in-progress cell draft (uncommitted
|
|
@@ -41,11 +42,11 @@ import { Button as UiButton } from "../ui/button";
|
|
|
41
42
|
import { Checkbox } from "../ui/checkbox";
|
|
42
43
|
import { Input as UiInput } from "../ui/input";
|
|
43
44
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../ui/table";
|
|
44
|
-
import { useIsMobile } from "../ui/use-mobile";
|
|
45
45
|
import { ComboboxInput } from "./combobox";
|
|
46
46
|
import { DateInput } from "./date-input";
|
|
47
47
|
import { formatMoney, MoneyInput } from "./money-input";
|
|
48
48
|
import { TimestampInput } from "./timestamp-input";
|
|
49
|
+
import { useIsNarrowViewport } from "./use-narrow-viewport";
|
|
49
50
|
|
|
50
51
|
// `input[type=hidden]` excluded — ComboboxInput renders one as a plain
|
|
51
52
|
// name-carrier before its focusable trigger button.
|
|
@@ -383,7 +384,7 @@ export function EmbeddedListInput({
|
|
|
383
384
|
// component always hardcoded.
|
|
384
385
|
const effectiveCurrency = currency ?? "EUR";
|
|
385
386
|
const resolvedLocale = useLocale().locale();
|
|
386
|
-
const isMobile =
|
|
387
|
+
const isMobile = useIsNarrowViewport();
|
|
387
388
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
388
389
|
const [pendingFocusCellId, setPendingFocusCellId] = useState<string | undefined>(undefined);
|
|
389
390
|
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { useSyncExternalStore } from "react";
|
|
2
|
+
|
|
3
|
+
// Matches ui/use-mobile.ts's MOBILE_BREAKPOINT. Kept as a separate constant
|
|
4
|
+
// because that file is vendored shadcn (regenerated via scripts/sync-shadcn.ts)
|
|
5
|
+
// and cannot be imported from without risking a future overwrite.
|
|
6
|
+
const MOBILE_BREAKPOINT = 768;
|
|
7
|
+
|
|
8
|
+
function subscribe(callback: () => void): () => void {
|
|
9
|
+
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
|
|
10
|
+
mql.addEventListener("change", callback);
|
|
11
|
+
return () => mql.removeEventListener("change", callback);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function getSnapshot(): boolean {
|
|
15
|
+
return window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`).matches;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function getServerSnapshot(): boolean {
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Unlike the vendored `useIsMobile` (ui/use-mobile.ts), which only sets its
|
|
23
|
+
// result in a `useEffect` and therefore always reports `false` on the first
|
|
24
|
+
// render regardless of actual viewport, this reads the real value up front
|
|
25
|
+
// via `useSyncExternalStore` — no wrong-then-corrected first render.
|
|
26
|
+
export function useIsNarrowViewport(): boolean {
|
|
27
|
+
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
|
|
28
|
+
}
|
|
@@ -299,6 +299,28 @@ describe("Drawer", () => {
|
|
|
299
299
|
expect(handle.getAttribute("aria-valuenow")).toBe("484");
|
|
300
300
|
});
|
|
301
301
|
|
|
302
|
+
test("PointerDown on the handle locks text selection, PointerUp restores it (fw#1965)", () => {
|
|
303
|
+
render(
|
|
304
|
+
<Drawer
|
|
305
|
+
open={true}
|
|
306
|
+
onOpenChange={() => {}}
|
|
307
|
+
side="right"
|
|
308
|
+
testId="drawer"
|
|
309
|
+
resize={{ defaultWidthPx: 400, minWidthPx: 300, maxWidthPx: 500 }}
|
|
310
|
+
>
|
|
311
|
+
<div>Body</div>
|
|
312
|
+
</Drawer>,
|
|
313
|
+
);
|
|
314
|
+
const handle = screen.getByRole("separator");
|
|
315
|
+
expect(document.body.style.userSelect).toBe("");
|
|
316
|
+
|
|
317
|
+
fireEvent.pointerDown(handle, { pointerId: 1, clientX: 100 });
|
|
318
|
+
expect(document.body.style.userSelect).toBe("none");
|
|
319
|
+
|
|
320
|
+
fireEvent.pointerUp(handle, { pointerId: 1, clientX: 100 });
|
|
321
|
+
expect(document.body.style.userSelect).toBe("");
|
|
322
|
+
});
|
|
323
|
+
|
|
302
324
|
test("side='top' with resize set: no resize handle, no maximize button (vertical drawers can't resize)", () => {
|
|
303
325
|
render(
|
|
304
326
|
<Drawer
|
|
@@ -110,6 +110,30 @@ describe("UploadZone", () => {
|
|
|
110
110
|
expect(screen.queryByText("upload_failed")).toBeNull();
|
|
111
111
|
});
|
|
112
112
|
|
|
113
|
+
test("uploads multiple files with distinct, stable row keys even without crypto.randomUUID (insecure-context LAN preview)", async () => {
|
|
114
|
+
// `crypto.randomUUID` only exists in a secure context — an HTTP-over-LAN
|
|
115
|
+
// preview leaves it undefined, so calling it throws TypeError. Row ids
|
|
116
|
+
// must not depend on it.
|
|
117
|
+
const originalRandomUUID = crypto.randomUUID;
|
|
118
|
+
(crypto as unknown as { randomUUID?: () => string }).randomUUID = undefined;
|
|
119
|
+
try {
|
|
120
|
+
const onUpload = mock(async () => {});
|
|
121
|
+
render(<UploadZone title="Datei hochladen" onUpload={onUpload} testId="zone" />);
|
|
122
|
+
const a = new File(["a"], "a.pdf");
|
|
123
|
+
const b = new File(["b"], "b.pdf");
|
|
124
|
+
pick(screen.getByTestId("zone-input"), [a, b]);
|
|
125
|
+
|
|
126
|
+
await waitFor(() => expect(onUpload).toHaveBeenCalledTimes(2));
|
|
127
|
+
expect(screen.getByText("a.pdf")).toBeTruthy();
|
|
128
|
+
expect(screen.getByText("b.pdf")).toBeTruthy();
|
|
129
|
+
// Two distinct rows in the DOM (not collapsed onto one shared/undefined
|
|
130
|
+
// React key) proves the ids stayed unique without crypto.randomUUID.
|
|
131
|
+
expect(screen.getAllByTestId("zone-row")).toHaveLength(2);
|
|
132
|
+
} finally {
|
|
133
|
+
crypto.randomUUID = originalRandomUUID;
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
|
|
113
137
|
test("disabled unterdrückt den Drop", () => {
|
|
114
138
|
const onUpload = mock(async () => {});
|
|
115
139
|
render(<UploadZone title="Datei hochladen" onUpload={onUpload} disabled testId="zone" />);
|
|
@@ -164,7 +188,9 @@ describe("UploadZone", () => {
|
|
|
164
188
|
try {
|
|
165
189
|
const onUpload = mock(async (_uploaded: File) => {});
|
|
166
190
|
render(<UploadZone title="Datei hochladen" onUpload={onUpload} testId="zone" />);
|
|
167
|
-
|
|
191
|
+
// Content sized well above the fake resize's 13 bytes — resizeImageBeforeUpload
|
|
192
|
+
// (framework#1979) only takes the re-encode when it's actually smaller.
|
|
193
|
+
const file = new File(["x".repeat(2000)], "photo.jpg", { type: "image/jpeg" });
|
|
168
194
|
pick(screen.getByTestId("zone-input"), [file]);
|
|
169
195
|
|
|
170
196
|
await waitFor(() => expect(onUpload).toHaveBeenCalledTimes(1));
|
package/src/widgets/drawer.tsx
CHANGED
|
@@ -54,13 +54,13 @@ function defaultWidthFromViewport(): number {
|
|
|
54
54
|
function floatingSideClass(side: "left" | "right" | "top" | "bottom"): string {
|
|
55
55
|
switch (side) {
|
|
56
56
|
case "left":
|
|
57
|
-
return "inset-y-8 left-8 h-auto w-[max(520px,25vw)] max-w-[85vw] sm:max-w-[max(520px,25vw)] rounded-[2rem] border shadow-2xl";
|
|
57
|
+
return "inset-y-8 left-8 h-auto w-[max(520px,25vw)] max-w-[85vw] sm:max-w-[max(520px,25vw)] rounded-[2rem] border shadow-2xl overflow-hidden";
|
|
58
58
|
case "top":
|
|
59
|
-
return "inset-x-8 top-8 h-auto max-h-[80vh] rounded-[2rem] border shadow-2xl";
|
|
59
|
+
return "inset-x-8 top-8 h-auto max-h-[80vh] rounded-[2rem] border shadow-2xl overflow-hidden";
|
|
60
60
|
case "bottom":
|
|
61
|
-
return "inset-x-8 bottom-8 h-auto max-h-[80vh] rounded-[2rem] border shadow-2xl";
|
|
61
|
+
return "inset-x-8 bottom-8 h-auto max-h-[80vh] rounded-[2rem] border shadow-2xl overflow-hidden";
|
|
62
62
|
default:
|
|
63
|
-
return "inset-y-8 right-8 h-auto w-[max(520px,25vw)] max-w-[85vw] sm:max-w-[max(520px,25vw)] rounded-[2rem] border shadow-2xl";
|
|
63
|
+
return "inset-y-8 right-8 h-auto w-[max(520px,25vw)] max-w-[85vw] sm:max-w-[max(520px,25vw)] rounded-[2rem] border shadow-2xl overflow-hidden";
|
|
64
64
|
}
|
|
65
65
|
}
|
|
66
66
|
|
|
@@ -101,9 +101,14 @@ export function Drawer({
|
|
|
101
101
|
const effectiveWidthPx = maximized ? effectiveMaxWidthPx() : width;
|
|
102
102
|
|
|
103
103
|
const onHandlePointerDown = (event: React.PointerEvent<HTMLDivElement>): void => {
|
|
104
|
+
event.preventDefault();
|
|
104
105
|
event.currentTarget.setPointerCapture(event.pointerId);
|
|
105
106
|
dragRef.current = { startX: event.clientX, startWidth: effectiveWidthPx };
|
|
106
107
|
setMaximized(false);
|
|
108
|
+
// Handle already carries `cursor-col-resize`, so only the text-selection
|
|
109
|
+
// lock is needed here — without it, a fast drag over the drawer content
|
|
110
|
+
// selects the text underneath instead of just resizing.
|
|
111
|
+
document.body.style.setProperty("user-select", "none");
|
|
107
112
|
};
|
|
108
113
|
const onHandlePointerMove = (event: React.PointerEvent<HTMLDivElement>): void => {
|
|
109
114
|
if (dragRef.current === null) return;
|
|
@@ -114,6 +119,7 @@ export function Drawer({
|
|
|
114
119
|
const onHandlePointerUp = (event: React.PointerEvent<HTMLDivElement>): void => {
|
|
115
120
|
event.currentTarget.releasePointerCapture(event.pointerId);
|
|
116
121
|
dragRef.current = null;
|
|
122
|
+
document.body.style.removeProperty("user-select");
|
|
117
123
|
};
|
|
118
124
|
const onHandleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>): void => {
|
|
119
125
|
const step = event.shiftKey ? 40 : 16;
|
|
@@ -90,9 +90,13 @@ export function UploadZone({
|
|
|
90
90
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
91
91
|
const [rows, setRows] = useState<readonly UploadRow[]>([]);
|
|
92
92
|
const [dragOver, setDragOver] = useState(false);
|
|
93
|
+
// `crypto.randomUUID` only exists in a secure context — a plain-HTTP LAN
|
|
94
|
+
// preview leaves it undefined. These ids are React keys/row ids, not
|
|
95
|
+
// globally unique identifiers, so a per-instance counter is enough.
|
|
96
|
+
const nextRowId = useRef(0);
|
|
93
97
|
|
|
94
98
|
async function uploadOne(file: File): Promise<void> {
|
|
95
|
-
const rowId =
|
|
99
|
+
const rowId = String(nextRowId.current++);
|
|
96
100
|
setRows((prev) => [...prev, { id: rowId, fileName: file.name, status: "uploading" }]);
|
|
97
101
|
try {
|
|
98
102
|
await onUpload(await resizeImageBeforeUpload(file));
|
|
@@ -113,7 +117,7 @@ export function UploadZone({
|
|
|
113
117
|
setRows((prev) => [
|
|
114
118
|
...prev,
|
|
115
119
|
{
|
|
116
|
-
id:
|
|
120
|
+
id: String(nextRowId.current++),
|
|
117
121
|
fileName: file.name,
|
|
118
122
|
status: "error",
|
|
119
123
|
error: t("kumiko.widget.upload.rejected-type"),
|