Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
5cfdd9e
Add an undo history that replays re-anchoring on restore (#184)
alex-rawlings-yyc Sep 30, 2026
5e64b26
Record draft edits as undo steps; re-anchor in the loader (#184)
alex-rawlings-yyc Oct 2, 2026
849c9a2
Undo and redo draft edits from the keys, toolbar, and Edit menu (#184)
alex-rawlings-yyc Sep 30, 2026
2a85686
Take the reader to an undone or redone edit (#184)
alex-rawlings-yyc Sep 30, 2026
3cd786a
Make wiping undoable; drop a phrase edit whose phrase an undo removes
alex-rawlings-yyc Sep 30, 2026
7437a01
Announce undone and redone catalog edits and wipes (#184)
alex-rawlings-yyc Sep 30, 2026
2b0e53b
Delete catalog analyses at once, with Undo in the notification (#184)
alex-rawlings-yyc Oct 2, 2026
51fb65d
Keep the undo notification up longer; align Undo/Redo left of view
alex-rawlings-yyc Sep 30, 2026
79630e9
Dismiss undo notifications that arrive late or out of order (#184)
alex-rawlings-yyc Sep 30, 2026
ba385e8
Keep a clean draft clean through re-anchoring (#184)
alex-rawlings-yyc Sep 30, 2026
238b310
Close undo review gaps: per-book replay, keys, focus, catalog (#184)
alex-rawlings-yyc Oct 2, 2026
d83413f
Reveal the surviving catalog row on redoing a collapse (#184)
alex-rawlings-yyc Sep 30, 2026
fade820
Close undo review gaps: native redo, reveal lifetime, source switch
alex-rawlings-yyc Oct 1, 2026
5a6c62a
Name the book, not its code, when announcing an undone wipe (#184)
alex-rawlings-yyc Oct 2, 2026
8f51b50
Announce undos and redos in the order they were made (#184)
alex-rawlings-yyc Oct 2, 2026
1209f75
Name a wiped book by its platform name, falling back to English (#184)
alex-rawlings-yyc Oct 2, 2026
6c36063
Close undo review gaps: stalled book name, no-op edits (#184)
alex-rawlings-yyc Oct 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ This is a **Platform.Bible extension** for interlinear Bible text alignment. Pla

[src/main.ts](src/main.ts) — called by Platform.Bible on activation. Exports two lifecycle functions:

- `activate(context)` — stores the `ExecutionToken`, registers the `interlinearizer.mainWebView` WebView provider, command handlers, the `interlinearizer.continuousScroll` project settings validator, and the `onDidOpenWebView` / `onDidCloseWebView` subscriptions. All registrations are added to `context.registrations` so the platform disposes them on deactivation.
- `activate(context)` — stores the `ExecutionToken`, registers the `interlinearizer.mainWebView` WebView provider, command handlers, the `interlinearizer.onUndoFromNotification` network event emitter, the `interlinearizer.continuousScroll` project settings validator, and the `onDidOpenWebView` / `onDidCloseWebView` subscriptions. All registrations are added to `context.registrations` so the platform disposes them on deactivation.
- `deactivate()` — clears `openWebViewsByProject` and returns `true`.

`openWebViewsByProject` (`Map<string, string>`) tracks one open WebView ID per project to prevent duplicates; reopening an already-open project brings that tab to front via the `existingId` option.
Expand Down
5 changes: 5 additions & 0 deletions __mocks__/papi-backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ const mockDeleteUserData = jest.fn();
const mockNotificationsSend = jest.fn();
const mockProjectDataProvidersGet = jest.fn();
const mockGetLocalizedString = jest.fn();
const mockCreateNetworkEventEmitterAsync = jest.fn();
const mockLogger = {
debug: jest.fn(),
error: jest.fn(),
Expand All @@ -43,6 +44,9 @@ const papi = {
localization: {
getLocalizedString: mockGetLocalizedString,
},
network: {
createNetworkEventEmitterAsync: mockCreateNetworkEventEmitterAsync,
},
storage: {
readUserData: mockReadUserData,
writeUserData: mockWriteUserData,
Expand Down Expand Up @@ -79,6 +83,7 @@ const defaultExport = {
__mockNotificationsSend: mockNotificationsSend,
__mockProjectDataProvidersGet: mockProjectDataProvidersGet,
__mockGetLocalizedString: mockGetLocalizedString,
__mockCreateNetworkEventEmitterAsync: mockCreateNetworkEventEmitterAsync,
__mockLogger: mockLogger,
};

Expand Down
10 changes: 10 additions & 0 deletions __mocks__/papi-frontend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ const mockLogger = {

const mockSendCommand = jest.fn();
const mockNotificationsSend = jest.fn();
const mockNotificationsDismiss = jest.fn();
const mockGetLocalizedString = jest.fn();
const mockGetNetworkEvent = jest.fn();
const mockProjectDataProvidersGet = jest.fn();
const mockNetworkObjectsGet = jest.fn();
const mockWaitForNetworkObject = jest.fn();
Expand All @@ -22,6 +25,13 @@ const papi = {
},
notifications: {
send: mockNotificationsSend,
dismiss: mockNotificationsDismiss,
},
localization: {
getLocalizedString: mockGetLocalizedString,
},
network: {
getNetworkEvent: mockGetNetworkEvent,
},
menuData: {
dataProviderName: 'platform.menuDataServiceDataProvider',
Expand Down
75 changes: 75 additions & 0 deletions __mocks__/platform-bible-react.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,24 @@ export const MOCK_WIPE_MENU_ITEM: MenuItemContainingCommand = {
localizeNotes: '',
};

/** Sentinel menu item passed by the mock toolbar when the undo button is clicked. */
export const MOCK_UNDO_MENU_ITEM: MenuItemContainingCommand = {
label: '%interlinearizer_undo%',
command: 'interlinearizer.undo',
group: 'interlinearizer.editActions',
order: 1,
localizeNotes: '',
};

/** Sentinel menu item passed by the mock toolbar when the redo button is clicked. */
export const MOCK_REDO_MENU_ITEM: MenuItemContainingCommand = {
label: '%interlinearizer_redo%',
command: 'interlinearizer.redo',
group: 'interlinearizer.editActions',
order: 2,
localizeNotes: '',
};

/** Sentinel menu item passed by the mock toolbar when the analysis-catalog button is clicked. */
export const MOCK_OPEN_ANALYSIS_CATALOG_MENU_ITEM: MenuItemContainingCommand = {
label: '%interlinearizer_openAnalysisCatalog%',
Expand Down Expand Up @@ -247,6 +265,24 @@ export function TabToolbar({
Wipe
</button>
)}
{onSelectProjectMenuItem && (
<button
type="button"
data-testid="tab-toolbar-undo"
onClick={() => onSelectProjectMenuItem(MOCK_UNDO_MENU_ITEM)}
>
Undo
</button>
)}
{onSelectProjectMenuItem && (
<button
type="button"
data-testid="tab-toolbar-redo"
onClick={() => onSelectProjectMenuItem(MOCK_REDO_MENU_ITEM)}
>
Redo
</button>
)}
{onSelectProjectMenuItem && (
<button
type="button"
Expand Down Expand Up @@ -485,6 +521,7 @@ export const Input = forwardRef<
onKeyDown?: KeyboardEventHandler<HTMLInputElement>;
'aria-label'?: string;
'data-testid'?: string;
'data-draft-field'?: string;
}>
>(function InputImpl(
{
Expand All @@ -500,12 +537,14 @@ export const Input = forwardRef<
onKeyDown,
'aria-label': ariaLabel,
'data-testid': testId,
'data-draft-field': draftField,
},
ref,
) {
return (
<input
ref={ref}
data-draft-field={draftField}
id={id}
type={type ?? 'text'}
value={value}
Expand Down Expand Up @@ -694,6 +733,42 @@ export function BookChapterControl({
);
}

/** Stub event hook; a test reads the handler it was given off its calls to fire an event. */
export const useEvent = jest.fn();

/** Localization keys of the undo and redo button tooltips, as the real component exports them. */
export const UNDO_REDO_BUTTONS_STRING_KEYS = Object.freeze([
'%undoButton_tooltip%',
'%redoButton_tooltip%',
] as const);

/** Stub undo and redo buttons, each labeled by its tooltip key and disabled as the props say. */
export function UndoRedoButtons({
onUndoClick,
onRedoClick,
canUndo = true,
canRedo = true,
}: Readonly<{
onUndoClick: () => void;
onRedoClick?: () => void;
canUndo?: boolean;
canRedo?: boolean;
localizedStrings?: Record<string, string | undefined>;
}>): ReactElement {
return (
<>
<button type="button" aria-label="%undoButton_tooltip%" disabled={!canUndo} onClick={onUndoClick}>
Undo
</button>
{onRedoClick && (
<button type="button" aria-label="%redoButton_tooltip%" disabled={!canRedo} onClick={onRedoClick}>
Redo
</button>
)}
</>
);
}

/**
* Stub toggle switch rendered as a native checkbox so tests can read and change the checked state
* without the real Radix UI implementation.
Expand Down
31 changes: 21 additions & 10 deletions contributions/localizedStrings.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,20 @@
"%interlinearizer_openLexiconChooser%": "Connect a Lexicon…",
"%interlinearizer_error_openLexiconChooser_failed%": "The lexicon chooser could not be opened.",

"%interlinearizer_menu_column_edit%": "Edit",
"%interlinearizer_undo%": "Undo",
"%interlinearizer_redo%": "Redo",
"%interlinearizer_undone_catalogEdit%": "Undid an edit to the analysis of {form}.",
"%interlinearizer_redone_catalogEdit%": "Redid an edit to the analysis of {form}.",
"%interlinearizer_undone_catalogMerge%": "Undid a merge into the analysis of {form}.",
"%interlinearizer_redone_catalogMerge%": "Redid a merge into the analysis of {form}.",
"%interlinearizer_undone_catalogDelete%": "Undid deleting the analysis of {form} ({count} uses).",
"%interlinearizer_redone_catalogDelete%": "Redid deleting the analysis of {form} ({count} uses).",
"%interlinearizer_undone_wipeBook%": "Undid wiping {book}.",
"%interlinearizer_redone_wipeBook%": "Redid wiping {book}.",
"%interlinearizer_undone_wipeAll%": "Undid wiping the whole draft.",
"%interlinearizer_redone_wipeAll%": "Redid wiping the whole draft.",

"%interlinearizer_menu_column_view%": "View",
"%interlinearizer_openAnalysisCatalog%": "Analysis Catalog",
"%interlinearizer_openConcordance%": "Concordance",
Expand Down Expand Up @@ -113,16 +127,13 @@
"%interlinearizer_analysisCatalog_mergedDismiss%": "Dismiss",
"%interlinearizer_analysisCatalog_draftStranded%": "An edit elsewhere removed the analysis of {form}, discarding the breakdown you were typing for it.",
"%interlinearizer_analysisCatalog_delete%": "Delete",
"%interlinearizer_analysisCatalog_deleteTitle%": "Delete the analysis of {form}?",
"%interlinearizer_analysisCatalog_deleteBlank%": "{count} uses will be left with no analysis.",
"%interlinearizer_analysisCatalog_deleteBlankNone%": "No token is analyzed as this, so nothing on screen changes.",
"%interlinearizer_analysisCatalog_deleteFallback%": "{count} uses will fall back to {gloss}.",
"%interlinearizer_analysisCatalog_deleteFallbackNoGloss%": "{count} uses will fall back to another analysis of the same form.",
"%interlinearizer_analysisCatalog_deleteFallbackUncertain%": "{count} uses will each fall back to another analysis or be left with none, depending on the use.",
"%interlinearizer_analysisCatalog_deleteUnapplied%": "{count} tokens also record this analysis without using it; those records are deleted too.",
"%interlinearizer_analysisCatalog_deleteUndoWarning%": "This cannot be undone.",
"%interlinearizer_analysisCatalog_deleteCancel%": "Cancel",
"%interlinearizer_analysisCatalog_deleteConfirm%": "Delete",
"%interlinearizer_analysisCatalog_deleted%": "Deleted the analysis of {form}.",
"%interlinearizer_analysisCatalog_deleteBlank%": "{count} uses are left with no analysis.",
"%interlinearizer_analysisCatalog_deleteBlankNone%": "No token was analyzed as this, so nothing on screen changed.",
"%interlinearizer_analysisCatalog_deleteFallback%": "{count} uses fall back to {gloss}.",
"%interlinearizer_analysisCatalog_deleteFallbackNoGloss%": "{count} uses fall back to another analysis of the same form.",
"%interlinearizer_analysisCatalog_deleteFallbackUncertain%": "{count} uses each fall back to another analysis or are left with none, depending on the use.",
"%interlinearizer_analysisCatalog_deleteUnapplied%": "{count} tokens also recorded this analysis without using it; those records were deleted too.",
"%interlinearizer_analysisCatalog_closeConfirmTitle%": "Discard the unsaved breakdown?",
"%interlinearizer_analysisCatalog_closeConfirmPrompt%": "You have typed a morpheme breakdown but not saved it. Closing the catalog discards it.",
"%interlinearizer_analysisCatalog_closeConfirmCancel%": "Keep editing",
Expand Down
25 changes: 24 additions & 1 deletion contributions/menus.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,15 @@
"localizeNotes": "Interlinearizer top menu column for project actions",
"order": 1
},
"interlinearizer.edit": {
"label": "%interlinearizer_menu_column_edit%",
"localizeNotes": "Interlinearizer top menu column for undoing and redoing edits",
"order": 2
},
"interlinearizer.view": {
"label": "%interlinearizer_menu_column_view%",
"localizeNotes": "Interlinearizer top menu column for opening views of the analysis",
"order": 2
"order": 3
}
},
"groups": {
Expand All @@ -45,6 +50,10 @@
"column": "interlinearizer.project",
"order": 4
},
"interlinearizer.editActions": {
"column": "interlinearizer.edit",
"order": 1
},
"interlinearizer.viewActions": {
"column": "interlinearizer.view",
"order": 1
Expand Down Expand Up @@ -100,6 +109,20 @@
"order": 1,
"command": "interlinearizer.openLexiconChooser"
},
{
"label": "%interlinearizer_undo%",
"localizeNotes": "Interlinearizer top menu > Edit > Undo the latest edit to the draft",
"group": "interlinearizer.editActions",
"order": 1,
"command": "interlinearizer.undo"
},
{
"label": "%interlinearizer_redo%",
"localizeNotes": "Interlinearizer top menu > Edit > Redo the most recently undone edit",
"group": "interlinearizer.editActions",
"order": 2,
"command": "interlinearizer.redo"
},
{
"label": "%interlinearizer_openAnalysisCatalog%",
"localizeNotes": "Interlinearizer top menu > Open the analysis catalog, listing every analysis recorded in the draft with its usage counts and locations",
Expand Down
Loading
Loading