feat(primitives): media-editor components, category reorg, perf + type cleanup

Reorganize components into category folders (forms/canvas/overlays/etc.); add the
media-editor headless family (timeline, curve-editor, waveform, crop, color
picker, etc.); apply perf fixes (O(1) collection lookups, plain-object drag
state, gesture-leak teardown, shallowRef color state, rect caching) and replace
source `any` with proper types.
This commit is contained in:
2026-06-15 16:54:29 +07:00
parent 661a55719e
commit eefd7abf83
1029 changed files with 65815 additions and 9449 deletions
+241
View File
@@ -0,0 +1,241 @@
import type { QrCode } from '@robonen/encoding';
import { QrCodeDataType } from '@robonen/encoding';
import { clamp } from '@robonen/stdlib';
/**
* Geometry helpers for rendering a {@link QrCode} matrix to SVG paths.
*
* Every coordinate is expressed in *module units*: a module at grid index
* `(x, y)` occupies the square `[x, x + 1] × [y, y + 1]`, so its center sits at
* `(x + 0.5, y + 0.5)`. Multiplying by a scale factor (or letting the SVG
* viewBox do it) yields pixels — the paths themselves are resolution-independent.
*/
/** Visual style applied to each data module ("pixel") of the code. */
export type QrCellPattern = 'square' | 'dot' | 'rounded' | 'fluid';
/** Shape of the outer 7×7 ring of a finder ("eye") pattern. */
export type QrMarkerFrame = 'square' | 'rounded' | 'circle';
/** Shape of the inner 3×3 ball of a finder ("eye") pattern. */
export type QrMarkerBall = 'square' | 'rounded' | 'circle' | 'diamond';
/** Which of the three finder patterns a {@link MarkerPlacement} refers to. */
export type MarkerCorner = 'top-left' | 'top-right' | 'bottom-left';
/** Position of a single finder pattern, given as the top-left module of its 7×7 region. */
export interface MarkerPlacement {
corner: MarkerCorner;
/** X index of the finder's top-left module. */
x: number;
/** Y index of the finder's top-left module. */
y: number;
}
/** A rectangular region of the matrix, in module units, used to knock out modules behind overlays. */
export interface QrCodeRegion {
x: number;
y: number;
width: number;
height: number;
}
/** Description of one dark module, passed to the `#cell` slot for custom rendering. */
export interface QrCellDescriptor {
/** Column index. */
x: number;
/** Row index. */
y: number;
/** Center X (`x + 0.5`). */
cx: number;
/** Center Y (`y + 0.5`). */
cy: number;
/** Structural role of the module (data, timing, alignment, …). */
type: QrCodeDataType;
}
/** Returns the top-left module index of each of the three finder patterns. */
export function markerPlacements(size: number): MarkerPlacement[] {
return [
{ corner: 'top-left', x: 0, y: 0 },
{ corner: 'top-right', x: size - 7, y: 0 },
{ corner: 'bottom-left', x: 0, y: size - 7 },
];
}
/** Rounds to 4 decimals and strips trailing zeros to keep generated path strings compact. */
function fmt(value: number): string {
return String(Math.round(value * 1e4) / 1e4);
}
/**
* Builds a rectangle path with an independent corner radius per corner. A radius
* of `0` collapses that corner to a sharp right angle, which is how the `fluid`
* pattern merges a module into its dark neighbours.
*/
export function roundedRectPath(
x: number,
y: number,
w: number,
h: number,
rTL: number,
rTR: number,
rBR: number,
rBL: number,
): string {
const max = Math.min(w, h) / 2;
rTL = clamp(rTL, 0, max);
rTR = clamp(rTR, 0, max);
rBR = clamp(rBR, 0, max);
rBL = clamp(rBL, 0, max);
let d = `M${fmt(x + rTL)} ${fmt(y)}`;
d += `L${fmt(x + w - rTR)} ${fmt(y)}`;
if (rTR > 0)
d += `A${fmt(rTR)} ${fmt(rTR)} 0 0 1 ${fmt(x + w)} ${fmt(y + rTR)}`;
d += `L${fmt(x + w)} ${fmt(y + h - rBR)}`;
if (rBR > 0)
d += `A${fmt(rBR)} ${fmt(rBR)} 0 0 1 ${fmt(x + w - rBR)} ${fmt(y + h)}`;
d += `L${fmt(x + rBL)} ${fmt(y + h)}`;
if (rBL > 0)
d += `A${fmt(rBL)} ${fmt(rBL)} 0 0 1 ${fmt(x)} ${fmt(y + h - rBL)}`;
d += `L${fmt(x)} ${fmt(y + rTL)}`;
if (rTL > 0)
d += `A${fmt(rTL)} ${fmt(rTL)} 0 0 1 ${fmt(x + rTL)} ${fmt(y)}`;
return `${d}Z`;
}
/** Full circle (disc) path, drawn as two arcs. */
export function circlePath(cx: number, cy: number, r: number): string {
return `M${fmt(cx - r)} ${fmt(cy)}`
+ `A${fmt(r)} ${fmt(r)} 0 1 0 ${fmt(cx + r)} ${fmt(cy)}`
+ `A${fmt(r)} ${fmt(r)} 0 1 0 ${fmt(cx - r)} ${fmt(cy)}Z`;
}
function diamondPath(cx: number, cy: number, r: number): string {
return `M${fmt(cx)} ${fmt(cy - r)}L${fmt(cx + r)} ${fmt(cy)}`
+ `L${fmt(cx)} ${fmt(cy + r)}L${fmt(cx - r)} ${fmt(cy)}Z`;
}
/** Predicate deciding whether a module participates in cell rendering. */
type ModuleFilter = (x: number, y: number) => boolean;
interface CellOptions {
pattern: QrCellPattern;
/** Corner roundness in `[0, 1]` for `rounded`/`fluid` patterns. */
radius: number;
/** Inset applied to every module in `[0, 1)` to create gaps between cells. */
gap: number;
/** When `false`, modules belonging to a finder pattern are skipped (drawn by `QrCodeMarker`). */
includeMarkers: boolean;
/** Returns `true` for modules covered by an overlay (e.g. a logo) — they are skipped. */
isReserved: ModuleFilter;
}
/** Whether a module should be drawn by `QrCodeCells` given the active options. */
function isCell(qr: QrCode, x: number, y: number, opts: CellOptions): boolean {
if (x < 0 || y < 0 || x >= qr.size || y >= qr.size)
return false;
if (!qr.getModule(x, y))
return false;
if (!opts.includeMarkers && qr.getType(x, y) === QrCodeDataType.Position)
return false;
return !opts.isReserved(x, y);
}
function cellSnippet(qr: QrCode, x: number, y: number, opts: CellOptions): string {
const { pattern } = opts;
if (pattern === 'fluid') {
// Connect to dark neighbours by squaring off the corners between them.
const r = clamp(opts.radius, 0, 1) * 0.5;
const top = isCell(qr, x, y - 1, opts);
const bottom = isCell(qr, x, y + 1, opts);
const left = isCell(qr, x - 1, y, opts);
const right = isCell(qr, x + 1, y, opts);
return roundedRectPath(
x,
y,
1,
1,
!top && !left ? r : 0,
!top && !right ? r : 0,
!bottom && !right ? r : 0,
!bottom && !left ? r : 0,
);
}
const gap = clamp(opts.gap, 0, 0.95);
const inset = gap / 2;
const s = 1 - gap;
if (pattern === 'dot')
return circlePath(x + 0.5, y + 0.5, s / 2);
const r = pattern === 'rounded' ? clamp(opts.radius, 0, 1) * (s / 2) : 0;
return roundedRectPath(x + inset, y + inset, s, s, r, r, r, r);
}
/** Builds a single `<path>` `d` string covering every rendered data module. */
export function cellsPath(qr: QrCode, opts: CellOptions): string {
const size = qr.size;
let d = '';
for (let y = 0; y < size; y++) {
for (let x = 0; x < size; x++) {
if (isCell(qr, x, y, opts))
d += cellSnippet(qr, x, y, opts);
}
}
return d;
}
/** Collects descriptors for every rendered data module, for slot-based custom rendering. */
export function cellList(
qr: QrCode,
opts: Pick<CellOptions, 'includeMarkers' | 'isReserved'>,
): QrCellDescriptor[] {
const full: CellOptions = { ...opts, pattern: 'square', radius: 0, gap: 0 };
const size = qr.size;
const cells: QrCellDescriptor[] = [];
for (let y = 0; y < size; y++) {
for (let x = 0; x < size; x++) {
if (isCell(qr, x, y, full))
cells.push({ x, y, cx: x + 0.5, cy: y + 0.5, type: qr.getType(x, y) });
}
}
return cells;
}
/**
* Path for a finder pattern's outer ring. The frame occupies the 7×7 region
* with a 1-module-thick border (`square`/`rounded`) or an annulus (`circle`),
* leaving the standard 1-module light gap before the inner ball. Render with
* `fill-rule="evenodd"` so the inner cut-out reads as a hole.
*/
export function markerFramePath(mx: number, my: number, shape: QrMarkerFrame, radius: number): string {
const cx = mx + 3.5;
const cy = my + 3.5;
const t = clamp(radius, 0, 1);
if (shape === 'circle')
return circlePath(cx, cy, 3.5) + circlePath(cx, cy, 2.5);
const ro = shape === 'rounded' ? t * 3.5 : 0;
const ri = shape === 'rounded' ? Math.max(0, ro - 1) : 0;
return roundedRectPath(mx, my, 7, 7, ro, ro, ro, ro)
+ roundedRectPath(mx + 1, my + 1, 5, 5, ri, ri, ri, ri);
}
/** Path for a finder pattern's inner 3×3 ball. */
export function markerBallPath(mx: number, my: number, shape: QrMarkerBall, radius: number): string {
const cx = mx + 3.5;
const cy = my + 3.5;
if (shape === 'circle')
return circlePath(cx, cy, 1.5);
if (shape === 'diamond')
return diamondPath(cx, cy, 1.5);
const r = shape === 'rounded' ? clamp(radius, 0, 1) * 1.5 : 0;
return roundedRectPath(mx + 2, my + 2, 3, 3, r, r, r, r);
}