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.
367 lines
12 KiB
367 lines
12 KiB
|
3 months ago
|
import { context, type WithRefOpts } from '../../provider';
|
||
|
|
import { type Active, type ActiveProps, type StateProps } from '$libs/reactive';
|
||
|
|
import type { OnChangeFn } from '../../types';
|
||
|
|
import { KEYS } from '../../keyboard';
|
||
|
|
import type { Gradient, GradientStop, StopColor, GradientKind } from '$libs/gradient';
|
||
|
|
|
||
|
|
import { gradientBuilderMorfo } from '../../../morfo/components/gradient-builder';
|
||
|
|
import { Soma } from '../../core/soma.svelte';
|
||
|
|
import type { SomaRuntime, SomaRuntimePart } from '../../runtime.svelte';
|
||
|
|
|
||
|
|
const clamp01 = (n: number): number => Math.min(1, Math.max(0, n));
|
||
|
|
const wrap360 = (deg: number): number => ((deg % 360) + 360) % 360;
|
||
|
|
/** Position step for a keyboard nudge (1%); Shift uses the coarse step. */
|
||
|
|
const STEP = 0.01;
|
||
|
|
const COARSE_STEP = 0.1;
|
||
|
|
|
||
|
|
// ── Root ─────────────────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
interface GradientBuilderOpts
|
||
|
|
extends WithRefOpts,
|
||
|
|
StateProps<{ value: Gradient }>,
|
||
|
|
ActiveProps<{
|
||
|
|
disabled: boolean;
|
||
|
|
onValueCommit: OnChangeFn<Gradient>;
|
||
|
|
}> {}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Headless GradientBuilder — the state machine over the canonical `Gradient`
|
||
|
|
* model (`$libs/gradient`). Owns stop add / move / remove / recolor, the kind
|
||
|
|
* (linear/radial/conic), and the angle. Every mutation flows through here so the
|
||
|
|
* Stop slider thumbs, the track, and the controls all read one source of truth.
|
||
|
|
* v1 edits the `stops` of a linear/radial/conic gradient (mesh editing is the
|
||
|
|
* fast-follow).
|
||
|
|
*/
|
||
|
|
export class GradientBuilderProvider {
|
||
|
|
readonly opts: GradientBuilderOpts;
|
||
|
|
readonly runtime: SomaRuntime;
|
||
|
|
readonly runtimePart: SomaRuntimePart;
|
||
|
|
readonly soma: Soma;
|
||
|
|
static readonly ctx = context<GradientBuilderProvider>('GradientBuilder');
|
||
|
|
static get(): GradientBuilderProvider | undefined {
|
||
|
|
return this.ctx.getOr(undefined) as GradientBuilderProvider | undefined;
|
||
|
|
}
|
||
|
|
static require(): GradientBuilderProvider {
|
||
|
|
return this.ctx.get();
|
||
|
|
}
|
||
|
|
static create(opts: GradientBuilderOpts) {
|
||
|
|
return new GradientBuilderProvider(opts);
|
||
|
|
}
|
||
|
|
|
||
|
|
/** The stop the controls edit (its color, etc.). */
|
||
|
|
selectedIndex = $state(0);
|
||
|
|
/** The stop currently being dragged, or null. */
|
||
|
|
activeDragIndex = $state<number | null>(null);
|
||
|
|
/** The track element (the drag surface) — registered by the Track part. */
|
||
|
|
trackRef = $state<HTMLElement | null>(null);
|
||
|
|
|
||
|
|
get isDisabled(): boolean {
|
||
|
|
return this.opts.disabled.current;
|
||
|
|
}
|
||
|
|
|
||
|
|
/** The current gradient's stops (linear/radial/conic). Mesh has none → empty. */
|
||
|
|
readonly stops = $derived.by<readonly GradientStop[]>(() => {
|
||
|
|
const v = this.opts.value.current;
|
||
|
|
return v.kind === 'mesh' ? [] : v.stops;
|
||
|
|
});
|
||
|
|
|
||
|
|
readonly kind = $derived.by<GradientKind>(() => this.opts.value.current.kind);
|
||
|
|
|
||
|
|
private constructor(opts: GradientBuilderOpts) {
|
||
|
|
this.opts = opts;
|
||
|
|
this.soma = Soma.require();
|
||
|
|
this.runtime = this.soma.runtime(gradientBuilderMorfo, {
|
||
|
|
props: {
|
||
|
|
disabled: () => this.isDisabled
|
||
|
|
},
|
||
|
|
events: {
|
||
|
|
'commit-set': () => this.opts.onValueCommit.current(this.opts.value.current)
|
||
|
|
}
|
||
|
|
});
|
||
|
|
this.runtimePart = this.runtime.part('provider', {
|
||
|
|
id: opts.id,
|
||
|
|
ref: opts.ref,
|
||
|
|
owner: this,
|
||
|
|
context: GradientBuilderProvider.ctx,
|
||
|
|
syncAttrs: true
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
setTrackRef(el: HTMLElement | null): void {
|
||
|
|
this.trackRef = el;
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Position 0..1 of stop `i` — its explicit position or the even distribution. */
|
||
|
|
stopPosition(i: number): number {
|
||
|
|
const s = this.stops;
|
||
|
|
const p = s[i]?.position;
|
||
|
|
if (p != null) return p;
|
||
|
|
return s.length > 1 ? i / (s.length - 1) : 0;
|
||
|
|
}
|
||
|
|
|
||
|
|
selectStop(i: number): void {
|
||
|
|
if (i >= 0 && i < this.stops.length) this.selectedIndex = i;
|
||
|
|
}
|
||
|
|
|
||
|
|
private writeStops(next: GradientStop[]): void {
|
||
|
|
const v = this.opts.value.current;
|
||
|
|
if (v.kind === 'mesh') return;
|
||
|
|
this.opts.value.current = { ...v, stops: next };
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Set stop `i`'s position (0..1), no commit (the live drag / keyboard step). */
|
||
|
|
moveStop(i: number, position01: number): void {
|
||
|
|
if (this.isDisabled || i < 0 || i >= this.stops.length) return;
|
||
|
|
const stops = [...this.stops];
|
||
|
|
stops[i] = { ...stops[i], position: clamp01(position01) };
|
||
|
|
this.writeStops(stops);
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Keyboard nudge — move + commit. */
|
||
|
|
nudgeStop(i: number, delta: number): void {
|
||
|
|
this.moveStop(i, this.stopPosition(i) + delta);
|
||
|
|
this.commit();
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Insert a stop at `position01`, colored from the nearest neighbour. */
|
||
|
|
addStop(position01: number): void {
|
||
|
|
if (this.isDisabled) return;
|
||
|
|
const pos = clamp01(position01);
|
||
|
|
const stops = [...this.stops];
|
||
|
|
let nearest = 0;
|
||
|
|
let best = Infinity;
|
||
|
|
stops.forEach((_, idx) => {
|
||
|
|
const d = Math.abs(this.stopPosition(idx) - pos);
|
||
|
|
if (d < best) {
|
||
|
|
best = d;
|
||
|
|
nearest = idx;
|
||
|
|
}
|
||
|
|
});
|
||
|
|
const color: StopColor = stops[nearest]?.color ?? { kind: 'css', value: 'currentColor' };
|
||
|
|
stops.push({ color, position: pos });
|
||
|
|
stops.sort((a, b) => (a.position ?? 0) - (b.position ?? 0));
|
||
|
|
this.writeStops(stops);
|
||
|
|
this.selectedIndex = stops.findIndex((s) => s.position === pos);
|
||
|
|
this.commit();
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Remove stop `i` (a gradient needs ≥ 2 stops). */
|
||
|
|
removeStop(i: number): void {
|
||
|
|
if (this.isDisabled || this.stops.length <= 2 || i < 0 || i >= this.stops.length) return;
|
||
|
|
const stops = this.stops.filter((_, idx) => idx !== i);
|
||
|
|
this.writeStops(stops);
|
||
|
|
this.selectedIndex = Math.min(this.selectedIndex, stops.length - 1);
|
||
|
|
this.commit();
|
||
|
|
}
|
||
|
|
|
||
|
|
setStopColor(i: number, color: StopColor): void {
|
||
|
|
if (this.isDisabled || i < 0 || i >= this.stops.length) return;
|
||
|
|
const stops = [...this.stops];
|
||
|
|
stops[i] = { ...stops[i], color };
|
||
|
|
this.writeStops(stops);
|
||
|
|
this.commit();
|
||
|
|
}
|
||
|
|
|
||
|
|
setStopAlpha(i: number, alpha: number): void {
|
||
|
|
if (this.isDisabled || i < 0 || i >= this.stops.length) return;
|
||
|
|
const stops = [...this.stops];
|
||
|
|
stops[i] = { ...stops[i], alpha: clamp01(alpha) };
|
||
|
|
this.writeStops(stops);
|
||
|
|
this.commit();
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Switch geometry, preserving the stops. */
|
||
|
|
setKind(kind: 'linear' | 'radial' | 'conic'): void {
|
||
|
|
if (this.isDisabled) return;
|
||
|
|
const v = this.opts.value.current;
|
||
|
|
if (v.kind === kind || v.kind === 'mesh') return;
|
||
|
|
this.opts.value.current = {
|
||
|
|
kind,
|
||
|
|
stops: [...this.stops],
|
||
|
|
interpolation: v.interpolation,
|
||
|
|
hints: v.hints
|
||
|
|
} as Gradient;
|
||
|
|
this.commit();
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Set the linear angle / conic from-angle (degrees). */
|
||
|
|
setAngle(deg: number): void {
|
||
|
|
if (this.isDisabled) return;
|
||
|
|
const v = this.opts.value.current;
|
||
|
|
if (v.kind === 'linear') this.opts.value.current = { ...v, angle: wrap360(deg) };
|
||
|
|
else if (v.kind === 'conic') this.opts.value.current = { ...v, from: wrap360(deg) };
|
||
|
|
else return;
|
||
|
|
this.commit();
|
||
|
|
}
|
||
|
|
|
||
|
|
/** The current angle for the dial (linear `angle` / conic `from`, else 0). */
|
||
|
|
get angle(): number {
|
||
|
|
const v = this.opts.value.current;
|
||
|
|
if (v.kind === 'linear') return v.angle ?? 0;
|
||
|
|
if (v.kind === 'conic') return v.from ?? 0;
|
||
|
|
return 0;
|
||
|
|
}
|
||
|
|
|
||
|
|
commit(): void {
|
||
|
|
void this.runtime.trigger('commit-set');
|
||
|
|
}
|
||
|
|
|
||
|
|
reset(value: Gradient): void {
|
||
|
|
this.opts.value.current = value;
|
||
|
|
void this.runtime.trigger('commit-reset');
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Map a pointer x to a position 0..1 along the registered track. */
|
||
|
|
positionFromPointer(clientX: number): number {
|
||
|
|
const el = this.trackRef;
|
||
|
|
if (!el) return 0;
|
||
|
|
const rect = el.getBoundingClientRect();
|
||
|
|
if (rect.width === 0) return 0;
|
||
|
|
return clamp01((clientX - rect.left) / rect.width);
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── Drag (driven by a Stop via pointer capture) ──────────────────────────
|
||
|
|
startStopDrag(i: number): void {
|
||
|
|
this.selectStop(i);
|
||
|
|
this.activeDragIndex = i;
|
||
|
|
void this.runtime.trigger('handle-pick');
|
||
|
|
}
|
||
|
|
dragStopTo(clientX: number): void {
|
||
|
|
const i = this.activeDragIndex;
|
||
|
|
if (i == null) return;
|
||
|
|
this.moveStop(i, this.positionFromPointer(clientX));
|
||
|
|
void this.runtime.trigger('handle-drag');
|
||
|
|
}
|
||
|
|
endStopDrag(): void {
|
||
|
|
if (this.activeDragIndex == null) return;
|
||
|
|
this.activeDragIndex = null;
|
||
|
|
this.commit();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── Stop (one slider thumb — the keyboard-accessible color stop) ──────────────
|
||
|
|
|
||
|
|
interface GradientStopOpts extends WithRefOpts {
|
||
|
|
index: Active<number>;
|
||
|
|
}
|
||
|
|
|
||
|
|
export class GradientStopProvider {
|
||
|
|
readonly opts: GradientStopOpts;
|
||
|
|
readonly runtimePart: SomaRuntimePart;
|
||
|
|
readonly provider = GradientBuilderProvider.require();
|
||
|
|
static readonly ctx = context<GradientStopProvider>('GradientStop');
|
||
|
|
static create(opts: GradientStopOpts) {
|
||
|
|
return new GradientStopProvider(opts);
|
||
|
|
}
|
||
|
|
|
||
|
|
private constructor(opts: GradientStopOpts) {
|
||
|
|
this.opts = opts;
|
||
|
|
this.runtimePart = this.provider.runtime.part('stop', {
|
||
|
|
id: opts.id,
|
||
|
|
ref: opts.ref,
|
||
|
|
owner: this,
|
||
|
|
context: GradientStopProvider.ctx,
|
||
|
|
syncAttrs: true
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
get index(): number {
|
||
|
|
return this.opts.index.current;
|
||
|
|
}
|
||
|
|
/** Position % for `aria-valuenow`. */
|
||
|
|
get position(): number {
|
||
|
|
return Math.round(this.provider.stopPosition(this.index) * 100);
|
||
|
|
}
|
||
|
|
get isSelected(): boolean {
|
||
|
|
return this.provider.selectedIndex === this.index;
|
||
|
|
}
|
||
|
|
|
||
|
|
/** The a11y differentiator: every stop is a real slider with arrow/Home/End/Delete. */
|
||
|
|
onkeydown(e: { key: string; shiftKey?: boolean; preventDefault(): void }): void {
|
||
|
|
const i = this.index;
|
||
|
|
const step = e.shiftKey ? COARSE_STEP : STEP;
|
||
|
|
switch (e.key) {
|
||
|
|
case KEYS.ARROW_LEFT:
|
||
|
|
case KEYS.ARROW_DOWN:
|
||
|
|
e.preventDefault();
|
||
|
|
this.provider.nudgeStop(i, -step);
|
||
|
|
break;
|
||
|
|
case KEYS.ARROW_RIGHT:
|
||
|
|
case KEYS.ARROW_UP:
|
||
|
|
e.preventDefault();
|
||
|
|
this.provider.nudgeStop(i, step);
|
||
|
|
break;
|
||
|
|
case KEYS.HOME:
|
||
|
|
e.preventDefault();
|
||
|
|
this.provider.moveStop(i, 0);
|
||
|
|
this.provider.commit();
|
||
|
|
break;
|
||
|
|
case KEYS.END:
|
||
|
|
e.preventDefault();
|
||
|
|
this.provider.moveStop(i, 1);
|
||
|
|
this.provider.commit();
|
||
|
|
break;
|
||
|
|
case KEYS.DELETE:
|
||
|
|
case KEYS.BACKSPACE:
|
||
|
|
e.preventDefault();
|
||
|
|
this.provider.removeStop(i);
|
||
|
|
break;
|
||
|
|
case KEYS.ENTER:
|
||
|
|
case KEYS.SPACE:
|
||
|
|
e.preventDefault();
|
||
|
|
this.provider.selectStop(i);
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
onpointerdown(e: { clientX: number; pointerId: number; currentTarget: EventTarget | null }): void {
|
||
|
|
const el = e.currentTarget as HTMLElement | null;
|
||
|
|
el?.setPointerCapture?.(e.pointerId);
|
||
|
|
this.provider.startStopDrag(this.index);
|
||
|
|
}
|
||
|
|
onpointermove(e: { clientX: number }): void {
|
||
|
|
this.provider.dragStopTo(e.clientX);
|
||
|
|
}
|
||
|
|
onpointerup(e: { pointerId: number; currentTarget: EventTarget | null }): void {
|
||
|
|
const el = e.currentTarget as HTMLElement | null;
|
||
|
|
el?.releasePointerCapture?.(e.pointerId);
|
||
|
|
this.provider.endStopDrag();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── Track (the stop rail / drag surface — click empty space to add a stop) ────
|
||
|
|
|
||
|
|
interface GradientTrackOpts extends WithRefOpts {}
|
||
|
|
|
||
|
|
export class GradientTrackProvider {
|
||
|
|
readonly opts: GradientTrackOpts;
|
||
|
|
readonly runtimePart: SomaRuntimePart;
|
||
|
|
readonly provider = GradientBuilderProvider.require();
|
||
|
|
static readonly ctx = context<GradientTrackProvider>('GradientTrack');
|
||
|
|
static create(opts: GradientTrackOpts) {
|
||
|
|
return new GradientTrackProvider(opts);
|
||
|
|
}
|
||
|
|
|
||
|
|
private constructor(opts: GradientTrackOpts) {
|
||
|
|
this.opts = opts;
|
||
|
|
this.runtimePart = this.provider.runtime.part('track', {
|
||
|
|
id: opts.id,
|
||
|
|
ref: opts.ref,
|
||
|
|
owner: this,
|
||
|
|
context: GradientTrackProvider.ctx,
|
||
|
|
syncAttrs: true
|
||
|
|
});
|
||
|
|
// The track IS the drag surface the provider measures against.
|
||
|
|
$effect(() => {
|
||
|
|
this.provider.setTrackRef(this.opts.ref?.current ?? null);
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Click on empty track → add a stop at that position. */
|
||
|
|
onpointerdown(e: { clientX: number; target: EventTarget | null; currentTarget: EventTarget | null }): void {
|
||
|
|
// Only the bare track adds — clicks on a stop are handled by the Stop.
|
||
|
|
if (e.target !== e.currentTarget) return;
|
||
|
|
this.provider.addStop(this.provider.positionFromPointer(e.clientX));
|
||
|
|
}
|
||
|
|
}
|