diff options
| author | Maze <167211895+mazeincoding@users.noreply.github.com> | 2026-02-23 03:24:02 +0100 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-02-23 03:24:02 +0100 |
| commit | 93d1e3383ce47e45157d70f9ba6251239dae6bc2 (patch) | |
| tree | 1e33a2d7533b65f0e7c8927f03905a1a99937bd1 /apps/web/src/utils | |
| parent | fca99d6126c31fbb18ed9f1034cee6f940b040e8 (diff) | |
| download | opencut-93d1e3383ce47e45157d70f9ba6251239dae6bc2.tar.gz | |
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
Diffstat (limited to 'apps/web/src/utils')
| -rw-r--r-- | apps/web/src/utils/color.ts | 182 | ||||
| -rw-r--r-- | apps/web/src/utils/math.ts | 16 |
2 files changed, 198 insertions, 0 deletions
diff --git a/apps/web/src/utils/color.ts b/apps/web/src/utils/color.ts new file mode 100644 index 00000000..7b01a92e --- /dev/null +++ b/apps/web/src/utils/color.ts @@ -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); + } + } +} diff --git a/apps/web/src/utils/math.ts b/apps/web/src/utils/math.ts index 012ac747..ac82ca17 100644 --- a/apps/web/src/utils/math.ts +++ b/apps/web/src/utils/math.ts @@ -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; + } +} |
