From 586fba7c77f456454fd253c3daa6aa7e038ab11f Mon Sep 17 00:00:00 2001 From: Tim Bradgate Date: Wed, 13 May 2026 18:38:48 +0100 Subject: [PATCH 1/4] Add tagged stage directions (#1030) * Add tagged stage directions (issue #660) Stage directions can now be optionally tagged to a character or character group. When tagged, a character heading appears above the stage direction in config and live views (both full and compact layouts), matching the heading behaviour of dialogue lines. Backend: StageDirectionValidator relaxed from rejecting any character/group assignment to allowing one-or-neither (mutual exclusion only), mirroring the DialogueValidator pattern. Tests updated and extended. Frontend: ScriptLinePart shows character/group dropdowns for stage directions; the SD style selector moves onto the same row. Navigation mixin skips only untagged stage directions when looking back for heading context. Both ScriptLineViewer components (config + live) render the character heading with a 1rem bottom gap before the stage direction text. ScriptLineViewerCompact displays the character name in the left column for tagged stage directions. Co-Authored-By: Claude Sonnet 4.6 * Reduce template duplication in tagged stage direction viewers Extract taggedStageDirectionHeadingName computed to both ScriptLineViewer components, replacing the repeated character/group v-if/v-else name lookup block with a single interpolation. Extract characterOrGroupName() method to ScriptLineViewerCompact to unify the same lookup used in the dialogue and stage direction character columns. Co-Authored-By: Claude Sonnet 4.6 * Move isTaggedStageDirection/taggedStageDirectionHeadingName/characterOrGroupName to scriptDisplayMixin Eliminates cross-file duplication flagged by SonarQube by centralising the three shared computed properties and method in the mixin, which is already imported by all three viewer components. Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Claude Sonnet 4.6 --- client/src/mixins/scriptDisplayMixin.ts | 21 +++++++++ client/src/mixins/scriptNavigationMixin.ts | 9 +++- .../show/config/script/ScriptLineEditor.vue | 22 ++++----- .../show/config/script/ScriptLinePart.vue | 34 +++++++++++++- .../show/config/script/ScriptLineViewer.vue | 12 ++++- .../show/live/ScriptLineViewer.vue | 10 ++++ .../show/live/ScriptLineViewerCompact.vue | 13 ++--- docs/pages/script_config.md | 13 ++++- .../api/show/script/test_script.py | 47 +++++++++++++++++-- .../utils/show/test_line_type_validator.py | 39 +++++++++++---- server/utils/show/line_type_validator.py | 10 ++-- 11 files changed, 185 insertions(+), 45 deletions(-) diff --git a/client/src/mixins/scriptDisplayMixin.ts b/client/src/mixins/scriptDisplayMixin.ts index 4b9c0b1d4..f2ad3f92a 100644 --- a/client/src/mixins/scriptDisplayMixin.ts +++ b/client/src/mixins/scriptDisplayMixin.ts @@ -96,6 +96,18 @@ export default defineComponent({ needsHeadingsAll(): boolean { return (this as any).needsHeadings.every((x: boolean) => x === true); }, + isTaggedStageDirection(): boolean { + const line: ScriptLine = (this as any).line; + const part = line.line_parts?.[0]; + return ( + line.line_type === LINE_TYPES.STAGE_DIRECTION && + (part?.character_id != null || part?.character_group_id != null) + ); + }, + taggedStageDirectionHeadingName(): string { + const part = ((this as any).line as ScriptLine).line_parts?.[0]; + return part ? (this as any).characterOrGroupName(part) : ''; + }, ...mapGetters(['USER_SETTINGS']), }, mounted() { @@ -122,6 +134,15 @@ export default defineComponent({ } }, methods: { + characterOrGroupName(part: any): string { + if (part.character_id != null) { + return (this as any).characters?.find((c: any) => c.id === part.character_id)?.name ?? ''; + } + return ( + (this as any).characterGroups?.find((c: any) => c.id === part.character_group_id)?.name ?? + '' + ); + }, onClassChange(classAttrValue: string | null, oldClassAttrValue: string | null): void { const classList = classAttrValue?.split(' ') ?? []; const oldClassList = oldClassAttrValue?.split(' ') ?? []; diff --git a/client/src/mixins/scriptNavigationMixin.ts b/client/src/mixins/scriptNavigationMixin.ts index 618c39403..37c120851 100644 --- a/client/src/mixins/scriptNavigationMixin.ts +++ b/client/src/mixins/scriptNavigationMixin.ts @@ -11,7 +11,7 @@ export default defineComponent({ let lineIndex: number | null = (this as any).previousLineIndex; while ( previousLine != null && - (previousLine.line_type === LINE_TYPES.STAGE_DIRECTION || this.isWholeLineCut(previousLine)) + (this.checkIsUntaggedStageDirection(previousLine) || this.isWholeLineCut(previousLine)) ) { [lineIndex, previousLine] = this.getPreviousLineForIndex(previousLine.page, lineIndex!); } @@ -61,6 +61,13 @@ export default defineComponent({ } return [null, null]; }, + checkIsUntaggedStageDirection(line: ScriptLine): boolean { + return ( + line.line_type === LINE_TYPES.STAGE_DIRECTION && + line.line_parts[0]?.character_id == null && + line.line_parts[0]?.character_group_id == null + ); + }, isWholeLineCut(line: ScriptLine): boolean { return isWholeLineCutUtil(line, (this as any).SCRIPT_CUTS); }, diff --git a/client/src/vue_components/show/config/script/ScriptLineEditor.vue b/client/src/vue_components/show/config/script/ScriptLineEditor.vue index e01df5bd0..d879a627d 100644 --- a/client/src/vue_components/show/config/script/ScriptLineEditor.vue +++ b/client/src/vue_components/show/config/script/ScriptLineEditor.vue @@ -55,23 +55,15 @@ :enable-add-button="state.line_parts.length < 4 && lineType === LINE_TYPES.DIALOGUE" :line-type="lineType" :line-parts="state.line_parts" + :stage-direction-styles=" + lineType === LINE_TYPES.STAGE_DIRECTION ? stageDirectionStyles : [] + " + :stage-direction-style-id="state.stage_direction_style_id" @input="stateChange" @addLinePart="addLinePart" @tryFinishLine="tryFinishLine" + @stage-direction-style-change="onStageDirectionStyleChange" /> - - - @@ -402,6 +394,10 @@ export default defineComponent({ } this.stateChange(); }, + onStageDirectionStyleChange(styleId: number | null): void { + (this as any).state.stage_direction_style_id = styleId; + this.stateChange(); + }, deleteLine(): void { this.$emit('deleteLine'); }, diff --git a/client/src/vue_components/show/config/script/ScriptLinePart.vue b/client/src/vue_components/show/config/script/ScriptLinePart.vue index 9d804da58..71ed73156 100644 --- a/client/src/vue_components/show/config/script/ScriptLinePart.vue +++ b/client/src/vue_components/show/config/script/ScriptLinePart.vue @@ -1,6 +1,6 @@