|
|
<script lang="ts">
|
|
|
import Cropper, {
|
|
|
type CropRect,
|
|
|
type CropShape,
|
|
|
type CropResult
|
|
|
} from '$uix/eidos/components/cropper';
|
|
|
import { compileMorfo } from '$uix/morfo';
|
|
|
import { cropperMorfo } from '@/uix/morfo/components/cropper';
|
|
|
import { getActiveUix } from '$active-uix';
|
|
|
|
|
|
const uix = getActiveUix();
|
|
|
|
|
|
type Tab = 'live' | 'api' | 'morfo' | 'sema' | 'recipe' | 'a11y';
|
|
|
let tab = $state<Tab>('live');
|
|
|
|
|
|
// ── Live state ────────────────────────────────────────────────────────
|
|
|
type AspectKey = 'free' | '1:1' | '4:3' | '16:9';
|
|
|
let aspectKey = $state<AspectKey>('free');
|
|
|
let shape = $state<CropShape>('rect');
|
|
|
let disabled = $state(false);
|
|
|
let sizeMode = $state<'free' | 'fixed'>('free');
|
|
|
const fixedSize = $derived(sizeMode === 'fixed' ? 0.45 : undefined);
|
|
|
let crop = $state<CropRect>({ x: 0.15, y: 0.15, width: 0.7, height: 0.7 });
|
|
|
let lastResult = $state<CropResult | null>(null);
|
|
|
let resultUrl = $state<string | null>(null);
|
|
|
|
|
|
const ASPECTS: Record<AspectKey, number | undefined> = {
|
|
|
free: undefined,
|
|
|
'1:1': 1,
|
|
|
'4:3': 4 / 3,
|
|
|
'16:9': 16 / 9
|
|
|
};
|
|
|
// Fixed size implies a square (1:1) crop so `round` is a true circle.
|
|
|
const aspect = $derived(sizeMode === 'fixed' ? 1 : ASPECTS[aspectKey]);
|
|
|
|
|
|
let cropperEl = $state<{ cropImage: () => Promise<CropResult | null> | undefined } | null>(null);
|
|
|
|
|
|
async function doCrop() {
|
|
|
const r = await cropperEl?.cropImage();
|
|
|
if (r) {
|
|
|
lastResult = r;
|
|
|
if (resultUrl) URL.revokeObjectURL(resultUrl);
|
|
|
resultUrl = URL.createObjectURL(r.blob);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
// Wide sample (16:9) so the crop reads clearly against the viewport.
|
|
|
const sample =
|
|
|
'data:image/svg+xml;utf8,' +
|
|
|
encodeURIComponent(
|
|
|
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 360">
|
|
|
<defs><linearGradient id="s" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#4ea8ff"/><stop offset="1" stop-color="#bfe3ff"/></linearGradient></defs>
|
|
|
<rect width="640" height="360" fill="url(#s)"/>
|
|
|
<circle cx="500" cy="92" r="48" fill="#ffd24a"/>
|
|
|
<path d="M0 250 Q160 190 320 240 T640 220 V360 H0 Z" fill="#2f9e57"/>
|
|
|
<path d="M0 296 Q190 250 400 286 T640 276 V360 H0 Z" fill="#1f7a42"/>
|
|
|
</svg>`
|
|
|
);
|
|
|
|
|
|
// The image to crop — the sample by default, or a file the user picks.
|
|
|
let src = $state(sample);
|
|
|
let uploadedUrl = $state<string | null>(null);
|
|
|
|
|
|
function onPickFile(e: Event) {
|
|
|
const file = (e.currentTarget as HTMLInputElement).files?.[0];
|
|
|
if (!file) return;
|
|
|
if (uploadedUrl) URL.revokeObjectURL(uploadedUrl);
|
|
|
uploadedUrl = URL.createObjectURL(file);
|
|
|
src = uploadedUrl;
|
|
|
// reset the crop for the new image's aspect
|
|
|
crop = { x: 0.15, y: 0.15, width: 0.7, height: 0.7 };
|
|
|
resultUrl = null;
|
|
|
}
|
|
|
|
|
|
type TraceEntry = { event: string; family: string; intent?: string; at: number };
|
|
|
let trace = $state<TraceEntry[]>([]);
|
|
|
|
|
|
let stageRef = $state<HTMLElement | null>(null);
|
|
|
$effect(() => {
|
|
|
const el = stageRef;
|
|
|
if (!el) return;
|
|
|
const obs = new MutationObserver((mutations) => {
|
|
|
for (const m of mutations) {
|
|
|
if (m.attributeName !== 'data-event') continue;
|
|
|
const target = m.target as Element;
|
|
|
const ev = target.getAttribute('data-event');
|
|
|
if (!ev) continue;
|
|
|
trace = [
|
|
|
{
|
|
|
event: ev,
|
|
|
family: target.getAttribute('data-event-family') ?? '—',
|
|
|
intent: target.getAttribute('data-event-intent') ?? undefined,
|
|
|
at: Date.now()
|
|
|
},
|
|
|
...trace
|
|
|
].slice(0, 6);
|
|
|
}
|
|
|
});
|
|
|
obs.observe(el, { attributes: true, subtree: true, attributeFilter: ['data-event'] });
|
|
|
return () => obs.disconnect();
|
|
|
});
|
|
|
|
|
|
const compiled = compileMorfo(cropperMorfo);
|
|
|
const partsList = $derived([...compiled.parts.byKebab.values()]);
|
|
|
const events = $derived([...compiled.actions.byName.values()]);
|
|
|
|
|
|
function fmtTime(at: number): string {
|
|
|
const d = new Date(at);
|
|
|
return `${String(d.getSeconds()).padStart(2, '0')}.${String(d.getMilliseconds()).padStart(3, '0')}`;
|
|
|
}
|
|
|
const r2 = (n: number) => Math.round(n * 100) / 100;
|
|
|
const cropStr = $derived(`${r2(crop.x)}, ${r2(crop.y)} · ${r2(crop.width)}×${r2(crop.height)}`);
|
|
|
|
|
|
const somaSnippet = $derived(
|
|
|
[
|
|
|
"<script lang='ts'>",
|
|
|
" import * as Cropper from '$soma/components/cropper';",
|
|
|
" import { Image } from '$uix/eidos/components/image';",
|
|
|
' let crop = $state({ x: 0.15, y: 0.15, width: 0.7, height: 0.7 });',
|
|
|
'</' + 'script>',
|
|
|
'',
|
|
|
'<Cropper.Provider {src} bind:crop onCropComplete={(r) => save(r.blob)}>',
|
|
|
' {#snippet children({ crop })}',
|
|
|
' <Cropper.Viewport>',
|
|
|
' <Image {src} alt="" fit="fill" />',
|
|
|
' <Cropper.Selection style="left:{crop.x*100}%; …">',
|
|
|
' <Cropper.Grid />',
|
|
|
' <Cropper.Handle corner="nw" /> <!-- …ne / se / sw -->',
|
|
|
' </Cropper.Selection>',
|
|
|
' </Cropper.Viewport>',
|
|
|
' {/snippet}',
|
|
|
'</Cropper.Provider>'
|
|
|
].join('\n')
|
|
|
);
|
|
|
|
|
|
const eidosSnippet = $derived(
|
|
|
[
|
|
|
"<script lang='ts'>",
|
|
|
" import Cropper from '$uix/eidos/components/cropper';",
|
|
|
' let cr; let crop = $state({ x: 0.15, y: 0.15, width: 0.7, height: 0.7 });',
|
|
|
'</' + 'script>',
|
|
|
'',
|
|
|
'<Cropper',
|
|
|
' bind:this={cr}',
|
|
|
' {src}',
|
|
|
' bind:crop',
|
|
|
aspectKey !== 'free' && ` aspect={${r2(aspect ?? 0)}}`,
|
|
|
shape !== 'rect' && ` shape="${shape}"`,
|
|
|
disabled && ' disabled',
|
|
|
' onCropComplete={(r) => save(r.blob)}',
|
|
|
'/>',
|
|
|
'',
|
|
|
'<button onclick={() => cr.cropImage()}>Crop</button>'
|
|
|
]
|
|
|
.filter(Boolean)
|
|
|
.join('\n')
|
|
|
);
|
|
|
</script>
|
|
|
|
|
|
<div data-uix-canvas-inner>
|
|
|
<header>
|
|
|
<div data-uix-eyebrow>Media · Cropper</div>
|
|
|
<h1 data-uix-page-title>Cropper</h1>
|
|
|
<p data-uix-page-lede>
|
|
|
Crop a region of an image — a movable / resizable selection over the image, with a
|
|
|
rule-of-thirds grid, corner handles and <strong>zoom/pan</strong> (rueda + controles). Soma
|
|
|
owns the crop rect, the drag/resize/zoom math and the <code><canvas></code> extraction to
|
|
|
a Blob; the zoom is a reusable <code>createZoomPan</code> layer. Consumes the <code>url</code>
|
|
|
an ImagePicker emits. Five parts, four events.
|
|
|
</p>
|
|
|
<div data-uix-page-meta>
|
|
|
<span data-uix-meta-pill><span data-uix-meta-key>parts</span>{compiled.parts.order.length}</span>
|
|
|
<span data-uix-meta-pill><span data-uix-meta-key>events</span>{events.length}</span>
|
|
|
<span data-uix-meta-pill><span data-uix-meta-key>shapes</span>rect · round</span>
|
|
|
<span data-uix-meta-pill><span data-uix-meta-key>output</span>Blob</span>
|
|
|
</div>
|
|
|
</header>
|
|
|
|
|
|
<!-- Live preview always rendered -->
|
|
|
<div data-uix-stage>
|
|
|
<div data-uix-stage-area bind:this={stageRef}>
|
|
|
<div class="cr-stage">
|
|
|
<div class="cr-editor">
|
|
|
<Cropper bind:this={cropperEl} {src} bind:crop {aspect} {shape} {fixedSize} {disabled} />
|
|
|
<button data-uix-chip onclick={doCrop} disabled={disabled}>Recortar</button>
|
|
|
</div>
|
|
|
<div class="cr-result">
|
|
|
<span data-uix-control-label>resultado</span>
|
|
|
{#if resultUrl}
|
|
|
<img class="cr-result-img" src={resultUrl} alt="Cropped result" data-shape={shape} />
|
|
|
<code style="font-size: var(--font-size-xs); color: var(--uix-text-muted);">
|
|
|
{Math.round((lastResult?.blob.size ?? 0) / 1024)} KB · png
|
|
|
</code>
|
|
|
{:else}
|
|
|
<span style="font-size: var(--font-size-xs); color: var(--uix-text-muted);">
|
|
|
arrastra el recorte y pulsa «Recortar»
|
|
|
</span>
|
|
|
{/if}
|
|
|
</div>
|
|
|
</div>
|
|
|
</div>
|
|
|
<div data-uix-stage-trace>
|
|
|
<span data-uix-stage-trace-key>trace</span>
|
|
|
{#if trace.length === 0}
|
|
|
<span>drag the selection / a handle, then crop to see events</span>
|
|
|
{:else}
|
|
|
{#each trace.slice(0, 3) as entry}
|
|
|
<span><span data-uix-stage-trace-event>{entry.event}</span> · {entry.family}{entry.intent ? ' · ' + entry.intent : ''}</span>
|
|
|
<span style="color: var(--uix-text-faint)">{fmtTime(entry.at)}</span>
|
|
|
{/each}
|
|
|
{/if}
|
|
|
<span style="margin-inline-start: auto;">
|
|
|
<span data-uix-stage-trace-key>crop</span>
|
|
|
{cropStr}
|
|
|
</span>
|
|
|
</div>
|
|
|
</div>
|
|
|
|
|
|
<!-- Tabs -->
|
|
|
<div data-uix-tabs role="tablist">
|
|
|
<button data-uix-tab data-active={tab === 'live'} onclick={() => (tab = 'live')}>Live</button>
|
|
|
<button data-uix-tab data-active={tab === 'api'} onclick={() => (tab = 'api')}>
|
|
|
API <span data-uix-tab-count>8</span>
|
|
|
</button>
|
|
|
<button data-uix-tab data-active={tab === 'morfo'} onclick={() => (tab = 'morfo')}>
|
|
|
<span data-uix-layer-badge="morfo">morfo</span>
|
|
|
<span data-uix-tab-count>{partsList.length}p · {events.length}e</span>
|
|
|
</button>
|
|
|
<button data-uix-tab data-active={tab === 'sema'} onclick={() => (tab = 'sema')}>
|
|
|
<span data-uix-layer-badge="sema">sema</span>
|
|
|
<span data-uix-tab-count>{events.length}</span>
|
|
|
</button>
|
|
|
<button data-uix-tab data-active={tab === 'recipe'} onclick={() => (tab = 'recipe')}>Recipe</button>
|
|
|
<button data-uix-tab data-active={tab === 'a11y'} onclick={() => (tab = 'a11y')}>A11y</button>
|
|
|
</div>
|
|
|
|
|
|
{#if tab === 'live'}
|
|
|
<section data-uix-section>
|
|
|
<h2 data-uix-section-title>Controls</h2>
|
|
|
<p data-uix-section-desc>
|
|
|
Props grouped by the architectural layer that owns them: <span data-uix-layer-badge="soma">soma</span>
|
|
|
headless behavior, <span data-uix-layer-badge="eidos">eidos</span> visual. Arrastra la
|
|
|
<strong>cuadrícula</strong> para mover el área de recorte; arrastra el <strong>fondo (la
|
|
|
imagen)</strong> para moverla y fijar el foco (con zoom); los handles (esquinas/lados)
|
|
|
redimensionan. Zoom con rueda/controles. Luego «Recortar». Events in the
|
|
|
<a href="#sema" onclick={(e) => { e.preventDefault(); tab = 'sema'; }}>Sema</a> tab.
|
|
|
</p>
|
|
|
|
|
|
<div data-uix-subsection-head>
|
|
|
<span data-uix-layer-badge="soma">soma</span> props · headless behavior
|
|
|
</div>
|
|
|
<div data-uix-controls>
|
|
|
<label data-uix-control>
|
|
|
<span data-uix-control-label>imagen <span data-uix-control-hint>src · sube un archivo</span></span>
|
|
|
<input type="file" accept="image/*" onchange={onPickFile} />
|
|
|
{#if uploadedUrl}
|
|
|
<button data-uix-chip onclick={() => { src = sample; uploadedUrl = null; crop = { x: 0.15, y: 0.15, width: 0.7, height: 0.7 }; resultUrl = null; }}>volver a la muestra</button>
|
|
|
{/if}
|
|
|
</label>
|
|
|
<label data-uix-control>
|
|
|
<span data-uix-control-label>aspect <span data-uix-control-hint>lock ratio</span></span>
|
|
|
<span data-uix-chips role="radiogroup">
|
|
|
{#each ['free', '1:1', '4:3', '16:9'] as const as a}
|
|
|
<button data-uix-chip data-active={aspectKey === a} onclick={() => (aspectKey = a)}>{a}</button>
|
|
|
{/each}
|
|
|
</span>
|
|
|
</label>
|
|
|
<label data-uix-control>
|
|
|
<span data-uix-control-label>shape</span>
|
|
|
<span data-uix-chips role="radiogroup">
|
|
|
{#each ['rect', 'round'] as const as s}
|
|
|
<button data-uix-chip data-active={shape === s} onclick={() => (shape = s)}>{s}</button>
|
|
|
{/each}
|
|
|
</span>
|
|
|
</label>
|
|
|
<label data-uix-control>
|
|
|
<span data-uix-control-label>tamaño <span data-uix-control-hint>fixedSize → solo mover</span></span>
|
|
|
<span data-uix-chips role="radiogroup">
|
|
|
<button data-uix-chip data-active={sizeMode === 'free'} onclick={() => (sizeMode = 'free')}>libre</button>
|
|
|
<button data-uix-chip data-active={sizeMode === 'fixed'} onclick={() => (sizeMode = 'fixed')}>fijo (avatar)</button>
|
|
|
</span>
|
|
|
</label>
|
|
|
<label data-uix-control>
|
|
|
<span data-uix-control-label>disabled</span>
|
|
|
<span data-uix-switch>
|
|
|
<input type="checkbox" bind:checked={disabled} />
|
|
|
<span data-uix-switch-label>{disabled ? 'on' : 'off'}</span>
|
|
|
</span>
|
|
|
</label>
|
|
|
<div data-uix-control>
|
|
|
<span data-uix-control-label>crop <span data-uix-control-hint>bindable · 0–1</span></span>
|
|
|
<code style="font-size: var(--font-size-xs); color: var(--uix-text-muted);">{cropStr}</code>
|
|
|
</div>
|
|
|
</div>
|
|
|
|
|
|
<div data-uix-code>
|
|
|
<div data-uix-code-head>
|
|
|
<span data-uix-layer-badge="soma">soma</span>
|
|
|
<span>headless · selection / grid / handles over the image</span>
|
|
|
<span data-uix-code-lang>svelte</span>
|
|
|
</div>
|
|
|
<pre><code>{somaSnippet}</code></pre>
|
|
|
</div>
|
|
|
|
|
|
<div data-uix-code style="margin-top: var(--uix-space-3);">
|
|
|
<div data-uix-code-head>
|
|
|
<span data-uix-layer-badge="eidos">eidos</span>
|
|
|
<span>visual · the configured cropper + crop() via bind:this</span>
|
|
|
<span data-uix-code-lang>svelte</span>
|
|
|
</div>
|
|
|
<pre><code>{eidosSnippet}</code></pre>
|
|
|
</div>
|
|
|
</section>
|
|
|
{/if}
|
|
|
|
|
|
{#if tab === 'api'}
|
|
|
<section data-uix-section>
|
|
|
<h2 data-uix-section-title>API reference</h2>
|
|
|
<p data-uix-section-desc>All public props on the Provider.</p>
|
|
|
<div data-uix-table-wrap>
|
|
|
<table data-uix-table>
|
|
|
<thead><tr><th>Prop</th><th>Type</th><th>Default</th><th>Description</th></tr></thead>
|
|
|
<tbody>
|
|
|
<tr><td class="name">src</td><td class="type">{`string | null`}</td><td class="default empty">—</td><td>Image to crop — typically an ImagePicker `url`.</td></tr>
|
|
|
<tr><td class="name">crop <span data-uix-tag data-kind="bindable">bindable</span></td><td class="type">{`{ x, y, width, height }`}</td><td class="default">centered 80%</td><td>Crop rect, normalized 0–1.</td></tr>
|
|
|
<tr><td class="name">aspect</td><td class="type">number</td><td class="default empty">free</td><td>Lock to a width/height ratio (display px).</td></tr>
|
|
|
<tr><td class="name">shape</td><td class="type">{`'rect' | 'round'`}</td><td class="default">'rect'</td><td>Rectangular or circular (alpha-masked) crop.</td></tr>
|
|
|
<tr><td class="name">minSize</td><td class="type">number</td><td class="default">0.05</td><td>Minimum crop size, fraction of the image.</td></tr>
|
|
|
<tr><td class="name">maxSize</td><td class="type">number</td><td class="default">1</td><td>Maximum crop size, fraction of the image.</td></tr>
|
|
|
<tr><td class="name">fixedSize</td><td class="type">number</td><td class="default empty">—</td><td>Fix the crop to this size → move-only (no handles). Avatar = round + fixedSize.</td></tr>
|
|
|
<tr><td class="name">minScale / maxScale</td><td class="type">number</td><td class="default">1 / 5</td><td>Zoom limits (wheel / pinch / the −/slider/+ controls).</td></tr>
|
|
|
<tr><td class="name">disabled</td><td class="type">boolean</td><td class="default">false</td><td>Disable interaction.</td></tr>
|
|
|
<tr><td class="name">onCropChange</td><td class="type">{`(rect) => void`}</td><td class="default empty">—</td><td>Live, as the rect moves / resizes.</td></tr>
|
|
|
<tr><td class="name">onCropComplete</td><td class="type">{`(r: CropResult) => void`}</td><td class="default empty">—</td><td>{`{ blob, crop, shape }`} when `cropImage()` runs.</td></tr>
|
|
|
<tr><td class="name">cropImage() <span data-uix-tag data-kind="eidos">method</span></td><td class="type">{`() => Promise<CropResult>`}</td><td class="default empty">via bind:this</td><td>Produce the cropped Blob via canvas.</td></tr>
|
|
|
</tbody>
|
|
|
</table>
|
|
|
</div>
|
|
|
|
|
|
<div data-uix-subsection-head>Reference comparison</div>
|
|
|
<div data-uix-table-wrap>
|
|
|
<table data-uix-table>
|
|
|
<thead><tr><th>Capability</th><th>Ark</th><th>react-image-crop</th><th>react-easy-crop</th><th>Cropper.js</th><th>UIX</th></tr></thead>
|
|
|
<tbody>
|
|
|
<tr><td class="name">Selection over image</td><td>✅</td><td>✅</td><td>— (pan/zoom)</td><td>✅</td><td>✅</td></tr>
|
|
|
<tr><td class="name">Corner handles + grid</td><td>✅</td><td>✅</td><td>partial</td><td>✅</td><td>✅</td></tr>
|
|
|
<tr><td class="name">Aspect lock</td><td>✅</td><td>✅</td><td>✅</td><td>✅</td><td>✅</td></tr>
|
|
|
<tr><td class="name">Circular crop</td><td>—</td><td>✅</td><td>✅</td><td>partial</td><td>✅</td></tr>
|
|
|
<tr><td class="name">Blob output (canvas)</td><td>headless</td><td>you extract</td><td>helper</td><td>✅</td><td>✅ built-in</td></tr>
|
|
|
<tr><td class="name">Sound / haptic</td><td>❌</td><td>❌</td><td>❌</td><td>❌</td><td>✅ sema</td></tr>
|
|
|
</tbody>
|
|
|
</table>
|
|
|
</div>
|
|
|
</section>
|
|
|
{/if}
|
|
|
|
|
|
{#if tab === 'morfo'}
|
|
|
<section data-uix-section>
|
|
|
<h2 data-uix-section-title>
|
|
|
<span data-uix-layer-badge="morfo">morfo</span>
|
|
|
· declarative contract
|
|
|
</h2>
|
|
|
<p data-uix-section-desc>
|
|
|
The 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: <code>src/uix/morfo/components/cropper.ts</code>.
|
|
|
</p>
|
|
|
|
|
|
<div data-uix-table-wrap>
|
|
|
<table data-uix-table>
|
|
|
<thead><tr><th>Field</th><th>Value</th></tr></thead>
|
|
|
<tbody>
|
|
|
<tr><td class="name">name</td><td class="type">"{cropperMorfo.name}"</td></tr>
|
|
|
<tr><td class="name">kebab</td><td class="type">"{cropperMorfo.kebab}"</td></tr>
|
|
|
<tr><td class="name">scope</td><td class="type">[{cropperMorfo.scope.map((s) => `"${s}"`).join(', ')}]</td></tr>
|
|
|
<tr><td class="name">parts.length</td><td class="default">{cropperMorfo.parts.length}</td></tr>
|
|
|
<tr><td class="name">events.length</td><td class="default">{cropperMorfo.events?.length ?? 0}</td></tr>
|
|
|
</tbody>
|
|
|
</table>
|
|
|
</div>
|
|
|
|
|
|
<div data-uix-subsection-head>Parts</div>
|
|
|
<div data-uix-table-wrap>
|
|
|
<table data-uix-table>
|
|
|
<thead><tr><th>Part</th><th>Marker</th><th>Element</th><th>Archetype</th><th>States</th><th>Optional</th></tr></thead>
|
|
|
<tbody>
|
|
|
{#each partsList as part}
|
|
|
<tr>
|
|
|
<td class="name">{part.kebab}</td>
|
|
|
<td><code data-uix-part-marker>[{part.marker}]</code></td>
|
|
|
<td class="type"><{part.defaultElement}></td>
|
|
|
<td class="default">{part.archetype ?? '—'}</td>
|
|
|
<td class="default">{part.states.length ? part.states.join(' | ') : '—'}</td>
|
|
|
<td class="default">{part.optional ? 'yes' : 'no'}</td>
|
|
|
</tr>
|
|
|
{/each}
|
|
|
</tbody>
|
|
|
</table>
|
|
|
</div>
|
|
|
|
|
|
{#each cropperMorfo.parts as rawPart}
|
|
|
{@const partAny = rawPart as unknown as { kebab: string }}
|
|
|
{@const dataAttrs = compiled.contracts.dataAttrsByPart.get(partAny.kebab) ?? []}
|
|
|
{#if dataAttrs.length}
|
|
|
<div data-uix-subsection-head>{partAny.kebab}</div>
|
|
|
<div data-uix-table-wrap>
|
|
|
<table data-uix-table>
|
|
|
<thead><tr><th>data-attr</th><th>Values</th><th>Source kind</th></tr></thead>
|
|
|
<tbody>
|
|
|
{#each dataAttrs as attr}
|
|
|
<tr>
|
|
|
<td class="name">{attr.attr}</td>
|
|
|
<td class="type">{attr.values ? attr.values.join(' | ') : '—'}</td>
|
|
|
<td class="default">{'value' in attr && attr.value ? (attr as { value: { kind: string } }).value.kind : '—'}</td>
|
|
|
</tr>
|
|
|
{/each}
|
|
|
</tbody>
|
|
|
</table>
|
|
|
</div>
|
|
|
{/if}
|
|
|
{/each}
|
|
|
|
|
|
<div data-uix-subsection-head>Events declaration</div>
|
|
|
<div data-uix-table-wrap>
|
|
|
<table data-uix-table>
|
|
|
<thead><tr><th>name</th><th>family</th><th>verb</th><th>sequence</th><th>intent</th><th>target</th></tr></thead>
|
|
|
<tbody>
|
|
|
{#each events as action}
|
|
|
{@const sem = action.semantic}
|
|
|
{@const intentDecl = 'intent' in sem ? sem.intent : undefined}
|
|
|
<tr>
|
|
|
<td class="name">{action.name}</td>
|
|
|
<td class="type">{sem.family}</td>
|
|
|
<td>{sem.verb ?? '—'}</td>
|
|
|
<td>{sem.sequence ?? 'pre'}</td>
|
|
|
<td class="default">{typeof intentDecl === 'string' ? intentDecl : '—'}</td>
|
|
|
<td>{action.target}</td>
|
|
|
</tr>
|
|
|
{/each}
|
|
|
</tbody>
|
|
|
</table>
|
|
|
</div>
|
|
|
</section>
|
|
|
{/if}
|
|
|
|
|
|
{#if tab === 'sema'}
|
|
|
<section data-uix-section id="sema">
|
|
|
<h2 data-uix-section-title>
|
|
|
<span data-uix-layer-badge="sema">sema</span>
|
|
|
· events + perceptual signature
|
|
|
</h2>
|
|
|
<p data-uix-section-desc>
|
|
|
<code>handle-drag</code> / <code>handle-resize</code> (handle · tick) fire once at gesture
|
|
|
start — the move itself is visual, not per-frame (no haptic buzz). <code>commit-crop</code>
|
|
|
(commit · apply · affirm) when the Blob is produced. Click <strong>play</strong> to fire.
|
|
|
</p>
|
|
|
<div data-uix-table-wrap>
|
|
|
<table data-uix-table>
|
|
|
<thead><tr><th>Name</th><th>Family</th><th>Verb</th><th>Sequence</th><th>Intent</th><th>Play</th></tr></thead>
|
|
|
<tbody>
|
|
|
{#each events as action}
|
|
|
{@const intentDecl = 'intent' in action.semantic ? action.semantic.intent : undefined}
|
|
|
{@const effectiveIntent = typeof intentDecl === 'string' ? intentDecl : undefined}
|
|
|
<tr>
|
|
|
<td class="name">{action.name}</td>
|
|
|
<td class="type">{action.semantic.family}</td>
|
|
|
<td>{action.semantic.verb ?? '—'}</td>
|
|
|
<td>{action.semantic.sequence ?? 'pre'}</td>
|
|
|
<td class="default">{effectiveIntent ?? '—'}</td>
|
|
|
<td>
|
|
|
<button
|
|
|
data-uix-play
|
|
|
onclick={() => {
|
|
|
const sel = `[data-cropper-${action.target === 'handle' ? 'handle' : action.target === 'selection' ? 'selection' : 'provider'}]`;
|
|
|
const target = (stageRef?.querySelector(sel) ?? stageRef?.querySelector('[data-cropper]') ?? stageRef) as HTMLElement | null;
|
|
|
if (!target) return;
|
|
|
void uix.events?.emit({ name: action.name, family: action.semantic.family, target, ...(effectiveIntent ? { intent: effectiveIntent } : {}) });
|
|
|
}}
|
|
|
>▶ play</button>
|
|
|
</td>
|
|
|
</tr>
|
|
|
{/each}
|
|
|
</tbody>
|
|
|
</table>
|
|
|
</div>
|
|
|
</section>
|
|
|
{/if}
|
|
|
|
|
|
{#if tab === 'recipe'}
|
|
|
<section data-uix-section>
|
|
|
<h2 data-uix-section-title>Eidos recipe</h2>
|
|
|
<p data-uix-section-desc>
|
|
|
Selectors at <code>src/uix/eidos/components/cropper/cropper.css</code>. Tokens
|
|
|
bare-prefixed (<code>--cropper-*</code>). The darken-mask is a box-shadow on the Selection
|
|
|
(follows border-radius → circular cutout for <code>round</code>).
|
|
|
</p>
|
|
|
<div data-uix-table-wrap>
|
|
|
<table data-uix-table>
|
|
|
<thead><tr><th>Selector</th><th>Source</th></tr></thead>
|
|
|
<tbody>
|
|
|
<tr><td class="name">[data-cropper] / [data-cropper][data-disabled]</td><td><span data-uix-tag data-kind="soma">morfo</span></td></tr>
|
|
|
<tr><td class="name">[data-cropper-viewport]</td><td><span data-uix-tag data-kind="soma">morfo</span></td></tr>
|
|
|
<tr><td class="name">[data-cropper-selection]</td><td><span data-uix-tag data-kind="soma">morfo</span></td></tr>
|
|
|
<tr><td class="name">[data-cropper-selection][data-shape='round']</td><td><span data-uix-tag data-kind="soma">morfo</span></td></tr>
|
|
|
<tr><td class="name">[data-cropper-grid]</td><td><span data-uix-tag data-kind="soma">morfo</span></td></tr>
|
|
|
<tr><td class="name">[data-cropper-handle][data-corner='…']</td><td><span data-uix-tag data-kind="soma">morfo</span></td></tr>
|
|
|
</tbody>
|
|
|
</table>
|
|
|
</div>
|
|
|
</section>
|
|
|
{/if}
|
|
|
|
|
|
{#if tab === 'a11y'}
|
|
|
<section data-uix-section>
|
|
|
<h2 data-uix-section-title>Accessibility</h2>
|
|
|
<p data-uix-section-desc>
|
|
|
The cropper is a labelled <code>role="group"</code>. v1 is pointer-driven; keyboard
|
|
|
move/resize of the selection is a documented gap (see the README).
|
|
|
</p>
|
|
|
<div data-uix-subsection-head>ARIA contract</div>
|
|
|
<div data-uix-table-wrap>
|
|
|
<table data-uix-table>
|
|
|
<thead><tr><th>Part</th><th>Attribute</th><th>Value</th></tr></thead>
|
|
|
<tbody>
|
|
|
<tr><td class="name">provider</td><td>role</td><td class="type">"group"</td></tr>
|
|
|
<tr><td class="name">provider</td><td>aria-label</td><td class="type">"Crop image" (or prop)</td></tr>
|
|
|
<tr><td class="name">selection</td><td>data-shape</td><td class="type">"rect" | "round"</td></tr>
|
|
|
<tr><td class="name">selection</td><td>data-dragging</td><td class="type">while moving / resizing</td></tr>
|
|
|
<tr><td class="name">handle</td><td>data-corner</td><td class="type">nw | ne | se | sw</td></tr>
|
|
|
</tbody>
|
|
|
</table>
|
|
|
</div>
|
|
|
</section>
|
|
|
{/if}
|
|
|
</div>
|
|
|
|
|
|
<style>
|
|
|
.cr-stage {
|
|
|
display: grid;
|
|
|
grid-template-columns: minmax(240px, 1fr) auto;
|
|
|
gap: var(--uix-space-4);
|
|
|
align-items: start;
|
|
|
inline-size: 100%;
|
|
|
}
|
|
|
@media (max-width: 720px) {
|
|
|
.cr-stage {
|
|
|
grid-template-columns: 1fr;
|
|
|
}
|
|
|
}
|
|
|
.cr-editor {
|
|
|
display: flex;
|
|
|
flex-direction: column;
|
|
|
gap: var(--uix-space-2);
|
|
|
align-items: flex-start;
|
|
|
max-inline-size: 460px;
|
|
|
}
|
|
|
.cr-result {
|
|
|
display: flex;
|
|
|
flex-direction: column;
|
|
|
gap: var(--uix-space-1);
|
|
|
}
|
|
|
.cr-result-img {
|
|
|
inline-size: 140px;
|
|
|
block-size: auto;
|
|
|
border-radius: var(--radius-md);
|
|
|
border: 1px solid var(--color-border-default);
|
|
|
background: var(--color-surface-muted);
|
|
|
}
|
|
|
.cr-result-img[data-shape='round'] {
|
|
|
border-radius: 50%;
|
|
|
}
|
|
|
</style>
|