Skip to content

Commit aba317b

Browse files
committed
Add edit mode toggle with in-place title editing and score locking for concurrent editing
Edit mode: - Add edit-mode toggle button in arrangementPalette bottom-right corner - Decouple toggle via requisitions: button sends editModeChanged, App reacts with state change, allowing any component to toggle - Header auto-pins during edit mode (forceExpanded) - Hide pin button during editing (redundant while force-pinned) Title editing: - In-place editing: native input replaces label with identical styling - Right-aligned, auto-width via fit-content (grows with text) - Focus selects all text, Enter/Blur saves only when title differs - Escape reverts to original and selects all - Title changes now tracked in undo/redo history (previously ignored) - Arrangement scoreId persisted through snapshots for localStorage round-trips Score locking: - New score_locks table: score_id (PK, FK→scores), user_id, username, lock_token, locked_at with 30-minute expiry timeout - lockScore endpoint: auth + permission check, returns token or conflict (409 with username/lockedAt). Supports silent re-lock via prevToken - unlockScore: token-validated release - forceUnlockScore: admin-only override for stuck locks - handleUpdateScore validates optional lock token; rejects with 409 - Client acquires lock on edit-mode enter, releases on exit - Local-only arrangements skip locking (no DB scoreId) - Data model passes lockToken on save; 409 conflict surface as error Fixes: - Remove duplicate arrangement-title id: use separate instances with -collapsed suffix - Fix title editing corrupting arrangement after undo (stale state) - Remove placeholder from title input (Chrome native popup) - Remove data-tooltip=expand from arrangement title - Suppress browser context menu app-wide - Remove section comments from Requsitions.ts - Remove dead code: handleKeyUp, DisplayMode enum, editingTitle state Tests: - score-locks.spec.ts: 5 tests for getArrangementSnapshot scoreId inclusion and applyArrangementSnapshot scoreId restoration - UndoRedoStack.spec.ts: updated title change test to reflect new tracking behavior Signed-off-by: Mike Lischke <mike@lischke-online.de>
1 parent 831fa4a commit aba317b

16 files changed

Lines changed: 666 additions & 144 deletions

File tree

src/App.scss

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -192,17 +192,29 @@ h6 {
192192
height: 100%;
193193
}
194194

195-
#mainArrangementTitle {
195+
.main-arrangement-title {
196196
display: block;
197197
font-size: clamp(12pt, 20pt, 20pt);
198-
margin-right: 12px;
199198
white-space: nowrap;
200199
overflow: hidden;
201200
text-overflow: ellipsis;
202-
min-width: 0;
201+
202+
text-align: right;
203+
padding: 0;
204+
margin: 0 12px 2px 0;
205+
206+
max-width: 100%;
207+
min-width: 1ch;
208+
width: fit-content;
209+
}
210+
211+
.main-arrangement-title:focus {
212+
outline: 1px solid var(--color-primary);
213+
outline-offset: 1px;
214+
border-radius: 2px;
203215
}
204216

205-
.collapsed-header #mainArrangementTitle {
217+
.collapsed-header .main-arrangement-title {
206218
display: block;
207219
font-size: clamp(10pt, 16pt, 16pt);
208220
white-space: nowrap;
@@ -214,6 +226,7 @@ h6 {
214226
#arrangementPalette {
215227
flex: 1 1 auto;
216228
min-width: 0;
229+
padding: 4px;
217230
}
218231

219232
#editTitleButton {

src/App.tsx

Lines changed: 138 additions & 76 deletions
Large diffs are not rendered by default.

src/components/ui/Arrangement/ArrangementTitle.tsx

Lines changed: 53 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,14 @@ import { createRef, type ComponentChild } from "preact";
77

88
import type { ISbDmArrangement } from "../../../core/ScoreBookDataModel.js";
99
import type { UndoManager } from "../../../core/UndoManager.js";
10-
import { Input } from "../framework/Input.js";
10+
import { KeyboardKeys } from "../../../core/utils.js";
1111
import { Label } from "../framework/Label.js";
1212
import { UIComponent, type ICommonUIProperties } from "../framework/UIComponent.js";
1313

1414
export interface IArrangementTitleProperties extends ICommonUIProperties {
1515
arrangement: Readonly<ISbDmArrangement>;
1616
undoManager: UndoManager;
1717
editMode: boolean;
18-
onEditEnd: () => void;
1918
}
2019

2120
interface IArrangementTitleState {
@@ -35,10 +34,7 @@ export class ArrangementTitle extends UIComponent<IArrangementTitleProperties, I
3534
}
3635

3736
public override componentDidMount(): void {
38-
const { editMode, arrangement } = this.props;
39-
if (editMode) {
40-
this.inputRef.current?.focus();
41-
}
37+
const { arrangement } = this.props;
4238

4339
this.setState({ title: arrangement.title, inputValue: arrangement.title });
4440
}
@@ -53,30 +49,24 @@ export class ArrangementTitle extends UIComponent<IArrangementTitleProperties, I
5349
}
5450

5551
public override render(): ComponentChild {
56-
const { id, editMode } = this.props;
52+
const { id, className, style, editMode } = this.props;
5753
const { title, inputValue } = this.state;
5854

5955
if (editMode) {
6056
return (
61-
<Input
57+
<input
6258
id={id}
6359
ref={this.inputRef}
64-
className={`input px-0 py-0`}
65-
autoFocus
66-
onChange={(e) => {
60+
className={className}
61+
style={style}
62+
onInput={(e) => {
6763
this.setState({
6864
inputValue: (e.target as HTMLInputElement).value
6965
});
7066
}}
71-
onConfirm={this.onConfirm}
72-
onCancel={this.onCancel}
73-
onBlur={this.onBlur}
74-
onKeyDown={(e) => {
75-
// Don't forward key events to parent elements, as they might trigger unwanted actions
76-
// (e.g. space triggering play/pause).
77-
e.stopPropagation();
78-
}}
79-
placeholder="Add a title..."
67+
onFocus={this.handleFocus}
68+
onKeyDown={this.handleInputKeyDown}
69+
onBlur={this.handleBlur}
8070
value={inputValue}
8171
/>
8272
);
@@ -85,40 +75,65 @@ export class ArrangementTitle extends UIComponent<IArrangementTitleProperties, I
8575
return (
8676
<Label
8777
id={id}
78+
className={className}
79+
style={style}
8880
{...this.dataAttributes}
8981
>
9082
{title}
9183
</Label>
9284
);
9385
}
9486

95-
private onBlur = (event: FocusEvent) => {
96-
const { editMode, onEditEnd, undoManager, arrangement } = this.props;
97-
98-
if (editMode) {
99-
undoManager.edit({
100-
type: "EditCommand_ArrangementTitle", arrangement,
101-
newTitle: (event.target as HTMLInputElement).value
102-
});
103-
onEditEnd();
104-
}
87+
private handleFocus = (): void => {
88+
this.inputRef.current?.select();
10589
};
10690

107-
private onConfirm = (event: KeyboardEvent) => {
108-
const { onEditEnd, undoManager, arrangement } = this.props;
91+
private handleBlur = (event: FocusEvent) => {
92+
const { undoManager, arrangement } = this.props;
93+
const { title } = this.state;
94+
const newTitle = (event.target as HTMLInputElement).value;
95+
96+
if (newTitle === arrangement.title) {
97+
if (title !== arrangement.title) {
98+
this.setState({ title: arrangement.title, inputValue: arrangement.title });
99+
}
100+
101+
return;
102+
}
109103

110104
undoManager.edit({
111105
type: "EditCommand_ArrangementTitle", arrangement,
112-
newTitle: (event.target as HTMLInputElement).value
106+
newTitle
113107
});
114-
onEditEnd();
115108
};
116109

117-
private onCancel = (event: KeyboardEvent) => {
118-
const { onEditEnd, arrangement } = this.props;
110+
private handleInputKeyDown = (e: KeyboardEvent) => {
111+
const { arrangement } = this.props;
112+
113+
switch (e.key) {
114+
case KeyboardKeys.Enter: {
115+
this.inputRef.current?.blur();
116+
117+
break;
118+
}
119119

120-
this.setState({ inputValue: arrangement.title });
121-
onEditEnd();
120+
case KeyboardKeys.Escape: {
121+
const input = this.inputRef.current;
122+
if (input) {
123+
input.value = arrangement.title;
124+
}
125+
126+
this.setState({ inputValue: arrangement.title }, () => {
127+
this.inputRef.current?.blur();
128+
});
129+
130+
break;
131+
}
132+
133+
default: {
134+
break;
135+
}
136+
}
122137
};
123138

124139
}

src/core/Arrangement.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,11 @@ export class Arrangement implements ISbDmArrangement {
168168
// same TPs. However, applying the full snapshot is required for Undo/Redo.
169169
this.applyTimeParams(arrangementSnapshot);
170170
this.title = arrangementSnapshot.title ?? "Untitled Arrangement";
171+
172+
if (arrangementSnapshot.scoreId !== undefined) {
173+
this.id = arrangementSnapshot.scoreId;
174+
}
175+
171176
this.measureLabels = arrangementSnapshot.measureLabels
172177
? { ...arrangementSnapshot.measureLabels }
173178
: {};

src/core/ScoreBookDataModel.ts

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -656,6 +656,9 @@ export class ScoreBookDataModel {
656656
return this.currentCapabilities;
657657
}
658658

659+
/** Token for the active score lock. Set by the lockScore flow. */
660+
public lockToken?: string;
661+
659662
private accessToken: string | undefined;
660663
private currentUser: IUserInfo | undefined;
661664
private currentGroup: { id: number; name: string; } | undefined;
@@ -739,6 +742,10 @@ export class ScoreBookDataModel {
739742
void this.rewriteMigratedScore(source, arrangement);
740743
}
741744

745+
if (this.isScoreEntry(source)) {
746+
arrangement.id = source.id;
747+
}
748+
742749
this.data.arrangement = arrangement;
743750
this.applyArrangementPlaybackSettings(arrangement);
744751
void requisitions.execute("scoreBookLoaded", ScoreBookChangeReason.ScoreLoaded);
@@ -859,6 +866,10 @@ export class ScoreBookDataModel {
859866

860867
const { success, id } = await res.json() as { success: boolean; id: number; };
861868
if (success) {
869+
if (this.data.arrangement) {
870+
(this.data.arrangement as Arrangement).id = id;
871+
}
872+
862873
const newScore: ISbDmScore = {
863874
type: SbDmEntityType.Score,
864875
id,
@@ -892,16 +903,28 @@ export class ScoreBookDataModel {
892903
* @param content The new content string to persist.
893904
*/
894905
public async updateScoreContent(score: ISbDmScore, content: string): Promise<void> {
906+
const body: Record<string, unknown> = { id: score.id, content };
907+
908+
if (this.lockToken) {
909+
body.token = this.lockToken;
910+
}
911+
895912
const res = await this.fetchApi(`/api?action=updateScore`, {
896913
method: "POST",
897914
headers: { "Content-Type": "application/json" },
898-
body: JSON.stringify({ id: score.id, content }),
915+
body: JSON.stringify(body),
899916
});
900917

901918
if (!res) {
902919
return;
903920
}
904921

922+
if (res.status === 409) {
923+
const data = await res.json() as { error?: string; };
924+
925+
throw new Error(data.error ?? "Score is locked by another user.");
926+
}
927+
905928
score.content = content;
906929
}
907930

@@ -1583,6 +1606,54 @@ export class ScoreBookDataModel {
15831606
void requisitions.execute("scoreBookLoaded", ScoreBookChangeReason.LibraryRefreshed);
15841607
}
15851608

1609+
/**
1610+
* Acquires an edit lock for a score. Returns the lock token on success, or conflict info
1611+
* if another user holds the lock.
1612+
*
1613+
* @param scoreId The ID of the score to lock.
1614+
* @param prevToken An optional previous token to attempt renewal of an expired lock.
1615+
*
1616+
* @returns Lock result with token or conflict details.
1617+
*/
1618+
public async lockScore(scoreId: number, prevToken?: string): Promise<{
1619+
success: boolean; token?: string; locked?: boolean; username?: string; lockedAt?: string;
1620+
}> {
1621+
const body: Record<string, unknown> = { scoreId };
1622+
1623+
if (prevToken) {
1624+
body.prevToken = prevToken;
1625+
}
1626+
1627+
const res = await this.fetchApi("/api?action=lockScore", {
1628+
method: "POST",
1629+
headers: { "Content-Type": "application/json" },
1630+
body: JSON.stringify(body),
1631+
}, true, true);
1632+
1633+
if (!res) {
1634+
return { success: false };
1635+
}
1636+
1637+
return res.json() as unknown as {
1638+
success: boolean; token?: string; locked?: boolean; username?: string;
1639+
lockedAt?: string;
1640+
};
1641+
}
1642+
1643+
/**
1644+
* Releases an edit lock for a score.
1645+
*
1646+
* @param scoreId The ID of the score to unlock.
1647+
* @param token The lock token to verify ownership.
1648+
*/
1649+
public async unlockScore(scoreId: number, token: string): Promise<void> {
1650+
await this.fetchApi("/api?action=unlockScore", {
1651+
method: "POST",
1652+
headers: { "Content-Type": "application/json" },
1653+
body: JSON.stringify({ scoreId, token }),
1654+
});
1655+
}
1656+
15861657
private async rewriteMigratedScore(score: ISbDmScore, arrangement: Arrangement): Promise<void> {
15871658
if (!this.canWriteScores) {
15881659
return;

src/core/UndoRedoStack.ts

Lines changed: 2 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,9 @@
55

66
import { requisitions } from "../supplement/Requisitions.js";
77
import { getArrangementSnapshot } from "./serialisation/snapshots.js";
8-
import type { EditCommand, EditCommand_ArrangementTitle, EditCommand_Note } from "./types/edit_commands.js";
8+
import type { EditCommand, EditCommand_Note } from "./types/edit_commands.js";
99
import type { IArrangementSnapshot, IAudioData } from "./types/general.js";
1010
import type { ISbDmArrangement } from "./ScoreBookDataModel.js";
11-
import { exists } from "./utils.js";
1211

1312
export interface IHistoryState {
1413
arrangementSnapshot: IArrangementSnapshot;
@@ -22,7 +21,6 @@ export interface IHistoryState {
2221
*
2322
* - Stores a timeline of arrangement snapshots as `past` and `future`.
2423
* - Publishes when `canUndo`/`canRedo` changes for UI controls.
25-
* - Ignores `EditCommand_ArrangementTitle` changes (title is always read live).
2624
* - Squashes rapid note-style cycling via a deferred timeout to keep history clean.
2725
*/
2826
export class UndoRedoStack {
@@ -70,10 +68,7 @@ export class UndoRedoStack {
7068
* @returns The current `IArrangementSnapshot` including the live title.
7169
*/
7270
public get currentState(): IArrangementSnapshot {
73-
return {
74-
...this.past[this.past.length - 1].arrangementSnapshot,
75-
title: this.arrangementView.title // Title is ignored in undo/redo, so we just pull the current title
76-
};
71+
return this.past[this.past.length - 1].arrangementSnapshot;
7772
}
7873

7974
/**
@@ -85,10 +80,6 @@ export class UndoRedoStack {
8580
* @param oldValue Optional note-style being replaced, used by squash heuristics.
8681
*/
8782
public handleEdit(command: EditCommand, oldValue?: IAudioData): void {
88-
if (exists((command as EditCommand_ArrangementTitle).newTitle)) {
89-
return; // Title is ignored in undo/redo, so we don't react to it changing at all
90-
}
91-
9283
this.past.push(this.getNewHistoryState(this.arrangementView, command, oldValue));
9384

9485
if (this.future.length) {

src/core/serialisation/snapshots.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,10 @@ export const getArrangementSnapshot = (arrangementView: Readonly<ISbDmArrangemen
2626
tracks: arrangementView.tracks.map(getTrackSnapshot),
2727
};
2828

29+
if (arrangementView.id >= 10000) {
30+
snapshot.scoreId = arrangementView.id;
31+
}
32+
2933
if (Object.keys(arrangementView.measureLabels).length > 0) {
3034
snapshot.measureLabels = { ...arrangementView.measureLabels };
3135
}

src/core/types/general.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,9 @@ export interface IArrangementSnapshot {
5555
timeParams: ITimeParamsBase;
5656
tracks: ITrackSnapshot[];
5757

58+
/** The database score ID, if this arrangement is backed by a DB score. */
59+
scoreId?: number;
60+
5861
/** Optional per-measure section labels, keyed by 1-based measure number. */
5962
measureLabels?: Record<number, string>;
6063
}

0 commit comments

Comments
 (0)