Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 1 addition & 15 deletions src/components/canvas/players/player.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,6 @@ export enum PlayerType {
*/
export abstract class Player extends Entity {
private static readonly DiscardedFrameCount = 0;
private static readonly DoubleClickThresholdMs = 350;

public layer: number;
public shouldDispose: boolean;
Expand Down Expand Up @@ -493,13 +492,8 @@ export abstract class Player extends Entity {
return this.getPlaybackTime() < Player.DiscardedFrameCount;
}

/** Timestamp of last single-click on this player, used for double-click detection. */
private lastClickAt = 0;

/**
* Handle pointer down - emit click event for selection handling.
* Two clicks within DoubleClickThresholdMs on the same player also emit
* CanvasClipDoubleClicked so text-clip editing can be triggered from the canvas.
* Handle pointer down — emit click event for selection handling.
* All drag/resize/rotate interaction is handled by SelectionHandles.
*/
private onPointerDown(event: pixi.FederatedPointerEvent): void {
Expand All @@ -508,14 +502,6 @@ export abstract class Player extends Entity {
}

this.edit.getInternalEvents().emit(InternalEvent.CanvasClipClicked, { player: this });

const now = performance.now();
if (now - this.lastClickAt <= Player.DoubleClickThresholdMs) {
this.edit.getInternalEvents().emit(InternalEvent.CanvasClipDoubleClicked, { player: this });
this.lastClickAt = 0;
} else {
this.lastClickAt = now;
}
}

private clipHasKeyframes(): boolean {
Expand Down
21 changes: 20 additions & 1 deletion src/core/edit-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2236,15 +2236,34 @@ export class Edit {
return bestMatch;
}

private lastClipClick: { player: Player; at: number } | null = null;
private static readonly DoubleClickThresholdMs = 500;

// ─── Intent Listeners ────────────────────────────────────────────────────────

private setupIntentListeners(): void {
this.internalEvents.on(InternalEvent.CanvasClipClicked, data => {
this.selectPlayer(data.player);
const wasSelected = this.getSelectedClipInfo()?.player === data.player;

if (!wasSelected) {
this.selectPlayer(data.player);
this.lastClipClick = null;
return;
}

const now = performance.now();
const within = this.lastClipClick && now - this.lastClipClick.at <= Edit.DoubleClickThresholdMs;
if (this.lastClipClick?.player === data.player && within) {
this.internalEvents.emit(InternalEvent.CanvasClipDoubleClicked, { player: data.player });
this.lastClipClick = null;
} else {
this.lastClipClick = { player: data.player, at: now };
}
});

this.internalEvents.on(InternalEvent.CanvasBackgroundClicked, () => {
this.clearSelection();
this.lastClipClick = null;
});
}

Expand Down
11 changes: 6 additions & 5 deletions src/core/ui/base-toolbar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,7 @@ export const TOOLBAR_ICONS = {
chevron: `<path d="M2.5 4.5L6 8L9.5 4.5" stroke="currentColor" stroke-width="1.5" fill="none" stroke-linecap="round"/>`,
transition: `<path d="M12 3v18"/><path d="M5 12H2l3-3 3 3H5"/><path d="M19 12h3l-3 3-3-3h3"/>`,
effect: `<circle cx="12" cy="12" r="3"/><path d="M12 1v4M12 19v4M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M1 12h4M19 12h4M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83"/>`,
trash: `<polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6M14 11v6"/><path d="M9 6V4a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2v2"/>`,
textCursor: `<path d="M12 4v16"/><path d="M8 4h8"/><path d="M8 20h8"/>`
trash: `<polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6M14 11v6"/><path d="M9 6V4a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2v2"/>`
};

/**
Expand Down Expand Up @@ -256,9 +255,11 @@ export abstract class BaseToolbar {
*/
protected setupOutsideClickHandler(): void {
this.clickOutsideHandler = (e: MouseEvent) => {
if (!this.container?.contains(e.target as Node)) {
this.closeAllPopups();
}
const target = e.target as HTMLElement | null;
if (!target) return;
if (this.container?.contains(target)) return;
if (target.tagName === "CANVAS") return;
this.closeAllPopups();
};
document.addEventListener("click", this.clickOutsideHandler);
}
Expand Down
52 changes: 24 additions & 28 deletions src/core/ui/rich-text-toolbar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { injectShotstackStyles } from "@styles/inject";
import { GOOGLE_FONTS_BY_FILENAME } from "../fonts/google-fonts";

import { BackgroundColorPicker } from "./background-color-picker";
import { BaseToolbar, FONT_SIZES, TOOLBAR_ICONS } from "./base-toolbar";
import { BaseToolbar, FONT_SIZES } from "./base-toolbar";
import { EffectPanel } from "./composites/EffectPanel";
import { SpacingPanel } from "./composites/SpacingPanel";
import { StylePanel, type StylePanelOptions } from "./composites/StylePanel";
Expand Down Expand Up @@ -127,24 +127,6 @@ export class RichTextToolbar extends BaseToolbar {
</div>
<div class="ss-toolbar-mode-divider"></div>

<div class="ss-toolbar-dropdown">
<button data-action="text-edit-toggle" class="ss-toolbar-btn ss-toolbar-btn--text-edit" title="Edit text">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
${TOOLBAR_ICONS.textCursor}
</svg>
<span>Edit text</span>
</button>
<div data-text-edit-popup class="ss-toolbar-popup ss-toolbar-popup--text-edit">
<div class="ss-toolbar-popup-header">Edit Text</div>
<div class="ss-toolbar-text-area-wrapper">
<textarea data-text-edit-area class="ss-toolbar-text-area" rows="4" placeholder="Enter text..."></textarea>
<div class="ss-autocomplete-popup" data-autocomplete-popup>
<div class="ss-autocomplete-items" data-autocomplete-items></div>
</div>
</div>
</div>
</div>

<div class="ss-toolbar-group ss-toolbar-group--bordered">
<button data-action="size-down" class="ss-toolbar-btn" title="Decrease font size">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
Expand Down Expand Up @@ -203,6 +185,23 @@ export class RichTextToolbar extends BaseToolbar {

<div class="ss-toolbar-divider"></div>

<div class="ss-toolbar-dropdown">
<button data-action="text-edit-toggle" class="ss-toolbar-btn ss-toolbar-btn--text-edit ss-toolbar-btn--primary" title="Edit text">
<span>Edit text</span>
</button>
<div data-text-edit-popup class="ss-toolbar-popup ss-toolbar-popup--text-edit">
<div class="ss-toolbar-popup-header">Edit Text</div>
<div class="ss-toolbar-text-area-wrapper">
<textarea data-text-edit-area class="ss-toolbar-text-area" rows="4" placeholder="Enter text..."></textarea>
<div class="ss-autocomplete-popup" data-autocomplete-popup>
<div class="ss-autocomplete-items" data-autocomplete-items></div>
</div>
</div>
</div>
</div>

<div class="ss-toolbar-divider"></div>

<!-- Formatting Group -->
<button data-action="align-cycle" class="ss-toolbar-btn" title="Text alignment">
<svg data-align-icon width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
Expand Down Expand Up @@ -835,12 +834,10 @@ export class RichTextToolbar extends BaseToolbar {

// Double-clicking the canvas text opens the edit popup for the current
// selection — same path as the "Edit text" toolbar button.
this.unsubCanvasDoubleClick = this.edit.getInternalEvents().on(InternalEvent.CanvasClipDoubleClicked, ({ player }) => {
this.unsubCanvasDoubleClick = this.edit.getInternalEvents().on(InternalEvent.CanvasClipDoubleClicked, () => {
if (this.selectedTrackIdx < 0 || this.selectedClipIdx < 0) return;
const selectedClipId = this.edit.getClipId(this.selectedTrackIdx, this.selectedClipIdx);
if (selectedClipId && player.clipId === selectedClipId) {
this.openTextEditPopup();
}
if (!this.container || !this.container.classList.contains("visible")) return;
this.openTextEditPopup();
});
}

Expand Down Expand Up @@ -1133,13 +1130,12 @@ export class RichTextToolbar extends BaseToolbar {
}

/** Open the edit-text popup and focus its textarea. Idempotent if already open. */
public openTextEditPopup(): void {
private openTextEditPopup(): void {
if (!this.isPopupOpen(this.textEditPopup)) {
this.toggleTextEditPopup();
} else {
this.textEditArea?.focus();
this.textEditArea?.select();
}
this.textEditArea?.focus();
this.textEditArea?.select();
}

private debouncedApplyTextEdit(): void {
Expand Down
28 changes: 24 additions & 4 deletions src/core/ui/selection-handles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,23 @@ import {
detectCornerZone,
detectEdgeZone
} from "@core/interaction/clip-interaction";
import { SELECTION_CONSTANTS, CURSOR_BASE_ANGLES, type CornerName, buildResizeCursor, buildRotationCursor, calculateHitArea } from "@core/interaction/selection-overlay";
import { type ClipBounds, createClipBounds, createSnapContext, filterContainedClips, snap, snapRotation, visualToLogical } from "@core/interaction/snap-system";
import {
SELECTION_CONSTANTS,
CURSOR_BASE_ANGLES,
type CornerName,
buildResizeCursor,
buildRotationCursor,
calculateHitArea
} from "@core/interaction/selection-overlay";
import {
type ClipBounds,
createClipBounds,
createSnapContext,
filterContainedClips,
snap,
snapRotation,
visualToLogical
} from "@core/interaction/snap-system";
import { updateSvgViewBox, isSimpleRectSvg } from "@core/shared/svg-utils";
import { Pointer } from "@inputs/pointer";
import type { Size, Vector } from "@layouts/geometry";
Expand Down Expand Up @@ -456,7 +471,13 @@ export class SelectionHandles implements CanvasOverlayRegistration {

// Check if inside player bounds for drag
if (localPoint.x >= 0 && localPoint.x <= size.width && localPoint.y >= 0 && localPoint.y <= size.height) {
this.edit.getInternalEvents().emit(InternalEvent.CanvasClipClicked, { player: this.selectedPlayer });
this.startDrag(event);
return;
}

if (event.target === this.outline || event.target === this.app?.stage) {
this.edit.getInternalEvents().emit(InternalEvent.CanvasBackgroundClicked);
}
}

Expand Down Expand Up @@ -536,8 +557,7 @@ export class SelectionHandles implements CanvasOverlayRegistration {
}

// Auto-set fit to "contain" for image/video clips when resizing
if ((this.scaleDirection || this.edgeDragDirection) &&
(finalClip.asset?.type === "image" || finalClip.asset?.type === "video")) {
if ((this.scaleDirection || this.edgeDragDirection) && (finalClip.asset?.type === "image" || finalClip.asset?.type === "video")) {
finalClip.fit = "contain";
this.edit.updateClipInDocument(this.selectedClipId, { fit: "contain" });
this.edit.resolveClip(this.selectedClipId);
Expand Down
44 changes: 21 additions & 23 deletions src/core/ui/text-toolbar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,21 +104,6 @@ export class TextToolbar extends BaseToolbar {
</div>
<div class="ss-toolbar-mode-divider"></div>

<div class="ss-toolbar-dropdown">
<button data-action="text-edit-toggle" class="ss-toolbar-btn ss-toolbar-btn--text-edit" title="Edit text">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
${TOOLBAR_ICONS.textCursor}
</svg>
<span>Edit text</span>
</button>
<div data-text-edit-popup class="ss-toolbar-popup ss-toolbar-popup--text-edit">
<div class="ss-toolbar-popup-header">Edit Text</div>
<div class="ss-toolbar-text-area-wrapper">
<textarea data-text-edit-area class="ss-toolbar-text-area" rows="4" placeholder="Enter text..."></textarea>
</div>
</div>
</div>

<div class="ss-toolbar-group ss-toolbar-group--bordered">
<button data-action="size-down" class="ss-toolbar-btn" title="Decrease font size">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
Expand Down Expand Up @@ -195,6 +180,20 @@ export class TextToolbar extends BaseToolbar {

<div class="ss-toolbar-divider"></div>

<div class="ss-toolbar-dropdown">
<button data-action="text-edit-toggle" class="ss-toolbar-btn ss-toolbar-btn--text-edit ss-toolbar-btn--primary" title="Edit text">
<span>Edit text</span>
</button>
<div data-text-edit-popup class="ss-toolbar-popup ss-toolbar-popup--text-edit">
<div class="ss-toolbar-popup-header">Edit Text</div>
<div class="ss-toolbar-text-area-wrapper">
<textarea data-text-edit-area class="ss-toolbar-text-area" rows="4" placeholder="Enter text..."></textarea>
</div>
</div>
</div>

<div class="ss-toolbar-divider"></div>

<button data-action="align-cycle" class="ss-toolbar-btn" title="Text alignment">
<svg data-align-icon width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
${TOOLBAR_ICONS.alignCenter}
Expand Down Expand Up @@ -343,7 +342,7 @@ export class TextToolbar extends BaseToolbar {
}

/** Open the edit-text popup and focus its textarea. Idempotent if already open. */
public openTextEditPopup(): void {
private openTextEditPopup(): void {
if (!this.isPopupOpen(this.textEditPopup)) {
this.togglePopup(this.textEditPopup);
}
Expand Down Expand Up @@ -390,14 +389,13 @@ export class TextToolbar extends BaseToolbar {
this.strokeColorInput?.addEventListener("input", () => this.handleStrokeChange());

// Double-clicking the text on the canvas opens the edit popup — same
// path as clicking the toolbar's "Edit text" button. Guarded against
// firing for clips other than the currently-selected one.
this.unsubCanvasDoubleClick = this.edit.getInternalEvents().on(InternalEvent.CanvasClipDoubleClicked, ({ player }) => {
// path as clicking the toolbar's "Edit text" button. The Edit layer only
// emits CanvasClipDoubleClicked for the *current* selection, so the
// toolbar just needs to be visible (i.e. it has an active selection).
this.unsubCanvasDoubleClick = this.edit.getInternalEvents().on(InternalEvent.CanvasClipDoubleClicked, () => {
if (this.selectedTrackIdx < 0 || this.selectedClipIdx < 0) return;
const selectedClipId = this.edit.getClipId(this.selectedTrackIdx, this.selectedClipIdx);
if (selectedClipId && player.clipId === selectedClipId) {
this.openTextEditPopup();
}
if (!this.container || !this.container.classList.contains("visible")) return;
this.openTextEditPopup();
});

// Mount composite panels
Expand Down
2 changes: 1 addition & 1 deletion src/main.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { type Edit as EditSchema } from "@schemas";
import { Timeline } from "@timeline/index";

import template from "./templates/caption.json";
import template from "./templates/test.json";

import { Edit, Canvas, Controls, UIController } from "./index";

Expand Down
17 changes: 17 additions & 0 deletions src/styles/ui/rich-text-toolbar.css
Original file line number Diff line number Diff line change
Expand Up @@ -464,6 +464,23 @@
white-space: nowrap;
}

.ss-toolbar-btn.ss-toolbar-btn--primary {
padding: 0 16px;
font-size: 13px;
font-weight: 600;
letter-spacing: -0.01em;
color: rgba(255, 255, 255, 0.95);
background: rgba(255, 255, 255, 0.08);
}
.ss-toolbar-btn.ss-toolbar-btn--primary:hover {
background: rgba(255, 255, 255, 0.16);
color: #fff;
}
.ss-toolbar-btn.ss-toolbar-btn--primary.active {
background: rgba(255, 255, 255, 0.22);
color: #fff;
}

.ss-toolbar-popup--text-edit {
min-width: 280px;
padding: 14px 16px;
Expand Down
Loading
Loading