From 609e527e6ec70eeaabd4d5b64ba3d226a86b343e Mon Sep 17 00:00:00 2001 From: Tim Bradgate Date: Sat, 20 Jun 2026 00:30:32 +0100 Subject: [PATCH 1/7] Add bulk character/group assignment to script bulk edit mode (#1224) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends the existing bulk act/scene modal into a full BulkEditModal supporting character and character group assignment across a line range. Users can now select a target part index (1–4) and assign a character or character group to that part for all lines in the bulk range; lines without the targeted part are silently skipped. Both act/scene and character assignment sections are always visible and independently applied based on whether their values are filled in. Implemented in both client-v3 and client (Vue 2 legacy) with parity. Co-authored-by: Claude Sonnet 4.6 --- .../e2e/tests/10-show-config-script.spec.ts | 91 +++++- .../show/config/script/BulkEditModal.vue | 247 +++++++++++++++++ .../show/config/script/ScriptEditor.vue | 44 ++- .../show/config/script/BulkEditModal.vue | 262 ++++++++++++++++++ .../show/config/script/ScriptEditor.vue | 54 +++- 5 files changed, 674 insertions(+), 24 deletions(-) create mode 100644 client-v3/src/components/show/config/script/BulkEditModal.vue create mode 100644 client/src/vue_components/show/config/script/BulkEditModal.vue diff --git a/client-v3/e2e/tests/10-show-config-script.spec.ts b/client-v3/e2e/tests/10-show-config-script.spec.ts index f942e51fa..068f8b80e 100644 --- a/client-v3/e2e/tests/10-show-config-script.spec.ts +++ b/client-v3/e2e/tests/10-show-config-script.spec.ts @@ -97,11 +97,31 @@ test('saves a dialogue line to the script', async () => { await lineEditor.locator('select').nth(0).selectOption({ label: 'Act 1' }); await lineEditor.locator('select').nth(1).selectOption({ label: 'Scene 1' }); - await page.locator('select').filter({ hasText: 'Hamlet' }).selectOption({ label: 'Hamlet' }); + await lineEditor + .locator('select') + .filter({ hasText: 'Hamlet' }) + .selectOption({ label: 'Hamlet' }); await page.locator('input[type="text"]:visible').last().fill('To be or not to be'); await page.locator('button:has-text("Done")').first().click(); - // ScriptEditor auto-adds a blank dialogue line after Done — delete it before asserting + // ScriptEditor auto-adds a blank dialogue editor after Done — fill it in as a second line so + // the bulk edit tests below can use a two-line range without needing the split dropdown. + await expect(page.locator('button:has-text("Done")').first()).toBeVisible({ timeout: 5_000 }); + + const lineEditor2 = page + .locator('div.row') + .filter({ has: page.locator('button:has-text("Done")') }) + .first(); + await lineEditor2.locator('select').nth(0).selectOption({ label: 'Act 1' }); + await lineEditor2.locator('select').nth(1).selectOption({ label: 'Scene 1' }); + await lineEditor2 + .locator('select') + .filter({ hasText: 'Hamlet' }) + .selectOption({ label: 'Hamlet' }); + await page.locator('input[type="text"]:visible').last().fill('That is the question'); + + await page.locator('button:has-text("Done")').first().click(); + // Delete the third auto-added blank editor await page.locator('button.btn-danger:has-text("Delete")').first().click(); await expect(page.locator('button:has-text("Done")')).not.toBeVisible({ timeout: 5_000 }); @@ -118,6 +138,59 @@ test('saves a dialogue line to the script', async () => { await expect( page.locator('.viewable-line').filter({ hasText: 'To be or not to be' }) ).toBeVisible({ timeout: 10_000 }); + await expect( + page.locator('.viewable-line').filter({ hasText: 'That is the question' }) + ).toBeVisible({ timeout: 10_000 }); +}); + +// ── Bulk edit ───────────────────────────────────────────────────────────── + +test('bulk edit mode is accessible and shows Start/End buttons', async () => { + // Still in edit mode from the save test, which saved two viewable dialogue lines. + await page.click('button:has-text("Bulk Edit")'); + // Two ScriptLineViewer rows → two Start/End button pairs + await expect(page.getByRole('button', { name: 'Start', exact: true }).first()).toBeVisible({ + timeout: 5_000, + }); + await expect(page.getByRole('button', { name: 'End', exact: true }).first()).toBeVisible({ + timeout: 5_000, + }); +}); + +test('bulk edit opens the Bulk Edit modal when start and end span two different lines', async () => { + // Start on the first line, End on the last line (different indices → valid range) + await page.getByRole('button', { name: 'Start', exact: true }).first().click(); + await page.getByRole('button', { name: 'End', exact: true }).last().click(); + await waitForModal(page, 'Bulk Edit'); +}); + +test('bulk edit can assign a character to part 1', async () => { + await page.locator('.modal.show #bulk-part-input').selectOption({ label: 'Part 1' }); + // Select Alice — a different character from Hamlet (who is already assigned) so the apply + // produces a real change that scriptChanges can detect via deep equality. + await page.locator('.modal.show #bulk-char-input').selectOption({ label: 'Alice' }); + await confirmModal(page); + await waitForModalClosed(page); + // After apply, bulk edit mode exits automatically + await expect(page.getByRole('button', { name: 'Bulk Edit', exact: true })).toBeVisible({ + timeout: 5_000, + }); +}); + +test('bulk edit stops when Exit Bulk Edit is clicked', async () => { + await page.click('button:has-text("Bulk Edit")'); + await expect(page.getByRole('button', { name: 'Start', exact: true }).first()).toBeVisible({ + timeout: 5_000, + }); + await page.click('button:has-text("Exit Bulk Edit")'); + await expect(page.getByRole('button', { name: 'Start', exact: true })).not.toBeVisible({ + timeout: 3_000, + }); + await page.click('button:has-text("Stop Editing")'); + await confirmDialog(page); + await expect(page.getByRole('button', { name: 'Edit', exact: true })).toBeVisible({ + timeout: 10_000, + }); }); // ── Cut mode ────────────────────────────────────────────────────────────── @@ -232,7 +305,7 @@ test('adds a cue to the script line', async () => { // Scope to the visible modal's select to avoid matching the hidden "Add Cue Type" modal // dialog which BVN assigns id="new-cue-type" via its auto-ID scheme. await page.locator('.modal.show select#new-cue-type').selectOption({ index: 1 }); - await page.fill('#new-cue-ident', '001'); + await page.locator('.modal.show #new-cue-ident').fill('001'); await confirmModal(page); await waitForModalClosed(page); // Wait for the actual cue button (not the add-cue-btn which shares the cue-button class) @@ -246,7 +319,7 @@ test('edits the cue identifier', async () => { // Use :not(.add-cue-btn) to target the real cue button, not the add button await page.locator('.cue-button:not(.add-cue-btn)').first().click(); await waitForModal(page, 'Edit Cue'); - await page.fill('#edit-cue-ident', '002'); + await page.locator('.modal.show #edit-cue-ident').fill('002'); await confirmModal(page); await waitForModalClosed(page); await expect(page.locator('.cue-button:not(.add-cue-btn)').first()).toBeVisible({ @@ -258,9 +331,11 @@ test('can add a cue using Enter key in Add New Cue modal', async () => { await page.locator('.add-cue-btn').first().click(); await waitForModal(page, 'Add New Cue'); await page.locator('.modal.show select#new-cue-type').selectOption({ index: 1 }); - await page.fill('#new-cue-ident', '003'); + await page.locator('.modal.show #new-cue-ident').fill('003'); // Enter key submits the form (fix: BForm @submit bound to onSubmitNew) - await page.locator('#new-cue-ident').press('Enter'); + // Scope to .modal.show to avoid strict-mode violation from other Add New Cue modal instances + // in the DOM (one per viewable script line — BVN v-show keeps them all present). + await page.locator('.modal.show #new-cue-ident').press('Enter'); await waitForModalClosed(page); await expect(page.locator('.cue-button:not(.add-cue-btn)')).toHaveCount(2, { timeout: 5_000 }); }); @@ -269,9 +344,9 @@ test('can edit a cue identifier using Enter key in Edit Cue modal', async () => // Click the second cue button (003) await page.locator('.cue-button:not(.add-cue-btn)').last().click(); await waitForModal(page, 'Edit Cue'); - await page.fill('#edit-cue-ident', '004'); + await page.locator('.modal.show #edit-cue-ident').fill('004'); // Enter key submits the form (fix: BForm @submit bound to onSubmitEdit) - await page.locator('#edit-cue-ident').press('Enter'); + await page.locator('.modal.show #edit-cue-ident').press('Enter'); await waitForModalClosed(page); await expect(page.locator('.cue-button:not(.add-cue-btn)')).toHaveCount(2, { timeout: 5_000 }); }); diff --git a/client-v3/src/components/show/config/script/BulkEditModal.vue b/client-v3/src/components/show/config/script/BulkEditModal.vue new file mode 100644 index 000000000..1e47215ab --- /dev/null +++ b/client-v3/src/components/show/config/script/BulkEditModal.vue @@ -0,0 +1,247 @@ + + + diff --git a/client-v3/src/components/show/config/script/ScriptEditor.vue b/client-v3/src/components/show/config/script/ScriptEditor.vue index ce862e05c..88a28ef7e 100644 --- a/client-v3/src/components/show/config/script/ScriptEditor.vue +++ b/client-v3/src/components/show/config/script/ScriptEditor.vue @@ -161,12 +161,14 @@ /> - @@ -211,7 +213,7 @@ import { useConfirm } from '@/composables/useConfirm'; import { toast } from '@/js/toast'; import ScriptLineEditor from './ScriptLineEditor.vue'; import ScriptLineViewer from './ScriptLineViewer.vue'; -import BulkActSceneModal from './BulkActSceneModal.vue'; +import BulkEditModal from './BulkEditModal.vue'; import type { ScriptLine } from '@/types/api/script'; const MRU_LOOK_BACK = 4; @@ -247,7 +249,7 @@ const pageInputNo = ref(1); const saveModal = ref>(); const goToPageModal = ref>(); -const bulkModal = ref>(); +const bulkModal = ref>(); const currentPageLines = computed(() => scriptConfigStore.getTmpPage(currentPage.value)); const deletedLines = computed(() => scriptConfigStore.getDeletedLines(currentPage.value)); @@ -712,11 +714,22 @@ function endLines_isLast(lines: ScriptLine[], index: number): boolean { return index === lines.length - 1; } -async function onBulkApply({ actId, sceneId }: { actId: number; sceneId: number }): Promise { +async function onBulkApply(payload: { + actId: number | null; + sceneId: number | null; + partIndex: number | null; + characterId: number | null; + characterGroupId: number | null; +}): Promise { if (!bulkEditStart.value || !bulkEditEnd.value) return; const { page: startPage, lineIndex: startIndex } = bulkEditStart.value; const { page: endPage, lineIndex: endIndex } = bulkEditEnd.value; + const applyActScene = payload.actId != null && payload.sceneId != null; + const applyCharacter = + payload.partIndex != null && (payload.characterId != null || payload.characterGroupId != null); + const targetPartIdx = (payload.partIndex ?? 1) - 1; + for (let p = startPage; p <= endPage; p++) { await loadPage(p); const pageLines = scriptConfigStore.getTmpPage(p); @@ -725,7 +738,28 @@ async function onBulkApply({ actId, sceneId }: { actId: number; sceneId: number const deletedOnPage = scriptConfigStore.getDeletedLines(p); for (let i = fromIdx; i <= toIdx; i++) { if (deletedOnPage.includes(i)) continue; - scriptConfigStore.setLine(p, i, { ...pageLines[i], act_id: actId, scene_id: sceneId }); + const updatedLine = { ...pageLines[i] }; + if (applyActScene) { + updatedLine.act_id = payload.actId!; + updatedLine.scene_id = payload.sceneId!; + } + if (applyCharacter) { + const sortedParts = [...updatedLine.line_parts].sort( + (a, b) => (a.part_index ?? 0) - (b.part_index ?? 0) + ); + if (sortedParts.length > targetPartIdx) { + updatedLine.line_parts = sortedParts.map((part, idx) => + idx === targetPartIdx + ? { + ...part, + character_id: payload.characterId, + character_group_id: payload.characterGroupId, + } + : part + ); + } + } + scriptConfigStore.setLine(p, i, updatedLine); } } diff --git a/client/src/vue_components/show/config/script/BulkEditModal.vue b/client/src/vue_components/show/config/script/BulkEditModal.vue new file mode 100644 index 000000000..e5d96fcb1 --- /dev/null +++ b/client/src/vue_components/show/config/script/BulkEditModal.vue @@ -0,0 +1,262 @@ + + + diff --git a/client/src/vue_components/show/config/script/ScriptEditor.vue b/client/src/vue_components/show/config/script/ScriptEditor.vue index 9945b5ab7..740d9b1b5 100644 --- a/client/src/vue_components/show/config/script/ScriptEditor.vue +++ b/client/src/vue_components/show/config/script/ScriptEditor.vue @@ -167,11 +167,13 @@ /> - { if (val != null) { await this.loadBoundaryLines(); - (this as any).$bvModal.show('bulk-act-scene-modal'); + (this as any).$bvModal.show('bulk-edit-modal'); } }, USER_SETTINGS(): void { @@ -1045,10 +1047,22 @@ export default defineComponent({ this.nextLineOfEnd = nextPage ? nextPage[0] : null; } }, - async onBulkApply({ actId, sceneId }: { actId: number; sceneId: number }): Promise { + async onBulkApply(payload: { + actId: number | null; + sceneId: number | null; + partIndex: number | null; + characterId: number | null; + characterGroupId: number | null; + }): Promise { const { page: startPage, lineIndex: startIndex } = this.bulkEditStart!; const { page: endPage, lineIndex: endIndex } = this.bulkEditEnd!; + const applyActScene = payload.actId != null && payload.sceneId != null; + const applyCharacter = + payload.partIndex != null && + (payload.characterId != null || payload.characterGroupId != null); + const targetPartIdx = (payload.partIndex ?? 1) - 1; + for (let p = startPage; p <= endPage; p++) { await (this as any).LOAD_SCRIPT_PAGE(p); } @@ -1060,15 +1074,33 @@ export default defineComponent({ const toIndex = p === endPage ? endIndex : pageLines.length - 1; for (let i = fromIndex; i <= toIndex; i++) { if ((this as any).DELETED_LINES(p).includes(i)) continue; - (this as any).SET_LINE({ - pageNo: p, - lineIndex: i, - lineObj: { ...pageLines[i], act_id: actId, scene_id: sceneId }, - }); + const line = pageLines[i]; + const updatedLine: any = { ...line }; + if (applyActScene) { + updatedLine.act_id = payload.actId; + updatedLine.scene_id = payload.sceneId; + } + if (applyCharacter) { + const sortedParts = [...(line.line_parts ?? [])].sort( + (a: any, b: any) => (a.part_index ?? 0) - (b.part_index ?? 0) + ); + if (sortedParts.length > targetPartIdx) { + updatedLine.line_parts = sortedParts.map((part: any, idx: number) => + idx === targetPartIdx + ? { + ...part, + character_id: payload.characterId, + character_group_id: payload.characterGroupId, + } + : part + ); + } + } + (this as any).SET_LINE({ pageNo: p, lineIndex: i, lineObj: updatedLine }); } } - (this as any).$bvModal.hide('bulk-act-scene-modal'); + (this as any).$bvModal.hide('bulk-edit-modal'); this.exitBulkEditMode(); }, ...mapMutations([ From efa6c4f144bea1aff96dfdac6da5beb96630fdfb Mon Sep 17 00:00:00 2001 From: Tim Bradgate Date: Sat, 20 Jun 2026 01:03:08 +0100 Subject: [PATCH 2/7] Add ability to delete individual line parts from the script editor (#1225) * Add ability to delete individual line parts from the script editor Users previously had to delete and re-add an entire line to remove a single part. A remove button now appears in the character row of each ScriptLinePart when the line has more than one part, and disappears when only one part remains. Implemented in both client-v3 and client with E2E coverage added to the script spec. Co-Authored-By: Claude Sonnet 4.6 * Fix vertical alignment of remove line part button Replace BFormGroup label=" " wrapper with align-self-end mb-3 on the column directly, which is the standard Bootstrap way to align a button with adjacent form controls without adding a dummy label. Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Claude Sonnet 4.6 --- .../e2e/tests/10-show-config-script.spec.ts | 76 +++++++++++++++++++ .../show/config/script/ScriptLineEditor.vue | 9 +++ .../show/config/script/ScriptLinePart.vue | 12 +++ .../show/config/script/ScriptLineEditor.vue | 9 +++ .../show/config/script/ScriptLinePart.vue | 17 +++++ 5 files changed, 123 insertions(+) diff --git a/client-v3/e2e/tests/10-show-config-script.spec.ts b/client-v3/e2e/tests/10-show-config-script.spec.ts index 068f8b80e..d1a4c1b4f 100644 --- a/client-v3/e2e/tests/10-show-config-script.spec.ts +++ b/client-v3/e2e/tests/10-show-config-script.spec.ts @@ -393,3 +393,79 @@ test('deletes the cue', async () => { // After deletion only the add-cue-btn remains; no actual cue buttons should exist await expect(page.locator('.cue-button:not(.add-cue-btn)')).not.toBeVisible({ timeout: 5_000 }); }); + +// ── Line part removal ────────────────────────────────────────────────────── + +test('remove button is absent on a single-part dialogue line', async () => { + await page.goto(`${UI_BASE}/show-config/script`); + await waitForAppReady(page); + await page.getByRole('button', { name: 'Edit', exact: true }).click(); + await expect(page.locator('button:has-text("Stop Editing")')).toBeVisible({ timeout: 10_000 }); + + await page.click('button:has-text("Add Dialogue")'); + const lineEditor = page + .locator('div.row') + .filter({ has: page.locator('button:has-text("Done")') }) + .first(); + await expect(lineEditor.locator('button:has-text("Done")')).toBeVisible({ timeout: 5_000 }); + + // With only 1 part no remove button should be present + await expect(lineEditor.locator('button.btn-outline-danger')).not.toBeVisible(); +}); + +test('remove button appears when a second part is added', async () => { + const lineEditor = page + .locator('div.row') + .filter({ has: page.locator('button:has-text("Done")') }) + .first(); + + // btn-secondary is the add-line-part (+) button — only btn-secondary in the editor row + await lineEditor.locator('button.btn-secondary').click(); + + // One remove button per part + await expect(lineEditor.locator('button.btn-outline-danger')).toHaveCount(2, { timeout: 3_000 }); +}); + +test('clicking remove reduces to single part and hides the remove button', async () => { + const lineEditor = page + .locator('div.row') + .filter({ has: page.locator('button:has-text("Done")') }) + .first(); + + await lineEditor.locator('button.btn-outline-danger').first().click(); + + // Back to 1 part — remove button gone + await expect(lineEditor.locator('button.btn-outline-danger')).not.toBeVisible({ timeout: 3_000 }); +}); + +test('dialogue line with removed part saves correctly', async () => { + const lineEditor = page + .locator('div.row') + .filter({ has: page.locator('button:has-text("Done")') }) + .first(); + + await lineEditor.locator('select').nth(0).selectOption({ label: 'Act 1' }); + await lineEditor.locator('select').nth(1).selectOption({ label: 'Scene 1' }); + await lineEditor + .locator('select') + .filter({ hasText: 'Hamlet' }) + .selectOption({ label: 'Hamlet' }); + await lineEditor.locator('input[type="text"]').last().fill('Surviving part text'); + + await lineEditor.locator('button:has-text("Done")').click(); + // Delete the auto-added blank editor that appears after Done + await page.locator('button.btn-danger:has-text("Delete")').first().click(); + await expect(page.locator('button:has-text("Done")')).not.toBeVisible({ timeout: 5_000 }); + + await page.getByRole('button', { name: 'Save', exact: true }).click(); + await waitForModal(page, 'Saving Script'); + await expect(page.locator('.modal.show').getByText('Finished saving script.')).toBeVisible({ + timeout: 15_000, + }); + await confirmModal(page); + await waitForModalClosed(page); + + await expect( + page.locator('.viewable-line').filter({ hasText: 'Surviving part text' }) + ).toBeVisible({ timeout: 10_000 }); +}); diff --git a/client-v3/src/components/show/config/script/ScriptLineEditor.vue b/client-v3/src/components/show/config/script/ScriptLineEditor.vue index af36a7ec6..f6eea1103 100644 --- a/client-v3/src/components/show/config/script/ScriptLineEditor.vue +++ b/client-v3/src/components/show/config/script/ScriptLineEditor.vue @@ -50,6 +50,7 @@ scriptMode === 1 " :enable-add-button="state.line_parts.length < 4 && lineType === LINE_TYPES.DIALOGUE" + :show-remove-button="state.line_parts.length > 1 && lineType === LINE_TYPES.DIALOGUE" :line-type="lineType" :line-parts="state.line_parts" :stage-direction-styles=" @@ -58,6 +59,7 @@ :stage-direction-style-id="state.stage_direction_style_id" @update:model-value="onPartUpdate(index, $event)" @add-line-part="addLinePart" + @remove-line-part="removeLinePart(index)" @try-finish-line="tryFinishLine" @stage-direction-style-change="onStageDirectionStyleChange" /> @@ -210,6 +212,13 @@ function onPartUpdate(index: number, part: ScriptLinePartType): void { onStateChange(); } +function removeLinePart(index: number): void { + state.value.line_parts = state.value.line_parts + .filter((_, i) => i !== index) + .map((part, i) => ({ ...part, part_index: i })); + onStateChange(); +} + function addLinePart(): void { const blank = blankLinePart(); blank.part_index = state.value.line_parts.length; diff --git a/client-v3/src/components/show/config/script/ScriptLinePart.vue b/client-v3/src/components/show/config/script/ScriptLinePart.vue index aeb88a22f..b5c99d42d 100644 --- a/client-v3/src/components/show/config/script/ScriptLinePart.vue +++ b/client-v3/src/components/show/config/script/ScriptLinePart.vue @@ -47,6 +47,16 @@ /> + + + + + @@ -94,6 +104,7 @@ const props = defineProps<{ characterGroups: CharacterGroup[]; showAddButton: boolean; enableAddButton: boolean; + showRemoveButton: boolean; lineType: number; lineParts: ScriptLinePart[]; stageDirectionStyles?: StageDirectionStyle[]; @@ -104,6 +115,7 @@ const props = defineProps<{ const emit = defineEmits<{ 'update:modelValue': [part: ScriptLinePart]; 'add-line-part': []; + 'remove-line-part': []; 'try-finish-line': []; 'stage-direction-style-change': [id: number | null]; }>(); diff --git a/client/src/vue_components/show/config/script/ScriptLineEditor.vue b/client/src/vue_components/show/config/script/ScriptLineEditor.vue index d879a627d..fb66f979b 100644 --- a/client/src/vue_components/show/config/script/ScriptLineEditor.vue +++ b/client/src/vue_components/show/config/script/ScriptLineEditor.vue @@ -53,6 +53,7 @@ CURRENT_SHOW.script_mode === 1 " :enable-add-button="state.line_parts.length < 4 && lineType === LINE_TYPES.DIALOGUE" + :show-remove-button="state.line_parts.length > 1 && lineType === LINE_TYPES.DIALOGUE" :line-type="lineType" :line-parts="state.line_parts" :stage-direction-styles=" @@ -61,6 +62,7 @@ :stage-direction-style-id="state.stage_direction_style_id" @input="stateChange" @addLinePart="addLinePart" + @removeLinePart="removeLinePart(index)" @tryFinishLine="tryFinishLine" @stage-direction-style-change="onStageDirectionStyleChange" /> @@ -374,6 +376,13 @@ export default defineComponent({ (this as any).$v.state.$touch(); this.$emit('input', (this as any).state); }, + removeLinePart(index: number): void { + const state = (this as any).state; + state.line_parts = state.line_parts + .filter((_: any, i: number) => i !== index) + .map((part: any, i: number) => ({ ...part, part_index: i })); + this.stateChange(); + }, addLinePart(): void { const state = (this as any).state; const blankLine = JSON.parse(JSON.stringify(this.blankLinePartObj)); diff --git a/client/src/vue_components/show/config/script/ScriptLinePart.vue b/client/src/vue_components/show/config/script/ScriptLinePart.vue index 71ed73156..b79017212 100644 --- a/client/src/vue_components/show/config/script/ScriptLinePart.vue +++ b/client/src/vue_components/show/config/script/ScriptLinePart.vue @@ -69,6 +69,16 @@ /> + + + + + @@ -129,6 +139,10 @@ export default defineComponent({ required: true, type: Boolean, }, + showRemoveButton: { + required: true, + type: Boolean, + }, lineType: { required: true, type: Number, @@ -271,6 +285,9 @@ export default defineComponent({ addLinePart(): void { this.$emit('addLinePart'); }, + removeLinePart(): void { + this.$emit('removeLinePart'); + }, stateChange(): void { (this as any).$v.state.$touch(); this.$emit('input', (this as any).state); From 8c5993062f4faa7498dc23c3fbfca0d83108a6bd Mon Sep 17 00:00:00 2001 From: Tim Bradgate Date: Sat, 20 Jun 2026 21:30:51 +0100 Subject: [PATCH 3/7] Fix cut stage directions not showing strikethrough in cue editor (#1226) * Fix cut stage directions not showing strikethrough in cue editor Stage directions in the cue editor were missing strikethrough styling when cut, because the render path used the base sdStyling computed (no cut check). Replace it with sdStylingWithCuts, which appends line-through to the existing text-decoration-line value when the stage direction's first part ID is in cuts. Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Claude Sonnet 4.6 --- .../show/config/cues/ScriptLineCueEditor.vue | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/client-v3/src/components/show/config/cues/ScriptLineCueEditor.vue b/client-v3/src/components/show/config/cues/ScriptLineCueEditor.vue index 9c2853d87..7bf5c4e63 100644 --- a/client-v3/src/components/show/config/cues/ScriptLineCueEditor.vue +++ b/client-v3/src/components/show/config/cues/ScriptLineCueEditor.vue @@ -83,7 +83,7 @@