smoodly 0.0.10 → 0.0.12
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/README.md +108 -8
- package/dist/admin/client-ops.js +1 -0
- package/dist/admin/forms/CropEditor.d.ts +10 -0
- package/dist/admin/forms/CropEditor.js +61 -0
- package/dist/admin/forms/FieldWidget.js +4 -4
- package/dist/admin/forms/MediaGrid.d.ts +11 -0
- package/dist/admin/forms/MediaGrid.js +16 -0
- package/dist/admin/forms/MediaPicker.d.ts +8 -0
- package/dist/admin/forms/MediaPicker.js +44 -0
- package/dist/admin/forms/MediaWidget.d.ts +9 -0
- package/dist/admin/forms/MediaWidget.js +83 -0
- package/dist/admin/forms/assets-context.d.ts +19 -0
- package/dist/admin/forms/assets-context.js +35 -0
- package/dist/admin/forms/crop-editor.d.ts +13 -0
- package/dist/admin/forms/crop-editor.js +72 -0
- package/dist/admin/forms/measure.d.ts +2 -0
- package/dist/admin/forms/measure.js +39 -0
- package/dist/admin/forms/upload.d.ts +17 -0
- package/dist/admin/forms/upload.js +33 -0
- package/dist/admin/index.d.ts +1 -1
- package/dist/admin/next/op-handler.d.ts +3 -0
- package/dist/admin/next/op-handler.js +1 -0
- package/dist/admin/ops-impl.d.ts +3 -0
- package/dist/admin/ops-impl.js +83 -2
- package/dist/admin/ops.d.ts +27 -0
- package/dist/admin/serialize.d.ts +5 -0
- package/dist/admin/serialize.js +1 -0
- package/dist/admin/shell/AdminApp.d.ts +0 -6
- package/dist/admin/shell/AdminApp.js +10 -17
- package/dist/admin/shell/IconRail.d.ts +7 -0
- package/dist/admin/shell/IconRail.js +73 -0
- package/dist/admin/shell/MediaScreen.d.ts +8 -0
- package/dist/admin/shell/MediaScreen.js +86 -0
- package/dist/admin/shell/media-list.d.ts +8 -0
- package/dist/admin/shell/media-list.js +14 -0
- package/dist/admin/ui/Modal.d.ts +8 -0
- package/dist/admin/ui/Modal.js +15 -0
- package/dist/admin/ui/primitives.d.ts +1 -1
- package/dist/admin/ui/primitives.js +3 -3
- package/dist/admin/ui/theme.d.ts +1 -1
- package/dist/admin/ui/theme.js +107 -24
- package/dist/admin/validate.js +6 -0
- package/dist/asset-store.d.ts +86 -0
- package/dist/asset-store.js +141 -0
- package/dist/collections.js +2 -0
- package/dist/config.d.ts +12 -0
- package/dist/config.js +17 -5
- package/dist/fields.d.ts +8 -10
- package/dist/fields.js +14 -1
- package/dist/image/SmoodlyImage.d.ts +18 -0
- package/dist/image/SmoodlyImage.js +16 -0
- package/dist/image/SmoodlyVideo.d.ts +7 -0
- package/dist/image/SmoodlyVideo.js +5 -0
- package/dist/image/crop-math.d.ts +14 -0
- package/dist/image/crop-math.js +38 -0
- package/dist/image/index.d.ts +6 -0
- package/dist/image/index.js +5 -0
- package/dist/image/loaders.d.ts +15 -0
- package/dist/image/loaders.js +5 -0
- package/dist/image/next-config.d.ts +15 -0
- package/dist/image/next-config.js +27 -0
- package/dist/image-dimensions.d.ts +4 -0
- package/dist/image-dimensions.js +25 -0
- package/dist/index.d.ts +8 -2
- package/dist/index.js +5 -1
- package/dist/media.d.ts +108 -0
- package/dist/media.js +129 -0
- package/dist/refs.d.ts +2 -0
- package/dist/refs.js +25 -4
- package/dist/resolve.d.ts +19 -4
- package/dist/resolve.js +55 -2
- package/dist/site.d.ts +2 -0
- package/dist/site.js +21 -3
- package/dist/sql-space.d.ts +8 -1
- package/dist/sql-space.js +28 -0
- package/dist/sql.js +30 -0
- package/dist/store.js +3 -0
- package/dist/supabase-asset-store.d.ts +21 -0
- package/dist/supabase-asset-store.js +170 -0
- package/package.json +9 -1
package/README.md
CHANGED
|
@@ -131,6 +131,19 @@ Implemented:
|
|
|
131
131
|
store constructor options (`sections`, `entries`, shared
|
|
132
132
|
`MemoryRefIndex` in memory); the cross-store contract
|
|
133
133
|
(`test/page-refs-contract.ts`) runs against memory AND Supabase.
|
|
134
|
+
- Media (spec 2026-09-08): `f.image({ aspect? })` / `f.video()` / `f.file()`
|
|
135
|
+
store `{ asset, alt?, crop?, hotspot?, aspect? }`; `AssetStore` (memory +
|
|
136
|
+
`SupabaseAssetStore`: rows in `assets`, bytes in the public `media`
|
|
137
|
+
bucket, two-op signed uploads); the refs index carries `asset` edges
|
|
138
|
+
through lists, objects and styles, so delete is guarded and usage is
|
|
139
|
+
listed; `resolveTree`/`resolveFields`/the site layer resolve media to
|
|
140
|
+
`{ url, width, height, mime, alt, crop?, hotspot?, aspect? }` with one
|
|
141
|
+
fetch per page; `smoodly/image` ships `<SmoodlyImage>` (a box at the
|
|
142
|
+
aspect, the image framed by crop and hotspot over `next/image`),
|
|
143
|
+
`<SmoodlyVideo>`, `imageUrl` behind the loader seam (v1: the Next
|
|
144
|
+
loader only) and `smoodlyImages()` for next.config's `images`. Admin: the media
|
|
145
|
+
widget in every form, the library picker, the crop-and-hotspot editor,
|
|
146
|
+
the Media screen with metadata, usage links and guarded delete.
|
|
134
147
|
- `EntryStore` contract + `MemoryEntryStore` + `SupabaseEntryStore` —
|
|
135
148
|
collection-entry persistence over entries/entry_locales/entry_versions/paths
|
|
136
149
|
— one node, one row per locale, each locale with its own slug, status
|
|
@@ -297,6 +310,16 @@ Implemented:
|
|
|
297
310
|
derives every URL from it, and `pages.get(id)` hands the admin the
|
|
298
311
|
finished `paths` (locale → CMS path) and `urls` (`href` applied), so the
|
|
299
312
|
admin no longer derives a path of its own.
|
|
313
|
+
- `smoodly/image` — the site's media components: `<SmoodlyImage>`,
|
|
314
|
+
`<SmoodlyVideo>`, `imageUrl(image, { width, quality? }, loader?)`,
|
|
315
|
+
`frameFor`/`boxAspectOf` (the crop math). Server-safe, no `"use client"`.
|
|
316
|
+
- `smoodly/image/config` — `smoodlyImages()` for `next.config`'s `images`:
|
|
317
|
+
the Supabase host as a remote pattern, plus `dangerouslyAllowLocalIP`
|
|
318
|
+
when that host is loopback or private — Next 16's optimizer refuses a
|
|
319
|
+
private upstream otherwise, and the local stack is one. Its own entry
|
|
320
|
+
because Next loads `next.config.ts` in plain Node, where anything that
|
|
321
|
+
reaches `next/image` fails to resolve (next@16 has no `exports` map);
|
|
322
|
+
`smoodly/image` still re-exports the helper for code the bundler sees.
|
|
300
323
|
|
|
301
324
|
- Locked zones are usable: `fillLockedZones` (a pure tree op) materializes
|
|
302
325
|
the one node of every empty locked zone from the component's `sample`
|
|
@@ -350,17 +373,41 @@ is open is hiding a section by a locale-dependent condition).
|
|
|
350
373
|
|
|
351
374
|
## Releasing
|
|
352
375
|
|
|
353
|
-
Both packages move together.
|
|
376
|
+
Both packages move together. Bump before build (the CLI's templates are
|
|
377
|
+
regenerated from `examples/site` in the build and pin `smoodly@<version>`),
|
|
378
|
+
the user runs the two publishes (npm 2FA is a browser passkey), tag only
|
|
379
|
+
after both are on the registry. From the repo root:
|
|
354
380
|
|
|
355
|
-
npm version 0.0.
|
|
381
|
+
npm version 0.0.12 -w smoodly -w create-smoodly-app --no-git-tag-version
|
|
356
382
|
npm run build
|
|
357
|
-
npm publish -w smoodly --
|
|
358
|
-
npm publish -w
|
|
359
|
-
|
|
383
|
+
npm publish -w smoodly --dry-run && npm publish -w create-smoodly-app --dry-run
|
|
384
|
+
npm publish -w smoodly --access public # user; first, the scaffold pins it
|
|
385
|
+
npm publish -w create-smoodly-app --access public # user
|
|
386
|
+
npm view smoodly@0.0.12 version # 404s for a minute or two after a good publish; poll
|
|
387
|
+
npm view create-smoodly-app@0.0.12 version
|
|
388
|
+
git commit -am "release: 0.0.12 — <changes since 0.0.11>" && git tag v0.0.12 && git push --tags origin main
|
|
360
389
|
|
|
361
|
-
`prepublishOnly` rebuilds each package
|
|
362
|
-
|
|
363
|
-
|
|
390
|
+
`prepublishOnly` rebuilds each package. The Claude Code skill
|
|
391
|
+
`.claude/skills/releasing-smoodly` carries the same order with its checks.
|
|
392
|
+
|
|
393
|
+
## Follow-ups (logged 2026-09-08, tooling session)
|
|
394
|
+
|
|
395
|
+
- **CI live job red since e09a632, 0.0.11 fresh install broken — fixed
|
|
396
|
+
2026-09-08, released as 0.0.12: `smoodly/image/config` is the entry
|
|
397
|
+
`next.config.ts` imports; `test/image-config-entry.test.ts` loads the
|
|
398
|
+
built file under bare node.** The
|
|
399
|
+
scaffolded `next.config.ts` imports `smoodlyImages` from `smoodly/image`,
|
|
400
|
+
whose index re-exports `SmoodlyImage.tsx` and its `import Image from
|
|
401
|
+
"next/image"`. Next loads the config in plain Node ESM and next@16.3.4
|
|
402
|
+
has no `exports` map, so the extensionless specifier fails
|
|
403
|
+
(`ERR_MODULE_NOT_FOUND ... Did you mean "next/image.js"?`) before
|
|
404
|
+
`next build` starts; the bundler resolves it, which is why nothing else
|
|
405
|
+
caught it. Reproduce from `examples/site`:
|
|
406
|
+
`node --input-type=module -e "await import('smoodly/image')"`. Fix: a
|
|
407
|
+
component-free entry for the config helper (the template and the
|
|
408
|
+
example comment then import from it), a plain-Node import test, then
|
|
409
|
+
release 0.0.12. Appending `.js` alone is weaker: Node hands back the
|
|
410
|
+
CommonJS `module.exports` object as the default, not the component.
|
|
364
411
|
|
|
365
412
|
## Follow-ups (logged 2026-09-08, client routing spec)
|
|
366
413
|
|
|
@@ -398,10 +445,63 @@ site at the tagged commit.
|
|
|
398
445
|
- Pre-generated image variants at upload time, for hosts without sharp.
|
|
399
446
|
- Transform loaders beyond the Next default: Supabase Image
|
|
400
447
|
Transformations, imgix, Cloudflare Images (DESIGN.md OQ 28).
|
|
448
|
+
- Delivery caching (checked 2026-09-08): Supabase Storage's basic CDN
|
|
449
|
+
fronts every public-bucket object on a hosted project with no code on
|
|
450
|
+
our side, and the Smart CDN (Pro plan, automatic) only adds
|
|
451
|
+
invalidation on overwrite, which never happens — objects are
|
|
452
|
+
immutable, no upsert on either path, Replace picks a new id. So both
|
|
453
|
+
upload paths now set a year-long `cache-control`
|
|
454
|
+
(`MEDIA_CACHE_SECONDS`): the `cacheControl` option on the server-side
|
|
455
|
+
upload, the header on the browser's signed PUT (without it a signed
|
|
456
|
+
upload lands as `no-cache`; Storage's default is one hour). The live
|
|
457
|
+
suite reads the header back from the local stack. Not yet verified
|
|
458
|
+
against a hosted project's CDN edge; check `cf-cache-status` and
|
|
459
|
+
`cache-control` on a real upload there once. Remaining: in v1 the
|
|
460
|
+
site's `<SmoodlyImage>` goes through `next/image`, so the visitor hits
|
|
461
|
+
the app server's image cache and only its origin fetch touches the
|
|
462
|
+
CDN; video, files and SVGs hit the CDN directly. A Supabase Image
|
|
463
|
+
Transformations loader (`/storage/v1/render/image/public/...`, Pro
|
|
464
|
+
only, must stay optional) would move resizing to the CDN — the loader
|
|
465
|
+
bullet above.
|
|
401
466
|
- The asset refs inherit the index's scope: it reflects the latest saved
|
|
402
467
|
version of a source, so a published version still using an asset the
|
|
403
468
|
draft dropped is not protected from delete — the same gap entry refs
|
|
404
469
|
have today.
|
|
470
|
+
- Deviations from the spec, decided while building: the starter's Hero
|
|
471
|
+
declares `aspect: "4:5"` (the design's own ratio), not `16:9`; `assets.list`
|
|
472
|
+
pages by a `created_at` cursor, 60 per page; the loader seam is the
|
|
473
|
+
`loader` prop of `<SmoodlyImage>` and `imageUrl`'s third argument — a
|
|
474
|
+
config-level `media.loader` waits for a second loader to exist.
|
|
475
|
+
- Image dimensions server-side cover JPEG and PNG (`imageDimensions`); a
|
|
476
|
+
WebP or AVIF seeded through `AssetStore.create` lands without a size
|
|
477
|
+
and resolves to nothing until the row is edited. Browser uploads
|
|
478
|
+
measure every type.
|
|
479
|
+
- A section's `styles` are walked for media, the config's standard style
|
|
480
|
+
set is not (it holds selects). A standard-set image would need the
|
|
481
|
+
walk to read `resolvedStyles`.
|
|
482
|
+
- `<SmoodlyImage>` sizes the image by the crop factor, so `sizes` picks a
|
|
483
|
+
candidate for the box, not for the framed image; a CDN loader with
|
|
484
|
+
`handlesCrop` removes the overfetch.
|
|
485
|
+
- `smoodlyImages()` returns the whole `images` config, not just the
|
|
486
|
+
patterns (decided 2026-09-08 in the browser walk): Next 16's optimizer
|
|
487
|
+
answers 400 for an upstream that resolves to a private IP unless
|
|
488
|
+
`images.dangerouslyAllowLocalIP` is set, and the local stack is
|
|
489
|
+
`127.0.0.1`. The helper sets the flag only for `localhost`, loopback
|
|
490
|
+
and RFC 1918 hosts, so a hosted project stays strict; a Docker alias
|
|
491
|
+
that resolves privately needs the flag by hand. The spec's "a helper
|
|
492
|
+
on `smoodly/next`" lives on `smoodly/image` instead, beside the
|
|
493
|
+
components it serves.
|
|
494
|
+
- Build deviations from the plan (2026-09-08, sessions A–C):
|
|
495
|
+
`test/admin-pages-tree.test.ts` builds its `AdminRegistry` literal by
|
|
496
|
+
hand and carries `media: { maxSize: 1, accept: [] }`; `MediaPicker`'s
|
|
497
|
+
`filters` is `readonly KindFilter[]` and the `Segmented` calls pin
|
|
498
|
+
`<KindFilter>`; `MediaScreen` seeds its metadata state from
|
|
499
|
+
`initialSelected` because the plan's effect never runs under
|
|
500
|
+
`renderToStaticMarkup`; `forms/measure.ts` is browser-only and
|
|
501
|
+
untested by design (`putSigned` is unit-tested over a stubbed fetch
|
|
502
|
+
and exercised live since 2026-09-08); the installer's templates are
|
|
503
|
+
generated (gitignored), so "templates rebuilt" is a build step, not a
|
|
504
|
+
diff.
|
|
405
505
|
|
|
406
506
|
## Follow-ups (logged 2026-09-08, richtext widget spec)
|
|
407
507
|
|
package/dist/admin/client-ops.js
CHANGED
|
@@ -2,6 +2,7 @@ const GROUPS = {
|
|
|
2
2
|
pages: ["list", "get", "create", "saveDraft", "publish", "delete", "rename", "move", "setTemplate", "addLocale", "removeLocale"],
|
|
3
3
|
entries: ["list", "get", "create", "save", "setStatus", "delete", "move", "addLocale", "removeLocale"],
|
|
4
4
|
shared: ["list", "get", "save", "setStatus"],
|
|
5
|
+
assets: ["list", "get", "beginUpload", "finishUpload", "update", "delete", "usage"],
|
|
5
6
|
preview: ["enable", "disable"],
|
|
6
7
|
};
|
|
7
8
|
export function makeClientOps(action, token) {
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { type ReactElement } from "react";
|
|
2
|
+
import type { AssetRecord, ImageValue } from "../../media.ts";
|
|
3
|
+
import type { AdminField } from "../serialize.ts";
|
|
4
|
+
export declare function CropEditor({ asset, descriptor, value, onSave, onClose }: {
|
|
5
|
+
asset: AssetRecord;
|
|
6
|
+
descriptor: AdminField;
|
|
7
|
+
value: ImageValue;
|
|
8
|
+
onSave(next: ImageValue): void;
|
|
9
|
+
onClose(): void;
|
|
10
|
+
}): ReactElement;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
|
+
// The crop and hotspot modal (spec 2026-09-08 media §10): the full image,
|
|
4
|
+
// a draggable rectangle (locked to the declared aspect, a segmented
|
|
5
|
+
// switch when several), a draggable hotspot, the per-use alt override
|
|
6
|
+
// and a live preview at the chosen aspect. Pointer deltas become image
|
|
7
|
+
// fractions through the stage's width; the math is in crop-editor.ts.
|
|
8
|
+
import { useRef, useState } from "react";
|
|
9
|
+
import { parseAspect } from "../../media.js";
|
|
10
|
+
import { frameFor } from "../../image/crop-math.js";
|
|
11
|
+
import { Button, Field, Segmented } from "../ui/primitives.js";
|
|
12
|
+
import { Modal } from "../ui/Modal.js";
|
|
13
|
+
import { initialState, moveCrop, placeHotspot, relock, resizeCrop } from "./crop-editor.js";
|
|
14
|
+
const pct = (n) => `${n * 100}%`;
|
|
15
|
+
export function CropEditor({ asset, descriptor, value, onSave, onClose }) {
|
|
16
|
+
const declared = descriptor.aspect;
|
|
17
|
+
const options = Array.isArray(declared) ? declared : typeof declared === "string" ? [declared] : [];
|
|
18
|
+
const width = asset.width ?? 1, height = asset.height ?? 1;
|
|
19
|
+
const imageAspect = width / height;
|
|
20
|
+
const [chosen, setChosen] = useState(options.includes(value.aspect ?? "") ? value.aspect : options[0]);
|
|
21
|
+
const aspect = chosen ? parseAspect(chosen) : undefined;
|
|
22
|
+
const [state, setState] = useState(() => initialState(value, aspect, imageAspect));
|
|
23
|
+
const [alt, setAlt] = useState(value.alt ?? "");
|
|
24
|
+
const stage = useRef(null);
|
|
25
|
+
const drag = useRef(null);
|
|
26
|
+
const pick = (next) => { setChosen(next); setState((s) => relock(s, parseAspect(next), imageAspect)); };
|
|
27
|
+
const begin = (kind) => (e) => {
|
|
28
|
+
e.preventDefault();
|
|
29
|
+
e.stopPropagation();
|
|
30
|
+
e.currentTarget.setPointerCapture(e.pointerId);
|
|
31
|
+
drag.current = { kind, x: e.clientX, y: e.clientY, start: state };
|
|
32
|
+
};
|
|
33
|
+
const move = (e) => {
|
|
34
|
+
const d = drag.current, el = stage.current;
|
|
35
|
+
if (!d || !el)
|
|
36
|
+
return;
|
|
37
|
+
const dx = (e.clientX - d.x) / el.clientWidth, dy = (e.clientY - d.y) / el.clientHeight;
|
|
38
|
+
const lock = aspect === undefined ? undefined : aspect / imageAspect;
|
|
39
|
+
if (d.kind === "move") {
|
|
40
|
+
const crop = moveCrop(d.start.crop, dx, dy);
|
|
41
|
+
setState({ crop, hotspot: placeHotspot(d.start.hotspot, crop) });
|
|
42
|
+
}
|
|
43
|
+
else if (d.kind === "hotspot") {
|
|
44
|
+
setState({ crop: d.start.crop, hotspot: placeHotspot({ x: d.start.hotspot.x + dx, y: d.start.hotspot.y + dy }, d.start.crop) });
|
|
45
|
+
}
|
|
46
|
+
else {
|
|
47
|
+
const crop = resizeCrop(d.start.crop, d.kind, dx, dy, lock);
|
|
48
|
+
setState({ crop, hotspot: placeHotspot(d.start.hotspot, crop) });
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
const end = () => { drag.current = null; };
|
|
52
|
+
const { crop, hotspot } = state;
|
|
53
|
+
const previewAspect = aspect ?? (crop.width * imageAspect) / crop.height;
|
|
54
|
+
const frame = frameFor({ width, height, crop, hotspot }, previewAspect);
|
|
55
|
+
const save = () => onSave({
|
|
56
|
+
...value, crop, hotspot,
|
|
57
|
+
...(alt.trim() ? { alt: alt.trim() } : { alt: undefined }),
|
|
58
|
+
...(options.length > 1 && chosen ? { aspect: chosen } : { aspect: undefined }),
|
|
59
|
+
});
|
|
60
|
+
return (_jsx(Modal, { title: "Crop and focus", width: 900, onClose: onClose, footer: _jsxs(_Fragment, { children: [_jsx(Button, { variant: "secondary", onClick: onClose, children: "Cancel" }), _jsx(Button, { onClick: save, children: "Save" })] }), children: _jsxs("div", { className: "sm-crop", children: [_jsxs("div", { className: "sm-crop__stage", ref: stage, onPointerMove: move, onPointerUp: end, onPointerCancel: end, children: [_jsx("img", { src: asset.url, alt: "", draggable: false }), _jsx("div", { className: "sm-crop__shade", style: { clipPath: `polygon(0 0, 100% 0, 100% 100%, 0 100%, 0 ${pct(crop.top)}, ${pct(crop.left)} ${pct(crop.top)}, ${pct(crop.left)} ${pct(crop.top + crop.height)}, ${pct(crop.left + crop.width)} ${pct(crop.top + crop.height)}, ${pct(crop.left + crop.width)} ${pct(crop.top)}, 0 ${pct(crop.top)})` } }), _jsx("div", { className: "sm-crop__rect", style: { left: pct(crop.left), top: pct(crop.top), width: pct(crop.width), height: pct(crop.height) }, onPointerDown: begin("move"), children: ["nw", "ne", "sw", "se"].map((h) => (_jsx("span", { className: `sm-crop__handle sm-crop__handle--${h}`, onPointerDown: begin(h) }, h))) }), _jsx("div", { className: "sm-crop__hotspot", title: "Focus point", style: { left: pct(hotspot.x), top: pct(hotspot.y) }, onPointerDown: begin("hotspot") })] }), _jsxs("div", { className: "sm-crop__side", children: [options.length > 1 && (_jsx(Field, { label: "Aspect", asDiv: true, children: _jsx(Segmented, { options: options, value: chosen ?? options[0], onChange: pick }) })), _jsx(Field, { label: "Preview", asDiv: true, children: _jsx("div", { className: "sm-crop__preview", style: { aspectRatio: String(previewAspect) }, children: _jsx("img", { src: asset.url, alt: "", style: { left: `${frame.left}%`, top: `${frame.top}%`, width: `${frame.width}%`, height: `${frame.height}%` } }) }) }), _jsx(Field, { label: "Alt text for this use", hint: "Empty uses the asset's own alt.", children: _jsx("input", { className: "sm-input", value: alt, placeholder: asset.alt ?? "", onChange: (e) => setAlt(e.target.value) }) })] })] }) }));
|
|
61
|
+
}
|
|
@@ -7,6 +7,7 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
7
7
|
// logic lives in ./list.ts as pure functions.
|
|
8
8
|
import { useState } from "react";
|
|
9
9
|
import { RichtextWidget } from "./RichtextWidget.js";
|
|
10
|
+
import { MediaWidget } from "./MediaWidget.js";
|
|
10
11
|
import { Button, Field, IconButton } from "../ui/primitives.js";
|
|
11
12
|
import { addItem, itemSummary, moveIndex, moveItem, patchObject, removeIndex, removeItem } from "./list.js";
|
|
12
13
|
const label = (key) => key.replace(/([A-Z])/g, " $1").replace(/^./, (c) => c.toUpperCase());
|
|
@@ -47,11 +48,10 @@ export function FieldWidget({ descriptor: d, value, onChange, errors, path: path
|
|
|
47
48
|
break;
|
|
48
49
|
case "image":
|
|
49
50
|
case "video":
|
|
50
|
-
case "file":
|
|
51
|
-
|
|
52
|
-
control =
|
|
51
|
+
case "file":
|
|
52
|
+
asDiv = true; // buttons inside
|
|
53
|
+
control = _jsx(MediaWidget, { descriptor: d, value: value, onChange: onChange });
|
|
53
54
|
break;
|
|
54
|
-
}
|
|
55
55
|
case "ref":
|
|
56
56
|
control = (_jsxs("select", { className: "sm-select", value: String(value ?? ""), onChange: (e) => onChange(e.target.value || undefined), children: [_jsx("option", { value: "", children: "\u2014" }), refOptions.map((o) => _jsx("option", { value: o.id, children: optionTitle(o) }, o.id))] }));
|
|
57
57
|
break;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { ReactElement } from "react";
|
|
2
|
+
import type { AssetRecord } from "../../media.ts";
|
|
3
|
+
export declare function AssetThumb({ asset, className }: {
|
|
4
|
+
asset: AssetRecord;
|
|
5
|
+
className?: string;
|
|
6
|
+
}): ReactElement;
|
|
7
|
+
export declare function MediaGrid({ items, selectedId, onSelect }: {
|
|
8
|
+
items: AssetRecord[];
|
|
9
|
+
selectedId?: string | null;
|
|
10
|
+
onSelect(asset: AssetRecord): void;
|
|
11
|
+
}): ReactElement;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { formatBytes } from "../shell/media-list.js";
|
|
4
|
+
export function AssetThumb({ asset, className }) {
|
|
5
|
+
if (asset.kind === "image")
|
|
6
|
+
return _jsx("img", { className: className, src: asset.url, alt: "", loading: "lazy" });
|
|
7
|
+
if (asset.kind === "video") {
|
|
8
|
+
return asset.posterId
|
|
9
|
+
? _jsx("video", { className: className, src: asset.url, muted: true, playsInline: true, preload: "metadata" })
|
|
10
|
+
: _jsx("span", { className: `${className ?? ""} sm-thumb--chip`, children: "Video" });
|
|
11
|
+
}
|
|
12
|
+
return _jsx("span", { className: `${className ?? ""} sm-thumb--chip`, children: asset.filename.split(".").pop()?.toUpperCase() ?? "File" });
|
|
13
|
+
}
|
|
14
|
+
export function MediaGrid({ items, selectedId, onSelect }) {
|
|
15
|
+
return (_jsxs("div", { className: "sm-mediagrid", children: [items.map((asset) => (_jsxs("button", { type: "button", className: "sm-mediacard", "aria-pressed": asset.id === selectedId, onClick: () => onSelect(asset), children: [_jsx("span", { className: "sm-mediacard__thumb", children: _jsx(AssetThumb, { asset: asset, className: "sm-mediacard__img" }) }), _jsx("span", { className: "sm-mediacard__name", children: asset.title ?? asset.filename }), _jsxs("span", { className: "sm-mediacard__meta", children: [asset.width && asset.height ? `${asset.width}×${asset.height} · ` : "", formatBytes(asset.size)] })] }, asset.id))), items.length === 0 && _jsx("div", { className: "sm-empty", children: "No media yet." })] }));
|
|
16
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { type ReactElement } from "react";
|
|
2
|
+
import type { AssetKind, AssetRecord } from "../../media.ts";
|
|
3
|
+
export declare function MediaPicker({ kinds, onPick, onClose, initialItems }: {
|
|
4
|
+
kinds: AssetKind[];
|
|
5
|
+
onPick(asset: AssetRecord): void;
|
|
6
|
+
onClose(): void;
|
|
7
|
+
initialItems?: AssetRecord[];
|
|
8
|
+
}): ReactElement;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
// The library modal (spec 2026-09-08 media §10): grid first, an upload
|
|
4
|
+
// zone above it that uploads and selects in one motion, search and a
|
|
5
|
+
// kind filter. `initialItems` seeds the grid (tests and the widget's
|
|
6
|
+
// optimistic reopen); the effect loads the real list.
|
|
7
|
+
import { useCallback, useEffect, useState } from "react";
|
|
8
|
+
import { Banner, Segmented } from "../ui/primitives.js";
|
|
9
|
+
import { Modal } from "../ui/Modal.js";
|
|
10
|
+
import { useAssets } from "./assets-context.js";
|
|
11
|
+
import { uploadFile } from "./upload.js";
|
|
12
|
+
import { MediaGrid } from "./MediaGrid.js";
|
|
13
|
+
import { KIND_FILTERS, acceptFor, kindOfFilter } from "../shell/media-list.js";
|
|
14
|
+
export function MediaPicker({ kinds, onPick, onClose, initialItems = [] }) {
|
|
15
|
+
const assets = useAssets();
|
|
16
|
+
const [items, setItems] = useState(initialItems);
|
|
17
|
+
const [search, setSearch] = useState("");
|
|
18
|
+
const [filter, setFilter] = useState(kinds.length === 1 ? (kinds[0] === "image" ? "Images" : kinds[0] === "video" ? "Video" : "Files") : "All");
|
|
19
|
+
const [busy, setBusy] = useState(false);
|
|
20
|
+
const [error, setError] = useState(null);
|
|
21
|
+
const filters = kinds.length === 1 ? [filter] : KIND_FILTERS;
|
|
22
|
+
const load = useCallback(() => {
|
|
23
|
+
assets.ops.list({ kind: kindOfFilter(filter), search: search || undefined })
|
|
24
|
+
.then((r) => (r.ok ? setItems(r.data.items.filter((a) => kinds.includes(a.kind))) : setError(r.message)))
|
|
25
|
+
.catch(() => setError("Couldn't reach the server."));
|
|
26
|
+
}, [assets, filter, search, kinds]);
|
|
27
|
+
useEffect(load, [load]);
|
|
28
|
+
const onFiles = async (files) => {
|
|
29
|
+
const file = files?.[0];
|
|
30
|
+
if (!file)
|
|
31
|
+
return;
|
|
32
|
+
setBusy(true);
|
|
33
|
+
setError(null);
|
|
34
|
+
const r = await uploadFile(assets.ops, file, assets.upload);
|
|
35
|
+
setBusy(false);
|
|
36
|
+
if (!r.ok) {
|
|
37
|
+
setError(r.message);
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
assets.remember(r.data);
|
|
41
|
+
onPick(r.data);
|
|
42
|
+
};
|
|
43
|
+
return (_jsxs(Modal, { title: "Media", onClose: onClose, children: [_jsxs("div", { className: "sm-picker-bar", children: [_jsx("input", { className: "sm-input", placeholder: "Search media", value: search, onChange: (e) => setSearch(e.target.value), style: { maxWidth: 260 } }), filters.length > 1 && _jsx(Segmented, { options: filters, value: filter, onChange: setFilter }), _jsxs("label", { className: "sm-btn sm-btn--primary", style: { marginLeft: "auto" }, children: [busy ? "Uploading…" : "Upload", _jsx("input", { type: "file", accept: acceptFor(kinds, assets.media.accept), hidden: true, disabled: busy, onChange: (e) => { void onFiles(e.target.files); } })] })] }), error && _jsx(Banner, { tone: "error", onDismiss: () => setError(null), children: error }), _jsx("div", { className: "sm-dropzone sm-dropzone--bar", onDragOver: (e) => e.preventDefault(), onDrop: (e) => { e.preventDefault(); void onFiles(e.dataTransfer.files); }, children: "Drop a file here to upload it" }), _jsx(MediaGrid, { items: items, onSelect: onPick })] }));
|
|
44
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { type ReactElement } from "react";
|
|
2
|
+
import type { AssetRecord } from "../../media.ts";
|
|
3
|
+
import type { AdminField } from "../serialize.ts";
|
|
4
|
+
export declare function MediaWidget({ descriptor, value, onChange, asset: preloaded }: {
|
|
5
|
+
descriptor: AdminField;
|
|
6
|
+
value: unknown;
|
|
7
|
+
onChange(value: unknown): void;
|
|
8
|
+
asset?: AssetRecord | null;
|
|
9
|
+
}): ReactElement;
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
|
+
// The media field widget (spec 2026-09-08 media §10): empty is a drop
|
|
4
|
+
// zone (upload straight away, or open the library); filled is the
|
|
5
|
+
// thumbnail with the crop applied, the alt, and Replace / Edit / Remove.
|
|
6
|
+
// The record comes from the assets context (cached per id); `asset`
|
|
7
|
+
// preloads it for static renders.
|
|
8
|
+
import { useEffect, useState } from "react";
|
|
9
|
+
import { frameFor, boxAspectOf } from "../../image/crop-math.js";
|
|
10
|
+
import { Button } from "../ui/primitives.js";
|
|
11
|
+
import { acceptFor, formatBytes } from "../shell/media-list.js";
|
|
12
|
+
import { useAssets } from "./assets-context.js";
|
|
13
|
+
import { uploadFile } from "./upload.js";
|
|
14
|
+
import { MediaPicker } from "./MediaPicker.js";
|
|
15
|
+
import { CropEditor } from "./CropEditor.js";
|
|
16
|
+
import { AssetThumb } from "./MediaGrid.js";
|
|
17
|
+
export function MediaWidget({ descriptor, value, onChange, asset: preloaded }) {
|
|
18
|
+
const assets = useAssets();
|
|
19
|
+
const kind = descriptor.type;
|
|
20
|
+
const id = typeof value?.asset === "string" ? value.asset : null;
|
|
21
|
+
const [asset, setAsset] = useState(preloaded ?? null);
|
|
22
|
+
const [busy, setBusy] = useState(false);
|
|
23
|
+
const [error, setError] = useState(null);
|
|
24
|
+
const [picker, setPicker] = useState(false);
|
|
25
|
+
const [crop, setCrop] = useState(false);
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
if (!id) {
|
|
28
|
+
setAsset(null);
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
if (preloaded?.id === id) {
|
|
32
|
+
setAsset(preloaded);
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
let live = true;
|
|
36
|
+
assets.get(id).then((r) => { if (live)
|
|
37
|
+
setAsset(r); });
|
|
38
|
+
return () => { live = false; };
|
|
39
|
+
}, [id, assets, preloaded]);
|
|
40
|
+
const select = (record) => {
|
|
41
|
+
assets.remember(record);
|
|
42
|
+
setAsset(record);
|
|
43
|
+
setPicker(false);
|
|
44
|
+
onChange({ asset: record.id });
|
|
45
|
+
};
|
|
46
|
+
const onFiles = async (files) => {
|
|
47
|
+
const file = files?.[0];
|
|
48
|
+
if (!file)
|
|
49
|
+
return;
|
|
50
|
+
setBusy(true);
|
|
51
|
+
setError(null);
|
|
52
|
+
const r = await uploadFile(assets.ops, file, assets.upload);
|
|
53
|
+
setBusy(false);
|
|
54
|
+
if (!r.ok) {
|
|
55
|
+
setError(r.message);
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
select(r.data);
|
|
59
|
+
};
|
|
60
|
+
const accept = acceptFor([kind], assets.media.accept);
|
|
61
|
+
const fileInput = (label, variant = "secondary") => (_jsxs("label", { className: `sm-btn sm-btn--${variant}`, children: [busy ? "Uploading…" : label, _jsx("input", { type: "file", accept: accept, hidden: true, disabled: busy, onChange: (e) => { void onFiles(e.target.files); } })] }));
|
|
62
|
+
let body;
|
|
63
|
+
if (!id) {
|
|
64
|
+
body = (_jsxs("div", { className: "sm-dropzone", onDragOver: (e) => e.preventDefault(), onDrop: (e) => { e.preventDefault(); void onFiles(e.dataTransfer.files); }, children: [_jsx("span", { children: "Drop a file here" }), _jsxs("div", { className: "sm-media__actions", children: [fileInput("Upload", "primary"), _jsx(Button, { variant: "secondary", onClick: () => setPicker(true), children: "Choose from library" })] })] }));
|
|
65
|
+
}
|
|
66
|
+
else if (!asset) {
|
|
67
|
+
body = _jsx("div", { className: "sm-media__chip", children: "Loading\u2026" });
|
|
68
|
+
}
|
|
69
|
+
else if (asset.status === "pending") {
|
|
70
|
+
body = _jsx("div", { className: "sm-media__chip", children: "Uploading\u2026" });
|
|
71
|
+
}
|
|
72
|
+
else {
|
|
73
|
+
const v = value;
|
|
74
|
+
const preview = asset.kind === "image" && asset.width && asset.height ? (() => {
|
|
75
|
+
const image = { url: asset.url, width: asset.width, height: asset.height, mime: asset.mime, alt: "", crop: v.crop, hotspot: v.hotspot, aspect: v.aspect ?? (typeof descriptor.aspect === "string" ? descriptor.aspect : undefined) };
|
|
76
|
+
const box = boxAspectOf(image);
|
|
77
|
+
const f = frameFor(image, box);
|
|
78
|
+
return (_jsx("div", { className: "sm-media__thumb", style: { aspectRatio: String(box) }, children: _jsx("img", { src: asset.url, alt: "", style: { left: `${f.left}%`, top: `${f.top}%`, width: `${f.width}%`, height: `${f.height}%` } }) }));
|
|
79
|
+
})() : asset.kind === "video" ? (_jsx("div", { className: "sm-media__thumb", style: { aspectRatio: asset.width && asset.height ? `${asset.width} / ${asset.height}` : "16 / 9" }, children: _jsx(AssetThumb, { asset: asset, className: "sm-mediacard__img" }) })) : (_jsxs("div", { className: "sm-media__chip", children: [_jsx("span", { style: { fontWeight: 500 }, children: asset.filename }), _jsx("span", { className: "sm-media__alt", children: formatBytes(asset.size) })] }));
|
|
80
|
+
body = (_jsxs("div", { className: "sm-media", children: [preview, asset.kind === "image" && _jsx("span", { className: "sm-media__alt", children: v.alt ?? asset.alt ?? "No alt text" }), _jsxs("div", { className: "sm-media__actions", children: [_jsx(Button, { variant: "secondary", onClick: () => setPicker(true), children: "Replace" }), asset.kind === "image" && _jsx(Button, { variant: "secondary", onClick: () => setCrop(true), children: "Edit" }), _jsx(Button, { variant: "secondary", onClick: () => onChange(undefined), children: "Remove" })] })] }));
|
|
81
|
+
}
|
|
82
|
+
return (_jsxs(_Fragment, { children: [body, error && _jsx("span", { className: "sm-error", children: error }), picker && _jsx(MediaPicker, { kinds: [kind], onPick: select, onClose: () => setPicker(false) }), crop && asset && (_jsx(CropEditor, { asset: asset, descriptor: descriptor, value: value, onSave: (next) => { onChange(next); setCrop(false); }, onClose: () => setCrop(false) }))] }));
|
|
83
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { type ReactElement, type ReactNode } from "react";
|
|
2
|
+
import type { AdminOps } from "../ops.ts";
|
|
3
|
+
import type { AdminRegistry } from "../serialize.ts";
|
|
4
|
+
import type { AssetRecord } from "../../media.ts";
|
|
5
|
+
import { type UploadDeps } from "./upload.ts";
|
|
6
|
+
export type AssetsApi = {
|
|
7
|
+
ops: AdminOps["assets"];
|
|
8
|
+
get(id: string): Promise<AssetRecord | null>;
|
|
9
|
+
remember(record: AssetRecord): void;
|
|
10
|
+
upload: UploadDeps;
|
|
11
|
+
media: AdminRegistry["media"];
|
|
12
|
+
};
|
|
13
|
+
export declare function AssetsProvider({ ops, media, upload, children }: {
|
|
14
|
+
ops: AdminOps;
|
|
15
|
+
media: AdminRegistry["media"];
|
|
16
|
+
upload?: UploadDeps;
|
|
17
|
+
children: ReactNode;
|
|
18
|
+
}): ReactElement;
|
|
19
|
+
export declare function useAssets(): AssetsApi;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
+
// The one place widgets reach the assets ops: a context with a small
|
|
4
|
+
// record cache (an id fetched once per shell lifetime), the field
|
|
5
|
+
// limits from the registry, and the injectable upload deps.
|
|
6
|
+
import { createContext, useContext, useMemo, useRef } from "react";
|
|
7
|
+
import { putSigned } from "./upload.js";
|
|
8
|
+
import { measureFile } from "./measure.js";
|
|
9
|
+
const Ctx = createContext(null);
|
|
10
|
+
export function AssetsProvider({ ops, media, upload, children }) {
|
|
11
|
+
const cache = useRef(new Map());
|
|
12
|
+
const api = useMemo(() => ({
|
|
13
|
+
ops: ops.assets,
|
|
14
|
+
media,
|
|
15
|
+
upload: upload ?? { put: putSigned, measure: measureFile },
|
|
16
|
+
remember: (record) => { cache.current.set(record.id, record); },
|
|
17
|
+
get: async (id) => {
|
|
18
|
+
const hit = cache.current.get(id);
|
|
19
|
+
if (hit)
|
|
20
|
+
return hit;
|
|
21
|
+
const r = await ops.assets.get(id);
|
|
22
|
+
if (!r.ok)
|
|
23
|
+
return null;
|
|
24
|
+
cache.current.set(id, r.data);
|
|
25
|
+
return r.data;
|
|
26
|
+
},
|
|
27
|
+
}), [ops, media, upload]);
|
|
28
|
+
return _jsx(Ctx.Provider, { value: api, children: children });
|
|
29
|
+
}
|
|
30
|
+
export function useAssets() {
|
|
31
|
+
const api = useContext(Ctx);
|
|
32
|
+
if (!api)
|
|
33
|
+
throw new Error("smoodly: useAssets() needs an <AssetsProvider> above it.");
|
|
34
|
+
return api;
|
|
35
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { type Crop, type Hotspot, type ImageValue } from "../../media.ts";
|
|
2
|
+
export type Handle = "nw" | "ne" | "sw" | "se";
|
|
3
|
+
export type CropState = {
|
|
4
|
+
crop: Crop;
|
|
5
|
+
hotspot: Hotspot;
|
|
6
|
+
};
|
|
7
|
+
export declare const MIN_SIDE = 0.05;
|
|
8
|
+
export declare function fitAspect(aspect: number, imageAspect: number): Crop;
|
|
9
|
+
export declare function moveCrop(crop: Crop, dx: number, dy: number): Crop;
|
|
10
|
+
export declare function resizeCrop(crop: Crop, handle: Handle, dx: number, dy: number, lock?: number, min?: number): Crop;
|
|
11
|
+
export declare function placeHotspot(h: Hotspot, crop: Crop): Hotspot;
|
|
12
|
+
export declare function relock(state: CropState, aspect: number | undefined, imageAspect: number): CropState;
|
|
13
|
+
export declare function initialState(value: ImageValue | undefined, aspect: number | undefined, imageAspect: number): CropState;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// The crop editor's math, pure (spec 2026-09-08 media §10): everything in
|
|
2
|
+
// fractions of the original image. A "lock" is the wanted pixel ratio
|
|
3
|
+
// divided by the image's own, i.e. the ratio crop.width / crop.height
|
|
4
|
+
// must keep.
|
|
5
|
+
import { CENTER_HOTSPOT, FULL_CROP, clamp01 } from "../../media.js";
|
|
6
|
+
export const MIN_SIDE = 0.05;
|
|
7
|
+
export function fitAspect(aspect, imageAspect) {
|
|
8
|
+
const fa = aspect / imageAspect;
|
|
9
|
+
const width = fa <= 1 ? fa : 1;
|
|
10
|
+
const height = fa <= 1 ? 1 : 1 / fa;
|
|
11
|
+
return { left: (1 - width) / 2, top: (1 - height) / 2, width, height };
|
|
12
|
+
}
|
|
13
|
+
export function moveCrop(crop, dx, dy) {
|
|
14
|
+
return {
|
|
15
|
+
...crop,
|
|
16
|
+
left: Math.min(Math.max(crop.left + dx, 0), 1 - crop.width),
|
|
17
|
+
top: Math.min(Math.max(crop.top + dy, 0), 1 - crop.height),
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
export function resizeCrop(crop, handle, dx, dy, lock, min = MIN_SIDE) {
|
|
21
|
+
const right = crop.left + crop.width, bottom = crop.top + crop.height;
|
|
22
|
+
const ax = handle.includes("w") ? right : crop.left; // anchor: the opposite corner
|
|
23
|
+
const ay = handle.includes("n") ? bottom : crop.top;
|
|
24
|
+
const mx = clamp01((handle.includes("w") ? crop.left : right) + dx);
|
|
25
|
+
const my = clamp01((handle.includes("n") ? crop.top : bottom) + dy);
|
|
26
|
+
// Signed: a corner dragged past its anchor collapses to `min`, never flips.
|
|
27
|
+
let width = Math.max(handle.includes("w") ? ax - mx : mx - ax, min);
|
|
28
|
+
let height = Math.max(handle.includes("n") ? ay - my : my - ay, min);
|
|
29
|
+
const roomX = handle.includes("w") ? ax : 1 - ax; // how far the moving corner may go
|
|
30
|
+
const roomY = handle.includes("n") ? ay : 1 - ay;
|
|
31
|
+
if (lock !== undefined) {
|
|
32
|
+
height = width / lock;
|
|
33
|
+
if (height > roomY) {
|
|
34
|
+
height = roomY;
|
|
35
|
+
width = height * lock;
|
|
36
|
+
}
|
|
37
|
+
if (width > roomX) {
|
|
38
|
+
width = roomX;
|
|
39
|
+
height = width / lock;
|
|
40
|
+
}
|
|
41
|
+
if (height < min) {
|
|
42
|
+
height = min;
|
|
43
|
+
width = height * lock;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
else {
|
|
47
|
+
width = Math.min(width, roomX);
|
|
48
|
+
height = Math.min(height, roomY);
|
|
49
|
+
}
|
|
50
|
+
return {
|
|
51
|
+
left: handle.includes("w") ? ax - width : ax,
|
|
52
|
+
top: handle.includes("n") ? ay - height : ay,
|
|
53
|
+
width, height,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
export function placeHotspot(h, crop) {
|
|
57
|
+
return {
|
|
58
|
+
x: Math.min(Math.max(h.x, crop.left), crop.left + crop.width),
|
|
59
|
+
y: Math.min(Math.max(h.y, crop.top), crop.top + crop.height),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
const atRatio = (crop, lock) => Math.abs(crop.width / crop.height - lock) < 1e-3;
|
|
63
|
+
export function relock(state, aspect, imageAspect) {
|
|
64
|
+
if (aspect === undefined)
|
|
65
|
+
return { crop: state.crop, hotspot: placeHotspot(state.hotspot, state.crop) };
|
|
66
|
+
const lock = aspect / imageAspect;
|
|
67
|
+
const crop = atRatio(state.crop, lock) ? state.crop : fitAspect(aspect, imageAspect);
|
|
68
|
+
return { crop, hotspot: placeHotspot(state.hotspot, crop) };
|
|
69
|
+
}
|
|
70
|
+
export function initialState(value, aspect, imageAspect) {
|
|
71
|
+
return relock({ crop: value?.crop ?? FULL_CROP, hotspot: value?.hotspot ?? CENTER_HOTSPOT }, aspect, imageAspect);
|
|
72
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
const objectUrl = (file) => URL.createObjectURL(file);
|
|
2
|
+
function measureImage(file) {
|
|
3
|
+
return new Promise((resolve) => {
|
|
4
|
+
const url = objectUrl(file);
|
|
5
|
+
const img = new Image();
|
|
6
|
+
img.onload = () => { resolve({ width: img.naturalWidth, height: img.naturalHeight }); URL.revokeObjectURL(url); };
|
|
7
|
+
img.onerror = () => { resolve({}); URL.revokeObjectURL(url); };
|
|
8
|
+
img.src = url;
|
|
9
|
+
});
|
|
10
|
+
}
|
|
11
|
+
function measureVideo(file) {
|
|
12
|
+
return new Promise((resolve) => {
|
|
13
|
+
const url = objectUrl(file);
|
|
14
|
+
const video = document.createElement("video");
|
|
15
|
+
video.preload = "metadata";
|
|
16
|
+
video.muted = true;
|
|
17
|
+
const done = (m) => { resolve(m); URL.revokeObjectURL(url); };
|
|
18
|
+
video.onerror = () => done({});
|
|
19
|
+
video.onloadedmetadata = () => {
|
|
20
|
+
const base = { width: video.videoWidth, height: video.videoHeight, duration: video.duration };
|
|
21
|
+
video.onseeked = () => {
|
|
22
|
+
const canvas = document.createElement("canvas");
|
|
23
|
+
canvas.width = video.videoWidth;
|
|
24
|
+
canvas.height = video.videoHeight;
|
|
25
|
+
canvas.getContext("2d")?.drawImage(video, 0, 0);
|
|
26
|
+
canvas.toBlob((blob) => done(blob ? { ...base, poster: blob } : base), "image/jpeg", 0.85);
|
|
27
|
+
};
|
|
28
|
+
video.currentTime = Math.min(1, video.duration / 2);
|
|
29
|
+
};
|
|
30
|
+
video.src = url;
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
export function measureFile(file, mime) {
|
|
34
|
+
if (mime.startsWith("image/"))
|
|
35
|
+
return measureImage(file);
|
|
36
|
+
if (mime.startsWith("video/"))
|
|
37
|
+
return measureVideo(file);
|
|
38
|
+
return Promise.resolve({});
|
|
39
|
+
}
|