diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100644 index 00000000..2312dc58 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1 @@ +npx lint-staged diff --git a/.husky/pre-push b/.husky/pre-push new file mode 100644 index 00000000..aa17b694 --- /dev/null +++ b/.husky/pre-push @@ -0,0 +1 @@ +npx tsc --noEmit && npx jest --bail diff --git a/package.json b/package.json index 8868ba6b..2b9d0a04 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,7 @@ "url": "https://github.com/shotstack/shotstack-studio-sdk" }, "scripts": { + "prepare": "husky", "dev": "vite", "dev:shotstack": "vite --open /shotstack.html", "start": "npm run build && vite preview", @@ -86,8 +87,10 @@ "eslint-config-airbnb-typescript": "^18.0.0", "eslint-config-prettier": "^9.1.0", "eslint-plugin-import": "^2.31.0", + "husky": "^9.1.7", "jest": "^30.2.0", "jest-environment-jsdom": "^30.2.0", + "lint-staged": "^16.4.0", "prettier": "^3.6.2", "semantic-release": "^25.0.3", "ts-jest": "^29.4.5", @@ -96,7 +99,7 @@ "vite-plugin-dts": "^4.5.4" }, "dependencies": { - "@shotstack/schemas": "1.9.3", + "@shotstack/schemas": "1.9.10", "@shotstack/shotstack-canvas": "^2.1.12", "howler": "^2.2.4", "mediabunny": "^1.11.2", @@ -105,6 +108,12 @@ "pixi.js": "^8.15.0", "zod": "^4.0.0" }, + "lint-staged": { + "*.{ts,tsx}": [ + "eslint --fix", + "prettier --write" + ] + }, "publishConfig": { "access": "public", "registry": "https://registry.npmjs.org/" diff --git a/src/components/canvas/players/rich-caption-player.ts b/src/components/canvas/players/rich-caption-player.ts index a27396b3..6a3bfa49 100644 --- a/src/components/canvas/players/rich-caption-player.ts +++ b/src/components/canvas/players/rich-caption-player.ts @@ -484,7 +484,7 @@ export class RichCaptionPlayer extends Player { border: asset.border, padding: asset.padding, style: asset.style, - wordAnimation: asset.wordAnimation, + wordAnimation: asset.animation, align: asset.align, pauseThreshold: this.resolvedPauseThreshold }; diff --git a/src/core/shotstack-edit.ts b/src/core/shotstack-edit.ts index e5080e8b..55ece3a2 100644 --- a/src/core/shotstack-edit.ts +++ b/src/core/shotstack-edit.ts @@ -63,8 +63,7 @@ function convertEmptyTextClipToSvg(clip: Clip): Clip { ...clip, asset: { type: "svg", - src: svgMarkup, - opacity: 1 + src: svgMarkup } }; diff --git a/src/core/ui/rich-caption-toolbar.ts b/src/core/ui/rich-caption-toolbar.ts index d09d22ce..0ce223e4 100644 --- a/src/core/ui/rich-caption-toolbar.ts +++ b/src/core/ui/rich-caption-toolbar.ts @@ -56,11 +56,6 @@ export class RichCaptionToolbar extends RichTextToolbar { private activeWordTabs: NodeListOf | null = null; private activeWordPanels: NodeListOf | null = null; - // Active-mode scale control - private scaleSlider: HTMLInputElement | null = null; - private scaleValue: HTMLSpanElement | null = null; - private currentActiveScale = 1; - protected override createStylePanel(): StylePanel { return new StylePanel({}); } @@ -118,8 +113,6 @@ export class RichCaptionToolbar extends RichTextToolbar { this.activeTextDecorationBtns = null; this.activeWordTabs = null; this.activeWordPanels = null; - this.scaleSlider = null; - this.scaleValue = null; this.sourcePopup = null; this.sourceListContainer = null; this.sourceDot = null; @@ -162,7 +155,7 @@ export class RichCaptionToolbar extends RichTextToolbar { if (!asset) return; // ─── Word Animation ──────────────────────────────── - const wordAnim = asset.wordAnimation; + const wordAnim = asset.animation; const animStyle = wordAnim?.style ?? "karaoke"; this.container?.querySelectorAll("[data-caption-word-style]").forEach(btn => { this.setButtonActive(btn, btn.dataset["captionWordStyle"] === animStyle); @@ -261,14 +254,6 @@ export class RichCaptionToolbar extends RichTextToolbar { } if (this.activeShadowOpacityValue) this.activeShadowOpacityValue.textContent = `${shadowOpacity}%`; - // ─── Scale ───────────────────────────────────────── - const scale = activeData?.scale ?? 1; - if (this.scaleSlider) this.scaleSlider.value = String(scale); - if (this.scaleValue) this.scaleValue.textContent = `${scale.toFixed(1)}x`; - - const scaleSection = this.container?.querySelector("[data-caption-scale-section]") as HTMLElement | null; - if (scaleSection) scaleSection.style.display = animStyle === "pop" ? "" : "none"; - // ─── Source linked indicator ────────────────────── if (this.sourceDot) { const currentSource = findCurrentSource(this.edit, this.selectedTrackIdx, this.selectedClipIdx); @@ -454,14 +439,6 @@ export class RichCaptionToolbar extends RichTextToolbar { - `; @@ -499,10 +476,6 @@ export class RichCaptionToolbar extends RichTextToolbar { this.activeShadowOpacitySlider = activeWordDropdown.querySelector("[data-active-shadow-opacity]"); this.activeShadowOpacityValue = activeWordDropdown.querySelector("[data-active-shadow-opacity-value]"); - // Scale - this.scaleSlider = activeWordDropdown.querySelector("[data-caption-active-scale]"); - this.scaleValue = activeWordDropdown.querySelector("[data-caption-active-scale-value]"); - fragment.appendChild(activeWordDropdown); fragment.appendChild(sourceDropdown); @@ -511,7 +484,6 @@ export class RichCaptionToolbar extends RichTextToolbar { this.wireWordAnimControls(wordAnimDropdown); this.wireActiveWordTabs(); this.wireActiveTextDecorationControls(); - this.wireScaleControl(); this.wireActiveColorControls(); this.wireActiveStrokeControls(); this.wireActiveShadowControls(); @@ -586,7 +558,7 @@ export class RichCaptionToolbar extends RichTextToolbar { const style = btn.dataset["captionWordStyle"]; if (!style) return; const asset = this.getCaptionAsset(); - this.updateClipProperty({ wordAnimation: { ...(asset?.wordAnimation ?? {}), style } }); + this.updateClipProperty({ animation: { ...(asset?.animation ?? {}), style } }); }); }); @@ -596,7 +568,7 @@ export class RichCaptionToolbar extends RichTextToolbar { const direction = btn.dataset["captionWordDirection"]; if (!direction) return; const asset = this.getCaptionAsset(); - this.updateClipProperty({ wordAnimation: { style: "slide" as const, ...(asset?.wordAnimation ?? {}), direction } }); + this.updateClipProperty({ animation: { style: "slide" as const, ...(asset?.animation ?? {}), direction } }); }); }); } @@ -637,18 +609,6 @@ export class RichCaptionToolbar extends RichTextToolbar { }); } - // ─── Scale Wiring (active mode) ─────────────────────────────────── - - private wireScaleControl(): void { - this.scaleSlider?.addEventListener("input", e => { - const value = parseFloat((e.target as HTMLInputElement).value); - this.currentActiveScale = value; - if (this.scaleValue) this.scaleValue.textContent = `${value.toFixed(1)}x`; - const asset = this.getCaptionAsset(); - this.updateClipProperty({ active: { ...(asset?.active ?? {}), scale: value } }); - }); - } - // ─── Active Color Wiring ────────────────────────────────────────── private wireActiveColorControls(): void { diff --git a/tests/rich-caption-player.test.ts b/tests/rich-caption-player.test.ts index fffc2b7c..3b5e784d 100644 --- a/tests/rich-caption-player.test.ts +++ b/tests/rich-caption-player.test.ts @@ -520,7 +520,7 @@ describe("RichCaptionPlayer", () => { it("renders karaoke animation on every update", async () => { const asset = createAsset({ - wordAnimation: { style: "karaoke", speed: 1, direction: "up" } + animation: { style: "karaoke", speed: 1, direction: "up" } } as Partial); const edit = createMockEdit(); const player = new RichCaptionPlayer(edit, createClip(asset)); diff --git a/tests/rich-caption-toolbar.test.ts b/tests/rich-caption-toolbar.test.ts index 6a5eedfc..8f4225b9 100644 --- a/tests/rich-caption-toolbar.test.ts +++ b/tests/rich-caption-toolbar.test.ts @@ -134,7 +134,7 @@ function createMockEdit(overrides: Record = {}) { function createCaptionAsset(overrides: Record = {}) { return { type: "rich-caption", - wordAnimation: { style: "karaoke", direction: "up" }, + animation: { style: "karaoke", direction: "up" }, active: { font: { color: "#ffff00", opacity: 1 }, stroke: { width: 2, color: "#000000", opacity: 1 } @@ -281,7 +281,7 @@ describe("RichCaptionToolbar", () => { describe("syncState", () => { it("should sync word animation style buttons", () => { - setupCaptionClip(mockEdit, { wordAnimation: { style: "pop" } }); + setupCaptionClip(mockEdit, { animation: { style: "pop" } }); toolbar.mount(container); toolbar.show(0, 0); @@ -293,7 +293,7 @@ describe("RichCaptionToolbar", () => { }); it("should show direction section only for 'slide' animation", () => { - setupCaptionClip(mockEdit, { wordAnimation: { style: "slide", direction: "left" } }); + setupCaptionClip(mockEdit, { animation: { style: "slide", direction: "left" } }); toolbar.mount(container); toolbar.show(0, 0); @@ -302,7 +302,7 @@ describe("RichCaptionToolbar", () => { }); it("should hide direction section for non-slide animations", () => { - setupCaptionClip(mockEdit, { wordAnimation: { style: "karaoke" } }); + setupCaptionClip(mockEdit, { animation: { style: "karaoke" } }); toolbar.mount(container); toolbar.show(0, 0); @@ -346,26 +346,6 @@ describe("RichCaptionToolbar", () => { expect(opacitySlider?.value).toBe("75"); }); - it("should show scale section when word animation is 'pop'", () => { - setupCaptionClip(mockEdit, { wordAnimation: { style: "pop" }, active: { scale: 1.5 } }); - toolbar.mount(container); - toolbar.show(0, 0); - - const scaleSection = container.querySelector("[data-caption-scale-section]") as HTMLElement; - expect(scaleSection?.style.display).toBe(""); - - const scaleSlider = container.querySelector("[data-caption-active-scale]") as HTMLInputElement; - expect(scaleSlider?.value).toBe("1.5"); - }); - - it("should hide scale section when word animation is not 'pop'", () => { - setupCaptionClip(mockEdit, { wordAnimation: { style: "karaoke" } }); - toolbar.mount(container); - toolbar.show(0, 0); - - const scaleSection = container.querySelector("[data-caption-scale-section]") as HTMLElement; - expect(scaleSection?.style.display).toBe("none"); - }); }); // ── User Interactions ────────────────────────────────────────────── @@ -386,14 +366,14 @@ describe("RichCaptionToolbar", () => { 0, 0, expect.objectContaining({ asset: expect.objectContaining({ - wordAnimation: expect.objectContaining({ style: "pop" }) + animation: expect.objectContaining({ style: "pop" }) }) }) ); }); it("should call updateClip when direction button is clicked", () => { - setupCaptionClip(mockEdit, { wordAnimation: { style: "slide", direction: "up" } }); + setupCaptionClip(mockEdit, { animation: { style: "slide", direction: "up" } }); toolbar.mount(container); toolbar.show(0, 0); @@ -404,7 +384,7 @@ describe("RichCaptionToolbar", () => { 0, 0, expect.objectContaining({ asset: expect.objectContaining({ - wordAnimation: expect.objectContaining({ direction: "left" }) + animation: expect.objectContaining({ direction: "left" }) }) }) ); @@ -473,23 +453,6 @@ describe("RichCaptionToolbar", () => { ); }); - it("should call updateClip when scale slider changes", () => { - setupCaptionClip(mockEdit, { wordAnimation: { style: "pop" }, active: { scale: 1 } }); - toolbar.mount(container); - toolbar.show(0, 0); - - const slider = container.querySelector("[data-caption-active-scale]") as HTMLInputElement; - simulateInput(slider, "1.5"); - - expect(mockEdit.updateClip).toHaveBeenCalledWith( - 0, 0, - expect.objectContaining({ - asset: expect.objectContaining({ - active: expect.objectContaining({ scale: 1.5 }) - }) - }) - ); - }); }); // ── Source Popup ──────────────────────────────────────────────────