diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 0bf957d39..f53427448 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -346,7 +346,8 @@ "Bash(awk '/data-palabras-panel-card \\\\{/,/^\\\\}/' src/uix/eidos/components/palabras/palabras.css)", "Bash(awk '/data-palabras-panel-foot \\\\{/,/^\\\\}/' src/uix/eidos/components/palabras/palabras.css)", "Bash(awk 'NR>=300 && NR<=345' src/uix/eidos/components/palabras/palabras.css)", - "Bash(awk '/\\\\[data-palabras-panel-card\\\\] \\\\{/{f=1} f{print NR\": \"$0} /^\\\\}/{if\\(f\\){f=0}}' src/uix/eidos/components/palabras/palabras.css)" + "Bash(awk '/\\\\[data-palabras-panel-card\\\\] \\\\{/{f=1} f{print NR\": \"$0} /^\\\\}/{if\\(f\\){f=0}}' src/uix/eidos/components/palabras/palabras.css)", + "mcp__Claude_in_Chrome__get_page_text" ], "additionalDirectories": [ "\\tmp" diff --git a/src/uix/eidos/components/cropper/README.md b/src/uix/eidos/components/cropper/README.md new file mode 100644 index 000000000..204e07930 --- /dev/null +++ b/src/uix/eidos/components/cropper/README.md @@ -0,0 +1,99 @@ +# Cropper (eidos) + +The styled, **configured** image cropper — a movable / resizable selection over the +image, with a darken-outside mask, rule-of-thirds grid and four corner handles. It +composes the framework ``; the soma owns the crop rect, the drag/resize math +and the `` extraction. + +```svelte + + + save(r.blob)} /> + +``` + +## Baseline + +**No Air baseline.** Cropper is a new component (2026-06-10); no `air/` predecessor. +Designed fresh against the references below. + +## Comparativa + +| Capability | Ark image-cropper | react-image-crop | react-easy-crop | Cropper.js | → UIX | +| --- | --- | --- | --- | --- | --- | +| Selection over a fixed image | ✅ | ✅ | — (pan/zoom) | ✅ | ✅ | +| Corner handles + thirds grid | ✅ | ✅ | partial | ✅ | ✅ | +| Aspect lock | ✅ | ✅ | ✅ | ✅ | ✅ | +| Circular crop | — | ✅ | ✅ `cropShape` | partial | ✅ | +| Zoom / pan of the image | — | — | ✅ | ✅ | ✅ (reusable layer) | +| Fixed-size crop (move-only) | — | ✅ (locked) | ✅ | — | ✅ `fixedSize` | +| Blob output (canvas) | headless | you extract | helper | ✅ | ✅ built-in | +| Sound / haptic | ❌ | ❌ | ❌ | ❌ | ✅ sema | + +The Ark image-cropper (the reference the user pointed to — `Root · Viewport · Image +· Selection · Grid · Handle`) is headless and emits the rect; UIX mirrors the +anatomy and adds the canvas Blob + the circular mask + the perceptual layer. + +## Props + +`ProviderProps` (soma) — `src` / `crop` / `aspect` / `shape` / `minSize` / +`maxSize` / `fixedSize` / `minScale` / `maxScale` / `disabled` / `onCropChange` / +`onCropComplete` (see soma README), plus the `cropImage()` method exposed via +`bind:this`. + +- **`fixedSize`** pins the crop and renders **no resize handles** (move-only) — + `shape='round'` + `fixedSize` is an avatar picker. Respects `aspect` (use 1:1 + for a true circle). +- **Zoom** — wheel / pinch zoom centered on the cursor, drag the background to + pan, plus −/slider/+/reset controls. `minScale`/`maxScale` bound it. Backed by + the reusable `soma/layers/zoom-pan` (`createZoomPan`). + +## Decisiones + +- **Composition over reimplementation.** Renders the framework `` for the + preview; never re-declares an image surface. +- **Normalized 0–1 crop rect.** Resolution-independent. The viewport's + aspect-ratio is set to the image's natural aspect (no letterbox) so the rect maps + 1:1 to the image for the canvas extraction. +- **Darken-mask is a box-shadow on the Selection** (`0 0 0 9999px`), clipped by the + viewport's `overflow:hidden`. It follows `border-radius`, so a `round` selection + cuts a circular hole — one mechanism for both shapes. +- **`mask-bg` / `grid-line` are recipe tokens carrying literal rgba** (overlay + tints, not theme colors) — kept out of the component CSS to satisfy the raw-color + contract, and themeable. +- **`cropImage()` via `bind:this`** (not an auto-fired event) — the consumer + decides when to extract, the same shape as `react-image-crop`'s `getCroppedImg`. +- **`handle-drag` / `handle-resize` emit once at gesture start**, not per-frame + (no haptic buzz — the virtual-list doctrine). `handle-zoom` is throttled. +- **Zoom is a reusable layer, not cropper-local.** `soma/layers/zoom-pan` + (`createZoomPan`) owns scale + offset + clamp + cursor-centered zoom math, so an + image-viewer / diagram-pan / map can reuse it. The cropper wires wheel / pan / + controls to it and inverts the transform in `cropImage()`. +- **Two zones (Cropper.js model).** Dragging the **selection** moves the crop area + (it captures the gesture). Dragging the **background image** pans it to set the + focus point (when zoomed). The 8 handles resize. The `` is `draggable=false` + (the browser's native image drag would otherwise cancel the pan — the bug Ark's + zag machine guards the same way). Background shows `grab` when zoomed; the + selection keeps `move`. + +## Recipe tokens + +Public, themeable — `--cropper-*`: `viewport-bg/radius`, `mask-bg`, `grid-line`, +`selection-border(-width)`, `handle-size/bg/border(-width)`. + +`eidos-lint cropper` → invalid 0 (13 morfo-backed selectors). CSS imported from the +wrapper (code-split) — intentionally NOT in `eidos/index.css`. + +## Gaps + +| Gap | Disposition | Notes | +| --- | --- | --- | +| Keyboard move/resize/zoom of the selection | **diferir** | v1 is pointer-driven; arrows-to-move (FloatPanel grab-mode style) is a follow-up. | +| Edge handles (n/e/s/w) | **diferir** | The morfo declares all 8 corners; eidos renders the 4 corners. Edges are a quick add. | +| Aspect exactly at a boundary | **diferir** | Edge clamping may relax the locked ratio at the very edge; drag inward restores it. | +| External-CORS images taint the canvas | **diferir** | Blob URLs (the ImagePicker flow) are fine; cross-origin `` needs CORS headers to export. | +| Baking ImagePicker filter/rotation into the crop | **diferir** | v1 crops the source `src`; applying the upstream filter/rotation in the canvas is a pipeline follow-up. | diff --git a/src/uix/eidos/components/cropper/cropper.css b/src/uix/eidos/components/cropper/cropper.css new file mode 100644 index 000000000..7a82558c5 --- /dev/null +++ b/src/uix/eidos/components/cropper/cropper.css @@ -0,0 +1,188 @@ +/** + * Cropper recipe — the crop UI chrome: framed viewport, darken-outside mask, + * rule-of-thirds grid, and corner handles. The crop geometry is inline style on + * the Selection (set by soma); the image is the composed ``. + * + * Tokens: `--cropper-*` (public, themeable) from `recipes/base.ts`. + */ + +[data-cropper] { + display: block; + inline-size: 100%; +} + +[data-cropper][data-disabled] { + opacity: 0.6; + pointer-events: none; +} + +[data-cropper-viewport] { + position: relative; + overflow: hidden; + inline-size: 100%; + /* aspect-ratio is set inline from the image's natural size. Fallback 1:1. */ + aspect-ratio: 1; + border-radius: var(--cropper-viewport-radius); + background: var(--cropper-viewport-bg); + /* The image fills the viewport; no scrolling while dragging the selection. */ + touch-action: none; + user-select: none; +} + +.eidos-cropper-image { + position: absolute; + inset: 0; + inline-size: 100%; + block-size: 100%; +} + +/* Stop the browser's native image drag-and-drop, which would cancel the pan. */ +[data-cropper-viewport] img { + -webkit-user-drag: none; + user-select: none; +} + +/* The crop rectangle. The huge box-shadow darkens everything outside it + (clipped by the viewport's overflow:hidden) — it follows border-radius, so a + round selection cuts a circular hole. */ +[data-cropper-selection] { + position: absolute; + box-sizing: border-box; + border: var(--cropper-selection-border-width) solid var(--cropper-selection-border); + box-shadow: 0 0 0 9999px var(--cropper-mask-bg); + cursor: move; + touch-action: none; +} + +[data-cropper-selection][data-shape='round'] { + border-radius: 50%; +} + +[data-cropper-selection][data-dragging] { + cursor: grabbing; +} + +/* When zoomed, the background image shows a `grab` cursor (drag it to pan / set + the focus). The selection keeps `move` — it repositions the crop area. */ +.eidos-cropper-viewport.is-zoomed .eidos-cropper-image { + cursor: grab; +} +.eidos-cropper-viewport.is-zoomed .eidos-cropper-image:active { + cursor: grabbing; +} + +/* Rule-of-thirds — four thin lines at 1/3 and 2/3 on each axis. */ +[data-cropper-grid] { + position: absolute; + inset: 0; + pointer-events: none; + background: + linear-gradient(var(--cropper-grid-line), var(--cropper-grid-line)) 33.33% 0 / 1px 100% no-repeat, + linear-gradient(var(--cropper-grid-line), var(--cropper-grid-line)) 66.66% 0 / 1px 100% no-repeat, + linear-gradient(var(--cropper-grid-line), var(--cropper-grid-line)) 0 33.33% / 100% 1px no-repeat, + linear-gradient(var(--cropper-grid-line), var(--cropper-grid-line)) 0 66.66% / 100% 1px no-repeat; +} + +/* The thirds grid reads as noise inside a circular crop — hide it there. */ +[data-cropper-selection][data-shape='round'] [data-cropper-grid] { + display: none; +} + +[data-cropper-handle] { + position: absolute; + box-sizing: border-box; + inline-size: var(--cropper-handle-size); + block-size: var(--cropper-handle-size); + border: var(--cropper-handle-border-width) solid var(--cropper-handle-border); + border-radius: 50%; + background: var(--cropper-handle-bg); + touch-action: none; +} + +[data-cropper-handle][data-corner='nw'] { + inset-block-start: 0; + inset-inline-start: 0; + transform: translate(-50%, -50%); + cursor: nwse-resize; +} +[data-cropper-handle][data-corner='ne'] { + inset-block-start: 0; + inset-inline-end: 0; + transform: translate(50%, -50%); + cursor: nesw-resize; +} +[data-cropper-handle][data-corner='se'] { + inset-block-end: 0; + inset-inline-end: 0; + transform: translate(50%, 50%); + cursor: nwse-resize; +} +[data-cropper-handle][data-corner='sw'] { + inset-block-end: 0; + inset-inline-start: 0; + transform: translate(-50%, 50%); + cursor: nesw-resize; +} + +/* Edge handles — centered on each side. */ +[data-cropper-handle][data-corner='n'] { + inset-block-start: 0; + inset-inline-start: 50%; + transform: translate(-50%, -50%); + cursor: ns-resize; +} +[data-cropper-handle][data-corner='s'] { + inset-block-end: 0; + inset-inline-start: 50%; + transform: translate(-50%, 50%); + cursor: ns-resize; +} +[data-cropper-handle][data-corner='e'] { + inset-block-start: 50%; + inset-inline-end: 0; + transform: translate(50%, -50%); + cursor: ew-resize; +} +[data-cropper-handle][data-corner='w'] { + inset-block-start: 50%; + inset-inline-start: 0; + transform: translate(-50%, -50%); + cursor: ew-resize; +} + +/* ── Zoom controls (− slider + reset) ───────────────────────────────── */ +.eidos-cropper-zoom { + display: flex; + align-items: center; + gap: var(--cropper-zoom-gap); + margin-block-start: var(--space-2); +} + +.eidos-cropper-zoom [data-slider] { + flex: 1 1 auto; +} + +.eidos-cropper-zoom-btn { + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 auto; + inline-size: var(--cropper-zoom-btn-size); + block-size: var(--cropper-zoom-btn-size); + border: 1px solid var(--cropper-zoom-btn-border); + border-radius: var(--radius-md); + background: var(--cropper-zoom-btn-bg); + color: var(--cropper-zoom-btn-fg); + font-size: var(--font-size-md); + line-height: 1; + cursor: pointer; +} + +.eidos-cropper-zoom-btn:hover { + background: var(--color-surface-default); +} + +.eidos-cropper-zoom-btn:focus-visible { + outline: var(--focus-ring-width) solid var(--color-focus-ring); + outline-offset: 1px; +} diff --git a/src/uix/eidos/components/cropper/cropper.svelte b/src/uix/eidos/components/cropper/cropper.svelte new file mode 100644 index 000000000..85254923e --- /dev/null +++ b/src/uix/eidos/components/cropper/cropper.svelte @@ -0,0 +1,131 @@ + + + + {#snippet children({ crop: rect, transform, isFixed, isZoomed, scale, minScale, maxScale, zoomTo, resetZoom })} + + {#if src} + + {/if} + + + {#if !isFixed} + {#each HANDLES as corner (corner)} + + {/each} + {/if} + + + + {#if maxScale > minScale} +
+ + zoomTo(v[0])} + > + + + + + +
+ {/if} + {/snippet} +
diff --git a/src/uix/eidos/components/cropper/index.ts b/src/uix/eidos/components/cropper/index.ts new file mode 100644 index 000000000..9b8f56458 --- /dev/null +++ b/src/uix/eidos/components/cropper/index.ts @@ -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'; +// save(r.blob)} /> +export { default } from './cropper.svelte'; + +export type { CropperProps, CropRect, CropShape, CropCorner, CropResult } from './types'; diff --git a/src/uix/eidos/components/cropper/types.ts b/src/uix/eidos/components/cropper/types.ts new file mode 100644 index 000000000..bd80328a1 --- /dev/null +++ b/src/uix/eidos/components/cropper/types.ts @@ -0,0 +1,16 @@ +import type { ProviderProps } from '$soma/components/cropper'; + +/** + * Props for the eidos ``. The cropper is configured — it renders the + * viewport, the composed ``, 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'; diff --git a/src/uix/eidos/components/image-adjustments/README.md b/src/uix/eidos/components/image-adjustments/README.md new file mode 100644 index 000000000..d78bc9ca1 --- /dev/null +++ b/src/uix/eidos/components/image-adjustments/README.md @@ -0,0 +1,88 @@ +# ImageAdjustments (eidos) + +The styled, **configured** image-filter panel. Renders one labelled `` row +per adjustment from the `adjustments` prop — the consumer doesn't compose rows. + +```svelte + + + (filter = f)} /> + +``` + +## 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 `` 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 ``, 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. | diff --git a/src/uix/eidos/components/image-adjustments/image-adjustments.css b/src/uix/eidos/components/image-adjustments/image-adjustments.css new file mode 100644 index 000000000..16f475151 --- /dev/null +++ b/src/uix/eidos/components/image-adjustments/image-adjustments.css @@ -0,0 +1,97 @@ +/** + * ImageAdjustments recipe — layout for a stack of labelled `` 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; + } +} diff --git a/src/uix/eidos/components/image-adjustments/image-adjustments.svelte b/src/uix/eidos/components/image-adjustments/image-adjustments.svelte new file mode 100644 index 000000000..96d27fcab --- /dev/null +++ b/src/uix/eidos/components/image-adjustments/image-adjustments.svelte @@ -0,0 +1,75 @@ + + + + {#each resolved as key (key)} + + {#snippet children({ label, value: v, formattedValue, def, disabled, setValue })} +
+ + {label} + + + {formattedValue} + +
+ setValue(next[0])} + > + + + + {/snippet} +
+ {/each} + + {#if showReset} + + {#snippet children({ label })} + {label} + {/snippet} + + {/if} +
diff --git a/src/uix/eidos/components/image-adjustments/index.ts b/src/uix/eidos/components/image-adjustments/index.ts new file mode 100644 index 000000000..fc9841cb8 --- /dev/null +++ b/src/uix/eidos/components/image-adjustments/index.ts @@ -0,0 +1,14 @@ +// ImageAdjustments — a configured component: it renders one labelled `` +// 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'; +// (filter = f)} /> +export { default } from './image-adjustments.svelte'; + +export type { + ImageAdjustmentsProps, + ImageAdjustmentsSize, + ImageAdjustmentKey, + ImageAdjustmentValues +} from './types'; diff --git a/src/uix/eidos/components/image-adjustments/types.ts b/src/uix/eidos/components/image-adjustments/types.ts new file mode 100644 index 000000000..78f55500f --- /dev/null +++ b/src/uix/eidos/components/image-adjustments/types.ts @@ -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 `` size + * + row typography. Narrowed per the per-component-subset doctrine. + */ +export type ImageAdjustmentsSize = Extract; + +/** + * Props for the eidos ``. + * + * 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 `` 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; + /** Render the reset control below the rows. @default true */ + showReset?: boolean; +}; + +export type { ImageAdjustmentKey, ImageAdjustmentValues } from '$soma/components/image-adjustments'; diff --git a/src/uix/eidos/components/image-picker/README.md b/src/uix/eidos/components/image-picker/README.md new file mode 100644 index 000000000..04965d450 --- /dev/null +++ b/src/uix/eidos/components/image-picker/README.md @@ -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 + + + 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 | `` (drop + click), `accept="image/*"`, single file | +| ready | `` preview + a floating rotate/remove toolbar + `` | + +## 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 `` / + `` / `` — 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 `` 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`. | diff --git a/src/uix/eidos/components/image-picker/image-picker.css b/src/uix/eidos/components/image-picker/image-picker.css new file mode 100644 index 000000000..7a5e9ec67 --- /dev/null +++ b/src/uix/eidos/components/image-picker/image-picker.css @@ -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 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 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; + } +} diff --git a/src/uix/eidos/components/image-picker/image-picker.svelte b/src/uix/eidos/components/image-picker/image-picker.svelte new file mode 100644 index 000000000..4fd743cac --- /dev/null +++ b/src/uix/eidos/components/image-picker/image-picker.svelte @@ -0,0 +1,79 @@ + + + + {#snippet children({ state, url, filter, fit: previewFit, prompt, setFile })} + {#if state === 'empty'} + setFile(files[0] ?? null)} + > + + + {prompt} + + + {:else} + +
+ +
+ + ⟳ + ✕ + +
+ {#if showAdjustments} + + {/if} + {/if} + {/snippet} +
diff --git a/src/uix/eidos/components/image-picker/index.ts b/src/uix/eidos/components/image-picker/index.ts new file mode 100644 index 000000000..87a9f505f --- /dev/null +++ b/src/uix/eidos/components/image-picker/index.ts @@ -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'; +// save(v)} /> +export { default } from './image-picker.svelte'; + +export type { + ImagePickerProps, + ImagePickerSize, + ImagePickerFit, + ImagePickerRotation, + ImagePickerValue +} from './types'; diff --git a/src/uix/eidos/components/image-picker/types.ts b/src/uix/eidos/components/image-picker/types.ts new file mode 100644 index 000000000..9625655ca --- /dev/null +++ b/src/uix/eidos/components/image-picker/types.ts @@ -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; + +/** + * Props for the eidos ``. + * + * Extends the headless API with composition concerns: `accept`/`maxSize` flow to + * the composed ``, `size` to the composed ``, 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; + /** Render the ImageAdjustments panel below the preview. @default true */ + showAdjustments?: boolean; +}; + +export type { + ImagePickerFit, + ImagePickerRotation, + ImagePickerValue +} from '$soma/components/image-picker'; diff --git a/src/uix/eidos/components/palabras/palabras-appearance.svelte b/src/uix/eidos/components/palabras/palabras-appearance.svelte index 3cf0be5dc..f8c5add0d 100644 --- a/src/uix/eidos/components/palabras/palabras-appearance.svelte +++ b/src/uix/eidos/components/palabras/palabras-appearance.svelte @@ -2,22 +2,27 @@ /** * Appearance popover — opened by the panel's "Editar" button. Edits THIS * block's own visual override (`block.visual.*`) via `setBlockVisualAtPath`. - * A framework `` anchored to the active block on its LEFT (opposite - * the main panel), `align='center'` → centred on the block's mid-height; the - * Popover owns positioning + portal + dismissal. Colours use our ``, - * numbers our ``, alignment our ``. + * It's the framework `` anchored to the active block on its LEFT + * (opposite the main panel); a draggable free window (closes on click-outside / + * Esc). Its CONTENT is organised in `` by style group — Tipografía (only + * for text blocks), Colores, Disposición — Elementor-style. Colours use our + * ``, numbers our ``, alignment our ``. */ import type { ProviderSnippetProps } from '$soma/components/words'; - import { Button } from '$uix/eidos/components/button'; import { ToggleGroup } from '$uix/eidos/components/toggle-group'; import { NumberField } from '$uix/eidos/components/number-field'; - import { X } from '$uix/eidos/components/icon'; + import { Tabs } from '$uix/eidos/components/tabs'; import PalabrasColorRow from './palabras-color-row.svelte'; - import { Popover } from '$uix/eidos/components/popover'; - import { clampedBlockAnchor } from './palabras-anchor'; + import { FloatPanel } from '$uix/eidos/components/float-panel'; - // Panel width — kept in sync with `` AND the anchor clamp. - const PANEL_WIDTH = 256; + const PANEL_W = 284; + const PANEL_H = 300; + + // Style-group tabs. Tipografía only shows for text-bearing blocks ("la + // tipografía solo aparece en las partes que son tipográficas"). + const TEXT_TYPES = new Set(['heading', 'paragraph']); + // `tab` = the user's explicit pick (null → use the default). See `activeTab`. + let tab = $state(null); let { api, @@ -32,27 +37,55 @@ } = $props(); const loc = $derived(api.activeBlockLocation); + const isText = $derived(TEXT_TYPES.has(loc?.node?.type ?? '')); + // Active tab: the user's pick if still valid ('type'/Tipografía only on text + // blocks), else the default — Tipografía for text, Colores otherwise. A derived + // (not an effect) so it's robust to `isText` settling AFTER mount: no transient + // 'color' sticks the way an effect-set value would. + const activeTab = $derived( + tab && (tab !== 'type' || isText) ? tab : isText ? 'type' : 'color' + ); - // The active block element drives the Popover's `customAnchor` (placed on the - // LEFT of the block, opposite the main panel). The Popover owns positioning - // (flip / shift / fit) + portal + dismissal. - let anchorEl = $state(null); - $effect(() => { - void api.activeBlockId; + // The active block's element (resolved by its stable `data-words-id`, like the + // main panel). FloatPanel seeds its open position from this on the LEFT. + const anchorEl = $derived.by(() => { void open; - anchorEl = (content?.querySelector('[data-words-active]') as HTMLElement | null) ?? null; + const idPath = api.activeBlockId; + if (!content || !idPath) return null; + const segments = String(idPath).split('/').filter(Boolean); + const localId = segments[segments.length - 1]; + if (!localId) return null; + return content.querySelector(`[data-words-id="${CSS.escape(localId)}"]`) as HTMLElement | null; }); - // Clamped virtual anchor (side LEFT): keeps the appearance popover from - // flipping across the block when the left gutter is narrow. See palabras-anchor.ts. - const anchor = $derived.by(() => { + + // Re-anchor to the LEFT of the block: FloatPanel seeds on the first open; when + // the active block changes while open, reposition next to the new block. A drag + // within one block is preserved. Cleared on close so the next open re-seeds. + let panelPos = $state<{ x: number; y: number } | undefined>(undefined); + let posBlock: string | null = null; + $effect(() => { + if (!open) { + panelPos = undefined; + posBlock = null; + return; + } + const el = anchorEl; const win = content?.ownerDocument?.defaultView; - if (!anchorEl || !win) return null; - return clampedBlockAnchor(anchorEl, win, { - side: 'left', - panelWidth: PANEL_WIDTH, - gap: 8, - padding: 12 - }); + const id = api.activeBlockId; + if (!el || !win || !id) return; + if (posBlock === null) { + posBlock = id; + return; + } + if (id === posBlock) return; + posBlock = id; + const r = el.getBoundingClientRect(); + const gap = 16; + const pad = 16; + panelPos = { + x: Math.min(Math.max(r.left - PANEL_W - gap, pad), win.innerWidth - PANEL_W - pad), + y: Math.min(Math.max(r.top + r.height / 2 - PANEL_H / 2, pad), win.innerHeight - PANEL_H - pad) + }; }); type Align = 'left' | 'center' | 'right' | 'justify'; @@ -109,79 +142,104 @@ ]; - !v && onClose()} modal={false}> - - -
- Apariencia - -
+ !v && onClose()} + bind:position={panelPos} + anchor={anchorEl} + side="left" + align="center" + offset={16} + draggable + closeOnInteractOutside + variant="surface" + color="neutral" + defaultSize={{ width: PANEL_W, height: PANEL_H }} +> + + + Apariencia + + + + (tab = v)} variant="line" size="sm" scrollable> + + {#if isText} + Tipografía + {/if} + Colores + Disposición + + + + {#if isText} + +
+
+ Tamaño (px) + + setVisual({ fontSize: typeof v === 'number' && v > 0 ? v : undefined })} + > + + +
+
+ Interlineado + + setVisual({ lineHeight: typeof v === 'number' && v > 0 ? v : undefined })} + > + + +
+
+
+ {/if} + + +
+ setVisual({ color: hex })} + /> + setVisual({ background: hex })} + /> +
+
-
- setVisual({ color: hex })} - /> - setVisual({ background: hex })} - /> -
- Tamaño (px) - - setVisual({ fontSize: typeof v === 'number' && v > 0 ? v : undefined })} - > - - -
-
- Interlineado - - setVisual({ lineHeight: typeof v === 'number' && v > 0 ? v : undefined })} - > - - -
-
- Alineación - { - const a = v[0] as Align; - if (a) setVisual({ align: a }); - }} - > - {#each ALIGNS as a (a.v)} - {a.label} - {/each} - -
-
-
-
-
+ +
+
+ Alineación + { + const a = v[0] as Align; + if (a) setVisual({ align: a }); + }} + > + {#each ALIGNS as a (a.v)} + {a.label} + {/each} + +
+
+
+
+ + +
diff --git a/src/uix/eidos/components/palabras/palabras-panel.svelte b/src/uix/eidos/components/palabras/palabras-panel.svelte index 4c8989c79..9e76b8a12 100644 --- a/src/uix/eidos/components/palabras/palabras-panel.svelte +++ b/src/uix/eidos/components/palabras/palabras-panel.svelte @@ -1,51 +1,44 @@ {#if mounted} @@ -89,11 +85,9 @@ onOpen={(id) => { api.setActiveBlock(id); panelOpen = true; - appearanceOpen = false; }} onClose={() => { panelOpen = false; - appearanceOpen = false; }} /> {/if} @@ -111,7 +105,6 @@ // its interaction, so a click opens it. if (type === 'image') { panelOpen = true; - appearanceOpen = false; } }} /> @@ -127,29 +120,20 @@ - + (appearanceOpen = true)} - onClose={() => { - panelOpen = false; - appearanceOpen = false; - }} - /> - (appearanceOpen = false)} + onClose={() => (panelOpen = false)} />

haz clic en un bloque y pulsa su tirador ⋮ para ver sus propiedades · - Esc o clic fuera para cerrar + arrastra la cabecera para moverlo · clic fuera o Esc para cerrar

{/snippet} diff --git a/src/uix/eidos/components/palabras/panel-schema.ts b/src/uix/eidos/components/palabras/panel-schema.ts index 6e24b497e..da714e26f 100644 --- a/src/uix/eidos/components/palabras/panel-schema.ts +++ b/src/uix/eidos/components/palabras/panel-schema.ts @@ -3,40 +3,36 @@ * * Keyed by block `type`. This is a SIDECAR to the engine's block registry: the * engine spec stays pure (render / validate / serialize), palabras (eidos) owns - * the panel presentation. Adding a block's panel is one entry here — the panel - * + breadcrumb never switch on a concrete type. + * the panel presentation. * - * Pilot scope: `heading` (the working semantic field) + `paragraph` (header - * only). The rest land once the architecture is verified end-to-end. + * Model: every panel has THREE fixed tabs — `Contenido · Diseño · Avanzado` + * (open on Contenido). Each block type declares the fields of each tab. Adding a + * block's panel is one entry here. + * + * Pilot scope: `heading` Contenido tab (Texto / Nivel / Enlace). Diseño + + * Avanzado, and the other block types, land incrementally. */ -import { TextAlignStart, TextAlignCenter, TextAlignEnd } from '$uix/eidos/components/icon'; import type { PalabrasBlockSchema, PalabrasPanelRegistry } from './types'; const heading: PalabrasBlockSchema = { type: 'heading', label: 'Encabezado', dotToken: 'var(--color-primary-solid)', - sections: [ + contenido: [ { - title: 'QUÉ ES', - pinned: true, - fields: [ - { - key: 'level', - type: 'pills', - label: 'Nivel estructural', - options: [ - { value: 1, label: 'Sección' }, - { value: 2, label: 'Subsección' }, - { value: 3, label: 'Apartado' } - ] - }, - { key: 'index', type: 'toggle', label: 'Aparece en el índice' }, - { key: 'fold', type: 'toggle', label: 'Plegable' }, - { key: 'anchor', type: 'text', label: 'Ancla de enlace' } + key: 'level', + type: 'pills', + label: 'Nivel', + options: [ + { value: 1, label: 'Sección' }, + { value: 2, label: 'Subsección' }, + { value: 3, label: 'Apartado' } ] - } + }, + // `textarea` edits the block's TEXT (its inline `children`), not a prop. + { key: 'children', type: 'textarea', label: 'Texto', placeholder: 'Texto del encabezado' }, + { key: 'link', type: 'text', label: 'Enlace', placeholder: 'https://…', wide: true } ] }; @@ -44,172 +40,32 @@ const paragraph: PalabrasBlockSchema = { type: 'paragraph', label: 'Párrafo', dotToken: 'var(--color-content-muted)', - sections: [] + contenido: [ + { key: 'children', type: 'textarea', label: 'Texto', placeholder: 'Texto del párrafo' } + ] }; const image: PalabrasBlockSchema = { type: 'image', label: 'Imagen', dotToken: 'var(--color-secondary-solid)', - sections: [ - // Pinned header — always visible: URL (+ upload), alt, caption. + contenido: [ { - title: 'Origen', - pinned: true, - fields: [ - { - key: 'src', - type: 'text', - label: 'URL o ruta', - placeholder: 'https://… o /ruta/imagen.png', - wide: true, - upload: true - }, - { - key: 'alt', - type: 'text', - label: 'Texto alternativo', - placeholder: 'Describe la imagen (accesibilidad)', - wide: true - }, - { - key: 'caption', - type: 'text', - label: 'Pie de foto', - placeholder: 'Opcional', - wide: true - } - ] + key: 'src', + type: 'text', + label: 'URL o ruta', + placeholder: 'https://… o /ruta/imagen.png', + wide: true, + upload: true }, { - title: 'Tamaño y posición', - defaultCollapsed: true, - fields: [ - { - key: 'align', - type: 'pills', - label: 'Alineación', - gatedBy: 'fullWidth', - default: 'center', - options: [ - { value: 'left', label: 'Izquierda', icon: TextAlignStart }, - { value: 'center', label: 'Centro', icon: TextAlignCenter }, - { value: 'right', label: 'Derecha', icon: TextAlignEnd } - ] - }, - { - key: 'fit', - type: 'pills', - label: 'Ajuste', - options: [ - { value: 'fill', label: 'Fill' }, - { value: 'fit', label: 'Fit' }, - { value: 'crop', label: 'Crop' }, - { value: 'tile', label: 'Tile' } - ] - }, - { key: 'fullWidth', type: 'toggle', label: 'Ancho completo', default: false }, - { - key: 'width', - type: 'length', - label: 'Ancho', - placeholder: 'auto', - unit: 'px', - units: ['px', '%', 'rem', 'em', 'vw', 'vh'], - gatedBy: 'fullWidth' - }, - { - key: 'height', - type: 'length', - label: 'Alto', - placeholder: 'auto', - unit: 'px', - // Same units as width, `%` included (per request). NOTE: a percentage - // height only takes effect with a definite-height ancestor — otherwise - // the browser ignores it — but we accept it rather than block the input. - units: ['px', 'rem', 'em', 'vh', 'vw', '%'], - gatedBy: 'fullWidth' - } - ] + key: 'alt', + type: 'text', + label: 'Texto alternativo', + placeholder: 'Describe la imagen (accesibilidad)', + wide: true }, - // Photo adjustments (CSS filters). Defaults are the neutral values; the - // renderer treats them as identity, so an untouched image carries no filter. - { - title: 'Apariencia', - defaultCollapsed: true, - fields: [ - { - key: 'saturate', - type: 'slider', - label: 'Saturación', - unit: '%', - min: 0, - max: 200, - step: 5, - default: 100 - }, - { - key: 'brightness', - type: 'slider', - label: 'Brillo', - unit: '%', - min: 0, - max: 200, - step: 5, - default: 100 - }, - { - key: 'contrast', - type: 'slider', - label: 'Contraste', - unit: '%', - min: 0, - max: 200, - step: 5, - default: 100 - }, - { - key: 'hueRotate', - type: 'slider', - label: 'Tono', - unit: '°', - min: 0, - max: 360, - step: 5, - default: 0 - }, - { - key: 'grayscale', - type: 'slider', - label: 'Escala de grises', - unit: '%', - min: 0, - max: 100, - step: 5, - default: 0 - }, - { - key: 'sepia', - type: 'slider', - label: 'Sepia', - unit: '%', - min: 0, - max: 100, - step: 5, - default: 0 - }, - { - key: 'blur', - type: 'slider', - label: 'Desenfoque', - unit: 'px', - min: 0, - max: 20, - step: 1, - default: 0 - } - ] - } + { key: 'caption', type: 'text', label: 'Pie de foto', placeholder: 'Opcional', wide: true } ] }; diff --git a/src/uix/eidos/components/palabras/types.ts b/src/uix/eidos/components/palabras/types.ts index 35e93bf09..ae597353e 100644 --- a/src/uix/eidos/components/palabras/types.ts +++ b/src/uix/eidos/components/palabras/types.ts @@ -39,9 +39,11 @@ export type PalabrasFieldType = | 'select' | 'chip' | 'text' + | 'textarea' | 'number' | 'slider' - | 'length'; + | 'length' + | 'color'; /** One choice in a `pills` / `select` field. */ export interface PalabrasFieldOption { @@ -83,6 +85,13 @@ export interface PalabrasFieldDef { /** Disable this control when the active block's `node[gatedBy]` is truthy — e.g. * width / height / align gated by `fullWidth` (the engine ignores them then). */ readonly gatedBy?: string; + /** When true, this field edits a VISUAL style override — written via + * `setBlockVisualAtPath` (the engine's STYLE_KEYS: color / background / + * fontSize / fontFamily / fontWeight / lineHeight / align / border / margin / + * padding) — instead of a structural prop (`updateBlockAtPath`). The style + * groups (Tipografía / Colores / Disposición / Bordes) set this; structural + * groups (Origen / Avanzado / Tamaño) leave it off. */ + readonly visual?: boolean; /** Default VALUE shown when the block hasn't set this prop yet — this field-def * IS the per-block-property defaults schema. The control displays * `node[key] ?? default`; the renderer treats the neutral value as identity. */ @@ -110,14 +119,24 @@ export interface PalabrasSectionDef { readonly fields: readonly PalabrasFieldDef[]; } -/** The panel definition for one block type. */ +/** The panel definition for one block type — THREE fixed tabs. + * The panel always shows `Contenido · Diseño · Avanzado` at the top (open on + * Contenido). Each tab's content is per-block-type: + * - `contenido` — the block's OWN content props (e.g. heading: text / level / link). + * - `diseño` — visual style (block.visual.*). Populated incrementally. + * - `avanzado` — advanced / structural extras. Populated incrementally. */ export interface PalabrasBlockSchema { readonly type: string; /** Shown in the panel header + breadcrumb. */ readonly label: string; /** Colour token for the header dot. */ readonly dotToken?: string; - readonly sections: readonly PalabrasSectionDef[]; + /** Tab "Contenido" (default). */ + readonly contenido: readonly PalabrasFieldDef[]; + /** Tab "Diseño". */ + readonly diseño?: readonly PalabrasFieldDef[]; + /** Tab "Avanzado". */ + readonly avanzado?: readonly PalabrasFieldDef[]; } /** Block type → panel schema. */ diff --git a/src/uix/eidos/components/slider/slider.css b/src/uix/eidos/components/slider/slider.css index cb0759089..27b3faa4e 100644 --- a/src/uix/eidos/components/slider/slider.css +++ b/src/uix/eidos/components/slider/slider.css @@ -100,14 +100,21 @@ box-shadow: var(--slider-thumb-shadow); cursor: grab; outline: none; + /* `scale` is its own property, independent of the `transform: translate()` + the soma uses to centre the thumb — they compose, so the lift scales the + thumb in place. */ transition: background var(--slider-transition-duration) var(--slider-transition-ease), border-color var(--slider-transition-duration) var(--slider-transition-ease), + scale var(--slider-transition-duration) var(--slider-transition-ease), box-shadow var(--slider-transition-duration) var(--slider-transition-ease); } +/* Pickup lift on grab: scale toward the viewer + a higher shadow. */ [data-slider-thumb]:active { cursor: grabbing; + scale: var(--slider-thumb-scale-active); + box-shadow: var(--slider-thumb-shadow-active); } [data-slider-thumb]:focus-visible { @@ -144,4 +151,8 @@ [data-slider-thumb] { transition: none; } + /* No pickup zoom under reduced motion — the raised shadow still conveys grab. */ + [data-slider-thumb]:active { + scale: 1; + } } diff --git a/src/uix/eidos/components/textarea/textarea.css b/src/uix/eidos/components/textarea/textarea.css index 2033bc04f..cd8ca494d 100644 --- a/src/uix/eidos/components/textarea/textarea.css +++ b/src/uix/eidos/components/textarea/textarea.css @@ -8,8 +8,12 @@ --_textarea-line-height: var(--leading-normal); --_textarea-radius: var(--radius-md); --_textarea-border: var(--color-neutral-border); - --_textarea-border-hover: var(--color-neutral-border-strong); - --_textarea-border-focus: var(--color-primary-border-strong); + /* `--color-{role}-border-strong` is NOT part of this theme's token vocabulary + (only `--color-{role}-border` exists). Consume the SAME tokens the other inputs + use: the Field hovers to `--color-border-strong` and focuses to + `--color-focus-ring`. Per-color focus accents below use each role's `-solid`. */ + --_textarea-border-hover: var(--color-border-strong); + --_textarea-border-focus: var(--color-focus-ring); --_textarea-bg: var(--color-surface); --_textarea-color: var(--color-content); --_textarea-placeholder: var(--color-content-muted); @@ -47,27 +51,29 @@ --_textarea-radius: var(--radius-lg); } -/* Color accents — applied on focus + when invalid. */ +/* Color accents — applied on focus + when invalid. Each role's `-solid` (the + saturated accent, step 9) is the existing token closest to the intended strong + focus border. */ [data-textarea][data-color='secondary'] { - --_textarea-border-focus: var(--color-secondary-border-strong); + --_textarea-border-focus: var(--color-secondary-solid); } [data-textarea][data-color='neutral'] { - --_textarea-border-focus: var(--color-neutral-border-strong); + --_textarea-border-focus: var(--color-neutral-solid); } [data-textarea][data-color='affirm'] { - --_textarea-border-focus: var(--color-affirm-border-strong); + --_textarea-border-focus: var(--color-affirm-solid); } [data-textarea][data-color='fulfill'] { - --_textarea-border-focus: var(--color-fulfill-border-strong); + --_textarea-border-focus: var(--color-fulfill-solid); } [data-textarea][data-color='risk'] { - --_textarea-border-focus: var(--color-risk-border-strong); + --_textarea-border-focus: var(--color-risk-solid); } [data-textarea][data-color='threat'] { - --_textarea-border-focus: var(--color-threat-border-strong); + --_textarea-border-focus: var(--color-threat-solid); } [data-textarea][data-color='loss'] { - --_textarea-border-focus: var(--color-loss-border-strong); + --_textarea-border-focus: var(--color-loss-solid); } /* The real input. */ @@ -101,7 +107,7 @@ } [data-textarea-input][data-invalid] { - border-color: var(--color-threat-border-strong); + border-color: var(--color-threat-solid); } [data-textarea][data-variant='outline'] [data-textarea-input] { diff --git a/src/uix/eidos/generated/base.css b/src/uix/eidos/generated/base.css index 26f5d1579..c65482628 100644 --- a/src/uix/eidos/generated/base.css +++ b/src/uix/eidos/generated/base.css @@ -1337,6 +1337,46 @@ --float-panel-accent: var(--color-neutral-border); --float-panel-transition-duration: var(--duration-fast); --float-panel-transition-ease: var(--ease-default); + --image-adjustments-gap: var(--space-3); + --image-adjustments-row-gap: var(--space-1-5); + --image-adjustments-label-color: var(--color-content-secondary); + --image-adjustments-label-font-size: var(--font-size-sm); + --image-adjustments-label-font-weight: var(--font-weight-medium); + --image-adjustments-value-color: var(--color-content-tertiary); + --image-adjustments-value-font-size: var(--font-size-xs); + --image-adjustments-value-font-family: var(--font-mono); + --image-adjustments-reset-gap: var(--space-2); + --image-adjustments-reset-color: var(--color-content-secondary); + --image-adjustments-reset-font-size: var(--font-size-xs); + --image-adjustments-hue-track: linear-gradient(to right, hsl(0 85% 60%), hsl(60 85% 60%), hsl(120 85% 60%), hsl(180 85% 60%), hsl(240 85% 60%), hsl(300 85% 60%), hsl(360 85% 60%)); + --image-picker-gap: var(--space-3); + --image-picker-preview-radius: var(--radius-lg); + --image-picker-preview-bg: var(--color-surface-muted); + --image-picker-preview-border: var(--color-border-default); + --image-picker-preview-aspect: 4 / 3; + --image-picker-toolbar-gap: var(--space-1-5); + --image-picker-toolbar-inset: var(--space-2); + --image-picker-button-size: var(--space-8); + --image-picker-button-bg: color-mix(in srgb, var(--color-surface-default) 88%, transparent); + --image-picker-button-fg: var(--color-content-primary); + --image-picker-button-border: var(--color-border-default); + --image-picker-button-radius: var(--radius-md); + --image-picker-button-font-size: var(--font-size-md); + --cropper-viewport-bg: var(--color-surface-muted); + --cropper-viewport-radius: var(--radius-lg); + --cropper-mask-bg: rgb(0 0 0 / 0.55); + --cropper-grid-line: rgb(255 255 255 / 0.4); + --cropper-selection-border: var(--color-surface-default); + --cropper-selection-border-width: var(--border-width); + --cropper-handle-size: var(--space-3-5); + --cropper-handle-bg: var(--color-surface-default); + --cropper-handle-border: var(--color-primary-solid); + --cropper-handle-border-width: var(--border-width); + --cropper-zoom-gap: var(--space-2); + --cropper-zoom-btn-size: var(--space-7); + --cropper-zoom-btn-bg: var(--color-surface-muted); + --cropper-zoom-btn-fg: var(--color-content-primary); + --cropper-zoom-btn-border: var(--color-border-default); --date-field-stack-gap: var(--space-1-5); --date-field-height-xs: var(--control-height-xs); --date-field-height-sm: var(--control-height-sm); @@ -2734,13 +2774,15 @@ --slider-min-inline-size: 12rem; --slider-min-block-size: 10rem; --slider-track-radius: var(--radius-full); - --slider-track-bg: var(--color-neutral-track); + --slider-track-bg: color-mix(in srgb, var(--color-content-primary) 12%, var(--color-surface-default)); --slider-range-bg: var(--color-primary-solid); --slider-thumb-radius: var(--radius-full); --slider-thumb-border-width: var(--border-width); --slider-thumb-border: var(--color-border-default); --slider-thumb-bg: var(--color-surface-default); --slider-thumb-shadow: var(--shadow-subtle); + --slider-thumb-scale-active: 1.15; + --slider-thumb-shadow-active: var(--shadow-3); --slider-thumb-focus-shadow: 0 0 0 var(--focus-ring-offset) var(--color-surface-default), 0 0 0 calc(var(--focus-ring-offset) + var(--focus-ring-width)) var(--focus-ring-color), var(--slider-thumb-shadow); --slider-tick-radius: var(--radius-full); --slider-tick-bg: var(--color-neutral-border); diff --git a/src/uix/eidos/lib/recipes/base.ts b/src/uix/eidos/lib/recipes/base.ts index 11c51fcf2..94c29962a 100644 --- a/src/uix/eidos/lib/recipes/base.ts +++ b/src/uix/eidos/lib/recipes/base.ts @@ -1178,6 +1178,66 @@ export const THEME_BASE_RECIPE_TOKENS = { 'transition-duration': 'var(--duration-fast)', 'transition-ease': 'var(--ease-default)' }, + 'image-adjustments': { + // Vertical rhythm: gap between adjustment rows, and within a row + // (label/value line → slider). + gap: 'var(--space-3)', + 'row-gap': 'var(--space-1-5)', + 'label-color': 'var(--color-content-secondary)', + 'label-font-size': 'var(--font-size-sm)', + 'label-font-weight': 'var(--font-weight-medium)', + // Read-out: tabular numerals so the value doesn't jitter while dragging. + 'value-color': 'var(--color-content-tertiary)', + 'value-font-size': 'var(--font-size-xs)', + 'value-font-family': 'var(--font-mono)', + 'reset-gap': 'var(--space-2)', + 'reset-color': 'var(--color-content-secondary)', + 'reset-font-size': 'var(--font-size-xs)', + // The `hue` row paints its slider track as a hue wheel so the control + // reads as a hue picker. A literal rainbow (the hues ARE the value) — kept + // as a token so it's themeable and stays out of the component CSS. + 'hue-track': + 'linear-gradient(to right, hsl(0 85% 60%), hsl(60 85% 60%), hsl(120 85% 60%), hsl(180 85% 60%), hsl(240 85% 60%), hsl(300 85% 60%), hsl(360 85% 60%))' + }, + 'image-picker': { + // Vertical stack: preview → adjustments. + gap: 'var(--space-3)', + // Preview box (ready state). The composed fills it. + 'preview-radius': 'var(--radius-lg)', + 'preview-bg': 'var(--color-surface-muted)', + 'preview-border': 'var(--color-border-default)', + 'preview-aspect': '4 / 3', + // Toolbar floats over the preview's top-right corner. + 'toolbar-gap': 'var(--space-1-5)', + 'toolbar-inset': 'var(--space-2)', + // Icon buttons (rotate / remove). + 'button-size': 'var(--space-8)', + 'button-bg': 'color-mix(in srgb, var(--color-surface-default) 88%, transparent)', + 'button-fg': 'var(--color-content-primary)', + 'button-border': 'var(--color-border-default)', + 'button-radius': 'var(--radius-md)', + 'button-font-size': 'var(--font-size-md)' + }, + cropper: { + 'viewport-bg': 'var(--color-surface-muted)', + 'viewport-radius': 'var(--radius-lg)', + // The darken-outside mask + the thirds grid carry literal rgba/rgb (an + // overlay tint, not a theme color) — kept as tokens so they stay out of + // the component CSS (raw-color contract) and remain themeable. + 'mask-bg': 'rgb(0 0 0 / 0.55)', + 'grid-line': 'rgb(255 255 255 / 0.4)', + 'selection-border': 'var(--color-surface-default)', + 'selection-border-width': 'var(--border-width)', + 'handle-size': 'var(--space-3-5)', + 'handle-bg': 'var(--color-surface-default)', + 'handle-border': 'var(--color-primary-solid)', + 'handle-border-width': 'var(--border-width)', + 'zoom-gap': 'var(--space-2)', + 'zoom-btn-size': 'var(--space-7)', + 'zoom-btn-bg': 'var(--color-surface-muted)', + 'zoom-btn-fg': 'var(--color-content-primary)', + 'zoom-btn-border': 'var(--color-border-default)' + }, 'date-field': { 'stack-gap': 'var(--space-1-5)', 'height-xs': 'var(--control-height-xs)', @@ -3124,13 +3184,23 @@ export const THEME_BASE_RECIPE_TOKENS = { 'min-inline-size': '12rem', 'min-block-size': '10rem', 'track-radius': 'var(--radius-full)', - 'track-bg': 'var(--color-neutral-track)', + // The unfilled groove. `--color-neutral-track` equals the surface in the + // base theme (invisible against panels), so the track uses the same + // visible-track mix as Progress: a light tint of content-primary on the + // surface. Reads on white AND dark, and follows the theme. + 'track-bg': 'color-mix(in srgb, var(--color-content-primary) 12%, var(--color-surface-default))', 'range-bg': 'var(--color-primary-solid)', 'thumb-radius': 'var(--radius-full)', 'thumb-border-width': 'var(--border-width)', 'thumb-border': 'var(--color-border-default)', 'thumb-bg': 'var(--color-surface-default)', 'thumb-shadow': 'var(--shadow-subtle)', + // Pickup lift WHILE grabbed (pointer :active): the thumb scales toward the + // viewer + rides a higher shadow — the draggable-surface lift doctrine + // (THEMING §14), scaled up from the surface `--motion-scale-lift` (1.02) + // because a ~20px thumb needs a larger multiplier to read as a lift. + 'thumb-scale-active': '1.15', + 'thumb-shadow-active': 'var(--shadow-3)', 'thumb-focus-shadow': '0 0 0 var(--focus-ring-offset) var(--color-surface-default), 0 0 0 calc(var(--focus-ring-offset) + var(--focus-ring-width)) var(--focus-ring-color), var(--slider-thumb-shadow)', 'tick-radius': 'var(--radius-full)', diff --git a/src/uix/langs/components/cropper.ts b/src/uix/langs/components/cropper.ts new file mode 100644 index 000000000..de9bfd734 --- /dev/null +++ b/src/uix/langs/components/cropper.ts @@ -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; diff --git a/src/uix/langs/components/image-adjustments.ts b/src/uix/langs/components/image-adjustments.ts new file mode 100644 index 000000000..8ba71bead --- /dev/null +++ b/src/uix/langs/components/image-adjustments.ts @@ -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; diff --git a/src/uix/langs/components/image-picker.ts b/src/uix/langs/components/image-picker.ts new file mode 100644 index 000000000..73b373367 --- /dev/null +++ b/src/uix/langs/components/image-picker.ts @@ -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; diff --git a/src/uix/langs/components/index.ts b/src/uix/langs/components/index.ts index 086011d55..598cbad8e 100644 --- a/src/uix/langs/components/index.ts +++ b/src/uix/langs/components/index.ts @@ -16,6 +16,7 @@ import { colorPickerLangs } from './color-picker'; import { comboboxLangs } from './combobox'; import { commandLangs } from './command'; import { contextMenuLangs } from './context-menu'; +import { cropperLangs } from './cropper'; import { dateFieldLangs } from './date-field'; import { datePickerLangs } from './date-picker'; import { dateRangeFieldLangs } from './date-range-field'; @@ -32,6 +33,8 @@ import { floatPanelLangs } from './float-panel'; import { formLangs } from './form'; import { gridListLangs } from './grid-list'; import { imageLangs } from './image'; +import { imageAdjustmentsLangs } from './image-adjustments'; +import { imagePickerLangs } from './image-picker'; import { linkPreviewLangs } from './link-preview'; import { listboxLangs } from './listbox'; import { menubarLangs } from './menubar'; @@ -100,6 +103,7 @@ export const componentLangs = { combobox: comboboxLangs, command: commandLangs, 'context-menu': contextMenuLangs, + cropper: cropperLangs, 'date-field': dateFieldLangs, 'date-picker': datePickerLangs, 'date-range-field': dateRangeFieldLangs, @@ -116,6 +120,8 @@ export const componentLangs = { form: formLangs, 'grid-list': gridListLangs, image: imageLangs, + 'image-adjustments': imageAdjustmentsLangs, + 'image-picker': imagePickerLangs, 'link-preview': linkPreviewLangs, listbox: listboxLangs, menubar: menubarLangs, diff --git a/src/uix/morfo/components/cropper.ts b/src/uix/morfo/components/cropper.ts new file mode 100644 index 000000000..61501dc53 --- /dev/null +++ b/src/uix/morfo/components/cropper.ts @@ -0,0 +1,148 @@ +import type { Morfo } from '../types'; +import { v } from '../types'; + +/** + * Cropper — crop a region of an image (Ark-style: a movable / resizable + * selection over a fixed image, with a rule-of-thirds grid + corner handles). + * + * Declarative DNA only. The crop rect geometry (x/y/width/height) is continuous + * and lives as inline style on the Selection (set by soma, like FloatPanel's + * position) — NOT as data-attrs. The morfo declares the parts, the shape + * (rect / round), the corner of each handle, and three events. The drag/resize + * math + the `` extraction to a Blob live in soma. + * + * Consumes an image `src` — typically the `url` an ImagePicker emits — and + * produces a cropped Blob, closing the pick → adjust → crop pipeline. + */ +export const cropperMorfo = { + name: 'Cropper', + kebab: 'cropper', + scope: ['soma', 'sema'], + texts: { + label: '#?components.cropper.label|Crop image', + selection: '#?components.cropper.selection|Crop region' + }, + events: [ + { + // The user starts dragging the selection to move it. Emitted once at + // gesture start (NOT per-frame — that would buzz haptics; the move + // itself is visual). family `handle`, verb `drag`. + name: 'handle-drag', + semantic: { + family: 'handle', + verb: 'drag', + target: v.partRef('selection'), + sequence: 'pre' + } + }, + { + // The user starts resizing via a corner handle. Emitted once at gesture + // start. family `handle`, verb `resize`. + name: 'handle-resize', + semantic: { + family: 'handle', + verb: 'resize', + target: v.partRef('handle'), + sequence: 'pre' + } + }, + { + // Zoom the image (wheel / pinch / the +/− controls). Emitted throttled + // (NOT per wheel tick). family `handle`, verb `zoom`. + name: 'handle-zoom', + semantic: { + family: 'handle', + verb: 'zoom', + target: v.partRef('viewport'), + sequence: 'pre' + } + }, + { + // The crop is applied — the cropped Blob is produced. family `commit`, + // verb `apply` (the canonical commit verb for applying a transform). + name: 'commit-crop', + semantic: { + family: 'commit', + verb: 'apply', + target: v.partRef('provider'), + intent: 'affirm', + 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' }] + }, + { + // The framed crop area — the image + the selection live here. + name: 'Viewport', + kebab: 'viewport', + archetype: 'group', + kind: 'public', + defaultElement: 'div', + optional: true, + data: [], + aria: [] + }, + { + // The crop rectangle. `data-shape` drives the rectangular vs circular + // mask; `data-dragging` reflects the active move/resize gesture. The + // x/y/width/height geometry is inline style (continuous), not data-attrs. + name: 'Selection', + kebab: 'selection', + archetype: 'group', + kind: 'public', + defaultElement: 'div', + optional: true, + data: [ + { + attr: 'data-shape', + values: ['rect', 'round'], + value: v.propRef('shape'), + severity: 'optional' + }, + { attr: 'data-dragging', value: v.propRef('dragging'), severity: 'optional' } + ], + aria: [] + }, + { + // Rule-of-thirds overlay inside the selection. + name: 'Grid', + kebab: 'grid', + archetype: 'group', + kind: 'public', + defaultElement: 'div', + optional: true, + data: [], + aria: [] + }, + { + // A resize handle (repeatable). `data-corner` names which corner / edge + // it drives, so eidos positions it + soma maps the drag axis. + name: 'Handle', + kebab: 'handle', + archetype: 'thumb', + kind: 'public', + defaultElement: 'div', + optional: true, + data: [ + { + attr: 'data-corner', + values: ['nw', 'ne', 'sw', 'se', 'n', 'e', 's', 'w'], + value: v.propRef('corner'), + severity: 'optional' + } + ], + aria: [] + } + ] +} as const satisfies Morfo; diff --git a/src/uix/morfo/components/image-adjustments.ts b/src/uix/morfo/components/image-adjustments.ts new file mode 100644 index 000000000..34202e7e7 --- /dev/null +++ b/src/uix/morfo/components/image-adjustments.ts @@ -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 `` 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 `` (not ``): + // 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; diff --git a/src/uix/morfo/components/image-picker.ts b/src/uix/morfo/components/image-picker.ts new file mode 100644 index 000000000..41be9c0d5 --- /dev/null +++ b/src/uix/morfo/components/image-picker.ts @@ -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; diff --git a/src/uix/sema/components/cropper.ts b/src/uix/sema/components/cropper.ts new file mode 100644 index 000000000..667ee0870 --- /dev/null +++ b/src/uix/sema/components/cropper.ts @@ -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>[2]) => + semaSelector(cropperMorfo, 'selection', matchers); +const onHandle = (matchers?: Parameters>[2]) => + semaSelector(cropperMorfo, 'handle', matchers); +const onViewport = (matchers?: Parameters>[2]) => + semaSelector(cropperMorfo, 'viewport', matchers); +const onProvider = (matchers?: Parameters>[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' } + } + ] +}; diff --git a/src/uix/sema/components/image-adjustments.ts b/src/uix/sema/components/image-adjustments.ts new file mode 100644 index 000000000..57cfccb05 --- /dev/null +++ b/src/uix/sema/components/image-adjustments.ts @@ -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 + * `` 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>[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' } + } + ] +}; diff --git a/src/uix/sema/components/image-picker.ts b/src/uix/sema/components/image-picker.ts new file mode 100644 index 000000000..ef5f449ba --- /dev/null +++ b/src/uix/sema/components/image-picker.ts @@ -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>[2]) => + semaSelector(imagePickerMorfo, 'provider', matchers); +const onPreview = (matchers?: Parameters>[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' } + } + ] +}; diff --git a/src/uix/sema/components/index.ts b/src/uix/sema/components/index.ts index 3c95ea839..ac5a3db7a 100644 --- a/src/uix/sema/components/index.ts +++ b/src/uix/sema/components/index.ts @@ -15,6 +15,7 @@ export { collapsibleSema } from './collapsible'; export { colorPickerSema } from './color-picker'; export { comboboxSema } from './combobox'; export { contextMenuSema } from './context-menu'; +export { cropperSema } from './cropper'; export { cssFieldSema } from './css-field'; export { dateFieldSema } from './date-field'; export { dialogSema } from './dialog'; @@ -24,6 +25,8 @@ export { editableSema } from './editable'; export { fileUploadSema } from './file-upload'; export { floatPanelSema } from './float-panel'; export { formSema } from './form'; +export { imageAdjustmentsSema } from './image-adjustments'; +export { imagePickerSema } from './image-picker'; export { menubarSema } from './menubar'; export { navigationMenuSema } from './navigation-menu'; export { numberFieldSema } from './number-field'; diff --git a/src/uix/sema/verbs.ts b/src/uix/sema/verbs.ts index e46147d93..1ea3d7ff2 100644 --- a/src/uix/sema/verbs.ts +++ b/src/uix/sema/verbs.ts @@ -114,7 +114,9 @@ export const SEMA_VERBS = { 'remind' ], - handle: ['pick', 'carry', 'drop', 'drag', 'resize', 'reorder', 'rotate', 'scroll'], + // `zoom` (extensión del autor 2026-06-10): manipulación directa de la escala + // del contenido (rueda / pinch), junto a drag / resize / rotate / scroll. + handle: ['pick', 'carry', 'drop', 'drag', 'resize', 'reorder', 'rotate', 'scroll', 'zoom'], emerge: [ 'present', diff --git a/src/uix/soma/components/cropper/README.md b/src/uix/soma/components/cropper/README.md new file mode 100644 index 000000000..4d935b792 --- /dev/null +++ b/src/uix/soma/components/cropper/README.md @@ -0,0 +1,72 @@ +# Cropper (soma) + +Headless image cropper — a movable / resizable **selection** over a fixed image +(Ark model), with the drag/resize math + the `` extraction to a Blob. It +consumes an image `src` (typically the `url` an ImagePicker emits) and produces a +cropped PNG, closing the pick → adjust → crop pipeline. + +## Anatomy + +```svelte + + {#snippet children({ crop, cropImage })} + + + + + + + + {/snippet} + +``` + +| Part | Element | Role | +| --- | --- | --- | +| `Provider` | `div` | `role="group"`, `data-cropper`, `data-disabled` | +| `Viewport` | `div` | the framed area; its px size converts pointer deltas → normalized | +| `Selection` | `div` | the crop rect; `data-shape` (rect/round), `data-dragging`. Geometry = inline style | +| `Grid` | `div` | rule-of-thirds overlay | +| `Handle` | `div` | resize handle (repeatable); `data-corner` | + +The crop rect is **normalized 0–1** (fraction of the image), so it's +resolution-independent. The viewport's aspect-ratio matches the image (no +letterbox), so the rect maps 1:1 to the image for the canvas extraction. + +## Sema events + +| Event | Family · verb | Target | Intent | When | +| --- | --- | --- | --- | --- | +| `handle-drag` | handle · drag | selection | — | start moving the selection | +| `handle-resize` | handle · resize | handle | — | start resizing via a handle | +| `handle-zoom` | handle · zoom | viewport | — | zoom the image (wheel / controls) | +| `commit-crop` | commit · apply | provider | affirm | the cropped Blob is produced | + +`handle-drag` / `handle-resize` fire **once at gesture start** + `handle-zoom` is +throttled (not per-frame — that would buzz haptics; the move itself is visual). +`zoom` is an author-added `handle` verb (2026-06-10). Pack: +`src/uix/sema/components/cropper.ts`. + +## API + +| Prop / method | Type | Default | Notes | +| --- | --- | --- | --- | +| `src` | `string \| null` | — | image to crop | +| `crop` | `CropRect` | centered 80% | bindable, normalized 0–1 | +| `aspect` | `number` | free | lock width/height ratio (display px) | +| `shape` | `'rect' \| 'round'` | `'rect'` | circular = alpha-masked Blob | +| `minSize` / `maxSize` | `number` | `0.05` / `1` | crop size range, fraction of the image | +| `fixedSize` | `number` | — | pin the crop size → move-only (no handles); avatar = round + fixedSize | +| `minScale` / `maxScale` | `number` | `1` / `5` | zoom limits | +| `disabled` | `boolean` | `false` | | +| `onCropChange` | `(rect) => void` | — | live | +| `onCropComplete` | `(r: CropResult) => void` | — | on `cropImage()` | +| `cropImage()` | `() => Promise` | — | via `bind:this` / snippet; canvas → Blob | +| `zoomTo()` / `resetZoom()` | snippet methods | — | drive zoom from a custom control | + +Zoom is the reusable **`soma/layers/zoom-pan`** (`createZoomPan`) — scale + offset ++ clamp + cursor-centered math, applicable to any image-viewer / pan surface. + +`moveRect` / `resizeRect` are exported pure functions (the geometry — fully +unit-tested). The canvas extraction uses `dom.getDocument()` / `dom.getWindow()` +(iframe-safe). diff --git a/src/uix/soma/components/cropper/components/cropper-grid.svelte b/src/uix/soma/components/cropper/components/cropper-grid.svelte new file mode 100644 index 000000000..ae6530a35 --- /dev/null +++ b/src/uix/soma/components/cropper/components/cropper-grid.svelte @@ -0,0 +1,35 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} +
+ {@render children?.()} +
+{/if} diff --git a/src/uix/soma/components/cropper/components/cropper-handle.svelte b/src/uix/soma/components/cropper/components/cropper-handle.svelte new file mode 100644 index 000000000..5ae356a3c --- /dev/null +++ b/src/uix/soma/components/cropper/components/cropper-handle.svelte @@ -0,0 +1,37 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} +
+ {@render children?.()} +
+{/if} diff --git a/src/uix/soma/components/cropper/components/cropper-selection.svelte b/src/uix/soma/components/cropper/components/cropper-selection.svelte new file mode 100644 index 000000000..d053f6353 --- /dev/null +++ b/src/uix/soma/components/cropper/components/cropper-selection.svelte @@ -0,0 +1,35 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} +
+ {@render children?.()} +
+{/if} diff --git a/src/uix/soma/components/cropper/components/cropper-viewport.svelte b/src/uix/soma/components/cropper/components/cropper-viewport.svelte new file mode 100644 index 000000000..77fd76074 --- /dev/null +++ b/src/uix/soma/components/cropper/components/cropper-viewport.svelte @@ -0,0 +1,35 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} +
+ {@render children?.()} +
+{/if} diff --git a/src/uix/soma/components/cropper/components/cropper.svelte b/src/uix/soma/components/cropper/components/cropper.svelte new file mode 100644 index 000000000..e4a8bff5a --- /dev/null +++ b/src/uix/soma/components/cropper/components/cropper.svelte @@ -0,0 +1,69 @@ + + +{#if child} + {@render child({ ...state.snippetProps, props: mergedProps })} +{:else} +
+ {@render children?.(state.snippetProps)} +
+{/if} diff --git a/src/uix/soma/components/cropper/cropper-provider.svelte.test.ts b/src/uix/soma/components/cropper/cropper-provider.svelte.test.ts new file mode 100644 index 000000000..82155d41d --- /dev/null +++ b/src/uix/soma/components/cropper/cropper-provider.svelte.test.ts @@ -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(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) => + 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(document.createElement('div')), + crop: state(crop), + src: state('blob:x'), + aspect: state(undefined), + shape: state<'rect' | 'round'>('rect'), + minSize: state(0.05), + maxSize: state(1), + fixedSize: state(undefined), + minScale: state(1), + maxScale: state(5), + disabled: state(false), + onCropChange: state<((c: CropRect) => void) | undefined>(undefined), + onCropComplete: state(undefined), + ariaLabel: state(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(); + }); +}); diff --git a/src/uix/soma/components/cropper/cropper-provider.svelte.ts b/src/uix/soma/components/cropper/cropper-provider.svelte.ts new file mode 100644 index 000000000..decda275a --- /dev/null +++ b/src/uix/soma/components/cropper/cropper-provider.svelte.ts @@ -0,0 +1,639 @@ +import { context, type WithRefOpts } from '../../provider'; +import { type Active, type ActiveProps, type StateProps } from '$libs/reactive'; +import type { OnChangeFn } from '../../types'; + +import { cropperMorfo } from '../../../morfo/components/cropper'; +import { Soma } from '../../core/soma.svelte'; +import type { SomaRuntime, SomaRuntimePart } from '../../runtime.svelte'; +import { ZoomPan } from '../../layers/zoom-pan.svelte'; +import { CROPPER_LANGS } from './langs'; +import type { CropCorner, CropRect, CropResult, CropShape } from './types'; + +// ── Pure geometry (normalized 0–1 fractions of the image / viewport) ───────── + +const clamp = (v: number, min: number, max: number) => Math.min(Math.max(v, min), max); + +/** Translate the rect, keeping it inside [0,1]. */ +export function moveRect(r: CropRect, dxN: number, dyN: number): CropRect { + return { + ...r, + x: clamp(r.x + dxN, 0, 1 - r.width), + y: clamp(r.y + dyN, 0, 1 - r.height) + }; +} + +/** + * Resize the rect by dragging `corner` by (dxN, dyN). Enforces `minSize`, the + * optional pixel `aspect` (needs the viewport px size W×H to relate normalized + * width↔height), and the [0,1] bounds. Edge clamping may relax aspect exactly at + * a boundary (documented v1 limitation). + */ +export function resizeRect( + r: CropRect, + corner: CropCorner, + dxN: number, + dyN: number, + aspect: number | undefined, + W: number, + H: number, + minSize: number, + maxSize = 1 +): CropRect { + let x = r.x; + let y = r.y; + let right = r.x + r.width; + let bottom = r.y + r.height; + + if (corner.includes('e')) right += dxN; + if (corner.includes('w')) x += dxN; + if (corner.includes('s')) bottom += dyN; + if (corner.includes('n')) y += dyN; + + let width = right - x; + let height = bottom - y; + + // Min size — push the moving edge back if it crossed the anchor. + if (width < minSize) { + if (corner.includes('w')) x = right - minSize; + width = minSize; + } + if (height < minSize) { + if (corner.includes('n')) y = bottom - minSize; + height = minSize; + } + + // Aspect lock — derive the dependent dimension, keeping the anchor edge. + if (aspect && aspect > 0 && W > 0 && H > 0) { + if (corner.includes('e') || corner.includes('w')) { + // A horizontal edge drove the change → derive height from width. + const targetHeight = (width * W) / (aspect * H); + if (corner.includes('n')) y = y + height - targetHeight; + height = targetHeight; + } else { + // A vertical-only edge (n / s) drove it → derive width from height. + const targetWidth = (height * aspect * H) / W; + width = targetWidth; + } + } + + // Max size — clamp from the anchor edge (the corner not being dragged). + if (corner.includes('w') && width > maxSize) x = x + width - maxSize; + if (corner.includes('n') && height > maxSize) y = y + height - maxSize; + width = Math.min(width, maxSize); + height = Math.min(height, maxSize); + + // Bounds. + x = clamp(x, 0, 1 - minSize); + y = clamp(y, 0, 1 - minSize); + width = clamp(width, minSize, 1 - x); + height = clamp(height, minSize, 1 - y); + + return { x, y, width, height }; +} + +const DEFAULT_CROP: CropRect = { x: 0.1, y: 0.1, width: 0.8, height: 0.8 }; + +function loadImage(win: Window, src: string): Promise { + return new Promise((resolve) => { + const ImageCtor = (win as unknown as { Image: typeof Image }).Image; + if (!ImageCtor) return resolve(null); + const img = new ImageCtor(); + img.crossOrigin = 'anonymous'; + img.onload = () => resolve(img); + img.onerror = () => resolve(null); + img.src = src; + }); +} + +// ── Root provider ──────────────────────────────────────────────────────────── + +interface CropperOpts + extends WithRefOpts, + StateProps<{ crop: CropRect }>, + ActiveProps<{ + src: string | null | undefined; + aspect: number | undefined; + shape: CropShape; + minSize: number; + maxSize: number; + fixedSize: number | undefined; + minScale: number; + maxScale: number; + disabled: boolean; + onCropChange: OnChangeFn | undefined; + onCropComplete: OnChangeFn | undefined; + }> { + ariaLabel?: Active; +} + +export class CropperProvider { + readonly opts: CropperOpts; + readonly runtimePart: SomaRuntimePart; + readonly soma: Soma; + readonly runtime: SomaRuntime; + + /** Active move/resize gesture flag — drives Selection's `data-dragging`. */ + dragging = $state(false); + /** The Viewport element, registered by the Viewport provider — its px size + * converts pointer deltas to normalized space. */ + viewportEl = $state(null); + /** Reusable zoom/pan state for the image (scale + offset). */ + readonly zoom: ZoomPan; + + static readonly ctx = context('Cropper'); + static get(): CropperProvider | undefined { + return this.ctx.getOr(undefined) as CropperProvider | undefined; + } + static require(): CropperProvider { + return this.ctx.get(); + } + static create(opts: CropperOpts) { + return new CropperProvider(opts); + } + + private constructor(opts: CropperOpts) { + this.opts = opts; + this.soma = Soma.require(); + this.zoom = ZoomPan.create({ + minScale: opts.minScale.current, + maxScale: opts.maxScale.current + }); + this.runtime = this.soma.runtime(cropperMorfo, { + props: { + disabled: () => this.opts.disabled.current, + ariaLabel: () => this.resolvedAriaLabel + }, + events: { + 'handle-drag': () => {}, + 'handle-resize': () => {}, + 'handle-zoom': () => {}, + 'commit-crop': () => {} + } + }); + this.runtimePart = this.runtime.part('provider', { + id: opts.id, + ref: opts.ref, + owner: this, + context: CropperProvider.ctx, + syncAttrs: true + }); + + // Fixed-size crop: pin the selection to `fixedSize` (respecting `aspect`), + // re-centering. Resize is then suppressed (no handles) — move-only. + $effect(() => { + const fixed = this.opts.fixedSize.current; + if (fixed == null) return; + const { w, h } = this.viewportSize(); + const aspect = this.opts.aspect.current; + const width = fixed; + const height = aspect && aspect > 0 ? (fixed * w) / (aspect * h) : fixed; + const c = this.opts.crop.current; + const cx = c.x + c.width / 2; + const cy = c.y + c.height / 2; + const next: CropRect = { + width, + height, + x: clamp(cx - width / 2, 0, 1 - width), + y: clamp(cy - height / 2, 0, 1 - height) + }; + if ( + Math.abs(next.width - c.width) > 1e-6 || + Math.abs(next.height - c.height) > 1e-6 + ) { + this.opts.crop.current = next; + } + }); + } + + /** True when the crop is a fixed size (move-only, no resize handles). */ + readonly isFixed = $derived.by(() => this.opts.fixedSize.current != null); + + readonly resolvedAriaLabel = $derived.by( + () => this.opts.ariaLabel?.current || this.soma.langs.ts(CROPPER_LANGS.LABEL) + ); + + /** Viewport px size, for pointer-delta → normalized conversion. */ + viewportSize(): { w: number; h: number } { + const el = this.viewportEl; + if (!el) return { w: 1, h: 1 }; + const r = el.getBoundingClientRect(); + return { w: r.width || 1, h: r.height || 1 }; + } + + /** Commit a new crop rect + fire `onCropChange`. */ + setCrop(rect: CropRect): void { + if (this.opts.disabled.current) return; + this.opts.crop.current = rect; + this.opts.onCropChange.current?.(rect); + } + + readonly isDisabled = $derived.by(() => this.opts.disabled.current); + + /** + * Produce the cropped image as a PNG Blob via an offscreen `` (alpha- + * masked when `shape='round'`). Fires `onCropComplete` + the `commit-crop` + * perceptual event. Returns null when there's no source / canvas support. + */ + async cropImage(): Promise { + const src = this.opts.src.current; + if (!src) return null; + const doc = this.soma.dom.getDocument(); + const win = this.soma.dom.getWindow(); + if (!doc || !win) return null; + + const img = await loadImage(win, src); + if (!img) return null; + const nw = img.naturalWidth || img.width; + const nh = img.naturalHeight || img.height; + if (!nw || !nh) return null; + + // Map the crop (viewport fractions) → image fractions, inverting the + // current zoom/pan. At scale 1 / offset 0 this is the identity. + const c = this.opts.crop.current; + const tl = this.zoom.viewportFracToContentFrac(c.x, c.y); + const br = this.zoom.viewportFracToContentFrac(c.x + c.width, c.y + c.height); + const sx = Math.round(clamp(tl.x, 0, 1) * nw); + const sy = Math.round(clamp(tl.y, 0, 1) * nh); + const sw = Math.max(1, Math.round((clamp(br.x, 0, 1) - clamp(tl.x, 0, 1)) * nw)); + const sh = Math.max(1, Math.round((clamp(br.y, 0, 1) - clamp(tl.y, 0, 1)) * nh)); + + const canvas = doc.createElement('canvas'); + canvas.width = sw; + canvas.height = sh; + const ctx = canvas.getContext('2d'); + if (!ctx) return null; + + const shape = this.opts.shape.current; + if (shape === 'round') { + ctx.save(); + ctx.beginPath(); + ctx.ellipse(sw / 2, sh / 2, sw / 2, sh / 2, 0, 0, Math.PI * 2); + ctx.closePath(); + ctx.clip(); + } + // 5-arg form (full image, offset) — NOT the 9-arg source-rect form, which + // silently draws nothing for SVGs that lack intrinsic width/height (Chrome). + // Drawing the whole image at natural size, offset by -(sx,sy), clips the + // (sx,sy,sw,sh) region into the canvas. Works for SVG + raster alike. + ctx.drawImage(img, -sx, -sy, nw, nh); + if (shape === 'round') ctx.restore(); + + const blob = await new Promise((resolve) => + canvas.toBlob((b) => resolve(b), 'image/png') + ); + if (!blob) return null; + + const result: CropResult = { blob, crop: { ...c }, shape }; + this.opts.onCropComplete.current?.(result); + void this.runtime.trigger('commit-crop'); + return result; + } + + // ── Zoom ───────────────────────────────────────────────────────────────── + + private zoomEmitThrottled = false; + private emitZoom(): void { + // Throttle the perceptual cue — the wheel fires fast. + if (this.zoomEmitThrottled) return; + this.zoomEmitThrottled = true; + void this.runtime.trigger('handle-zoom'); + const win = this.soma.dom.getWindow(); + (win ?? globalThis).setTimeout(() => (this.zoomEmitThrottled = false), 120); + } + + private syncZoomViewport(): void { + const { w, h } = this.viewportSize(); + this.zoom.setViewport(w, h); + } + + /** Zoom by a multiplicative factor, centered on a viewport point (px). */ + zoomBy(factor: number, cx?: number, cy?: number): void { + if (this.opts.disabled.current) return; + this.syncZoomViewport(); + this.zoom.zoomBy(factor, cx, cy); + this.emitZoom(); + } + + /** Set the absolute scale (clamped). */ + zoomTo(scale: number): void { + if (this.opts.disabled.current) return; + this.syncZoomViewport(); + this.zoom.zoomTo(scale); + this.emitZoom(); + } + + /** Back to scale 1, no pan. */ + resetZoom(): void { + this.zoom.reset(); + } + + /** Pan the image by a viewport-px delta. */ + panBy(dxPx: number, dyPx: number): void { + if (this.opts.disabled.current) return; + this.syncZoomViewport(); + this.zoom.panBy(dxPx, dyPx); + } + + readonly snippetProps = $derived.by(() => ({ + crop: this.opts.crop.current, + shape: this.opts.shape.current, + dragging: this.dragging, + disabled: this.opts.disabled.current, + scale: this.zoom.scale, + isZoomed: this.zoom.isZoomed, + isFixed: this.isFixed, + minScale: this.zoom.minScale, + maxScale: this.zoom.maxScale, + transform: this.zoom.transform, + cropImage: () => this.cropImage(), + zoomTo: (s: number) => this.zoomTo(s), + resetZoom: () => this.resetZoom() + })); + + readonly props = $derived.by(() => ({ ...this.runtimePart.props })); +} + +// ── Viewport ─────────────────────────────────────────────────────────────── + +interface CropperViewportOpts extends WithRefOpts {} + +export class CropperViewportProvider { + readonly opts: CropperViewportOpts; + readonly runtimePart: SomaRuntimePart; + readonly provider: CropperProvider; + + static create(opts: CropperViewportOpts) { + return new CropperViewportProvider(opts); + } + + private constructor(opts: CropperViewportOpts) { + this.opts = opts; + this.provider = CropperProvider.require(); + this.runtimePart = this.provider.runtime.part('viewport', { + id: opts.id, + ref: opts.ref, + owner: this, + syncAttrs: true + }); + // Register the viewport element with the root for px-size lookups. + this.provider.viewportEl = opts.ref.current; + $effect(() => { + this.provider.viewportEl = opts.ref.current; + }); + } + + private panLastX = 0; + private panLastY = 0; + private panning = false; + + private localPoint(e: PointerEvent | WheelEvent): { x: number; y: number } { + const r = this.opts.ref?.current?.getBoundingClientRect(); + return { x: e.clientX - (r?.left ?? 0), y: e.clientY - (r?.top ?? 0) }; + } + + readonly onwheel = (e: WheelEvent) => { + if (this.provider.isDisabled) return; + e.preventDefault(); + const p = this.localPoint(e); + this.provider.zoomBy(e.deltaY < 0 ? 1.12 : 1 / 1.12, p.x, p.y); + }; + + // Pan the image. Reaches here only on a background pointerdown — the Selection + // and Handles stop propagation. (At scale 1 the clamp keeps offset at 0.) + readonly onpointerdown = (e: PointerEvent) => { + if (this.provider.isDisabled) return; + const el = this.opts.ref?.current; + if (!el) return; + this.panning = true; + this.panLastX = e.clientX; + this.panLastY = e.clientY; + this.provider.dragging = true; + try { + el.setPointerCapture(e.pointerId); + } catch { + /* no active pointer (synthetic) */ + } + }; + + readonly onpointermove = (e: PointerEvent) => { + if (!this.panning) return; + this.provider.panBy(e.clientX - this.panLastX, e.clientY - this.panLastY); + this.panLastX = e.clientX; + this.panLastY = e.clientY; + }; + + readonly onpointerup = (e: PointerEvent) => { + if (!this.panning) return; + this.panning = false; + this.provider.dragging = false; + this.opts.ref?.current?.releasePointerCapture(e.pointerId); + }; + + // The image is a native `` → the browser starts its own drag-and-drop on + // pointerdown, which cancels the pan. Suppress it (bubbles up from the image). + readonly ondragstart = (e: Event) => e.preventDefault(); + + readonly props = $derived.by(() => ({ + ...this.runtimePart.props, + onwheel: this.onwheel, + onpointerdown: this.onpointerdown, + onpointermove: this.onpointermove, + onpointerup: this.onpointerup, + onpointercancel: this.onpointerup, + ondragstart: this.ondragstart + })); +} + +// ── Selection (move) ───────────────────────────────────────────────────────── + +interface CropperSelectionOpts extends WithRefOpts {} + +export class CropperSelectionProvider { + readonly opts: CropperSelectionOpts; + readonly runtimePart: SomaRuntimePart; + readonly provider: CropperProvider; + + private startCrop: CropRect | null = null; + private startX = 0; + private startY = 0; + private size = { w: 1, h: 1 }; + + static create(opts: CropperSelectionOpts) { + return new CropperSelectionProvider(opts); + } + + private constructor(opts: CropperSelectionOpts) { + this.opts = opts; + this.provider = CropperProvider.require(); + this.runtimePart = this.provider.runtime.part('selection', { + id: opts.id, + ref: opts.ref, + owner: this, + props: { + shape: () => this.provider.opts.shape.current, + dragging: () => this.provider.dragging + }, + syncAttrs: true + }); + } + + readonly onpointerdown = (e: PointerEvent) => { + if (this.provider.isDisabled) return; + const el = this.opts.ref?.current; + if (!el) return; + e.preventDefault(); + // The selection CAPTURES the drag and moves the crop area (Cropper.js model) + // — stop it bubbling to the Viewport's pan handler. The image is panned only + // by dragging the background; the handles resize. + e.stopPropagation(); + this.startCrop = { ...this.provider.opts.crop.current }; + this.startX = e.clientX; + this.startY = e.clientY; + this.size = this.provider.viewportSize(); + this.provider.dragging = true; + void this.provider.runtime.trigger('handle-drag'); + // Best-effort — capture keeps moves flowing here even outside the element. + try { + el.setPointerCapture(e.pointerId); + } catch { + /* no active pointer (synthetic events) */ + } + }; + + readonly onpointermove = (e: PointerEvent) => { + if (!this.startCrop) return; + const dxN = (e.clientX - this.startX) / this.size.w; + const dyN = (e.clientY - this.startY) / this.size.h; + this.provider.setCrop(moveRect(this.startCrop, dxN, dyN)); + }; + + readonly onpointerup = (e: PointerEvent) => { + if (!this.startCrop) return; + this.startCrop = null; + this.provider.dragging = false; + this.opts.ref?.current?.releasePointerCapture(e.pointerId); + }; + + readonly props = $derived.by(() => ({ + ...this.runtimePart.props, + onpointerdown: this.onpointerdown, + onpointermove: this.onpointermove, + onpointerup: this.onpointerup, + onpointercancel: this.onpointerup + })); +} + +// ── Grid ───────────────────────────────────────────────────────────────────── + +interface CropperGridOpts extends WithRefOpts {} + +export class CropperGridProvider { + readonly opts: CropperGridOpts; + readonly runtimePart: SomaRuntimePart; + readonly provider: CropperProvider; + + static create(opts: CropperGridOpts) { + return new CropperGridProvider(opts); + } + + private constructor(opts: CropperGridOpts) { + this.opts = opts; + this.provider = CropperProvider.require(); + this.runtimePart = this.provider.runtime.part('grid', { + id: opts.id, + ref: opts.ref, + owner: this, + syncAttrs: true + }); + } + + readonly props = $derived.by(() => ({ ...this.runtimePart.props })); +} + +// ── Handle (resize) ────────────────────────────────────────────────────────── + +interface CropperHandleOpts extends WithRefOpts, ActiveProps<{ corner: CropCorner }> {} + +export class CropperHandleProvider { + readonly opts: CropperHandleOpts; + readonly runtimePart: SomaRuntimePart; + readonly provider: CropperProvider; + + private startCrop: CropRect | null = null; + private startX = 0; + private startY = 0; + private size = { w: 1, h: 1 }; + + static create(opts: CropperHandleOpts) { + return new CropperHandleProvider(opts); + } + + private constructor(opts: CropperHandleOpts) { + this.opts = opts; + this.provider = CropperProvider.require(); + this.runtimePart = this.provider.runtime.part('handle', { + id: opts.id, + ref: opts.ref, + owner: this, + props: { + corner: () => this.opts.corner.current + }, + syncAttrs: true + }); + } + + readonly onpointerdown = (e: PointerEvent) => { + if (this.provider.isDisabled || this.provider.isFixed) return; + const el = this.opts.ref?.current; + if (!el) return; + // Stop the Selection's move handler from also firing on this pointerdown — + // must run BEFORE setPointerCapture (which can throw on synthetic events). + e.preventDefault(); + e.stopPropagation(); + this.startCrop = { ...this.provider.opts.crop.current }; + this.startX = e.clientX; + this.startY = e.clientY; + this.size = this.provider.viewportSize(); + this.provider.dragging = true; + void this.provider.runtime.trigger('handle-resize'); + try { + el.setPointerCapture(e.pointerId); + } catch { + /* no active pointer (synthetic events) */ + } + }; + + readonly onpointermove = (e: PointerEvent) => { + if (!this.startCrop) return; + const dxN = (e.clientX - this.startX) / this.size.w; + const dyN = (e.clientY - this.startY) / this.size.h; + this.provider.setCrop( + resizeRect( + this.startCrop, + this.opts.corner.current, + dxN, + dyN, + this.provider.opts.aspect.current, + this.size.w, + this.size.h, + this.provider.opts.minSize.current, + this.provider.opts.maxSize.current + ) + ); + }; + + readonly onpointerup = (e: PointerEvent) => { + if (!this.startCrop) return; + this.startCrop = null; + this.provider.dragging = false; + this.opts.ref?.current?.releasePointerCapture(e.pointerId); + }; + + readonly props = $derived.by(() => ({ + ...this.runtimePart.props, + onpointerdown: this.onpointerdown, + onpointermove: this.onpointermove, + onpointerup: this.onpointerup, + onpointercancel: this.onpointerup + })); +} diff --git a/src/uix/soma/components/cropper/exports.ts b/src/uix/soma/components/cropper/exports.ts new file mode 100644 index 000000000..0073218bd --- /dev/null +++ b/src/uix/soma/components/cropper/exports.ts @@ -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'; diff --git a/src/uix/soma/components/cropper/index.ts b/src/uix/soma/components/cropper/index.ts new file mode 100644 index 000000000..8570a426a --- /dev/null +++ b/src/uix/soma/components/cropper/index.ts @@ -0,0 +1 @@ +export * from './exports'; diff --git a/src/uix/soma/components/cropper/langs.ts b/src/uix/soma/components/cropper/langs.ts new file mode 100644 index 000000000..6acf5c645 --- /dev/null +++ b/src/uix/soma/components/cropper/langs.ts @@ -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; diff --git a/src/uix/soma/components/cropper/types.ts b/src/uix/soma/components/cropper/types.ts new file mode 100644 index 000000000..835a11734 --- /dev/null +++ b/src/uix/soma/components/cropper/types.ts @@ -0,0 +1,106 @@ +import type { WithChild, Without, OnChangeFn, PrimitiveDivAttributes } from '../../types'; + +/** Crop rectangle, NORMALIZED to the image (0–1 fractions). Resolution-independent. */ +export interface CropRect { + x: number; + y: number; + width: number; + height: number; +} + +/** Crop shape — rectangular or circular (avatar). */ +export type CropShape = 'rect' | 'round'; + +/** A resize handle position. v1 renders the four corners. */ +export type CropCorner = 'nw' | 'ne' | 'sw' | 'se' | 'n' | 'e' | 's' | 'w'; + +/** Payload of `onCropComplete` / the `crop()` method — the produced image. */ +export interface CropResult { + /** The cropped image as a PNG Blob (alpha-masked when `shape='round'`). */ + blob: Blob; + /** The normalized crop rect that produced it. */ + crop: CropRect; + /** The shape used. */ + shape: CropShape; +} + +export type CropperProps = WithChild< + { + /** Unique identifier. Auto-generated if omitted. */ + id?: string; + /** Image source to crop — typically the `url` an ImagePicker emits. */ + src?: string | null; + /** The crop rect, normalized 0–1. Bindable. @default centered 80% */ + crop?: CropRect; + /** Lock the crop to this width/height ratio (display pixels). Omit for free. */ + aspect?: number; + /** Crop shape. @default 'rect' */ + shape?: CropShape; + /** Minimum crop size as a fraction of the image (0–1). @default 0.05 */ + minSize?: number; + /** Maximum crop size as a fraction of the image (0–1). @default 1 */ + maxSize?: number; + /** + * Fix the crop to this size (fraction of the image) — the selection becomes + * move-only (no resize handles). Combine with `shape='round'` for a + * fixed-radius avatar crop. Respects `aspect` for the height. + */ + fixedSize?: number; + /** Minimum zoom scale. @default 1 */ + minScale?: number; + /** Maximum zoom scale. @default 5 */ + maxScale?: number; + /** Disable interaction. @default false */ + disabled?: boolean; + /** Fired live as the crop rect changes (move / resize). */ + onCropChange?: OnChangeFn; + /** Fired when `crop()` produces the cropped image. */ + onCropComplete?: OnChangeFn; + }, + CropperSnippetProps +> & + Without; + +/** Snippet props exposed by the Provider's `children` / `child`. */ +export type CropperSnippetProps = { + /** The current normalized crop rect. */ + crop: CropRect; + /** The crop shape. */ + shape: CropShape; + /** Whether a move / resize gesture is active. */ + dragging: boolean; + /** Whether interaction is disabled. */ + disabled: boolean; + /** Current zoom scale. */ + scale: number; + /** Whether the image is zoomed past the minimum. */ + isZoomed: boolean; + /** Whether the crop is a fixed size (no resize handles). */ + isFixed: boolean; + /** Min / max zoom scale. */ + minScale: number; + maxScale: number; + /** CSS transform for the image element. */ + transform: string; + /** Produce the cropped image now (also fires `onCropComplete`). */ + cropImage: () => Promise; + /** Set the absolute zoom scale. */ + zoomTo: (scale: number) => void; + /** Reset zoom to 1 / no pan. */ + resetZoom: () => void; +}; + +export type CropperViewportProps = WithChild<{ id?: string }> & + Without; + +export type CropperSelectionProps = WithChild<{ id?: string }> & + Without; + +export type CropperGridProps = WithChild<{ id?: string }> & Without; + +export type CropperHandleProps = WithChild<{ + id?: string; + /** Which corner / edge this handle drives. */ + corner: CropCorner; +}> & + Without; diff --git a/src/uix/soma/components/image-adjustments/README.md b/src/uix/soma/components/image-adjustments/README.md new file mode 100644 index 000000000..42e684bc4 --- /dev/null +++ b/src/uix/soma/components/image-adjustments/README.md @@ -0,0 +1,80 @@ +# ImageAdjustments (soma) + +Headless provider for a panel of **image-filter sliders**. Holds the per-adjustment +values, computes a live CSS `filter` string, and exposes a reset. It does **not** +render sliders — it composes the framework's `` one per row in eidos, and +streams the recomputed `filter` out so any surface can apply it. + +> The component's real product is a portable CSS `filter` string. Wire +> `onFilterChange` to a preview ``, an avatar, a Words image, or a future +> cropper — the math lives here once. + +## Anatomy + +```svelte + + + … + + … + + … + + +``` + +| Part | Element | Role | +| --- | --- | --- | +| `Provider` | `div` | `role="group"`, `data-image-adjustments`, `data-disabled` | +| `Item` | `div` | one adjustment row; `data-adjustment="{key}"` | +| `ItemLabel` | `span` | the adjustment's localized label | +| `ItemValue` | `span` | the formatted read-out (`+20`, `60°`, `4px`, …) | +| `Reset` | `button` | resets every adjustment to neutral | + +`Item`/`ItemLabel`/`ItemValue`/`Reset` are the **internal** parts the configured +eidos component renders in a loop — consumers normally use the eidos +`` and never compose them by hand. + +## Adjustment catalog + +`IMAGE_ADJUSTMENTS` (exported) maps each key to its range + CSS `filter()` fragment. +`computeImageFilter(values, order)` is a pure function — `'none'` when neutral. + +| Key | Range | CSS | +| --- | --- | --- | +| `brightness` | −100…100 | `brightness((100+v)/100)` | +| `contrast` | −100…100 | `contrast((100+v)/100)` | +| `saturation` | −100…100 | `saturate((100+v)/100)` | +| `temperature` | −100…100 | approx — warm→`sepia`, cool→`hue-rotate`+`saturate` | +| `hue` | −180…180° | `hue-rotate(v deg)` | +| `blur` | 0…20px | `blur(v px)` | +| `grayscale` | 0…100% | `grayscale(v%)` | +| `sepia` | 0…100% | `sepia(v%)` | + +`temperature` is a documented CSS-only approximation (no native white-balance). +Tone curves (`highlights`/`shadows`) need canvas / SVG `feComponentTransfer` and are +intentionally out of scope. + +## Sema events + +| Event | Family · verb | Target | Intent | When | +| --- | --- | --- | --- | --- | +| `commit-reset` | commit · reset | provider | neutral | user resets every adjustment to neutral | + +Per-slider drag feedback (`handle-drag`) is owned by the composed `` pack — +ImageAdjustments doesn't re-emit it. Pack: `src/uix/sema/components/image-adjustments.ts` +(`form.commit.soft` + a `tap`). `sequence: 'post'` — the values mutate first, then the +pulse. + +## Props (Provider) + +| Prop | Type | Default | Notes | +| --- | --- | --- | --- | +| `value` | `ImageAdjustmentValues` | `{}` | bindable; missing keys = neutral | +| `onValueChange` | `(v) => void` | — | full values record on any change | +| `onFilterChange` | `(filter) => void` | — | recomputed CSS `filter` string | +| `adjustments` | `ImageAdjustmentKey[]` | core 6 | which adjustments + order | +| `disabled` | `boolean` | `false` | blocks sliders + reset | +| `aria-label` | `string` | "Image adjustments" | names the group | + +Reuses `Slider` (one per `Item`). Never reinvents a slider. diff --git a/src/uix/soma/components/image-adjustments/components/image-adjustments-item-label.svelte b/src/uix/soma/components/image-adjustments/components/image-adjustments-item-label.svelte new file mode 100644 index 000000000..7cad27eda --- /dev/null +++ b/src/uix/soma/components/image-adjustments/components/image-adjustments-item-label.svelte @@ -0,0 +1,35 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} + + {@render children?.()} + +{/if} diff --git a/src/uix/soma/components/image-adjustments/components/image-adjustments-item-value.svelte b/src/uix/soma/components/image-adjustments/components/image-adjustments-item-value.svelte new file mode 100644 index 000000000..e2b304730 --- /dev/null +++ b/src/uix/soma/components/image-adjustments/components/image-adjustments-item-value.svelte @@ -0,0 +1,35 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} + + {@render children?.()} + +{/if} diff --git a/src/uix/soma/components/image-adjustments/components/image-adjustments-item.svelte b/src/uix/soma/components/image-adjustments/components/image-adjustments-item.svelte new file mode 100644 index 000000000..3d8e715e1 --- /dev/null +++ b/src/uix/soma/components/image-adjustments/components/image-adjustments-item.svelte @@ -0,0 +1,47 @@ + + +{#if child} + {@render child({ ...snippetProps, props: mergedProps })} +{:else} +
+ {@render children?.(snippetProps)} +
+{/if} diff --git a/src/uix/soma/components/image-adjustments/components/image-adjustments-reset.svelte b/src/uix/soma/components/image-adjustments/components/image-adjustments-reset.svelte new file mode 100644 index 000000000..31d012919 --- /dev/null +++ b/src/uix/soma/components/image-adjustments/components/image-adjustments-reset.svelte @@ -0,0 +1,37 @@ + + +{#if child} + {@render child({ ...snippetProps, props: mergedProps })} +{:else} + +{/if} diff --git a/src/uix/soma/components/image-adjustments/components/image-adjustments.svelte b/src/uix/soma/components/image-adjustments/components/image-adjustments.svelte new file mode 100644 index 000000000..39e67a216 --- /dev/null +++ b/src/uix/soma/components/image-adjustments/components/image-adjustments.svelte @@ -0,0 +1,50 @@ + + +{#if child} + {@render child({ ...state.snippetProps, props: mergedProps })} +{:else} +
+ {@render children?.(state.snippetProps)} +
+{/if} diff --git a/src/uix/soma/components/image-adjustments/exports.ts b/src/uix/soma/components/image-adjustments/exports.ts new file mode 100644 index 000000000..e517a8025 --- /dev/null +++ b/src/uix/soma/components/image-adjustments/exports.ts @@ -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'; diff --git a/src/uix/soma/components/image-adjustments/image-adjustments-provider.svelte.test.ts b/src/uix/soma/components/image-adjustments/image-adjustments-provider.svelte.test.ts new file mode 100644 index 000000000..1538b257d --- /dev/null +++ b/src/uix/soma/components/image-adjustments/image-adjustments-provider.svelte.test.ts @@ -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(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) => + 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(document.createElement('div')), + value: state({}), + onValueChange: state<((v: ImageAdjustmentValues) => void) | undefined>(undefined), + onFilterChange: state<((f: string) => void) | undefined>(undefined), + adjustments: state(undefined), + disabled: state(false), + ariaLabel: state(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' }); + }); +}); diff --git a/src/uix/soma/components/image-adjustments/image-adjustments-provider.svelte.ts b/src/uix/soma/components/image-adjustments/image-adjustments-provider.svelte.ts new file mode 100644 index 000000000..3226a00a2 --- /dev/null +++ b/src/uix/soma/components/image-adjustments/image-adjustments-provider.svelte.ts @@ -0,0 +1,392 @@ +import { context, type WithRefOpts } from '../../provider'; +import { type Active, type ActiveProps, type StateProps } from '$libs/reactive'; +import type { OnChangeFn } from '../../types'; + +import { imageAdjustmentsMorfo } from '../../../morfo/components/image-adjustments'; +import { Soma } from '../../core/soma.svelte'; +import type { SomaRuntime, SomaRuntimePart } from '../../runtime.svelte'; +import { IMAGE_ADJUSTMENTS_LANGS, IMAGE_ADJUSTMENT_LABELS } from './langs'; +import type { AdjustmentDef, ImageAdjustmentKey, ImageAdjustmentValues } from './types'; + +// ── Adjustment catalog ───────────────────────────────────────────────────── +// +// Each adjustment maps a numeric value to a CSS `filter()` fragment (logic lives +// here in soma — never in morfo). `toFilter` returns '' at the neutral default so +// the composed string only carries non-neutral terms. `temperature` has no native +// CSS primitive, so it's a documented approximation (warm → sepia, cool → blue +// hue-rotate + slight saturation lift). Tone curves (highlights/shadows) need +// canvas / SVG and are deliberately NOT here. + +const round = (v: number) => Math.round(v); +const toneFormat = (v: number) => (v > 0 ? `+${round(v)}` : String(round(v))); + +/** The canonical adjustment catalog. Exported so eidos / demos can introspect ranges. */ +export const IMAGE_ADJUSTMENTS: Record = { + brightness: { + min: -100, + max: 100, + default: 0, + step: 1, + unit: '', + toFilter: (v) => (v === 0 ? '' : `brightness(${(100 + v) / 100})`), + format: toneFormat + }, + contrast: { + min: -100, + max: 100, + default: 0, + step: 1, + unit: '', + toFilter: (v) => (v === 0 ? '' : `contrast(${(100 + v) / 100})`), + format: toneFormat + }, + saturation: { + min: -100, + max: 100, + default: 0, + step: 1, + unit: '', + toFilter: (v) => (v === 0 ? '' : `saturate(${(100 + v) / 100})`), + format: toneFormat + }, + temperature: { + min: -100, + max: 100, + default: 0, + step: 1, + unit: '', + // CSS has no white-balance primitive — approximate: warm (+) leans sepia, + // cool (−) hue-rotates toward blue and lifts saturation slightly. + toFilter: (v) => { + if (v === 0) return ''; + if (v > 0) return `sepia(${((v / 100) * 0.5).toFixed(3)})`; + return `hue-rotate(${(v * 0.35).toFixed(1)}deg) saturate(${(1 + -v / 250).toFixed(3)})`; + }, + format: toneFormat + }, + hue: { + min: -180, + max: 180, + default: 0, + step: 1, + unit: '°', + toFilter: (v) => (v === 0 ? '' : `hue-rotate(${round(v)}deg)`), + format: (v) => `${round(v)}°` + }, + blur: { + min: 0, + max: 20, + default: 0, + step: 0.5, + unit: 'px', + toFilter: (v) => (v <= 0 ? '' : `blur(${v}px)`), + format: (v) => `${v}px` + }, + grayscale: { + min: 0, + max: 100, + default: 0, + step: 1, + unit: '%', + toFilter: (v) => (v <= 0 ? '' : `grayscale(${round(v)}%)`), + format: (v) => `${round(v)}%` + }, + sepia: { + min: 0, + max: 100, + default: 0, + step: 1, + unit: '%', + toFilter: (v) => (v <= 0 ? '' : `sepia(${round(v)}%)`), + format: (v) => `${round(v)}%` + } +}; + +/** Default adjustment set + order — the core tone controls. */ +export const DEFAULT_IMAGE_ADJUSTMENTS: readonly ImageAdjustmentKey[] = [ + 'brightness', + 'contrast', + 'saturation', + 'temperature', + 'hue', + 'blur' +]; + +/** Pure value → CSS `filter` string. 'none' when every adjustment is neutral. */ +export function computeImageFilter( + values: ImageAdjustmentValues, + order: readonly ImageAdjustmentKey[] +): string { + const parts: string[] = []; + for (const key of order) { + const def = IMAGE_ADJUSTMENTS[key]; + const frag = def.toFilter(values[key] ?? def.default); + if (frag) parts.push(frag); + } + return parts.length ? parts.join(' ') : 'none'; +} + +// ── Root provider ──────────────────────────────────────────────────────────── + +interface ImageAdjustmentsOpts + extends WithRefOpts, + StateProps<{ value: ImageAdjustmentValues }>, + ActiveProps<{ + adjustments: ImageAdjustmentKey[] | undefined; + disabled: boolean; + onValueChange: OnChangeFn | undefined; + onFilterChange: OnChangeFn | undefined; + }> { + ariaLabel?: Active; +} + +export class ImageAdjustmentsProvider { + readonly opts: ImageAdjustmentsOpts; + readonly runtimePart: SomaRuntimePart; + readonly soma: Soma; + readonly runtime: SomaRuntime; + + static readonly ctx = context('ImageAdjustments'); + static get(): ImageAdjustmentsProvider | undefined { + return this.ctx.getOr(undefined) as ImageAdjustmentsProvider | undefined; + } + static require(): ImageAdjustmentsProvider { + return this.ctx.get(); + } + static create(opts: ImageAdjustmentsOpts) { + return new ImageAdjustmentsProvider(opts); + } + + private constructor(opts: ImageAdjustmentsOpts) { + this.opts = opts; + this.soma = Soma.require(); + this.runtime = this.soma.runtime(imageAdjustmentsMorfo, { + props: { + disabled: () => this.opts.disabled.current, + ariaLabel: () => this.resolvedAriaLabel + }, + events: { + // Reset is post-sequence: mutate first, then the perceptual pulse. + 'commit-reset': () => { + const next: ImageAdjustmentValues = {}; + this.opts.value.current = next; + this.opts.onValueChange.current?.(next); + this.opts.onFilterChange.current?.( + computeImageFilter(next, this.resolvedAdjustments) + ); + } + } + }); + this.runtimePart = this.runtime.part('provider', { + id: opts.id, + ref: opts.ref, + owner: this, + context: ImageAdjustmentsProvider.ctx, + syncAttrs: true + }); + } + + /** Resolved adjustment set + order (the prop, or the default core set). */ + readonly resolvedAdjustments = $derived.by( + () => this.opts.adjustments.current ?? DEFAULT_IMAGE_ADJUSTMENTS + ); + + readonly resolvedAriaLabel = $derived.by( + () => this.opts.ariaLabel?.current || this.soma.langs.ts(IMAGE_ADJUSTMENTS_LANGS.LABEL) + ); + + /** Live CSS `filter` string for the current values. */ + readonly filter = $derived.by(() => + computeImageFilter(this.opts.value.current, this.resolvedAdjustments) + ); + + /** True when any adjustment differs from its neutral default. */ + readonly isModified = $derived.by(() => + this.resolvedAdjustments.some( + (key) => (this.opts.value.current[key] ?? IMAGE_ADJUSTMENTS[key].default) !== IMAGE_ADJUSTMENTS[key].default + ) + ); + + /** Current value for an adjustment (its neutral default when unset). */ + getAdjustment(key: ImageAdjustmentKey): number { + return this.opts.value.current[key] ?? IMAGE_ADJUSTMENTS[key].default; + } + + /** Set one adjustment; fires `onValueChange` + `onFilterChange`. */ + setAdjustment(key: ImageAdjustmentKey, value: number): void { + if (this.opts.disabled.current) return; + const next: ImageAdjustmentValues = { ...this.opts.value.current, [key]: value }; + this.opts.value.current = next; + this.opts.onValueChange.current?.(next); + this.opts.onFilterChange.current?.(computeImageFilter(next, this.resolvedAdjustments)); + } + + /** Reset every adjustment to its neutral default. Drives `commit-reset`. */ + reset(): void { + if (this.opts.disabled.current) return; + void this.runtime.trigger('commit-reset'); + } + + readonly snippetProps = $derived.by(() => ({ + filter: this.filter, + isModified: this.isModified, + values: this.opts.value.current + })); + + readonly props = $derived.by(() => ({ ...this.runtimePart.props })); +} + +// ── Item ─────────────────────────────────────────────────────────────────── + +interface ImageAdjustmentsItemOpts + extends WithRefOpts, + ActiveProps<{ adjustment: ImageAdjustmentKey }> {} + +export class ImageAdjustmentsItemProvider { + readonly opts: ImageAdjustmentsItemOpts; + readonly runtimePart: SomaRuntimePart; + readonly provider: ImageAdjustmentsProvider; + + static readonly ctx = context('ImageAdjustmentsItem'); + static require(): ImageAdjustmentsItemProvider { + return this.ctx.get(); + } + static create(opts: ImageAdjustmentsItemOpts) { + return new ImageAdjustmentsItemProvider(opts); + } + + private constructor(opts: ImageAdjustmentsItemOpts) { + this.opts = opts; + this.provider = ImageAdjustmentsProvider.require(); + this.runtimePart = this.provider.runtime.part('item', { + id: opts.id, + ref: opts.ref, + owner: this, + context: ImageAdjustmentsItemProvider.ctx, + props: { + adjustment: () => this.opts.adjustment.current + }, + syncAttrs: true + }); + } + + /** This row's adjustment definition (range / step / unit / mapping). */ + readonly def = $derived.by(() => IMAGE_ADJUSTMENTS[this.opts.adjustment.current]); + + /** Current value (neutral default when unset). */ + readonly value = $derived.by(() => this.provider.getAdjustment(this.opts.adjustment.current)); + + /** Localized label for this adjustment. */ + readonly label = $derived.by(() => + this.provider.soma.langs.ts(IMAGE_ADJUSTMENT_LABELS[this.opts.adjustment.current]) + ); + + /** Formatted read-out of the current value. */ + readonly formattedValue = $derived.by(() => this.def.format(this.value)); + + readonly isDisabled = $derived.by(() => this.provider.opts.disabled.current); + + /** Set this row's value (used by the composed ``). */ + setValue(value: number): void { + this.provider.setAdjustment(this.opts.adjustment.current, value); + } + + readonly props = $derived.by(() => ({ ...this.runtimePart.props })); +} + +// ── ItemLabel ──────────────────────────────────────────────────────────────── + +interface ImageAdjustmentsItemLabelOpts extends WithRefOpts {} + +export class ImageAdjustmentsItemLabelProvider { + readonly opts: ImageAdjustmentsItemLabelOpts; + readonly runtimePart: SomaRuntimePart; + readonly item: ImageAdjustmentsItemProvider; + + static create(opts: ImageAdjustmentsItemLabelOpts) { + return new ImageAdjustmentsItemLabelProvider(opts); + } + + private constructor(opts: ImageAdjustmentsItemLabelOpts) { + this.opts = opts; + this.item = ImageAdjustmentsItemProvider.require(); + this.runtimePart = this.item.provider.runtime.part('item-label', { + id: opts.id, + ref: opts.ref, + owner: this, + syncAttrs: true + }); + } + + readonly props = $derived.by(() => ({ ...this.runtimePart.props })); +} + +// ── ItemValue ──────────────────────────────────────────────────────────────── + +interface ImageAdjustmentsItemValueOpts extends WithRefOpts {} + +export class ImageAdjustmentsItemValueProvider { + readonly opts: ImageAdjustmentsItemValueOpts; + readonly runtimePart: SomaRuntimePart; + readonly item: ImageAdjustmentsItemProvider; + + static create(opts: ImageAdjustmentsItemValueOpts) { + return new ImageAdjustmentsItemValueProvider(opts); + } + + private constructor(opts: ImageAdjustmentsItemValueOpts) { + this.opts = opts; + this.item = ImageAdjustmentsItemProvider.require(); + this.runtimePart = this.item.provider.runtime.part('item-value', { + id: opts.id, + ref: opts.ref, + owner: this, + syncAttrs: true + }); + } + + readonly props = $derived.by(() => ({ ...this.runtimePart.props })); +} + +// ── Reset ──────────────────────────────────────────────────────────────────── + +interface ImageAdjustmentsResetOpts extends WithRefOpts {} + +export class ImageAdjustmentsResetProvider { + readonly opts: ImageAdjustmentsResetOpts; + readonly runtimePart: SomaRuntimePart; + readonly provider: ImageAdjustmentsProvider; + + static create(opts: ImageAdjustmentsResetOpts) { + return new ImageAdjustmentsResetProvider(opts); + } + + private constructor(opts: ImageAdjustmentsResetOpts) { + this.opts = opts; + this.provider = ImageAdjustmentsProvider.require(); + this.runtimePart = this.provider.runtime.part('reset', { + id: opts.id, + ref: opts.ref, + owner: this, + syncAttrs: true + }); + } + + readonly isDisabled = $derived.by( + () => this.provider.opts.disabled.current || !this.provider.isModified + ); + + readonly onclick = () => { + if (this.isDisabled) return; + this.provider.reset(); + }; + + readonly label = $derived.by(() => this.provider.soma.langs.ts(IMAGE_ADJUSTMENTS_LANGS.RESET)); + + readonly props = $derived.by(() => ({ + ...this.runtimePart.props, + disabled: this.isDisabled || undefined, + 'aria-label': this.label, + onclick: this.onclick + })); +} diff --git a/src/uix/soma/components/image-adjustments/index.ts b/src/uix/soma/components/image-adjustments/index.ts new file mode 100644 index 000000000..8570a426a --- /dev/null +++ b/src/uix/soma/components/image-adjustments/index.ts @@ -0,0 +1 @@ +export * from './exports'; diff --git a/src/uix/soma/components/image-adjustments/langs.ts b/src/uix/soma/components/image-adjustments/langs.ts new file mode 100644 index 000000000..b6b19d676 --- /dev/null +++ b/src/uix/soma/components/image-adjustments/langs.ts @@ -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 = { + 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' +}; diff --git a/src/uix/soma/components/image-adjustments/types.ts b/src/uix/soma/components/image-adjustments/types.ts new file mode 100644 index 000000000..85ff8a33f --- /dev/null +++ b/src/uix/soma/components/image-adjustments/types.ts @@ -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>; + +/** 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; + /** Callback with the recomputed CSS `filter` string (e.g. `brightness(1.1) contrast(0.9)`). */ + onFilterChange?: OnChangeFn; + /** + * 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; + +export type ImageAdjustmentsItemProps = WithChild< + { + id?: string; + /** Which adjustment this row drives. */ + adjustment: ImageAdjustmentKey; + }, + ImageAdjustmentsItemSnippetProps +> & + Without; + +export type ImageAdjustmentsItemLabelProps = WithChild<{ id?: string }> & + Without; + +export type ImageAdjustmentsItemValueProps = WithChild<{ id?: string }> & + Without; + +export type ImageAdjustmentsResetProps = WithChild< + { id?: string }, + ImageAdjustmentsResetSnippetProps +> & + Without; diff --git a/src/uix/soma/components/image-picker/README.md b/src/uix/soma/components/image-picker/README.md new file mode 100644 index 000000000..180599c45 --- /dev/null +++ b/src/uix/soma/components/image-picker/README.md @@ -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 + + {#snippet children({ state, url, filter, fit, prompt, setFile })} + {#if state === 'empty'} + + {:else} + + + + + + + + {/if} + {/snippet} + +``` + +| 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. diff --git a/src/uix/soma/components/image-picker/components/image-picker-preview.svelte b/src/uix/soma/components/image-picker/components/image-picker-preview.svelte new file mode 100644 index 000000000..1c271f255 --- /dev/null +++ b/src/uix/soma/components/image-picker/components/image-picker-preview.svelte @@ -0,0 +1,35 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} +
+ {@render children?.()} +
+{/if} diff --git a/src/uix/soma/components/image-picker/components/image-picker-remove.svelte b/src/uix/soma/components/image-picker/components/image-picker-remove.svelte new file mode 100644 index 000000000..24af5f83a --- /dev/null +++ b/src/uix/soma/components/image-picker/components/image-picker-remove.svelte @@ -0,0 +1,37 @@ + + +{#if child} + {@render child({ ...snippetProps, props: mergedProps })} +{:else} + +{/if} diff --git a/src/uix/soma/components/image-picker/components/image-picker-rotate.svelte b/src/uix/soma/components/image-picker/components/image-picker-rotate.svelte new file mode 100644 index 000000000..71d226fbf --- /dev/null +++ b/src/uix/soma/components/image-picker/components/image-picker-rotate.svelte @@ -0,0 +1,37 @@ + + +{#if child} + {@render child({ ...snippetProps, props: mergedProps })} +{:else} + +{/if} diff --git a/src/uix/soma/components/image-picker/components/image-picker-toolbar.svelte b/src/uix/soma/components/image-picker/components/image-picker-toolbar.svelte new file mode 100644 index 000000000..1acbf4f69 --- /dev/null +++ b/src/uix/soma/components/image-picker/components/image-picker-toolbar.svelte @@ -0,0 +1,35 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} +
+ {@render children?.()} +
+{/if} diff --git a/src/uix/soma/components/image-picker/components/image-picker.svelte b/src/uix/soma/components/image-picker/components/image-picker.svelte new file mode 100644 index 000000000..f619c06d7 --- /dev/null +++ b/src/uix/soma/components/image-picker/components/image-picker.svelte @@ -0,0 +1,67 @@ + + +{#if child} + {@render child({ ...state.snippetProps, props: mergedProps })} +{:else} +
+ {@render children?.(state.snippetProps)} +
+{/if} diff --git a/src/uix/soma/components/image-picker/exports.ts b/src/uix/soma/components/image-picker/exports.ts new file mode 100644 index 000000000..91e3de2a3 --- /dev/null +++ b/src/uix/soma/components/image-picker/exports.ts @@ -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'; diff --git a/src/uix/soma/components/image-picker/image-picker-provider.svelte.test.ts b/src/uix/soma/components/image-picker/image-picker-provider.svelte.test.ts new file mode 100644 index 000000000..a4977f2f8 --- /dev/null +++ b/src/uix/soma/components/image-picker/image-picker-provider.svelte.test.ts @@ -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(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) => + 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(document.createElement('div')), + file: state(null), + fit: state('cover'), + rotation: state(0), + adjustments: state({}), + adjustmentKeys: state(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(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(); + }); +}); diff --git a/src/uix/soma/components/image-picker/image-picker-provider.svelte.ts b/src/uix/soma/components/image-picker/image-picker-provider.svelte.ts new file mode 100644 index 000000000..be17f3900 --- /dev/null +++ b/src/uix/soma/components/image-picker/image-picker-provider.svelte.ts @@ -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 | undefined; + onSelect: OnChangeFn | undefined; + onRemove: (() => void) | undefined; + }> { + ariaLabel?: Active; +} + +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(null); + + static readonly ctx = context('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( + () => 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 + })); +} diff --git a/src/uix/soma/components/image-picker/index.ts b/src/uix/soma/components/image-picker/index.ts new file mode 100644 index 000000000..8570a426a --- /dev/null +++ b/src/uix/soma/components/image-picker/index.ts @@ -0,0 +1 @@ +export * from './exports'; diff --git a/src/uix/soma/components/image-picker/langs.ts b/src/uix/soma/components/image-picker/langs.ts new file mode 100644 index 000000000..37557ce3e --- /dev/null +++ b/src/uix/soma/components/image-picker/langs.ts @@ -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; diff --git a/src/uix/soma/components/image-picker/types.ts b/src/uix/soma/components/image-picker/types.ts new file mode 100644 index 000000000..daa197b42 --- /dev/null +++ b/src/uix/soma/components/image-picker/types.ts @@ -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 `` `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; + /** Fired when an image is selected (or replaced). */ + onSelect?: OnChangeFn; + /** Fired when the image is removed. */ + onRemove?: () => void; + }, + ImagePickerSnippetProps +> & + Without; + +/** 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; + +export type ImagePickerToolbarProps = WithChild<{ id?: string }> & + Without; + +export type ImagePickerRotateProps = WithChild<{ id?: string }, { disabled: boolean }> & + Without; + +export type ImagePickerRemoveProps = WithChild<{ id?: string }, { disabled: boolean }> & + Without; diff --git a/src/uix/soma/components/index.ts b/src/uix/soma/components/index.ts index 9ddc39f9b..8fc7aa707 100644 --- a/src/uix/soma/components/index.ts +++ b/src/uix/soma/components/index.ts @@ -13,6 +13,7 @@ export * as ColorPicker from './color-picker'; export * as Combobox from './combobox'; export * as Command from './command'; export * as ContextMenu from './context-menu'; +export * as Cropper from './cropper'; export * as CssField from './css-field'; export * as DateField from './date-field'; export * as DatePicker from './date-picker'; @@ -29,6 +30,8 @@ export * as FileUpload from './file-upload'; export * as FloatPanel from './float-panel'; export * as Form from './form'; export * as GridList from './grid-list'; +export * as ImageAdjustments from './image-adjustments'; +export * as ImagePicker from './image-picker'; export * as LinkPreview from './link-preview'; export * as Listbox from './listbox'; export * as Menubar from './menubar'; diff --git a/src/uix/soma/layers/index.ts b/src/uix/soma/layers/index.ts index 76c92b453..f470466cc 100644 --- a/src/uix/soma/layers/index.ts +++ b/src/uix/soma/layers/index.ts @@ -5,6 +5,7 @@ export { Dismissal, type DismissalOpts, type DismissalBehavior } from './dismiss export { TextSelection, type TextSelectionOpts } from './text-selection.svelte'; export { ScrollLock, type ScrollLockOption } from './scroll-lock.svelte'; export { ResizeObserver$ } from './resize-observer.svelte'; +export { ZoomPan, type ZoomPanConfig } from './zoom-pan.svelte'; export { ImageProvider, type ImageProviderOptions, diff --git a/src/uix/soma/layers/zoom-pan.svelte.ts b/src/uix/soma/layers/zoom-pan.svelte.ts new file mode 100644 index 000000000..834fddb90 --- /dev/null +++ b/src/uix/soma/layers/zoom-pan.svelte.ts @@ -0,0 +1,109 @@ +// ── Zoom-Pan Layer ─────────────────────────────────────────────────────────── +// +// Reusable scale + pan state for a content element inside a fixed viewport. +// Consumed by Cropper today (zoom the image while cropping); applicable to any +// image-viewer / diagram-pan / map surface. Pure reactive state + math — the +// consumer wires wheel / pointer / button gestures to the methods and applies +// `transform` to the content element. +// +// Coordinate model: offset is in VIEWPORT PIXELS with `transform-origin: 0 0`. +// At scale 1 the content exactly fills the viewport (offset 0). At scale > 1 the +// offset is clamped so the content keeps covering the viewport (no gutters). + +export interface ZoomPanConfig { + /** Minimum scale. @default 1 */ + minScale?: number; + /** Maximum scale. @default 5 */ + maxScale?: number; +} + +export class ZoomPan { + /** Current scale (≥ minScale). */ + scale = $state(1); + /** Offset in viewport px (transform-origin 0 0). */ + offset = $state<{ x: number; y: number }>({ x: 0, y: 0 }); + + readonly minScale: number; + readonly maxScale: number; + private vw = 1; + private vh = 1; + + static create(config?: ZoomPanConfig) { + return new ZoomPan(config); + } + + constructor(config?: ZoomPanConfig) { + this.minScale = config?.minScale ?? 1; + this.maxScale = config?.maxScale ?? 5; + } + + /** Tell the layer the viewport px size (for offset clamping). */ + setViewport(w: number, h: number): void { + this.vw = w || 1; + this.vh = h || 1; + this.clamp(); + } + + private clampScale(s: number): number { + return Math.min(Math.max(s, this.minScale), this.maxScale); + } + + /** Keep the (scaled) content covering the viewport — no empty gutters. */ + private clamp(): void { + const minX = this.vw * (1 - this.scale); // ≤ 0 when scale ≥ 1 + const minY = this.vh * (1 - this.scale); + this.offset = { + x: Math.min(0, Math.max(minX, this.offset.x)), + y: Math.min(0, Math.max(minY, this.offset.y)) + }; + } + + /** Multiply the scale by `factor`, keeping the viewport point (cx,cy) fixed. */ + zoomBy(factor: number, cx = this.vw / 2, cy = this.vh / 2): void { + const next = this.clampScale(this.scale * factor); + if (next === this.scale) return; + // The content coord under the cursor stays put: cx = coord*scale + offset. + const coordX = (cx - this.offset.x) / this.scale; + const coordY = (cy - this.offset.y) / this.scale; + this.scale = next; + this.offset = { x: cx - coordX * next, y: cy - coordY * next }; + this.clamp(); + } + + /** Set the absolute scale (clamped), centered on (cx,cy) or the viewport. */ + zoomTo(scale: number, cx?: number, cy?: number): void { + const target = this.clampScale(scale); + if (target === this.scale) return; + this.zoomBy(target / this.scale, cx, cy); + } + + /** Pan by a viewport-px delta. */ + panBy(dx: number, dy: number): void { + this.offset = { x: this.offset.x + dx, y: this.offset.y + dy }; + this.clamp(); + } + + /** Back to scale 1, no offset. */ + reset(): void { + this.scale = 1; + this.offset = { x: 0, y: 0 }; + } + + readonly isZoomed = $derived.by(() => this.scale > this.minScale + 1e-6); + + /** CSS transform for the content element. */ + readonly transform = $derived.by( + () => `translate(${this.offset.x}px, ${this.offset.y}px) scale(${this.scale})` + ); + + /** + * Map a viewport-fraction point (0–1) to a content-fraction point (0–1), + * inverting the current zoom/pan. Used by the cropper's canvas extraction. + */ + viewportFracToContentFrac(fx: number, fy: number): { x: number; y: number } { + return { + x: (fx - this.offset.x / this.vw) / this.scale, + y: (fy - this.offset.y / this.vh) / this.scale + }; + } +} diff --git a/web/routes/uix/+layout@.svelte b/web/routes/uix/+layout@.svelte index 388f8f956..9c0577611 100644 --- a/web/routes/uix/+layout@.svelte +++ b/web/routes/uix/+layout@.svelte @@ -13,6 +13,8 @@ import { fileUploadSema } from '$uix/sema/components/file-upload'; import { floatPanelSema } from '$uix/sema/components/float-panel'; import { formSema } from '$uix/sema/components/form'; + import { imageAdjustmentsSema } from '$uix/sema/components/image-adjustments'; + import { imagePickerSema } from '$uix/sema/components/image-picker'; import { numberFieldSema } from '$uix/sema/components/number-field'; import { paginationSema } from '$uix/sema/components/pagination'; import { passwordFieldSema } from '$uix/sema/components/password-field'; @@ -35,6 +37,7 @@ import { calendarSema } from '$uix/sema/components/calendar'; import { colorPickerSema } from '$uix/sema/components/color-picker'; import { comboboxSema } from '$uix/sema/components/combobox'; + import { cropperSema } from '$uix/sema/components/cropper'; import { cssFieldSema } from '$uix/sema/components/css-field'; import { dateFieldSema } from '$uix/sema/components/date-field'; import { Popover } from '$uix/eidos/components/popover'; @@ -71,6 +74,7 @@ checkboxSema, colorPickerSema, comboboxSema, + cropperSema, cssFieldSema, dateFieldSema, dialogSema, @@ -79,6 +83,8 @@ fileUploadSema, floatPanelSema, formSema, + imageAdjustmentsSema, + imagePickerSema, numberFieldSema, paginationSema, passwordFieldSema, @@ -202,6 +208,9 @@ { slug: '/uix/components/avatar-group', label: 'AvatarGroup' }, { slug: '/uix/components/icon', label: 'Icon' }, { slug: '/uix/components/image', label: 'Image' }, + { slug: '/uix/components/image-adjustments', label: 'Image adjustments' }, + { slug: '/uix/components/image-picker', label: 'Image picker' }, + { slug: '/uix/components/cropper', label: 'Cropper' }, { slug: '/uix/components/carousel', label: 'Carousel' } ] }, diff --git a/web/routes/uix/components/cropper/+page.svelte b/web/routes/uix/components/cropper/+page.svelte new file mode 100644 index 000000000..98e0d6a07 --- /dev/null +++ b/web/routes/uix/components/cropper/+page.svelte @@ -0,0 +1,572 @@ + + +
+
+
Media · Cropper
+

Cropper

+

+ Crop a region of an image — a movable / resizable selection over the image, with a + rule-of-thirds grid, corner handles and zoom/pan (rueda + controles). Soma + owns the crop rect, the drag/resize/zoom math and the <canvas> extraction to + a Blob; the zoom is a reusable createZoomPan layer. Consumes the url + an ImagePicker emits. Five parts, four events. +

+
+ parts{compiled.parts.order.length} + events{events.length} + shapesrect · round + outputBlob +
+
+ + +
+
+
+
+ + +
+
+ resultado + {#if resultUrl} + Cropped result + + {Math.round((lastResult?.blob.size ?? 0) / 1024)} KB · png + + {:else} + + arrastra el recorte y pulsa «Recortar» + + {/if} +
+
+
+
+ trace + {#if trace.length === 0} + drag the selection / a handle, then crop to see events + {:else} + {#each trace.slice(0, 3) as entry} + {entry.event} · {entry.family}{entry.intent ? ' · ' + entry.intent : ''} + {fmtTime(entry.at)} + {/each} + {/if} + + crop + {cropStr} + +
+
+ + +
+ + + + + + +
+ + {#if tab === 'live'} +
+

Controls

+

+ Props grouped by the architectural layer that owns them: soma + headless behavior, eidos visual. Arrastra la + cuadrícula para mover el área de recorte; arrastra el fondo (la + imagen) para moverla y fijar el foco (con zoom); los handles (esquinas/lados) + redimensionan. Zoom con rueda/controles. Luego «Recortar». Events in the + { e.preventDefault(); tab = 'sema'; }}>Sema tab. +

+ +
+ soma props · headless behavior +
+
+ + + + + +
+ crop bindable · 0–1 + {cropStr} +
+
+ +
+
+ soma + headless · selection / grid / handles over the image + svelte +
+
{somaSnippet}
+
+ +
+
+ eidos + visual · the configured cropper + crop() via bind:this + svelte +
+
{eidosSnippet}
+
+
+ {/if} + + {#if tab === 'api'} +
+

API reference

+

All public props on the Provider.

+
+ + + + + + + + + + + + + + + + +
PropTypeDefaultDescription
src{`string | null`}—Image to crop — typically an ImagePicker `url`.
crop bindable{`{ x, y, width, height }`}centered 80%Crop rect, normalized 0–1.
aspectnumberfreeLock to a width/height ratio (display px).
shape{`'rect' | 'round'`}'rect'Rectangular or circular (alpha-masked) crop.
minSizenumber0.05Minimum crop size, fraction of the image.
maxSizenumber1Maximum crop size, fraction of the image.
fixedSizenumber—Fix the crop to this size → move-only (no handles). Avatar = round + fixedSize.
minScale / maxScalenumber1 / 5Zoom limits (wheel / pinch / the −/slider/+ controls).
disabledbooleanfalseDisable interaction.
onCropChange{`(rect) => void`}—Live, as the rect moves / resizes.
onCropComplete{`(r: CropResult) => void`}—{`{ blob, crop, shape }`} when `cropImage()` runs.
cropImage() method{`() => Promise`}via bind:thisProduce the cropped Blob via canvas.
+
+ +
Reference comparison
+
+ + + + + + + + + + +
CapabilityArkreact-image-cropreact-easy-cropCropper.jsUIX
Selection over image✅✅— (pan/zoom)✅✅
Corner handles + grid✅✅partial✅✅
Aspect lock✅✅✅✅✅
Circular crop—✅✅partial✅
Blob output (canvas)headlessyou extracthelper✅✅ built-in
Sound / haptic❌❌❌❌✅ sema
+
+
+ {/if} + + {#if tab === 'morfo'} +
+

+ morfo + · declarative contract +

+

+ The crop geometry is inline style on the Selection (continuous, like FloatPanel's + position); the morfo declares the parts, the shape, each handle's corner, and the events. + Source: src/uix/morfo/components/cropper.ts. +

+ +
+ + + + + + + + + +
FieldValue
name"{cropperMorfo.name}"
kebab"{cropperMorfo.kebab}"
scope[{cropperMorfo.scope.map((s) => `"${s}"`).join(', ')}]
parts.length{cropperMorfo.parts.length}
events.length{cropperMorfo.events?.length ?? 0}
+
+ +
Parts
+
+ + + + {#each partsList as part} + + + + + + + + + {/each} + +
PartMarkerElementArchetypeStatesOptional
{part.kebab}[{part.marker}]<{part.defaultElement}>{part.archetype ?? '—'}{part.states.length ? part.states.join(' | ') : '—'}{part.optional ? 'yes' : 'no'}
+
+ + {#each cropperMorfo.parts as rawPart} + {@const partAny = rawPart as unknown as { kebab: string }} + {@const dataAttrs = compiled.contracts.dataAttrsByPart.get(partAny.kebab) ?? []} + {#if dataAttrs.length} +
{partAny.kebab}
+
+ + + + {#each dataAttrs as attr} + + + + + + {/each} + +
data-attrValuesSource kind
{attr.attr}{attr.values ? attr.values.join(' | ') : '—'}{'value' in attr && attr.value ? (attr as { value: { kind: string } }).value.kind : '—'}
+
+ {/if} + {/each} + +
Events declaration
+
+ + + + {#each events as action} + {@const sem = action.semantic} + {@const intentDecl = 'intent' in sem ? sem.intent : undefined} + + + + + + + + + {/each} + +
namefamilyverbsequenceintenttarget
{action.name}{sem.family}{sem.verb ?? '—'}{sem.sequence ?? 'pre'}{typeof intentDecl === 'string' ? intentDecl : '—'}{action.target}
+
+
+ {/if} + + {#if tab === 'sema'} +
+

+ sema + · events + perceptual signature +

+

+ handle-drag / handle-resize (handle · tick) fire once at gesture + start — the move itself is visual, not per-frame (no haptic buzz). commit-crop + (commit · apply · affirm) when the Blob is produced. Click play to fire. +

+
+ + + + {#each events as action} + {@const intentDecl = 'intent' in action.semantic ? action.semantic.intent : undefined} + {@const effectiveIntent = typeof intentDecl === 'string' ? intentDecl : undefined} + + + + + + + + + {/each} + +
NameFamilyVerbSequenceIntentPlay
{action.name}{action.semantic.family}{action.semantic.verb ?? '—'}{action.semantic.sequence ?? 'pre'}{effectiveIntent ?? '—'} + +
+
+
+ {/if} + + {#if tab === 'recipe'} +
+

Eidos recipe

+

+ Selectors at src/uix/eidos/components/cropper/cropper.css. Tokens + bare-prefixed (--cropper-*). The darken-mask is a box-shadow on the Selection + (follows border-radius → circular cutout for round). +

+
+ + + + + + + + + + +
SelectorSource
[data-cropper] / [data-cropper][data-disabled]morfo
[data-cropper-viewport]morfo
[data-cropper-selection]morfo
[data-cropper-selection][data-shape='round']morfo
[data-cropper-grid]morfo
[data-cropper-handle][data-corner='…']morfo
+
+
+ {/if} + + {#if tab === 'a11y'} +
+

Accessibility

+

+ The cropper is a labelled role="group". v1 is pointer-driven; keyboard + move/resize of the selection is a documented gap (see the README). +

+
ARIA contract
+
+ + + + + + + + + +
PartAttributeValue
providerrole"group"
provideraria-label"Crop image" (or prop)
selectiondata-shape"rect" | "round"
selectiondata-draggingwhile moving / resizing
handledata-cornernw | ne | se | sw
+
+
+ {/if} +
+ + diff --git a/web/routes/uix/components/image-adjustments/+page.svelte b/web/routes/uix/components/image-adjustments/+page.svelte new file mode 100644 index 000000000..a2930ae32 --- /dev/null +++ b/web/routes/uix/components/image-adjustments/+page.svelte @@ -0,0 +1,649 @@ + + +
+
+
Media · Image adjustments
+

ImageAdjustments

+

+ A reusable panel of image-filter sliders. Soma owns the values and the value→CSS-filter math; it composes the framework's <Slider> per row and streams a live + filter string via onFilterChange — apply it to any surface (an + ImagePicker preview, an avatar, a Words image, a future cropper). Five parts, one event + (commit-reset). +

+
+ + parts{compiled.parts.order.length} + + + events{events.length} + + + adjustments8 + + + sizes3 + + + outputCSS filter + +
+
+ + +
+
+
+
+ (filter = f)} + aria-label="Photo adjustments" + /> +
+
+ Adjustable sample landscape + +
+
+
+
+ trace + {#if trace.length === 0} + drag a slider, then press Reset to see the commit-reset event + {:else} + {#each trace.slice(0, 3) as entry} + {entry.event} · {entry.family}{entry.intent + ? ' · ' + entry.intent + : ''} + {fmtTime(entry.at)} + {/each} + {/if} + + filter + {filter} + +
+
+ + +
+ + + + + + +
+ + {#if tab === 'live'} +
+

Controls

+

+ Props grouped by the architectural layer that owns them: soma + headless behavior, eidos visual treatment. Drag + any slider — the preview updates live. The one Sema event lives in the dedicated + { e.preventDefault(); tab = 'sema'; }}>Sema tab. +

+ + +
+ soma props · headless behavior +
+
+ +
+ value bindable · driven by the sliders + {Object.keys(value).length ? JSON.stringify(value) : '{}'} +
+ +
+ + +
+ eidos props · visual treatment +
+
+ + +
+ + +
+
+ soma + headless · composes Slider per Item + svelte +
+
{somaSnippet}
+
+ +
+
+ eidos + visual · configured rows, adds size + showReset + svelte +
+
{eidosSnippet}
+
+
+ {/if} + + {#if tab === 'api'} +
+

API reference

+

All public props. Eidos additions are tagged.

+ +
Provider
+
+ + + + + + + + + + + + +
PropTypeDefaultDescription
value bindable{`ImageAdjustmentValues`}{`{}`}Per-adjustment values. Missing keys = neutral default.
onValueChange{`(v: ImageAdjustmentValues) => void`}—Full values record on any change.
onFilterChange{`(filter: string) => void`}—The recomputed CSS filter string. The component's real output.
adjustmentsImageAdjustmentKey[]core 6Which adjustments + order. Subset of the 8 keys.
disabledbooleanfalseDisable all sliders + reset.
aria-labelstring"Image adjustments"Names the role=group.
size eidos{`ResponsiveProp<'sm' | 'md' | 'lg'>`}'md'Slider + row typography scale.
showReset eidosbooleantrueRender the reset control below the rows.
+
+ +
Adjustment catalog
+
+ + + + + + + + + + + + +
KeyRangeCSS mapping
brightness−100…100{`brightness((100+v)/100)`}
contrast−100…100{`contrast((100+v)/100)`}
saturation−100…100{`saturate((100+v)/100)`}
temperature−100…100approx: warm→sepia, cool→hue-rotate+saturate
hue−180…180°{`hue-rotate(v deg)`}
blur0…20px{`blur(v px)`}
grayscale0…100%{`grayscale(v%)`}
sepia0…100%{`sepia(v%)`}
+
+ +
Reference comparison
+
+ + + + + + + + + +
CapabilityUntitledChakraRadixMUIUIX
Filter-slider panel primitive✅❌❌❌✅
Live CSS filter output⚠️ canvas———✅ string
Composes the lib's own Slider✅n/an/an/a✅
Reusable outside an image picker❌———✅
Sound/haptic on reset❌❌❌❌✅
+
+
+ {/if} + + {#if tab === 'morfo'} +
+

+ morfo + · declarative contract +

+

+ The morfo is the single source of truth: parts + the one event. Soma transcribes it to + runtime behavior; eidos targets the data-attrs it emits; sema dispatches the event + signature. Source: src/uix/morfo/components/image-adjustments.ts. +

+ + +
+ + + + + + + + + +
FieldValue
name"{imageAdjustmentsMorfo.name}"
kebab"{imageAdjustmentsMorfo.kebab}"
scope[{imageAdjustmentsMorfo.scope.map((s) => `"${s}"`).join(', ')}]
parts.length{imageAdjustmentsMorfo.parts.length}
events.length{imageAdjustmentsMorfo.events?.length ?? 0}
+
+ + +
Parts
+
+ + + + {#each partsList as part} + + + + + + + + + + {/each} + +
PartMarkerElementRoleArchetypeStatesOptional
{part.kebab}[{part.marker}]<{part.defaultElement}>{part.role ?? '—'}{part.archetype ?? '—'}{part.states.length ? part.states.join(' | ') : '—'}{part.optional ? 'yes' : 'no'}
+
+ + + {#each imageAdjustmentsMorfo.parts as rawPart} + {@const partAny = rawPart as unknown as { kebab: string; aria?: ReadonlyArray<{ attr: string; value: { kind: string }; condition?: { when: string; prop?: string; part?: string }; severity?: string }> }} + {@const dataAttrs = compiled.contracts.dataAttrsByPart.get(partAny.kebab) ?? []} + {@const ariaAttrs = partAny.aria ?? []} + {#if dataAttrs.length || ariaAttrs.length} +
{partAny.kebab}
+ + {#if dataAttrs.length} +
+ + + + {#each dataAttrs as attr} + + + + + + {/each} + +
data-attrValuesSource kind
{attr.attr}{attr.values ? attr.values.join(' | ') : '—'}{'value' in attr && attr.value ? (attr as { value: { kind: string } }).value.kind : '—'}
+
+ {/if} + + {#if ariaAttrs.length} +
+ + + + {#each ariaAttrs as a} + + + + + + + {/each} + +
aria-attrSource kindConditionSeverity
{a.attr}{a.value.kind}{a.condition ? `when ${a.condition.when}${a.condition.prop ? ` (${a.condition.prop})` : ''}` : 'always'}{a.severity ?? 'required'}
+
+ {/if} + {/if} + {/each} + + +
Events declaration
+
+ + + + {#each events as action} + {@const sem = action.semantic} + {@const intentDecl = 'intent' in sem ? sem.intent : undefined} + + + + + + + + + {/each} + +
namefamilyverbsequenceintenttarget
{action.name}{sem.family}{sem.verb ?? '—'}{sem.sequence ?? 'pre'}{typeof intentDecl === 'string' ? intentDecl : '—'}{action.target}
+
+
+ {/if} + + {#if tab === 'sema'} +
+

+ sema + · events + perceptual signature +

+

+ One event: commit-reset (family commit, verb reset, + neutral, sequence: post) — the deliberate wipe back to neutral, riding the + soft form-commit tuning + a tap. Per-slider drag feedback is owned by the composed + <Slider> pack, not here. Click play to fire the signal + on the live group. +

+ +
+ + + + {#each events as action} + {@const intentDecl = 'intent' in action.semantic ? action.semantic.intent : undefined} + {@const effectiveIntent = typeof intentDecl === 'string' ? intentDecl : undefined} + + + + + + + + + {/each} + +
NameFamilyVerbSequenceIntentPlay
{action.name}{action.semantic.family}{action.semantic.verb ?? '—'}{action.semantic.sequence ?? 'pre'}{effectiveIntent ?? '—'} + +
+
+
+ {/if} + + {#if tab === 'recipe'} +
+

Eidos recipe

+

+ Selectors at src/uix/eidos/components/image-adjustments/image-adjustments.css. + Tokens bare-prefixed (--image-adjustments-*). The only slider override is the + hue row's rainbow track, keyed off the morfo's data-adjustment. +

+
+ + + + + + + + + + + +
SelectorSource
[data-image-adjustments]morfo
[data-image-adjustments][data-disabled]morfo
[data-image-adjustments-item]morfo
[data-image-adjustments-item-label]morfo
[data-image-adjustments-item-value]morfo
[data-image-adjustments-reset]morfo
[data-adjustment='hue'] [data-slider]eidos
+
+
+ {/if} + + {#if tab === 'a11y'} +
+

Accessibility

+

+ The group is a labelled role="group"; each row is an APG-pattern slider via + the composed <Slider> (full keyboard + aria-valuenow). The + value read-out is a plain <span> (not a live region) — the slider thumb + already announces, so a live region would double-announce. +

+ +
Keyboard (per slider row)
+
+ + + + + + + + + + +
KeyAction
TabMove between sliders and the reset control.
← / →Decrease / increase by one step.
↑ / ↓Increase / decrease by one step.
PageUp / PageDownLarger step (×10).
Home / EndJump to min / max.
Enter / SpaceActivate the reset control.
+
+ +
ARIA contract
+
+ + + + + + + + + + + +
PartAttributeValue
providerrole"group"
provideraria-label"Image adjustments" (or prop)
providerdata-disabledwhen disabled
thumb (slider)role"slider"
thumb (slider)aria-valuemin / max / nowthe adjustment range + value
thumb (slider)aria-labelthe adjustment label
resetaria-label"Reset"
+
+
+ {/if} +
+ + diff --git a/web/routes/uix/components/image-picker/+page.svelte b/web/routes/uix/components/image-picker/+page.svelte new file mode 100644 index 000000000..b91553891 --- /dev/null +++ b/web/routes/uix/components/image-picker/+page.svelte @@ -0,0 +1,562 @@ + + +
+
+
Media · Image picker
+

ImagePicker

+

+ Pick, preview and adjust an image. A pure composition — it wires the framework's + FileUpload (drop + click), Image (fit modes), + ImageAdjustments (filter sliders) and adds 90° rotation. The soma owns the file, + the object-URL lifecycle and the composed CSS filter; its streamed value is + cropper-ready. Five parts, three events. +

+
+ parts{compiled.parts.order.length} + events{events.length} + composes3 + outputFile + filter +
+
+ + +
+
+
+ (lastValue = v)} + aria-label="Photo picker" + /> + +
+
+
+ trace + {#if trace.length === 0} + select / rotate / remove an image to see events + {:else} + {#each trace.slice(0, 3) as entry} + {entry.event} · {entry.family}{entry.intent ? ' · ' + entry.intent : ''} + {fmtTime(entry.at)} + {/each} + {/if} + + state + {pickerState} · {rotation}° + +
+
+ + +
+ + + + + + +
+ + {#if tab === 'live'} +
+

Controls

+

+ Props grouped by the architectural layer that owns them: soma + headless behavior, eidos composition. Load the + sample (or drop a real image), then rotate / adjust / remove. Events live in the + { e.preventDefault(); tab = 'sema'; }}>Sema tab. +

+ +
+ soma props · headless behavior +
+
+ +
+ rotation via Rotate button + {rotation}° +
+
+ file bindable + {file ? file.name : 'null'} +
+ +
+ +
+ eidos props · composition +
+
+ + +
+ filter onChange output + {lastValue?.filter ?? 'none'} +
+
+ +
+
+ soma + headless · composes FileUpload + Image + ImageAdjustments + svelte +
+
{somaSnippet}
+
+ +
+
+ eidos + visual · the configured composition + svelte +
+
{eidosSnippet}
+
+
+ {/if} + + {#if tab === 'api'} +
+

API reference

+

All public props. Eidos additions are tagged.

+ +
Provider
+
+ + + + + + + + + + + + + + + +
PropTypeDefaultDescription
file bindable{`File | null`}nullThe selected file.
fit bindable{`'cover' | 'contain' | 'fill'`}'cover'Fill mode → the composed Image.
rotation bindable0 | 90 | 180 | 2700Clockwise rotation, driven by the Rotate button.
adjustments bindableImageAdjustmentValues{`{}`}Filter values → the composed ImageAdjustments.
onChange{`(v: ImagePickerValue) => void`}—{`{ file, url, fit, rotation, filter, adjustments }`} on any change. Cropper-ready.
onSelect / onRemove{`(file) => void`} / {`() => void`}—Fire on select / remove.
disabledbooleanfalseDisable picking + all controls.
accept eidosstring'image/*'→ FileUpload.
maxSize eidosnumber—Max bytes → FileUpload.
size eidos{`'sm' | 'md' | 'lg'`}'sm'Adjustments panel scale.
showAdjustments eidosbooleantrueRender the filters panel.
+
+ +
Reference comparison
+
+ + + + + + + + + +
CapabilityUntitledChakraRadixMUIUIX
Image picker primitive✅❌❌❌✅
File upload base✅✅❌❌✅ reuse
Fill modes + 90° rotation✅❌❌❌✅
Built-in filter sliders✅❌❌❌✅ reuse
Cropper-ready output⚠️———✅ File + url
+
+

+ 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. +

+
+ {/if} + + {#if tab === 'morfo'} +
+

+ morfo + · declarative contract +

+

+ The morfo declares only the picker's OWN orchestration — the empty↔ready state, the + fit + rotation transforms, and the select / remove / rotate + events. The dropzone, image and sliders carry their own morfos. Source: + src/uix/morfo/components/image-picker.ts. +

+ +
+ + + + + + + + + +
FieldValue
name"{imagePickerMorfo.name}"
kebab"{imagePickerMorfo.kebab}"
scope[{imagePickerMorfo.scope.map((s) => `"${s}"`).join(', ')}]
parts.length{imagePickerMorfo.parts.length}
events.length{imagePickerMorfo.events?.length ?? 0}
+
+ +
Parts
+
+ + + + {#each partsList as part} + + + + + + + + + + {/each} + +
PartMarkerElementRoleArchetypeStatesOptional
{part.kebab}[{part.marker}]<{part.defaultElement}>{part.role ?? '—'}{part.archetype ?? '—'}{part.states.length ? part.states.join(' | ') : '—'}{part.optional ? 'yes' : 'no'}
+
+ + {#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} +
{partAny.kebab}
+ {#if dataAttrs.length} +
+ + + + {#each dataAttrs as attr} + + + + + + {/each} + +
data-attrValuesSource kind
{attr.attr}{attr.values ? attr.values.join(' | ') : '—'}{'value' in attr && attr.value ? (attr as { value: { kind: string } }).value.kind : '—'}
+
+ {/if} + {#if ariaAttrs.length} +
+ + + + {#each ariaAttrs as a} + + + + + + {/each} + +
aria-attrSource kindSeverity
{a.attr}{a.value.kind}{a.severity ?? 'required'}
+
+ {/if} + {/if} + {/each} + +
Events declaration
+
+ + + + {#each events as action} + {@const sem = action.semantic} + {@const intentDecl = 'intent' in sem ? sem.intent : undefined} + + + + + + + + + {/each} + +
namefamilyverbsequenceintenttarget
{action.name}{sem.family}{sem.verb ?? '—'}{sem.sequence ?? 'pre'}{typeof intentDecl === 'string' ? intentDecl : '—'}{action.target}
+
+
+ {/if} + + {#if tab === 'sema'} +
+

+ sema + · events + perceptual signature +

+

+ Three events: commit-select (commit · affirm — the pickup of a chosen image), + commit-remove (commit · neutral), handle-rotate (handle · neutral + — a spatial nudge, haptic tick). The per-slider feedback is owned by ImageAdjustments. + Click play to fire on the live picker. +

+
+ + + + {#each events as action} + {@const intentDecl = 'intent' in action.semantic ? action.semantic.intent : undefined} + {@const effectiveIntent = typeof intentDecl === 'string' ? intentDecl : undefined} + + + + + + + + + {/each} + +
NameFamilyVerbSequenceIntentPlay
{action.name}{action.semantic.family}{action.semantic.verb ?? '—'}{action.semantic.sequence ?? 'pre'}{effectiveIntent ?? '—'} + +
+
+
+ {/if} + + {#if tab === 'recipe'} +
+

Eidos recipe

+

+ Selectors at src/uix/eidos/components/image-picker/image-picker.css. Tokens + bare-prefixed (--image-picker-*). The recipe owns only the orchestration chrome + — the preview box, the rotation transform, the floating toolbar; the composed components + bring their own recipes. +

+
+ + + + + + + + + + +
SelectorSource
[data-image-picker]morfo
[data-image-picker][data-disabled]morfo
[data-image-picker-preview]morfo
[data-image-picker-preview][data-rotation]morfo
[data-image-picker-toolbar]morfo
[data-image-picker-rotate] / [data-image-picker-remove]morfo
+
+
+ {/if} + + {#if tab === 'a11y'} +
+

Accessibility

+

+ The picker is a labelled role="group". The dropzone (FileUpload) and the + sliders (ImageAdjustments) carry their own APG-pattern keyboard + ARIA; the toolbar buttons + are plain buttons with explicit aria-labels. +

+
Keyboard
+
+ + + + + + + +
KeyAction
TabMove between the dropzone / preview / toolbar / sliders.
Enter / SpaceActivate the focused control (browse, rotate, remove).
← → ↑ ↓Adjust the focused filter slider.
+
+
ARIA contract
+
+ + + + + + + + + +
PartAttributeValue
providerrole"group"
provideraria-label"Image picker" (or prop)
providerdata-state"empty" | "ready"
rotatearia-label"Rotate 90°"
removearia-label"Remove image"
+
+
+ {/if} +
+ +