@lotics/ui 29.2.0 → 29.3.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/docs/catalog.md +7 -1
- package/docs/data_entry.md +13 -0
- package/package.json +2 -1
- package/src/file_intake.ts +17 -2
- package/src/mime.ts +76 -0
package/docs/catalog.md
CHANGED
|
@@ -1328,7 +1328,13 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
|
|
|
1328
1328
|
|
|
1329
1329
|
- **`mime`** — pure MIME predicates: `isImageMimeType` / `isVideoMimeType` /
|
|
1330
1330
|
`isAudioMimeType` / `isPdfMimeType` / `isExcelMimeType` / `isCsvMimeType` /
|
|
1331
|
-
`isDocxMimeType` / `isPreviewableMimeType`.
|
|
1331
|
+
`isDocxMimeType` / `isPreviewableMimeType`. Plus
|
|
1332
|
+
**`resolveMimeType(declaredType, filename)`** — what to TREAT a file as. An
|
|
1333
|
+
honestly-declared type is returned untouched; an *uninformative* one (`""`,
|
|
1334
|
+
`application/octet-stream` and friends — what an OS clipboard or drag hands
|
|
1335
|
+
over) is resolved from the filename extension instead, falling back to the
|
|
1336
|
+
declaration when the extension is unknown too. It returns the same answer the
|
|
1337
|
+
server stores, so a surface that filters on it agrees with what gets persisted.
|
|
1332
1338
|
- **`download`** — `downloadFileFromUrl(url, filename, { credentials? })`: fetch+blob+anchor
|
|
1333
1339
|
download that works inside sandboxed iframes (where `window.open` is silently dropped);
|
|
1334
1340
|
`credentials` defaults to `same-origin` — pass `"include"` only for auth-gated same-site
|
package/docs/data_entry.md
CHANGED
|
@@ -581,6 +581,19 @@ intake paths, all wired to ONE handler:
|
|
|
581
581
|
modal dialog with no wrapper). Scope it with `enabled` ("while this record/dialog is open");
|
|
582
582
|
a paste with no acceptable file is left alone, so Ctrl+V in a text field still pastes text.
|
|
583
583
|
|
|
584
|
+
**What `accept` is matched against, and the one thing a paste cannot do.** A pattern is an
|
|
585
|
+
extension (`.pdf`), a wildcard type (`image/*`) or an exact MIME. A file is matched on
|
|
586
|
+
`resolveMimeType(file.type, file.name)` — its own declared type when that type says something,
|
|
587
|
+
and what its EXTENSION says when it doesn't. That fallback is not a nicety: a file copied in
|
|
588
|
+
Finder/Explorer and pasted arrives with real bytes and a real filename but an uninformative
|
|
589
|
+
type — `""`, or just as often `application/octet-stream`, the same non-answer wearing the shape
|
|
590
|
+
of one. Judging it on that reads silence as "not a PDF", the file is discarded, and since an
|
|
591
|
+
unacceptable paste is deliberately left alone, the user sees nothing happen at all. Matching on
|
|
592
|
+
the resolved type also means the surface agrees with what the server will store. Separately,
|
|
593
|
+
some clipboards carry only a file *reference* (a path/URL, no bytes): there is nothing to
|
|
594
|
+
upload and no API that can go read it, so those pastes cannot work by any means — dragging the
|
|
595
|
+
file is the path that always does.
|
|
596
|
+
|
|
584
597
|
**Paste routing (focus, then stack).** When two file targets are enabled on the same layer,
|
|
585
598
|
the paste goes to the TOP-MOST one whose `region` contains focus (`document.activeElement`) —
|
|
586
599
|
so two peer file sections each win while the user is working in them, never "last-mounted
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lotics/ui",
|
|
3
|
-
"version": "29.
|
|
3
|
+
"version": "29.3.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"exports": {
|
|
6
6
|
"./vite": {
|
|
@@ -324,6 +324,7 @@
|
|
|
324
324
|
"test": "vitest run"
|
|
325
325
|
},
|
|
326
326
|
"devDependencies": {
|
|
327
|
+
"@lotics/shared": "*",
|
|
327
328
|
"@types/react-dom": "~19.2.2",
|
|
328
329
|
"@vitejs/plugin-react": "^4.3.4",
|
|
329
330
|
"lucide-react": "^0.562.0",
|
package/src/file_intake.ts
CHANGED
|
@@ -13,11 +13,13 @@
|
|
|
13
13
|
|
|
14
14
|
import type { ReactNode } from "react";
|
|
15
15
|
import type { StyleProp, ViewStyle } from "react-native";
|
|
16
|
+
import { resolveMimeType } from "./mime";
|
|
16
17
|
|
|
17
18
|
/** The filter every intake path applies to what arrived. */
|
|
18
19
|
export interface FileIntakeFilter {
|
|
19
20
|
/** Native `accept` filter (e.g. `"application/pdf,image/*"`) — matched against
|
|
20
|
-
* the file's MIME type
|
|
21
|
+
* the file's MIME type (falling back to what its extension says when the file
|
|
22
|
+
* declares none), or against its filename for a `.ext` pattern. */
|
|
21
23
|
accept?: string;
|
|
22
24
|
/** Keep more than one file. Default true. */
|
|
23
25
|
multiple?: boolean;
|
|
@@ -79,15 +81,28 @@ export interface FileDropTargetProps extends FileIntakeFilter {
|
|
|
79
81
|
* Does a file pass a native `accept` list? Each comma-separated pattern is an
|
|
80
82
|
* extension (`.pdf`), a wildcard type (`image/*`) or an exact MIME
|
|
81
83
|
* (`application/pdf`); an empty/absent list accepts everything.
|
|
84
|
+
*
|
|
85
|
+
* A file whose declared type says NOTHING is judged by its extension instead.
|
|
86
|
+
* The OS clipboard is the case that forces this: copy a file in
|
|
87
|
+
* Finder/Explorer and paste it, and the browser hands over real bytes and a
|
|
88
|
+
* real filename while declaring `""` — or, just as often,
|
|
89
|
+
* `application/octet-stream`, the same non-answer wearing the shape of one.
|
|
90
|
+
* Reading either as a MIME mismatch asserts "this is not a PDF" from data that
|
|
91
|
+
* never said so, and the file is dropped; because a paste carrying nothing
|
|
92
|
+
* acceptable is deliberately left alone, that drop is invisible.
|
|
93
|
+
*
|
|
94
|
+
* `resolveMimeType` owns which declarations count as uninformative, so this
|
|
95
|
+
* matches on the same type the SERVER will store for the file.
|
|
82
96
|
*/
|
|
83
97
|
export function matchesAccept(file: File, accept: string | undefined): boolean {
|
|
84
98
|
if (!accept) return true;
|
|
85
99
|
const patterns = accept.split(",").map((p) => p.trim().toLowerCase()).filter(Boolean);
|
|
86
100
|
if (patterns.length === 0) return true;
|
|
87
|
-
const mime = file.type.toLowerCase();
|
|
88
101
|
const name = file.name.toLowerCase();
|
|
102
|
+
const mime = resolveMimeType(file.type, name);
|
|
89
103
|
return patterns.some((p) => {
|
|
90
104
|
if (p.startsWith(".")) return name.endsWith(p);
|
|
105
|
+
if (!mime) return false;
|
|
91
106
|
if (p.endsWith("/*")) return mime.startsWith(p.slice(0, -1));
|
|
92
107
|
return mime === p;
|
|
93
108
|
});
|
package/src/mime.ts
CHANGED
|
@@ -52,3 +52,79 @@ export function isPreviewableMimeType(mimeType: string): boolean {
|
|
|
52
52
|
isDocxMimeType(mimeType)
|
|
53
53
|
);
|
|
54
54
|
}
|
|
55
|
+
|
|
56
|
+
/** A MIME type without its parameters (`text/csv; charset=utf-8` → `text/csv`). */
|
|
57
|
+
function normalizeMimeType(mimeType: string): string {
|
|
58
|
+
return mimeType.split(";")[0]!.trim().toLowerCase();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Types that carry no information — a sender that declined to classify the
|
|
63
|
+
* bytes. The empty string is only the most obvious one: an OS clipboard or drag
|
|
64
|
+
* routinely hands a file over as `application/octet-stream` instead, which is
|
|
65
|
+
* the same statement made in a way that LOOKS like an answer.
|
|
66
|
+
*/
|
|
67
|
+
const GENERIC_MIME_TYPES: ReadonlySet<string> = new Set([
|
|
68
|
+
"",
|
|
69
|
+
"application/octet-stream",
|
|
70
|
+
"binary/octet-stream",
|
|
71
|
+
"application/binary",
|
|
72
|
+
"application/unknown",
|
|
73
|
+
]);
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Extension → MIME, for the document and image formats an intake surface names.
|
|
77
|
+
* A `Map`, not an object literal, so a file named `x.constructor` looks up a
|
|
78
|
+
* miss rather than `Object.prototype`'s member.
|
|
79
|
+
*/
|
|
80
|
+
const MIME_BY_EXTENSION: ReadonlyMap<string, string> = new Map([
|
|
81
|
+
["pdf", "application/pdf"],
|
|
82
|
+
["png", "image/png"],
|
|
83
|
+
["jpg", "image/jpeg"],
|
|
84
|
+
["jpeg", "image/jpeg"],
|
|
85
|
+
["gif", "image/gif"],
|
|
86
|
+
["webp", "image/webp"],
|
|
87
|
+
["avif", "image/avif"],
|
|
88
|
+
["bmp", "image/bmp"],
|
|
89
|
+
["svg", "image/svg+xml"],
|
|
90
|
+
["tif", "image/tiff"],
|
|
91
|
+
["tiff", "image/tiff"],
|
|
92
|
+
["heic", "image/heic"],
|
|
93
|
+
["heif", "image/heif"],
|
|
94
|
+
["csv", "text/csv"],
|
|
95
|
+
["tsv", "text/tab-separated-values"],
|
|
96
|
+
["txt", "text/plain"],
|
|
97
|
+
["xml", "application/xml"],
|
|
98
|
+
["json", "application/json"],
|
|
99
|
+
["zip", "application/zip"],
|
|
100
|
+
["xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"],
|
|
101
|
+
["xls", "application/vnd.ms-excel"],
|
|
102
|
+
["docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document"],
|
|
103
|
+
["doc", "application/msword"],
|
|
104
|
+
]);
|
|
105
|
+
|
|
106
|
+
/** What a filename's extension says the file is, or `""` when it says nothing. */
|
|
107
|
+
function mimeTypeFromFileName(name: string): string {
|
|
108
|
+
const dot = name.lastIndexOf(".");
|
|
109
|
+
// `dot <= 0` also rejects a dotfile (`.pdf` is a NAME, not an extension);
|
|
110
|
+
// `length - 1` rejects a trailing dot.
|
|
111
|
+
if (dot <= 0 || dot === name.length - 1) return "";
|
|
112
|
+
return MIME_BY_EXTENSION.get(name.slice(dot + 1).toLowerCase()) ?? "";
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* The type to TREAT content as, given what its sender declared and what it is
|
|
117
|
+
* called. An honestly-declared type is never second-guessed — only an
|
|
118
|
+
* uninformative one is resolved, and only from the filename, falling back to
|
|
119
|
+
* the declaration when the extension is unrecognized too.
|
|
120
|
+
*
|
|
121
|
+
* Mirrors `resolveMimeType` in `@lotics/shared/file_type`, which is what the
|
|
122
|
+
* server stores. The kit cannot import it (`primitives_purity.test.ts` keeps
|
|
123
|
+
* `src/` free of `@lotics/shared`), so `mime_parity.test.ts` asserts the two
|
|
124
|
+
* agree instead of trusting that they will.
|
|
125
|
+
*/
|
|
126
|
+
export function resolveMimeType(declaredMimeType: string, filename: string): string {
|
|
127
|
+
const declared = normalizeMimeType(declaredMimeType);
|
|
128
|
+
if (!GENERIC_MIME_TYPES.has(declared)) return declared;
|
|
129
|
+
return mimeTypeFromFileName(filename) || declared;
|
|
130
|
+
}
|