feat: major editor overhaul (assets, properties, timeline, fonts) (#709)

* feat: major editor overhaul (assets, properties, timeline, fonts)

Refactor editor core systems to standardize UI architecture and improve performance.

Assets & Properties:
- Replace monolithic property items with composable `Section` architecture.
- Add specialized sections for Transform, Blending, and Text.
- Implement `NumberField` with scrubbing and math evaluation.
- Add new ColorPicker with EyeDropper and multiple format support.
- Standardize asset panels using new `PanelView` layout.

Fonts & Stickers:
- Implement custom font atlas/sprite system for high-performance previews.
- Add virtualized FontPicker with search and favorites.
- Refactor stickers to use a provider-based architecture (icons, emoji, flags, shapes).
- Standardize sticker IDs to `provider:value` format.

Timeline & Interaction:
- Convert bookmarks to rich objects with notes, colors, and duration.
- Refactor drag-and-drop to use Command pattern (enabling proper undo/redo).
- Add Shift modifier to disable snapping during moves/resizes.
- Add new overlays for layout guides and text editing.

Renderer:
- Add support for multi-line text, custom line-height, and letter-spacing.
- Implement global composite operation (blend modes).
- Update sticker node to resolve dynamic provider IDs.

Infrastructure:
- Add storage migrations (v3->v6) for text weights, sticker IDs, and bookmarks.
- Update global styles and core UI components (Button, Input, Popover).

* add ts-nocheck directive to settings-legacy.tsx to suppress TypeScript errors

* fix: correct global composite operation assignment in TextNode to ensure proper blend mode handling

* deleted shadcn components with errors

* formatting

* fix linter issues

* migrate from next middleware to proxy

* add missing component back

* add breadcrumb back

* chore: add @radix-ui/react-primitive deps

* chore: more deps

* chore: add missing env vars to bun-ci

* next env
This commit is contained in:
Maze
2026-02-23 03:24:02 +01:00
committed by GitHub
parent fca99d6126
commit 93d1e3383c
215 changed files with 26980 additions and 8364 deletions
+182
View File
@@ -0,0 +1,182 @@
import { converter, formatHex, formatHex8, parse, type Rgb } from "culori";
export type ColorFormat = "hex" | "rgb" | "hsl" | "hsv";
const toRgb = converter("rgb");
const toHsv = converter("hsv");
const toHsl = converter("hsl");
export function hexToHsv({ hex }: { hex: string }): [number, number, number] {
const color = toHsv(`#${hex}`);
if (!color) return [0, 0, 0];
return [color.h ?? 0, color.s ?? 0, color.v ?? 0];
}
export function hsvToHex({
h,
s,
v,
}: {
h: number;
s: number;
v: number;
}): string {
const hex = formatHex({ mode: "hsv", h, s, v });
return hex.slice(1);
}
export function parseHexAlpha({ hex }: { hex: string }): {
rgb: string;
alpha: number;
} {
const color = parse(`#${hex}`);
const rgbHex = color
? formatHex(color).slice(1)
: hex.slice(0, 6).toLowerCase();
return {
rgb: rgbHex,
alpha: color?.alpha ?? 1,
};
}
export function appendAlpha({
rgbHex,
alpha,
}: {
rgbHex: string;
alpha: number;
}): string {
if (alpha >= 1) return rgbHex;
const hex8 = formatHex8({ mode: "rgb", r: 0, g: 0, b: 0, alpha });
const alphaHex = hex8.slice(7, 9);
return rgbHex + alphaHex;
}
function stripCssNoise({ text }: { text: string }): string {
let cleaned = text.trim();
cleaned = cleaned
.replace(/\s*!important\s*/gi, "")
.replace(/;+\s*$/, "")
.trim();
const colonIndex = cleaned.indexOf(":");
const parenIndex = cleaned.indexOf("(");
if (colonIndex !== -1 && (parenIndex === -1 || colonIndex < parenIndex)) {
cleaned = cleaned.slice(colonIndex + 1).trim();
}
return cleaned;
}
function colorToHexWithAlpha({ color }: { color: Rgb }): string {
const hex = formatHex(color).slice(1);
if (color.alpha !== undefined && color.alpha < 1) {
const hex8 = formatHex8(color);
return hex8.slice(1);
}
return hex;
}
export function extractColorFromText({
text,
}: {
text: string;
}): string | null {
const cleaned = stripCssNoise({ text });
const color = toRgb(cleaned);
if (color) return colorToHexWithAlpha({ color });
// bare hex without # (culori needs the prefix)
const bareHexMatch = cleaned.match(/^([0-9a-fA-F]{3,8})$/);
if (bareHexMatch) {
const withHash = toRgb(`#${bareHexMatch[1]}`);
if (withHash) return colorToHexWithAlpha({ color: withHash });
}
// fallback: find #hex anywhere in the original text
const embeddedHexMatch = text.match(/#([0-9a-fA-F]{3,8})\b/);
if (embeddedHexMatch) {
const embedded = toRgb(`#${embeddedHexMatch[1]}`);
if (embedded) return colorToHexWithAlpha({ color: embedded });
}
return null;
}
export function formatColorValue({
hex,
format,
}: {
hex: string;
format: ColorFormat;
}): string {
switch (format) {
case "hex":
return hex;
case "rgb": {
const color = toRgb(`#${hex}`);
if (!color) return hex;
return `${Math.round(color.r * 255)}, ${Math.round(color.g * 255)}, ${Math.round(color.b * 255)}`;
}
case "hsl": {
const color = toHsl(`#${hex}`);
if (!color) return hex;
return `${Math.round(color.h ?? 0)}, ${Math.round((color.s ?? 0) * 100)}%, ${Math.round((color.l ?? 0) * 100)}%`;
}
case "hsv": {
const color = toHsv(`#${hex}`);
if (!color) return hex;
return `${Math.round(color.h ?? 0)}, ${Math.round((color.s ?? 0) * 100)}%, ${Math.round((color.v ?? 0) * 100)}%`;
}
}
}
export function parseColorInput({
input,
format,
}: {
input: string;
format: ColorFormat;
}): string | null {
switch (format) {
case "hex": {
const cleaned = input.replace("#", "");
const isValidHex = /^[0-9a-fA-F]{3,8}$/.test(cleaned);
return isValidHex ? cleaned : null;
}
case "rgb": {
const parts = input.split(",").map((part) => parseInt(part.trim(), 10));
if (parts.length < 3 || parts.some(Number.isNaN)) return null;
const color = {
mode: "rgb" as const,
r: parts[0] / 255,
g: parts[1] / 255,
b: parts[2] / 255,
};
return formatHex(color).slice(1);
}
case "hsl": {
const parts = input.split(",").map((part) => parseFloat(part.trim()));
if (parts.length < 3 || parts.some(Number.isNaN)) return null;
const color = {
mode: "hsl" as const,
h: parts[0],
s: parts[1] / 100,
l: parts[2] / 100,
};
return formatHex(color).slice(1);
}
case "hsv": {
const parts = input.split(",").map((part) => parseFloat(part.trim()));
if (parts.length < 3 || parts.some(Number.isNaN)) return null;
const color = {
mode: "hsv" as const,
h: parts[0],
s: parts[1] / 100,
v: parts[2] / 100,
};
return formatHex(color).slice(1);
}
}
}
+16
View File
@@ -9,3 +9,19 @@ export function clamp({
}): number {
return Math.max(min, Math.min(max, value));
}
export function evaluateMathExpression({
input,
}: {
input: string;
}): number | null {
const sanitized = input.trim();
if (!/^[\d.\s+\-*/()]+$/.test(sanitized)) return null;
try {
const result = new Function(`return (${sanitized})`)();
if (typeof result !== "number" || !Number.isFinite(result)) return null;
return result;
} catch {
return null;
}
}