Repository navigation
PT-4960: Let an empty paragraph after a chapter be deleted - #39
Conversation
With structure unprotected, a paragraph delete merges the paragraph into the one before it. Right after a `\c` there is nothing to merge into, so the delete was refused, and an empty line there could never be removed. An empty paragraph right after a chapter is now removed by the usual two presses when a paragraph follows it in the chapter, and the caret goes to that paragraph. One alone in its chapter, or one with text, is still refused. Delete in any empty paragraph now removes that paragraph rather than pulling the next paragraph's text in under the empty one's marker. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review of the empty-paragraph delete found two inconsistencies. Delete at the end of a paragraph with an empty paragraph after it moved the caret down past the removed paragraph. The arm picked the empty paragraph for a merge, but the fire removed it and placed the caret after it. The direction is now settled once at the arm, from where the caret was (`$paraDeleteIntent`), and the fire reuses it, so the caret stays at the end of its own paragraph. A selected marker's Delete now lands the caret in the same place under "guarded" and "off". Backspace at the start of a paragraph after an empty one still merged its text in under the empty paragraph's marker, the trap Delete no longer falls into. It now removes the empty paragraph instead, keeping this paragraph's marker and the caret, under "guarded" and "off". A selected marker still merges its paragraph up. Also: update the docs and comments that described merges only, make `$canRemoveEmptyParaAfterChapter` module-private, share the test helpers, read the caret's focus, and cover two empty paragraphs after a chapter, undo, and Delete before a chapter. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
tjcouch-sil
left a comment
There was a problem hiding this comment.
Thanks, Jolie! I traced the arm and fire steps through guarded, off and protected modes, and they agree on direction in every case I tried. I'd like two changes before this merges (marked [Required] inline), and a few [Strongly suggested] ones. Two of those aren't tied to a line:
[Strongly suggested] CHANGELOG. packages/platform/CHANGELOG.md has no entry for this, and Backspace/Delete behave differently in the paragraph-structure view now. Could you add a short line under Unreleased → Fixed? For example: an empty paragraph right after a chapter can now be deleted, and deleting an empty paragraph next to another paragraph removes the empty one instead of moving the other paragraph's text under its marker.
[Strongly suggested] Full-editor tests. Everything here is tested in shared-react with hand-built trees. packages/platform/src/editor/gutterParaMarkers.test.tsx already mounts the real <Editor> the way Simple mode runs it (paragraph-structure view, guarded), presses keys through the DOM, and can check getUsj() and undo(). Could you add these cases there, or in a file next to it? Each one should check the saved USJ, and that a single Undo restores the original:
- Backspace twice in an empty
\sright after\c 1, with a\pafter it:\sis gone and\pis unchanged. - Delete twice in an empty
\sbetween a\pand a\q1:\sis gone, and\q1keeps its marker and verse. - Backspace twice at the start of that
\q1: same result as 2. - An empty
\sthat is the only paragraph after\c 1: Backspace is refused and the USJ doesn't change. - Once the arm fix below is in, one Backspace at the start of
\q1after an empty\s, then a typing key: the caret is still in\q1, not in\s.
I ran 1–3 against this branch in that harness and they pass, so this should be quick to add. Please leave out an Undo check for Backspace inside an empty \s in the middle of a chapter. That goes through the existing merge path, whose Undo has a known issue that's being fixed separately, so the test would fail for reasons unrelated to this PR.
- Arm a Backspace on an empty paragraph above the caret without selecting its marker, so a key typed after the arm stays in the caret's paragraph - Share the "empty with a paragraph after it" rule between arming and deleting - Test the after-chapter cases with ImmutableChapterNode, as the paragraph-structure view renders - Add full-editor tests for the empty-paragraph deletes and their undo - Update the Standard view merge test: an empty paragraph before is now removed, not merged into - Correct comments and add a CHANGELOG entry Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Both non-inline items are in:
One behavior change to flag: the "empty paragraph before" rule applies in Standard view too, so two Backspaces at the start of |
Per product input, deleting the marker of an empty paragraph that is the only one in its chapter is now allowed: it leaves a blank line with no marker, with the caret in it, where the user picks a marker. A `\` menu paragraph pick at the start of a line with no marker now gives it that marker instead of doing nothing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…para-after-chapter # Conflicts: # packages/platform/CHANGELOG.md # packages/platform/dist/index.js # packages/platform/dist/index.js.map
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The previous dist rebuild committed the plain build's re-export stub and a stray Editorial.d.ts instead of the extract-api rollup. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
tjcouch-sil
left a comment
There was a problem hiding this comment.
One required follow-up on the new blank-line behaviour; details inline.
…nchanged A chapter with no line after it now loads with the same empty implied paragraph that deleting its only paragraph leaves, in views where markers are not editable text, so a click below the chapter number no longer puts the caret before the chapter. It saves as nothing, so the file is unchanged. The empty-paragraph removal and blanking no longer apply in Standard view, where the marker is editable text. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
tjcouch-sil
left a comment
There was a problem hiding this comment.
Approving, thanks! I re-tested the reload case in Chromium and it behaves well now. Merge when you're ready.
…para-after-chapter # Conflicts: # packages/platform/dist/index.js # packages/platform/dist/index.js.map
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brings in PT-4753 (#38, caret host recognized by its mark), PT-4974 (#42, generateUsjFontCss) and PT-4960 (#39, empty paragraphs around a chapter). Resolution: - node.utils.ts, editor-usj.adaptor.ts: keep this branch's lone-NBSP rule and main's mark-based caret host check. - structureKeyboard.utils.ts: main's delete-target code, not this branch's chapter target. The refusal that replaces it, and the removal of the rest of the chapter plumbing, follow in their own commit. - Test helpers: keep this branch's editorView registry, drop main's exported requireViewOptions, and port authored-zwsp-roundtrip.test.tsx to it. - Committed dist: this branch's side; rebuilt in its own commit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Code Review Summary
Branch: pt-4960-delete-empty-para-after-chapter
Base: origin/main
Date: 2026-10-29
Review model: Claude Opus 5.5
Files changed: 8
Overview
With structure unprotected, an empty paragraph right after
\ccould never be deleted. Deleting a paragraph merges it into the one before, and after a chapter there is nothing to merge into. This branch lets such a paragraph be removed when another paragraph follows it in the chapter. In"guarded"the delete takes two presses: the first arms it and shows theremoveEmptyParahint, the second removes it. In"off"it takes one. The caret moves to the paragraph that follows. An empty paragraph that is alone in its chapter (the next chapter or the end of the book after it) leaves a blank line with no marker, per Ian's answer on PT-4960. Protected structure is unchanged.The branch also stops both delete keys from pulling text under an empty paragraph's marker. Delete inside an empty paragraph, and (added during this review) Backspace at the start of the paragraph after one, now remove the empty paragraph. The paragraph with text keeps its own marker. Before, verse text could silently become heading text.
During the review, the arm and fire steps of a paragraph delete were made to agree on direction.
$paraDeleteIntentdecides, at arm time, from where the caret was, whether the second press removes forward (the caret moves to the next paragraph) or joins backward (the caret stays put). This fixed a caret jump when Delete at the end of a paragraph removed an empty paragraph below it.API Changes
The published
platform-editorAPI is unchanged (noetc/*.api.mdchanges). The changes below are in the internal workspace librarylibs/shared-react, re-exported throughsrc/plugins/usj/index.ts:$canDeletePara(para: SomeParaNode): boolean$deletePara(para: SomeParaNode, intent?: DeleteIntent): void$getEmptyParaBefore(para: SomeParaNode): SomeParaNode | undefined(added during review)$paraDeleteIntent(selection: BaseSelection, para: SomeParaNode, intent: DeleteIntent): DeleteIntent(added during review)ArmedDeletegains an optionalparaIntent?: DeleteIntentfield (added during review)$canRemoveEmptyParaAfterChapterwas exported on the branch and made module-private during review$canMergeIntoPreviousand$mergeParaIntoPreviousare still exported, but the plugins now call them through$deleteParaParaDeleteEffectis unchanged."removeEmptyPara"already existed, and paranext-core already localizes it (en and es), so paranext-core needs no code change.Findings
Critical — Must address before merge
None.
Important — Should address before merge
structureKeyboard.utils.ts$deletePara,StructureKeyboardPlugin.tsxfire path). The arm step targeted the next, empty paragraph for a merge. The fire step then took the new remove branch and placed the caret at the start of the paragraph after it, contradicting the "a Delete arm keeps the caret" design. (fixed during review: the new$paraDeleteIntentsettles the direction at arm time and stores it onArmedDelete.paraIntent, which the fire step passes to$deletePara. Regression tests added: the caret stays atfirst:5, and a selected-marker Delete gives the same result in"guarded"and"off".)\skept the following\p's marker. Backspace at the start of that\pstill merged the verse text under\s. (fixed during review: Backspace at a paragraph's start now targets an empty paragraph right before it ($getEmptyParaBefore) and removes it. The text paragraph keeps its marker and the caret. The rule is applied in"guarded"($structuralDeleteTarget) and in"off"(ParaMarkerSelectionPlugin's caret path). A selected marker deliberately still merges up. Tests added for both modes, with and without a gutter marker.)\c. (fixed during review: added a test showing that one backward delete from a caret in the empty\sremoves it and lands the caret in the\p.)$structuralDeleteTarget,$paraDeleteEffect,$isUnmergeableParaStart, the$mergeParaIntoPreviousdispatch note, theStructureKeyboardPluginplugin TSDoc and its arm, fire and refusal comments, andParaMarkerSelectionPlugin's$deferToTwoStepDeleteand class TSDoc. (fixed during review: all of them reworded. The$isUnmergeableParaStartrename was deliberately deferred; its doc now explains that it means "no paragraph delete may act here".)The author agreed with each finding and asked for the recommended fix.
Minor — Consider
"guarded"(next paragraph) and"off"(previous paragraph). (fixed during review: resolved by the$paraDeleteIntentchange above, with a test covering both modes)"off", a forward Delete from a caret in an empty paragraph is still native, so the next paragraph's text comes up under the empty paragraph's marker. Only the selected-marker and Backspace paths changed in"off". (fixed during review: PR description corrected)$canRemoveEmptyParaAfterChapterwas exported but used only inside its file. (fixed during review: made module-private)expectCaretInreadselection.anchor; CLAUDE.md says to readfocus. (fixed during review: it readsfocusnow)blocksOfinline and had no caret assertion. (fixed during review:blocksOfandexpectCaretInhoisted to file scope and reused; caret assertions added)structureKeyboard.utils.test.ts). (fixed during review: renamed to "deleteForward in an empty para with a next para → the empty para itself", and a sibling test added for the new Backspace target)\c(removed one at a time, caret moving down), Undo restoring a removal in one step, Delete in an empty paragraph right before a\c(left alone), and caret checks in the after-chapterParaMarkerSelectionPlugintests)packages/platform/distwasn't verified byte-for-byte. Permissions blocked the analysis pass from running the rebuild and verify scripts, and the source changed during the review anyway. (fixed during review: rebuilt withscripts/rebuild-committed-output.mjsafter the source commit;scripts/verify-committed-dist.mjsreports "Committed build output is current")\c, protected mode). The silent refusal predates this PR. A refusal hint would need a newdata-verse-delete-kindvalue and new localized strings in paranext-core, so it's recorded as a follow-up rather than done here.The author asked to fix all the minor findings. All were addressed except the refusal-feedback follow-up, which needs a paranext-core change.
Template Propagation
Shared Regions Modified
None. This repo has no templates to propagate to.
Extension Config Changes
None. paranext-core already maps the reused
removeEmptyParakind.Positive Observations
$canDeleteParapredicate and one$deleteParaoperation, so arming, refusal andParaMarkerSelectionPlugin's defer decision stay in agreement."guarded", and"off"stays one press, so each mode is consistent with itself.$function documents its calling context, as CLAUDE.md requires. There are no issue-tracker IDs in code or comments.$createParaNodeis hoisted with children chained inside$getRoot().append(...), and checks read editor-state JSON rather than the DOM. They cover the negative cases as well (next chapter, end of book, protected mode).removeEmptyParakind avoids any localization or paranext-core change.Interview Notes
\cbe deleted, prompted by an empty GNTD-based book (1 Samuel 1) and a Discord report. Taken from the PR description; the author passed the PR URL as the review argument.actreturned because jsdom's DOM selection re-synced Lexical's selection. Tracing showed the handler leaves the caret in the right place, so the test now captures the caret from the delete's own committed update, as other tests in that file do.$isUnmergeableParaStartrename.$paraDeleteIntent→ArmedDelete.paraIntent→$deletePara).In-Review Quality Check
pnpm nx run-many -t typecheck lint test -p shared-react platform: all targets pass. Lint reports 4 warnings, all in files this PR doesn't touch. Tests: 2317 passed, 1 skipped in the run shown, and all 2180 usj tests pass.distdeclaration files while the build was rewriting them, a build-order race unrelated to these changes. The re-run passed.packages/utilities/dist, a straypackages/platform/dist/Editorial.d.ts) was restored or removed and isn't committed.Suggested Review Focus
$paraDeleteIntent(structureKeyboard.utils.ts): check its rules (forward only when the caret already sits where removal would leave it; a selected marker is always backward) against every arm path: Backspace and Delete, with and without a gutter marker, in"guarded"and"off".\c, now removes the empty one instead of merging into it. Confirm this is wanted, and add it to the questions for Ian next to "Decisions" item 4."off"mode: Backspace at the start of a paragraph after an empty one. The caret's final position was verified only at the commit of the edit, because jsdom re-syncs the selection afterwards.\sand press Backspace twice. The\sshould go and the caret should land in the\p.data-verse-delete-kindvalue and localized string in paranext-core).PT-4960: Let an empty paragraph after a chapter be deleted
Problem
With structure unprotected (Simple mode,
"guarded"), deleting a paragraph merges it into the one before it. Right after a\cthere is nothing to merge into, so the delete was refused. That also meant an empty line right after\ccould never be removed. Roopa hit this with the empty\sin an empty book based on the GNTD model (1 Samuel 1), and Sebastian hit it with an empty row after\con Discord.What changed
\ccan now be deleted with the usual two presses, when another paragraph follows it in the chapter. The first press highlights it and the second removes it. The caret goes to the start of the next paragraph. This works by Backspace or Delete from the caret, or with its marker selected.\sheading. The paragraph with text keeps its marker, and the caret stays where it was. A selected marker still merges its paragraph up, as before."off") gets the same removal in one press, by Backspace from the caret or with the marker selected. It had the same dead end. A forward Delete from the caret is still native in"off", so there, Delete in an empty paragraph still pulls the next paragraph's text up under its marker."off"). The user gives it a marker with the\menu or the paragraph-style dropdown (formatPara). Backspace in the blank line does nothing.\menu paragraph pick at the start of a line with no marker now gives that line the marker and keeps its text. Before, the pick split the line into another marker-less line and gave up, so nothing happened.\c(or in the\idline for chapter 1). The blank line saves as nothing, so the file doesn't change.The first press reports
data-verse-delete-kind="removeEmptyPara", which paranext-core already maps to its localized tooltip ("Press {key} again to remove the empty paragraph"). No paranext-core code change is needed, only the usual dist update.Product decisions
Ian answered Q1–Q3 on PT-4960. Items 1–3 follow his answers. Items 4 and 5 weren't asked and remain our call.
Allow the two-press delete of an empty paragraph after
\c(Q1). Ian: yes. This follows NN 8 (paragraph-level markers can be added, deleted or changed easily when permitted), and it keeps the deliberate-delete safeguard from nice-to-have 7.$canRemoveEmptyParaAfterChapter's use in$canDeletePara(structureKeyboard.utils.ts).Deleting the only paragraph in a chapter leaves a blank line with no marker (Q2). Ian: "If it is possible deleting the final marker should be possible, just leaving a blank area where a user should insert a marker." The editor can't leave nothing at all, because in Simple mode the caret can't sit on a chapter number, so the user would have nowhere to add a marker. The paragraph is replaced by an empty
ImpliedParaNode($canBlankEmptyParaAloneInChapterand$deleteParainstructureKeyboard.utils.ts), and the\menu now retags such a line (markerMenuApply.utils.ts). The first press reuses the existingremoveEmptyParahint.insertBlankLinesAfterBareChaptersinusj-editor.adaptor.ts), so a session and a reload look the same. Delta positions agree too: the blank line counts as oneLFin both, and the host only applies delta ops to the editor's own live state, never to file offsets. Annotation positions are USJ paths, where an empty implied paragraph has no items, so they don't shift.A paragraph with text right after
\cis still refused (Q3). Ian: yes, keep blocking until the paragraph is empty. A future floating toolbar may offer to change the marker instead.Delete in an empty paragraph, and Backspace at the start of the paragraph after one, remove the empty paragraph, everywhere, not only after
\c(not asked). This was a related trap in the same scenario, and it changes existing behavior for empty paragraphs between two paragraphs. Backspace was added in review so the two keys give the same result at the same boundary.\ccase: in$structuralDeleteTarget, drop the backward branch's$getEmptyParaBeforetarget, and in the forward branch also require the empty paragraph to follow a chapter. InParaMarkerSelectionPlugin's off-mode caret path, drop the$getEmptyParaBeforebranch.Power mode (
"off") also removes the empty paragraph (not asked). It happens in one press, as other merges do there.ParaMarkerSelectionPlugin's$removeMarkerand off-mode gutter path call$mergeParaIntoPreviousagain.Testing
StructureKeyboardPlugin.test.tsxcover:\c, Undo, and Delete before a\cParaMarkerSelectionPlugin.test.tsxcover:"guarded"(two presses) and"off"(one press)"off"backward delete from the caret, both after\cand after an empty paragraphstructureKeyboard.utils.test.tscover the target and refusal rules.gutterParaMarkers.test.tsxcover a bare chapter loading with a blank line that saves as nothing, its delta matching the delta after deleting the chapter's only paragraph, and an annotation in the next chapter landing on the right text.usj-editor-adaptor.test.tschecks that the blank line is added in paragraph-structure view and left out in Standard view.guardedMergeTyping.test.tsxis back tomain's expectation (Standard view unchanged).nx run-many -t test typecheck lint -p shared-react platformpasses, andverify-committed-dist.mjspasses.Manual check in PT10: in an empty GNTD-based book, go to 1 Samuel 1, put the caret in the empty
\sand press Backspace twice. The\sgoes, and the caret lands in the\pbelow. In a chapter whose only paragraph is an empty\s, press Backspace twice. A blank line with no marker is left. Type\there and pick\p; the line becomes a\p. Undo the pick, save, and reopen the chapter: the blank line is still there, and clicking below the chapter number puts the caret in it. Also, in Power mode, press Backspace at the start of a paragraph that follows an empty one. The empty paragraph goes, and the caret stays at the start of the text.🤖 Generated with Claude Code
This change is