diff --git a/src/core/ui/rich-caption-toolbar.ts b/src/core/ui/rich-caption-toolbar.ts index b422057..21becd3 100644 --- a/src/core/ui/rich-caption-toolbar.ts +++ b/src/core/ui/rich-caption-toolbar.ts @@ -61,7 +61,12 @@ export class RichCaptionToolbar extends RichTextToolbar { } protected override createSpacingPanel(): SpacingPanel { - return new SpacingPanel({ showWordSpacing: true }); + // No word-spacing control: RichCaptionStyle deliberately excludes wordSpacing + return new SpacingPanel(); + } + + protected override supportsWordSpacing(): boolean { + return false; } protected override createFontColorPicker(): FontColorPicker { diff --git a/src/core/ui/rich-text-toolbar.ts b/src/core/ui/rich-text-toolbar.ts index 0b6ba5c..6577274 100644 --- a/src/core/ui/rich-text-toolbar.ts +++ b/src/core/ui/rich-text-toolbar.ts @@ -375,7 +375,7 @@ export class RichTextToolbar extends BaseToolbar { style: { ...(asset.style || {}), letterSpacing: state.letterSpacing, - wordSpacing: state.wordSpacing, + ...(this.supportsWordSpacing() ? { wordSpacing: state.wordSpacing } : {}), lineHeight: state.lineHeight } }; @@ -384,7 +384,11 @@ export class RichTextToolbar extends BaseToolbar { } else { // Discrete update (creates command) this.updateClipProperty({ - style: { letterSpacing: state.letterSpacing, wordSpacing: state.wordSpacing, lineHeight: state.lineHeight } + style: { + letterSpacing: state.letterSpacing, + ...(this.supportsWordSpacing() ? { wordSpacing: state.wordSpacing } : {}), + lineHeight: state.lineHeight + } }); } }); @@ -404,7 +408,9 @@ export class RichTextToolbar extends BaseToolbar { const finalClip = structuredClone(session.initialState); if (finalClip.asset && (finalClip.asset.type === "rich-text" || finalClip.asset.type === "rich-caption") && finalClip.asset.style) { finalClip.asset.style.letterSpacing = finalState.letterSpacing; - (finalClip.asset.style as Record)["wordSpacing"] = finalState.wordSpacing; + if (this.supportsWordSpacing()) { + (finalClip.asset.style as Record)["wordSpacing"] = finalState.wordSpacing; + } finalClip.asset.style.lineHeight = finalState.lineHeight; } @@ -943,6 +949,14 @@ export class RichTextToolbar extends BaseToolbar { return new SpacingPanel(); } + /** + * Whether this toolbar's asset type accepts `style.wordSpacing`. Rich-caption + * does not — its schema rejects the key, so it must never be written. + */ + protected supportsWordSpacing(): boolean { + return true; + } + protected createFontColorPicker(): FontColorPicker { return new FontColorPicker(); } diff --git a/tests/rich-caption-toolbar.test.ts b/tests/rich-caption-toolbar.test.ts index 98147c7..c529bb5 100644 --- a/tests/rich-caption-toolbar.test.ts +++ b/tests/rich-caption-toolbar.test.ts @@ -712,3 +712,71 @@ describe("RichCaptionToolbar", () => { }); }); }); + +// ============================================================================ +// Word spacing exclusion (RichCaptionStyle rejects the key — never render or write it) +// ============================================================================ + +describe("RichCaptionToolbar spacing controls", () => { + let toolbar: InstanceType; + let mockEdit: ReturnType; + let container: HTMLDivElement; + + beforeEach(async () => { + mockEdit = createMockEdit(); + const { RichCaptionToolbar } = await import("../src/core/ui/rich-caption-toolbar"); + toolbar = new RichCaptionToolbar(mockEdit as never); + container = createTestContainer(); + setupCaptionClip(mockEdit); + toolbar.mount(container); + }); + + afterEach(() => { + toolbar.dispose(); + cleanupTestContainer(container); + }); + + it("does not render a word-spacing slider", () => { + expect(container.querySelector("[data-word-spacing-slider]")).toBeNull(); + expect(container.querySelector("[data-letter-spacing-slider]")).not.toBeNull(); + }); + + it("omits wordSpacing from spacing updates", () => { + const slider = container.querySelector("[data-letter-spacing-slider]") as HTMLInputElement; + expect(slider).not.toBeNull(); + simulateInput(slider, 5); + + expect(mockEdit.updateClip).toHaveBeenCalled(); + const lastCall = mockEdit.updateClip.mock.calls.at(-1) as unknown[]; + const updates = lastCall[2] as { asset: { style: Record } }; + expect(updates.asset.style["letterSpacing"]).toBe(5); + expect(updates.asset.style).not.toHaveProperty("wordSpacing"); + }); +}); + +describe("RichTextToolbar spacing controls (contrast)", () => { + it("includes wordSpacing in spacing updates for rich-text", async () => { + const mockEdit = createMockEdit(); + const { RichTextToolbar } = await import("../src/core/ui/rich-text-toolbar"); + const toolbar = new RichTextToolbar(mockEdit as never); + const container = createTestContainer(); + + const asset = { type: "rich-text", text: "Hello", font: { family: "Open Sans", size: 48 } }; + const clip = { asset, start: 0, length: 5 }; + mockEdit.getPlayerClip.mockReturnValue({ clipConfiguration: clip, getMergeFieldBinding: jest.fn(() => null) } as never); + mockEdit.getResolvedClip.mockReturnValue(clip as never); + toolbar.mount(container); + + const slider = container.querySelector("[data-letter-spacing-slider]") as HTMLInputElement; + expect(slider).not.toBeNull(); + simulateInput(slider, 5); + + expect(mockEdit.updateClip).toHaveBeenCalled(); + const lastCall = mockEdit.updateClip.mock.calls.at(-1) as unknown[]; + const updates = lastCall[2] as { asset: { style: Record } }; + expect(updates.asset.style).toHaveProperty("wordSpacing"); + + toolbar.dispose(); + cleanupTestContainer(container); + }); +});