Three new components across all 4 layers (morfo/soma/sema/eidos) + demos + langs + READMEs: - ImageAdjustments — reusable image-filter sliders subcomponent. - ImagePicker — composes file-upload + image + image-adjustments (fill modes, rotation). - Cropper — Ark image-cropper anatomy: normalized 0-1 crop rect, rect + round shapes, 8 resize handles, fixedSize move-only (avatar), canvas Blob output (5-arg drawImage for SVG safety), wheel/pan zoom, two-zone gesture (selection moves the area, the background pans to set focus; img draggable=false guards native DnD per zag). New reusable soma/layers/zoom-pan: cursor-centered zoom, clamped pan, transform + viewport-frac-to-content-frac mapping. Image-viewer / diagram-pan / map can reuse it. sema: add 'zoom' verb to the handle family (direct manipulation, documented extension). slider: track visible at zero (gray, not white), thumb lifts on drag. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>active-uix
parent
1e6a9df874
commit
7da7285d94
@ -0,0 +1,9 @@
|
||||
// Cropper — a configured component: crop a region of an image with a movable /
|
||||
// resizable selection, grid + corner handles. The default export IS the whole
|
||||
// component (it renders the viewport / image / selection / handles internally).
|
||||
//
|
||||
// import Cropper from '$uix/eidos/components/cropper';
|
||||
// <Cropper {src} bind:crop onCropComplete={(r) => save(r.blob)} />
|
||||
export { default } from './cropper.svelte';
|
||||
|
||||
export type { CropperProps, CropRect, CropShape, CropCorner, CropResult } from './types';
|
||||
@ -0,0 +1,16 @@
|
||||
import type { ProviderProps } from '$soma/components/cropper';
|
||||
|
||||
/**
|
||||
* Props for the eidos `<Cropper>`. The cropper is configured — it renders the
|
||||
* viewport, the composed `<Image>`, the masked selection, the grid and the four
|
||||
* corner handles internally. All behavior (crop rect, aspect, shape, canvas
|
||||
* extraction) lives in the soma; eidos adds the visual chrome.
|
||||
*/
|
||||
export type CropperProps = ProviderProps;
|
||||
|
||||
export type {
|
||||
CropRect,
|
||||
CropShape,
|
||||
CropCorner,
|
||||
CropResult
|
||||
} from '$soma/components/cropper';
|
||||
@ -0,0 +1,88 @@
|
||||
# ImageAdjustments (eidos)
|
||||
|
||||
The styled, **configured** image-filter panel. Renders one labelled `<Slider>` row
|
||||
per adjustment from the `adjustments` prop — the consumer doesn't compose rows.
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
import ImageAdjustments from '$uix/eidos/components/image-adjustments';
|
||||
let value = $state({});
|
||||
let filter = $state('none');
|
||||
</script>
|
||||
|
||||
<ImageAdjustments bind:value onFilterChange={(f) => (filter = f)} />
|
||||
<img src={src} style="filter: {filter}" />
|
||||
```
|
||||
|
||||
## Baseline
|
||||
|
||||
**No Air baseline.** ImageAdjustments is a new component (2026-06-10); there is no
|
||||
`air/` predecessor. Designed fresh against the references below.
|
||||
|
||||
## Comparativa
|
||||
|
||||
| Capability | Untitled UI | Chakra | Radix | MUI | → UIX |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| Filter-slider panel primitive | ✅ (image-picker) | ❌ | ❌ | ❌ | ✅ |
|
||||
| Live CSS `filter` output | ⚠️ canvas | — | — | — | ✅ string |
|
||||
| Composes the lib's own Slider | ✅ | n/a | n/a | n/a | ✅ |
|
||||
| Configurable adjustment set | fixed | — | — | — | ✅ subset |
|
||||
| Reusable outside an image picker | ❌ | — | — | — | ✅ subcomponent |
|
||||
| Sound / haptic on reset | ❌ | ❌ | ❌ | ❌ | ✅ sema |
|
||||
|
||||
Most libraries treat image filtering as app code, not a primitive. Untitled ships
|
||||
an adjustment-sliders block (the visual reference); UIX makes it a reusable
|
||||
subcomponent that emits a portable `filter` string and composes the framework's
|
||||
own Slider.
|
||||
|
||||
## Props
|
||||
|
||||
`ProviderProps` (soma) + layout knobs:
|
||||
|
||||
| Prop | Type | Default | Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| `value` | `ImageAdjustmentValues` | `{}` | bindable per-adjustment values |
|
||||
| `onValueChange` | `(v) => void` | — | full values record |
|
||||
| `onFilterChange` | `(filter) => void` | — | recomputed CSS `filter` — the real output |
|
||||
| `adjustments` | `ImageAdjustmentKey[]` | core 6 | which adjustments + order |
|
||||
| `size` | `'sm' \| 'md' \| 'lg'` | `'md'` | slider + row typography |
|
||||
| `showReset` | `boolean` | `true` | render the reset control |
|
||||
| `disabled` | `boolean` | `false` | disable sliders + reset |
|
||||
|
||||
## Decisiones
|
||||
|
||||
- **Composition over reimplementation.** Imports the framework `<Slider>` and
|
||||
renders one per row — never reinvents a slider.
|
||||
- **The `filter` string is the product.** Soma owns the value→CSS-`filter` math;
|
||||
the consumer applies the streamed string to any surface.
|
||||
- **`ItemValue` is a plain `<span>`, not a live region.** The Slider thumb already
|
||||
announces `aria-valuenow`; a live region would double-announce.
|
||||
- **The `hue` rainbow track is a recipe token** (`--image-adjustments-hue-track`),
|
||||
not raw CSS — keeps the literal rainbow out of the component CSS (raw-color
|
||||
contract) and makes it themeable.
|
||||
- **`temperature` is a CSS approximation** (warm→sepia, cool→hue-rotate+saturate);
|
||||
there is no native white-balance primitive.
|
||||
|
||||
## Recipe tokens
|
||||
|
||||
Public, themeable — `--image-adjustments-*`:
|
||||
|
||||
| Token | Default |
|
||||
| --- | --- |
|
||||
| `gap` / `row-gap` | space-3 / space-1.5 |
|
||||
| `label-*` | content-secondary · sm · medium |
|
||||
| `value-*` | content-tertiary · xs · mono (tabular) |
|
||||
| `reset-*` | space-2 · content-secondary · xs |
|
||||
| `hue-track` | the rainbow gradient |
|
||||
|
||||
`eidos-lint image-adjustments` → invalid 0. CSS imported from the wrapper
|
||||
(code-split) — intentionally NOT in `eidos/index.css`.
|
||||
|
||||
## Gaps
|
||||
|
||||
| Gap | Disposition | Notes |
|
||||
| --- | --- | --- |
|
||||
| `highlights` / `shadows` tone curves | **diferir** | Need canvas / SVG `feComponentTransfer`; CSS `filter` can't express tone curves. |
|
||||
| Per-adjustment reset | **descartar** | One global reset matches the reference; per-row reset adds clutter. |
|
||||
| `temperature` exact white-balance | **diferir** | CSS approximation today; a true Kelvin curve needs canvas. |
|
||||
| WAI-ARIA APG pattern | **diferir** | A labelled `group` of APG sliders; no single composite pattern. |
|
||||
@ -0,0 +1,97 @@
|
||||
/**
|
||||
* ImageAdjustments recipe — layout for a stack of labelled `<Slider>` rows.
|
||||
*
|
||||
* Owns ONLY grouping concerns: the vertical rhythm of the rows, the
|
||||
* label/value head line, the read-out typography, and the reset control. The
|
||||
* sliders bring their own recipe (`slider.css`); the only slider override here
|
||||
* is the `hue` row's rainbow track, keyed off the morfo's `data-adjustment`.
|
||||
*
|
||||
* Tokens: `--image-adjustments-*` (public, themeable) from `recipes/base.ts`.
|
||||
*/
|
||||
|
||||
[data-image-adjustments] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--image-adjustments-gap);
|
||||
}
|
||||
|
||||
[data-image-adjustments][data-disabled] {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
[data-image-adjustments-item] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--image-adjustments-row-gap);
|
||||
}
|
||||
|
||||
/* Label + live read-out on one baseline-aligned line. */
|
||||
.eidos-image-adjustments-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
[data-image-adjustments-item-label] {
|
||||
color: var(--image-adjustments-label-color);
|
||||
font-size: var(--image-adjustments-label-font-size);
|
||||
font-weight: var(--image-adjustments-label-font-weight);
|
||||
line-height: var(--leading-ui);
|
||||
}
|
||||
|
||||
[data-image-adjustments-item-value] {
|
||||
color: var(--image-adjustments-value-color);
|
||||
font-size: var(--image-adjustments-value-font-size);
|
||||
font-family: var(--image-adjustments-value-font-family);
|
||||
/* Tabular numerals so the read-out doesn't reflow while dragging. */
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* Reset: a quiet text button, trailing-aligned. */
|
||||
[data-image-adjustments-reset] {
|
||||
align-self: flex-end;
|
||||
margin-block-start: var(--image-adjustments-reset-gap);
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: var(--image-adjustments-reset-color);
|
||||
font-size: var(--image-adjustments-reset-font-size);
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
[data-image-adjustments-reset]:hover:not([disabled]) {
|
||||
color: var(--color-content-primary);
|
||||
}
|
||||
|
||||
[data-image-adjustments-reset]:focus-visible {
|
||||
outline: var(--focus-ring-width) solid var(--color-focus-ring);
|
||||
outline-offset: 2px;
|
||||
border-radius: var(--radius-xs);
|
||||
}
|
||||
|
||||
[data-image-adjustments-reset][disabled] {
|
||||
opacity: 0.4;
|
||||
cursor: default;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* `hue` row: paint the slider track as a hue wheel so the control reads as a
|
||||
hue picker. Keyed off the morfo's `data-adjustment` value. The range fill
|
||||
goes transparent so the rainbow shows through. */
|
||||
[data-image-adjustments-item][data-adjustment='hue'] [data-slider] {
|
||||
--slider-track-bg: var(--image-adjustments-hue-track);
|
||||
}
|
||||
|
||||
[data-image-adjustments-item][data-adjustment='hue'] [data-slider-range] {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
@media (forced-colors: active) {
|
||||
[data-image-adjustments-reset]:focus-visible {
|
||||
outline-color: Highlight;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,75 @@
|
||||
<script lang="ts">
|
||||
import './image-adjustments.css';
|
||||
/**
|
||||
* Eidos `<ImageAdjustments>` — a labelled `<Slider>` row per image filter.
|
||||
*
|
||||
* The soma owns the values, the value→CSS-`filter` math, `data-adjustment`,
|
||||
* and the `commit-reset` event. Eidos renders the layout: one row per
|
||||
* adjustment (label + live read-out + a composed `<Slider>`) plus an optional
|
||||
* reset. It composes the framework's `<Slider>` — it never reinvents one.
|
||||
*
|
||||
* The recomputed `filter` reaches the consumer via `onFilterChange` (apply it
|
||||
* to any surface: an ImagePicker preview, an avatar, a words image, …).
|
||||
*/
|
||||
import { ActiveEidos } from '$uix/eidos';
|
||||
import * as ImageAdjustments from '$soma/components/image-adjustments';
|
||||
import { DEFAULT_IMAGE_ADJUSTMENTS } from '$soma/components/image-adjustments';
|
||||
import { Slider } from '$uix/eidos/components/slider';
|
||||
import type { ImageAdjustmentsProps } from './types';
|
||||
|
||||
let {
|
||||
size = 'md',
|
||||
showReset = true,
|
||||
adjustments,
|
||||
value = $bindable({}),
|
||||
...headlessProps
|
||||
}: ImageAdjustmentsProps = $props();
|
||||
|
||||
const eidos = ActiveEidos.require();
|
||||
const resolvedSize = $derived(eidos.resolve(size, 'md'));
|
||||
const resolved = $derived(adjustments ?? [...DEFAULT_IMAGE_ADJUSTMENTS]);
|
||||
</script>
|
||||
|
||||
<ImageAdjustments.Provider
|
||||
{...headlessProps}
|
||||
{adjustments}
|
||||
bind:value
|
||||
data-size={resolvedSize}
|
||||
class="eidos-image-adjustments {headlessProps.class ?? ''}"
|
||||
>
|
||||
{#each resolved as key (key)}
|
||||
<ImageAdjustments.Item adjustment={key} class="eidos-image-adjustments-item">
|
||||
{#snippet children({ label, value: v, formattedValue, def, disabled, setValue })}
|
||||
<div class="eidos-image-adjustments-head">
|
||||
<ImageAdjustments.ItemLabel class="eidos-image-adjustments-label">
|
||||
{label}
|
||||
</ImageAdjustments.ItemLabel>
|
||||
<ImageAdjustments.ItemValue class="eidos-image-adjustments-value">
|
||||
{formattedValue}
|
||||
</ImageAdjustments.ItemValue>
|
||||
</div>
|
||||
<Slider
|
||||
value={[v]}
|
||||
min={def.min}
|
||||
max={def.max}
|
||||
step={def.step}
|
||||
{disabled}
|
||||
size={resolvedSize}
|
||||
aria-label={label}
|
||||
onValueChange={(next) => setValue(next[0])}
|
||||
>
|
||||
<Slider.Range />
|
||||
<Slider.Thumb />
|
||||
</Slider>
|
||||
{/snippet}
|
||||
</ImageAdjustments.Item>
|
||||
{/each}
|
||||
|
||||
{#if showReset}
|
||||
<ImageAdjustments.Reset class="eidos-image-adjustments-reset">
|
||||
{#snippet children({ label })}
|
||||
{label}
|
||||
{/snippet}
|
||||
</ImageAdjustments.Reset>
|
||||
{/if}
|
||||
</ImageAdjustments.Provider>
|
||||
@ -0,0 +1,14 @@
|
||||
// ImageAdjustments — a configured component: it renders one labelled `<Slider>`
|
||||
// per image filter from the `adjustments` prop. The consumer doesn't compose
|
||||
// rows, so the default export IS the whole component.
|
||||
//
|
||||
// import ImageAdjustments from '$uix/eidos/components/image-adjustments';
|
||||
// <ImageAdjustments bind:value onFilterChange={(f) => (filter = f)} />
|
||||
export { default } from './image-adjustments.svelte';
|
||||
|
||||
export type {
|
||||
ImageAdjustmentsProps,
|
||||
ImageAdjustmentsSize,
|
||||
ImageAdjustmentKey,
|
||||
ImageAdjustmentValues
|
||||
} from './types';
|
||||
@ -0,0 +1,25 @@
|
||||
import type { ProviderProps } from '$soma/components/image-adjustments';
|
||||
import type { ResponsiveProp, Size } from '$uix/eidos/lib/types';
|
||||
|
||||
/**
|
||||
* Sizing subset ImageAdjustments exposes. Maps to the composed `<Slider>` size
|
||||
* + row typography. Narrowed per the per-component-subset doctrine.
|
||||
*/
|
||||
export type ImageAdjustmentsSize = Extract<Size, 'sm' | 'md' | 'lg'>;
|
||||
|
||||
/**
|
||||
* Props for the eidos `<ImageAdjustments>`.
|
||||
*
|
||||
* Extends the headless API with **layout** concerns only. The soma owns the
|
||||
* values, the value→`filter` math, `data-adjustment`, and `commit-reset`; eidos
|
||||
* renders one labelled `<Slider>` row per adjustment + an optional reset, and
|
||||
* styles them via the `--image-adjustments-*` recipe tokens.
|
||||
*/
|
||||
export type ImageAdjustmentsProps = ProviderProps & {
|
||||
/** Sizing scale (slider + row typography). @default 'md' */
|
||||
size?: ResponsiveProp<ImageAdjustmentsSize>;
|
||||
/** Render the reset control below the rows. @default true */
|
||||
showReset?: boolean;
|
||||
};
|
||||
|
||||
export type { ImageAdjustmentKey, ImageAdjustmentValues } from '$soma/components/image-adjustments';
|
||||
@ -0,0 +1,105 @@
|
||||
# ImagePicker (eidos)
|
||||
|
||||
The styled, **configured** image picker — a pure composition of three already-built
|
||||
framework components: `FileUpload` (drop + click) → `Image` (preview, fit modes) +
|
||||
a rotate/remove toolbar + `ImageAdjustments` (filters). The consumer uses the
|
||||
configured root; the empty/ready states + the toolbar are rendered internally.
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
import ImagePicker from '$uix/eidos/components/image-picker';
|
||||
let file = $state(null);
|
||||
</script>
|
||||
|
||||
<ImagePicker bind:file onChange={(v) => save(v)} />
|
||||
```
|
||||
|
||||
## Baseline
|
||||
|
||||
**No Air baseline.** ImagePicker is a new component (2026-06-10); there is no
|
||||
`air/` predecessor to recover. It was designed fresh against the references below,
|
||||
not ported.
|
||||
|
||||
## Comparativa
|
||||
|
||||
Audited against the visual reference (Untitled UI) + the headless / file references:
|
||||
|
||||
| Capability | Untitled UI | Chakra | Ark UI | Radix | MUI | → UIX |
|
||||
| --- | --- | --- | --- | --- | --- | --- |
|
||||
| Image picker primitive | ✅ image-pickers | ❌ | ❌ | ❌ | ❌ | ✅ |
|
||||
| File-upload base | ✅ | ✅ FileUpload | ✅ FileUpload | ❌ | ❌ (base-ui open issue) | ✅ reuse `file-upload` |
|
||||
| Preview + fill modes | ✅ fill/fit/crop/tile | ❌ | ❌ | ❌ | ❌ | ✅ reuse `image` (cover/contain/fill) |
|
||||
| 90° rotation | ✅ | ❌ | ❌ | ❌ | ❌ | ✅ |
|
||||
| Filter / effect sliders | ✅ | ❌ | ❌ | ❌ | ❌ | ✅ reuse `ImageAdjustments` |
|
||||
| Crop | ❌ (separate) | ❌ | ✅ `image-cropper` | ❌ | ❌ | hand-off (next component) |
|
||||
| Sound / haptic on actions | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ sema |
|
||||
|
||||
No mainstream component library ships an image picker as a primitive — it's app
|
||||
code over a file input. UIX composes three resolved components and emits a
|
||||
cropper-ready value.
|
||||
|
||||
## What it composes
|
||||
|
||||
| State | Renders |
|
||||
| --- | --- |
|
||||
| empty | `<FileUpload>` (drop + click), `accept="image/*"`, single file |
|
||||
| ready | `<Image>` preview + a floating rotate/remove toolbar + `<ImageAdjustments>` |
|
||||
|
||||
## Props
|
||||
|
||||
`ProviderProps` (soma) + composition knobs:
|
||||
|
||||
| Prop | Type | Default | Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| `file` / `fit` / `rotation` / `adjustments` | — | — | bindable — see soma README |
|
||||
| `onChange` | `(v: ImagePickerValue) => void` | — | cropper-ready value |
|
||||
| `disabled` | `boolean` | `false` | → both FileUpload + controls |
|
||||
| `accept` | `string` | `'image/*'` | → FileUpload |
|
||||
| `maxSize` | `number` | — | → FileUpload |
|
||||
| `size` | `'sm' \| 'md' \| 'lg'` | `'sm'` | embedded ImageAdjustments scale |
|
||||
| `showAdjustments` | `boolean` | `true` | render the filters panel |
|
||||
|
||||
## Decisiones
|
||||
|
||||
- **Composition over reimplementation.** ImagePicker renders `<FileUpload>` /
|
||||
`<Image>` / `<ImageAdjustments>` — it never re-declares a dropzone, an image
|
||||
surface or a slider. The morfo declares only the picker's OWN orchestration.
|
||||
- **`rotate` is a `handle` verb.** Spatial manipulation (with drag / resize /
|
||||
scroll), even though a discrete button triggers it — that's its canonical
|
||||
family. select / remove are `commit`.
|
||||
- **Object-URL lives in soma.** Created on `file` change and revoked via the
|
||||
effect cleanup (previous URL on replace, last on unmount). `createObjectURL`
|
||||
is used directly, matching the `file-upload` / `words` precedent (it is not an
|
||||
ActiveDom concern).
|
||||
- **`data-fit` is reflected on the picker even though `<Image>` owns the visual
|
||||
`object-fit`.** It is a picker-level state reflection (parallel to
|
||||
`data-rotation` / `data-state`) so tooling and consumer CSS can read the
|
||||
picker's fit from its own DOM. Chosen over removing it (which would hide the
|
||||
state) and over a contrived CSS rule.
|
||||
- **Rotation rotates a `.canvas` wrapper, not the toolbar.** The toolbar is a
|
||||
sibling of the canvas inside the Preview, so it stays upright while the image
|
||||
rotates.
|
||||
|
||||
## Recipe tokens
|
||||
|
||||
Public, themeable — `--image-picker-*` (the composed components bring their own):
|
||||
|
||||
| Token | Default |
|
||||
| --- | --- |
|
||||
| `gap` | space-3 (preview → adjustments) |
|
||||
| `preview-radius` / `preview-bg` / `preview-border` / `preview-aspect` | rounded box, 4/3 |
|
||||
| `toolbar-gap` / `toolbar-inset` | floating top-right |
|
||||
| `button-*` | icon-button chrome (rotate / remove) |
|
||||
|
||||
`eidos-lint image-picker` → invalid 0 (15 morfo-backed selectors). CSS is imported
|
||||
from the wrapper (code-split chunk) — intentionally NOT in `eidos/index.css`.
|
||||
|
||||
## Gaps
|
||||
|
||||
| Gap | Disposition | Notes |
|
||||
| --- | --- | --- |
|
||||
| Crop (region selection) | **descartar** here | Belongs to the separate Ark-style `image-cropper`; ImagePicker hands it `url`/`file`. |
|
||||
| Rotation letterbox at 90°/270° | **diferir** | The preview box doesn't swap dimensions, so a non-square image letterboxes. Revisit when the cropper lands. |
|
||||
| `highlights` / `shadows` tone curves | **diferir** | Need canvas / SVG `feComponentTransfer`; tracked in ImageAdjustments. |
|
||||
| Multiple images / gallery | **descartar** | Single image by design (`multiple={false}`); use `file-upload` directly for multi. |
|
||||
| WAI-ARIA APG pattern | **diferir** | No single APG pattern covers a file-picker composite; the dropzone is a button, the root a labelled `group`. |
|
||||
@ -0,0 +1,111 @@
|
||||
/**
|
||||
* ImagePicker recipe — layout for the pick / preview / adjust composition.
|
||||
*
|
||||
* Owns ONLY the orchestration chrome: the vertical stack, the preview box (with
|
||||
* the data-rotation transform), and the floating rotate/remove toolbar. The
|
||||
* dropzone (FileUpload), the image (Image) and the sliders (ImageAdjustments)
|
||||
* bring their own recipes.
|
||||
*
|
||||
* Tokens: `--image-picker-*` (public, themeable) from `recipes/base.ts`.
|
||||
*/
|
||||
|
||||
[data-image-picker] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--image-picker-gap);
|
||||
}
|
||||
|
||||
[data-image-picker][data-disabled] {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
[data-image-picker-preview] {
|
||||
position: relative;
|
||||
aspect-ratio: var(--image-picker-preview-aspect);
|
||||
border-radius: var(--image-picker-preview-radius);
|
||||
background: var(--image-picker-preview-bg);
|
||||
border: 1px solid var(--image-picker-preview-border);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* The canvas holds the <Image> and carries the rotation transform, so the
|
||||
floating toolbar (a sibling) stays upright. */
|
||||
.eidos-image-picker-canvas {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
transition: transform var(--duration-fast) var(--ease-default);
|
||||
}
|
||||
|
||||
/* The composed <Image> fills the canvas so its object-fit has the full box to
|
||||
act on. Plain `> *` (NOT `:global(*)` — this is a plain CSS file, where the
|
||||
Svelte `:global()` wrapper is invalid and would drop the rule). */
|
||||
.eidos-image-picker-canvas > * {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
inline-size: 100%;
|
||||
block-size: 100%;
|
||||
}
|
||||
|
||||
[data-image-picker-preview][data-rotation='90'] .eidos-image-picker-canvas {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
[data-image-picker-preview][data-rotation='180'] .eidos-image-picker-canvas {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
[data-image-picker-preview][data-rotation='270'] .eidos-image-picker-canvas {
|
||||
transform: rotate(270deg);
|
||||
}
|
||||
|
||||
[data-image-picker-toolbar] {
|
||||
position: absolute;
|
||||
inset-block-start: var(--image-picker-toolbar-inset);
|
||||
inset-inline-end: var(--image-picker-toolbar-inset);
|
||||
display: flex;
|
||||
gap: var(--image-picker-toolbar-gap);
|
||||
}
|
||||
|
||||
[data-image-picker-rotate],
|
||||
[data-image-picker-remove] {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
inline-size: var(--image-picker-button-size);
|
||||
block-size: var(--image-picker-button-size);
|
||||
border: 1px solid var(--image-picker-button-border);
|
||||
border-radius: var(--image-picker-button-radius);
|
||||
background: var(--image-picker-button-bg);
|
||||
color: var(--image-picker-button-fg);
|
||||
font-size: var(--image-picker-button-font-size);
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
[data-image-picker-rotate]:hover:not([disabled]),
|
||||
[data-image-picker-remove]:hover:not([disabled]) {
|
||||
background: var(--color-surface-default);
|
||||
}
|
||||
|
||||
[data-image-picker-rotate]:focus-visible,
|
||||
[data-image-picker-remove]:focus-visible {
|
||||
outline: var(--focus-ring-width) solid var(--color-focus-ring);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
[data-image-picker-rotate][disabled],
|
||||
[data-image-picker-remove][disabled] {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.eidos-image-picker-canvas {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (forced-colors: active) {
|
||||
[data-image-picker-rotate]:focus-visible,
|
||||
[data-image-picker-remove]:focus-visible {
|
||||
outline-color: Highlight;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,79 @@
|
||||
<script lang="ts">
|
||||
import './image-picker.css';
|
||||
/**
|
||||
* Eidos `<ImagePicker>` — pick / preview / adjust an image.
|
||||
*
|
||||
* Pure composition of three already-built framework components:
|
||||
* - empty state → `<FileUpload>` (drop + click to select)
|
||||
* - ready state → `<Image>` preview (fit modes) + a rotate/remove toolbar
|
||||
* + `<ImageAdjustments>` (filter sliders)
|
||||
*
|
||||
* The soma provider owns the file, the object-URL lifecycle, fit, rotation
|
||||
* and the composed CSS `filter`. The streamed value is cropper-ready.
|
||||
*/
|
||||
import * as ImagePicker from '$soma/components/image-picker';
|
||||
import { FileUpload } from '$uix/eidos/components/file-upload';
|
||||
import { Image } from '$uix/eidos/components/image';
|
||||
import ImageAdjustments from '$uix/eidos/components/image-adjustments';
|
||||
import type { ImagePickerProps } from './types';
|
||||
|
||||
let {
|
||||
file = $bindable(null),
|
||||
fit = $bindable('cover'),
|
||||
rotation = $bindable(0),
|
||||
adjustments = $bindable({}),
|
||||
adjustmentKeys,
|
||||
disabled = false,
|
||||
accept = 'image/*',
|
||||
maxSize,
|
||||
size = 'sm',
|
||||
showAdjustments = true,
|
||||
...headlessProps
|
||||
}: ImagePickerProps = $props();
|
||||
</script>
|
||||
|
||||
<ImagePicker.Provider
|
||||
{...headlessProps}
|
||||
bind:file
|
||||
bind:fit
|
||||
bind:rotation
|
||||
bind:adjustments
|
||||
{adjustmentKeys}
|
||||
{disabled}
|
||||
class="eidos-image-picker {headlessProps.class ?? ''}"
|
||||
>
|
||||
{#snippet children({ state, url, filter, fit: previewFit, prompt, setFile })}
|
||||
{#if state === 'empty'}
|
||||
<FileUpload
|
||||
{accept}
|
||||
{maxSize}
|
||||
{disabled}
|
||||
multiple={false}
|
||||
onFilesChange={(files) => setFile(files[0] ?? null)}
|
||||
>
|
||||
<FileUpload.Dropzone>
|
||||
<FileUpload.HiddenInput />
|
||||
<FileUpload.Trigger>{prompt}</FileUpload.Trigger>
|
||||
</FileUpload.Dropzone>
|
||||
</FileUpload>
|
||||
{:else}
|
||||
<ImagePicker.Preview class="eidos-image-picker-preview">
|
||||
<div class="eidos-image-picker-canvas">
|
||||
<Image
|
||||
src={url}
|
||||
alt=""
|
||||
fit={previewFit}
|
||||
style="filter: {filter === 'none' ? '' : filter};"
|
||||
/>
|
||||
</div>
|
||||
<ImagePicker.Toolbar class="eidos-image-picker-toolbar">
|
||||
<ImagePicker.Rotate class="eidos-image-picker-btn">⟳</ImagePicker.Rotate>
|
||||
<ImagePicker.Remove class="eidos-image-picker-btn">✕</ImagePicker.Remove>
|
||||
</ImagePicker.Toolbar>
|
||||
</ImagePicker.Preview>
|
||||
{#if showAdjustments}
|
||||
<ImageAdjustments bind:value={adjustments} adjustments={adjustmentKeys} {size} {disabled} />
|
||||
{/if}
|
||||
{/if}
|
||||
{/snippet}
|
||||
</ImagePicker.Provider>
|
||||
@ -0,0 +1,15 @@
|
||||
// ImagePicker — a composition component: pick / preview / adjust an image by
|
||||
// wiring together the framework's FileUpload + Image + ImageAdjustments. The
|
||||
// consumer uses the configured root; the default export IS the whole component.
|
||||
//
|
||||
// import ImagePicker from '$uix/eidos/components/image-picker';
|
||||
// <ImagePicker bind:file onChange={(v) => save(v)} />
|
||||
export { default } from './image-picker.svelte';
|
||||
|
||||
export type {
|
||||
ImagePickerProps,
|
||||
ImagePickerSize,
|
||||
ImagePickerFit,
|
||||
ImagePickerRotation,
|
||||
ImagePickerValue
|
||||
} from './types';
|
||||
@ -0,0 +1,31 @@
|
||||
import type { ProviderProps } from '$soma/components/image-picker';
|
||||
import type { ResponsiveProp, Size } from '$uix/eidos/lib/types';
|
||||
|
||||
/** Sizing subset for the embedded ImageAdjustments panel. */
|
||||
export type ImagePickerSize = Extract<Size, 'sm' | 'md' | 'lg'>;
|
||||
|
||||
/**
|
||||
* Props for the eidos `<ImagePicker>`.
|
||||
*
|
||||
* Extends the headless API with composition concerns: `accept`/`maxSize` flow to
|
||||
* the composed `<FileUpload>`, `size` to the composed `<ImageAdjustments>`, and
|
||||
* `showAdjustments` toggles the filters panel. The soma owns the file, the
|
||||
* transforms (fit/rotation) and the composed filter; eidos wires the three
|
||||
* already-built components together.
|
||||
*/
|
||||
export type ImagePickerProps = ProviderProps & {
|
||||
/** Accepted file types (native `accept` syntax) → FileUpload. @default 'image/*' */
|
||||
accept?: string;
|
||||
/** Max file size in bytes → FileUpload. */
|
||||
maxSize?: number;
|
||||
/** Scale of the embedded adjustments panel. @default 'sm' */
|
||||
size?: ResponsiveProp<ImagePickerSize>;
|
||||
/** Render the ImageAdjustments panel below the preview. @default true */
|
||||
showAdjustments?: boolean;
|
||||
};
|
||||
|
||||
export type {
|
||||
ImagePickerFit,
|
||||
ImagePickerRotation,
|
||||
ImagePickerValue
|
||||
} from '$soma/components/image-picker';
|
||||
@ -0,0 +1,16 @@
|
||||
import type { LangNode } from '$libs/langs';
|
||||
|
||||
/**
|
||||
* Default strings for the Cropper component. Merged under
|
||||
* `components.cropper.*` by `ActiveUix` (via the `componentLangs` barrel).
|
||||
*/
|
||||
export const cropperLangs = {
|
||||
label: {
|
||||
es: 'Recortar imagen',
|
||||
en: 'Crop image'
|
||||
},
|
||||
selection: {
|
||||
es: 'Región de recorte',
|
||||
en: 'Crop region'
|
||||
}
|
||||
} satisfies LangNode;
|
||||
@ -0,0 +1,52 @@
|
||||
import type { LangNode } from '$libs/langs';
|
||||
|
||||
/**
|
||||
* Default strings for the ImageAdjustments component. Merged under
|
||||
* `components.image-adjustments.*` by `ActiveUix` (via the `componentLangs`
|
||||
* barrel). The morfo references `label` + `reset`; the soma provider resolves
|
||||
* per-adjustment labels under `adjustments.*` via `langs.ts(...)`.
|
||||
*/
|
||||
export const imageAdjustmentsLangs = {
|
||||
label: {
|
||||
es: 'Ajustes de imagen',
|
||||
en: 'Image adjustments'
|
||||
},
|
||||
reset: {
|
||||
es: 'Restablecer',
|
||||
en: 'Reset'
|
||||
},
|
||||
adjustments: {
|
||||
brightness: {
|
||||
es: 'Brillo',
|
||||
en: 'Brightness'
|
||||
},
|
||||
contrast: {
|
||||
es: 'Contraste',
|
||||
en: 'Contrast'
|
||||
},
|
||||
saturation: {
|
||||
es: 'Saturación',
|
||||
en: 'Saturation'
|
||||
},
|
||||
temperature: {
|
||||
es: 'Temperatura',
|
||||
en: 'Temperature'
|
||||
},
|
||||
hue: {
|
||||
es: 'Tono',
|
||||
en: 'Hue'
|
||||
},
|
||||
blur: {
|
||||
es: 'Desenfoque',
|
||||
en: 'Blur'
|
||||
},
|
||||
grayscale: {
|
||||
es: 'Escala de grises',
|
||||
en: 'Grayscale'
|
||||
},
|
||||
sepia: {
|
||||
es: 'Sepia',
|
||||
en: 'Sepia'
|
||||
}
|
||||
}
|
||||
} satisfies LangNode;
|
||||
@ -0,0 +1,24 @@
|
||||
import type { LangNode } from '$libs/langs';
|
||||
|
||||
/**
|
||||
* Default strings for the ImagePicker component. Merged under
|
||||
* `components.image-picker.*` by `ActiveUix` (via the `componentLangs` barrel).
|
||||
*/
|
||||
export const imagePickerLangs = {
|
||||
label: {
|
||||
es: 'Selector de imagen',
|
||||
en: 'Image picker'
|
||||
},
|
||||
prompt: {
|
||||
es: 'Suelta una imagen o haz clic para explorar',
|
||||
en: 'Drop an image or click to browse'
|
||||
},
|
||||
remove: {
|
||||
es: 'Quitar imagen',
|
||||
en: 'Remove image'
|
||||
},
|
||||
rotate: {
|
||||
es: 'Girar 90°',
|
||||
en: 'Rotate 90°'
|
||||
}
|
||||
} satisfies LangNode;
|
||||
@ -0,0 +1,116 @@
|
||||
import type { Morfo } from '../types';
|
||||
import { v } from '../types';
|
||||
|
||||
/**
|
||||
* ImageAdjustments — a reusable set of image-filter sliders.
|
||||
*
|
||||
* Declarative DNA only: the parts (a group of adjustment rows + a reset). The
|
||||
* adjustment definitions, ranges and the value→CSS-`filter` math live in soma
|
||||
* (logic), and eidos composes a `<Slider>` per row. The component emits a live
|
||||
* CSS `filter` string + the per-adjustment values, so ANY surface can apply it
|
||||
* (the ImagePicker preview, an avatar editor, a words image, the cropper…).
|
||||
*
|
||||
* Reuses the framework's `Slider` (one per `Item`); never reinvents a slider.
|
||||
*/
|
||||
export const imageAdjustmentsMorfo = {
|
||||
name: 'ImageAdjustments',
|
||||
kebab: 'image-adjustments',
|
||||
scope: ['soma', 'sema'],
|
||||
texts: {
|
||||
label: '#?components.image-adjustments.label|Image adjustments',
|
||||
reset: '#?components.image-adjustments.reset|Reset'
|
||||
},
|
||||
events: [
|
||||
{
|
||||
// Reset every adjustment back to its neutral default.
|
||||
name: 'commit-reset',
|
||||
semantic: {
|
||||
family: 'commit',
|
||||
verb: 'reset',
|
||||
target: v.partRef('provider'),
|
||||
intent: 'neutral',
|
||||
sequence: 'post'
|
||||
}
|
||||
}
|
||||
],
|
||||
parts: [
|
||||
{
|
||||
name: 'Provider',
|
||||
kebab: 'provider',
|
||||
archetype: 'group',
|
||||
kind: 'public',
|
||||
defaultElement: 'div',
|
||||
role: 'group',
|
||||
optional: false,
|
||||
data: [{ attr: 'data-disabled', value: v.propRef('disabled'), severity: 'optional' }],
|
||||
aria: [
|
||||
{ attr: 'aria-label', value: v.propRef('ariaLabel'), severity: 'recommended' }
|
||||
]
|
||||
},
|
||||
{
|
||||
// One adjustment row (repeatable). `data-adjustment` names which
|
||||
// adjustment it drives (brightness / contrast / …) so eidos can style a
|
||||
// row per-adjustment (e.g. a rainbow track for `hue`). It's a string
|
||||
// value, so it MUST declare `values` + `value` — a bare propRef without
|
||||
// `values` would emit empty-string presence instead of the key.
|
||||
name: 'Item',
|
||||
kebab: 'item',
|
||||
archetype: 'group',
|
||||
kind: 'public',
|
||||
defaultElement: 'div',
|
||||
optional: true,
|
||||
data: [
|
||||
{
|
||||
attr: 'data-adjustment',
|
||||
values: [
|
||||
'brightness',
|
||||
'contrast',
|
||||
'saturation',
|
||||
'temperature',
|
||||
'hue',
|
||||
'blur',
|
||||
'grayscale',
|
||||
'sepia'
|
||||
],
|
||||
value: v.propRef('adjustment'),
|
||||
severity: 'optional'
|
||||
}
|
||||
],
|
||||
aria: []
|
||||
},
|
||||
{
|
||||
name: 'ItemLabel',
|
||||
kebab: 'item-label',
|
||||
archetype: 'label',
|
||||
kind: 'public',
|
||||
defaultElement: 'span',
|
||||
optional: true,
|
||||
data: [],
|
||||
aria: []
|
||||
},
|
||||
{
|
||||
// Value read-out for an adjustment. A plain `<span>` (not `<output>`):
|
||||
// the composed Slider's thumb already exposes `aria-valuenow`, so a
|
||||
// live region here would double-announce on every step.
|
||||
name: 'ItemValue',
|
||||
kebab: 'item-value',
|
||||
archetype: 'description',
|
||||
kind: 'public',
|
||||
defaultElement: 'span',
|
||||
optional: true,
|
||||
data: [],
|
||||
aria: []
|
||||
},
|
||||
{
|
||||
name: 'Reset',
|
||||
kebab: 'reset',
|
||||
archetype: 'action',
|
||||
kind: 'public',
|
||||
defaultElement: 'button',
|
||||
role: 'button',
|
||||
optional: true,
|
||||
data: [],
|
||||
aria: [{ attr: 'type', value: v.literal('button') }]
|
||||
}
|
||||
]
|
||||
} as const satisfies Morfo;
|
||||
@ -0,0 +1,143 @@
|
||||
import type { Morfo } from '../types';
|
||||
import { v } from '../types';
|
||||
|
||||
/**
|
||||
* ImagePicker — pick / preview / adjust an image.
|
||||
*
|
||||
* A composition component: it orchestrates the framework's already-built
|
||||
* `file-upload` (drop + click to select), `image` (preview with fit modes) and
|
||||
* `ImageAdjustments` (filter sliders). This morfo declares only what is NEW —
|
||||
* the picker's own orchestration: the empty↔ready state, the fill `fit`, the 90°
|
||||
* `rotation`, and the select / remove / rotate events. The dropzone + the
|
||||
* sliders carry their own morfos; ImagePicker never re-declares them.
|
||||
*
|
||||
* Output is cropper-ready: the provider exposes the `File` + an object-URL +
|
||||
* the transform state, so the (separate) image cropper can consume it directly.
|
||||
*/
|
||||
export const imagePickerMorfo = {
|
||||
name: 'ImagePicker',
|
||||
kebab: 'image-picker',
|
||||
scope: ['soma', 'sema'],
|
||||
texts: {
|
||||
label: '#?components.image-picker.label|Image picker',
|
||||
prompt: '#?components.image-picker.prompt|Drop an image or click to browse',
|
||||
remove: '#?components.image-picker.remove|Remove image',
|
||||
rotate: '#?components.image-picker.rotate|Rotate 90°'
|
||||
},
|
||||
events: [
|
||||
{
|
||||
// An image was selected (or replaced) — the affirmative pickup.
|
||||
name: 'commit-select',
|
||||
semantic: {
|
||||
family: 'commit',
|
||||
verb: 'select',
|
||||
target: v.partRef('provider'),
|
||||
intent: 'affirm',
|
||||
sequence: 'post'
|
||||
}
|
||||
},
|
||||
{
|
||||
// The image was removed — back to empty.
|
||||
name: 'commit-remove',
|
||||
semantic: {
|
||||
family: 'commit',
|
||||
verb: 'remove',
|
||||
target: v.partRef('provider'),
|
||||
intent: 'neutral',
|
||||
sequence: 'post'
|
||||
}
|
||||
},
|
||||
{
|
||||
// Rotate the preview 90° clockwise. `rotate` is canonically a `handle`
|
||||
// verb (spatial manipulation, alongside drag / resize / scroll) — even
|
||||
// though it's triggered by a discrete button here.
|
||||
name: 'handle-rotate',
|
||||
semantic: {
|
||||
family: 'handle',
|
||||
verb: 'rotate',
|
||||
target: v.partRef('preview'),
|
||||
intent: 'neutral',
|
||||
sequence: 'post'
|
||||
}
|
||||
}
|
||||
],
|
||||
parts: [
|
||||
{
|
||||
name: 'Provider',
|
||||
kebab: 'provider',
|
||||
archetype: 'group',
|
||||
kind: 'public',
|
||||
defaultElement: 'div',
|
||||
role: 'group',
|
||||
optional: false,
|
||||
data: [
|
||||
{
|
||||
attr: 'data-state',
|
||||
values: ['empty', 'ready'],
|
||||
value: v.propRef('state'),
|
||||
severity: 'required'
|
||||
},
|
||||
{ attr: 'data-disabled', value: v.propRef('disabled'), severity: 'optional' }
|
||||
],
|
||||
aria: [{ attr: 'aria-label', value: v.propRef('ariaLabel'), severity: 'recommended' }]
|
||||
},
|
||||
{
|
||||
// The preview area. Carries the transform state eidos reacts to:
|
||||
// `data-fit` (fill mode) + `data-rotation` (0/90/180/270).
|
||||
name: 'Preview',
|
||||
kebab: 'preview',
|
||||
archetype: 'group',
|
||||
kind: 'public',
|
||||
defaultElement: 'div',
|
||||
optional: true,
|
||||
data: [
|
||||
{
|
||||
attr: 'data-fit',
|
||||
values: ['cover', 'contain', 'fill'],
|
||||
value: v.propRef('fit'),
|
||||
severity: 'optional'
|
||||
},
|
||||
{
|
||||
attr: 'data-rotation',
|
||||
values: ['0', '90', '180', '270'],
|
||||
value: v.propRef('rotation'),
|
||||
severity: 'optional'
|
||||
}
|
||||
],
|
||||
aria: []
|
||||
},
|
||||
{
|
||||
// Controls container (rotate / remove). Structural.
|
||||
name: 'Toolbar',
|
||||
kebab: 'toolbar',
|
||||
archetype: 'group',
|
||||
kind: 'public',
|
||||
defaultElement: 'div',
|
||||
optional: true,
|
||||
data: [],
|
||||
aria: []
|
||||
},
|
||||
{
|
||||
name: 'Rotate',
|
||||
kebab: 'rotate',
|
||||
archetype: 'action',
|
||||
kind: 'public',
|
||||
defaultElement: 'button',
|
||||
role: 'button',
|
||||
optional: true,
|
||||
data: [],
|
||||
aria: [{ attr: 'type', value: v.literal('button') }]
|
||||
},
|
||||
{
|
||||
name: 'Remove',
|
||||
kebab: 'remove',
|
||||
archetype: 'action',
|
||||
kind: 'public',
|
||||
defaultElement: 'button',
|
||||
role: 'button',
|
||||
optional: true,
|
||||
data: [],
|
||||
aria: [{ attr: 'type', value: v.literal('button') }]
|
||||
}
|
||||
]
|
||||
} as const satisfies Morfo;
|
||||
@ -0,0 +1,47 @@
|
||||
import { semaSelector } from '$uix/morfo';
|
||||
import { cropperMorfo } from '$uix/morfo/components/cropper';
|
||||
import { soundTuning } from '../sounds';
|
||||
import type { Sema } from '../sema-map';
|
||||
|
||||
/**
|
||||
* Cropper perceptual defaults — HAPTIC + VERY SOFT SOUND.
|
||||
*
|
||||
* - `handle-drag` (handle · drag) — grabbing the selection to move it. Emitted
|
||||
* once at gesture start (the move is visual); family `handle` activates a tick.
|
||||
* - `handle-resize` (handle · resize) — grabbing a corner handle. Tick.
|
||||
* - `commit-crop` (commit · apply · affirm) — the crop is produced. The affirm
|
||||
* profile comes from intent.deltas (capa 2); the pack adds a soft chime + tap
|
||||
* (NEVER overrides pitch/gain/contour).
|
||||
*/
|
||||
|
||||
const onSelection = (matchers?: Parameters<typeof semaSelector<typeof cropperMorfo>>[2]) =>
|
||||
semaSelector(cropperMorfo, 'selection', matchers);
|
||||
const onHandle = (matchers?: Parameters<typeof semaSelector<typeof cropperMorfo>>[2]) =>
|
||||
semaSelector(cropperMorfo, 'handle', matchers);
|
||||
const onViewport = (matchers?: Parameters<typeof semaSelector<typeof cropperMorfo>>[2]) =>
|
||||
semaSelector(cropperMorfo, 'viewport', matchers);
|
||||
const onProvider = (matchers?: Parameters<typeof semaSelector<typeof cropperMorfo>>[2]) =>
|
||||
semaSelector(cropperMorfo, 'provider', matchers);
|
||||
|
||||
export const cropperSema: Sema = {
|
||||
name: 'cropper',
|
||||
cascade: [
|
||||
{
|
||||
selector: onSelection({ eventName: 'handle-drag' }),
|
||||
haptic: { kind: 'tick' }
|
||||
},
|
||||
{
|
||||
selector: onHandle({ eventName: 'handle-resize' }),
|
||||
haptic: { kind: 'tick' }
|
||||
},
|
||||
{
|
||||
selector: onViewport({ eventName: 'handle-zoom' }),
|
||||
haptic: { kind: 'tick' }
|
||||
},
|
||||
{
|
||||
selector: onProvider({ eventName: 'commit-crop' }),
|
||||
sound: soundTuning('form.commit.soft'),
|
||||
haptic: { kind: 'tap' }
|
||||
}
|
||||
]
|
||||
};
|
||||
@ -0,0 +1,31 @@
|
||||
import { semaSelector } from '$uix/morfo';
|
||||
import { imageAdjustmentsMorfo } from '$uix/morfo/components/image-adjustments';
|
||||
import { soundTuning } from '../sounds';
|
||||
import type { Sema } from '../sema-map';
|
||||
|
||||
/**
|
||||
* ImageAdjustments perceptual defaults — HAPTIC + VERY SOFT SOUND.
|
||||
*
|
||||
* The only declared event is `commit-reset` (family `commit`, verb `reset`,
|
||||
* intent `neutral`): the user wipes every adjustment back to neutral. That's a
|
||||
* real, deliberate commit — but a quiet one, so it rides the soft form-commit
|
||||
* tuning + a single tap. The per-slider drag feedback is owned by the composed
|
||||
* `<Slider>` pack (`handle-drag`), not here.
|
||||
*
|
||||
* NEVER override `pitch` / `gain` / `contour` — the neutral intent's profile
|
||||
* comes from the family base + intent.deltas (capa 2).
|
||||
*/
|
||||
|
||||
const onProvider = (matchers?: Parameters<typeof semaSelector<typeof imageAdjustmentsMorfo>>[2]) =>
|
||||
semaSelector(imageAdjustmentsMorfo, 'provider', matchers);
|
||||
|
||||
export const imageAdjustmentsSema: Sema = {
|
||||
name: 'image-adjustments',
|
||||
cascade: [
|
||||
{
|
||||
selector: onProvider({ eventName: 'commit-reset' }),
|
||||
sound: soundTuning('form.commit.soft'),
|
||||
haptic: { kind: 'tap' }
|
||||
}
|
||||
]
|
||||
};
|
||||
@ -0,0 +1,43 @@
|
||||
import { semaSelector } from '$uix/morfo';
|
||||
import { imagePickerMorfo } from '$uix/morfo/components/image-picker';
|
||||
import { soundTuning } from '../sounds';
|
||||
import type { Sema } from '../sema-map';
|
||||
|
||||
/**
|
||||
* ImagePicker perceptual defaults — HAPTIC + VERY SOFT SOUND.
|
||||
*
|
||||
* Three events:
|
||||
* - `commit-select` (commit · affirm) — the affirmative pickup of a chosen
|
||||
* image. Affirm's evaluative profile comes from intent.deltas (capa 2); the
|
||||
* pack only adds a soft chime + tap (NEVER overrides pitch/gain/contour).
|
||||
* - `commit-remove` (commit · neutral) — clearing the image; even softer.
|
||||
* - `handle-rotate` (handle · neutral) — a spatial nudge; family `handle`
|
||||
* activates HAPTIC, so a single tick conveys the 90° step.
|
||||
*
|
||||
* The per-slider drag feedback is owned by ImageAdjustments / Slider, not here.
|
||||
*/
|
||||
|
||||
const onProvider = (matchers?: Parameters<typeof semaSelector<typeof imagePickerMorfo>>[2]) =>
|
||||
semaSelector(imagePickerMorfo, 'provider', matchers);
|
||||
const onPreview = (matchers?: Parameters<typeof semaSelector<typeof imagePickerMorfo>>[2]) =>
|
||||
semaSelector(imagePickerMorfo, 'preview', matchers);
|
||||
|
||||
export const imagePickerSema: Sema = {
|
||||
name: 'image-picker',
|
||||
cascade: [
|
||||
{
|
||||
selector: onProvider({ eventName: 'commit-select' }),
|
||||
sound: soundTuning('form.commit.soft'),
|
||||
haptic: { kind: 'tap' }
|
||||
},
|
||||
{
|
||||
selector: onProvider({ eventName: 'commit-remove' }),
|
||||
sound: soundTuning('form.commit.subtle'),
|
||||
haptic: { kind: 'tap' }
|
||||
},
|
||||
{
|
||||
selector: onPreview({ eventName: 'handle-rotate' }),
|
||||
haptic: { kind: 'tick' }
|
||||
}
|
||||
]
|
||||
};
|
||||
@ -0,0 +1,35 @@
|
||||
<script lang="ts">
|
||||
import { readableActive, writableActive } from '$libs/reactive';
|
||||
import { mergeProps } from '../../../props';
|
||||
import { createId } from '$active-uix/id';
|
||||
import { CropperGridProvider } from '../cropper-provider.svelte';
|
||||
import type { CropperGridProps } from '../types';
|
||||
|
||||
const uid = $props.id();
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
id = createId(uid, 'cropper-grid'),
|
||||
children,
|
||||
child,
|
||||
...restProps
|
||||
}: CropperGridProps = $props();
|
||||
|
||||
const state = CropperGridProvider.create({
|
||||
id: readableActive(() => id),
|
||||
ref: writableActive(
|
||||
() => ref,
|
||||
(v) => (ref = v)
|
||||
)
|
||||
});
|
||||
|
||||
const mergedProps = $derived(mergeProps(restProps, state.props));
|
||||
</script>
|
||||
|
||||
{#if child}
|
||||
{@render child({ props: mergedProps })}
|
||||
{:else}
|
||||
<div {...mergedProps}>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
{/if}
|
||||
@ -0,0 +1,37 @@
|
||||
<script lang="ts">
|
||||
import { readableActive, writableActive } from '$libs/reactive';
|
||||
import { mergeProps } from '../../../props';
|
||||
import { createId } from '$active-uix/id';
|
||||
import { CropperHandleProvider } from '../cropper-provider.svelte';
|
||||
import type { CropperHandleProps } from '../types';
|
||||
|
||||
const uid = $props.id();
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
id = createId(uid, 'cropper-handle'),
|
||||
corner,
|
||||
children,
|
||||
child,
|
||||
...restProps
|
||||
}: CropperHandleProps = $props();
|
||||
|
||||
const state = CropperHandleProvider.create({
|
||||
id: readableActive(() => id),
|
||||
ref: writableActive(
|
||||
() => ref,
|
||||
(v) => (ref = v)
|
||||
),
|
||||
corner: readableActive(() => corner)
|
||||
});
|
||||
|
||||
const mergedProps = $derived(mergeProps(restProps, state.props));
|
||||
</script>
|
||||
|
||||
{#if child}
|
||||
{@render child({ props: mergedProps })}
|
||||
{:else}
|
||||
<div {...mergedProps}>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
{/if}
|
||||
@ -0,0 +1,35 @@
|
||||
<script lang="ts">
|
||||
import { readableActive, writableActive } from '$libs/reactive';
|
||||
import { mergeProps } from '../../../props';
|
||||
import { createId } from '$active-uix/id';
|
||||
import { CropperSelectionProvider } from '../cropper-provider.svelte';
|
||||
import type { CropperSelectionProps } from '../types';
|
||||
|
||||
const uid = $props.id();
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
id = createId(uid, 'cropper-selection'),
|
||||
children,
|
||||
child,
|
||||
...restProps
|
||||
}: CropperSelectionProps = $props();
|
||||
|
||||
const state = CropperSelectionProvider.create({
|
||||
id: readableActive(() => id),
|
||||
ref: writableActive(
|
||||
() => ref,
|
||||
(v) => (ref = v)
|
||||
)
|
||||
});
|
||||
|
||||
const mergedProps = $derived(mergeProps(restProps, state.props));
|
||||
</script>
|
||||
|
||||
{#if child}
|
||||
{@render child({ props: mergedProps })}
|
||||
{:else}
|
||||
<div {...mergedProps}>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
{/if}
|
||||
@ -0,0 +1,35 @@
|
||||
<script lang="ts">
|
||||
import { readableActive, writableActive } from '$libs/reactive';
|
||||
import { mergeProps } from '../../../props';
|
||||
import { createId } from '$active-uix/id';
|
||||
import { CropperViewportProvider } from '../cropper-provider.svelte';
|
||||
import type { CropperViewportProps } from '../types';
|
||||
|
||||
const uid = $props.id();
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
id = createId(uid, 'cropper-viewport'),
|
||||
children,
|
||||
child,
|
||||
...restProps
|
||||
}: CropperViewportProps = $props();
|
||||
|
||||
const state = CropperViewportProvider.create({
|
||||
id: readableActive(() => id),
|
||||
ref: writableActive(
|
||||
() => ref,
|
||||
(v) => (ref = v)
|
||||
)
|
||||
});
|
||||
|
||||
const mergedProps = $derived(mergeProps(restProps, state.props));
|
||||
</script>
|
||||
|
||||
{#if child}
|
||||
{@render child({ props: mergedProps })}
|
||||
{:else}
|
||||
<div {...mergedProps}>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
{/if}
|
||||
@ -0,0 +1,69 @@
|
||||
<script lang="ts">
|
||||
import { readableActive, writableActive } from '$libs/reactive';
|
||||
import { mergeProps } from '../../../props';
|
||||
import { createId } from '$active-uix/id';
|
||||
import { CropperProvider } from '../cropper-provider.svelte';
|
||||
import type { CropperProps } from '../types';
|
||||
|
||||
const uid = $props.id();
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
id = createId(uid, 'cropper'),
|
||||
src = null,
|
||||
crop = $bindable({ x: 0.1, y: 0.1, width: 0.8, height: 0.8 }),
|
||||
aspect,
|
||||
shape = 'rect',
|
||||
minSize = 0.05,
|
||||
maxSize = 1,
|
||||
fixedSize,
|
||||
minScale = 1,
|
||||
maxScale = 5,
|
||||
disabled = false,
|
||||
onCropChange,
|
||||
onCropComplete,
|
||||
'aria-label': ariaLabel,
|
||||
children,
|
||||
child,
|
||||
...restProps
|
||||
}: CropperProps = $props();
|
||||
|
||||
const state = CropperProvider.create({
|
||||
id: readableActive(() => id),
|
||||
ref: writableActive(
|
||||
() => ref,
|
||||
(v) => (ref = v)
|
||||
),
|
||||
crop: writableActive(
|
||||
() => crop,
|
||||
(v) => (crop = v)
|
||||
),
|
||||
src: readableActive(() => src),
|
||||
aspect: readableActive(() => aspect),
|
||||
shape: readableActive(() => shape),
|
||||
minSize: readableActive(() => minSize),
|
||||
maxSize: readableActive(() => maxSize),
|
||||
fixedSize: readableActive(() => fixedSize),
|
||||
minScale: readableActive(() => minScale),
|
||||
maxScale: readableActive(() => maxScale),
|
||||
disabled: readableActive(() => disabled),
|
||||
onCropChange: readableActive(() => onCropChange),
|
||||
onCropComplete: readableActive(() => onCropComplete),
|
||||
ariaLabel: readableActive(() => ariaLabel ?? undefined)
|
||||
});
|
||||
|
||||
const mergedProps = $derived(mergeProps(restProps, state.props));
|
||||
|
||||
/** Produce the cropped image now. Accessible via `bind:this`. */
|
||||
export function cropImage() {
|
||||
return state.cropImage();
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if child}
|
||||
{@render child({ ...state.snippetProps, props: mergedProps })}
|
||||
{:else}
|
||||
<div {...mergedProps}>
|
||||
{@render children?.(state.snippetProps)}
|
||||
</div>
|
||||
{/if}
|
||||
@ -0,0 +1,224 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { createActiveDom } from '$adom';
|
||||
import { state } from '$libs/reactive';
|
||||
import type { Morfo } from '$uix/morfo';
|
||||
import { Soma } from '$soma/core/soma.svelte';
|
||||
import { createSomaRuntime, type SomaRuntimeSources } from '$soma/runtime.svelte';
|
||||
|
||||
import { CropperProvider, moveRect, resizeRect } from './cropper-provider.svelte';
|
||||
import { ZoomPan } from '../../layers/zoom-pan.svelte';
|
||||
import type { CropRect } from './types';
|
||||
|
||||
function withEffectRoot<T>(fn: () => T): { result: T; cleanup: () => void } {
|
||||
let result!: T;
|
||||
const cleanup = $effect.root(() => {
|
||||
result = fn();
|
||||
});
|
||||
return { result, cleanup };
|
||||
}
|
||||
|
||||
function installSomaHarness() {
|
||||
const dom = createActiveDom();
|
||||
const soma = {
|
||||
dom,
|
||||
langs: { ts: (ref: string) => ref },
|
||||
runtime: (morfo: Morfo, sources: Omit<SomaRuntimeSources, 'dom' | 'eventEngine'>) =>
|
||||
createSomaRuntime(morfo, { dom, translate: (key) => key, ...sources })
|
||||
} as unknown as Soma;
|
||||
vi.spyOn(Soma, 'require').mockReturnValue(soma);
|
||||
vi.spyOn(CropperProvider.ctx, 'set').mockImplementation((value) => value);
|
||||
return { dom };
|
||||
}
|
||||
|
||||
function cropperOpts(crop: CropRect = { x: 0.1, y: 0.1, width: 0.8, height: 0.8 }) {
|
||||
return {
|
||||
id: state('cr-root'),
|
||||
ref: state<HTMLElement | null>(document.createElement('div')),
|
||||
crop: state<CropRect>(crop),
|
||||
src: state<string | null | undefined>('blob:x'),
|
||||
aspect: state<number | undefined>(undefined),
|
||||
shape: state<'rect' | 'round'>('rect'),
|
||||
minSize: state(0.05),
|
||||
maxSize: state(1),
|
||||
fixedSize: state<number | undefined>(undefined),
|
||||
minScale: state(1),
|
||||
maxScale: state(5),
|
||||
disabled: state(false),
|
||||
onCropChange: state<((c: CropRect) => void) | undefined>(undefined),
|
||||
onCropComplete: state<undefined>(undefined),
|
||||
ariaLabel: state<string | undefined>(undefined)
|
||||
};
|
||||
}
|
||||
|
||||
const approx = (v: number, t: number) => Math.abs(v - t) < 1e-6;
|
||||
|
||||
describe('moveRect', () => {
|
||||
it('translates and clamps inside [0,1]', () => {
|
||||
const r = { x: 0.2, y: 0.2, width: 0.4, height: 0.4 };
|
||||
const moved = moveRect(r, 0.1, -0.1);
|
||||
expect(approx(moved.x, 0.3)).toBe(true);
|
||||
expect(approx(moved.y, 0.1)).toBe(true);
|
||||
expect(moved.width).toBe(0.4);
|
||||
// clamp at the right/bottom edge: x cannot exceed 1 - width
|
||||
const clamped = moveRect(r, 1, 1);
|
||||
expect(approx(clamped.x, 0.6)).toBe(true);
|
||||
expect(approx(clamped.y, 0.6)).toBe(true);
|
||||
// clamp at the top/left edge
|
||||
expect(moveRect(r, -1, -1)).toMatchObject({ x: 0, y: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('resizeRect', () => {
|
||||
const base = { x: 0.1, y: 0.1, width: 0.4, height: 0.4 };
|
||||
|
||||
it('se corner grows width + height from the top-left anchor', () => {
|
||||
const r = resizeRect(base, 'se', 0.2, 0.1, undefined, 100, 100, 0.05);
|
||||
expect(approx(r.x, 0.1)).toBe(true);
|
||||
expect(approx(r.y, 0.1)).toBe(true);
|
||||
expect(approx(r.width, 0.6)).toBe(true);
|
||||
expect(approx(r.height, 0.5)).toBe(true);
|
||||
});
|
||||
|
||||
it('nw corner moves the top-left, keeping the bottom-right anchor', () => {
|
||||
const r = resizeRect(base, 'nw', 0.1, 0.1, undefined, 100, 100, 0.05);
|
||||
expect(approx(r.x, 0.2)).toBe(true);
|
||||
expect(approx(r.y, 0.2)).toBe(true);
|
||||
expect(approx(r.width, 0.3)).toBe(true);
|
||||
expect(approx(r.height, 0.3)).toBe(true);
|
||||
});
|
||||
|
||||
it('respects minSize', () => {
|
||||
const r = resizeRect(base, 'se', -1, -1, undefined, 100, 100, 0.05);
|
||||
expect(approx(r.width, 0.05)).toBe(true);
|
||||
expect(approx(r.height, 0.05)).toBe(true);
|
||||
});
|
||||
|
||||
it('locks aspect (square viewport, aspect=1 → width === height)', () => {
|
||||
const r = resizeRect(base, 'se', 0.2, 0, 1, 100, 100, 0.05);
|
||||
expect(approx(r.width, 0.6)).toBe(true);
|
||||
expect(approx(r.height, 0.6)).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps the rect inside [0,1]', () => {
|
||||
const r = resizeRect({ x: 0.8, y: 0.8, width: 0.15, height: 0.15 }, 'se', 0.5, 0.5, undefined, 100, 100, 0.05);
|
||||
expect(r.x + r.width).toBeLessThanOrEqual(1.0000001);
|
||||
expect(r.y + r.height).toBeLessThanOrEqual(1.0000001);
|
||||
});
|
||||
|
||||
it('respects maxSize', () => {
|
||||
const r = resizeRect({ x: 0.1, y: 0.1, width: 0.4, height: 0.4 }, 'se', 1, 1, undefined, 100, 100, 0.05, 0.5);
|
||||
expect(r.width).toBeLessThanOrEqual(0.5 + 1e-6);
|
||||
expect(r.height).toBeLessThanOrEqual(0.5 + 1e-6);
|
||||
});
|
||||
|
||||
it('edge handle "e" resizes width only', () => {
|
||||
const r = resizeRect({ x: 0.1, y: 0.1, width: 0.4, height: 0.4 }, 'e', 0.2, 0.1, undefined, 100, 100, 0.05);
|
||||
expect(approx(r.width, 0.6)).toBe(true);
|
||||
expect(approx(r.height, 0.4)).toBe(true);
|
||||
expect(approx(r.y, 0.1)).toBe(true);
|
||||
});
|
||||
|
||||
it('edge handle "s" resizes height only', () => {
|
||||
const r = resizeRect({ x: 0.1, y: 0.1, width: 0.4, height: 0.4 }, 's', 0.2, 0.1, undefined, 100, 100, 0.05);
|
||||
expect(approx(r.width, 0.4)).toBe(true);
|
||||
expect(approx(r.height, 0.5)).toBe(true);
|
||||
expect(approx(r.x, 0.1)).toBe(true);
|
||||
});
|
||||
|
||||
it('edge handle "n" with aspect derives width from height', () => {
|
||||
// square viewport + aspect 1 → height change pulls width to match.
|
||||
const r = resizeRect({ x: 0.2, y: 0.2, width: 0.4, height: 0.4 }, 'n', 0, -0.1, 1, 100, 100, 0.05);
|
||||
expect(approx(r.height, 0.5)).toBe(true);
|
||||
expect(approx(r.width, 0.5)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ZoomPan', () => {
|
||||
it('clamps scale to [min,max]', () => {
|
||||
const z = ZoomPan.create({ minScale: 1, maxScale: 3 });
|
||||
z.setViewport(100, 100);
|
||||
z.zoomTo(5);
|
||||
expect(z.scale).toBe(3);
|
||||
z.zoomTo(0.5);
|
||||
expect(z.scale).toBe(1);
|
||||
});
|
||||
|
||||
it('clamps pan so the content keeps covering the viewport', () => {
|
||||
const z = ZoomPan.create({ minScale: 1, maxScale: 4 });
|
||||
z.setViewport(100, 100);
|
||||
z.panBy(50, 50); // at scale 1 the offset must stay 0
|
||||
expect(z.offset).toEqual({ x: 0, y: 0 });
|
||||
z.zoomTo(2); // content = 200px → offset ∈ [-100, 0]
|
||||
z.panBy(-40, -40);
|
||||
expect(z.offset.x).toBeLessThanOrEqual(0);
|
||||
expect(z.offset.x).toBeGreaterThanOrEqual(-100);
|
||||
});
|
||||
|
||||
it('viewportFracToContentFrac is identity at scale 1', () => {
|
||||
const z = ZoomPan.create();
|
||||
z.setViewport(100, 100);
|
||||
expect(z.viewportFracToContentFrac(0.3, 0.7)).toEqual({ x: 0.3, y: 0.7 });
|
||||
});
|
||||
|
||||
it('maps fractions under zoom (from the corner)', () => {
|
||||
const z = ZoomPan.create({ minScale: 1, maxScale: 4 });
|
||||
z.setViewport(100, 100);
|
||||
z.zoomTo(2, 0, 0); // zoom centered on the corner → offset stays 0
|
||||
expect(z.viewportFracToContentFrac(0.5, 0.5)).toEqual({ x: 0.25, y: 0.25 });
|
||||
});
|
||||
|
||||
it('reset returns to scale 1 / no offset', () => {
|
||||
const z = ZoomPan.create({ minScale: 1, maxScale: 4 });
|
||||
z.setViewport(100, 100);
|
||||
z.zoomTo(3);
|
||||
z.reset();
|
||||
expect(z.scale).toBe(1);
|
||||
expect(z.offset).toEqual({ x: 0, y: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('CropperProvider', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
it('setCrop updates the rect + fires onCropChange', () => {
|
||||
installSomaHarness();
|
||||
const opts = cropperOpts();
|
||||
const onCropChange = vi.fn();
|
||||
opts.onCropChange.current = onCropChange;
|
||||
const { result: p, cleanup } = withEffectRoot(() => CropperProvider.create(opts));
|
||||
|
||||
const next = { x: 0, y: 0, width: 0.5, height: 0.5 };
|
||||
p.setCrop(next);
|
||||
expect(opts.crop.current).toEqual(next);
|
||||
expect(onCropChange).toHaveBeenCalledWith(next);
|
||||
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('disabled blocks setCrop', () => {
|
||||
installSomaHarness();
|
||||
const opts = cropperOpts();
|
||||
opts.disabled.current = true;
|
||||
const { result: p, cleanup } = withEffectRoot(() => CropperProvider.create(opts));
|
||||
|
||||
p.setCrop({ x: 0, y: 0, width: 0.2, height: 0.2 });
|
||||
expect(opts.crop.current).toEqual({ x: 0.1, y: 0.1, width: 0.8, height: 0.8 });
|
||||
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('exposes crop + shape + dragging in snippetProps', () => {
|
||||
installSomaHarness();
|
||||
const opts = cropperOpts();
|
||||
const { result: p, cleanup } = withEffectRoot(() => CropperProvider.create(opts));
|
||||
expect(p.snippetProps).toMatchObject({ shape: 'rect', dragging: false, disabled: false });
|
||||
expect(typeof p.snippetProps.cropImage).toBe('function');
|
||||
cleanup();
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,19 @@
|
||||
export { default as Provider } from './components/cropper.svelte';
|
||||
export { default as Viewport } from './components/cropper-viewport.svelte';
|
||||
export { default as Selection } from './components/cropper-selection.svelte';
|
||||
export { default as Grid } from './components/cropper-grid.svelte';
|
||||
export { default as Handle } from './components/cropper-handle.svelte';
|
||||
|
||||
export { moveRect, resizeRect } from './cropper-provider.svelte';
|
||||
|
||||
export type {
|
||||
CropRect,
|
||||
CropShape,
|
||||
CropCorner,
|
||||
CropResult,
|
||||
CropperProps as ProviderProps,
|
||||
CropperViewportProps as ViewportProps,
|
||||
CropperSelectionProps as SelectionProps,
|
||||
CropperGridProps as GridProps,
|
||||
CropperHandleProps as HandleProps
|
||||
} from './types';
|
||||
@ -0,0 +1 @@
|
||||
export * from './exports';
|
||||
@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Lang refs for Cropper. Resolved via `soma.langs.ts(ref)`. Catalog leaves live
|
||||
* in `src/uix/langs/components/cropper.ts`.
|
||||
*/
|
||||
export const CROPPER_LANGS = {
|
||||
LABEL: '#?components.cropper.label|Crop image',
|
||||
SELECTION: '#?components.cropper.selection|Crop region'
|
||||
} as const;
|
||||
@ -0,0 +1,35 @@
|
||||
<script lang="ts">
|
||||
import { readableActive, writableActive } from '$libs/reactive';
|
||||
import { mergeProps } from '../../../props';
|
||||
import { createId } from '$active-uix/id';
|
||||
import { ImageAdjustmentsItemLabelProvider } from '../image-adjustments-provider.svelte';
|
||||
import type { ImageAdjustmentsItemLabelProps } from '../types';
|
||||
|
||||
const uid = $props.id();
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
id = createId(uid, 'image-adjustments-item-label'),
|
||||
children,
|
||||
child,
|
||||
...restProps
|
||||
}: ImageAdjustmentsItemLabelProps = $props();
|
||||
|
||||
const state = ImageAdjustmentsItemLabelProvider.create({
|
||||
id: readableActive(() => id),
|
||||
ref: writableActive(
|
||||
() => ref,
|
||||
(v) => (ref = v)
|
||||
)
|
||||
});
|
||||
|
||||
const mergedProps = $derived(mergeProps(restProps, state.props));
|
||||
</script>
|
||||
|
||||
{#if child}
|
||||
{@render child({ props: mergedProps })}
|
||||
{:else}
|
||||
<span {...mergedProps}>
|
||||
{@render children?.()}
|
||||
</span>
|
||||
{/if}
|
||||
@ -0,0 +1,35 @@
|
||||
<script lang="ts">
|
||||
import { readableActive, writableActive } from '$libs/reactive';
|
||||
import { mergeProps } from '../../../props';
|
||||
import { createId } from '$active-uix/id';
|
||||
import { ImageAdjustmentsItemValueProvider } from '../image-adjustments-provider.svelte';
|
||||
import type { ImageAdjustmentsItemValueProps } from '../types';
|
||||
|
||||
const uid = $props.id();
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
id = createId(uid, 'image-adjustments-item-value'),
|
||||
children,
|
||||
child,
|
||||
...restProps
|
||||
}: ImageAdjustmentsItemValueProps = $props();
|
||||
|
||||
const state = ImageAdjustmentsItemValueProvider.create({
|
||||
id: readableActive(() => id),
|
||||
ref: writableActive(
|
||||
() => ref,
|
||||
(v) => (ref = v)
|
||||
)
|
||||
});
|
||||
|
||||
const mergedProps = $derived(mergeProps(restProps, state.props));
|
||||
</script>
|
||||
|
||||
{#if child}
|
||||
{@render child({ props: mergedProps })}
|
||||
{:else}
|
||||
<span {...mergedProps}>
|
||||
{@render children?.()}
|
||||
</span>
|
||||
{/if}
|
||||
@ -0,0 +1,47 @@
|
||||
<script lang="ts">
|
||||
import { readableActive, writableActive } from '$libs/reactive';
|
||||
import { mergeProps } from '../../../props';
|
||||
import { createId } from '$active-uix/id';
|
||||
import { ImageAdjustmentsItemProvider } from '../image-adjustments-provider.svelte';
|
||||
import type { ImageAdjustmentsItemProps } from '../types';
|
||||
|
||||
const uid = $props.id();
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
id = createId(uid, 'image-adjustments-item'),
|
||||
adjustment,
|
||||
children,
|
||||
child,
|
||||
...restProps
|
||||
}: ImageAdjustmentsItemProps = $props();
|
||||
|
||||
const state = ImageAdjustmentsItemProvider.create({
|
||||
id: readableActive(() => id),
|
||||
ref: writableActive(
|
||||
() => ref,
|
||||
(v) => (ref = v)
|
||||
),
|
||||
adjustment: readableActive(() => adjustment)
|
||||
});
|
||||
|
||||
const mergedProps = $derived(mergeProps(restProps, state.props));
|
||||
|
||||
const snippetProps = $derived({
|
||||
adjustment,
|
||||
value: state.value,
|
||||
label: state.label,
|
||||
formattedValue: state.formattedValue,
|
||||
def: state.def,
|
||||
disabled: state.isDisabled,
|
||||
setValue: (v: number) => state.setValue(v)
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if child}
|
||||
{@render child({ ...snippetProps, props: mergedProps })}
|
||||
{:else}
|
||||
<div {...mergedProps}>
|
||||
{@render children?.(snippetProps)}
|
||||
</div>
|
||||
{/if}
|
||||
@ -0,0 +1,37 @@
|
||||
<script lang="ts">
|
||||
import { readableActive, writableActive } from '$libs/reactive';
|
||||
import { mergeProps } from '../../../props';
|
||||
import { createId } from '$active-uix/id';
|
||||
import { ImageAdjustmentsResetProvider } from '../image-adjustments-provider.svelte';
|
||||
import type { ImageAdjustmentsResetProps } from '../types';
|
||||
|
||||
const uid = $props.id();
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
id = createId(uid, 'image-adjustments-reset'),
|
||||
children,
|
||||
child,
|
||||
...restProps
|
||||
}: ImageAdjustmentsResetProps = $props();
|
||||
|
||||
const state = ImageAdjustmentsResetProvider.create({
|
||||
id: readableActive(() => id),
|
||||
ref: writableActive(
|
||||
() => ref,
|
||||
(v) => (ref = v)
|
||||
)
|
||||
});
|
||||
|
||||
const mergedProps = $derived(mergeProps(restProps, state.props));
|
||||
|
||||
const snippetProps = $derived({ disabled: state.isDisabled, label: state.label });
|
||||
</script>
|
||||
|
||||
{#if child}
|
||||
{@render child({ ...snippetProps, props: mergedProps })}
|
||||
{:else}
|
||||
<button {...mergedProps}>
|
||||
{@render children?.(snippetProps)}
|
||||
</button>
|
||||
{/if}
|
||||
@ -0,0 +1,50 @@
|
||||
<script lang="ts">
|
||||
import { readableActive, writableActive } from '$libs/reactive';
|
||||
import { mergeProps } from '../../../props';
|
||||
import { createId } from '$active-uix/id';
|
||||
import { ImageAdjustmentsProvider } from '../image-adjustments-provider.svelte';
|
||||
import type { ImageAdjustmentsProps } from '../types';
|
||||
|
||||
const uid = $props.id();
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
id = createId(uid, 'image-adjustments'),
|
||||
value = $bindable({}),
|
||||
onValueChange = () => {},
|
||||
onFilterChange = () => {},
|
||||
adjustments,
|
||||
disabled = false,
|
||||
'aria-label': ariaLabel,
|
||||
children,
|
||||
child,
|
||||
...restProps
|
||||
}: ImageAdjustmentsProps = $props();
|
||||
|
||||
const state = ImageAdjustmentsProvider.create({
|
||||
id: readableActive(() => id),
|
||||
ref: writableActive(
|
||||
() => ref,
|
||||
(v) => (ref = v)
|
||||
),
|
||||
value: writableActive(
|
||||
() => value,
|
||||
(v) => (value = v)
|
||||
),
|
||||
onValueChange: readableActive(() => onValueChange),
|
||||
onFilterChange: readableActive(() => onFilterChange),
|
||||
adjustments: readableActive(() => adjustments),
|
||||
disabled: readableActive(() => disabled),
|
||||
ariaLabel: readableActive(() => ariaLabel ?? undefined)
|
||||
});
|
||||
|
||||
const mergedProps = $derived(mergeProps(restProps, state.props));
|
||||
</script>
|
||||
|
||||
{#if child}
|
||||
{@render child({ ...state.snippetProps, props: mergedProps })}
|
||||
{:else}
|
||||
<div {...mergedProps}>
|
||||
{@render children?.(state.snippetProps)}
|
||||
</div>
|
||||
{/if}
|
||||
@ -0,0 +1,22 @@
|
||||
export { default as Provider } from './components/image-adjustments.svelte';
|
||||
export { default as Item } from './components/image-adjustments-item.svelte';
|
||||
export { default as ItemLabel } from './components/image-adjustments-item-label.svelte';
|
||||
export { default as ItemValue } from './components/image-adjustments-item-value.svelte';
|
||||
export { default as Reset } from './components/image-adjustments-reset.svelte';
|
||||
|
||||
export {
|
||||
IMAGE_ADJUSTMENTS,
|
||||
DEFAULT_IMAGE_ADJUSTMENTS,
|
||||
computeImageFilter
|
||||
} from './image-adjustments-provider.svelte';
|
||||
|
||||
export type {
|
||||
AdjustmentDef,
|
||||
ImageAdjustmentKey,
|
||||
ImageAdjustmentValues,
|
||||
ImageAdjustmentsProps as ProviderProps,
|
||||
ImageAdjustmentsItemProps as ItemProps,
|
||||
ImageAdjustmentsItemLabelProps as ItemLabelProps,
|
||||
ImageAdjustmentsItemValueProps as ItemValueProps,
|
||||
ImageAdjustmentsResetProps as ResetProps
|
||||
} from './types';
|
||||
@ -0,0 +1,207 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { tick } from 'svelte';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { createActiveDom } from '$adom';
|
||||
import { state } from '$libs/reactive';
|
||||
import type { Morfo } from '$uix/morfo';
|
||||
import { Soma } from '$soma/core/soma.svelte';
|
||||
import { createSomaRuntime, type SomaRuntimeSources } from '$soma/runtime.svelte';
|
||||
|
||||
import {
|
||||
ImageAdjustmentsProvider,
|
||||
IMAGE_ADJUSTMENTS,
|
||||
DEFAULT_IMAGE_ADJUSTMENTS,
|
||||
computeImageFilter
|
||||
} from './image-adjustments-provider.svelte';
|
||||
import type { ImageAdjustmentKey, ImageAdjustmentValues } from './types';
|
||||
|
||||
function withEffectRoot<T>(fn: () => T): { result: T; cleanup: () => void } {
|
||||
let result!: T;
|
||||
const cleanup = $effect.root(() => {
|
||||
result = fn();
|
||||
});
|
||||
return { result, cleanup };
|
||||
}
|
||||
|
||||
async function flushRuntimeTrigger() {
|
||||
await Promise.resolve();
|
||||
await tick();
|
||||
}
|
||||
|
||||
function installSomaHarness() {
|
||||
const dom = createActiveDom();
|
||||
const soma = {
|
||||
dom,
|
||||
// Passthrough langs stub (mirrors the toggle harness's `translate: key => key`).
|
||||
// The real catalog is validated by `translations:check`, not here.
|
||||
langs: { ts: (ref: string) => ref },
|
||||
runtime: (morfo: Morfo, sources: Omit<SomaRuntimeSources, 'dom' | 'eventEngine'>) =>
|
||||
createSomaRuntime(morfo, {
|
||||
dom,
|
||||
translate: (key) => key,
|
||||
...sources
|
||||
})
|
||||
} as unknown as Soma;
|
||||
|
||||
vi.spyOn(Soma, 'require').mockReturnValue(soma);
|
||||
// `runtime.part` sets the provider context; setContext can't run inside an
|
||||
// effect-root, so bypass it (same shim as the slider provider test).
|
||||
vi.spyOn(ImageAdjustmentsProvider.ctx, 'set').mockImplementation((value) => value);
|
||||
return { dom };
|
||||
}
|
||||
|
||||
function adjustmentsOpts(id = 'ia-root') {
|
||||
return {
|
||||
id: state(id),
|
||||
ref: state<HTMLElement | null>(document.createElement('div')),
|
||||
value: state<ImageAdjustmentValues>({}),
|
||||
onValueChange: state<((v: ImageAdjustmentValues) => void) | undefined>(undefined),
|
||||
onFilterChange: state<((f: string) => void) | undefined>(undefined),
|
||||
adjustments: state<ImageAdjustmentKey[] | undefined>(undefined),
|
||||
disabled: state(false),
|
||||
ariaLabel: state<string | undefined>(undefined)
|
||||
};
|
||||
}
|
||||
|
||||
describe('computeImageFilter', () => {
|
||||
it('returns "none" when every adjustment is neutral', () => {
|
||||
expect(computeImageFilter({}, DEFAULT_IMAGE_ADJUSTMENTS)).toBe('none');
|
||||
expect(computeImageFilter({ brightness: 0, contrast: 0 }, DEFAULT_IMAGE_ADJUSTMENTS)).toBe(
|
||||
'none'
|
||||
);
|
||||
});
|
||||
|
||||
it('maps tone adjustments to their CSS functions', () => {
|
||||
expect(computeImageFilter({ brightness: 20 }, ['brightness'])).toBe('brightness(1.2)');
|
||||
expect(computeImageFilter({ contrast: -50 }, ['contrast'])).toBe('contrast(0.5)');
|
||||
expect(computeImageFilter({ saturation: 100 }, ['saturation'])).toBe('saturate(2)');
|
||||
expect(computeImageFilter({ hue: 90 }, ['hue'])).toBe('hue-rotate(90deg)');
|
||||
});
|
||||
|
||||
it('maps the 0-based adjustments and skips them at zero', () => {
|
||||
expect(computeImageFilter({ blur: 4 }, ['blur'])).toBe('blur(4px)');
|
||||
expect(computeImageFilter({ blur: 0 }, ['blur'])).toBe('none');
|
||||
expect(computeImageFilter({ grayscale: 60 }, ['grayscale'])).toBe('grayscale(60%)');
|
||||
expect(computeImageFilter({ sepia: 100 }, ['sepia'])).toBe('sepia(100%)');
|
||||
});
|
||||
|
||||
it('approximates temperature: warm leans sepia, cool hue-rotates + saturates', () => {
|
||||
expect(computeImageFilter({ temperature: 50 }, ['temperature'])).toBe('sepia(0.250)');
|
||||
expect(computeImageFilter({ temperature: -50 }, ['temperature'])).toBe(
|
||||
'hue-rotate(-17.5deg) saturate(1.200)'
|
||||
);
|
||||
});
|
||||
|
||||
it('composes multiple adjustments in declared order', () => {
|
||||
expect(
|
||||
computeImageFilter({ contrast: 10, brightness: 20 }, ['brightness', 'contrast'])
|
||||
).toBe('brightness(1.2) contrast(1.1)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ImageAdjustmentsProvider', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
it('computes the live filter from current values', () => {
|
||||
installSomaHarness();
|
||||
const opts = adjustmentsOpts();
|
||||
const { result: provider, cleanup } = withEffectRoot(() =>
|
||||
ImageAdjustmentsProvider.create(opts)
|
||||
);
|
||||
|
||||
expect(provider.filter).toBe('none');
|
||||
expect(provider.isModified).toBe(false);
|
||||
|
||||
opts.value.current = { brightness: 30 };
|
||||
expect(provider.filter).toBe('brightness(1.3)');
|
||||
expect(provider.isModified).toBe(true);
|
||||
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('setAdjustment mutates value + fires onValueChange/onFilterChange', () => {
|
||||
installSomaHarness();
|
||||
const opts = adjustmentsOpts();
|
||||
const onValueChange = vi.fn();
|
||||
const onFilterChange = vi.fn();
|
||||
opts.onValueChange.current = onValueChange;
|
||||
opts.onFilterChange.current = onFilterChange;
|
||||
|
||||
const { result: provider, cleanup } = withEffectRoot(() =>
|
||||
ImageAdjustmentsProvider.create(opts)
|
||||
);
|
||||
|
||||
provider.setAdjustment('contrast', -40);
|
||||
expect(opts.value.current).toEqual({ contrast: -40 });
|
||||
expect(onValueChange).toHaveBeenCalledWith({ contrast: -40 });
|
||||
expect(onFilterChange).toHaveBeenLastCalledWith('contrast(0.6)');
|
||||
expect(provider.getAdjustment('contrast')).toBe(-40);
|
||||
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('reset() clears values + fires callbacks through the runtime', async () => {
|
||||
installSomaHarness();
|
||||
const opts = adjustmentsOpts();
|
||||
opts.value.current = { brightness: 20, blur: 5 };
|
||||
const onFilterChange = vi.fn();
|
||||
opts.onFilterChange.current = onFilterChange;
|
||||
|
||||
const { result: provider, cleanup } = withEffectRoot(() =>
|
||||
ImageAdjustmentsProvider.create(opts)
|
||||
);
|
||||
expect(provider.isModified).toBe(true);
|
||||
|
||||
provider.reset();
|
||||
await flushRuntimeTrigger();
|
||||
|
||||
expect(opts.value.current).toEqual({});
|
||||
expect(provider.filter).toBe('none');
|
||||
expect(onFilterChange).toHaveBeenLastCalledWith('none');
|
||||
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('honours a custom adjustments subset for filter + isModified', () => {
|
||||
installSomaHarness();
|
||||
const opts = adjustmentsOpts();
|
||||
opts.adjustments.current = ['grayscale'];
|
||||
// brightness is set but NOT in the active subset → ignored by filter.
|
||||
opts.value.current = { grayscale: 50, brightness: 20 };
|
||||
|
||||
const { result: provider, cleanup } = withEffectRoot(() =>
|
||||
ImageAdjustmentsProvider.create(opts)
|
||||
);
|
||||
|
||||
expect(provider.filter).toBe('grayscale(50%)');
|
||||
expect(provider.isModified).toBe(true);
|
||||
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('disabled blocks setAdjustment', () => {
|
||||
installSomaHarness();
|
||||
const opts = adjustmentsOpts();
|
||||
opts.disabled.current = true;
|
||||
|
||||
const { result: provider, cleanup } = withEffectRoot(() =>
|
||||
ImageAdjustmentsProvider.create(opts)
|
||||
);
|
||||
|
||||
provider.setAdjustment('brightness', 50);
|
||||
expect(opts.value.current).toEqual({});
|
||||
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('exposes the canonical catalog + default set', () => {
|
||||
expect(DEFAULT_IMAGE_ADJUSTMENTS).toContain('brightness');
|
||||
expect(IMAGE_ADJUSTMENTS.hue).toMatchObject({ min: -180, max: 180, unit: '°' });
|
||||
expect(IMAGE_ADJUSTMENTS.blur).toMatchObject({ min: 0, max: 20, unit: 'px' });
|
||||
});
|
||||
});
|
||||
@ -0,0 +1 @@
|
||||
export * from './exports';
|
||||
@ -0,0 +1,22 @@
|
||||
import type { ImageAdjustmentKey } from './types';
|
||||
|
||||
/**
|
||||
* Lang refs for ImageAdjustments. Resolved via `soma.langs.ts(ref)`. Catalog
|
||||
* leaves live in `src/uix/langs/components/image-adjustments.ts`.
|
||||
*/
|
||||
export const IMAGE_ADJUSTMENTS_LANGS = {
|
||||
LABEL: '#?components.image-adjustments.label|Image adjustments',
|
||||
RESET: '#?components.image-adjustments.reset|Reset'
|
||||
} as const;
|
||||
|
||||
/** Per-adjustment label refs, keyed by adjustment. */
|
||||
export const IMAGE_ADJUSTMENT_LABELS: Record<ImageAdjustmentKey, string> = {
|
||||
brightness: '#?components.image-adjustments.adjustments.brightness|Brightness',
|
||||
contrast: '#?components.image-adjustments.adjustments.contrast|Contrast',
|
||||
saturation: '#?components.image-adjustments.adjustments.saturation|Saturation',
|
||||
temperature: '#?components.image-adjustments.adjustments.temperature|Temperature',
|
||||
hue: '#?components.image-adjustments.adjustments.hue|Hue',
|
||||
blur: '#?components.image-adjustments.adjustments.blur|Blur',
|
||||
grayscale: '#?components.image-adjustments.adjustments.grayscale|Grayscale',
|
||||
sepia: '#?components.image-adjustments.adjustments.sepia|Sepia'
|
||||
};
|
||||
@ -0,0 +1,111 @@
|
||||
import type { WithChild, Without, OnChangeFn, PrimitiveDivAttributes, PrimitiveButtonAttributes } from '../../types';
|
||||
|
||||
/**
|
||||
* The CSS-mappable adjustment keys. Each maps to a CSS `filter()` function (or a
|
||||
* best-effort approximation for `temperature`). `highlights`/`shadows` are NOT
|
||||
* here — they need tone curves (canvas / SVG `feComponentTransfer`), deferred.
|
||||
*/
|
||||
export type ImageAdjustmentKey =
|
||||
| 'brightness'
|
||||
| 'contrast'
|
||||
| 'saturation'
|
||||
| 'temperature'
|
||||
| 'hue'
|
||||
| 'blur'
|
||||
| 'grayscale'
|
||||
| 'sepia';
|
||||
|
||||
/** Per-adjustment values. Missing keys fall back to each adjustment's neutral default. */
|
||||
export type ImageAdjustmentValues = Partial<Record<ImageAdjustmentKey, number>>;
|
||||
|
||||
/** Definition of one adjustment: range, neutral default, CSS mapping, read-out format. */
|
||||
export interface AdjustmentDef {
|
||||
min: number;
|
||||
max: number;
|
||||
default: number;
|
||||
step: number;
|
||||
/** Unit suffix for the read-out (e.g. '°', 'px', '%'); '' for unitless tone. */
|
||||
unit: string;
|
||||
/** CSS `filter()` fragment for a value; '' when at neutral default. */
|
||||
toFilter: (v: number) => string;
|
||||
/** Human read-out for the value (e.g. '+20', '-15', '90°', '4px', '60%'). */
|
||||
format: (v: number) => string;
|
||||
}
|
||||
|
||||
/** Snippet props exposed by the Provider's `children` / `child`. */
|
||||
export type ImageAdjustmentsSnippetProps = {
|
||||
/** Live CSS `filter` string for the current values ('none' when neutral). */
|
||||
filter: string;
|
||||
/** True when any adjustment differs from its neutral default. */
|
||||
isModified: boolean;
|
||||
/** The current values record. */
|
||||
values: ImageAdjustmentValues;
|
||||
};
|
||||
|
||||
/** Snippet props exposed by an Item's `children` / `child`. */
|
||||
export type ImageAdjustmentsItemSnippetProps = {
|
||||
adjustment: ImageAdjustmentKey;
|
||||
/** Current value (neutral default when unset). */
|
||||
value: number;
|
||||
/** Localized adjustment label. */
|
||||
label: string;
|
||||
/** Formatted read-out of the current value. */
|
||||
formattedValue: string;
|
||||
/** This adjustment's definition (range / step / unit / mapping). */
|
||||
def: AdjustmentDef;
|
||||
/** Whether the group is disabled. */
|
||||
disabled: boolean;
|
||||
/** Set this row's value (wire to the composed slider). */
|
||||
setValue: (value: number) => void;
|
||||
};
|
||||
|
||||
/** Snippet props exposed by the Reset button's `children` / `child`. */
|
||||
export type ImageAdjustmentsResetSnippetProps = {
|
||||
disabled: boolean;
|
||||
/** Localized reset label. */
|
||||
label: string;
|
||||
};
|
||||
|
||||
export type ImageAdjustmentsProps = WithChild<
|
||||
{
|
||||
/** Unique identifier. Auto-generated if omitted. */
|
||||
id?: string;
|
||||
/** Current adjustment values. Bindable. */
|
||||
value?: ImageAdjustmentValues;
|
||||
/** Callback when any adjustment changes (the full values record). */
|
||||
onValueChange?: OnChangeFn<ImageAdjustmentValues>;
|
||||
/** Callback with the recomputed CSS `filter` string (e.g. `brightness(1.1) contrast(0.9)`). */
|
||||
onFilterChange?: OnChangeFn<string>;
|
||||
/**
|
||||
* Which adjustments to show, in order. Defaults to the core tone set
|
||||
* (brightness, contrast, saturation, temperature, hue, blur).
|
||||
*/
|
||||
adjustments?: ImageAdjustmentKey[];
|
||||
/** Disable all sliders + reset. @default false */
|
||||
disabled?: boolean;
|
||||
},
|
||||
ImageAdjustmentsSnippetProps
|
||||
> &
|
||||
Without<PrimitiveDivAttributes, { value?: unknown }>;
|
||||
|
||||
export type ImageAdjustmentsItemProps = WithChild<
|
||||
{
|
||||
id?: string;
|
||||
/** Which adjustment this row drives. */
|
||||
adjustment: ImageAdjustmentKey;
|
||||
},
|
||||
ImageAdjustmentsItemSnippetProps
|
||||
> &
|
||||
Without<PrimitiveDivAttributes, {}>;
|
||||
|
||||
export type ImageAdjustmentsItemLabelProps = WithChild<{ id?: string }> &
|
||||
Without<PrimitiveDivAttributes, {}>;
|
||||
|
||||
export type ImageAdjustmentsItemValueProps = WithChild<{ id?: string }> &
|
||||
Without<PrimitiveDivAttributes, {}>;
|
||||
|
||||
export type ImageAdjustmentsResetProps = WithChild<
|
||||
{ id?: string },
|
||||
ImageAdjustmentsResetSnippetProps
|
||||
> &
|
||||
Without<PrimitiveButtonAttributes, {}>;
|
||||
@ -0,0 +1,68 @@
|
||||
# ImagePicker (soma)
|
||||
|
||||
Headless orchestrator for **pick / preview / adjust an image**. It composes the
|
||||
framework's already-built `file-upload`, `image` and `ImageAdjustments` — it
|
||||
declares only its OWN concerns: the empty↔ready state, the `fit` fill mode, the
|
||||
90° `rotation`, the object-URL lifecycle, and the composed CSS `filter`.
|
||||
|
||||
> Output is **cropper-ready**: `onChange` streams
|
||||
> `{ file, url, fit, rotation, filter, adjustments }`. Feed `url`/`file` to the
|
||||
> image cropper as the next step.
|
||||
|
||||
## Anatomy
|
||||
|
||||
```svelte
|
||||
<ImagePicker.Provider bind:file onChange={…}>
|
||||
{#snippet children({ state, url, filter, fit, prompt, setFile })}
|
||||
{#if state === 'empty'}
|
||||
<!-- compose <FileUpload> → setFile(files[0]) -->
|
||||
{:else}
|
||||
<ImagePicker.Preview>
|
||||
<!-- compose <Image src={url} {fit} style="filter:{filter}" /> -->
|
||||
<ImagePicker.Toolbar>
|
||||
<ImagePicker.Rotate /> <ImagePicker.Remove />
|
||||
</ImagePicker.Toolbar>
|
||||
</ImagePicker.Preview>
|
||||
<!-- compose <ImageAdjustments bind:value={adjustments} /> -->
|
||||
{/if}
|
||||
{/snippet}
|
||||
</ImagePicker.Provider>
|
||||
```
|
||||
|
||||
| Part | Element | Role |
|
||||
| --- | --- | --- |
|
||||
| `Provider` | `div` | `role="group"`, `data-state` (empty/ready), `data-disabled` |
|
||||
| `Preview` | `div` | `data-fit` (cover/contain/fill), `data-rotation` (0/90/180/270) |
|
||||
| `Toolbar` | `div` | controls container (structural) |
|
||||
| `Rotate` | `button` | rotate 90° clockwise |
|
||||
| `Remove` | `button` | remove + reset transforms |
|
||||
|
||||
## Sema events
|
||||
|
||||
| Event | Family · verb | Target | Intent | When |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `commit-select` | commit · select | provider | affirm | an image is selected / replaced |
|
||||
| `commit-remove` | commit · remove | provider | neutral | the image is removed |
|
||||
| `handle-rotate` | handle · rotate | preview | neutral | the preview is rotated 90° |
|
||||
|
||||
`rotate` is canonically a `handle` verb (spatial manipulation, with drag / resize
|
||||
/ scroll) — even though a discrete button triggers it. Pack:
|
||||
`src/uix/sema/components/image-picker.ts`. All `sequence: 'post'`.
|
||||
|
||||
## Props (Provider)
|
||||
|
||||
| Prop | Type | Default | Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| `file` | `File \| null` | `null` | bindable; the selected file |
|
||||
| `fit` | `'cover' \| 'contain' \| 'fill'` | `'cover'` | bindable; fill mode |
|
||||
| `rotation` | `0 \| 90 \| 180 \| 270` | `0` | bindable |
|
||||
| `adjustments` | `ImageAdjustmentValues` | `{}` | bindable; filter values |
|
||||
| `adjustmentKeys` | `ImageAdjustmentKey[]` | core 6 | which sliders the panel shows |
|
||||
| `disabled` | `boolean` | `false` | gates picking + controls |
|
||||
| `onChange` | `(v: ImagePickerValue) => void` | — | full value on any change |
|
||||
| `onSelect` / `onRemove` | `(file) => void` / `() => void` | — | select / remove hooks |
|
||||
| `aria-label` | `string` | "Image picker" | names the group |
|
||||
|
||||
The object-URL is created on `file` change and revoked via the effect cleanup
|
||||
(previous URL on replace, last URL on unmount). Reuses `file-upload` / `image` /
|
||||
`ImageAdjustments` — never reinvents them.
|
||||
@ -0,0 +1,35 @@
|
||||
<script lang="ts">
|
||||
import { readableActive, writableActive } from '$libs/reactive';
|
||||
import { mergeProps } from '../../../props';
|
||||
import { createId } from '$active-uix/id';
|
||||
import { ImagePickerPreviewProvider } from '../image-picker-provider.svelte';
|
||||
import type { ImagePickerPreviewProps } from '../types';
|
||||
|
||||
const uid = $props.id();
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
id = createId(uid, 'image-picker-preview'),
|
||||
children,
|
||||
child,
|
||||
...restProps
|
||||
}: ImagePickerPreviewProps = $props();
|
||||
|
||||
const state = ImagePickerPreviewProvider.create({
|
||||
id: readableActive(() => id),
|
||||
ref: writableActive(
|
||||
() => ref,
|
||||
(v) => (ref = v)
|
||||
)
|
||||
});
|
||||
|
||||
const mergedProps = $derived(mergeProps(restProps, state.props));
|
||||
</script>
|
||||
|
||||
{#if child}
|
||||
{@render child({ props: mergedProps })}
|
||||
{:else}
|
||||
<div {...mergedProps}>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
{/if}
|
||||
@ -0,0 +1,37 @@
|
||||
<script lang="ts">
|
||||
import { readableActive, writableActive } from '$libs/reactive';
|
||||
import { mergeProps } from '../../../props';
|
||||
import { createId } from '$active-uix/id';
|
||||
import { ImagePickerRemoveProvider } from '../image-picker-provider.svelte';
|
||||
import type { ImagePickerRemoveProps } from '../types';
|
||||
|
||||
const uid = $props.id();
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
id = createId(uid, 'image-picker-remove'),
|
||||
children,
|
||||
child,
|
||||
...restProps
|
||||
}: ImagePickerRemoveProps = $props();
|
||||
|
||||
const state = ImagePickerRemoveProvider.create({
|
||||
id: readableActive(() => id),
|
||||
ref: writableActive(
|
||||
() => ref,
|
||||
(v) => (ref = v)
|
||||
)
|
||||
});
|
||||
|
||||
const mergedProps = $derived(mergeProps(restProps, state.props));
|
||||
|
||||
const snippetProps = $derived({ disabled: state.isDisabled });
|
||||
</script>
|
||||
|
||||
{#if child}
|
||||
{@render child({ ...snippetProps, props: mergedProps })}
|
||||
{:else}
|
||||
<button {...mergedProps}>
|
||||
{@render children?.(snippetProps)}
|
||||
</button>
|
||||
{/if}
|
||||
@ -0,0 +1,37 @@
|
||||
<script lang="ts">
|
||||
import { readableActive, writableActive } from '$libs/reactive';
|
||||
import { mergeProps } from '../../../props';
|
||||
import { createId } from '$active-uix/id';
|
||||
import { ImagePickerRotateProvider } from '../image-picker-provider.svelte';
|
||||
import type { ImagePickerRotateProps } from '../types';
|
||||
|
||||
const uid = $props.id();
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
id = createId(uid, 'image-picker-rotate'),
|
||||
children,
|
||||
child,
|
||||
...restProps
|
||||
}: ImagePickerRotateProps = $props();
|
||||
|
||||
const state = ImagePickerRotateProvider.create({
|
||||
id: readableActive(() => id),
|
||||
ref: writableActive(
|
||||
() => ref,
|
||||
(v) => (ref = v)
|
||||
)
|
||||
});
|
||||
|
||||
const mergedProps = $derived(mergeProps(restProps, state.props));
|
||||
|
||||
const snippetProps = $derived({ disabled: state.isDisabled });
|
||||
</script>
|
||||
|
||||
{#if child}
|
||||
{@render child({ ...snippetProps, props: mergedProps })}
|
||||
{:else}
|
||||
<button {...mergedProps}>
|
||||
{@render children?.(snippetProps)}
|
||||
</button>
|
||||
{/if}
|
||||
@ -0,0 +1,35 @@
|
||||
<script lang="ts">
|
||||
import { readableActive, writableActive } from '$libs/reactive';
|
||||
import { mergeProps } from '../../../props';
|
||||
import { createId } from '$active-uix/id';
|
||||
import { ImagePickerToolbarProvider } from '../image-picker-provider.svelte';
|
||||
import type { ImagePickerToolbarProps } from '../types';
|
||||
|
||||
const uid = $props.id();
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
id = createId(uid, 'image-picker-toolbar'),
|
||||
children,
|
||||
child,
|
||||
...restProps
|
||||
}: ImagePickerToolbarProps = $props();
|
||||
|
||||
const state = ImagePickerToolbarProvider.create({
|
||||
id: readableActive(() => id),
|
||||
ref: writableActive(
|
||||
() => ref,
|
||||
(v) => (ref = v)
|
||||
)
|
||||
});
|
||||
|
||||
const mergedProps = $derived(mergeProps(restProps, state.props));
|
||||
</script>
|
||||
|
||||
{#if child}
|
||||
{@render child({ props: mergedProps })}
|
||||
{:else}
|
||||
<div {...mergedProps}>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
{/if}
|
||||
@ -0,0 +1,67 @@
|
||||
<script lang="ts">
|
||||
import { readableActive, writableActive } from '$libs/reactive';
|
||||
import { mergeProps } from '../../../props';
|
||||
import { createId } from '$active-uix/id';
|
||||
import { ImagePickerProvider } from '../image-picker-provider.svelte';
|
||||
import type { ImagePickerProps } from '../types';
|
||||
|
||||
const uid = $props.id();
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
id = createId(uid, 'image-picker'),
|
||||
file = $bindable(null),
|
||||
fit = $bindable('cover'),
|
||||
rotation = $bindable(0),
|
||||
adjustments = $bindable({}),
|
||||
adjustmentKeys,
|
||||
disabled = false,
|
||||
onChange,
|
||||
onSelect,
|
||||
onRemove,
|
||||
'aria-label': ariaLabel,
|
||||
children,
|
||||
child,
|
||||
...restProps
|
||||
}: ImagePickerProps = $props();
|
||||
|
||||
const state = ImagePickerProvider.create({
|
||||
id: readableActive(() => id),
|
||||
ref: writableActive(
|
||||
() => ref,
|
||||
(v) => (ref = v)
|
||||
),
|
||||
file: writableActive(
|
||||
() => file,
|
||||
(v) => (file = v)
|
||||
),
|
||||
fit: writableActive(
|
||||
() => fit,
|
||||
(v) => (fit = v)
|
||||
),
|
||||
rotation: writableActive(
|
||||
() => rotation,
|
||||
(v) => (rotation = v)
|
||||
),
|
||||
adjustments: writableActive(
|
||||
() => adjustments,
|
||||
(v) => (adjustments = v)
|
||||
),
|
||||
adjustmentKeys: readableActive(() => adjustmentKeys),
|
||||
disabled: readableActive(() => disabled),
|
||||
onChange: readableActive(() => onChange),
|
||||
onSelect: readableActive(() => onSelect),
|
||||
onRemove: readableActive(() => onRemove),
|
||||
ariaLabel: readableActive(() => ariaLabel ?? undefined)
|
||||
});
|
||||
|
||||
const mergedProps = $derived(mergeProps(restProps, state.props));
|
||||
</script>
|
||||
|
||||
{#if child}
|
||||
{@render child({ ...state.snippetProps, props: mergedProps })}
|
||||
{:else}
|
||||
<div {...mergedProps}>
|
||||
{@render children?.(state.snippetProps)}
|
||||
</div>
|
||||
{/if}
|
||||
@ -0,0 +1,16 @@
|
||||
export { default as Provider } from './components/image-picker.svelte';
|
||||
export { default as Preview } from './components/image-picker-preview.svelte';
|
||||
export { default as Toolbar } from './components/image-picker-toolbar.svelte';
|
||||
export { default as Rotate } from './components/image-picker-rotate.svelte';
|
||||
export { default as Remove } from './components/image-picker-remove.svelte';
|
||||
|
||||
export type {
|
||||
ImagePickerFit,
|
||||
ImagePickerRotation,
|
||||
ImagePickerValue,
|
||||
ImagePickerProps as ProviderProps,
|
||||
ImagePickerPreviewProps as PreviewProps,
|
||||
ImagePickerToolbarProps as ToolbarProps,
|
||||
ImagePickerRotateProps as RotateProps,
|
||||
ImagePickerRemoveProps as RemoveProps
|
||||
} from './types';
|
||||
@ -0,0 +1,178 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { tick } from 'svelte';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { createActiveDom } from '$adom';
|
||||
import { state } from '$libs/reactive';
|
||||
import type { Morfo } from '$uix/morfo';
|
||||
import { Soma } from '$soma/core/soma.svelte';
|
||||
import { createSomaRuntime, type SomaRuntimeSources } from '$soma/runtime.svelte';
|
||||
|
||||
import { ImagePickerProvider } from './image-picker-provider.svelte';
|
||||
import type { ImagePickerFit, ImagePickerRotation, ImagePickerValue } from './types';
|
||||
import type { ImageAdjustmentValues } from '../image-adjustments';
|
||||
|
||||
function withEffectRoot<T>(fn: () => T): { result: T; cleanup: () => void } {
|
||||
let result!: T;
|
||||
const cleanup = $effect.root(() => {
|
||||
result = fn();
|
||||
});
|
||||
return { result, cleanup };
|
||||
}
|
||||
|
||||
async function flush() {
|
||||
await Promise.resolve();
|
||||
await tick();
|
||||
}
|
||||
|
||||
function installSomaHarness() {
|
||||
const dom = createActiveDom();
|
||||
const soma = {
|
||||
dom,
|
||||
langs: { ts: (ref: string) => ref },
|
||||
runtime: (morfo: Morfo, sources: Omit<SomaRuntimeSources, 'dom' | 'eventEngine'>) =>
|
||||
createSomaRuntime(morfo, { dom, translate: (key) => key, ...sources })
|
||||
} as unknown as Soma;
|
||||
|
||||
vi.spyOn(Soma, 'require').mockReturnValue(soma);
|
||||
vi.spyOn(ImagePickerProvider.ctx, 'set').mockImplementation((value) => value);
|
||||
return { dom };
|
||||
}
|
||||
|
||||
function pickerOpts() {
|
||||
return {
|
||||
id: state('ip-root'),
|
||||
ref: state<HTMLElement | null>(document.createElement('div')),
|
||||
file: state<File | null>(null),
|
||||
fit: state<ImagePickerFit>('cover'),
|
||||
rotation: state<ImagePickerRotation>(0),
|
||||
adjustments: state<ImageAdjustmentValues>({}),
|
||||
adjustmentKeys: state<undefined>(undefined),
|
||||
disabled: state(false),
|
||||
onChange: state<((v: ImagePickerValue) => void) | undefined>(undefined),
|
||||
onSelect: state<((f: File) => void) | undefined>(undefined),
|
||||
onRemove: state<(() => void) | undefined>(undefined),
|
||||
ariaLabel: state<string | undefined>(undefined)
|
||||
};
|
||||
}
|
||||
|
||||
const png = () => new File(['x'], 'photo.png', { type: 'image/png' });
|
||||
|
||||
describe('ImagePickerProvider', () => {
|
||||
beforeEach(() => {
|
||||
// jsdom has no object-URL support — stub it.
|
||||
(URL as unknown as { createObjectURL: (f: File) => string }).createObjectURL = () =>
|
||||
'blob:mock-url';
|
||||
(URL as unknown as { revokeObjectURL: (u: string) => void }).revokeObjectURL = () => {};
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
it('starts empty and reports ready once a file is set', async () => {
|
||||
installSomaHarness();
|
||||
const opts = pickerOpts();
|
||||
const { result: p, cleanup } = withEffectRoot(() => ImagePickerProvider.create(opts));
|
||||
|
||||
expect(p.state).toBe('empty');
|
||||
expect(p.value.url).toBeNull();
|
||||
|
||||
p.setFile(png());
|
||||
await flush();
|
||||
expect(p.state).toBe('ready');
|
||||
expect(opts.file.current?.name).toBe('photo.png');
|
||||
expect(p.url).toBe('blob:mock-url');
|
||||
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('setFile fires onSelect + onChange and triggers commit-select', async () => {
|
||||
installSomaHarness();
|
||||
const opts = pickerOpts();
|
||||
const onSelect = vi.fn();
|
||||
const onChange = vi.fn();
|
||||
opts.onSelect.current = onSelect;
|
||||
opts.onChange.current = onChange;
|
||||
const { result: p, cleanup } = withEffectRoot(() => ImagePickerProvider.create(opts));
|
||||
|
||||
const f = png();
|
||||
p.setFile(f);
|
||||
await flush();
|
||||
expect(onSelect).toHaveBeenCalledWith(f);
|
||||
expect(onChange).toHaveBeenCalled();
|
||||
expect(onChange.mock.lastCall?.[0]).toMatchObject({ file: f, fit: 'cover', rotation: 0 });
|
||||
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('rotate cycles 90° clockwise and wraps at 360', async () => {
|
||||
installSomaHarness();
|
||||
const opts = pickerOpts();
|
||||
const { result: p, cleanup } = withEffectRoot(() => ImagePickerProvider.create(opts));
|
||||
|
||||
p.setFile(png());
|
||||
await flush();
|
||||
p.rotate();
|
||||
expect(opts.rotation.current).toBe(90);
|
||||
p.rotate();
|
||||
p.rotate();
|
||||
expect(opts.rotation.current).toBe(270);
|
||||
p.rotate();
|
||||
expect(opts.rotation.current).toBe(0);
|
||||
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('remove clears the file + resets transforms', async () => {
|
||||
installSomaHarness();
|
||||
const opts = pickerOpts();
|
||||
const onRemove = vi.fn();
|
||||
opts.onRemove.current = onRemove;
|
||||
const { result: p, cleanup } = withEffectRoot(() => ImagePickerProvider.create(opts));
|
||||
|
||||
p.setFile(png());
|
||||
await flush();
|
||||
p.rotate();
|
||||
opts.adjustments.current = { brightness: 30 };
|
||||
p.remove();
|
||||
await flush();
|
||||
|
||||
expect(opts.file.current).toBeNull();
|
||||
expect(opts.rotation.current).toBe(0);
|
||||
expect(opts.adjustments.current).toEqual({});
|
||||
expect(p.state).toBe('empty');
|
||||
expect(onRemove).toHaveBeenCalled();
|
||||
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('composes the CSS filter from the adjustment values', async () => {
|
||||
installSomaHarness();
|
||||
const opts = pickerOpts();
|
||||
const { result: p, cleanup } = withEffectRoot(() => ImagePickerProvider.create(opts));
|
||||
|
||||
expect(p.filter).toBe('none');
|
||||
opts.adjustments.current = { brightness: 20, blur: 4 };
|
||||
// blur is in the default set; brightness too → both contribute.
|
||||
expect(p.filter).toContain('brightness(1.2)');
|
||||
expect(p.filter).toContain('blur(4px)');
|
||||
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('disabled blocks setFile / rotate / remove', async () => {
|
||||
installSomaHarness();
|
||||
const opts = pickerOpts();
|
||||
opts.disabled.current = true;
|
||||
const { result: p, cleanup } = withEffectRoot(() => ImagePickerProvider.create(opts));
|
||||
|
||||
p.setFile(png());
|
||||
await flush();
|
||||
expect(opts.file.current).toBeNull();
|
||||
expect(p.state).toBe('empty');
|
||||
|
||||
cleanup();
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,329 @@
|
||||
import { context, type WithRefOpts } from '../../provider';
|
||||
import { type Active, type ActiveProps, type StateProps } from '$libs/reactive';
|
||||
import type { OnChangeFn } from '../../types';
|
||||
|
||||
import { imagePickerMorfo } from '../../../morfo/components/image-picker';
|
||||
import { Soma } from '../../core/soma.svelte';
|
||||
import type { SomaRuntime, SomaRuntimePart } from '../../runtime.svelte';
|
||||
import {
|
||||
computeImageFilter,
|
||||
DEFAULT_IMAGE_ADJUSTMENTS,
|
||||
type ImageAdjustmentKey,
|
||||
type ImageAdjustmentValues
|
||||
} from '../image-adjustments';
|
||||
import { IMAGE_PICKER_LANGS } from './langs';
|
||||
import type { ImagePickerFit, ImagePickerRotation, ImagePickerValue } from './types';
|
||||
|
||||
// ── Root provider ────────────────────────────────────────────────────────────
|
||||
|
||||
interface ImagePickerOpts
|
||||
extends WithRefOpts,
|
||||
StateProps<{
|
||||
file: File | null;
|
||||
fit: ImagePickerFit;
|
||||
rotation: ImagePickerRotation;
|
||||
adjustments: ImageAdjustmentValues;
|
||||
}>,
|
||||
ActiveProps<{
|
||||
adjustmentKeys: ImageAdjustmentKey[] | undefined;
|
||||
disabled: boolean;
|
||||
onChange: OnChangeFn<ImagePickerValue> | undefined;
|
||||
onSelect: OnChangeFn<File> | undefined;
|
||||
onRemove: (() => void) | undefined;
|
||||
}> {
|
||||
ariaLabel?: Active<string | undefined>;
|
||||
}
|
||||
|
||||
export class ImagePickerProvider {
|
||||
readonly opts: ImagePickerOpts;
|
||||
readonly runtimePart: SomaRuntimePart;
|
||||
readonly soma: Soma;
|
||||
readonly runtime: SomaRuntime;
|
||||
|
||||
/** Object-URL for the selected file. Lifecycle-managed by the effect below. */
|
||||
url = $state<string | null>(null);
|
||||
|
||||
static readonly ctx = context<ImagePickerProvider>('ImagePicker');
|
||||
static get(): ImagePickerProvider | undefined {
|
||||
return this.ctx.getOr(undefined) as ImagePickerProvider | undefined;
|
||||
}
|
||||
static require(): ImagePickerProvider {
|
||||
return this.ctx.get();
|
||||
}
|
||||
static create(opts: ImagePickerOpts) {
|
||||
return new ImagePickerProvider(opts);
|
||||
}
|
||||
|
||||
private constructor(opts: ImagePickerOpts) {
|
||||
this.opts = opts;
|
||||
this.soma = Soma.require();
|
||||
this.runtime = this.soma.runtime(imagePickerMorfo, {
|
||||
props: {
|
||||
state: () => this.state,
|
||||
disabled: () => this.opts.disabled.current,
|
||||
ariaLabel: () => this.resolvedAriaLabel
|
||||
},
|
||||
events: {
|
||||
'commit-select': () => {},
|
||||
'commit-remove': () => {},
|
||||
'handle-rotate': () => {}
|
||||
}
|
||||
});
|
||||
this.runtimePart = this.runtime.part('provider', {
|
||||
id: opts.id,
|
||||
ref: opts.ref,
|
||||
owner: this,
|
||||
context: ImagePickerProvider.ctx,
|
||||
syncAttrs: true
|
||||
});
|
||||
|
||||
// Object-URL lifecycle: create on file change, revoke the previous one
|
||||
// (and the last one on unmount) via the effect's cleanup.
|
||||
$effect(() => {
|
||||
const file = this.opts.file.current;
|
||||
if (!file || typeof URL === 'undefined' || !URL.createObjectURL) {
|
||||
this.url = null;
|
||||
return;
|
||||
}
|
||||
const u = URL.createObjectURL(file);
|
||||
this.url = u;
|
||||
return () => {
|
||||
try {
|
||||
URL.revokeObjectURL(u);
|
||||
} catch {
|
||||
/* noop — already revoked / unsupported */
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
readonly state = $derived.by<'empty' | 'ready'>(() =>
|
||||
this.opts.file.current ? 'ready' : 'empty'
|
||||
);
|
||||
|
||||
readonly resolvedAriaLabel = $derived.by(
|
||||
() => this.opts.ariaLabel?.current || this.soma.langs.ts(IMAGE_PICKER_LANGS.LABEL)
|
||||
);
|
||||
|
||||
readonly resolvedPrompt = $derived.by(() => this.soma.langs.ts(IMAGE_PICKER_LANGS.PROMPT));
|
||||
|
||||
readonly adjustmentOrder = $derived.by<readonly ImageAdjustmentKey[]>(
|
||||
() => this.opts.adjustmentKeys.current ?? DEFAULT_IMAGE_ADJUSTMENTS
|
||||
);
|
||||
|
||||
/** Composed CSS `filter` from the current adjustment values. */
|
||||
readonly filter = $derived.by(() =>
|
||||
computeImageFilter(this.opts.adjustments.current, this.adjustmentOrder)
|
||||
);
|
||||
|
||||
/** The full picker value — the onChange payload + cropper hand-off. */
|
||||
get value(): ImagePickerValue {
|
||||
return {
|
||||
file: this.opts.file.current,
|
||||
url: this.url,
|
||||
fit: this.opts.fit.current,
|
||||
rotation: this.opts.rotation.current,
|
||||
filter: this.filter,
|
||||
adjustments: this.opts.adjustments.current
|
||||
};
|
||||
}
|
||||
|
||||
private emitChange(): void {
|
||||
this.opts.onChange.current?.(this.value);
|
||||
}
|
||||
|
||||
/** Select (or replace) the image. Passing `null` removes it. */
|
||||
setFile(file: File | null): void {
|
||||
if (this.opts.disabled.current) return;
|
||||
if (!file) {
|
||||
this.remove();
|
||||
return;
|
||||
}
|
||||
this.opts.file.current = file;
|
||||
this.opts.onSelect.current?.(file);
|
||||
this.emitChange();
|
||||
void this.runtime.trigger('commit-select');
|
||||
}
|
||||
|
||||
/** Remove the image and reset its transforms. */
|
||||
remove(): void {
|
||||
if (this.opts.disabled.current) return;
|
||||
if (!this.opts.file.current) return;
|
||||
this.opts.file.current = null;
|
||||
this.opts.rotation.current = 0;
|
||||
this.opts.adjustments.current = {};
|
||||
this.opts.onRemove.current?.();
|
||||
this.emitChange();
|
||||
void this.runtime.trigger('commit-remove');
|
||||
}
|
||||
|
||||
/** Rotate the preview 90° clockwise. */
|
||||
rotate(): void {
|
||||
if (this.opts.disabled.current || !this.opts.file.current) return;
|
||||
this.opts.rotation.current = (((this.opts.rotation.current + 90) % 360) as ImagePickerRotation);
|
||||
this.emitChange();
|
||||
void this.runtime.trigger('handle-rotate');
|
||||
}
|
||||
|
||||
readonly isDisabled = $derived.by(() => this.opts.disabled.current);
|
||||
|
||||
readonly snippetProps = $derived.by(() => ({
|
||||
state: this.state,
|
||||
url: this.url,
|
||||
filter: this.filter,
|
||||
fit: this.opts.fit.current,
|
||||
rotation: this.opts.rotation.current,
|
||||
disabled: this.opts.disabled.current,
|
||||
prompt: this.resolvedPrompt,
|
||||
setFile: (file: File | null) => this.setFile(file),
|
||||
remove: () => this.remove(),
|
||||
rotate: () => this.rotate()
|
||||
}));
|
||||
|
||||
readonly props = $derived.by(() => ({ ...this.runtimePart.props }));
|
||||
}
|
||||
|
||||
// ── Preview ────────────────────────────────────────────────────────────────
|
||||
|
||||
interface ImagePickerPreviewOpts extends WithRefOpts {}
|
||||
|
||||
export class ImagePickerPreviewProvider {
|
||||
readonly opts: ImagePickerPreviewOpts;
|
||||
readonly runtimePart: SomaRuntimePart;
|
||||
readonly provider: ImagePickerProvider;
|
||||
|
||||
static create(opts: ImagePickerPreviewOpts) {
|
||||
return new ImagePickerPreviewProvider(opts);
|
||||
}
|
||||
|
||||
private constructor(opts: ImagePickerPreviewOpts) {
|
||||
this.opts = opts;
|
||||
this.provider = ImagePickerProvider.require();
|
||||
this.runtimePart = this.provider.runtime.part('preview', {
|
||||
id: opts.id,
|
||||
ref: opts.ref,
|
||||
owner: this,
|
||||
props: {
|
||||
fit: () => this.provider.opts.fit.current,
|
||||
// data-rotation values are strings ('0' / '90' / …) — stringify.
|
||||
rotation: () => String(this.provider.opts.rotation.current)
|
||||
},
|
||||
syncAttrs: true
|
||||
});
|
||||
}
|
||||
|
||||
readonly props = $derived.by(() => ({ ...this.runtimePart.props }));
|
||||
}
|
||||
|
||||
// ── Toolbar ────────────────────────────────────────────────────────────────
|
||||
|
||||
interface ImagePickerToolbarOpts extends WithRefOpts {}
|
||||
|
||||
export class ImagePickerToolbarProvider {
|
||||
readonly opts: ImagePickerToolbarOpts;
|
||||
readonly runtimePart: SomaRuntimePart;
|
||||
readonly provider: ImagePickerProvider;
|
||||
|
||||
static create(opts: ImagePickerToolbarOpts) {
|
||||
return new ImagePickerToolbarProvider(opts);
|
||||
}
|
||||
|
||||
private constructor(opts: ImagePickerToolbarOpts) {
|
||||
this.opts = opts;
|
||||
this.provider = ImagePickerProvider.require();
|
||||
this.runtimePart = this.provider.runtime.part('toolbar', {
|
||||
id: opts.id,
|
||||
ref: opts.ref,
|
||||
owner: this,
|
||||
syncAttrs: true
|
||||
});
|
||||
}
|
||||
|
||||
readonly props = $derived.by(() => ({ ...this.runtimePart.props }));
|
||||
}
|
||||
|
||||
// ── Rotate ─────────────────────────────────────────────────────────────────
|
||||
|
||||
interface ImagePickerRotateOpts extends WithRefOpts {}
|
||||
|
||||
export class ImagePickerRotateProvider {
|
||||
readonly opts: ImagePickerRotateOpts;
|
||||
readonly runtimePart: SomaRuntimePart;
|
||||
readonly provider: ImagePickerProvider;
|
||||
|
||||
static create(opts: ImagePickerRotateOpts) {
|
||||
return new ImagePickerRotateProvider(opts);
|
||||
}
|
||||
|
||||
private constructor(opts: ImagePickerRotateOpts) {
|
||||
this.opts = opts;
|
||||
this.provider = ImagePickerProvider.require();
|
||||
this.runtimePart = this.provider.runtime.part('rotate', {
|
||||
id: opts.id,
|
||||
ref: opts.ref,
|
||||
owner: this,
|
||||
syncAttrs: true
|
||||
});
|
||||
}
|
||||
|
||||
readonly isDisabled = $derived.by(
|
||||
() => this.provider.opts.disabled.current || !this.provider.opts.file.current
|
||||
);
|
||||
|
||||
readonly onclick = () => {
|
||||
if (this.isDisabled) return;
|
||||
this.provider.rotate();
|
||||
};
|
||||
|
||||
readonly label = $derived.by(() => this.provider.soma.langs.ts(IMAGE_PICKER_LANGS.ROTATE));
|
||||
|
||||
readonly props = $derived.by(() => ({
|
||||
...this.runtimePart.props,
|
||||
disabled: this.isDisabled || undefined,
|
||||
'aria-label': this.label,
|
||||
onclick: this.onclick
|
||||
}));
|
||||
}
|
||||
|
||||
// ── Remove ─────────────────────────────────────────────────────────────────
|
||||
|
||||
interface ImagePickerRemoveOpts extends WithRefOpts {}
|
||||
|
||||
export class ImagePickerRemoveProvider {
|
||||
readonly opts: ImagePickerRemoveOpts;
|
||||
readonly runtimePart: SomaRuntimePart;
|
||||
readonly provider: ImagePickerProvider;
|
||||
|
||||
static create(opts: ImagePickerRemoveOpts) {
|
||||
return new ImagePickerRemoveProvider(opts);
|
||||
}
|
||||
|
||||
private constructor(opts: ImagePickerRemoveOpts) {
|
||||
this.opts = opts;
|
||||
this.provider = ImagePickerProvider.require();
|
||||
this.runtimePart = this.provider.runtime.part('remove', {
|
||||
id: opts.id,
|
||||
ref: opts.ref,
|
||||
owner: this,
|
||||
syncAttrs: true
|
||||
});
|
||||
}
|
||||
|
||||
readonly isDisabled = $derived.by(
|
||||
() => this.provider.opts.disabled.current || !this.provider.opts.file.current
|
||||
);
|
||||
|
||||
readonly onclick = () => {
|
||||
if (this.isDisabled) return;
|
||||
this.provider.remove();
|
||||
};
|
||||
|
||||
readonly label = $derived.by(() => this.provider.soma.langs.ts(IMAGE_PICKER_LANGS.REMOVE));
|
||||
|
||||
readonly props = $derived.by(() => ({
|
||||
...this.runtimePart.props,
|
||||
disabled: this.isDisabled || undefined,
|
||||
'aria-label': this.label,
|
||||
onclick: this.onclick
|
||||
}));
|
||||
}
|
||||
@ -0,0 +1 @@
|
||||
export * from './exports';
|
||||
@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Lang refs for ImagePicker. Resolved via `soma.langs.ts(ref)`. Catalog leaves
|
||||
* live in `src/uix/langs/components/image-picker.ts`.
|
||||
*/
|
||||
export const IMAGE_PICKER_LANGS = {
|
||||
LABEL: '#?components.image-picker.label|Image picker',
|
||||
PROMPT: '#?components.image-picker.prompt|Drop an image or click to browse',
|
||||
REMOVE: '#?components.image-picker.remove|Remove image',
|
||||
ROTATE: '#?components.image-picker.rotate|Rotate 90°'
|
||||
} as const;
|
||||
@ -0,0 +1,87 @@
|
||||
import type { WithChild, Without, OnChangeFn, PrimitiveDivAttributes, PrimitiveButtonAttributes } from '../../types';
|
||||
import type { ImageAdjustmentKey, ImageAdjustmentValues } from '../image-adjustments';
|
||||
|
||||
/** Fill mode for the preview — maps to the composed `<Image>` `fit`. */
|
||||
export type ImagePickerFit = 'cover' | 'contain' | 'fill';
|
||||
|
||||
/** Rotation in degrees, clockwise, 90° steps. */
|
||||
export type ImagePickerRotation = 0 | 90 | 180 | 270;
|
||||
|
||||
/** The full picker output — cropper-ready (the `file`/`url` feed the cropper). */
|
||||
export interface ImagePickerValue {
|
||||
/** The selected file, or null when empty. */
|
||||
file: File | null;
|
||||
/** Object-URL for the selected file, or null. Lifecycle-managed by the provider. */
|
||||
url: string | null;
|
||||
/** Current fill mode. */
|
||||
fit: ImagePickerFit;
|
||||
/** Current rotation (deg, clockwise). */
|
||||
rotation: ImagePickerRotation;
|
||||
/** The composed CSS `filter` string from the adjustments ('none' when neutral). */
|
||||
filter: string;
|
||||
/** Raw per-adjustment values. */
|
||||
adjustments: ImageAdjustmentValues;
|
||||
}
|
||||
|
||||
export type ImagePickerProps = WithChild<
|
||||
{
|
||||
/** Unique identifier. Auto-generated if omitted. */
|
||||
id?: string;
|
||||
/** The selected file. Bindable. @default null */
|
||||
file?: File | null;
|
||||
/** Fill mode. Bindable. @default 'cover' */
|
||||
fit?: ImagePickerFit;
|
||||
/** Rotation (deg, clockwise, 90° steps). Bindable. @default 0 */
|
||||
rotation?: ImagePickerRotation;
|
||||
/** Per-adjustment filter values. Bindable. @default {} */
|
||||
adjustments?: ImageAdjustmentValues;
|
||||
/** Which adjustments the panel exposes (passed to ImageAdjustments). */
|
||||
adjustmentKeys?: ImageAdjustmentKey[];
|
||||
/** Disable picking + all controls. @default false */
|
||||
disabled?: boolean;
|
||||
/** Fired with the full picker value whenever anything changes. */
|
||||
onChange?: OnChangeFn<ImagePickerValue>;
|
||||
/** Fired when an image is selected (or replaced). */
|
||||
onSelect?: OnChangeFn<File>;
|
||||
/** Fired when the image is removed. */
|
||||
onRemove?: () => void;
|
||||
},
|
||||
ImagePickerSnippetProps
|
||||
> &
|
||||
Without<PrimitiveDivAttributes, { value?: unknown }>;
|
||||
|
||||
/** Snippet props exposed by the Provider's `children` / `child`. */
|
||||
export type ImagePickerSnippetProps = {
|
||||
/** 'empty' (no image) or 'ready' (image selected). */
|
||||
state: 'empty' | 'ready';
|
||||
/** Object-URL for the selected file, or null. */
|
||||
url: string | null;
|
||||
/** The composed CSS `filter` string. */
|
||||
filter: string;
|
||||
/** Current fill mode. */
|
||||
fit: ImagePickerFit;
|
||||
/** Current rotation. */
|
||||
rotation: ImagePickerRotation;
|
||||
/** Whether the picker is disabled. */
|
||||
disabled: boolean;
|
||||
/** Localized empty-state prompt. */
|
||||
prompt: string;
|
||||
/** Select (or replace) the image. Pass `null` to remove. Bridge for file-upload. */
|
||||
setFile: (file: File | null) => void;
|
||||
/** Remove the image + reset its transforms. */
|
||||
remove: () => void;
|
||||
/** Rotate the preview 90° clockwise. */
|
||||
rotate: () => void;
|
||||
};
|
||||
|
||||
export type ImagePickerPreviewProps = WithChild<{ id?: string }> &
|
||||
Without<PrimitiveDivAttributes, {}>;
|
||||
|
||||
export type ImagePickerToolbarProps = WithChild<{ id?: string }> &
|
||||
Without<PrimitiveDivAttributes, {}>;
|
||||
|
||||
export type ImagePickerRotateProps = WithChild<{ id?: string }, { disabled: boolean }> &
|
||||
Without<PrimitiveButtonAttributes, {}>;
|
||||
|
||||
export type ImagePickerRemoveProps = WithChild<{ id?: string }, { disabled: boolean }> &
|
||||
Without<PrimitiveButtonAttributes, {}>;
|
||||
@ -0,0 +1,562 @@
|
||||
<script lang="ts">
|
||||
import ImagePicker, {
|
||||
type ImagePickerFit,
|
||||
type ImagePickerValue
|
||||
} from '$uix/eidos/components/image-picker';
|
||||
import type { ImageAdjustmentValues } from '$uix/eidos/components/image-adjustments';
|
||||
import { compileMorfo } from '$uix/morfo';
|
||||
import { imagePickerMorfo } from '@/uix/morfo/components/image-picker';
|
||||
import { getActiveUix } from '$active-uix';
|
||||
|
||||
const uix = getActiveUix();
|
||||
|
||||
type Tab = 'live' | 'api' | 'morfo' | 'sema' | 'recipe' | 'a11y';
|
||||
let tab = $state<Tab>('live');
|
||||
|
||||
// ── Live state ────────────────────────────────────────────────────────
|
||||
let file = $state<File | null>(null);
|
||||
let fit = $state<ImagePickerFit>('cover');
|
||||
let rotation = $state<0 | 90 | 180 | 270>(0);
|
||||
let adjustments = $state<ImageAdjustmentValues>({});
|
||||
let size = $state<'sm' | 'md' | 'lg'>('sm');
|
||||
let showAdjustments = $state(true);
|
||||
let disabled = $state(false);
|
||||
let lastValue = $state<ImagePickerValue | null>(null);
|
||||
|
||||
const pickerState = $derived(file ? 'ready' : 'empty');
|
||||
|
||||
// A synthetic sample image (inline SVG → File) so the "ready" state is
|
||||
// demoable without a real upload. Deliberately WIDE (16:9) so it differs from
|
||||
// the 4:3 preview box — that's what makes the `fit` modes visibly distinct:
|
||||
// cover crops the sides, contain letterboxes, fill stretches. The real
|
||||
// dropzone also accepts files.
|
||||
const sampleSvg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 360">
|
||||
<defs><linearGradient id="s" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#4ea8ff"/><stop offset="1" stop-color="#bfe3ff"/></linearGradient></defs>
|
||||
<rect width="640" height="360" fill="url(#s)"/>
|
||||
<circle cx="510" cy="86" r="46" fill="#ffd24a"/>
|
||||
<path d="M0 250 Q160 190 320 240 T640 220 V360 H0 Z" fill="#2f9e57"/>
|
||||
<path d="M0 296 Q190 250 400 286 T640 276 V360 H0 Z" fill="#1f7a42"/>
|
||||
</svg>`;
|
||||
function loadSample() {
|
||||
file = new File([sampleSvg], 'sample.svg', { type: 'image/svg+xml' });
|
||||
}
|
||||
|
||||
type TraceEntry = { event: string; family: string; intent?: string; at: number };
|
||||
let trace = $state<TraceEntry[]>([]);
|
||||
|
||||
let stageRef = $state<HTMLElement | null>(null);
|
||||
$effect(() => {
|
||||
const el = stageRef;
|
||||
if (!el) return;
|
||||
const obs = new MutationObserver((mutations) => {
|
||||
for (const m of mutations) {
|
||||
if (m.attributeName !== 'data-event') continue;
|
||||
const target = m.target as Element;
|
||||
const ev = target.getAttribute('data-event');
|
||||
if (!ev) continue;
|
||||
trace = [
|
||||
{
|
||||
event: ev,
|
||||
family: target.getAttribute('data-event-family') ?? '—',
|
||||
intent: target.getAttribute('data-event-intent') ?? undefined,
|
||||
at: Date.now()
|
||||
},
|
||||
...trace
|
||||
].slice(0, 6);
|
||||
}
|
||||
});
|
||||
obs.observe(el, { attributes: true, subtree: true, attributeFilter: ['data-event'] });
|
||||
return () => obs.disconnect();
|
||||
});
|
||||
|
||||
// ── Compiled morfo ────────────────────────────────────────────────────
|
||||
const compiled = compileMorfo(imagePickerMorfo);
|
||||
const partsList = $derived([...compiled.parts.byKebab.values()]);
|
||||
const events = $derived([...compiled.actions.byName.values()]);
|
||||
|
||||
function fmtTime(at: number): string {
|
||||
const d = new Date(at);
|
||||
return `${String(d.getSeconds()).padStart(2, '0')}.${String(d.getMilliseconds()).padStart(3, '0')}`;
|
||||
}
|
||||
|
||||
const somaSnippet = $derived(
|
||||
[
|
||||
"<script lang='ts'>",
|
||||
" import * as ImagePicker from '$soma/components/image-picker';",
|
||||
" import { FileUpload } from '$uix/eidos/components/file-upload';",
|
||||
" import { Image } from '$uix/eidos/components/image';",
|
||||
" import ImageAdjustments from '$uix/eidos/components/image-adjustments';",
|
||||
' let file = $state(null);',
|
||||
'</' + 'script>',
|
||||
'',
|
||||
'<ImagePicker.Provider bind:file onChange={(v) => save(v)}>',
|
||||
' {#snippet children({ state, url, filter, fit, prompt, setFile })}',
|
||||
" {#if state === 'empty'}",
|
||||
' <FileUpload accept="image/*" multiple={false}',
|
||||
' onFilesChange={(f) => setFile(f[0] ?? null)}>',
|
||||
' <FileUpload.Dropzone><FileUpload.HiddenInput />',
|
||||
' <FileUpload.Trigger>{prompt}</FileUpload.Trigger>',
|
||||
' </FileUpload.Dropzone>',
|
||||
' </FileUpload>',
|
||||
' {:else}',
|
||||
' <ImagePicker.Preview>',
|
||||
' <Image src={url} alt="" {fit} style="filter: {filter}" />',
|
||||
' <ImagePicker.Toolbar>',
|
||||
' <ImagePicker.Rotate>⟳</ImagePicker.Rotate>',
|
||||
' <ImagePicker.Remove>✕</ImagePicker.Remove>',
|
||||
' </ImagePicker.Toolbar>',
|
||||
' </ImagePicker.Preview>',
|
||||
' <ImageAdjustments bind:value={adjustments} />',
|
||||
' {/if}',
|
||||
' {/snippet}',
|
||||
'</ImagePicker.Provider>'
|
||||
].join('\n')
|
||||
);
|
||||
|
||||
const eidosSnippet = $derived(
|
||||
[
|
||||
"<script lang='ts'>",
|
||||
" import ImagePicker from '$uix/eidos/components/image-picker';",
|
||||
' let file = $state(null);',
|
||||
'</' + 'script>',
|
||||
'',
|
||||
'<ImagePicker',
|
||||
' bind:file',
|
||||
' onChange={(v) => save(v)}',
|
||||
fit !== 'cover' && ` fit="${fit}"`,
|
||||
size !== 'sm' && ` size="${size}"`,
|
||||
!showAdjustments && ' showAdjustments={false}',
|
||||
disabled && ' disabled',
|
||||
'/>'
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n')
|
||||
);
|
||||
</script>
|
||||
|
||||
<div data-uix-canvas-inner>
|
||||
<header>
|
||||
<div data-uix-eyebrow>Media · Image picker</div>
|
||||
<h1 data-uix-page-title>ImagePicker</h1>
|
||||
<p data-uix-page-lede>
|
||||
Pick, preview and adjust an image. A pure composition — it wires the framework's
|
||||
<code>FileUpload</code> (drop + click), <code>Image</code> (fit modes),
|
||||
<code>ImageAdjustments</code> (filter sliders) and adds 90° rotation. The soma owns the file,
|
||||
the object-URL lifecycle and the composed CSS <code>filter</code>; its streamed value is
|
||||
cropper-ready. Five parts, three events.
|
||||
</p>
|
||||
<div data-uix-page-meta>
|
||||
<span data-uix-meta-pill><span data-uix-meta-key>parts</span>{compiled.parts.order.length}</span>
|
||||
<span data-uix-meta-pill><span data-uix-meta-key>events</span>{events.length}</span>
|
||||
<span data-uix-meta-pill><span data-uix-meta-key>composes</span>3</span>
|
||||
<span data-uix-meta-pill><span data-uix-meta-key>output</span>File + filter</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Live preview always rendered -->
|
||||
<div data-uix-stage>
|
||||
<div data-uix-stage-area bind:this={stageRef}>
|
||||
<div class="ip-stage">
|
||||
<ImagePicker
|
||||
bind:file
|
||||
bind:fit
|
||||
bind:rotation
|
||||
bind:adjustments
|
||||
{size}
|
||||
{showAdjustments}
|
||||
{disabled}
|
||||
onChange={(v) => (lastValue = v)}
|
||||
aria-label="Photo picker"
|
||||
/>
|
||||
<button data-uix-chip onclick={loadSample} style="align-self: flex-start;">
|
||||
Load sample image
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div data-uix-stage-trace>
|
||||
<span data-uix-stage-trace-key>trace</span>
|
||||
{#if trace.length === 0}
|
||||
<span>select / rotate / remove an image to see events</span>
|
||||
{:else}
|
||||
{#each trace.slice(0, 3) as entry}
|
||||
<span><span data-uix-stage-trace-event>{entry.event}</span> · {entry.family}{entry.intent ? ' · ' + entry.intent : ''}</span>
|
||||
<span style="color: var(--uix-text-faint)">{fmtTime(entry.at)}</span>
|
||||
{/each}
|
||||
{/if}
|
||||
<span style="margin-inline-start: auto;">
|
||||
<span data-uix-stage-trace-key>state</span>
|
||||
{pickerState} · {rotation}°
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div data-uix-tabs role="tablist">
|
||||
<button data-uix-tab data-active={tab === 'live'} onclick={() => (tab = 'live')}>Live</button>
|
||||
<button data-uix-tab data-active={tab === 'api'} onclick={() => (tab = 'api')}>
|
||||
API <span data-uix-tab-count>9</span>
|
||||
</button>
|
||||
<button data-uix-tab data-active={tab === 'morfo'} onclick={() => (tab = 'morfo')}>
|
||||
<span data-uix-layer-badge="morfo">morfo</span>
|
||||
<span data-uix-tab-count>{partsList.length}p · {events.length}e</span>
|
||||
</button>
|
||||
<button data-uix-tab data-active={tab === 'sema'} onclick={() => (tab = 'sema')}>
|
||||
<span data-uix-layer-badge="sema">sema</span>
|
||||
<span data-uix-tab-count>{events.length}</span>
|
||||
</button>
|
||||
<button data-uix-tab data-active={tab === 'recipe'} onclick={() => (tab = 'recipe')}>Recipe</button>
|
||||
<button data-uix-tab data-active={tab === 'a11y'} onclick={() => (tab = 'a11y')}>A11y</button>
|
||||
</div>
|
||||
|
||||
{#if tab === 'live'}
|
||||
<section data-uix-section>
|
||||
<h2 data-uix-section-title>Controls</h2>
|
||||
<p data-uix-section-desc>
|
||||
Props grouped by the architectural layer that owns them: <span data-uix-layer-badge="soma">soma</span>
|
||||
headless behavior, <span data-uix-layer-badge="eidos">eidos</span> composition. Load the
|
||||
sample (or drop a real image), then rotate / adjust / remove. Events live in the
|
||||
<a href="#sema" onclick={(e) => { e.preventDefault(); tab = 'sema'; }}>Sema</a> tab.
|
||||
</p>
|
||||
|
||||
<div data-uix-subsection-head>
|
||||
<span data-uix-layer-badge="soma">soma</span> props · headless behavior
|
||||
</div>
|
||||
<div data-uix-controls>
|
||||
<label data-uix-control>
|
||||
<span data-uix-control-label>fit <span data-uix-control-hint>fill mode</span></span>
|
||||
<span data-uix-chips role="radiogroup">
|
||||
{#each ['cover', 'contain', 'fill'] as const as f}
|
||||
<button data-uix-chip data-active={fit === f} onclick={() => (fit = f)}>{f}</button>
|
||||
{/each}
|
||||
</span>
|
||||
</label>
|
||||
<div data-uix-control>
|
||||
<span data-uix-control-label>rotation <span data-uix-control-hint>via Rotate button</span></span>
|
||||
<code style="font-size: var(--font-size-xs); color: var(--uix-text-muted);">{rotation}°</code>
|
||||
</div>
|
||||
<div data-uix-control>
|
||||
<span data-uix-control-label>file <span data-uix-control-hint>bindable</span></span>
|
||||
<code style="font-size: var(--font-size-xs); color: var(--uix-text-muted);">{file ? file.name : 'null'}</code>
|
||||
</div>
|
||||
<label data-uix-control>
|
||||
<span data-uix-control-label>disabled</span>
|
||||
<span data-uix-switch>
|
||||
<input type="checkbox" bind:checked={disabled} />
|
||||
<span data-uix-switch-label>{disabled ? 'on' : 'off'}</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div data-uix-subsection-head>
|
||||
<span data-uix-layer-badge="eidos">eidos</span> props · composition
|
||||
</div>
|
||||
<div data-uix-controls>
|
||||
<label data-uix-control>
|
||||
<span data-uix-control-label>size <span data-uix-control-hint>adjustments scale</span></span>
|
||||
<span data-uix-chips role="radiogroup">
|
||||
{#each ['sm', 'md', 'lg'] as const as s}
|
||||
<button data-uix-chip data-active={size === s} onclick={() => (size = s)}>{s}</button>
|
||||
{/each}
|
||||
</span>
|
||||
</label>
|
||||
<label data-uix-control>
|
||||
<span data-uix-control-label>showAdjustments</span>
|
||||
<span data-uix-switch>
|
||||
<input type="checkbox" bind:checked={showAdjustments} />
|
||||
<span data-uix-switch-label>{showAdjustments ? 'on' : 'off'}</span>
|
||||
</span>
|
||||
</label>
|
||||
<div data-uix-control>
|
||||
<span data-uix-control-label>filter <span data-uix-control-hint>onChange output</span></span>
|
||||
<code style="font-size: var(--font-size-xs); color: var(--uix-text-muted); max-inline-size: 16rem; overflow: hidden; text-overflow: ellipsis;">{lastValue?.filter ?? 'none'}</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div data-uix-code>
|
||||
<div data-uix-code-head>
|
||||
<span data-uix-layer-badge="soma">soma</span>
|
||||
<span>headless · composes FileUpload + Image + ImageAdjustments</span>
|
||||
<span data-uix-code-lang>svelte</span>
|
||||
</div>
|
||||
<pre><code>{somaSnippet}</code></pre>
|
||||
</div>
|
||||
|
||||
<div data-uix-code style="margin-top: var(--uix-space-3);">
|
||||
<div data-uix-code-head>
|
||||
<span data-uix-layer-badge="eidos">eidos</span>
|
||||
<span>visual · the configured composition</span>
|
||||
<span data-uix-code-lang>svelte</span>
|
||||
</div>
|
||||
<pre><code>{eidosSnippet}</code></pre>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if tab === 'api'}
|
||||
<section data-uix-section>
|
||||
<h2 data-uix-section-title>API reference</h2>
|
||||
<p data-uix-section-desc>All public props. Eidos additions are tagged.</p>
|
||||
|
||||
<div data-uix-subsection-head>Provider</div>
|
||||
<div data-uix-table-wrap>
|
||||
<table data-uix-table>
|
||||
<thead><tr><th>Prop</th><th>Type</th><th>Default</th><th>Description</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td class="name">file <span data-uix-tag data-kind="bindable">bindable</span></td><td class="type">{`File | null`}</td><td class="default">null</td><td>The selected file.</td></tr>
|
||||
<tr><td class="name">fit <span data-uix-tag data-kind="bindable">bindable</span></td><td class="type">{`'cover' | 'contain' | 'fill'`}</td><td class="default">'cover'</td><td>Fill mode → the composed Image.</td></tr>
|
||||
<tr><td class="name">rotation <span data-uix-tag data-kind="bindable">bindable</span></td><td class="type">0 | 90 | 180 | 270</td><td class="default">0</td><td>Clockwise rotation, driven by the Rotate button.</td></tr>
|
||||
<tr><td class="name">adjustments <span data-uix-tag data-kind="bindable">bindable</span></td><td class="type">ImageAdjustmentValues</td><td class="default">{`{}`}</td><td>Filter values → the composed ImageAdjustments.</td></tr>
|
||||
<tr><td class="name">onChange</td><td class="type">{`(v: ImagePickerValue) => void`}</td><td class="default empty">—</td><td>{`{ file, url, fit, rotation, filter, adjustments }`} on any change. Cropper-ready.</td></tr>
|
||||
<tr><td class="name">onSelect / onRemove</td><td class="type">{`(file) => void`} / {`() => void`}</td><td class="default empty">—</td><td>Fire on select / remove.</td></tr>
|
||||
<tr><td class="name">disabled</td><td class="type">boolean</td><td class="default">false</td><td>Disable picking + all controls.</td></tr>
|
||||
<tr><td class="name">accept <span data-uix-tag data-kind="eidos">eidos</span></td><td class="type">string</td><td class="default">'image/*'</td><td>→ FileUpload.</td></tr>
|
||||
<tr><td class="name">maxSize <span data-uix-tag data-kind="eidos">eidos</span></td><td class="type">number</td><td class="default empty">—</td><td>Max bytes → FileUpload.</td></tr>
|
||||
<tr><td class="name">size <span data-uix-tag data-kind="eidos">eidos</span></td><td class="type">{`'sm' | 'md' | 'lg'`}</td><td class="default">'sm'</td><td>Adjustments panel scale.</td></tr>
|
||||
<tr><td class="name">showAdjustments <span data-uix-tag data-kind="eidos">eidos</span></td><td class="type">boolean</td><td class="default">true</td><td>Render the filters panel.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div data-uix-subsection-head>Reference comparison</div>
|
||||
<div data-uix-table-wrap>
|
||||
<table data-uix-table>
|
||||
<thead><tr><th>Capability</th><th>Untitled</th><th>Chakra</th><th>Radix</th><th>MUI</th><th>UIX</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td class="name">Image picker primitive</td><td>✅</td><td>❌</td><td>❌</td><td>❌</td><td>✅</td></tr>
|
||||
<tr><td class="name">File upload base</td><td>✅</td><td>✅</td><td>❌</td><td>❌</td><td>✅ reuse</td></tr>
|
||||
<tr><td class="name">Fill modes + 90° rotation</td><td>✅</td><td>❌</td><td>❌</td><td>❌</td><td>✅</td></tr>
|
||||
<tr><td class="name">Built-in filter sliders</td><td>✅</td><td>❌</td><td>❌</td><td>❌</td><td>✅ reuse</td></tr>
|
||||
<tr><td class="name">Cropper-ready output</td><td>⚠️</td><td>—</td><td>—</td><td>—</td><td>✅ File + url</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p data-uix-section-desc>
|
||||
No mainstream component library ships an image picker as a primitive — it's app code on top
|
||||
of a file input. UIX composes three resolved components and emits a cropper-ready value.
|
||||
</p>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if tab === 'morfo'}
|
||||
<section data-uix-section>
|
||||
<h2 data-uix-section-title>
|
||||
<span data-uix-layer-badge="morfo">morfo</span>
|
||||
· declarative contract
|
||||
</h2>
|
||||
<p data-uix-section-desc>
|
||||
The morfo declares only the picker's OWN orchestration — the empty↔ready state, the
|
||||
<code>fit</code> + <code>rotation</code> transforms, and the select / remove / rotate
|
||||
events. The dropzone, image and sliders carry their own morfos. Source:
|
||||
<code>src/uix/morfo/components/image-picker.ts</code>.
|
||||
</p>
|
||||
|
||||
<div data-uix-table-wrap>
|
||||
<table data-uix-table>
|
||||
<thead><tr><th>Field</th><th>Value</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td class="name">name</td><td class="type">"{imagePickerMorfo.name}"</td></tr>
|
||||
<tr><td class="name">kebab</td><td class="type">"{imagePickerMorfo.kebab}"</td></tr>
|
||||
<tr><td class="name">scope</td><td class="type">[{imagePickerMorfo.scope.map((s) => `"${s}"`).join(', ')}]</td></tr>
|
||||
<tr><td class="name">parts.length</td><td class="default">{imagePickerMorfo.parts.length}</td></tr>
|
||||
<tr><td class="name">events.length</td><td class="default">{imagePickerMorfo.events?.length ?? 0}</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div data-uix-subsection-head>Parts</div>
|
||||
<div data-uix-table-wrap>
|
||||
<table data-uix-table>
|
||||
<thead><tr><th>Part</th><th>Marker</th><th>Element</th><th>Role</th><th>Archetype</th><th>States</th><th>Optional</th></tr></thead>
|
||||
<tbody>
|
||||
{#each partsList as part}
|
||||
<tr>
|
||||
<td class="name">{part.kebab}</td>
|
||||
<td><code data-uix-part-marker>[{part.marker}]</code></td>
|
||||
<td class="type"><{part.defaultElement}></td>
|
||||
<td class="type">{part.role ?? '—'}</td>
|
||||
<td class="default">{part.archetype ?? '—'}</td>
|
||||
<td class="default">{part.states.length ? part.states.join(' | ') : '—'}</td>
|
||||
<td class="default">{part.optional ? 'yes' : 'no'}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{#each imagePickerMorfo.parts as rawPart}
|
||||
{@const partAny = rawPart as unknown as { kebab: string; aria?: ReadonlyArray<{ attr: string; value: { kind: string }; condition?: { when: string; prop?: string }; severity?: string }> }}
|
||||
{@const dataAttrs = compiled.contracts.dataAttrsByPart.get(partAny.kebab) ?? []}
|
||||
{@const ariaAttrs = partAny.aria ?? []}
|
||||
{#if dataAttrs.length || ariaAttrs.length}
|
||||
<div data-uix-subsection-head>{partAny.kebab}</div>
|
||||
{#if dataAttrs.length}
|
||||
<div data-uix-table-wrap>
|
||||
<table data-uix-table>
|
||||
<thead><tr><th>data-attr</th><th>Values</th><th>Source kind</th></tr></thead>
|
||||
<tbody>
|
||||
{#each dataAttrs as attr}
|
||||
<tr>
|
||||
<td class="name">{attr.attr}</td>
|
||||
<td class="type">{attr.values ? attr.values.join(' | ') : '—'}</td>
|
||||
<td class="default">{'value' in attr && attr.value ? (attr as { value: { kind: string } }).value.kind : '—'}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
{#if ariaAttrs.length}
|
||||
<div data-uix-table-wrap style="margin-top: var(--uix-space-2);">
|
||||
<table data-uix-table>
|
||||
<thead><tr><th>aria-attr</th><th>Source kind</th><th>Severity</th></tr></thead>
|
||||
<tbody>
|
||||
{#each ariaAttrs as a}
|
||||
<tr>
|
||||
<td class="name">{a.attr}</td>
|
||||
<td class="type">{a.value.kind}</td>
|
||||
<td class="default">{a.severity ?? 'required'}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
<div data-uix-subsection-head>Events declaration</div>
|
||||
<div data-uix-table-wrap>
|
||||
<table data-uix-table>
|
||||
<thead><tr><th>name</th><th>family</th><th>verb</th><th>sequence</th><th>intent</th><th>target</th></tr></thead>
|
||||
<tbody>
|
||||
{#each events as action}
|
||||
{@const sem = action.semantic}
|
||||
{@const intentDecl = 'intent' in sem ? sem.intent : undefined}
|
||||
<tr>
|
||||
<td class="name">{action.name}</td>
|
||||
<td class="type">{sem.family}</td>
|
||||
<td>{sem.verb ?? '—'}</td>
|
||||
<td>{sem.sequence ?? 'pre'}</td>
|
||||
<td class="default">{typeof intentDecl === 'string' ? intentDecl : '—'}</td>
|
||||
<td>{action.target}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if tab === 'sema'}
|
||||
<section data-uix-section id="sema">
|
||||
<h2 data-uix-section-title>
|
||||
<span data-uix-layer-badge="sema">sema</span>
|
||||
· events + perceptual signature
|
||||
</h2>
|
||||
<p data-uix-section-desc>
|
||||
Three events: <code>commit-select</code> (commit · affirm — the pickup of a chosen image),
|
||||
<code>commit-remove</code> (commit · neutral), <code>handle-rotate</code> (handle · neutral
|
||||
— a spatial nudge, haptic tick). The per-slider feedback is owned by ImageAdjustments.
|
||||
Click <strong>play</strong> to fire on the live picker.
|
||||
</p>
|
||||
<div data-uix-table-wrap>
|
||||
<table data-uix-table>
|
||||
<thead><tr><th>Name</th><th>Family</th><th>Verb</th><th>Sequence</th><th>Intent</th><th>Play</th></tr></thead>
|
||||
<tbody>
|
||||
{#each events as action}
|
||||
{@const intentDecl = 'intent' in action.semantic ? action.semantic.intent : undefined}
|
||||
{@const effectiveIntent = typeof intentDecl === 'string' ? intentDecl : undefined}
|
||||
<tr>
|
||||
<td class="name">{action.name}</td>
|
||||
<td class="type">{action.semantic.family}</td>
|
||||
<td>{action.semantic.verb ?? '—'}</td>
|
||||
<td>{action.semantic.sequence ?? 'pre'}</td>
|
||||
<td class="default">{effectiveIntent ?? '—'}</td>
|
||||
<td>
|
||||
<button
|
||||
data-uix-play
|
||||
onclick={() => {
|
||||
const target = (stageRef?.querySelector(`[data-image-picker-${action.target === 'preview' ? 'preview' : 'provider'}]`) ?? stageRef?.querySelector('[data-image-picker]') ?? stageRef) as HTMLElement | null;
|
||||
if (!target) return;
|
||||
void uix.events?.emit({ name: action.name, family: action.semantic.family, target, ...(effectiveIntent ? { intent: effectiveIntent } : {}) });
|
||||
}}
|
||||
>▶ play</button>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if tab === 'recipe'}
|
||||
<section data-uix-section>
|
||||
<h2 data-uix-section-title>Eidos recipe</h2>
|
||||
<p data-uix-section-desc>
|
||||
Selectors at <code>src/uix/eidos/components/image-picker/image-picker.css</code>. Tokens
|
||||
bare-prefixed (<code>--image-picker-*</code>). The recipe owns only the orchestration chrome
|
||||
— the preview box, the rotation transform, the floating toolbar; the composed components
|
||||
bring their own recipes.
|
||||
</p>
|
||||
<div data-uix-table-wrap>
|
||||
<table data-uix-table>
|
||||
<thead><tr><th>Selector</th><th>Source</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td class="name">[data-image-picker]</td><td><span data-uix-tag data-kind="soma">morfo</span></td></tr>
|
||||
<tr><td class="name">[data-image-picker][data-disabled]</td><td><span data-uix-tag data-kind="soma">morfo</span></td></tr>
|
||||
<tr><td class="name">[data-image-picker-preview]</td><td><span data-uix-tag data-kind="soma">morfo</span></td></tr>
|
||||
<tr><td class="name">[data-image-picker-preview][data-rotation]</td><td><span data-uix-tag data-kind="soma">morfo</span></td></tr>
|
||||
<tr><td class="name">[data-image-picker-toolbar]</td><td><span data-uix-tag data-kind="soma">morfo</span></td></tr>
|
||||
<tr><td class="name">[data-image-picker-rotate] / [data-image-picker-remove]</td><td><span data-uix-tag data-kind="soma">morfo</span></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if tab === 'a11y'}
|
||||
<section data-uix-section>
|
||||
<h2 data-uix-section-title>Accessibility</h2>
|
||||
<p data-uix-section-desc>
|
||||
The picker is a labelled <code>role="group"</code>. The dropzone (FileUpload) and the
|
||||
sliders (ImageAdjustments) carry their own APG-pattern keyboard + ARIA; the toolbar buttons
|
||||
are plain buttons with explicit <code>aria-label</code>s.
|
||||
</p>
|
||||
<div data-uix-subsection-head>Keyboard</div>
|
||||
<div data-uix-table-wrap>
|
||||
<table data-uix-table>
|
||||
<thead><tr><th>Key</th><th>Action</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><span data-uix-kbd>Tab</span></td><td>Move between the dropzone / preview / toolbar / sliders.</td></tr>
|
||||
<tr><td><span data-uix-kbd>Enter</span> / <span data-uix-kbd>Space</span></td><td>Activate the focused control (browse, rotate, remove).</td></tr>
|
||||
<tr><td><span data-uix-kbd>←</span> <span data-uix-kbd>→</span> <span data-uix-kbd>↑</span> <span data-uix-kbd>↓</span></td><td>Adjust the focused filter slider.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div data-uix-subsection-head>ARIA contract</div>
|
||||
<div data-uix-table-wrap>
|
||||
<table data-uix-table>
|
||||
<thead><tr><th>Part</th><th>Attribute</th><th>Value</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td class="name">provider</td><td>role</td><td class="type">"group"</td></tr>
|
||||
<tr><td class="name">provider</td><td>aria-label</td><td class="type">"Image picker" (or prop)</td></tr>
|
||||
<tr><td class="name">provider</td><td>data-state</td><td class="type">"empty" | "ready"</td></tr>
|
||||
<tr><td class="name">rotate</td><td>aria-label</td><td class="type">"Rotate 90°"</td></tr>
|
||||
<tr><td class="name">remove</td><td>aria-label</td><td class="type">"Remove image"</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.ip-stage {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--uix-space-2);
|
||||
inline-size: 100%;
|
||||
max-inline-size: 420px;
|
||||
}
|
||||
</style>
|
||||
Loading…
Reference in new issue