Skip to content

Upgrade question plugin: extract module, add resize, verse context popover, and bug fixes#128

Open
hendriebeats wants to merge 9 commits into
masterfrom
upgraded-question-plugin
Open

Upgrade question plugin: extract module, add resize, verse context popover, and bug fixes#128
hendriebeats wants to merge 9 commits into
masterfrom
upgraded-question-plugin

Conversation

@hendriebeats

Copy link
Copy Markdown
Contributor

Extracts the question feature out of the monolithic Editor.tsx into a dedicated QuestionPlugin.ts, and ships several fixes and improvements made during that work.

What changed

Module extraction

  • All question-related code (~700 lines) moved from Editor.tsx into frontend/src/Editor/QuestionPlugin.ts and re-exported; Editor.tsx now imports from it.

Resizable popup

  • The question popup is now resizable from all four edges and corners, not just the bottom-right triangle. Invisible 6px hit zones handle each edge/corner, with a visual border highlight on hover to show which edge is active.

Verse context popover

  • Clicking the verse reference label (e.g. "3:16") on a question now shows a small popover with the surrounding passage — one verse before and after the selection — with the highlighted text marked in yellow. Clicking outside dismisses it.

Bug fixes

  • Stale getPos on undo: QuestionView now refreshes questionMap[id].getPos on every remount (e.g. after undo), so dispatchInner always calls the live position callback.
  • Popup positioning: switched from position: absolute (page-relative) to position: fixed (viewport-relative), and from document.body.scrollHeight to window.innerHeight, since only #mainEditorHolder scrolls — not the page itself.
  • Resize event listener leak: window.removeEventListener("resize", clampToViewport) is now called when the popup closes.
  • Null step mapping: dispatchInner now skips steps where step.map(offsetMap) returns null, preventing a potential crash.
  • getPos undefined guard: dispatchInner early-returns if qNode.getPos() is undefined (can happen transiently during undo).
  • findDiffEnd null guard: QuestionView.update now checks for a null return from findDiffEnd before destructuring.
  • Min-width consistency: CSS min-width raised from 200px to 250px to match the 250px floor enforced in the JS resize handler.
  • Mod-Shift-Z redo: added to the inner editor's keymap (was missing; Mod-Y was the only redo binding).
  • Focus on close: view.focus() is called when the popup closes, returning keyboard focus to the outer editor.

Accessibility / polish

  • Question mark widget changed from <div> to <button> with aria-label; keyboard-activatable via Enter/Space.
  • Popup close button changed from <closer> custom element to <button> with aria-label.
  • noQuestionsText built with DOM API instead of innerHTML to avoid XSS via window.base.
  • Verse reference label gets cursor: default; user-select: none so it doesn't look like editable text.
  • Dead commented-out code removed throughout.

hendriebeats and others added 9 commits February 21, 2026 23:31
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Editor.tsx had grown to ~1700 lines with a large portion dedicated
entirely to the question feature. Following the existing pattern of
OtherCursorPlugin.ts and QuestionHighlightPlugin.ts, extract all
question-specific code into its own module.

Moved to QuestionPlugin.ts:
- QuestionMapItem interface and Dictionary type
- newQuestionNode / newQuestionAnswerNode factory functions
- QuestionsView, QuestionView, QuestionAnswerView node views
- questionReferenceMarkView and referenceToMarkView mark views
- zIndices/zIndexCounter state and questionPopup function
- questionMarkPlugin factory (with questionMarkWidget helper)
- addQuestion, deleteQuestionSelection, deleteAnswerSelection,
  removeQuestion commands

Editor.tsx retains all wiring: plugin registration, nodeViews/markViews
entries, questionMap property, and addQuestionCommand method.
- Clicking the verse reference label on a question node now shows a
  popover with surrounding passage context: one verse before and after
  the highlighted selection, with the referenced text highlighted in
  yellow and dimmed neighboring verses for orientation.
- Switch question popup to `position: fixed` so it stays in the
  viewport rather than drifting with the scrolling editor container.
- Clamp popup within viewport on open and on window resize, shrinking
  width/height if the viewport is smaller than the popup.
- Constrain drag/resize to use `window.innerHeight` instead of
  `document.body.scrollHeight` so bounds are always viewport-relative.
- Increase minimum resize width from 200 px to 250 px.
- Extract `closePopup` helper to de-duplicate destroy/remove logic and
  ensure the resize listener is always cleaned up on close.
- Add `cursor: default; user-select: none` to verse reference label and
  `position: relative` to `.popup` content area (needed for absolute
  popover anchoring).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Convert close button and question-mark widget from custom/div elements
  to <button> so they have an implicit ARIA role and are keyboard-reachable
  (Enter/Space); add aria-label to both and aria-hidden to decorative icons.
- Add CSS reset (border/padding: 0/none) to .closer and .questionMark so
  the new <button> elements look identical to before.
- Fix QuestionView: assign this.questionMap unconditionally before the
  registration guard, so update() can sync the popup editor after undo/redo.
- Add contenteditable="false" to noQuestionsText so ProseMirror does not
  try to manage it as document content.
- Replace two innerHTML+window.base template literals with safe DOM API
  calls (createElement + .src) to eliminate latent XSS surface.
- Remove duplicate undo/redo import, dead referenceToMarkView code (empty
  handlers, commented-out bodies, unused qselector variable), and
  self-evident inline comments.
- Remove commented-out CSS rules and a duplicate margin-right declaration
  in .questionMark; trim excess blank lines in base.css.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Bug 1 — clampToViewport NaN: the drag handler writes pop.style.left as
"calc(924px)", so parseFloat(pop.style.left) returns NaN and the clamp
is silently skipped. Switch to reading position from getBoundingClientRect()
which always gives the real viewport-relative coordinates regardless of
how the style was written.

Bug 2 — Position NaN out of range on undo: ProseMirror can destroy and
remount a NodeView on undo. When QuestionView is remounted it found an
existing questionMap entry and skipped registration entirely, leaving a
stale getPos closure in the map. dispatchInner then called the old getPos
(now invalid), got undefined, and passed StepMap.offset(NaN) to the
outer transaction, triggering ProseMirror's range error. Fix: always
refresh node and getPos in the map entry on remount.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Guard dispatchInner: skip outer dispatch if getPos() returns undefined
  (node no longer in document) and skip null-mapped steps, preventing
  the RangeError: Position NaN out of range that fired on every keystroke
  and undo/redo cycle in the popup editor.
- Guard update(): null-check findDiffEnd result before destructuring to
  avoid undefined endA/endB being passed into tr.replace().
- Add "Mod-Shift-z": redo to the popup editor keymap so Cmd+Shift+Z
  works for redo alongside the existing Cmd+Y binding.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
After closePopup() removed the DOM element, the browser moved focus to
document.body, making Cmd+Z unreachable on the outer editor. In practice
this meant deleting a question via the trash button was not undoable.

Add view.focus() at the end of closePopup() so focus always returns to
the outer ProseMirror editor, regardless of which close path is taken
(trash button, × button, Escape, or touch).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@hendriebeats hendriebeats added Size: Large 401–1,000 lines: Higher risk; usually should be split unless it’s a mechanical refactor. Siee: XL / Too large 1,000+ lines: Hard to review; often requires staged PRs or special review strategy. and removed Size: Large 401–1,000 lines: Higher risk; usually should be split unless it’s a mechanical refactor. labels Feb 24, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Siee: XL / Too large 1,000+ lines: Hard to review; often requires staged PRs or special review strategy.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant