mirror of
https://github.com/OpenCut-app/OpenCut.git
synced 2026-07-13 21:52:53 +02:00
refactor: split constants by domain and isolate timeline panel from core
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
export const BACKGROUND_BLUR_INTENSITY_PRESETS: Array<{
|
||||
label: string;
|
||||
value: number;
|
||||
}> = [
|
||||
{ label: "Light", value: 10 },
|
||||
{ label: "Medium", value: 50 },
|
||||
{ label: "Heavy", value: 100 },
|
||||
] as const;
|
||||
|
||||
export const DEFAULT_BACKGROUND_BLUR_INTENSITY = 10;
|
||||
export const DEFAULT_BACKGROUND_COLOR = "#000000";
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { TCanvasSize } from "@/lib/project/types";
|
||||
|
||||
export const DEFAULT_CANVAS_PRESETS: TCanvasSize[] = [
|
||||
{ width: 1920, height: 1080 },
|
||||
{ width: 1080, height: 1920 },
|
||||
{ width: 1080, height: 1080 },
|
||||
{ width: 1440, height: 1080 },
|
||||
];
|
||||
|
||||
export const DEFAULT_CANVAS_SIZE: TCanvasSize = { width: 1920, height: 1080 };
|
||||
@@ -5,7 +5,7 @@ import type { MediaAsset } from "@/lib/media/types";
|
||||
import { generateUUID } from "@/utils/id";
|
||||
import { storageService } from "@/services/storage/service";
|
||||
import { hasMediaId } from "@/lib/timeline/element-utils";
|
||||
import { getHighestImportedVideoFps } from "@/lib/project/fps";
|
||||
import { getHighestImportedVideoFps } from "@/lib/fps/utils";
|
||||
import { UpdateProjectSettingsCommand } from "@/lib/commands/project";
|
||||
|
||||
export class AddMediaAssetCommand extends Command {
|
||||
|
||||
@@ -9,7 +9,7 @@ import type {
|
||||
import { generateUUID } from "@/utils/id";
|
||||
import { requiresMediaId } from "@/lib/timeline/element-utils";
|
||||
import type { MediaAsset } from "@/lib/media/types";
|
||||
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
||||
import { DEFAULT_NEW_ELEMENT_DURATION_SECONDS } from "@/lib/timeline/creation";
|
||||
import { graphicsRegistry, registerDefaultGraphics } from "@/lib/graphics";
|
||||
import {
|
||||
applyPlacement,
|
||||
@@ -136,7 +136,7 @@ export class InsertElementCommand extends Command {
|
||||
startTime: element.startTime,
|
||||
trimStart: element.trimStart ?? 0,
|
||||
trimEnd: element.trimEnd ?? 0,
|
||||
duration: element.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION,
|
||||
duration: element.duration ?? DEFAULT_NEW_ELEMENT_DURATION_SECONDS,
|
||||
} as TimelineElement;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { EditorCore } from "@/core";
|
||||
import { clampRetimeRate } from "@/constants/retime-constants";
|
||||
import { clampRetimeRate } from "@/lib/retime/rate";
|
||||
import { clampAnimationsToDuration } from "@/lib/animation";
|
||||
import { Command } from "@/lib/commands/base-command";
|
||||
import { getTimelineDurationForSourceSpan, getSourceSpanAtClipTime } from "@/lib/retime";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Command } from "@/lib/commands/base-command";
|
||||
import type { TimelineTrack } from "@/lib/timeline";
|
||||
import { EditorCore } from "@/core";
|
||||
import { canTracktHaveAudio } from "@/lib/timeline";
|
||||
import { canTrackHaveAudio } from "@/lib/timeline";
|
||||
|
||||
export class ToggleTrackMuteCommand extends Command {
|
||||
private savedState: TimelineTrack[] | null = null;
|
||||
@@ -22,7 +22,7 @@ export class ToggleTrackMuteCommand extends Command {
|
||||
}
|
||||
|
||||
const updatedTracks = this.savedState.map((track) =>
|
||||
track.id === this.trackId && canTracktHaveAudio(track)
|
||||
track.id === this.trackId && canTrackHaveAudio(track)
|
||||
? { ...track, muted: !track.muted }
|
||||
: track,
|
||||
);
|
||||
|
||||
+65
-65
@@ -1,65 +1,65 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
getHighestImportedVideoFps,
|
||||
getRaisedProjectFpsForImportedMedia,
|
||||
} from "@/lib/project/fps";
|
||||
|
||||
describe("getHighestImportedVideoFps", () => {
|
||||
test("returns the highest valid video fps", () => {
|
||||
expect(
|
||||
getHighestImportedVideoFps({
|
||||
mediaAssets: [
|
||||
{ type: "audio" },
|
||||
{ type: "video", fps: 30 },
|
||||
{ type: "image", fps: 120 },
|
||||
{ type: "video", fps: 60 },
|
||||
],
|
||||
}),
|
||||
).toBe(60);
|
||||
});
|
||||
|
||||
test("ignores missing and invalid fps values", () => {
|
||||
expect(
|
||||
getHighestImportedVideoFps({
|
||||
mediaAssets: [
|
||||
{ type: "video" },
|
||||
{ type: "video", fps: 0 },
|
||||
{ type: "video", fps: -10 },
|
||||
{ type: "audio", fps: 120 },
|
||||
],
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getRaisedProjectFpsForImportedMedia", () => {
|
||||
test("raises the project fps to match a higher-fps import", () => {
|
||||
expect(
|
||||
getRaisedProjectFpsForImportedMedia({
|
||||
currentFps: 30,
|
||||
importedAssets: [{ type: "video", fps: 60 }],
|
||||
}),
|
||||
).toBe(60);
|
||||
});
|
||||
|
||||
test("does not lower the project fps for lower-fps imports", () => {
|
||||
expect(
|
||||
getRaisedProjectFpsForImportedMedia({
|
||||
currentFps: 60,
|
||||
importedAssets: [{ type: "video", fps: 10 }],
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
test("ignores non-video imports", () => {
|
||||
expect(
|
||||
getRaisedProjectFpsForImportedMedia({
|
||||
currentFps: 30,
|
||||
importedAssets: [
|
||||
{ type: "image", fps: 60 },
|
||||
{ type: "audio", fps: 120 },
|
||||
],
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
getHighestImportedVideoFps,
|
||||
getRaisedProjectFpsForImportedMedia,
|
||||
} from "@/lib/fps/utils";
|
||||
|
||||
describe("getHighestImportedVideoFps", () => {
|
||||
test("returns the highest valid video fps", () => {
|
||||
expect(
|
||||
getHighestImportedVideoFps({
|
||||
mediaAssets: [
|
||||
{ type: "audio" },
|
||||
{ type: "video", fps: 30 },
|
||||
{ type: "image", fps: 120 },
|
||||
{ type: "video", fps: 60 },
|
||||
],
|
||||
}),
|
||||
).toBe(60);
|
||||
});
|
||||
|
||||
test("ignores missing and invalid fps values", () => {
|
||||
expect(
|
||||
getHighestImportedVideoFps({
|
||||
mediaAssets: [
|
||||
{ type: "video" },
|
||||
{ type: "video", fps: 0 },
|
||||
{ type: "video", fps: -10 },
|
||||
{ type: "audio", fps: 120 },
|
||||
],
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getRaisedProjectFpsForImportedMedia", () => {
|
||||
test("raises the project fps to match a higher-fps import", () => {
|
||||
expect(
|
||||
getRaisedProjectFpsForImportedMedia({
|
||||
currentFps: 30,
|
||||
importedAssets: [{ type: "video", fps: 60 }],
|
||||
}),
|
||||
).toBe(60);
|
||||
});
|
||||
|
||||
test("does not lower the project fps for lower-fps imports", () => {
|
||||
expect(
|
||||
getRaisedProjectFpsForImportedMedia({
|
||||
currentFps: 60,
|
||||
importedAssets: [{ type: "video", fps: 10 }],
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
test("ignores non-video imports", () => {
|
||||
expect(
|
||||
getRaisedProjectFpsForImportedMedia({
|
||||
currentFps: 30,
|
||||
importedAssets: [
|
||||
{ type: "image", fps: 60 },
|
||||
{ type: "audio", fps: 120 },
|
||||
],
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
export const FPS_PRESETS = [
|
||||
{ value: "24", label: "24 fps" },
|
||||
{ value: "25", label: "25 fps" },
|
||||
{ value: "30", label: "30 fps" },
|
||||
{ value: "60", label: "60 fps" },
|
||||
{ value: "120", label: "120 fps" },
|
||||
] as const;
|
||||
|
||||
export const DEFAULT_FPS = 30;
|
||||
@@ -1,39 +1,39 @@
|
||||
import type { MediaAsset } from "@/lib/media/types";
|
||||
|
||||
type MediaAssetFpsInput = Pick<MediaAsset, "type" | "fps">;
|
||||
|
||||
export function getHighestImportedVideoFps({
|
||||
mediaAssets,
|
||||
}: {
|
||||
mediaAssets: MediaAssetFpsInput[];
|
||||
}): number | null {
|
||||
let highestFps: number | null = null;
|
||||
|
||||
for (const asset of mediaAssets) {
|
||||
const fps = asset.fps ?? Number.NaN;
|
||||
if (asset.type !== "video") continue;
|
||||
if (!Number.isFinite(fps) || fps <= 0) continue;
|
||||
|
||||
highestFps = highestFps === null ? fps : Math.max(highestFps, fps);
|
||||
}
|
||||
|
||||
return highestFps;
|
||||
}
|
||||
|
||||
export function getRaisedProjectFpsForImportedMedia({
|
||||
currentFps,
|
||||
importedAssets,
|
||||
}: {
|
||||
currentFps: number;
|
||||
importedAssets: MediaAssetFpsInput[];
|
||||
}): number | null {
|
||||
const highestImportedVideoFps = getHighestImportedVideoFps({
|
||||
mediaAssets: importedAssets,
|
||||
});
|
||||
|
||||
if (highestImportedVideoFps === null || highestImportedVideoFps <= currentFps) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return highestImportedVideoFps;
|
||||
}
|
||||
import type { MediaAsset } from "@/lib/media/types";
|
||||
|
||||
type MediaAssetFpsInput = Pick<MediaAsset, "type" | "fps">;
|
||||
|
||||
export function getHighestImportedVideoFps({
|
||||
mediaAssets,
|
||||
}: {
|
||||
mediaAssets: MediaAssetFpsInput[];
|
||||
}): number | null {
|
||||
let highestFps: number | null = null;
|
||||
|
||||
for (const asset of mediaAssets) {
|
||||
const fps = asset.fps ?? Number.NaN;
|
||||
if (asset.type !== "video") continue;
|
||||
if (!Number.isFinite(fps) || fps <= 0) continue;
|
||||
|
||||
highestFps = highestFps === null ? fps : Math.max(highestFps, fps);
|
||||
}
|
||||
|
||||
return highestFps;
|
||||
}
|
||||
|
||||
export function getRaisedProjectFpsForImportedMedia({
|
||||
currentFps,
|
||||
importedAssets,
|
||||
}: {
|
||||
currentFps: number;
|
||||
importedAssets: MediaAssetFpsInput[];
|
||||
}): number | null {
|
||||
const highestImportedVideoFps = getHighestImportedVideoFps({
|
||||
mediaAssets: importedAssets,
|
||||
});
|
||||
|
||||
if (highestImportedVideoFps === null || highestImportedVideoFps <= currentFps) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return highestImportedVideoFps;
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import type {
|
||||
RetimeConfig,
|
||||
TimelineTrack,
|
||||
} from "@/lib/timeline";
|
||||
import { shouldMaintainPitch } from "@/constants/retime-constants";
|
||||
import { shouldMaintainPitch } from "@/lib/retime/rate";
|
||||
import type { MediaAsset } from "@/lib/media/types";
|
||||
import { applyAudioMasteringToBuffer } from "@/lib/media/audio-mastering";
|
||||
import type { AudioCapableElement } from "@/lib/timeline/audio-state";
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
doesElementHaveEnabledAudio,
|
||||
} from "@/lib/timeline/audio-separation";
|
||||
import { canElementHaveAudio, hasMediaId } from "@/lib/timeline/element-utils";
|
||||
import { canTracktHaveAudio } from "@/lib/timeline";
|
||||
import { canTrackHaveAudio } from "@/lib/timeline";
|
||||
import { mediaSupportsAudio } from "@/lib/media/media-utils";
|
||||
import { getSourceTimeAtClipTime, renderRetimedBuffer } from "@/lib/retime";
|
||||
import { Input, ALL_FORMATS, BlobSource, AudioBufferSink } from "mediabunny";
|
||||
@@ -97,7 +97,7 @@ export async function collectAudioElements({
|
||||
const pendingElements: Array<Promise<CollectedAudioElement | null>> = [];
|
||||
|
||||
for (const track of tracks) {
|
||||
if (canTracktHaveAudio(track) && track.muted) continue;
|
||||
if (canTrackHaveAudio(track) && track.muted) continue;
|
||||
|
||||
for (const element of track.elements) {
|
||||
if (!canElementHaveAudio(element)) continue;
|
||||
@@ -108,7 +108,7 @@ export async function collectAudioElements({
|
||||
: null;
|
||||
if (!doesElementHaveEnabledAudio({ element, mediaAsset })) continue;
|
||||
|
||||
const isTrackMuted = canTracktHaveAudio(track) && track.muted;
|
||||
const isTrackMuted = canTrackHaveAudio(track) && track.muted;
|
||||
|
||||
if (element.type === "audio") {
|
||||
pendingElements.push(
|
||||
@@ -453,7 +453,7 @@ export async function collectAudioMixSources({
|
||||
const pendingLibrarySources: Array<Promise<AudioMixSource | null>> = [];
|
||||
|
||||
for (const track of tracks) {
|
||||
if (canTracktHaveAudio(track) && track.muted) continue;
|
||||
if (canTrackHaveAudio(track) && track.muted) continue;
|
||||
|
||||
for (const element of track.elements) {
|
||||
if (!canElementHaveAudio(element)) continue;
|
||||
@@ -515,7 +515,7 @@ export async function collectAudioClips({
|
||||
const pendingLibraryClips: Array<Promise<AudioClipSource | null>> = [];
|
||||
|
||||
for (const track of tracks) {
|
||||
const isTrackMuted = canTracktHaveAudio(track) && track.muted;
|
||||
const isTrackMuted = canTrackHaveAudio(track) && track.muted;
|
||||
|
||||
for (const element of track.elements) {
|
||||
if (!canElementHaveAudio(element)) continue;
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
export const PANEL_CONFIG = {
|
||||
panels: {
|
||||
tools: 25,
|
||||
preview: 50,
|
||||
properties: 25,
|
||||
mainContent: 50,
|
||||
timeline: 50,
|
||||
},
|
||||
} as const;
|
||||
@@ -0,0 +1,7 @@
|
||||
export const PREVIEW_ZOOM_PRESETS = [25, 50, 75, 100, 150, 200];
|
||||
|
||||
export const PREVIEW_ZOOM = {
|
||||
min: 0.25,
|
||||
max: 16,
|
||||
step: 1.25,
|
||||
} as const;
|
||||
@@ -1,5 +1,5 @@
|
||||
import { PitchShifter } from "soundtouchjs";
|
||||
import { clampRetimeRate, shouldMaintainPitch } from "@/constants/retime-constants";
|
||||
import { clampRetimeRate, shouldMaintainPitch } from "@/lib/retime/rate";
|
||||
import type { RetimeConfig } from "@/lib/timeline";
|
||||
import { getSourceTimeAtClipTime } from "./resolve";
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from "./rate";
|
||||
export * from "./audio-stretch";
|
||||
export * from "./presets";
|
||||
export * from "./resolve";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { RetimeConfig } from "@/lib/timeline";
|
||||
import { clampRetimeRate } from "@/constants/retime-constants";
|
||||
import { clampRetimeRate } from "@/lib/retime/rate";
|
||||
|
||||
export function buildConstantRetime({
|
||||
rate,
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
export const DEFAULT_RETIME_RATE = 1;
|
||||
export const MIN_RETIME_RATE = 0.01;
|
||||
export const MAX_RETIME_RATE = 5;
|
||||
|
||||
export function clampRetimeRate({ rate }: { rate: number }): number {
|
||||
if (!Number.isFinite(rate) || rate <= 0) {
|
||||
return DEFAULT_RETIME_RATE;
|
||||
}
|
||||
|
||||
return Math.min(Math.max(rate, MIN_RETIME_RATE), MAX_RETIME_RATE);
|
||||
}
|
||||
|
||||
export function canMaintainPitch({ rate }: { rate: number }): boolean {
|
||||
return Number.isFinite(rate) && rate > 0;
|
||||
}
|
||||
|
||||
export function shouldMaintainPitch({
|
||||
rate,
|
||||
maintainPitch,
|
||||
}: {
|
||||
rate: number;
|
||||
maintainPitch?: boolean;
|
||||
}): boolean {
|
||||
return maintainPitch === true && canMaintainPitch({ rate });
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { RetimeConfig } from "@/lib/timeline";
|
||||
import { clampRetimeRate } from "@/constants/retime-constants";
|
||||
import { clampRetimeRate } from "@/lib/retime/rate";
|
||||
|
||||
function getSafeRate({ rate }: { rate: number }): number {
|
||||
return clampRetimeRate({ rate });
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
export const MIN_FONT_SIZE = 5;
|
||||
export const MAX_FONT_SIZE = 300;
|
||||
export const DEFAULT_TEXT_COLOR = "#000000";
|
||||
|
||||
/**
|
||||
* higher value: smaller font size
|
||||
* lower value: larger font size
|
||||
*/
|
||||
export const FONT_SIZE_SCALE_REFERENCE = 90;
|
||||
|
||||
export const CORNER_RADIUS_MIN = 0;
|
||||
export const CORNER_RADIUS_MAX = 100;
|
||||
@@ -0,0 +1 @@
|
||||
export const DEFAULT_NEW_ELEMENT_DURATION_SECONDS = 5;
|
||||
@@ -1,4 +1,4 @@
|
||||
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
||||
import { DEFAULT_NEW_ELEMENT_DURATION_SECONDS } from "@/lib/timeline/creation";
|
||||
import type { TTimelineViewState } from "@/lib/project/types";
|
||||
import type { BlendMode, Transform } from "@/lib/rendering";
|
||||
import type { TextElement } from "./types";
|
||||
@@ -41,7 +41,7 @@ const defaultTextElement: Omit<TextElement, "id"> = {
|
||||
textDecoration: "none",
|
||||
letterSpacing: defaultTextLetterSpacing,
|
||||
lineHeight: defaultTextLineHeight,
|
||||
duration: TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION,
|
||||
duration: DEFAULT_NEW_ELEMENT_DURATION_SECONDS,
|
||||
startTime: 0,
|
||||
trimStart: 0,
|
||||
trimEnd: 0,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
||||
import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale";
|
||||
|
||||
export function getMouseTimeFromClientX({
|
||||
clientX,
|
||||
@@ -14,6 +14,6 @@ export function getMouseTimeFromClientX({
|
||||
const mouseX = clientX - containerRect.left + scrollLeft;
|
||||
return Math.max(
|
||||
0,
|
||||
mouseX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel),
|
||||
mouseX / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,257 +0,0 @@
|
||||
import type {
|
||||
TimelineTrack,
|
||||
TimelineElement,
|
||||
} from "@/lib/timeline";
|
||||
import { TRACK_CONFIG, TRACK_GAP } from "@/constants/timeline-constants";
|
||||
import type { ComputeDropTargetParams, DropTarget } from "@/lib/timeline";
|
||||
import { resolveTrackPlacement } from "@/lib/timeline/placement";
|
||||
|
||||
function findElementAtPosition({
|
||||
mouseX,
|
||||
tracks,
|
||||
trackIndex,
|
||||
targetElementTypes,
|
||||
pixelsPerSecond,
|
||||
zoomLevel,
|
||||
}: {
|
||||
mouseX: number;
|
||||
tracks: TimelineTrack[];
|
||||
trackIndex: number;
|
||||
targetElementTypes: string[];
|
||||
pixelsPerSecond: number;
|
||||
zoomLevel: number;
|
||||
}): { elementId: string; trackId: string } | null {
|
||||
const time = mouseX / (pixelsPerSecond * zoomLevel);
|
||||
const track = tracks[trackIndex];
|
||||
if (!track || !("elements" in track)) return null;
|
||||
|
||||
const hit = track.elements.find(
|
||||
(element: TimelineElement) =>
|
||||
targetElementTypes.includes(element.type) &&
|
||||
element.startTime <= time &&
|
||||
time < element.startTime + element.duration,
|
||||
);
|
||||
if (!hit) return null;
|
||||
return { elementId: hit.id, trackId: track.id };
|
||||
}
|
||||
|
||||
function getTrackAtY({
|
||||
mouseY,
|
||||
tracks,
|
||||
verticalDragDirection,
|
||||
}: {
|
||||
mouseY: number;
|
||||
tracks: TimelineTrack[];
|
||||
verticalDragDirection?: "up" | "down" | null;
|
||||
}): { trackIndex: number; relativeY: number } | null {
|
||||
let cumulativeHeight = 0;
|
||||
|
||||
for (let i = 0; i < tracks.length; i++) {
|
||||
const trackHeight = TRACK_CONFIG[tracks[i].type].height;
|
||||
const trackTop = cumulativeHeight;
|
||||
const trackBottom = trackTop + trackHeight;
|
||||
|
||||
if (mouseY >= trackTop && mouseY < trackBottom) {
|
||||
return {
|
||||
trackIndex: i,
|
||||
relativeY: mouseY - trackTop,
|
||||
};
|
||||
}
|
||||
|
||||
if (i < tracks.length - 1 && verticalDragDirection) {
|
||||
const gapTop = trackBottom;
|
||||
const gapBottom = gapTop + TRACK_GAP;
|
||||
if (mouseY >= gapTop && mouseY < gapBottom) {
|
||||
const isDraggingUp = verticalDragDirection === "up";
|
||||
return {
|
||||
trackIndex: isDraggingUp ? i : i + 1,
|
||||
relativeY: isDraggingUp ? trackHeight - 1 : 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
cumulativeHeight += trackHeight + TRACK_GAP;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
const EMPTY_TARGET_ELEMENT = null;
|
||||
|
||||
function fallbackNewTrackDropTarget({
|
||||
xPosition,
|
||||
}: {
|
||||
xPosition: number;
|
||||
}): DropTarget {
|
||||
return {
|
||||
trackIndex: 0,
|
||||
isNewTrack: true,
|
||||
insertPosition: null,
|
||||
xPosition,
|
||||
targetElement: EMPTY_TARGET_ELEMENT,
|
||||
};
|
||||
}
|
||||
|
||||
export function computeDropTarget({
|
||||
elementType,
|
||||
mouseX,
|
||||
mouseY,
|
||||
tracks,
|
||||
playheadTime,
|
||||
isExternalDrop,
|
||||
elementDuration,
|
||||
pixelsPerSecond,
|
||||
zoomLevel,
|
||||
verticalDragDirection,
|
||||
startTimeOverride,
|
||||
excludeElementId,
|
||||
targetElementTypes,
|
||||
}: ComputeDropTargetParams): DropTarget {
|
||||
const xPosition =
|
||||
typeof startTimeOverride === "number"
|
||||
? startTimeOverride
|
||||
: isExternalDrop
|
||||
? playheadTime
|
||||
: Math.max(0, mouseX / (pixelsPerSecond * zoomLevel));
|
||||
|
||||
if (tracks.length === 0) {
|
||||
const placementResult = resolveTrackPlacement({
|
||||
tracks,
|
||||
elementType,
|
||||
timeSpans: [{ startTime: xPosition, duration: elementDuration, excludeElementId }],
|
||||
strategy: {
|
||||
type: "preferIndex",
|
||||
trackIndex: 0,
|
||||
hoverDirection: "below",
|
||||
createNewTrackOnly: true,
|
||||
},
|
||||
});
|
||||
const emptyTimelineResult =
|
||||
placementResult?.kind === "newTrack" ? placementResult : null;
|
||||
if (!emptyTimelineResult) {
|
||||
return fallbackNewTrackDropTarget({ xPosition });
|
||||
}
|
||||
|
||||
return {
|
||||
trackIndex: emptyTimelineResult.insertIndex,
|
||||
isNewTrack: true,
|
||||
insertPosition: emptyTimelineResult.insertPosition,
|
||||
xPosition,
|
||||
targetElement: EMPTY_TARGET_ELEMENT,
|
||||
};
|
||||
}
|
||||
|
||||
const trackAtMouse = getTrackAtY({ mouseY, tracks, verticalDragDirection });
|
||||
|
||||
if (!trackAtMouse) {
|
||||
const isAboveAllTracks = mouseY < 0;
|
||||
|
||||
const placementResult = resolveTrackPlacement({
|
||||
tracks,
|
||||
elementType,
|
||||
timeSpans: [{ startTime: xPosition, duration: elementDuration, excludeElementId }],
|
||||
strategy: {
|
||||
type: "preferIndex",
|
||||
trackIndex: isAboveAllTracks ? 0 : tracks.length - 1,
|
||||
hoverDirection: isAboveAllTracks ? "above" : "below",
|
||||
createNewTrackOnly: true,
|
||||
},
|
||||
});
|
||||
const outOfBoundsResult =
|
||||
placementResult?.kind === "newTrack" ? placementResult : null;
|
||||
if (!outOfBoundsResult) {
|
||||
return fallbackNewTrackDropTarget({ xPosition });
|
||||
}
|
||||
|
||||
return {
|
||||
trackIndex: outOfBoundsResult.insertIndex,
|
||||
isNewTrack: true,
|
||||
insertPosition: outOfBoundsResult.insertPosition,
|
||||
xPosition,
|
||||
targetElement: EMPTY_TARGET_ELEMENT,
|
||||
};
|
||||
}
|
||||
|
||||
const { trackIndex, relativeY } = trackAtMouse;
|
||||
const track = tracks[trackIndex];
|
||||
|
||||
if (
|
||||
targetElementTypes &&
|
||||
targetElementTypes.length > 0
|
||||
) {
|
||||
const targetElement = findElementAtPosition({
|
||||
mouseX,
|
||||
tracks,
|
||||
trackIndex,
|
||||
targetElementTypes,
|
||||
pixelsPerSecond,
|
||||
zoomLevel,
|
||||
});
|
||||
if (targetElement) {
|
||||
return {
|
||||
trackIndex,
|
||||
isNewTrack: false,
|
||||
insertPosition: null,
|
||||
xPosition,
|
||||
targetElement,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const trackHeight = TRACK_CONFIG[track.type].height;
|
||||
const placementResult = resolveTrackPlacement({
|
||||
tracks,
|
||||
elementType,
|
||||
timeSpans: [{ startTime: xPosition, duration: elementDuration, excludeElementId }],
|
||||
strategy: {
|
||||
type: "preferIndex",
|
||||
trackIndex,
|
||||
hoverDirection: relativeY < trackHeight / 2 ? "above" : "below",
|
||||
verticalDragDirection,
|
||||
},
|
||||
});
|
||||
if (!placementResult) {
|
||||
return fallbackNewTrackDropTarget({ xPosition });
|
||||
}
|
||||
|
||||
if (placementResult.kind === "existingTrack") {
|
||||
const adjustedXPosition =
|
||||
placementResult.adjustedStartTime ?? xPosition;
|
||||
|
||||
return {
|
||||
trackIndex: placementResult.trackIndex,
|
||||
isNewTrack: false,
|
||||
insertPosition: null,
|
||||
xPosition: adjustedXPosition,
|
||||
targetElement: EMPTY_TARGET_ELEMENT,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
trackIndex: placementResult.insertIndex,
|
||||
isNewTrack: true,
|
||||
insertPosition: placementResult.insertPosition,
|
||||
xPosition,
|
||||
targetElement: EMPTY_TARGET_ELEMENT,
|
||||
};
|
||||
}
|
||||
|
||||
export function getDropLineY({
|
||||
dropTarget,
|
||||
tracks,
|
||||
}: {
|
||||
dropTarget: DropTarget;
|
||||
tracks: TimelineTrack[];
|
||||
}): number {
|
||||
const safeTrackIndex = Math.min(
|
||||
Math.max(dropTarget.trackIndex, 0),
|
||||
tracks.length,
|
||||
);
|
||||
let y = 0;
|
||||
|
||||
for (let i = 0; i < safeTrackIndex; i++) {
|
||||
y += TRACK_CONFIG[tracks[i].type].height + TRACK_GAP;
|
||||
}
|
||||
|
||||
return y;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
||||
import { DEFAULT_NEW_ELEMENT_DURATION_SECONDS } from "@/lib/timeline/creation";
|
||||
import {
|
||||
MASKABLE_ELEMENT_TYPES,
|
||||
RETIMABLE_ELEMENT_TYPES,
|
||||
@@ -115,7 +115,7 @@ export function buildTextElement({
|
||||
type: "text",
|
||||
name: t.name ?? DEFAULTS.text.element.name,
|
||||
content: t.content ?? DEFAULTS.text.element.content,
|
||||
duration: t.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION,
|
||||
duration: t.duration ?? DEFAULT_NEW_ELEMENT_DURATION_SECONDS,
|
||||
startTime,
|
||||
trimStart: 0,
|
||||
trimEnd: 0,
|
||||
@@ -150,7 +150,7 @@ export function buildEffectElement({
|
||||
name: capitalizeFirstLetter({ string: instance.type }),
|
||||
effectType,
|
||||
params: instance.params,
|
||||
duration: duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION,
|
||||
duration: duration ?? DEFAULT_NEW_ELEMENT_DURATION_SECONDS,
|
||||
startTime,
|
||||
trimStart: 0,
|
||||
trimEnd: 0,
|
||||
@@ -178,7 +178,7 @@ export function buildStickerElement({
|
||||
stickerId,
|
||||
intrinsicWidth,
|
||||
intrinsicHeight,
|
||||
duration: TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION,
|
||||
duration: DEFAULT_NEW_ELEMENT_DURATION_SECONDS,
|
||||
startTime,
|
||||
trimStart: 0,
|
||||
trimEnd: 0,
|
||||
@@ -208,7 +208,7 @@ export function buildGraphicElement({
|
||||
name: name ?? capitalizeFirstLetter({ string: instance.definitionId }),
|
||||
definitionId: instance.definitionId,
|
||||
params: { ...instance.params, ...(params ?? {}) } as ParamValues,
|
||||
duration: TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION,
|
||||
duration: DEFAULT_NEW_ELEMENT_DURATION_SECONDS,
|
||||
startTime,
|
||||
trimStart: 0,
|
||||
trimEnd: 0,
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { TimelineTrack } from "./types";
|
||||
|
||||
export * from "./types";
|
||||
export * from "./drag";
|
||||
export * from "./track-utils";
|
||||
export * from "./track-capabilities";
|
||||
export * from "./track-element-update";
|
||||
export * from "./element-utils";
|
||||
export * from "./audio-separation";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
||||
import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale";
|
||||
|
||||
export const TIMELINE_INDICATOR_LINE_WIDTH_PX = 2;
|
||||
|
||||
@@ -31,7 +31,7 @@ export function getTimelinePixelsPerSecond({
|
||||
}: {
|
||||
zoomLevel: number;
|
||||
}): number {
|
||||
return TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
|
||||
return BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel;
|
||||
}
|
||||
|
||||
export function timelineTimeToPixels({
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { TRACK_CONFIG } from "@/constants/timeline-constants";
|
||||
import { DEFAULT_TRACK_NAMES } from "@/lib/timeline/tracks";
|
||||
import type { TrackType, TimelineTrack } from "@/lib/timeline";
|
||||
|
||||
export function buildEmptyTrack({
|
||||
@@ -10,7 +10,7 @@ export function buildEmptyTrack({
|
||||
type: TrackType;
|
||||
name?: string;
|
||||
}): TimelineTrack {
|
||||
const trackName = name ?? TRACK_CONFIG[type].defaultName;
|
||||
const trackName = name ?? DEFAULT_TRACK_NAMES[type];
|
||||
|
||||
switch (type) {
|
||||
case "video":
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
||||
import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale";
|
||||
|
||||
/**
|
||||
* frame intervals for labels - starts at 2 so there's always at least
|
||||
@@ -56,7 +56,7 @@ export function getRulerConfig({
|
||||
zoomLevel: number;
|
||||
fps: number;
|
||||
}): RulerConfig {
|
||||
const pixelsPerSecond = TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
|
||||
const pixelsPerSecond = BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel;
|
||||
const pixelsPerFrame = pixelsPerSecond / fps;
|
||||
|
||||
const labelIntervalSeconds = findOptimalInterval({
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export const BASE_TIMELINE_PIXELS_PER_SECOND = 50;
|
||||
export const TIMELINE_ZOOM_MIN = 0.1;
|
||||
export const TIMELINE_ZOOM_MAX = 100;
|
||||
@@ -1,133 +0,0 @@
|
||||
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
||||
import { getCumulativeHeightBefore, getTrackHeight } from "@/lib/timeline";
|
||||
import type { TimelineTrack } from "@/lib/timeline";
|
||||
|
||||
type TimelineElementRef = { trackId: string; elementId: string };
|
||||
|
||||
interface SelectionRectangle {
|
||||
left: number;
|
||||
top: number;
|
||||
right: number;
|
||||
bottom: number;
|
||||
}
|
||||
|
||||
function getNormalizedRectangle({
|
||||
startPos,
|
||||
endPos,
|
||||
}: {
|
||||
startPos: { x: number; y: number };
|
||||
endPos: { x: number; y: number };
|
||||
}): SelectionRectangle {
|
||||
return {
|
||||
left: Math.min(startPos.x, endPos.x),
|
||||
top: Math.min(startPos.y, endPos.y),
|
||||
right: Math.max(startPos.x, endPos.x),
|
||||
bottom: Math.max(startPos.y, endPos.y),
|
||||
};
|
||||
}
|
||||
|
||||
function getSelectionRectangleInContent({
|
||||
container,
|
||||
scrollContainer,
|
||||
startPos,
|
||||
endPos,
|
||||
}: {
|
||||
container: HTMLElement;
|
||||
scrollContainer: HTMLDivElement | null;
|
||||
startPos: { x: number; y: number };
|
||||
endPos: { x: number; y: number };
|
||||
}): SelectionRectangle {
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const scrollRect = scrollContainer?.getBoundingClientRect() ?? containerRect;
|
||||
const scrollLeft = scrollContainer?.scrollLeft ?? 0;
|
||||
const scrollTop = scrollContainer?.scrollTop ?? 0;
|
||||
|
||||
const adjustedStart = {
|
||||
x: startPos.x - containerRect.left + scrollLeft,
|
||||
y: startPos.y - scrollRect.top + scrollTop,
|
||||
};
|
||||
const adjustedEnd = {
|
||||
x: endPos.x - containerRect.left + scrollLeft,
|
||||
y: endPos.y - scrollRect.top + scrollTop,
|
||||
};
|
||||
|
||||
return getNormalizedRectangle({
|
||||
startPos: adjustedStart,
|
||||
endPos: adjustedEnd,
|
||||
});
|
||||
}
|
||||
|
||||
function isRectangleIntersecting({
|
||||
elementRectangle,
|
||||
selectionRectangle,
|
||||
}: {
|
||||
elementRectangle: SelectionRectangle;
|
||||
selectionRectangle: SelectionRectangle;
|
||||
}): boolean {
|
||||
return !(
|
||||
elementRectangle.right < selectionRectangle.left ||
|
||||
elementRectangle.left > selectionRectangle.right ||
|
||||
elementRectangle.bottom < selectionRectangle.top ||
|
||||
elementRectangle.top > selectionRectangle.bottom
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveTimelineElementIntersections({
|
||||
container,
|
||||
scrollContainer,
|
||||
tracks,
|
||||
zoomLevel,
|
||||
startPos,
|
||||
currentPos,
|
||||
}: {
|
||||
container: HTMLElement;
|
||||
scrollContainer: HTMLDivElement | null;
|
||||
tracks: TimelineTrack[];
|
||||
zoomLevel: number;
|
||||
startPos: { x: number; y: number };
|
||||
currentPos: { x: number; y: number };
|
||||
}): TimelineElementRef[] {
|
||||
const selectionRectangle = getSelectionRectangleInContent({
|
||||
container,
|
||||
scrollContainer,
|
||||
startPos,
|
||||
endPos: currentPos,
|
||||
});
|
||||
const pixelsPerSecond = TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
|
||||
const selectedElements: TimelineElementRef[] = [];
|
||||
|
||||
for (const [trackIndex, track] of tracks.entries()) {
|
||||
const trackTop = getCumulativeHeightBefore({
|
||||
tracks,
|
||||
trackIndex,
|
||||
});
|
||||
const trackHeight = getTrackHeight({ type: track.type });
|
||||
const elementTop = TIMELINE_CONSTANTS.PADDING_TOP_PX + trackTop;
|
||||
const elementBottom = elementTop + trackHeight;
|
||||
|
||||
for (const element of track.elements) {
|
||||
const elementLeft = element.startTime * pixelsPerSecond;
|
||||
const elementRight = elementLeft + element.duration * pixelsPerSecond;
|
||||
const elementRectangle = {
|
||||
left: elementLeft,
|
||||
top: elementTop,
|
||||
right: elementRight,
|
||||
bottom: elementBottom,
|
||||
};
|
||||
|
||||
if (
|
||||
isRectangleIntersecting({
|
||||
elementRectangle,
|
||||
selectionRectangle,
|
||||
})
|
||||
) {
|
||||
selectedElements.push({
|
||||
trackId: track.id,
|
||||
elementId: element.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return selectedElements;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Bookmark, TimelineTrack } from "@/lib/timeline";
|
||||
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
||||
import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale";
|
||||
import { BOOKMARK_TIME_EPSILON } from "@/lib/timeline/bookmarks";
|
||||
import { getElementKeyframes } from "@/lib/animation";
|
||||
|
||||
@@ -107,7 +107,7 @@ export function snapToNearestPoint({
|
||||
zoomLevel: number;
|
||||
snapThreshold?: number;
|
||||
}): SnapResult {
|
||||
const pixelsPerSecond = TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
|
||||
const pixelsPerSecond = BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel;
|
||||
const thresholdInSeconds = snapThreshold / pixelsPerSecond;
|
||||
|
||||
let closestSnapPoint: SnapPoint | null = null;
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import type {
|
||||
TimelineTrack,
|
||||
VideoTrack,
|
||||
AudioTrack,
|
||||
GraphicTrack,
|
||||
TextTrack,
|
||||
EffectTrack,
|
||||
} from "@/lib/timeline";
|
||||
|
||||
export function canTrackHaveAudio(
|
||||
track: TimelineTrack,
|
||||
): track is VideoTrack | AudioTrack {
|
||||
return track.type === "audio" || track.type === "video";
|
||||
}
|
||||
|
||||
export function canTrackBeHidden(
|
||||
track: TimelineTrack,
|
||||
): track is VideoTrack | TextTrack | GraphicTrack | EffectTrack {
|
||||
return track.type !== "audio";
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
import type {
|
||||
TrackType,
|
||||
TimelineTrack,
|
||||
VideoTrack,
|
||||
AudioTrack,
|
||||
GraphicTrack,
|
||||
TextTrack,
|
||||
EffectTrack,
|
||||
} from "@/lib/timeline";
|
||||
import {
|
||||
TRACK_CONFIG,
|
||||
ELEMENT_TYPE_CONFIG,
|
||||
TRACK_GAP,
|
||||
} from "@/constants/timeline-constants";
|
||||
|
||||
export function canTracktHaveAudio(
|
||||
track: TimelineTrack,
|
||||
): track is VideoTrack | AudioTrack {
|
||||
return track.type === "audio" || track.type === "video";
|
||||
}
|
||||
|
||||
export function canTrackBeHidden(
|
||||
track: TimelineTrack,
|
||||
): track is VideoTrack | TextTrack | GraphicTrack | EffectTrack {
|
||||
return track.type !== "audio";
|
||||
}
|
||||
|
||||
export function getElementClasses({ type }: { type: TrackType }) {
|
||||
return ELEMENT_TYPE_CONFIG[type].background.trim();
|
||||
}
|
||||
|
||||
export function getTrackHeight({ type }: { type: TrackType }): number {
|
||||
return TRACK_CONFIG[type].height;
|
||||
}
|
||||
|
||||
export function getCumulativeHeightBefore({
|
||||
tracks,
|
||||
trackIndex,
|
||||
}: {
|
||||
tracks: Array<{ type: TrackType }>;
|
||||
trackIndex: number;
|
||||
}): number {
|
||||
return tracks
|
||||
.slice(0, trackIndex)
|
||||
.reduce(
|
||||
(sum, track) => sum + getTrackHeight({ type: track.type }) + TRACK_GAP,
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
export function getTotalTracksHeight({
|
||||
tracks,
|
||||
}: {
|
||||
tracks: Array<{ type: TrackType }>;
|
||||
}): number {
|
||||
const tracksHeight = tracks.reduce(
|
||||
(sum, track) => sum + getTrackHeight({ type: track.type }),
|
||||
0,
|
||||
);
|
||||
const gapsHeight = Math.max(0, tracks.length - 1) * TRACK_GAP;
|
||||
return tracksHeight + gapsHeight;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { TrackType } from "@/lib/timeline";
|
||||
|
||||
export const DEFAULT_TRACK_NAMES: Record<TrackType, string> = {
|
||||
video: "Video track",
|
||||
text: "Text track",
|
||||
audio: "Audio track",
|
||||
graphic: "Graphic track",
|
||||
effect: "Effect track",
|
||||
} as const;
|
||||
@@ -1,4 +1,7 @@
|
||||
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
||||
import {
|
||||
BASE_TIMELINE_PIXELS_PER_SECOND,
|
||||
TIMELINE_ZOOM_MAX,
|
||||
} from "@/lib/timeline/scale";
|
||||
|
||||
const PADDING_MAX_RATIO = 0.75;
|
||||
const PADDING_MIN_RATIO = 0.15;
|
||||
@@ -16,9 +19,9 @@ export function getTimelineZoomMin({
|
||||
const contentRatioAtMinZoom = 1 - PADDING_MAX_RATIO;
|
||||
const availableWidth = safeContainerWidth * contentRatioAtMinZoom;
|
||||
const zoomToFit =
|
||||
availableWidth / (safeDuration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND);
|
||||
availableWidth / (safeDuration * BASE_TIMELINE_PIXELS_PER_SECOND);
|
||||
|
||||
return Math.min(TIMELINE_CONSTANTS.ZOOM_MAX, zoomToFit);
|
||||
return Math.min(TIMELINE_ZOOM_MAX, zoomToFit);
|
||||
}
|
||||
|
||||
export function getTimelinePaddingPx({
|
||||
@@ -49,7 +52,7 @@ export function getZoomPercent({
|
||||
zoomLevel: number;
|
||||
minZoom: number;
|
||||
}): number {
|
||||
return (zoomLevel - minZoom) / (TIMELINE_CONSTANTS.ZOOM_MAX - minZoom);
|
||||
return (zoomLevel - minZoom) / (TIMELINE_ZOOM_MAX - minZoom);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -59,7 +62,7 @@ export function getZoomPercent({
|
||||
export function sliderToZoom({
|
||||
sliderPosition,
|
||||
minZoom,
|
||||
maxZoom = TIMELINE_CONSTANTS.ZOOM_MAX,
|
||||
maxZoom = TIMELINE_ZOOM_MAX,
|
||||
}: {
|
||||
sliderPosition: number;
|
||||
minZoom: number;
|
||||
@@ -75,7 +78,7 @@ export function sliderToZoom({
|
||||
export function zoomToSlider({
|
||||
zoomLevel,
|
||||
minZoom,
|
||||
maxZoom = TIMELINE_CONSTANTS.ZOOM_MAX,
|
||||
maxZoom = TIMELINE_ZOOM_MAX,
|
||||
}: {
|
||||
zoomLevel: number;
|
||||
minZoom: number;
|
||||
|
||||
Reference in New Issue
Block a user