feat: Clip effects, asset sorting, and timeline improvements

Major features and improvements:

* **Clip Effects**:
  * Added UI in Properties Panel to manage effects on video/image clips (add, remove, toggle, reorder).
  * Implemented dynamic parameter fields for effects.
  * Added support for keyframing effect parameters.

* **Assets Panel**:
  * Added sorting options: Name, Type, Duration, and File Size.
  * Persisted view preferences (grid/list mode, sort order) to local storage.
  * Refactored media item rendering and drag interactions.

* **Timeline & Interaction**:
  * **Keyframe Dragging**: Added ability to drag keyframes directly on the timeline element.
  * **Resizing**: Improved resize logic to respect neighboring clips (prevents overlaps).
  * **Visuals**: Implemented tiled background rendering for video/image clips on the timeline.
  * **Shortcuts**: Added "Deselect All" action bound to the `Escape` key.
  * **Fixes**: Corrected drag-and-drop coordinate calculations when the timeline track area is scrolled.

* **Text Elements**:
  * Refactored text background storage to use an explicit `enabled` flag.
  * Added `V8toV9` storage migration to update existing projects.

* **Architecture**:
  * Moved export state management to `ProjectManager` for better lifecycle handling.
  * Refactored `PropertiesPanel` sections to be more composable (custom headers, borders).
This commit is contained in:
Maze Winther
2026-03-02 13:13:07 +01:00
parent 93bea01c9e
commit e7dcb586c0
66 changed files with 3688 additions and 1333 deletions
+6 -1
View File
@@ -114,6 +114,11 @@ export const ACTIONS = {
category: "selection",
defaultShortcuts: ["ctrl+a"],
},
"deselect-all": {
description: "Deselect all elements",
category: "selection",
defaultShortcuts: ["escape"],
},
"duplicate-selected": {
description: "Duplicate selected element",
category: "selection",
@@ -145,7 +150,7 @@ export const ACTIONS = {
export type TAction = keyof typeof ACTIONS;
export function getActionDefinition(action: TAction): TActionDefinition {
export function getActionDefinition({ action }: { action: TAction }): TActionDefinition {
return ACTIONS[action];
}
@@ -0,0 +1,117 @@
import type { Effect, EffectParamValues } from "@/types/effects";
import type {
ElementAnimations,
NumberAnimationChannel,
} from "@/types/animation";
import {
getChannel,
removeKeyframe,
setChannel,
upsertKeyframe,
} from "./keyframes";
import { getChannelValueAtTime } from "./interpolation";
const EFFECT_PARAM_PATH_PREFIX = "effects.";
const EFFECT_PARAM_PATH_SUFFIX = ".params.";
function buildEffectParamPath({
effectId,
paramKey,
}: {
effectId: string;
paramKey: string;
}): string {
return `${EFFECT_PARAM_PATH_PREFIX}${effectId}${EFFECT_PARAM_PATH_SUFFIX}${paramKey}`;
}
export function resolveEffectParamsAtTime({
effect,
animations,
localTime,
}: {
effect: Effect;
animations: ElementAnimations | undefined;
localTime: number;
}): EffectParamValues {
const resolved: EffectParamValues = {};
for (const [paramKey, staticValue] of Object.entries(effect.params)) {
const path = buildEffectParamPath({ effectId: effect.id, paramKey });
const channel = getChannel({ animations, propertyPath: path });
if (channel && channel.keyframes.length > 0) {
resolved[paramKey] = getChannelValueAtTime({
channel,
time: localTime,
fallbackValue: staticValue,
}) as number | string | boolean;
} else {
resolved[paramKey] = staticValue;
}
}
return resolved;
}
const EMPTY_NUMBER_CHANNEL: NumberAnimationChannel = {
valueKind: "number",
keyframes: [],
};
export function upsertEffectParamKeyframe({
animations,
effectId,
paramKey,
time,
value,
interpolation,
keyframeId,
}: {
animations: ElementAnimations | undefined;
effectId: string;
paramKey: string;
time: number;
value: number;
interpolation?: "linear" | "hold";
keyframeId?: string;
}): ElementAnimations | undefined {
const path = buildEffectParamPath({ effectId, paramKey });
const channel = getChannel({ animations, propertyPath: path });
const targetChannel =
channel && channel.valueKind === "number" ? channel : EMPTY_NUMBER_CHANNEL;
const updatedChannel = upsertKeyframe({
channel: targetChannel,
time,
value,
interpolation: interpolation ?? "linear",
keyframeId,
});
return (
setChannel({
animations,
propertyPath: path,
channel: updatedChannel,
}) ?? { channels: {} }
);
}
export function removeEffectParamKeyframe({
animations,
effectId,
paramKey,
keyframeId,
}: {
animations: ElementAnimations | undefined;
effectId: string;
paramKey: string;
keyframeId: string;
}): ElementAnimations | undefined {
const path = buildEffectParamPath({ effectId, paramKey });
const channel = getChannel({ animations, propertyPath: path });
const updatedChannel = removeKeyframe({ channel, keyframeId });
return setChannel({
animations,
propertyPath: path,
channel: updatedChannel,
});
}
+25 -7
View File
@@ -127,7 +127,9 @@ function buildKeyframe({
}
if (typeof value !== "string" && typeof value !== "boolean") {
throw new Error("Discrete channel keyframes require boolean or string values");
throw new Error(
"Discrete channel keyframes require boolean or string values",
);
}
return {
@@ -145,14 +147,23 @@ function createEmptyChannel({
}): AnimationChannel {
const propertyDefinition = getAnimationPropertyDefinition({ propertyPath });
if (propertyDefinition.valueKind === "number") {
return { valueKind: "number", keyframes: [] } satisfies NumberAnimationChannel;
return {
valueKind: "number",
keyframes: [],
} satisfies NumberAnimationChannel;
}
if (propertyDefinition.valueKind === "color") {
return { valueKind: "color", keyframes: [] } satisfies ColorAnimationChannel;
return {
valueKind: "color",
keyframes: [],
} satisfies ColorAnimationChannel;
}
return { valueKind: "discrete", keyframes: [] } satisfies DiscreteAnimationChannel;
return {
valueKind: "discrete",
keyframes: [],
} satisfies DiscreteAnimationChannel;
}
export function upsertKeyframe({
@@ -432,7 +443,10 @@ export function splitAnimationsAtTime({
const hasBoundaryOnRight = rightKeyframes.some((keyframe) =>
isNearlySameTime({ leftTime: keyframe.time, rightTime: 0 }),
);
if (shouldIncludeSplitBoundary && (!hasBoundaryOnLeft || !hasBoundaryOnRight)) {
if (
shouldIncludeSplitBoundary &&
(!hasBoundaryOnLeft || !hasBoundaryOnRight)
) {
const boundaryValue = getChannelValueAtTime({
channel: normalizedChannel,
time: splitTime,
@@ -442,7 +456,9 @@ export function splitAnimationsAtTime({
? (propertyPath as AnimationPropertyPath)
: null;
const boundaryInterpolation = knownPropertyPath
? getDefaultInterpolationForProperty({ propertyPath: knownPropertyPath })
? getDefaultInterpolationForProperty({
propertyPath: knownPropertyPath,
})
: normalizedChannel.valueKind === "discrete"
? "hold"
: "linear";
@@ -523,7 +539,9 @@ export function upsertElementKeyframe({
return animations;
}
const defaultInterpolation = getDefaultInterpolationForProperty({ propertyPath });
const defaultInterpolation = getDefaultInterpolationForProperty({
propertyPath,
});
const propertyDefinition = getAnimationPropertyDefinition({ propertyPath });
const channel = getChannel({ animations, propertyPath });
const targetChannel =
@@ -0,0 +1,74 @@
import { Command } from "@/lib/commands/base-command";
import { EditorCore } from "@/core";
import { isVisualElement, updateElementInTracks } from "@/lib/timeline";
import type { TimelineTrack, VisualElement } from "@/types/timeline";
import { buildDefaultEffectInstance } from "@/lib/effects";
function addEffectToElement({
element,
effectType,
}: {
element: VisualElement;
effectType: string;
}): VisualElement {
const instance = buildDefaultEffectInstance({ effectType });
const currentEffects = element.effects ?? [];
return { ...element, effects: [...currentEffects, instance] };
}
export class AddClipEffectCommand extends Command {
private savedState: TimelineTrack[] | null = null;
private effectId: string | null = null;
private readonly trackId: string;
private readonly elementId: string;
private readonly effectType: string;
constructor({
trackId,
elementId,
effectType,
}: {
trackId: string;
elementId: string;
effectType: string;
}) {
super();
this.trackId = trackId;
this.elementId = elementId;
this.effectType = effectType;
}
execute(): void {
const editor = EditorCore.getInstance();
this.savedState = editor.timeline.getTracks();
const updatedTracks = updateElementInTracks({
tracks: this.savedState,
trackId: this.trackId,
elementId: this.elementId,
elementPredicate: isVisualElement,
update: (element) => {
const updated = addEffectToElement({
element: element as VisualElement,
effectType: this.effectType,
});
const effects = updated.effects ?? [];
this.effectId = effects[effects.length - 1]?.id ?? null;
return updated;
},
});
editor.timeline.updateTracks(updatedTracks);
}
undo(): void {
if (this.savedState) {
const editor = EditorCore.getInstance();
editor.timeline.updateTracks(this.savedState);
}
}
getEffectId(): string | null {
return this.effectId;
}
}
@@ -0,0 +1,5 @@
export { AddClipEffectCommand } from "./add-effect";
export { RemoveClipEffectCommand } from "./remove-effect";
export { ToggleClipEffectCommand } from "./toggle-effect";
export { UpdateClipEffectParamsCommand } from "./update-effect-params";
export { ReorderClipEffectsCommand } from "./reorder-effect";
@@ -0,0 +1,65 @@
import { Command } from "@/lib/commands/base-command";
import { EditorCore } from "@/core";
import { isVisualElement, updateElementInTracks } from "@/lib/timeline";
import type { TimelineTrack, VisualElement } from "@/types/timeline";
function removeEffectFromElement({
element,
effectId,
}: {
element: VisualElement;
effectId: string;
}): VisualElement {
const currentEffects = element.effects ?? [];
const filtered = currentEffects.filter((effect) => effect.id !== effectId);
return { ...element, effects: filtered };
}
export class RemoveClipEffectCommand extends Command {
private savedState: TimelineTrack[] | null = null;
private readonly trackId: string;
private readonly elementId: string;
private readonly effectId: string;
constructor({
trackId,
elementId,
effectId,
}: {
trackId: string;
elementId: string;
effectId: string;
}) {
super();
this.trackId = trackId;
this.elementId = elementId;
this.effectId = effectId;
}
execute(): void {
const editor = EditorCore.getInstance();
this.savedState = editor.timeline.getTracks();
const updatedTracks = updateElementInTracks({
tracks: this.savedState,
trackId: this.trackId,
elementId: this.elementId,
elementPredicate: isVisualElement,
update: (element) => {
return removeEffectFromElement({
element: element as VisualElement,
effectId: this.effectId,
});
},
});
editor.timeline.updateTracks(updatedTracks);
}
undo(): void {
if (this.savedState) {
const editor = EditorCore.getInstance();
editor.timeline.updateTracks(this.savedState);
}
}
}
@@ -0,0 +1,73 @@
import { Command } from "@/lib/commands/base-command";
import { EditorCore } from "@/core";
import { isVisualElement, updateElementInTracks } from "@/lib/timeline";
import type { TimelineTrack, VisualElement } from "@/types/timeline";
function reorderEffectsOnElement({
element,
fromIndex,
toIndex,
}: {
element: VisualElement;
fromIndex: number;
toIndex: number;
}): VisualElement {
const effects = [...(element.effects ?? [])];
const [moved] = effects.splice(fromIndex, 1);
effects.splice(toIndex, 0, moved);
return { ...element, effects };
}
export class ReorderClipEffectsCommand extends Command {
private savedState: TimelineTrack[] | null = null;
private readonly trackId: string;
private readonly elementId: string;
private readonly fromIndex: number;
private readonly toIndex: number;
constructor({
trackId,
elementId,
fromIndex,
toIndex,
}: {
trackId: string;
elementId: string;
fromIndex: number;
toIndex: number;
}) {
super();
this.trackId = trackId;
this.elementId = elementId;
this.fromIndex = fromIndex;
this.toIndex = toIndex;
}
execute(): void {
const editor = EditorCore.getInstance();
this.savedState = editor.timeline.getTracks();
const updatedTracks = updateElementInTracks({
tracks: this.savedState,
trackId: this.trackId,
elementId: this.elementId,
elementPredicate: isVisualElement,
update: (element) => {
return reorderEffectsOnElement({
element: element as VisualElement,
fromIndex: this.fromIndex,
toIndex: this.toIndex,
});
},
});
editor.timeline.updateTracks(updatedTracks);
}
undo(): void {
if (this.savedState) {
const editor = EditorCore.getInstance();
editor.timeline.updateTracks(this.savedState);
}
}
}
@@ -0,0 +1,67 @@
import { Command } from "@/lib/commands/base-command";
import { EditorCore } from "@/core";
import { isVisualElement, updateElementInTracks } from "@/lib/timeline";
import type { TimelineTrack, VisualElement } from "@/types/timeline";
export function toggleEffectOnElement({
element,
effectId,
}: {
element: VisualElement;
effectId: string;
}): VisualElement {
const currentEffects = element.effects ?? [];
const updated = currentEffects.map((effect) =>
effect.id === effectId ? { ...effect, enabled: !effect.enabled } : effect,
);
return { ...element, effects: updated };
}
export class ToggleClipEffectCommand extends Command {
private savedState: TimelineTrack[] | null = null;
private readonly trackId: string;
private readonly elementId: string;
private readonly effectId: string;
constructor({
trackId,
elementId,
effectId,
}: {
trackId: string;
elementId: string;
effectId: string;
}) {
super();
this.trackId = trackId;
this.elementId = elementId;
this.effectId = effectId;
}
execute(): void {
const editor = EditorCore.getInstance();
this.savedState = editor.timeline.getTracks();
const updatedTracks = updateElementInTracks({
tracks: this.savedState,
trackId: this.trackId,
elementId: this.elementId,
elementPredicate: isVisualElement,
update: (element) => {
return toggleEffectOnElement({
element: element as VisualElement,
effectId: this.effectId,
});
},
});
editor.timeline.updateTracks(updatedTracks);
}
undo(): void {
if (this.savedState) {
const editor = EditorCore.getInstance();
editor.timeline.updateTracks(this.savedState);
}
}
}
@@ -0,0 +1,86 @@
import { Command } from "@/lib/commands/base-command";
import { EditorCore } from "@/core";
import { isVisualElement, updateElementInTracks } from "@/lib/timeline";
import type { EffectParamValues } from "@/types/effects";
import type { TimelineTrack, VisualElement } from "@/types/timeline";
function updateEffectParamsOnElement({
element,
effectId,
params,
}: {
element: VisualElement;
effectId: string;
params: Partial<EffectParamValues>;
}): VisualElement {
const currentEffects = element.effects ?? [];
const updated = currentEffects.map((effect) => {
if (effect.id !== effectId) {
return effect;
}
const nextParams = { ...effect.params };
for (const [key, value] of Object.entries(params)) {
if (value !== undefined) {
nextParams[key] = value;
}
}
return { ...effect, params: nextParams };
});
return { ...element, effects: updated };
}
export class UpdateClipEffectParamsCommand extends Command {
private savedState: TimelineTrack[] | null = null;
private readonly trackId: string;
private readonly elementId: string;
private readonly effectId: string;
private readonly params: Partial<EffectParamValues>;
constructor({
trackId,
elementId,
effectId,
params,
}: {
trackId: string;
elementId: string;
effectId: string;
params: Partial<EffectParamValues>;
}) {
super();
this.trackId = trackId;
this.elementId = elementId;
this.effectId = effectId;
this.params = params;
}
execute(): void {
const editor = EditorCore.getInstance();
this.savedState = editor.timeline.getTracks();
const updatedTracks = updateElementInTracks({
tracks: this.savedState,
trackId: this.trackId,
elementId: this.elementId,
elementPredicate: isVisualElement,
update: (element) => {
return updateEffectParamsOnElement({
element: element as VisualElement,
effectId: this.effectId,
params: this.params,
});
},
});
editor.timeline.updateTracks(updatedTracks);
}
undo(): void {
if (this.savedState) {
const editor = EditorCore.getInstance();
editor.timeline.updateTracks(this.savedState);
}
}
}
@@ -10,3 +10,4 @@ export { ToggleElementsVisibilityCommand } from "./toggle-elements-visibility";
export { ToggleElementsMutedCommand } from "./toggle-elements-muted";
export { MoveElementCommand } from "./move-elements";
export * from "./keyframes";
export * from "./effects";
@@ -1,3 +1,5 @@
export * from "./remove-effect-param-keyframe";
export * from "./remove-keyframe";
export * from "./retime-keyframe";
export * from "./upsert-effect-param-keyframe";
export * from "./upsert-keyframe";
@@ -0,0 +1,68 @@
import { EditorCore } from "@/core";
import { Command } from "@/lib/commands/base-command";
import { removeEffectParamKeyframe } from "@/lib/animation/effect-param-channel";
import { updateElementInTracks } from "@/lib/timeline";
import { isVisualElement } from "@/lib/timeline/element-utils";
import type { TimelineTrack } from "@/types/timeline";
export class RemoveEffectParamKeyframeCommand extends Command {
private savedState: TimelineTrack[] | null = null;
private readonly trackId: string;
private readonly elementId: string;
private readonly effectId: string;
private readonly paramKey: string;
private readonly keyframeId: string;
constructor({
trackId,
elementId,
effectId,
paramKey,
keyframeId,
}: {
trackId: string;
elementId: string;
effectId: string;
paramKey: string;
keyframeId: string;
}) {
super();
this.trackId = trackId;
this.elementId = elementId;
this.effectId = effectId;
this.paramKey = paramKey;
this.keyframeId = keyframeId;
}
execute(): void {
const editor = EditorCore.getInstance();
this.savedState = editor.timeline.getTracks();
const updatedTracks = updateElementInTracks({
tracks: this.savedState,
trackId: this.trackId,
elementId: this.elementId,
elementPredicate: isVisualElement,
update: (element) => {
const animations = removeEffectParamKeyframe({
animations: element.animations,
effectId: this.effectId,
paramKey: this.paramKey,
keyframeId: this.keyframeId,
});
return { ...element, animations };
},
});
editor.timeline.updateTracks(updatedTracks);
}
undo(): void {
if (!this.savedState) {
return;
}
const editor = EditorCore.getInstance();
editor.timeline.updateTracks(this.savedState);
}
}
@@ -0,0 +1,84 @@
import { EditorCore } from "@/core";
import { Command } from "@/lib/commands/base-command";
import { upsertEffectParamKeyframe } from "@/lib/animation/effect-param-channel";
import { updateElementInTracks } from "@/lib/timeline";
import { isVisualElement } from "@/lib/timeline/element-utils";
import type { TimelineTrack } from "@/types/timeline";
export class UpsertEffectParamKeyframeCommand extends Command {
private savedState: TimelineTrack[] | null = null;
private readonly trackId: string;
private readonly elementId: string;
private readonly effectId: string;
private readonly paramKey: string;
private readonly time: number;
private readonly value: number;
private readonly interpolation: "linear" | "hold" | undefined;
private readonly keyframeId: string | undefined;
constructor({
trackId,
elementId,
effectId,
paramKey,
time,
value,
interpolation,
keyframeId,
}: {
trackId: string;
elementId: string;
effectId: string;
paramKey: string;
time: number;
value: number;
interpolation?: "linear" | "hold";
keyframeId?: string;
}) {
super();
this.trackId = trackId;
this.elementId = elementId;
this.effectId = effectId;
this.paramKey = paramKey;
this.time = time;
this.value = value;
this.interpolation = interpolation;
this.keyframeId = keyframeId;
}
execute(): void {
const editor = EditorCore.getInstance();
this.savedState = editor.timeline.getTracks();
const updatedTracks = updateElementInTracks({
tracks: this.savedState,
trackId: this.trackId,
elementId: this.elementId,
elementPredicate: isVisualElement,
update: (element) => {
const boundedTime = Math.max(0, Math.min(this.time, element.duration));
const animations = upsertEffectParamKeyframe({
animations: element.animations,
effectId: this.effectId,
paramKey: this.paramKey,
time: boundedTime,
value: this.value,
interpolation: this.interpolation,
keyframeId: this.keyframeId,
});
return { ...element, animations };
},
});
editor.timeline.updateTracks(updatedTracks);
}
undo(): void {
if (!this.savedState) {
return;
}
const editor = EditorCore.getInstance();
editor.timeline.updateTracks(this.savedState);
}
}
@@ -11,6 +11,7 @@ import {
validateElementTrackCompatibility,
enforceMainTrackStart,
} from "@/lib/timeline/track-utils";
import { rippleShiftElements } from "@/lib/timeline/ripple-utils";
export class MoveElementCommand extends Command {
private savedState: TimelineTrack[] | null = null;
@@ -19,6 +20,7 @@ export class MoveElementCommand extends Command {
private readonly elementId: string;
private readonly newStartTime: number;
private readonly createTrack: { type: TrackType; index: number } | undefined;
private readonly rippleEnabled: boolean;
constructor({
sourceTrackId,
@@ -26,12 +28,14 @@ export class MoveElementCommand extends Command {
elementId,
newStartTime,
createTrack,
rippleEnabled = false,
}: {
sourceTrackId: string;
targetTrackId: string;
elementId: string;
newStartTime: number;
createTrack?: { type: TrackType; index: number };
rippleEnabled?: boolean;
}) {
super();
this.sourceTrackId = sourceTrackId;
@@ -39,6 +43,7 @@ export class MoveElementCommand extends Command {
this.elementId = elementId;
this.newStartTime = newStartTime;
this.createTrack = createTrack;
this.rippleEnabled = rippleEnabled;
}
execute(): void {
@@ -53,8 +58,7 @@ export class MoveElementCommand extends Command {
);
if (!sourceTrack || !element) {
console.error("Source track or element not found");
return;
throw new Error("Source track or element not found");
}
let targetTrack = this.savedState.find((track) => track.id === this.targetTrackId);
@@ -69,8 +73,7 @@ export class MoveElementCommand extends Command {
targetTrack = newTrack;
}
if (!targetTrack) {
console.error("Target track not found");
return;
throw new Error("Target track not found");
}
const validation = validateElementTrackCompatibility({
@@ -79,8 +82,7 @@ export class MoveElementCommand extends Command {
});
if (!validation.isValid) {
console.error(validation.errorMessage);
return;
throw new Error(validation.errorMessage);
}
const adjustedStartTime = enforceMainTrackStart({
@@ -105,27 +107,32 @@ export class MoveElementCommand extends Command {
elements: track.elements.map((trackElement) =>
trackElement.id === this.elementId ? movedElement : trackElement,
),
};
} as typeof track;
}
if (track.id === this.sourceTrackId) {
return {
...track,
elements: track.elements.filter(
(trackElement) => trackElement.id !== this.elementId,
),
};
const remainingElements = track.elements.filter(
(trackElement) => trackElement.id !== this.elementId,
);
const shiftedElements = this.rippleEnabled
? rippleShiftElements({
elements: remainingElements,
afterTime: element.startTime,
shiftAmount: element.duration,
})
: remainingElements;
return { ...track, elements: shiftedElements } as typeof track;
}
if (track.id === this.targetTrackId) {
return {
...track,
elements: [...track.elements, movedElement],
};
} as typeof track;
}
return track;
});
return track;
});
if (!isSameTrack) {
const sourceTrackAfterMove = updatedTracks.find(
+20
View File
@@ -16,3 +16,23 @@ export function getExportFileExtension({
}): string {
return `.${format}`;
}
export function downloadBuffer({
buffer,
filename,
mimeType,
}: {
buffer: ArrayBuffer;
filename: string;
mimeType: string;
}): void {
const blob = new Blob([buffer], { type: mimeType });
const url = URL.createObjectURL(blob);
const downloadLink = document.createElement("a");
downloadLink.href = url;
downloadLink.download = filename;
document.body.appendChild(downloadLink);
downloadLink.click();
document.body.removeChild(downloadLink);
URL.revokeObjectURL(url);
}
+15 -7
View File
@@ -1,5 +1,5 @@
import { DEFAULT_TEXT_BACKGROUND } from "@/constants/text-constants";
import type { TextElement } from "@/types/timeline";
import type { TextBackground, TextElement } from "@/types/timeline";
type TextRect = {
left: number;
@@ -74,8 +74,12 @@ function getTextRect({
textAlign: TextElement["textAlign"];
block: TextBlockMeasurement;
}): TextRect {
const left =
textAlign === "left" ? 0 : textAlign === "right" ? -block.maxWidth : -block.maxWidth / 2;
const textAlignToLeft: Record<typeof textAlign, number> = {
left: 0,
right: -block.maxWidth,
center: -block.maxWidth / 2,
};
const left = textAlignToLeft[textAlign];
return {
left,
@@ -88,9 +92,13 @@ function getTextRect({
function isTextBackgroundVisible({
background,
}: {
background: TextElement["background"];
background: TextBackground;
}): boolean {
return Boolean(background.color) && background.color !== "transparent";
return (
background.enabled &&
Boolean(background.color) &&
background.color !== "transparent"
);
}
export function getTextBackgroundRect({
@@ -101,7 +109,7 @@ export function getTextBackgroundRect({
}: {
textAlign: TextElement["textAlign"];
block: TextBlockMeasurement;
background: TextElement["background"];
background: TextBackground;
fontSizeRatio?: number;
}): TextRect | null {
if (!isTextBackgroundVisible({ background })) {
@@ -132,7 +140,7 @@ export function getTextVisualRect({
}: {
textAlign: TextElement["textAlign"];
block: TextBlockMeasurement;
background: TextElement["background"];
background: TextBackground;
fontSizeRatio?: number;
}): TextRect {
const textRect = getTextRect({ textAlign, block });
+24 -13
View File
@@ -13,6 +13,7 @@ import type {
CreateStickerElement,
CreateUploadAudioElement,
CreateLibraryAudioElement,
TextBackground,
TextElement,
TimelineElement,
TimelineTrack,
@@ -46,7 +47,7 @@ export function isVisualElement(
export function canElementBeHidden(
element: TimelineElement,
): element is VisualElement {
return element.type !== "audio";
return isVisualElement(element);
}
export function hasMediaId(
@@ -127,13 +128,30 @@ export function wouldElementOverlap({
endTime: number;
excludeElementId?: string;
}): boolean {
return elements.some((el) => {
if (excludeElementId && el.id === excludeElementId) return false;
const elEnd = el.startTime + el.duration;
return startTime < elEnd && endTime > el.startTime;
return elements.some((element) => {
if (excludeElementId && element.id === excludeElementId) return false;
const elementEnd = element.startTime + element.duration;
return startTime < elementEnd && endTime > element.startTime;
});
}
function buildTextBackground(
raw: Partial<TextBackground> | undefined,
): TextBackground {
const color = raw?.color ?? DEFAULT_TEXT_ELEMENT.background.color;
const enabled =
typeof raw?.enabled === "boolean" ? raw.enabled : color !== "transparent";
return {
enabled,
color,
cornerRadius: raw?.cornerRadius,
paddingX: raw?.paddingX,
paddingY: raw?.paddingY,
offsetX: raw?.offsetX,
offsetY: raw?.offsetY,
};
}
export function buildTextElement({
raw,
startTime,
@@ -157,14 +175,7 @@ export function buildTextElement({
: DEFAULT_TEXT_ELEMENT.fontSize,
fontFamily: t.fontFamily ?? DEFAULT_TEXT_ELEMENT.fontFamily,
color: t.color ?? DEFAULT_TEXT_ELEMENT.color,
background: {
color: t.background?.color ?? DEFAULT_TEXT_ELEMENT.background.color,
cornerRadius: t.background?.cornerRadius,
paddingX: t.background?.paddingX,
paddingY: t.background?.paddingY,
offsetX: t.background?.offsetX,
offsetY: t.background?.offsetY,
},
background: buildTextBackground(t.background),
textAlign: t.textAlign ?? DEFAULT_TEXT_ELEMENT.textAlign,
fontWeight: t.fontWeight ?? DEFAULT_TEXT_ELEMENT.fontWeight,
fontStyle: t.fontStyle ?? DEFAULT_TEXT_ELEMENT.fontStyle,