You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
svelte-kit-vice/web/routes/uix/components/cropper/+page.svelte

573 lines
24 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

<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>&lt;canvas&gt;</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">&lt;{part.defaultElement}&gt;</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>

Powered by TurnKey Linux.