@cosmicdrift/kumiko-renderer-web 0.194.0 → 0.196.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 +394 -1
- 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/nav-tree.tsx +8 -0
- package/src/layout/workspace-switcher.tsx +6 -0
- package/src/lib/__tests__/resize-image.test.ts +50 -6
- package/src/lib/accept-attr.ts +5 -0
- 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 +21 -8
- package/src/primitives/file-upload.tsx +1 -6
- package/src/primitives/index.tsx +7 -0
- 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 +16 -6
- package/src/widgets/infinity-list.tsx +33 -5
- package/src/widgets/upload-zone.tsx +7 -8
|
@@ -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
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { useTranslation } from "@cosmicdrift/kumiko-renderer";
|
|
1
2
|
import { Maximize2Icon, Minimize2Icon } from "lucide-react";
|
|
2
3
|
import { type ReactNode, useRef, useState } from "react";
|
|
3
4
|
import { clamp } from "../lib/clamp";
|
|
@@ -54,13 +55,13 @@ function defaultWidthFromViewport(): number {
|
|
|
54
55
|
function floatingSideClass(side: "left" | "right" | "top" | "bottom"): string {
|
|
55
56
|
switch (side) {
|
|
56
57
|
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";
|
|
58
|
+
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
59
|
case "top":
|
|
59
|
-
return "inset-x-8 top-8 h-auto max-h-[80vh] rounded-[2rem] border shadow-2xl";
|
|
60
|
+
return "inset-x-8 top-8 h-auto max-h-[80vh] rounded-[2rem] border shadow-2xl overflow-hidden";
|
|
60
61
|
case "bottom":
|
|
61
|
-
return "inset-x-8 bottom-8 h-auto max-h-[80vh] rounded-[2rem] border shadow-2xl";
|
|
62
|
+
return "inset-x-8 bottom-8 h-auto max-h-[80vh] rounded-[2rem] border shadow-2xl overflow-hidden";
|
|
62
63
|
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";
|
|
64
|
+
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
65
|
}
|
|
65
66
|
}
|
|
66
67
|
|
|
@@ -79,6 +80,7 @@ export function Drawer({
|
|
|
79
80
|
resize,
|
|
80
81
|
backdrop,
|
|
81
82
|
}: DrawerProps): ReactNode {
|
|
83
|
+
const t = useTranslation();
|
|
82
84
|
const canResize = resize !== undefined && (side === "left" || side === "right");
|
|
83
85
|
const minWidthPx = resize?.minWidthPx ?? MIN_WIDTH_PX;
|
|
84
86
|
const maxWidthPx = resize?.maxWidthPx ?? MAX_WIDTH_PX;
|
|
@@ -101,9 +103,14 @@ export function Drawer({
|
|
|
101
103
|
const effectiveWidthPx = maximized ? effectiveMaxWidthPx() : width;
|
|
102
104
|
|
|
103
105
|
const onHandlePointerDown = (event: React.PointerEvent<HTMLDivElement>): void => {
|
|
106
|
+
event.preventDefault();
|
|
104
107
|
event.currentTarget.setPointerCapture(event.pointerId);
|
|
105
108
|
dragRef.current = { startX: event.clientX, startWidth: effectiveWidthPx };
|
|
106
109
|
setMaximized(false);
|
|
110
|
+
// Handle already carries `cursor-col-resize`, so only the text-selection
|
|
111
|
+
// lock is needed here — without it, a fast drag over the drawer content
|
|
112
|
+
// selects the text underneath instead of just resizing.
|
|
113
|
+
document.body.style.setProperty("user-select", "none");
|
|
107
114
|
};
|
|
108
115
|
const onHandlePointerMove = (event: React.PointerEvent<HTMLDivElement>): void => {
|
|
109
116
|
if (dragRef.current === null) return;
|
|
@@ -114,6 +121,7 @@ export function Drawer({
|
|
|
114
121
|
const onHandlePointerUp = (event: React.PointerEvent<HTMLDivElement>): void => {
|
|
115
122
|
event.currentTarget.releasePointerCapture(event.pointerId);
|
|
116
123
|
dragRef.current = null;
|
|
124
|
+
document.body.style.removeProperty("user-select");
|
|
117
125
|
};
|
|
118
126
|
const onHandleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>): void => {
|
|
119
127
|
const step = event.shiftKey ? 40 : 16;
|
|
@@ -146,7 +154,9 @@ export function Drawer({
|
|
|
146
154
|
type="button"
|
|
147
155
|
onClick={() => setMaximized((m) => !m)}
|
|
148
156
|
aria-pressed={maximized}
|
|
149
|
-
aria-label={
|
|
157
|
+
aria-label={
|
|
158
|
+
maximized ? t("kumiko.widget.drawer.restore") : t("kumiko.widget.drawer.maximize")
|
|
159
|
+
}
|
|
150
160
|
className="absolute top-4 right-14 z-10 rounded-xs p-1 text-muted-foreground opacity-70 transition-opacity hover:opacity-100 hover:bg-secondary focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-hidden"
|
|
151
161
|
>
|
|
152
162
|
{maximized ? (
|
|
@@ -169,7 +179,7 @@ export function Drawer({
|
|
|
169
179
|
<div
|
|
170
180
|
role="separator"
|
|
171
181
|
aria-orientation="vertical"
|
|
172
|
-
aria-label="
|
|
182
|
+
aria-label={t("kumiko.widget.drawer.resize")}
|
|
173
183
|
aria-valuenow={effectiveWidthPx}
|
|
174
184
|
aria-valuemin={minWidthPx}
|
|
175
185
|
aria-valuemax={effectiveMaxWidthPx()}
|
|
@@ -20,6 +20,12 @@ export type InfinityListProps<TData = unknown, TRow = Readonly<Record<string, un
|
|
|
20
20
|
/** Must derive from row content (e.g. `row.id`), not from `index` — a
|
|
21
21
|
* live refresh reorders rows (new/changed rows move to the front). */
|
|
22
22
|
readonly rowId: (row: TRow, index: number) => string;
|
|
23
|
+
/** Pass this when `rowId` depends on list position — the live-merge path
|
|
24
|
+
* needs a position-independent identity. Without it, an index-based
|
|
25
|
+
* `rowId` (legitimate when live-merge isn't used) can collide between
|
|
26
|
+
* the freshly-fetched first page's local indices and the accumulated
|
|
27
|
+
* list's indices, silently dropping an unrelated mid-list row on merge. */
|
|
28
|
+
readonly rowKey?: (row: TRow) => string;
|
|
23
29
|
readonly renderRow: (row: TRow) => ReactNode;
|
|
24
30
|
readonly emptyState?: ReactNode;
|
|
25
31
|
readonly className?: string;
|
|
@@ -49,6 +55,7 @@ export function InfinityList<TData = unknown, TRow = Readonly<Record<string, unk
|
|
|
49
55
|
rows,
|
|
50
56
|
nextCursor,
|
|
51
57
|
rowId,
|
|
58
|
+
rowKey,
|
|
52
59
|
renderRow,
|
|
53
60
|
emptyState,
|
|
54
61
|
className,
|
|
@@ -73,6 +80,16 @@ export function InfinityList<TData = unknown, TRow = Readonly<Record<string, unk
|
|
|
73
80
|
nextCursorRef.current = nextCursor;
|
|
74
81
|
const rowIdRef = useRef(rowId);
|
|
75
82
|
rowIdRef.current = rowId;
|
|
83
|
+
const rowKeyRef = useRef(rowKey);
|
|
84
|
+
rowKeyRef.current = rowKey;
|
|
85
|
+
// Position-independent when rowKey is supplied, otherwise falls back to
|
|
86
|
+
// the (possibly position-dependent) rowId — same identity function used
|
|
87
|
+
// for both sides of the live-merge diff below so a position-based rowId
|
|
88
|
+
// can no longer collide across the fresh page's local indices and the
|
|
89
|
+
// accumulated list's indices (fw#1829).
|
|
90
|
+
const identifyRef = useRef((row: TRow, index: number): string =>
|
|
91
|
+
rowKeyRef.current !== undefined ? rowKeyRef.current(row) : rowIdRef.current(row, index),
|
|
92
|
+
);
|
|
76
93
|
|
|
77
94
|
// Discards a response whose request was superseded by a newer one before
|
|
78
95
|
// it resolved (e.g. two searches fired in quick succession) — without
|
|
@@ -102,8 +119,11 @@ export function InfinityList<TData = unknown, TRow = Readonly<Record<string, unk
|
|
|
102
119
|
}
|
|
103
120
|
const nextRows = rowsRef.current(res.data);
|
|
104
121
|
if (cursor === null) {
|
|
122
|
+
// Same identity as refreshFirstPage's freshIds/staleRows diff below
|
|
123
|
+
// — this ref is what that diff's previousFirstPageIds reads, so it
|
|
124
|
+
// must agree on rowKey vs rowId or the two id spaces never overlap.
|
|
105
125
|
firstPageIdsRef.current = new Set(
|
|
106
|
-
nextRows.map((row, index) =>
|
|
126
|
+
nextRows.map((row, index) => identifyRef.current(row, index)),
|
|
107
127
|
);
|
|
108
128
|
}
|
|
109
129
|
setState((prev) => {
|
|
@@ -154,7 +174,7 @@ export function InfinityList<TData = unknown, TRow = Readonly<Record<string, unk
|
|
|
154
174
|
// skip: background live refresh failed, keep showing the current rows
|
|
155
175
|
if (!res.isSuccess) return;
|
|
156
176
|
const freshRows = rowsRef.current(res.data);
|
|
157
|
-
const freshIds = new Set(freshRows.map((row, index) =>
|
|
177
|
+
const freshIds = new Set(freshRows.map((row, index) => identifyRef.current(row, index)));
|
|
158
178
|
// A row absent from freshIds is stale for one of two reasons: it was
|
|
159
179
|
// dropped from page 1 (deleted, or filtered out elsewhere) and must be
|
|
160
180
|
// pruned, or it belongs to an already-accumulated later page and must
|
|
@@ -163,10 +183,18 @@ export function InfinityList<TData = unknown, TRow = Readonly<Record<string, unk
|
|
|
163
183
|
const previousFirstPageIds = firstPageIdsRef.current;
|
|
164
184
|
firstPageIdsRef.current = freshIds;
|
|
165
185
|
setState((prev) => {
|
|
166
|
-
// skip:
|
|
167
|
-
|
|
186
|
+
// skip: the initial load() is still in flight and owns the eventual
|
|
187
|
+
// state — a concurrent refresh landing here has nothing accumulated
|
|
188
|
+
// to merge into yet.
|
|
189
|
+
if (prev.kind === "loading") return prev;
|
|
190
|
+
if (prev.kind !== "ready") {
|
|
191
|
+
// prev.kind === "error": this refresh succeeded — promote straight
|
|
192
|
+
// to "ready" instead of leaving the list stuck on the earlier
|
|
193
|
+
// failure forever (a live event should be able to recover it).
|
|
194
|
+
return { kind: "ready", rows: freshRows, cursor: nextCursorRef.current(res.data) };
|
|
195
|
+
}
|
|
168
196
|
const staleRows = prev.rows.filter((row, index) => {
|
|
169
|
-
const id =
|
|
197
|
+
const id = identifyRef.current(row, index);
|
|
170
198
|
return !freshIds.has(id) && !previousFirstPageIds.has(id);
|
|
171
199
|
});
|
|
172
200
|
return { kind: "ready", rows: [...freshRows, ...staleRows], cursor: prev.cursor };
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { useTranslation } from "@cosmicdrift/kumiko-renderer";
|
|
2
2
|
import { CheckCircle2, FileUp, Loader2, TriangleAlert, Upload } from "lucide-react";
|
|
3
3
|
import { type DragEvent, type ReactNode, useId, useRef, useState } from "react";
|
|
4
|
+
import { toAcceptAttr } from "../lib/accept-attr";
|
|
4
5
|
import { cn } from "../lib/cn";
|
|
5
6
|
import { resizeImageBeforeUpload } from "../lib/resize-image";
|
|
6
7
|
|
|
@@ -39,12 +40,6 @@ export type UploadZoneProps = {
|
|
|
39
40
|
readonly testId?: string;
|
|
40
41
|
};
|
|
41
42
|
|
|
42
|
-
// "jpg" → ".jpg", "image/png" stays as-is. Empty list → no accept attribute.
|
|
43
|
-
function toAcceptAttr(accept?: readonly string[]): string | undefined {
|
|
44
|
-
if (accept === undefined || accept.length === 0) return undefined;
|
|
45
|
-
return accept.map((a) => (a.startsWith(".") || a.includes("/") ? a : `.${a}`)).join(",");
|
|
46
|
-
}
|
|
47
|
-
|
|
48
43
|
// `accept` on the native <input> only filters the file-picker dialog — a
|
|
49
44
|
// drag&drop drop is never routed through it, so any file type lands in
|
|
50
45
|
// `onUpload` regardless of what `accept` promises. Same matching rules as
|
|
@@ -90,9 +85,13 @@ export function UploadZone({
|
|
|
90
85
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
91
86
|
const [rows, setRows] = useState<readonly UploadRow[]>([]);
|
|
92
87
|
const [dragOver, setDragOver] = useState(false);
|
|
88
|
+
// `crypto.randomUUID` only exists in a secure context — a plain-HTTP LAN
|
|
89
|
+
// preview leaves it undefined. These ids are React keys/row ids, not
|
|
90
|
+
// globally unique identifiers, so a per-instance counter is enough.
|
|
91
|
+
const nextRowId = useRef(0);
|
|
93
92
|
|
|
94
93
|
async function uploadOne(file: File): Promise<void> {
|
|
95
|
-
const rowId =
|
|
94
|
+
const rowId = String(nextRowId.current++);
|
|
96
95
|
setRows((prev) => [...prev, { id: rowId, fileName: file.name, status: "uploading" }]);
|
|
97
96
|
try {
|
|
98
97
|
await onUpload(await resizeImageBeforeUpload(file));
|
|
@@ -113,7 +112,7 @@ export function UploadZone({
|
|
|
113
112
|
setRows((prev) => [
|
|
114
113
|
...prev,
|
|
115
114
|
{
|
|
116
|
-
id:
|
|
115
|
+
id: String(nextRowId.current++),
|
|
117
116
|
fileName: file.name,
|
|
118
117
|
status: "error",
|
|
119
118
|
error: t("kumiko.widget.upload.rejected-type"),
|