mirror of
https://github.com/OpenCut-app/OpenCut.git
synced 2026-07-13 21:52:53 +02:00
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:
@@ -0,0 +1,219 @@
|
||||
import type { TimelineTrack, TimelineElement } from "@/types/timeline";
|
||||
import type { MediaAsset } from "@/types/assets";
|
||||
import { isMainTrack } from "@/lib/timeline";
|
||||
import {
|
||||
DEFAULT_LINE_HEIGHT,
|
||||
FONT_SIZE_SCALE_REFERENCE,
|
||||
} from "@/constants/text-constants";
|
||||
|
||||
export interface ElementBounds {
|
||||
cx: number;
|
||||
cy: number;
|
||||
width: number;
|
||||
height: number;
|
||||
rotation: number;
|
||||
}
|
||||
|
||||
export interface ElementWithBounds {
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
element: TimelineElement;
|
||||
bounds: ElementBounds;
|
||||
}
|
||||
|
||||
function getVisualElementBounds({
|
||||
canvasWidth,
|
||||
canvasHeight,
|
||||
sourceWidth,
|
||||
sourceHeight,
|
||||
transform,
|
||||
}: {
|
||||
canvasWidth: number;
|
||||
canvasHeight: number;
|
||||
sourceWidth: number;
|
||||
sourceHeight: number;
|
||||
transform: {
|
||||
scale: number;
|
||||
position: { x: number; y: number };
|
||||
rotate: number;
|
||||
};
|
||||
}): ElementBounds {
|
||||
const containScale = Math.min(
|
||||
canvasWidth / sourceWidth,
|
||||
canvasHeight / sourceHeight,
|
||||
);
|
||||
const scaledWidth = sourceWidth * containScale * transform.scale;
|
||||
const scaledHeight = sourceHeight * containScale * transform.scale;
|
||||
const cx = canvasWidth / 2 + transform.position.x;
|
||||
const cy = canvasHeight / 2 + transform.position.y;
|
||||
|
||||
return {
|
||||
cx,
|
||||
cy,
|
||||
width: scaledWidth,
|
||||
height: scaledHeight,
|
||||
rotation: transform.rotate,
|
||||
};
|
||||
}
|
||||
|
||||
export function getElementBounds({
|
||||
element,
|
||||
canvasSize,
|
||||
mediaAsset,
|
||||
}: {
|
||||
element: TimelineElement;
|
||||
canvasSize: { width: number; height: number };
|
||||
mediaAsset?: MediaAsset | null;
|
||||
}): ElementBounds | null {
|
||||
if (element.type === "audio") return null;
|
||||
if ("hidden" in element && element.hidden) return null;
|
||||
|
||||
const { width: canvasWidth, height: canvasHeight } = canvasSize;
|
||||
|
||||
if (element.type === "video" || element.type === "image") {
|
||||
const sourceWidth = mediaAsset?.width ?? canvasWidth;
|
||||
const sourceHeight = mediaAsset?.height ?? canvasHeight;
|
||||
return getVisualElementBounds({
|
||||
canvasWidth,
|
||||
canvasHeight,
|
||||
sourceWidth,
|
||||
sourceHeight,
|
||||
transform: element.transform,
|
||||
});
|
||||
}
|
||||
|
||||
if (element.type === "sticker") {
|
||||
return getVisualElementBounds({
|
||||
canvasWidth,
|
||||
canvasHeight,
|
||||
sourceWidth: 200,
|
||||
sourceHeight: 200,
|
||||
transform: element.transform,
|
||||
});
|
||||
}
|
||||
|
||||
if (element.type === "text") {
|
||||
const scaledFontSize =
|
||||
element.fontSize * (canvasHeight / FONT_SIZE_SCALE_REFERENCE);
|
||||
const letterSpacing = element.letterSpacing ?? 0;
|
||||
const lineHeight = element.lineHeight ?? DEFAULT_LINE_HEIGHT;
|
||||
const lineHeightPx = scaledFontSize * lineHeight;
|
||||
|
||||
let measuredWidth = 100;
|
||||
let measuredHeight = scaledFontSize;
|
||||
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = 4096;
|
||||
canvas.height = 4096;
|
||||
const ctx = canvas.getContext("2d");
|
||||
|
||||
if (ctx) {
|
||||
const fontWeight = element.fontWeight === "bold" ? "bold" : "normal";
|
||||
const fontStyle = element.fontStyle === "italic" ? "italic" : "normal";
|
||||
const fontFamily = `"${element.fontFamily.replace(/"/g, '\\"')}"`;
|
||||
ctx.font = `${fontStyle} ${fontWeight} ${scaledFontSize}px ${fontFamily}, sans-serif`;
|
||||
ctx.textAlign = element.textAlign as CanvasTextAlign;
|
||||
if ("letterSpacing" in ctx) {
|
||||
(
|
||||
ctx as CanvasRenderingContext2D & { letterSpacing: string }
|
||||
).letterSpacing = `${letterSpacing}px`;
|
||||
}
|
||||
|
||||
const lines = element.content.split("\n");
|
||||
const lineMetrics = lines.map((line) => ctx.measureText(line));
|
||||
|
||||
let top = Number.POSITIVE_INFINITY;
|
||||
let bottom = Number.NEGATIVE_INFINITY;
|
||||
let maxWidth = 0;
|
||||
|
||||
for (let i = 0; i < lineMetrics.length; i++) {
|
||||
const metrics = lineMetrics[i];
|
||||
const y = i * lineHeightPx;
|
||||
top = Math.min(
|
||||
top,
|
||||
y - (metrics.actualBoundingBoxAscent ?? scaledFontSize * 0.8),
|
||||
);
|
||||
bottom = Math.max(
|
||||
bottom,
|
||||
y + (metrics.actualBoundingBoxDescent ?? scaledFontSize * 0.2),
|
||||
);
|
||||
maxWidth = Math.max(maxWidth, metrics.width);
|
||||
}
|
||||
|
||||
measuredWidth = maxWidth;
|
||||
measuredHeight = bottom - top;
|
||||
}
|
||||
|
||||
const width = measuredWidth * element.transform.scale;
|
||||
const height = measuredHeight * element.transform.scale;
|
||||
return {
|
||||
cx: canvasWidth / 2 + element.transform.position.x,
|
||||
cy: canvasHeight / 2 + element.transform.position.y,
|
||||
width,
|
||||
height,
|
||||
rotation: element.transform.rotate,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getVisibleElementsWithBounds({
|
||||
tracks,
|
||||
currentTime,
|
||||
canvasSize,
|
||||
mediaAssets,
|
||||
}: {
|
||||
tracks: TimelineTrack[];
|
||||
currentTime: number;
|
||||
canvasSize: { width: number; height: number };
|
||||
mediaAssets: MediaAsset[];
|
||||
}): ElementWithBounds[] {
|
||||
const mediaMap = new Map(mediaAssets.map((m) => [m.id, m]));
|
||||
const visibleTracks = tracks.filter(
|
||||
(track) => !("hidden" in track && track.hidden),
|
||||
);
|
||||
const orderedTracks = [
|
||||
...visibleTracks.filter((track) => !isMainTrack(track)),
|
||||
...visibleTracks.filter((track) => isMainTrack(track)),
|
||||
].reverse();
|
||||
|
||||
const result: ElementWithBounds[] = [];
|
||||
|
||||
for (const track of orderedTracks) {
|
||||
const elements = track.elements
|
||||
.filter((element) => !("hidden" in element && element.hidden))
|
||||
.filter(
|
||||
(element) =>
|
||||
currentTime >= element.startTime &&
|
||||
currentTime < element.startTime + element.duration,
|
||||
)
|
||||
.slice()
|
||||
.sort((a, b) => {
|
||||
if (a.startTime !== b.startTime) return a.startTime - b.startTime;
|
||||
return a.id.localeCompare(b.id);
|
||||
});
|
||||
|
||||
for (const element of elements) {
|
||||
const mediaAsset =
|
||||
element.type === "video" || element.type === "image"
|
||||
? mediaMap.get(element.mediaId)
|
||||
: undefined;
|
||||
const bounds = getElementBounds({
|
||||
element,
|
||||
canvasSize,
|
||||
mediaAsset,
|
||||
});
|
||||
if (bounds) {
|
||||
result.push({
|
||||
trackId: track.id,
|
||||
elementId: element.id,
|
||||
element,
|
||||
bounds,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { ElementWithBounds } from "./element-bounds";
|
||||
|
||||
function pointInRotatedRect({
|
||||
px,
|
||||
py,
|
||||
cx,
|
||||
cy,
|
||||
width,
|
||||
height,
|
||||
rotation,
|
||||
}: {
|
||||
px: number;
|
||||
py: number;
|
||||
cx: number;
|
||||
cy: number;
|
||||
width: number;
|
||||
height: number;
|
||||
rotation: number;
|
||||
}): boolean {
|
||||
const angleRad = (rotation * Math.PI) / 180;
|
||||
const cos = Math.cos(-angleRad);
|
||||
const sin = Math.sin(-angleRad);
|
||||
const dx = px - cx;
|
||||
const dy = py - cy;
|
||||
const localX = dx * cos - dy * sin;
|
||||
const localY = dx * sin + dy * cos;
|
||||
const halfW = width / 2;
|
||||
const halfH = height / 2;
|
||||
return (
|
||||
localX >= -halfW && localX <= halfW && localY >= -halfH && localY <= halfH
|
||||
);
|
||||
}
|
||||
|
||||
export function hitTest({
|
||||
canvasX,
|
||||
canvasY,
|
||||
elementsWithBounds,
|
||||
}: {
|
||||
canvasX: number;
|
||||
canvasY: number;
|
||||
elementsWithBounds: ElementWithBounds[];
|
||||
}): ElementWithBounds | null {
|
||||
for (let i = elementsWithBounds.length - 1; i >= 0; i--) {
|
||||
const { bounds } = elementsWithBounds[i];
|
||||
if (
|
||||
pointInRotatedRect({
|
||||
px: canvasX,
|
||||
py: canvasY,
|
||||
cx: bounds.cx,
|
||||
cy: bounds.cy,
|
||||
width: bounds.width,
|
||||
height: bounds.height,
|
||||
rotation: bounds.rotation,
|
||||
})
|
||||
) {
|
||||
return elementsWithBounds[i];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
export function screenToCanvas({
|
||||
clientX,
|
||||
clientY,
|
||||
canvas,
|
||||
}: {
|
||||
clientX: number;
|
||||
clientY: number;
|
||||
canvas: HTMLCanvasElement;
|
||||
}): { x: number; y: number } {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const scaleX = canvas.width / rect.width;
|
||||
const scaleY = canvas.height / rect.height;
|
||||
return {
|
||||
x: (clientX - rect.left) * scaleX,
|
||||
y: (clientY - rect.top) * scaleY,
|
||||
};
|
||||
}
|
||||
|
||||
export function canvasToOverlay({
|
||||
canvasX,
|
||||
canvasY,
|
||||
canvasRect,
|
||||
containerRect,
|
||||
canvasSize,
|
||||
}: {
|
||||
canvasX: number;
|
||||
canvasY: number;
|
||||
canvasRect: DOMRect;
|
||||
containerRect: DOMRect;
|
||||
canvasSize: { width: number; height: number };
|
||||
}): { x: number; y: number } {
|
||||
const scaleX = canvasRect.width / canvasSize.width;
|
||||
const scaleY = canvasRect.height / canvasSize.height;
|
||||
return {
|
||||
x: canvasRect.left - containerRect.left + canvasX * scaleX,
|
||||
y: canvasRect.top - containerRect.top + canvasY * scaleY,
|
||||
};
|
||||
}
|
||||
|
||||
export function positionToOverlay({
|
||||
positionX,
|
||||
positionY,
|
||||
canvasRect,
|
||||
containerRect,
|
||||
canvasSize,
|
||||
}: {
|
||||
positionX: number;
|
||||
positionY: number;
|
||||
canvasRect: DOMRect;
|
||||
containerRect: DOMRect;
|
||||
canvasSize: { width: number; height: number };
|
||||
}): { x: number; y: number } {
|
||||
const scaleX = canvasRect.width / canvasSize.width;
|
||||
const scaleY = canvasRect.height / canvasSize.height;
|
||||
const centerScreenX =
|
||||
canvasRect.left - containerRect.left + (canvasSize.width / 2) * scaleX;
|
||||
const centerScreenY =
|
||||
canvasRect.top - containerRect.top + (canvasSize.height / 2) * scaleY;
|
||||
return {
|
||||
x: centerScreenX + positionX * scaleX,
|
||||
y: centerScreenY + positionY * scaleY,
|
||||
};
|
||||
}
|
||||
|
||||
export function getDisplayScale({
|
||||
canvasRect,
|
||||
canvasSize,
|
||||
}: {
|
||||
canvasRect: DOMRect;
|
||||
canvasSize: { width: number; height: number };
|
||||
}): { x: number; y: number } {
|
||||
return {
|
||||
x: canvasRect.width / canvasSize.width,
|
||||
y: canvasRect.height / canvasSize.height,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
export interface SnapLine {
|
||||
type: "horizontal" | "vertical";
|
||||
position: number;
|
||||
}
|
||||
|
||||
const SNAP_THRESHOLD = 10;
|
||||
const ROTATION_SNAP_STEP_DEGREES = 90;
|
||||
const ROTATION_SNAP_THRESHOLD_DEGREES = 5;
|
||||
export const MIN_SCALE = 0.01;
|
||||
|
||||
export interface SnapResult {
|
||||
snappedPosition: { x: number; y: number };
|
||||
activeLines: SnapLine[];
|
||||
}
|
||||
|
||||
export function snapPosition({
|
||||
proposedPosition,
|
||||
canvasSize,
|
||||
elementSize,
|
||||
}: {
|
||||
proposedPosition: { x: number; y: number };
|
||||
canvasSize: { width: number; height: number };
|
||||
elementSize: { width: number; height: number };
|
||||
}): SnapResult {
|
||||
const centerX = 0;
|
||||
const centerY = 0;
|
||||
const left = -canvasSize.width / 2;
|
||||
const right = canvasSize.width / 2;
|
||||
const top = -canvasSize.height / 2;
|
||||
const bottom = canvasSize.height / 2;
|
||||
|
||||
const halfWidth = elementSize.width / 2;
|
||||
const halfHeight = elementSize.height / 2;
|
||||
const activeLines: SnapLine[] = [];
|
||||
|
||||
type AxisSnapCandidate = {
|
||||
snappedPosition: number;
|
||||
line: SnapLine;
|
||||
distance: number;
|
||||
};
|
||||
|
||||
function getClosestAxisSnap({
|
||||
candidates,
|
||||
}: {
|
||||
candidates: AxisSnapCandidate[];
|
||||
}): AxisSnapCandidate | null {
|
||||
const snapCandidatesWithinThreshold = candidates.filter(
|
||||
(candidate) => candidate.distance <= SNAP_THRESHOLD,
|
||||
);
|
||||
if (snapCandidatesWithinThreshold.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return snapCandidatesWithinThreshold.reduce((closest, current) =>
|
||||
current.distance < closest.distance ? current : closest,
|
||||
);
|
||||
}
|
||||
|
||||
const verticalTargets = [left, centerX, right];
|
||||
const horizontalTargets = [top, centerY, bottom];
|
||||
|
||||
const xCandidates: AxisSnapCandidate[] = [];
|
||||
for (const targetX of verticalTargets) {
|
||||
xCandidates.push({
|
||||
snappedPosition: targetX,
|
||||
line: { type: "vertical", position: targetX },
|
||||
distance: Math.abs(proposedPosition.x - targetX),
|
||||
});
|
||||
xCandidates.push({
|
||||
snappedPosition: targetX + halfWidth,
|
||||
line: { type: "vertical", position: targetX },
|
||||
distance: Math.abs(proposedPosition.x - halfWidth - targetX),
|
||||
});
|
||||
xCandidates.push({
|
||||
snappedPosition: targetX - halfWidth,
|
||||
line: { type: "vertical", position: targetX },
|
||||
distance: Math.abs(proposedPosition.x + halfWidth - targetX),
|
||||
});
|
||||
}
|
||||
const yCandidates: AxisSnapCandidate[] = [];
|
||||
for (const targetY of horizontalTargets) {
|
||||
yCandidates.push({
|
||||
snappedPosition: targetY,
|
||||
line: { type: "horizontal", position: targetY },
|
||||
distance: Math.abs(proposedPosition.y - targetY),
|
||||
});
|
||||
yCandidates.push({
|
||||
snappedPosition: targetY + halfHeight,
|
||||
line: { type: "horizontal", position: targetY },
|
||||
distance: Math.abs(proposedPosition.y - halfHeight - targetY),
|
||||
});
|
||||
yCandidates.push({
|
||||
snappedPosition: targetY - halfHeight,
|
||||
line: { type: "horizontal", position: targetY },
|
||||
distance: Math.abs(proposedPosition.y + halfHeight - targetY),
|
||||
});
|
||||
}
|
||||
|
||||
const closestX = getClosestAxisSnap({ candidates: xCandidates });
|
||||
const closestY = getClosestAxisSnap({ candidates: yCandidates });
|
||||
|
||||
const x = closestX?.snappedPosition ?? proposedPosition.x;
|
||||
const y = closestY?.snappedPosition ?? proposedPosition.y;
|
||||
if (closestX) {
|
||||
activeLines.push(closestX.line);
|
||||
}
|
||||
if (closestY) {
|
||||
activeLines.push(closestY.line);
|
||||
}
|
||||
|
||||
return {
|
||||
snappedPosition: { x, y },
|
||||
activeLines,
|
||||
};
|
||||
}
|
||||
|
||||
export interface ScaleSnapResult {
|
||||
snappedScale: number;
|
||||
activeLines: SnapLine[];
|
||||
}
|
||||
|
||||
export function snapScale({
|
||||
proposedScale,
|
||||
position,
|
||||
baseWidth,
|
||||
baseHeight,
|
||||
canvasSize,
|
||||
}: {
|
||||
proposedScale: number;
|
||||
position: { x: number; y: number };
|
||||
baseWidth: number;
|
||||
baseHeight: number;
|
||||
canvasSize: { width: number; height: number };
|
||||
}): ScaleSnapResult {
|
||||
const centerX = 0;
|
||||
const centerY = 0;
|
||||
const left = -canvasSize.width / 2;
|
||||
const right = canvasSize.width / 2;
|
||||
const top = -canvasSize.height / 2;
|
||||
const bottom = canvasSize.height / 2;
|
||||
|
||||
const leftEdge = position.x - (baseWidth * proposedScale) / 2;
|
||||
const rightEdge = position.x + (baseWidth * proposedScale) / 2;
|
||||
const topEdge = position.y - (baseHeight * proposedScale) / 2;
|
||||
const bottomEdge = position.y + (baseHeight * proposedScale) / 2;
|
||||
|
||||
interface SnapCandidate {
|
||||
scale: number;
|
||||
distance: number;
|
||||
lines: SnapLine[];
|
||||
}
|
||||
|
||||
const candidates: SnapCandidate[] = [];
|
||||
|
||||
const verticalTargets = [
|
||||
{ position: left, line: { type: "vertical" as const, position: left } },
|
||||
{
|
||||
position: centerX,
|
||||
line: { type: "vertical" as const, position: centerX },
|
||||
},
|
||||
{ position: right, line: { type: "vertical" as const, position: right } },
|
||||
];
|
||||
|
||||
for (const target of verticalTargets) {
|
||||
const distanceLeft = Math.abs(leftEdge - target.position);
|
||||
if (distanceLeft <= SNAP_THRESHOLD) {
|
||||
const scale = (2 * (position.x - target.position)) / baseWidth;
|
||||
if (scale > MIN_SCALE) {
|
||||
candidates.push({
|
||||
scale,
|
||||
distance: distanceLeft,
|
||||
lines: [target.line],
|
||||
});
|
||||
}
|
||||
}
|
||||
const distanceRight = Math.abs(rightEdge - target.position);
|
||||
if (distanceRight <= SNAP_THRESHOLD) {
|
||||
const scale = (2 * (target.position - position.x)) / baseWidth;
|
||||
if (scale > MIN_SCALE) {
|
||||
candidates.push({
|
||||
scale,
|
||||
distance: distanceRight,
|
||||
lines: [target.line],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const horizontalTargets = [
|
||||
{ position: top, line: { type: "horizontal" as const, position: top } },
|
||||
{
|
||||
position: centerY,
|
||||
line: { type: "horizontal" as const, position: centerY },
|
||||
},
|
||||
{
|
||||
position: bottom,
|
||||
line: { type: "horizontal" as const, position: bottom },
|
||||
},
|
||||
];
|
||||
|
||||
for (const target of horizontalTargets) {
|
||||
const distanceTop = Math.abs(topEdge - target.position);
|
||||
if (distanceTop <= SNAP_THRESHOLD) {
|
||||
const scale = (2 * (position.y - target.position)) / baseHeight;
|
||||
if (scale > MIN_SCALE) {
|
||||
candidates.push({
|
||||
scale,
|
||||
distance: distanceTop,
|
||||
lines: [target.line],
|
||||
});
|
||||
}
|
||||
}
|
||||
const distanceBottom = Math.abs(bottomEdge - target.position);
|
||||
if (distanceBottom <= SNAP_THRESHOLD) {
|
||||
const scale = (2 * (target.position - position.y)) / baseHeight;
|
||||
if (scale > MIN_SCALE) {
|
||||
candidates.push({
|
||||
scale,
|
||||
distance: distanceBottom,
|
||||
lines: [target.line],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (candidates.length === 0) {
|
||||
return { snappedScale: proposedScale, activeLines: [] };
|
||||
}
|
||||
|
||||
const best = candidates.reduce((acc, candidate) =>
|
||||
candidate.distance < acc.distance ? candidate : acc,
|
||||
);
|
||||
|
||||
const snappedLeft = position.x - (baseWidth * best.scale) / 2;
|
||||
const snappedRight = position.x + (baseWidth * best.scale) / 2;
|
||||
const snappedTop = position.y - (baseHeight * best.scale) / 2;
|
||||
const snappedBottom = position.y + (baseHeight * best.scale) / 2;
|
||||
|
||||
const activeLines: SnapLine[] = [];
|
||||
const seenKeys = new Set<string>();
|
||||
|
||||
function addLine({ line }: { line: SnapLine }) {
|
||||
const key = `${line.type}-${line.position}`;
|
||||
if (!seenKeys.has(key)) {
|
||||
seenKeys.add(key);
|
||||
activeLines.push(line);
|
||||
}
|
||||
}
|
||||
|
||||
for (const target of verticalTargets) {
|
||||
if (
|
||||
Math.abs(snappedLeft - target.position) <= 1 ||
|
||||
Math.abs(snappedRight - target.position) <= 1
|
||||
) {
|
||||
addLine({ line: target.line });
|
||||
}
|
||||
}
|
||||
for (const target of horizontalTargets) {
|
||||
if (
|
||||
Math.abs(snappedTop - target.position) <= 1 ||
|
||||
Math.abs(snappedBottom - target.position) <= 1
|
||||
) {
|
||||
addLine({ line: target.line });
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
snappedScale: best.scale,
|
||||
activeLines,
|
||||
};
|
||||
}
|
||||
|
||||
export interface RotationSnapResult {
|
||||
snappedRotation: number;
|
||||
isSnapped: boolean;
|
||||
}
|
||||
|
||||
export function snapRotation({
|
||||
proposedRotation,
|
||||
}: {
|
||||
proposedRotation: number;
|
||||
}): RotationSnapResult {
|
||||
const nearestRotationSnap =
|
||||
Math.round(proposedRotation / ROTATION_SNAP_STEP_DEGREES) *
|
||||
ROTATION_SNAP_STEP_DEGREES;
|
||||
const distanceToNearestSnap = Math.abs(
|
||||
proposedRotation - nearestRotationSnap,
|
||||
);
|
||||
if (distanceToNearestSnap <= ROTATION_SNAP_THRESHOLD_DEGREES) {
|
||||
return { snappedRotation: nearestRotationSnap, isSnapped: true };
|
||||
}
|
||||
return { snappedRotation: proposedRotation, isSnapped: false };
|
||||
}
|
||||
Reference in New Issue
Block a user